skip to content

Variables and Expansion

Everything that happens between the text you type and the argument list the command finally receives: quoting, parameter expansion, arrays, word splitting, and globbing. Most of the bash bugs interviewers ask about — a filename with spaces, an empty variable that eats an argument — are expansion bugs, which makes this the densest part of the language.

part ofBashoverview, primer and where to startread it →
on this pageshow

questions

page 1 of 2

In bash, how do you create an indexed array, append to it, read a single element and get the number of elements — and why does `echo $arr` print only the first element?

level: juniorimportance: must knowfreq 70%

answer

  1. parentheses make a list
  2. bare name means subscript zero
  3. braces are required around subscripts
  4. count versus character length

basics

~10 s

Create with arr=(a b c), append with arr+=(d), read one element as "${arr[0]}", count with "${#arr[@]}". A bare $arr is shorthand for ${arr[0]}, so it shows only the first element.

solid answer

~40 s

An indexed array literal is a parenthesised word list: `arr=(alpha "beta gamma" delta)`. Each word becomes one element, and quoting keeps a multi-word value in a single element. `arr+=(epsilon)` appends after the highest index. You read one element with `${arr[0]}` — the braces are mandatory, because `$arr[0]` expands `$arr` and then leaves a literal `[0]`. `${#arr[@]}` is the element count, while `${#arr}` is the character length of element 0, which is a classic trip-up. `${!arr[@]}` gives the indices, and `${arr[@]:1:2}` takes a two-element slice. Bare `$arr` is defined to mean `${arr[0]}`, so it never prints the whole array; to get every element, expand `"${arr[@]}"`. `declare -p arr` prints the real structure when you are unsure what you have.

code

bash · 9 lines
bash
arr=(alpha "beta gamma" delta)
echo "${arr[0]}"        # alpha
echo "${arr[1]}"        # beta gamma
echo "${#arr[@]}"       # 3  (elements)
echo "${#arr}"          # 5  (characters in alpha)
echo "${#arr[1]}"       # 10 (characters in beta gamma)
arr+=(epsilon)
printf '%s\n' "${arr[@]:1:2}"   # beta gamma / delta
declare -p arr

go deeper

for a junior

Say the four basics without hesitating: arr=(a b c) creates, arr+=(d) appends, "${arr[0]}" reads, "${#arr[@]}" counts. Add that a bare $arr is only the first element.

for a middle

Explain why the braces are mandatory — the subscript is part of the expansion syntax, not of the name — and distinguish ${#arr} (characters in element 0) from ${#arr[@]} (element count).

for a senior

Show the habits that survive real data: quote on the way in and on the way out, use declare -p to inspect element boundaries, and know that an empty array expands to zero words rather than one empty argument.

for a principal

Be ready to say where the shell's single flat list type runs out. When a script needs records, nested structures or keyed lookups threaded through many functions, argue for moving that logic to a real language rather than encoding structure into array elements.

## What an indexed array is An indexed array in bash is a variable that holds many values, each stored under an integer subscript starting at 0. It is the only built-in way to hold a list whose items may contain spaces, tabs or newlines: a plain string variable holding `a b c` is one value that only *looks* like three, and the shell will happily re-split it in ways you did not intend. ## Creating one The compound assignment is a parenthesised list of words: ```bash arr=(alpha "beta gamma" delta) ``` The words inside the parentheses go through the normal expansions, so quoting matters exactly as it does on a command line. `"beta gamma"` is one element; without the quotes it would be two. That gives three elements at indices 0, 1 and 2. You can also assign a single slot directly, `arr[7]=eta`, and bash will create the array for you. `declare -a arr` declares an empty indexed array explicitly, which is useful when a later `+=` is the first thing that touches it. ## Reading one element ```bash echo "${arr[1]}" # beta gamma ``` The braces are not optional. In `$arr[0]`, bash sees the parameter name `arr`, expands it, and leaves `[0]` as literal text, so you get `alpha[0]`. Any time a subscript is involved, the whole thing goes inside `${ }`. The subscript itself is an arithmetic context, so `${arr[i]}` works with a bare variable name and `${arr[n+1]}` does arithmetic. Bash 4.3 and later also accept negative subscripts, where `${arr[-1]}` is the last element. ## Length, count and indices Two expansions look almost identical and mean different things: ```bash echo "${#arr[@]}" # 3 -> number of elements echo "${#arr}" # 5 -> characters in ${arr[0]}, i.e. "alpha" echo "${#arr[1]}" # 10 -> characters in "beta gamma" ``` `${!arr[@]}` lists the subscripts that actually exist, which is the reliable way to iterate when the array has gaps. `${arr[@]:1:2}` is a slice of two elements starting at offset 1; `${arr[@]:1}` takes everything from offset 1 onward. ## Appending ```bash arr+=(epsilon zeta) ``` The `+=` compound assignment appends after the highest existing index — it does not overwrite and it does not care that earlier indices may be missing. This is the idiom for building a list in a loop, one `arr+=("$item")` per iteration, with the item quoted so a value containing spaces stays a single element. ## Why bare $arr is the first element Bash defines a bare array name without a subscript as equivalent to subscript 0. So `echo $arr` prints `alpha` and `arr=x` silently overwrites element 0 while leaving the rest of the array in place. There is no syntax that turns the whole array into a value except `${arr[@]}` and `${arr[*]}`, and in practice you almost always want the quoted `"${arr[@]}"`, which produces one word per element with the contents untouched. The same asymmetry explains why `${#arr}` counts characters: with no subscript, bash is measuring the string in element 0, exactly as it would for any ordinary variable. ## Inspecting what you actually have When an array is behaving oddly, print its real structure instead of guessing: ```bash declare -p arr # declare -a arr=([0]="alpha" [1]="beta gamma" [2]="delta") ``` This shows the element boundaries and the actual subscripts, which is far more informative than `echo`, where the boundaries are invisible. `printf '[%s]\n' "${arr[@]}"` is the quick equivalent: one bracketed line per element makes a stray empty element or an accidentally-merged pair obvious. ## Practical habits Quote on assignment (`arr+=("$line")`), quote on expansion (`"${arr[@]}"`), use `${#arr[@]}` for counts, and reach for `declare -p` when debugging. Those four habits cover nearly everything a script does with a list.

  • What does `arr=x` do to an array that already has three elements?
    It assigns to element 0 only, because a bare array name means subscript 0. Elements 1 and 2 survive untouched, so the array silently ends up with a new first value and the old tail. If you meant to replace the whole thing, use `arr=(x)`, or clear it first with `unset arr`.
  • How do you iterate over an array together with its indices?
    Loop over `"${!arr[@]}"`, which expands to the subscripts that exist, and use each one to read the value: `for i in "${!arr[@]}"; do printf '%s=%s\n' "$i" "${arr[i]}"; done`. This is correct even if some subscripts are missing, whereas a counter running from 0 to `${#arr[@]}-1` is not.
  • What does `${arr[@]:1:2}` give you, and what happens if you ask for more elements than exist?
    It is a slice: up to two elements starting at offset 1. Asking for more than exist is not an error — you simply get however many are available, and an offset past the end yields nothing at all. A leading space is needed for a negative offset, `${arr[@]: -2}`, otherwise bash reads `:-` as the default-value operator.

saying these in an interview costs you the question

  • Thinking $arr expands to the whole array
  • Using ${#arr} to count elements
  • Writing $arr[0] without braces
  • Assuming arr+=x appends a new element
  • Believing an array element cannot contain spaces

context

open as a page

In a bash script, the line `count = 5` fails with `count: command not found`. Why does bash reject spaces around `=` in an assignment, and what are the correct forms when the value itself contains spaces?

level: juniorimportance: must knowfreq 72%

basics

~20 s

Bash parses count = 5 as the command count with the arguments = and 5. An assignment must have no spaces around =: write count=5, and quote the value when it contains spaces, as in msg="hello world".

open as a page

In bash pathname expansion (globbing), what do the metacharacters `*`, `?`, `[abc]` and `[[:digit:]]` match, and why is the glob `*.txt` not the same pattern as the regular expression `*.txt`?

level: juniorimportance: must knowfreq 72%

basics

~20 s

In globbing, * matches any run of characters, ? exactly one character, and [abc] or [[:digit:]] one character from a set; the pattern must match a whole filename. In a regex, * instead repeats the preceding item.

open as a page

With f=/var/log/app/archive.tar.gz in a bash script, what does each of ${f##*/}, ${f%/*}, ${f%.*} and ${f%%.*} expand to, and what is the rule behind # versus % and single versus double?

level: juniorimportance: must knowfreq 58%

basics

~10 s

They give archive.tar.gz, /var/log/app, /var/log/app/archive.tar and /var/log/app/archive. In bash, # strips a matching prefix and % strips a matching suffix; doubling the character strips the longest match instead of the shortest.

open as a page

In a bash script, what is the difference between wrapping text in single quotes and wrapping it in double quotes, and which shell behaviours does each one turn off?

level: juniorimportance: must knowfreq 88%

basics

~20 s

Double quotes still expand variables and command substitutions while suppressing word splitting and filename globbing; single quotes suppress all of that as well, so every character between them is literal, including the dollar sign and the backslash.

open as a page

In bash, what is the difference between expanding an array as "${arr[@]}" and as "${arr[*]}", and what changes if you drop the double quotes from either one?

level: middleimportance: must knowfreq 62%

basics

~20 s

Quoted "${arr[@]}" produces one word per element with contents preserved. Quoted "${arr[*]}" produces a single word, elements joined by the first character of IFS. Unquoted, both collapse to one string that is then word-split and glob-expanded.

open as a page

A bash script that never calls declare does `counts[apple]=3; counts[banana]=5; echo "${counts[apple]}"` and prints 5. What did bash actually create, and why did the two keys collide?

level: middleimportance: must knowfreq 55%

basics

~20 s

Without declare -A, bash created an ordinary indexed array, whose subscript is an arithmetic expression. The unset names apple and banana both evaluate to 0, so both assignments wrote element 0 and the second overwrote the first.

open as a page

A bash wrapper script must forward every argument it received on to another command unchanged. What is the difference between `"$@"` and `"$*"`, and which one does that job?

level: middleimportance: must knowfreq 76%

basics

~10 s

"$@" expands to one separate word per positional parameter, preserving arguments that contain spaces. "$*" joins them all into a single word separated by the first character of IFS. Forwarding arguments always uses "$@".

open as a page

In what order does bash apply its expansions to a command line, and why is the output of one expansion never re-scanned as a fresh expansion?

level: middleimportance: must knowfreq 62%

basics

~20 s

Bash expands in one pass: brace expansion, tilde expansion, then parameter, arithmetic and command substitution, then word splitting, then pathname expansion, then quote removal. Each stage sees only the remaining stages, so a value that looks like shell syntax is data, not code.

open as a page

A bash script runs `for f in *.log; do process "$f"; done` in a directory that contains no .log files, and `process` is called once with the literal string `*.log`. Why does bash behave that way, and what are the correct fixes?

level: middleimportance: must knowfreq 62%

basics

~20 s

By default bash leaves a pattern that matches nothing unchanged, so the word *.log survives as a literal and the loop runs once. Fix it with shopt -s nullglob, shopt -s failglob, or an existence guard inside the loop.

open as a page

In bash, what do the parameter expansions ${VAR:-default}, ${VAR-default}, ${VAR:=default} and ${VAR:?message} each do, and what difference does the colon make?

level: middleimportance: must knowfreq 75%

basics

~20 s

${VAR:-default} substitutes default when VAR is unset or empty; ${VAR-default} substitutes only when VAR is unset. ${VAR:=default} substitutes and also assigns it back to VAR. ${VAR:?message} prints message to stderr and aborts the script instead of substituting.

open as a page

A bash wrapper script forwards its arguments with `mytool $@`, and a user who passes an argument containing a space finds that mytool sees two arguments. What do `"$@"`, `$@`, `"$*"` and `$*` each expand to, and which belongs in that wrapper?

level: middleimportance: must knowfreq 62%

basics

~10 s

Only "$@" forwards arguments faithfully: it expands to one word per positional parameter with boundaries intact. $@ and $* are both re-split on IFS and globbed, and "$*" joins everything into a single word.

open as a page

In a bash script, `files="report 2024.txt"` followed by `rm $files` deletes the wrong things. What exactly does bash do to the unquoted `$files` before `rm` starts, and how do you fix it?

level: middleimportance: must knowfreq 80%

basics

~20 s

Bash splits the expanded value into words at the characters in IFS (space, tab and newline by default), then pathname-expands each word, so rm receives two arguments, report and 2024.txt. Quoting it as "$files" passes one.

open as a page

Bash scripts almost always read a file line by line as `while IFS= read -r line; do ...; done < input.txt`. Explain what the `IFS=` prefix and the `-r` flag each contribute, and what goes wrong if you drop them.

level: middleimportance: must knowfreq 70%

basics

~20 s

IFS= empties the field separator for that one read, so the line is stored whole with leading and trailing whitespace intact. The -r flag stops read from treating backslashes as escapes. Without them, lines get trimmed and backslashes vanish.

open as a page

Given a bash associative array declared as `declare -A env_of`, how do you loop over its keys, what does `"${env_of[@]}"` give you instead, and can you rely on the order you get?

level: juniorimportance: should knowfreq 48%

basics

~20 s

Loop over "${!env_of[@]}" for the keys; "${env_of[@]}" expands to the values, and ${#env_of[@]} is the entry count. The order is bash's internal hash order — neither insertion order nor sorted — so sort explicitly if output must be stable.

open as a page

In bash, what does brace expansion such as `{a,b}`, `{1..5}` or `cp config.yml{,.bak}` produce, and how does it differ from a filename glob like `*.txt`?

level: juniorimportance: should knowfreq 58%

basics

~20 s

Brace expansion generates literal text: bash copies the text around the braces once per alternative, so {a,b} yields two words and {1..5} yields five. It never looks at the filesystem, unlike a glob, so it produces words even for files that do not exist.

open as a page

A script builds options in a string, `OPTS="--output 'my report.csv'"`, then runs `mytool $OPTS`. The tool receives four arguments instead of two, and one of them starts with a quote character. Why, and how should the option list be built instead?

level: middleimportance: should knowfreq 50%

basics

~20 s

Word splitting happens after the variable expands, and the quotes inside the string are then just literal characters — bash never re-parses them. Store each argument as its own array element and run mytool "${opts[@]}".

open as a page

In bash, a function needs to fill an array that its caller declared, but arrays cannot be exported or passed by value. How does `declare -n` (or `local -n`) let the caller pass the array by name, and what naming pitfall comes with it?

level: middleimportance: should knowfreq 30%

basics

~20 s

A nameref variable created with local -n out=$1 becomes an alias for whatever variable $1 names, so assignments to out write straight into the caller's array. It needs bash 4.3+, and the alias breaks if the caller's variable has the same name as the nameref.

open as a page

In bash, what does `declare -i n` change about later assignments to `n`, and what happens if you then assign a non-numeric value to it?

level: middleimportance: should knowfreq 40%

basics

~20 s

declare -i n attaches the integer attribute to the name n, so every later assignment evaluates its right-hand side as an arithmetic expression. n=2+3 stores 5, and a bare non-numeric word is treated as a variable name that evaluates to 0.

open as a page

A bash script sets `n=5` and then runs `for i in {1..$n}; do echo $i; done`. What does that loop actually print, and why?

level: middleimportance: should knowfreq 48%

basics

~20 s

It prints the single line {1..5}. Brace expansion runs before variables are expanded, so bash sees {1..$n}, decides it is not a valid range, and leaves it literal; the variable is substituted afterwards, producing one loop iteration over that text.

open as a page

Bash's extended glob operators `!(pat)`, `@(pat)`, `+(pat)`, `*(pat)` and `?(pat)` are off by default. What does each of them match, how do you turn them on, and why can the single line `shopt -s extglob; echo !(*.txt)` still fail with a syntax error?

level: middleimportance: should knowfreq 36%

basics

~20 s

With shopt -s extglob, bash adds five operators over a pipe-separated pattern list: ?(p) zero or one, *(p) zero or more, +(p) one or more, @(p) exactly one, and !(p) anything that does not match. Enabling it on the same line fails because bash parses the whole line before running any of it.

open as a page

In bash, how do ${path/foo/bar} and ${path//foo/bar} differ, and what kind of pattern is allowed on the left-hand side?

level: middleimportance: should knowfreq 45%

basics

~20 s

A single slash replaces only the first match; a double slash replaces every match. The left-hand side is a shell glob pattern, not a regular expression, so metacharacters like * and ? apply but dots are literal and there are no capture groups.

open as a page

In a bash script with s=deployment, what do ${s:4:3}, ${s:-3} and ${s: -3} each produce, and why are the last two different?

level: middleimportance: should knowfreq 32%

basics

~20 s

They produce oym, deployment and ent. ${s:4:3} takes three characters from zero-based offset 4. ${s:-3} is not a substring at all — it is the default-value operator, which returns s because s is set. Only ${s: -3}, with a space, means the last three characters.

open as a page

In bash, `[[ $file == *.txt ]]` and `[[ $file == "*.txt" ]]` behave differently. What does quoting the right-hand side of `==` inside `[[ ]]` do, and where else in bash does quoting turn a pattern into a literal string?

level: middleimportance: should knowfreq 42%

basics

~20 s

Quoting the right-hand side turns a pattern into literal text. Unquoted, *.txt is a glob matched against the value; quoted, it matches only the exact four-character-plus string *.txt. The same rule applies to case patterns and to the =~ regex operand.

open as a page

In a bash script you must split each line of a colon-delimited file into fields. Explain how `IFS=: read -ra fields <<< "$line"` works, what scope the `IFS=:` has, and what `read -ra` produces for the line `x::y`.

level: middleimportance: should knowfreq 52%

basics

~20 s

The IFS=: prefix applies to that single read, leaving the script's IFS untouched. read -ra splits the input on colons and assigns the fields to array elements. Because a colon is not whitespace, x::y yields three fields: x, an empty string, and y.

open as a page

In bash, why is `files=($(find . -name '*.log'))` an unsafe way to capture a list of filenames into an array, and what would you use instead?

level: seniorimportance: should knowfreq 40%

basics

~20 s

The unquoted substitution is split on IFS and then glob-expanded, so any filename containing a space, tab or newline becomes several elements and one containing a glob character can be replaced by other names. Fill the array with mapfile -t, or a NUL-delimited read loop.

open as a page

A CI script that begins `declare -A seen` runs fine on the Linux runners but fails on a developer's Mac with `declare: -A: invalid option`. What is happening, and what are your options for making that script work there?

level: seniorimportance: should knowfreq 42%

basics

~20 s

macOS still ships bash 3.2 as /bin/bash, and associative arrays arrived in bash 4.0, so declare -A is simply not a valid option there. Options: run the script under a newer bash the developer installed, guard on BASH_VERSINFO and fail fast, or restructure to avoid the map.

open as a page

A bash script needs the directory it lives in so it can load a file next to itself. Why is `dirname "$0"` unreliable, and what does `${BASH_SOURCE[0]}` give you instead?

level: seniorimportance: should knowfreq 50%

basics

~20 s

$0 is the name the shell was invoked with, so it points at the caller when a file is sourced and may be a symlink or a relative path. ${BASH_SOURCE[0]} is the path of the file whose code is executing, which is correct whether the file is run or sourced.

open as a page

A cleanup step runs `rm -rf "$dir"/*` but `.env` and `.cache` survive inside $dir, so a colleague adds `rm -rf "$dir"/.*`. Explain both behaviours in terms of bash pathname expansion, and give a safe fix.

level: seniorimportance: should knowfreq 44%

basics

~20 s

A leading dot must be matched explicitly, so * never expands to hidden names and they survive the cleanup. The proposed .* is worse: it also matches . and .., putting the parent directory on the command line. Use shopt -s dotglob, or remove and recreate the directory.

open as a page

A reviewer on your team insists that every expansion in a bash script be double-quoted, and a colleague objects that some of them cannot possibly break. In which bash contexts is an unquoted expansion genuinely safe, and would you still require the quotes there?

level: seniorimportance: should knowfreq 35%

basics

~20 s

Four contexts perform no word splitting or globbing: the right-hand side of a simple assignment, inside [[ ]], the word after case, and arithmetic contexts such as (( )). Everywhere else quoting is mandatory — and most teams still quote unconditionally, because the exceptions are a memory test.

open as a page

showing 1–30 of 39