41

I have an array and I am printing it like this:

echo "${data[*]}"

Output:

/QE-CI-RUN-71/workspace/QE-AU/57/testng-results_1.xml 
/QE-CI-RUN-71/workspace/QE-AU/57/testng-results_2.xml

I want to store the above output as a comma separated value. How can I achieve this in Bash?

The data array is dynamic, it may have any number of values.

codeforester
  • 39,467
  • 16
  • 112
  • 140
ArrchanaMohan
  • 2,314
  • 4
  • 36
  • 84
  • 2
    `echo "${data[*]}"` shouldn't be generating newlines unless they're there inside the values themselves. Could you [edit] to include the output of `declare -p data`, so we can see what the actual form of the array is? (If instead of being an array it were actually just one big string with literal newlines in the first place, that would explain your current output). – Charles Duffy Dec 18 '18 at 18:53
  • I've added the `bash` tag because POSIX shells do not allow arrays. If you meant a different shell, please correct this. – Adam Katz Dec 18 '18 at 18:58
  • zsh one-line solution: `echo "${${data[@]}//${IFS:0:1}/,}"` – Adam Katz Dec 18 '18 at 19:22
  • Does this answer your question? [How can I join elements of an array in Bash?](https://stackoverflow.com/questions/1527049/how-can-i-join-elements-of-an-array-in-bash) – Adam Katz Mar 03 '21 at 19:54

7 Answers7

62

There are a few ways to do this:

1. Join directly with printf (via Charles Duffy’s comment)

printf -v joined '%s,' "${data[@]}"
echo "${joined%,}"

The printf builtin implicitly joins arrays. You could print interactively like 3a below with a one-liner reading printf '%s,' "${data[@]}", but you'd be left with a trailing comma. (This method even works in POSIX shell, though you'd have to use $@ as your array since POSIX can't handle other array types).

2. Change the $IFS field separator (via chepner’s answer)

join_arr() {
  local IFS="$1"
  shift
  echo "$*"
}

join_arr , "${data[@]}"

This redefines the field separator within just the scope of this function so when the $data array is automatically expanded, it uses the desired delimiter instead of the first value of the global $IFS or (if it's empty or undefined) space.

This could be done without a function, but there's some nastiness about preserving $IFS: Charles Duffy notes that reverting IFS="$OLD_IFS" after temporarily reassigning it could evaluate to IFS="", but if $IFS was previously undefined, that's different from unset IFS and while it's possible to tease those apart, this functional approach is far cleaner thanks to its use of local to limit $IFS’s scope.

This solution only supports single-character delimiters. See #5 below for a similar function that supports delimiters of any length.

3a. Loop through its contents (and print incrementally)

delim=""
for item in "${data[@]}"; do
  printf "%s" "$delim$item"
  delim=","
done
echo # add a newline

If other code in that loop involves an external call (or even sleep 0.1), you'll actually watch this build piece by piece, which can be helpful in an interactive setting.

3b. Loop through its contents (and build a variable)

delim=""
joined=""
for item in "${data[@]}"; do
  joined="$joined$delim$item"
  delim=","
done
echo "$joined"

4. Save the array as a string and run replacement on it (note, the array must lack spaces*)

data_string="${data[*]}"
echo "${data_string//${IFS:0:1}/,}"

* This will only work if the first character of $IFS (space by default) does not exist in any of the array's items.

This uses bash pattern substitution: ${parameter//pattern/string} will replace each instance of pattern in $parameter with string. In this case, string is ${IFS:0:1}, the substring of $IFS starting at the beginning and ending after one character.

Z Shell (zsh) can do this in one nested parameter expansion:

echo "${${data[@]}//${IFS:0:1}/,}"

(Though Z Shell can also do it more elegantly with its dedicated join flag as echo "${(j:,:)data}" as noted by @DavidBaynard in a comment below this answer.)

5. Join with replacement in an implicit loop (via Nicholas Sushkin's answer to a duplicate question)

join_by() {
  local d="${1-}" f="${2-}"
  if shift 2; then
    printf %s "$f" "${@/#/$d}"
  fi
}

join_by , "${data[@]}"

This is very similar to #2 above (via chepner), but it uses pattern substitution rather than $IFS and therefore supports multi-character delimiters. $d saves the delimiter and $f saves the first item in the array (I'll say why in a moment). The real magic is ${@/#/$d}, which replaces the beginning (#) of each array element with the delimiter ($d). As you don't want to start with a delimiter, this uses shift to get past not only the delimiter argument but also the first array element (saved as $f), which is then printed right in front of the replacement.

printf has an odd behavior when you give it extra arguments as we do here. The template (%s) only specifies that there will be one argument, so the rest of the arguments act as if it's a loop and they're all concatenated onto each other. Consider changing that key line to printf "%s\n" "$f" "${@/#/$d}". You'll end up with a newline after each element. If you want a trailing newline after printing the joined array, do it with printf %s "$f" "${@/#/$d}" $'\n' (we need to use the $'…' notation to tell bash to interpret the escape; another way to do this would be to insert a literal newline, but then the code looks weird).

Adam Katz
  • 14,455
  • 5
  • 68
  • 83
  • 3
    The "string-then-replacement" approach will change other spaces as well. Consider `data=( "first item" "second item" "third item" )`; you want output of `first item,second item,third item`, not `first,item,second,item,third,item`. – Charles Duffy Dec 18 '18 at 19:00
  • true. we're flying blind without better examples of what the data look like. I was working on solving that disclaimer, but bash isn't terribly smart about combining variable replacements or involving implicit variables like `$IFS`, so I decided not to. I've since added a for loop answer too. – Adam Katz Dec 18 '18 at 19:04
  • 3
    In practice, btw, I would typically use `printf -v var '%s,' "${data[@]}"; echo "${var%,}"` -- doesn't change `IFS`, and doesn't make assumptions about what the data looks like. – Charles Duffy Dec 18 '18 at 19:13
  • As a POSIX shell programmer, I often miss bashisms like the sprintf-like `printf -v var`. Still, my loop outputs data as it is seen, which allows an interactive session to be a little more responsive given larger inputs on a loaded system (and doesn't need to undo the trailing delimiter). I'm not sure how much cost is associated with the frivolous assignments but I assume it's negligible (especially in this case since we're limited by command parameter length, `getconf ARG_MAX`). – Adam Katz Dec 18 '18 at 19:43
  • 3
    Curly brackets alone do *not* limit the scope of the change. That's the main difference between a command group and a subshell; the command group still executes in the current shell. – chepner Dec 18 '18 at 20:05
  • In the `for` loop, `delim` has an undefined value during the first iteration. Perhaps you want to initialize it to be empty? – Charles Duffy Dec 18 '18 at 20:11
  • @CharlesDuffy – oh, so thorough . Yes, it'd be safer to initialize it. Updated. – Adam Katz Dec 18 '18 at 20:18
  • I'm doing this on one line with a simple pipe `echo ${data[@]} | tr \ ,` – Brent K. Nov 09 '20 at 15:31
  • @BrentK. – Assuming there are no spaces in your `$data` array (see my final method), that works too, but it costs you an external call. As a one-off, it doesn't matter. Nested in a loop, that'll add up. All of the other methods noted in my answer and its comments so far have used [builtins](https://pubs.opengroup.org/onlinepubs/9699919799/utilities/V3_chap02.html#tag_18_14) (commands built into the shell). – Adam Katz Nov 09 '20 at 15:43
  • 1
    @AdamKatz For zsh you can use "${(j:,:)data}" for the same thing. There is a subtlety around word splitting: > Note that this occurs before field splitting by the s:string: flag or the SH_WORD_SPLIT option. (Source: https://zsh.sourceforge.io/Doc/Release/Expansion.html#Parameter-Expansion) – David Baynard Aug 14 '21 at 17:36
  • 1
    @DavidBaynard – Thanks, I've added that to the answer. It doesn't get its own section since this is a Bash question, but I think it merits a mention. – Adam Katz Aug 16 '21 at 01:33
12

To make it easier to localize the change of IFS, use a function:

join () {
  local IFS="$1"
  shift
  echo "$*"
}

join , "${data[@]}"
chepner
  • 497,756
  • 71
  • 530
  • 681
  • I like this. It solves the issue of overwriting `$IFS` and uses a syntax users of other languages will be quite familiar with. – Adam Katz Dec 18 '18 at 19:15
  • 1
    There's a shorter way to localize the value, `(IFS=,; echo "${data[*]}")`, but at the cost of (almost certainly) forking a new process for the subshell. – chepner Dec 18 '18 at 19:37
  • 1
    Curly brackets also don't localize the value of `IFS`. You have to use a subshell to do that. The benefit of the function is that you can use the `local` command to avoid overwriting the global value. – chepner Dec 18 '18 at 20:04
6

If you want to separate with commas, make that be the first character in IFS:

data=( first second third )
IFS=,
echo "${data[*]}"

...emits:

first,second,third

To avoid leaving IFS in a modified state, you can embed this code in a function and declare IFS as local. If you have bash 4.3 or later, namevar support can be used to parameterize the variable output is saved to without the overhead of a subshell:

comma_sep() {
  local -n comma_sep__dest=$1; shift || return
  local IFS=,
  comma_sep__dest=$*
}

comma_sep result "${data[@]}"
echo "$result" # prints first,second,third
Charles Duffy
  • 280,126
  • 43
  • 390
  • 441
  • 2
    Be careful about leaving `$IFS` defined in this way; you may not like what it does to commands later in your script. – Adam Katz Dec 18 '18 at 20:21
  • 1
    Problem is that `oIFS=$IFS; ...; IFS=$oIFS` isn't a noop either -- it'll change `unset IFS` to `IFS=''`, which are two different states with different behaviors (the former acts like `IFS=$' \t\n'`). The `local` approach, or scoping with a subshell, is appropriate if one wants to be safe -- and chepner's answer already covers them. I'd rather not write code that pretends to have safety features that don't really exist, and also consider any code that performs unquoted expansion without an explicit IFS value (or which fails to set `IFS` explicitly when running `read`) inherently broken. – Charles Duffy Dec 18 '18 at 20:28
6

For ksh, try this!

foo=`echo $(echo ${data[@]}) | tr ' ' ','`

In this way you can control the delimiter by translating the space (default) to comma! (or any other you can think of) :)

Dan Dimacuha
  • 95
  • 1
  • 5
  • 1
    Nice! Why the extra echo? This could be simplified to just: ``` foo=`echo ${data[@]} | tr ' ' ','` ``` – neowulf33 Jan 12 '23 at 18:51
4

One-liner with IFS changed in a sub-command (safe):

echo "$(IFS=,; echo "${data[*]}")"

Note: As IFS only takes first character, result would be comma separated list of values without space.

$ data=(file1 file2 file3)
$ (IFS=,; echo "${data[*]}")
file1,file2,file3

$ printf "%q" "$IFS"
$' \t\n'
LuckyDams
  • 109
  • 5
1
printComma(){
    printf "%s," "${@:1:${#}-1}"
    printf "%s" "${@:${#}}"
}
printNewline(){
    printf "%s\n" "${@:1:${#}-1}"
    echo "${@:${#}}"
}
join () {
  local IFS="$1"
  shift
  echo "$*"
}
declare -a comma=(
    a
    b
    c
)
declare -a newline=(
    xyz
    abc
    def
    ghi
)
echo "\
Comma separated list $(printComma "${comma[@]}")
Newline list: $(printNewline "${newline[@]}")

Comma separated list $(join , "${comma[@]}")
Newline list: $(join \n "${newline[@]}")"
Comma separated list a,b,c
Newline list: xyz
abc
def
ghi

Comma separated list a,b,c
Newline list: xyznabcndefnghi
Nico
  • 872
  • 10
  • 16
0

This approach won't win any awards, but it suits my more complex requirements, which were to change an array with (foo bar baz) into comma-seperated strings with spaces, surrounded by escaped double quotes \"foo\", \"bar\", \"baz\", which IFS is not well suited for.

data=(foo bar baz)
data_str="${data[*]}"
separated_data_str=\"${data_str// /\", \"}\"
MST
  • 649
  • 6
  • 4