Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

Bash & the POSIX Shell  Module 8 – Arrays and lists

Lists done right: arrays, mapfile and read -a

Keep Bash commands and lists in arrays instead of strings: build argument lists, load lines with mapfile, split one line with read -a and join arrays safely.

  • Intermediate
  • 15 minutes
  • Examples run with Bash 5.3.20, dash 0.5.13.5, shellcheck 0.11.0 and Python 3.14.8
  • By MySmartCoPilot
Free lesson, Pro answers: 15 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

What you will learn

  • Store a command and its arguments in an array, and explain why a string loses their quoting
  • Build an argument list from the options that apply, and print it one argument per line before it runs
  • Load the lines of a file or of a command's output into an array with mapfile
  • Split one line into fields with read -a, and join an array with IFS or printf
  • Choose the array forms that work on the oldest Bash a script must support, macOS's 3.2 included

Before you start

What you’ll learn · Before you start

What you will learn

  • Store a command and its arguments in an array, and explain why a string loses their quoting
  • Build an argument list from the options that apply, and print it one argument per line before it runs
  • Load the lines of a file or of a command's output into an array with mapfile
  • Split one line into fields with read -a, and join an array with IFS or printf
  • Choose the array forms that work on the oldest Bash a script must support, macOS's 3.2 included

Before you start

On this page

cmd="grep -i -e 'disk full' app.log" holds 30 characters of a sound search, yet running it as $cmd makes grep look for 'disk, apostrophe included, in two files, one of which does not exist, and the search ends with status 2. Kept in a Bash array, the same command finds both lines of the log that mention a full disk.

Lists of arguments, lines and fields break the same way whenever they go through a string. You will store commands as arrays, build argument lists from the options that apply and print them before they run, load a file or a command’s output with mapfile, split one line with read -a, join an array for printing, and tell which of these forms the Bash 3.2 of a Mac lacks.

Quotes kept in a string are just characters, so a command needs an array

The session keeps a search of app.log, a four-line backup log, in a variable and runs it three ways: as $cmd, as "$cmd" and from an array. Watch the lines that printf '[%s]\n' prints: each holds one argument between brackets.

A grep command kept in a string, then in an array, and what ShellCheck says Python · string_session.py
from session import run, write

write("app.log", "09:00 INFO backup started\n09:02 ERROR disk full on /data\n09:03 WARN Disk Full soon on /logs\n09:05 INFO backup done\n")
write("search-string.sh", "#!/usr/bin/env bash\n# search-string.sh: a grep command kept in a string\ncmd=\"grep -i -e 'disk full' app.log\"\n$cmd\n")

run(r"""
cat app.log
cmd="grep -i -e 'disk full' app.log"; echo "${#cmd} characters"
!$cmd
printf '[%s]\n' $cmd
!"$cmd"
cmd=(grep -i -e 'disk full' -- app.log)
printf '[%s]\n' "${cmd[@]}"
"${cmd[@]}"
declare -p cmd
cat search-string.sh
!shellcheck search-string.sh
""")

Output

$ cat app.log
09:00 INFO backup started
09:02 ERROR disk full on /data
09:03 WARN Disk Full soon on /logs
09:05 INFO backup done
$ cmd="grep -i -e 'disk full' app.log"; echo "${#cmd} characters"
30 characters
$ $cmd
grep: full': No such file or directory
[exit status 2]
$ printf '[%s]\n' $cmd
[grep]
[-i]
[-e]
['disk]
[full']
[app.log]
$ "$cmd"
bash: grep -i -e 'disk full' app.log: command not found
[exit status 127]
$ cmd=(grep -i -e 'disk full' -- app.log)
$ printf '[%s]\n' "${cmd[@]}"
[grep]
[-i]
[-e]
[disk full]
[--]
[app.log]
$ "${cmd[@]}"
09:02 ERROR disk full on /data
09:03 WARN Disk Full soon on /logs
$ declare -p cmd
declare -a cmd=([0]="grep" [1]="-i" [2]="-e" [3]="disk full" [4]="--" [5]="app.log")
$ cat search-string.sh
#!/usr/bin/env bash
# search-string.sh: a grep command kept in a string
cmd="grep -i -e 'disk full' app.log"
$cmd
$ shellcheck search-string.sh

In search-string.sh line 3:
cmd="grep -i -e 'disk full' app.log"
     ^----------------------------^ SC2089 (warning): Quotes/backslashes will be treated literally. Use an array.


In search-string.sh line 4:
$cmd
^--^ SC2090 (warning): Quotes/backslashes in this variable will not be respected.

For more information:
  https://www.shellcheck.net/wiki/SC2089 -- Quotes/backslashes will be treate...
  https://www.shellcheck.net/wiki/SC2090 -- Quotes/backslashes in this variab...
[exit status 1]

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 string_session.py

Unquoted, $cmd was word splitting: The step in which Bash cuts the unquoted results of parameter expansion, command substitution and arithmetic expansion into separate words at the characters of IFS. More in “Word splitting and IFS: why "$var" needs quotes” at its spaces into six words, and two of them still held the single quotes: 'disk and full'. grep took 'disk as its pattern, and full' and app.log as two files to search. It printed only “No such file or directory” for full' and ended with status 2. Quoted, "$cmd" stayed one word, so Bash looked for a command named after the whole value and reported “command not found”, status 127.

The cause is the order in which Bash reads a line. It finds quotes, operators and the boundaries of words while it reads what you typed, and only then expands variables. A value that comes out of a variable is split and globbed when it is unquoted, but it is never read again for quotes: the manual’s The last step of expansion: Bash deletes the quote characters and backslashes that were typed in the command line, once they have marked where each word begins and ends. deletes only the quote characters that “did not result from” an expansion. Inside a value, quotes are data.

An array keeps the boundaries instead of the text. In cmd=(grep -i -e 'disk full' -- app.log) the quotes are typed in the assignment, so they are quoting: declare -p cmd showed six elements, the fourth disk full with no quote characters left. "${cmd[@]}" then hands each element on as exactly one word, the rule the word splitting and IFS lesson showed for "$@". Such a command array: An indexed array that holds a command name and its arguments, one per element, run as "${cmd[@]}" so that each element reaches the command as exactly one word, spaces and quote characters included. is the way to keep a command whose arguments may hold spaces. The -- ends grep’s options, so a pattern or file name that starts with a dash would still be read as an operand, as Guideline 10 of the POSIX utility syntax guidelines asks.

Step through the same command kept both ways, one box per word that grep receives:

Interactive · step by step The words a string and an array hand to grep Step through the same grep command kept two ways. Each box is one word grep receives; its sub-label says what grep did with it.
The words a string and an array hand to grep, step 6 of 6. Showing From the string, From the array, grep, -i, -e, 'disk (the pattern), full' (a file: missing), app.log (a file: no match), grep, -i, -e, disk full (the pattern), -- (end of options), app.log (a file: 2 lines). In focus: From the array.From the stringunquoted $cmdFrom the array"${cmd[@]}"grep-i-e'diskthe patternfull'a file: missingapp.loga file: no matchgrep-i-edisk fullthe pattern--end of optionsapp.loga file: 2 lines

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. One command, two ways. Both columns start from the same search: grep, -i, -e, the pattern disk full in single quotes, and app.log. The left column will show what cmd="grep -i -e 'disk full' app.log" becomes when it runs as $cmd, the right one the elements of an array holding the same command.
  2. The string is split at spaces. Unquoted, $cmd is split at every space into six words. The single quotes were typed inside the double quotes of the assignment, so they are characters of the value, and nothing removes them: they end up inside the fourth and the fifth word.
  3. grep gets a wrong pattern. grep takes 'disk, apostrophe included, as its pattern and treats full' and app.log as two files to search. full' does not exist and no line of app.log holds 'disk, so grep prints only an error and ends with status 2.
  4. The array keeps disk full whole. In cmd=(grep -i -e 'disk full' -- app.log) the quotes are typed in the assignment itself, so they are quoting: Bash keeps disk full as one element and then removes the quote characters. The array has six elements, and the fifth is --.
  5. One word per element. "${cmd[@]}" turns each element into exactly one word. grep gets disk full as its pattern, -- tells it that no option follows, and app.log is its only file. It printed two lines: the one with disk full and the one with Disk Full.
  6. Quotes in a value are data. Quotes are syntax only where you type them, in a command line or an assignment. Quotes that come out of a variable stay characters, which is what ShellCheck's SC2089 and SC2090 warn about. Keep the right column.

Key idea

Quotes work only where they are typed. Keep a command and its arguments as array elements and run it as "${cmd[@]}", never from a string.

The left column of the figure is what ShellCheck warns about. In search-string.sh it marked the assignment with SC2089, “Quotes/backslashes will be treated literally. Use an array.”, and the line that runs $cmd with SC2090.

More quotes do not help

Wrong: escaping the quotes inside the string, as in cmd="grep -e \"disk full\" app.log", which still hands grep "disk and full", quote characters included. Right: cmd=(grep -e 'disk full' -- app.log), run as "${cmd[@]}".

Checkpoint

Check: Commands kept in arrays, not strings

  1. Question 1 of 2 After args="-n 'two words'", what does printf '[%s]\n' $args print?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: [-n], ['two] and [words'], one per line

    Bash removes only the quotes that are written in the command line. The session's printf '[%s]\n' $cmd showed the same thing with ['disk] and [full'].

  2. Question 2 of 2 Why does cmd=(grep -e 'disk full' app.log) keep disk full as one element?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: The quotes are typed in the assignment, so Bash treats them as quoting and then removes them

    Quotes typed in a command line or an assignment are syntax: they decide where a word ends, and quote removal deletes them afterwards. Quotes inside a variable's value are only characters.

An argument list grows by the options that apply, and a dry run shows it

A search that should ignore case only when ignore_case=1, and number its lines only when line_numbers=1, needs a list of options that grows by the ones that apply. Start from an empty array and append with +=( ), as in opts=(); [[ $ignore_case == 1 ]] && opts+=(-i). The session builds the list twice, tries the same with a string, and ends by printing the whole command before running it:

Options added to an array, the same with a string, and a dry run Python · options_session.py
from session import run, write

write("app.log", "09:00 INFO backup started\n09:02 ERROR disk full on /data\n09:03 WARN Disk Full soon on /logs\n09:05 INFO backup done\n")

run(r"""
ignore_case=1; line_numbers=0
opts=(); [[ $ignore_case == 1 ]] && opts+=(-i); [[ $line_numbers == 1 ]] && opts+=(-n); declare -p opts
grep "${opts[@]}" -e 'disk full' -- app.log
ignore_case=0; opts=(); [[ $ignore_case == 1 ]] && opts+=(-i); [[ $line_numbers == 1 ]] && opts+=(-n); declare -p opts
grep "${opts[@]}" -e 'disk full' -- app.log
flags=''; echo "flags=[$flags]"
!grep "$flags" -e 'disk full' -- app.log
flags='-i -n'
!grep "$flags" -e 'disk full' -- app.log
grep $flags -e 'disk full' -- app.log
opts=(-i); opts+=-n; declare -p opts
opts=(-i); opts+=(-n); declare -p opts
cmd=(grep "${opts[@]}" -e 'disk full' -- app.log); dry_run=1
if [[ $dry_run == 1 ]]; then printf '[%s]\n' "${cmd[@]}"; else "${cmd[@]}"; fi
dry_run=0; if [[ $dry_run == 1 ]]; then printf '[%s]\n' "${cmd[@]}"; else "${cmd[@]}"; fi
""")

Output

$ ignore_case=1; line_numbers=0
$ opts=(); [[ $ignore_case == 1 ]] && opts+=(-i); [[ $line_numbers == 1 ]] && opts+=(-n); declare -p opts
declare -a opts=([0]="-i")
$ grep "${opts[@]}" -e 'disk full' -- app.log
09:02 ERROR disk full on /data
09:03 WARN Disk Full soon on /logs
$ ignore_case=0; opts=(); [[ $ignore_case == 1 ]] && opts+=(-i); [[ $line_numbers == 1 ]] && opts+=(-n); declare -p opts
declare -a opts=()
$ grep "${opts[@]}" -e 'disk full' -- app.log
09:02 ERROR disk full on /data
$ flags=''; echo "flags=[$flags]"
flags=[]
$ grep "$flags" -e 'disk full' -- app.log
grep: : No such file or directory
app.log:09:02 ERROR disk full on /data
[exit status 2]
$ flags='-i -n'
$ grep "$flags" -e 'disk full' -- app.log
grep: invalid option --
usage: grep [-abcdDEFGHhIiJLlMmnOopqRSsUVvwXxZz] [-A num] [-B num] [-C[num]]
	[-e pattern] [-f file] [--binary-files=value] [--color=when]
	[--context[=num]] [--directories=action] [--label] [--line-buffered]
	[--null] [pattern] [file ...]
[exit status 2]
$ grep $flags -e 'disk full' -- app.log
2:09:02 ERROR disk full on /data
3:09:03 WARN Disk Full soon on /logs
$ opts=(-i); opts+=-n; declare -p opts
declare -a opts=([0]="-i-n")
$ opts=(-i); opts+=(-n); declare -p opts
declare -a opts=([0]="-i" [1]="-n")
$ cmd=(grep "${opts[@]}" -e 'disk full' -- app.log); dry_run=1
$ if [[ $dry_run == 1 ]]; then printf '[%s]\n' "${cmd[@]}"; else "${cmd[@]}"; fi
[grep]
[-i]
[-n]
[-e]
[disk full]
[--]
[app.log]
$ dry_run=0; if [[ $dry_run == 1 ]]; then printf '[%s]\n' "${cmd[@]}"; else "${cmd[@]}"; fi
2:09:02 ERROR disk full on /data
3:09:03 WARN Disk Full soon on /logs

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 options_session.py

With ignore_case=1 the list was -i and grep found both lines. With both switches off, declare -p showed opts=(), "${opts[@]}" added no word at all, and grep found the one line in lower case. The table sets every form of the session side by side:

Form What grep receives In the session
"${opts[@]}" with opts=() no word at all grep ran and found its line
"$flags" with flags='' one empty word “No such file or directory”, status 2
"$flags" with flags='-i -n' one word, -i -n “invalid option”, status 2
$flags with flags='-i -n' -i and -n, split and globbed worked, by luck
opts+=-n after opts=(-i) one element, -i-n opts+=(-n) gave two elements

The empty word is the subtle row. A quoted expansion is always exactly one word, even when its value is empty, and grep took that empty word for the name of a file: it printed an error for it and put app.log: before the line it found, because it now had two files to search. The string of two options failed the other way: as one word, -i -n made grep read -i and then a space as the next option letter. An empty array has no elements, so its quoted expansion is no word at all, which is exactly what an option that does not apply should be.

+= needs its parentheses for the same reason. The manual says that += with a compound assignment appends new values to the array, while on a plain value it appends to the text, and opts without a subscript means its first element: opts+=-n glued -n onto -i.

The last two lines are a dry run. cmd=(grep "${opts[@]}" -e 'disk full' -- app.log) puts the options into the command, and with dry_run=1 the if printed it with printf '[%s]\n' "${cmd[@]}" instead of running it. printf reuses its format for every argument, so each argument appeared on its own line between brackets, and the space inside [disk full] could not be taken for the end of an argument. With dry_run=0, the same array ran.

Build once, then print or run

Build the array once and decide only at the end whether to print it or run it. The dry run then shows exactly what runs, because both come from the same elements.

Checkpoint

Check: Argument lists built from options

  1. Question 1 of 2 With flags='', what does grep "$flags" -e 'disk full' -- app.log hand to grep before -e?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: An empty argument, which grep takes for the name of a file that does not exist

    An array with no elements adds no word, but a quoted empty string adds one empty word. That is why an option list starts as opts=() and grows with opts+=(…), never as a string that may stay empty.

  2. Question 2 of 2 After opts=(-i) and then opts+=-n, what does declare -p opts show?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: declare -a opts=([0]="-i-n")

    The session showed both forms: opts+=-n gave [0]="-i-n", and opts+=(-n) gave two elements.

mapfile keeps every line whole, and an unquoted command substitution does not

todo.txt holds four lines: renew the domain, * call the bank, an empty line and pay rent. lines-to-array.sh loads it into an array three ways and prints every element between brackets:

lines-to-array.sh: the lines of a file into an array, three ways Shell · lines-to-array.sh
#!/usr/bin/env bash
# lines-to-array.sh: the lines of a file into an array, three ways.
file=${1:-todo.txt}

# 1. Unquoted command substitution: the output is split at spaces, tabs
#    and newlines, then every word is globbed (ShellCheck SC2207).
broken=( $(cat "$file") )
echo "broken: ${#broken[@]} elements"
printf '  [%s]\n' "${broken[@]}"

# 2. mapfile: one element per line; -t removes the newline that ends each.
mapfile -t lines < "$file"
echo "mapfile: ${#lines[@]} elements"
printf '  [%s]\n' "${lines[@]}"

# 3. read -a: one line only, split at IFS into words, never globbed.
read -r -a words <<< "${lines[1]}"
echo "read -a on line 2: ${#words[@]} elements"
printf '  [%s]\n' "${words[@]}"

The session runs the script in a folder that also holds notes.md and report.pdf, then tries the options of mapfile one at a time:

lines-to-array.sh, ShellCheck SC2207, and mapfile with -t, a command, a pipe and -d Python · lines_session.py
from session import run, files, write

files("lines-to-array.sh")
write("todo.txt", "renew the domain\n* call the bank\n\npay rent\n")

run(r"""
touch notes.md report.pdf
bash lines-to-array.sh todo.txt
!shellcheck lines-to-array.sh
mapfile kept < todo.txt; declare -p kept
mapfile -t due < <(grep -v '^pay' todo.txt); declare -p due
printf '%s\n' one two | mapfile -t got; echo "${#got[@]} elements after the pipe"
type -t mapfile readarray
printf 'first\nsecond' > partial.txt; readarray -t p < partial.txt; declare -p p
mapfile -t -d , colours < <(printf %s 'red,green,blue'); declare -p colours
mapfile -t -d , colours <<< 'red,green,blue'; declare -p colours
""")

Output

$ touch notes.md report.pdf
$ bash lines-to-array.sh todo.txt
broken: 12 elements
  [renew]
  [the]
  [domain]
  [lines-to-array.sh]
  [notes.md]
  [report.pdf]
  [todo.txt]
  [call]
  [the]
  [bank]
  [pay]
  [rent]
mapfile: 4 elements
  [renew the domain]
  [* call the bank]
  []
  [pay rent]
read -a on line 2: 4 elements
  [*]
  [call]
  [the]
  [bank]
$ shellcheck lines-to-array.sh

In lines-to-array.sh line 7:
broken=( $(cat "$file") )
         ^------------^ SC2207 (warning): Prefer mapfile or read -a to split command output (or quote to avoid splitting).

For more information:
  https://www.shellcheck.net/wiki/SC2207 -- Prefer mapfile or read -a to spli...
[exit status 1]
$ mapfile kept < todo.txt; declare -p kept
declare -a kept=([0]=$'renew the domain\n' [1]=$'* call the bank\n' [2]=$'\n' [3]=$'pay rent\n')
$ mapfile -t due < <(grep -v '^pay' todo.txt); declare -p due
declare -a due=([0]="renew the domain" [1]="* call the bank" [2]="")
$ printf '%s\n' one two | mapfile -t got; echo "${#got[@]} elements after the pipe"
0 elements after the pipe
$ type -t mapfile readarray
builtin
builtin
$ printf 'first\nsecond' > partial.txt; readarray -t p < partial.txt; declare -p p
declare -a p=([0]="first" [1]="second")
$ mapfile -t -d , colours < <(printf %s 'red,green,blue'); declare -p colours
declare -a colours=([0]="red" [1]="green" [2]="blue")
$ mapfile -t -d , colours <<< 'red,green,blue'; declare -p colours
declare -a colours=([0]="red" [1]="green" [2]=$'blue\n')

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 lines_session.py

broken=( $(cat "$file") ) made 12 elements out of 4 lines. The unquoted command substitution: $(command), or the older backquoted form: Bash runs the command in a subshell and puts its standard output in place of the substitution, with every trailing newline removed. More in “Command and process substitution” was split at every space and newline, the empty line left nothing behind, and the * was treated as a glob pattern: A word containing *, ? or [ that Bash replaces with the sorted names of the files it matches; it stays as written when nothing matches, unless nullglob or failglob is set. More in “Globbing: wildcards, dotfiles, extglob, globstar” and replaced by the four file names of the folder. ShellCheck reported the line as SC2207, “Prefer mapfile or read -a to split command output”. mapfile -t lines < "$file" gave one element per line, 4 in all, the empty line as an empty element and the * as a plain character. read -a on line 2 split it into four words and still left the * alone.

An array filled from unquoted output

Wrong: due=( $(grep -v '^pay' todo.txt) ), which splits the lines into words and expands the * into file names. Right: mapfile -t due < <(grep -v '^pay' todo.txt), which gave 3 whole lines, the empty one included.

The other lines of the session show how mapfile behaves:

  • Without -t, each element keeps the newline that ended its line, as $'renew the domain\n' in declare -p kept shows; -t removes it.
  • < <(grep -v '^pay' todo.txt) hands mapfile the output of a command through process substitution: <(command) or >(command): Bash starts the command and puts a file name such as /dev/fd/63 in its place. Reading that file gives the command's output; writing to it feeds the command's input. More in “Command and process substitution”, which the command and process substitution lesson explains.
  • printf '%s\n' one two | mapfile -t got left 0 elements. The manual’s Pipelines section runs each command of a pipeline in its own subshell: A copy of the shell that runs commands apart from it, as Bash does for each command of a pipeline. It starts with the shell's variables, and every change it makes to them is lost when it exits. More in “Reading files line by line with read”, so the array was filled and lost with it, as the lesson on reading files line by line showed for a counting loop.
  • type -t called both names builtins: readarray is the same command as mapfile under another name. A last line without a newline, as in partial.txt, is still stored.

-d sets the record delimiter: The character that ends each record a builtin reads: a newline by default. mapfile -d and read -d choose another one, and an empty delimiter means the NUL byte., the character that ends each element instead of a newline, and came with Bash 4.4. mapfile -t -d , colours < <(printf %s 'red,green,blue') gave three clean elements. Fed by the here-string: The [n]<<< word form, a Bash extension: word goes through tilde, parameter, command and arithmetic expansion and quote removal, and the result plus one newline is fed to the command on its standard input. More in “Here-documents and here-strings” <<< 'red,green,blue', the last element was $'blue\n': a here-string adds a newline after its word, and -t removes only the delimiter, here the comma. -d '' ends each element at a NUL byte, the separator of NUL-separated input: A list in which each item, usually a file name, ends with a NUL byte instead of a newline, as find -print0 writes it. Bash reads one item per call with read -r -d ''. More in “Reading files line by line with read”, which the word splitting lesson used to keep file names whole, newlines and all.

Key idea

mapfile -t makes one element per line, empty lines and * kept as they are. Feed it with < file or < <(command), never through a pipe, and never fill an array from an unquoted $( ).

Checkpoint

Check: Lines into an array with mapfile

  1. Question 1 of 2 A file holds the lines a b, an empty line, * and c, and the last one has no newline at its end. How many elements does mapfile -t x < file give?

    Type a number.

    Show the answer: Question 1 of 2

    Answer: 4 elements

    One element per line: the empty line becomes an empty element, the * stays a character, and a last line without a newline is still stored, as readarray -t p < partial.txt stored second in the session.

  2. Question 2 of 2 At the prompt, ls | mapfile -t names; echo "${#names[@]}" prints 0 in a folder full of files. Why?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: mapfile ran in a subshell, as the last part of the pipeline, and its array went with it

    Keep mapfile in the current shell: feed it from a file with < file, or from a command with < <(command).

One line splits into fields with read, and a join needs IFS or printf

mapfile makes one element per line; read -a makes one element per field of a single line. It splits the line at the characters of IFS: The internal field separator: the characters Bash splits unquoted expansions on and read splits lines on; by default a space, a tab and a newline. More in “Word splitting and IFS: why "$var" needs quotes”, as word splitting does, but never expands a *. The session splits three lines and then joins a list of cities in four ways:

read -a on one line at a time, and four ways to join an array Python · split_join_session.py
from session import run, write

write("two.txt", "red green\nblue\n")

run(r"""
IFS=, read -r -a fields <<< 'Asha Rao,Pune,,* priority'; declare -p fields
read -r -a words <<< '  New Delhi   Pune  '; declare -p words
read -r -a first < two.txt; declare -p first
IFS=, read -r -a f <<< 'a,b,'; echo "${#f[@]} fields"
cities=(Pune Chennai 'New Delhi')
printf '%s\n' "${cities[@]}"
(IFS=,; echo "${cities[*]}")
echo "${cities[*]}"
(IFS=', '; echo "${cities[*]}")
csv() { local IFS=,; echo "$*"; }; csv "${cities[@]}"
none=(); printf '[%s]\n' "${none[@]}"; echo "${#none[@]} elements"
(IFS=,; echo "joined: [${none[*]}]")
""")

Output

$ IFS=, read -r -a fields <<< 'Asha Rao,Pune,,* priority'; declare -p fields
declare -a fields=([0]="Asha Rao" [1]="Pune" [2]="" [3]="* priority")
$ read -r -a words <<< '  New Delhi   Pune  '; declare -p words
declare -a words=([0]="New" [1]="Delhi" [2]="Pune")
$ read -r -a first < two.txt; declare -p first
declare -a first=([0]="red" [1]="green")
$ IFS=, read -r -a f <<< 'a,b,'; echo "${#f[@]} fields"
2 fields
$ cities=(Pune Chennai 'New Delhi')
$ printf '%s\n' "${cities[@]}"
Pune
Chennai
New Delhi
$ (IFS=,; echo "${cities[*]}")
Pune,Chennai,New Delhi
$ echo "${cities[*]}"
Pune Chennai New Delhi
$ (IFS=', '; echo "${cities[*]}")
Pune,Chennai,New Delhi
$ csv() { local IFS=,; echo "$*"; }; csv "${cities[@]}"
Pune,Chennai,New Delhi
$ none=(); printf '[%s]\n' "${none[@]}"; echo "${#none[@]} elements"
[]
0 elements
$ (IFS=,; echo "joined: [${none[*]}]")
joined: []

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 split_join_session.py

IFS=, read -r -a fields <<< 'Asha Rao,Pune,,* priority' gave 4 fields: the spaces stayed inside Asha Rao and * priority, the two commas in a row held an empty field, and the * was not expanded. With the default IFS, the spaces at both ends were dropped and a run of spaces counted as one separator, so ' New Delhi Pune ' gave New, Delhi and Pune. read took only the first line of two.txt, and a comma at the very end, as in a,b,, made no empty field after it: 2 fields. -r keeps backslashes as plain characters; the reading files lesson shows what read does to them without it.

The joins printed these lines:

Line Printed Joined with
printf '%s\n' "${cities[@]}" three lines a newline after each element
(IFS=,; echo "${cities[*]}") Pune,Chennai,New Delhi the first character of IFS
echo "${cities[*]}" Pune Chennai New Delhi a space, the default’s first character
(IFS=', '; echo "${cities[*]}") Pune,Chennai,New Delhi still only the comma
csv "${cities[@]}" with local IFS=, Pune,Chennai,New Delhi the function’s own IFS

"${cities[*]}" makes a single word of all the elements and puts the first character of IFS between each pair, so IFS has to be set before the expansion and only for as long as the join: inside a subshell, ( … ), or as local IFS=, in a function, which leaves the rest of the script with the default. IFS=', ' still joined with a bare comma. A separator of several characters needs the printf -v pattern of the word splitting lesson, which writes the separator after every element and then removes the last one.

An empty array joins to an empty string, but printf '[%s]\n' "${none[@]}" still printed one [] line, because printf uses its format once even when it gets no argument. A dry run of an empty list therefore looks like one empty argument; ${#none[@]}, 0 here, tells the two apart.

Key idea

read -r -a splits one line at IFS without globbing. "${a[*]}" joins with the first character of IFS, so set IFS for the join alone, in a subshell or as a function’s local.

Checkpoint

Check: Splitting one line, and joining a list

  1. Question 1 of 2 How many elements does IFS=, read -r -a f <<< 'x,,y' give?

    Type a number.

    Show the answer: Question 1 of 2

    Answer: 3 elements

    A comma is not a space, a tab or a newline, so two commas in a row hold an empty field between them: x, an empty element and y. The session's Asha Rao,Pune,,* priority gave four fields the same way, the third one empty.

  2. Question 2 of 2 With cities=(Pune Chennai 'New Delhi'), what does (IFS='|,'; echo "${cities[*]}") print?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Pune|Chennai|New Delhi

    The session's (IFS=', '; echo "${cities[*]}") printed Pune,Chennai,New Delhi, without the space, for the same reason. A separator of several characters needs printf -v.

Each array feature has a first Bash version, and the Mac’s own Bash is older than most

A script that runs well on Bash 5.3 can stop on the /bin/bash of a Mac, which reports itself as 3.2.57 here. The session runs the same lines in both:

Bash 5.3.20 against macOS's /bin/bash 3.2.57: an empty array under set -u, and mapfile Python · versions_session.py
from session import run, write

write("todo.txt", "renew the domain\n* call the bank\n\npay rent\n")

run(r"""
bash -c 'echo "$BASH_VERSION"'; /bin/bash -c 'echo "$BASH_VERSION"'
bash -c 'set -u; none=(); for c in "${none[@]}"; do echo "$c"; done; echo "loop done, no error"'
!/bin/bash -c 'set -u; none=(); for c in "${none[@]}"; do echo "$c"; done; echo "loop done, no error"'
/bin/bash -c 'set -u; none=(); for c in ${none[@]+"${none[@]}"}; do echo "$c"; done; echo "loop done, no error"'
!/bin/bash -c 'mapfile -t lines < todo.txt'
/bin/bash -c 'lines=(); while IFS= read -r line; do lines+=("$line"); done < todo.txt; echo "${#lines[@]} lines"'
""")

Output

$ bash -c 'echo "$BASH_VERSION"'; /bin/bash -c 'echo "$BASH_VERSION"'
5.3.20(1)-release
3.2.57(1)-release
$ bash -c 'set -u; none=(); for c in "${none[@]}"; do echo "$c"; done; echo "loop done, no error"'
loop done, no error
$ /bin/bash -c 'set -u; none=(); for c in "${none[@]}"; do echo "$c"; done; echo "loop done, no error"'
/bin/bash: none[@]: unbound variable
[exit status 127]
$ /bin/bash -c 'set -u; none=(); for c in ${none[@]+"${none[@]}"}; do echo "$c"; done; echo "loop done, no error"'
loop done, no error
$ /bin/bash -c 'mapfile -t lines < todo.txt'
/bin/bash: mapfile: command not found
[exit status 127]
$ /bin/bash -c 'lines=(); while IFS= read -r line; do lines+=("$line"); done < todo.txt; echo "${#lines[@]} lines"'
4 lines

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 versions_session.py

Under nounset: Bash's set -u option (also set -o nounset): the shell ends the script when it expands an unset variable or an unset positional parameter. "$@", "$*" (since Bash 4.1) and an empty array's "${a[@]}" (since Bash 4.4) are exempt; use ${v-default} or ${v:-default} to opt out explicitly where unset is acceptable. More in “set -e, set -u and pipefail: what they really do”, which turns the expansion of an unset variable into an error, Bash 5.3 ran the loop over the empty array none without a single round and went on. Bash 3.2 stopped with none[@]: unbound variable. The CHANGES file of bash-4.4-rc2 records the change: ${a[@]} of an array without elements no longer raises that error under nounset. On 3.2, ${none[@]+"${none[@]}"} expands to nothing when the array is empty and to its elements otherwise. It is the :+ form of the defaults lesson without the colon, so it asks only whether the array is set, and the manual counts an array as set once one of its elements has a value. mapfile did not exist in 3.2, which answered “command not found”, while a while IFS= read -r line loop with lines+=("$line") loaded the 4 lines there too.

Walk through the releases to see when each array feature arrived, and so which forms a script may use for the oldest Bash it has to support:

Timeline · 10 events in order Array features by Bash version Walk through the releases in order. Each event names a feature, the form it is written in and the entry of the Bash NEWS or CHANGES file that records it.
  1. 2.0

    Indexed arrays the first arrays

    Arrays indexed by number arrive: any length, the name=(…) compound assignment, a subscript that may be an arithmetic expression, and array support in declare, read and readonly. NEWS lists them as item p of bash-2.0.

  2. 3.0

    The list of indices indices

    ${!array[@]} expands to the indices that hold a value, so a loop can visit an array with gaps (NEWS, bash-3.0, item v).

  3. 3.1

    Appending with += and printf -v appending

    a+=(x y) appends elements, and printf -v name writes the formatted text into a variable instead of standard output (NEWS, bash-3.1, items i and q). macOS's /bin/bash, 3.2.57 in this lesson's session, has both.

  4. 4.0

    mapfile, readarray and associative arrays new builtins

    mapfile, also spelt readarray, reads lines into an indexed array, and declare -A creates arrays keyed by strings (NEWS, bash-4.0, items u and ii). Bash 3.2 has neither: its answer to mapfile is "command not found".

  5. 4.1

    printf -v into one element

    printf -v 'list[3]' '%s' value writes straight into one element of an array (NEWS, bash-4.1, item e).

  6. 4.2

    Negative subscripts, declare -g and -v reading from the end

    ${a[-1]} reads the last element, counting back from the end; declare -g creates a global variable from inside a function; and [[ -v name ]] tests whether a variable is set (NEWS, bash-4.2, items p, e and f).

  7. 4.3

    Namerefs, and negative subscripts that assign namerefs

    declare -n makes a name that stands for another variable, an array included; negative subscripts now also assign and unset, as in a[-1]=x; and -v understands array references such as [[ -v a[2] ]] (NEWS, bash-4.3, items w, x and ll).

  8. 4.4

    mapfile -d, and empty arrays under set -u set -u

    mapfile -d ends each element at a delimiter you choose, -d '' at a NUL byte, and -t then removes that delimiter (NEWS, bash-4.4, item d). And "${a[@]}" of an array without elements no longer counts as unset under set -u (CHANGES, bash-4.4-rc2, New Features a), the error macOS's 3.2 still gave in this lesson's session.

  9. 5.1

    Key-value lists and @K maps

    declare -A m=(k1 v1 k2 v2) fills an associative array from plain pairs, and ${m[@]@K} prints an array as quoted key-value pairs (NEWS, bash-5.1, items gg and hh).

  10. 5.3

    array_expand_once subscripts

    The shell option array_expand_once stops Bash from evaluating an array subscript more than once in arithmetic and in builtins that assign or read elements. It replaces assoc_expand_once, added in 5.0 for associative arrays, which stays as a synonym (NEWS, bash-5.3, item t; bash-5.0, item w).

The timeline has 10 events, from 2.0 to 5.3.

A script for 3.2 may use +=, printf -v and ${!a[@]}, but not mapfile, associative arrays or ${a[-1]}; one that needs mapfile -d needs 4.4 or newer. To see which Bash runs a script, print $BASH_VERSION, as the session did; the lesson on shells, terminals and Bash versions covers the rest.

Checkpoint

Check: Array features by Bash version

  1. One question Under set -u, with files=(), a script runs for f in "${files[@]}"; do echo "$f"; done; echo done. What do Bash 5.3 and macOS's Bash 3.2 do?

    Choose one answer.

    Show the answer: One question

    Answer: 5.3 prints done; 3.2 stops with files[@] unbound variable

    The CHANGES file of bash-4.4-rc2 records the change. On 3.2, ${files[@]+"${files[@]}"} expands to nothing without the error, as the last loop of the session showed.

Practise: three helpers for lists

Write three functions in a file lists.sh, source it, and try them in a folder that holds a few other files, so that a * which gets expanded by mistake shows at once:

  • parse_list FILE ARRAYNAME loads the lines of FILE into the array named ARRAYNAME, leaving out empty lines.
  • uniq_preserve_order ARRAYNAME removes repeated elements and keeps the first copy of each where it was.
  • join_by SEP ARRAYNAME prints the elements on one line with SEP between them, whatever the length of SEP.

A function that fills or reads an array its caller names needs a nameref, local -n, from the indirection lesson; give it a name no caller will use. Make the input with printf '%s\n' 'New Delhi' Chennai '' '*' 'New Delhi' Pune > cities.txt. Then parse_list cities.txt list; printf '[%s]\n' "${list[@]}" must print [New Delhi], [Chennai], [*], [New Delhi] and [Pune]; after uniq_preserve_order list the same printf prints the four without the second [New Delhi]; join_by ' | ' list prints New Delhi | Chennai | * | Pune; and bash -u -c 'source ./lists.sh; empty=(); join_by , empty; echo "status $?"' prints an empty line, then status 0. To check uniq_preserve_order on a longer list, paste its lines into the duplicate line remover, which also keeps the first copy of every line in place.

The questions mix all five sections, and every answer explains why a choice is right or what it misses:

Practice that adapts to you

Practise this lesson

15 questions about 5 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.

  1. Question 1 of 15 To avoid quotes, someone writes cmd='grep -i -e disk\ full app.log' and runs $cmd. What does grep get where the pattern should be?

    Choose one answer.

    Show the answer: Question 1 of 15

    Answer: disk\ and full, two words, with the backslash kept

    No quoting characters in a string can make one argument hold a space. Keep the command as an array instead: cmd=(grep -i -e 'disk full' -- app.log).

  2. Question 2 of 15 Which ShellCheck warning marks the assignment of a command with quotes inside a string?

    Choose one answer.

    Show the answer: Question 2 of 15

    Answer: SC2089, quotes or backslashes will be treated literally

    ShellCheck 0.11.0 reported SC2089 on line 3 of search-string.sh and SC2090 on line 4, where $cmd runs.

  3. Question 3 of 15 cmd holds grep -i -e 'disk full' app.log. What does "$cmd" do?

    Choose one answer.

    Show the answer: Question 3 of 15

    Answer: It looks for a command named after the whole value, and fails with command not found, status 127

    Neither "$cmd" nor $cmd can run a command whose argument holds a space. An array can, as "${cmd[@]}".

  4. Question 4 of 15 After opts=(), what does "${opts[@]}" add to the arguments of grep "${opts[@]}" -e x file?

    Choose one answer.

    Show the answer: Question 4 of 15

    Answer: Nothing at all, not even an empty argument

    That is why an option list starts as an empty array: when no option applies, it vanishes from the command, as declare -a opts=() did in the session while grep still found its line.

  5. Question 5 of 15 With cmd=(sort -t , -k 2 -- 'march sales.csv'), what does printf '[%s]\n' "${cmd[@]}" print?

    Choose one answer.

    Show the answer: Question 5 of 15

    Answer: Seven lines, one per element, the last one [march sales.csv]

    The brackets show where each argument starts and ends, so the comma after -t and the space inside the file name stay visible, which is what a dry run is for.

  6. Question 6 of 15 A script fills opts=(-i) correctly but runs grep ${opts[@]} -e "$pattern" -- "$file" without quotes around the expansion. When does that break?

    Choose one answer.

    Show the answer: Question 6 of 15

    Answer: When an element holds a space or a glob character, because unquoted elements are split and globbed again

    An option with a value that holds a space, such as --label='disk check', would arrive as two words. Quote the expansion, "${opts[@]}", every time.

  7. Question 7 of 15 What does -t change in mapfile -t lines < file?

    Choose one answer.

    Show the answer: Question 7 of 15

    Answer: It removes the newline at the end of each line, so the elements hold the text only

    -t removes the delimiter at the end of each element: the newline by default, or the character that -d names.

  8. Question 8 of 15 A folder holds only a.txt, b.txt and list.txt, and list.txt holds one line, * draft. How many elements does x=( $(cat list.txt) ) give?

    Type a number.

    Show the answer: Question 8 of 15

    Answer: 4 elements

    The output is split into * and draft, and the * is then globbed into the three names of the folder, a.txt, b.txt and list.txt, so draft makes four. mapfile -t x < list.txt gives one element, the line itself.

  9. Question 9 of 15 You need the output of sort -u names.txt in the array names, one element per line, in the current shell. Which line does that?

    Choose one answer.

    Show the answer: Question 9 of 15

    Answer: mapfile -t names < <(sort -u names.txt)

    < <(command) is the shape to use whenever the lines come from a command, and < file when they are in a file.

  10. Question 10 of 15 How much of its input does read -r -a words take?

    Choose one answer.

    Show the answer: Question 10 of 15

    Answer: One line, split into words at the characters of IFS

    read -a is for the fields of one line, and mapfile for a list of lines.

  11. Question 11 of 15 What does read -a w <<< 'C:\temp files' store, without -r?

    Choose one answer.

    Show the answer: Question 11 of 15

    Answer: C:temp and files

    The Bash manual says the backslash removes any special meaning of the next character read unless -r is given. Use read -r unless backslashes are meant as escapes.

  12. Question 12 of 15 Which line prints Pune,Chennai,New Delhi from the cities array and leaves the shell's IFS as it was?

    Choose one answer.

    Show the answer: Question 12 of 15

    Answer: (IFS=,; echo "${cities[*]}")

    A join needs IFS set before "${a[*]}" is expanded, and for no longer than the join: a subshell, or local IFS inside a function, as csv did in the session.

  13. Question 13 of 15 Which is the first Bash version with mapfile and readarray?

    Choose one answer.

    Show the answer: Question 13 of 15

    Answer: 4.0

    mapfile came with bash-4.0, the release that also brought associative arrays.

  14. Question 14 of 15 A script must also run on macOS's /bin/bash 3.2. Which line loads the lines of todo.txt into the array lines there?

    Choose one answer.

    Show the answer: Question 14 of 15

    Answer: lines=(); while IFS= read -r line; do lines+=("$line"); done < todo.txt

    IFS= keeps spaces at the ends of each line and -r keeps backslashes, so every line arrives whole, the empty one included.

  15. Question 15 of 15 Which of these lines work on Bash 4.3? Select every one that does.

    Choose every answer that is right.

    Show the answer: Question 15 of 15

    Answer:

    • echo "${a[-1]}"
    • a+=(x)
    • mapfile -t lines < file

    A feature works from its first version on: += from 3.1, mapfile from 4.0 and reading ${a[-1]} from 4.2, while mapfile -d needs 4.4 and plain key-value lists 5.1.

Interview questions

Warm-up (fresher to 2–5 years): what goes into an array with mapfile -t, and what with read -a?

mapfile -t arr reads its whole input and makes one element per line, with the newline at the end of each line removed. Spaces, a * and empty lines are kept exactly, and a last line without a newline is stored too. read -r -a arr reads one line only and splits it into fields at the characters of IFS: with the default IFS the spaces at its ends disappear and New Delhi becomes two elements, while IFS=, read -r -a arr cuts at commas and keeps an empty field between two of them. Neither expands a * into file names, as arr=( $(cmd) ) does. Both empty the array before they fill it, and both lose it when a pipe feeds them, because they then run in a subshell, so give them < file, < <(command) or a here-string instead.

Two harder questions go further. One is about a team whose job steps are lines of text that a loop runs, and the other about turning a user’s list of file-name patterns into a find command, an empty list included. Before you open either, write down the first command you would type to watch it go wrong:

Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.

  1. Mid to senior

    A team keeps the steps of a release job as lines of steps.conf, such as tar -czf 'site backup.tgz' public/, and a loop runs each line as $line. The job makes an archive named 'site and reports an error, and someone proposes eval "$line" instead. What do you change, and why not eval?

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. Mid to senior

    A cleanup script takes file-name patterns as its arguments, such as '.tmp' and 'core.', and must delete the files under work/ whose names match any of them. Build its find command, and say what the script should do when it is given no pattern at all.

    Model answer, Pro. First step and follow-ups

Flashcards · 8 cards

Recall it, then check

Say the answer to yourself before you look: recalling it is what makes it stick.

  1. Why does a command kept in a string lose its quoting?

    Quote characters stored in a variable's value are plain characters by the time the value is expanded; they quote nothing. Keep the command and its arguments as array elements and run it as "${cmd[@]}", so each element reaches it as one word.

  2. How do you build an argument list whose options depend on settings?

    Start with an empty array and add each option that applies with +=( ). An option that does not apply leaves no empty argument behind.

  3. How can a script show the exact arguments it is about to run?

    printf '[%s]\n' "${cmd[@]}" prints each argument on its own line between brackets, so a space inside one, as in [disk full], cannot be taken for the end of an argument.

  4. What does mapfile -t arr load?

    One element per line, with the newline removed, keeping spaces, a * and empty lines exactly, and a last line without a newline too. Feed it with < file or < <(command), never through a pipe.

  5. What is wrong with arr=($(command))?

    The unquoted output is split at IFS and then globbed, so a line with spaces becomes several elements and a * turns into file names.

  6. What does read -r -a arr do with a line?

    It splits that single line into fields at IFS, without globbing, one field per element: with the default IFS, New Delhi becomes two elements.

  7. How do you join an array's elements with a comma?

    Set IFS to a comma for the join alone, in a subshell or as a function's local IFS, and expand "${a[*]}", which joins with IFS's first character.

  8. Which array features need a newer Bash than the 3.2 of a Mac?

    mapfile needs 4.0, and mapfile -d and empty arrays allowed under set -u need 4.4. For Bash 3.2, use a while read loop and the ${a[@]+"${a[@]}"} form.

Key takeaways

  • A command kept in a string loses its quoting, because quotes that come out of a variable are only characters; an array keeps each argument as one element, and "${cmd[@]}" hands each one on as one word.
  • An argument list starts as an empty array and grows with +=( ), so an option that does not apply leaves no empty argument behind, and printf '[%s]\n' "${cmd[@]}" prints it one argument per line for a dry run.
  • mapfile -t loads one element per line, empty lines and * included, from < file or < <(command); an array filled from an unquoted $( ) is split and globbed, and one filled through a pipe is lost in a subshell.
  • read -r -a splits a single line into fields at IFS without globbing, and "${a[*]}" joins an array with the first character of IFS, which belongs in a subshell or in a function’s local IFS.
  • Array features arrived over many releases, mapfile in 4.0, mapfile -d and empty arrays allowed under set -u in 4.4, so a script for the Bash 3.2 of a Mac needs a while read loop and the ${a[@]+"${a[@]}"} form.

Terms in this lesson

command array
An indexed array that holds a command name and its arguments, one per element, run as "${cmd[@]}" so that each element reaches the command as exactly one word, spaces and quote characters included.
record delimiter
The character that ends each record a builtin reads: a newline by default. mapfile -d and read -d choose another one, and an empty delimiter means the NUL byte.

References

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.