Bash — Quick Reference
Reference guide for the Bash language: data (variables, parameter expansion, arrays), control flow (if/case/for/while/until), functions, redirections, pipes, traps, signals, job control, debugging, and strict-mode discipline.
When to Use
- Writing short-to-medium scripts that glue together UNIX commands on Linux/macOS hosts
- Creating one-shot wrappers around external commands with flags and defaults
- Writing Makefile recipes, Dockerfile RUN lines, CI steps, or systemd ExecStart entries
- File/process plumbing where bash is the shortest path to the solution
- Any place where a few lines of glue are exactly the right shape
When NOT to Use
- You need rich data types (nested dicts, typed records, dataframes) — use Python instead
- The work needs unit tests with decent ergonomics — switch to a real language at this point
- You need cross-platform Windows support — use PowerShell instead
- The script handles untrusted input involving filenames, environment variables, or external command output — bash is hard to write securely
- You're hitting "if my script were 30% bigger I'd write it in Python" — just write it in Python
Mental model
Hold these seven sentences in your head and the rest of bash starts making sense:
- Bash is a command interpreter that happens to have a
programming language attached. Most of what bash "does" is
parse a line of input, expand variables and globs, and execute
the resulting command. The language constructs (
if,for, functions) are layered on top of that. - Everything is a string until proven otherwise. Bash has no
built-in concept of types beyond "string" and "the shell does
integer arithmetic in
((...))". Numbers are strings the shell coerces during arithmetic. Arrays are arrays of strings. - Words are split by
IFSafter expansion. When you typecmd $var, bash expands$var, then splits the result on whitespace (or whateverIFSis set to), andcmdreceives the resulting words as separate arguments. The way to prevent splitting is to quote:cmd "$var". - Variables are global by default. Inside a function, every
assignment reaches into the parent scope unless you mark it
local. This bites people from other languages constantly. - A pipeline of
cmd1 | cmd2runs each side in a subshell. Variables set inside the right side don't propagate back to the parent shell. Process substitution (< <(cmd)) avoids this. - The exit status, not the output, is bash's contract.
Programs return integers; scripts test those integers.
0is success, anything else is failure (and conventionally has a meaning —man sysexitsshows the table). - Best bash scripts use the language sparingly and let small UNIX tools do the heavy lifting. When you find yourself writing more than a few dozen lines of bash logic in a script, you've usually crossed the line where a real programming language would be a better fit.
The rest of this card is the long form of those seven sentences.
A note on conventions
Examples in this card use:
→for an interactive shell prompt, so the command is visually distinct from its output.#!/usr/bin/env bashas the canonical shebang for bash scripts (more portable than#!/bin/bashbecause it locatesbashthrough$PATH).- The "strict mode" preamble (
set -Eeuo pipefail; IFS=$'\n\t') in any non-trivial script — see the Strict mode section for the full discussion. [[ ... ]]rather than[ ... ]for tests — the double-bracket form is a bash keyword that doesn't word-split or glob-expand its arguments, eliminating a whole class of bugs.$(...)rather than backticks for command substitution — the modern form nests cleanly.
When something works only in bash 4+ or only in bash 5+, the card
calls it out. The default bash on macOS is 3.2 (the last
GPL-2-licensed release), so portability between Linux's
modern bash and macOS's 3.2 bash is a real concern; install a
modern bash via Homebrew (brew install bash) if you'll be
writing serious scripts on macOS.
Invoking bash
A bash process can come to exist in several different ways, and the kind of bash determines which startup files it reads, which features it enables, and how it interprets its arguments. Knowing the rules saves you from "this works when I type it but not when I run it as a script" surprises.
Interactive vs. non-interactive
A bash process is interactive if its standard input is a
terminal (or if -i was passed). Interactive shells display a
prompt, enable command-line editing (readline), enable job
control by default, and read your interactive startup files.
Non-interactive shells (a script, a bash -c '...', a remote ssh
command) skip almost all of that.
You can detect mode in a script:
case $- in
*i*) echo "interactive" ;;
*) echo "non-interactive" ;;
esac
$- is the set of currently-active option flags; i is set when
the shell is interactive.
Login vs. non-login
A bash process is a login shell if it was started in one of several specific ways:
- The first character of
argv[0]is-(whichlogin(1)does). - It was invoked as
bash -lorbash --login. - It's the shell SSH ran for an interactive session.
Non-login shells are everything else — typically the shells you get inside a terminal emulator after the first one, or any sub-shell.
The distinction matters because login and non-login shells read
different startup files. That, in turn, matters because the wrong
file getting your PATH change means "but I added it to my
.bashrc!" mysteries.
Startup files (the canonical order)
For an interactive login shell, bash reads:
/etc/profile- The first of
~/.bash_profile,~/.bash_login,~/.profilethat exists.
When the login shell exits, it reads ~/.bash_logout.
For an interactive non-login shell:
/etc/bash.bashrc(some distributions)~/.bashrc
For a non-interactive shell (a script):
- Neither, unless
BASH_ENVis set, in which case bash reads the file named by it.
The conventional pattern: put environment variables and PATH
modifications in ~/.bash_profile (so login shells get them);
put shell aliases, functions, completion, and prompt theming in
~/.bashrc. Then source ~/.bashrc from ~/.bash_profile so the
distinction matters less:
# in ~/.bash_profile
[[ -f ~/.bashrc ]] && . ~/.bashrc
# environment-only stuff goes here:
export PATH="$HOME/bin:$PATH"
export EDITOR=vim
Invoking the shell — common flags
bash # interactive non-login shell
bash --login # interactive login shell
bash -c 'cmd' # run cmd, then exit
bash -c 'cmd' name arg1 arg2 # run cmd with $0=name, $1=arg1, $2=arg2
bash file.sh # run a script
bash -x file.sh # run with xtrace (echo each command before running)
bash -n file.sh # syntax check; don't run
bash -v file.sh # verbose; print each line as read
bash -l # login mode (--login)
bash -i # interactive mode
bash -p # privileged mode: don't read $BASH_ENV / $ENV; ignore $SHELLOPTS
bash --noprofile # skip /etc/profile and ~/.bash_profile etc.
bash --norc # skip ~/.bashrc
bash --version # show version
bash --help # short flag summary
Useful in scripts:
bash -n script.sh— syntax check only. The single best thing you can run in a pre-commit hook for shell scripts (shellcheckis the second).bash -x script.sh— execution trace. Each command is printed to stderr before being run, with+prefixes indicating the nesting depth.
Shebangs
The first line of a script is the shebang; the kernel reads it to decide which interpreter to invoke. The two reasonable forms:
#!/usr/bin/env bash
— uses env to find bash in $PATH. More portable, especially
on systems where bash isn't at /bin/bash (some BSDs, macOS when
using a Homebrew bash).
#!/bin/bash
— hard-codes the path. Slightly faster (one less exec), and
deterministic. Fine for scripts that you know are running on
systems with bash at the canonical location.
A useful disclaimer: #!/bin/sh is not the same as
#!/bin/bash. On Debian/Ubuntu systems, /bin/sh is dash, a
much smaller POSIX-only shell. A script written for bash and
shebanged with /bin/sh will fail in subtle ways: arrays don't
work, [[ ]] doesn't exist, $'string' ANSI-C quoting doesn't
exist, parameter expansion is more limited, and so on.
Exit status and the meaning of zero
Every command returns an integer status to its parent. By convention:
0— success.- Non-zero — failure of some kind. Programs sometimes use specific
numbers to mean specific things (
grepreturns1for "no matches",2for "error";diffreturns0for "files match",1for "differ",2for "error"). 126— command found but not executable.127— command not found.128 + N— terminated by signal N (soCtrl-Cis130,SIGSEGVis139,SIGTERMis143).
The shell exposes the previous command's exit status in $?:
ls /etc/passwd
echo $? # 0
ls /no/such/file
echo $? # 2
Inside a pipeline, $? is the last command's exit status.
$PIPESTATUS is an array of the exit statuses of every command
in the pipeline:
foo | bar | baz
echo "${PIPESTATUS[@]}" # exit statuses of foo, bar, and baz
For scripts where you want any failure in a pipeline to count,
set -o pipefail makes the pipeline's exit status the leftmost
non-zero. See the Strict mode
section.
Conventional exit codes from sysexits.h
The historical <sysexits.h> defines a small vocabulary of exit
codes worth knowing:
| Code | Name | Meaning |
|---|---|---|
| 0 | EX_OK | Success |
| 64 | EX_USAGE | Command-line usage error |
| 65 | EX_DATAERR | Data-format error in user input |
| 66 | EX_NOINPUT | Cannot open input |
| 67 | EX_NOUSER | Addressee unknown |
| 68 | EX_NOHOST | Host name unknown |
| 69 | EX_UNAVAILABLE | Service unavailable |
| 70 | EX_SOFTWARE | Internal software error |
| 71 | EX_OSERR | OS error (e.g. fork failed) |
| 72 | EX_OSFILE | System file missing |
| 73 | EX_CANTCREAT | Cannot create output file |
| 74 | EX_IOERR | I/O error |
| 75 | EX_TEMPFAIL | Temporary failure; retry |
| 76 | EX_PROTOCOL | Remote protocol error |
| 77 | EX_NOPERM | Permission denied |
| 78 | EX_CONFIG | Configuration error |
Most scripts get away with 0/1/2, but for tools that other scripts will consume, picking specific codes from this table makes debugging dramatically easier.
Quoting
The shell parses every line you type by walking through it left-to-right and applying rules about which characters mean what. Most "weird bash bugs" come down to misunderstanding which characters need to be quoted in which contexts. The good news is that the rules are simple and exhaustive: there are exactly three quoting mechanisms.
The three quoting mechanisms
Backslash
A backslash before a character makes that character literal — strips its special meaning to the shell.
echo \$HOME # prints $HOME literally
echo \" # prints "
echo a\ b # echoes "a b" as TWO arguments? No — as ONE: "a b". The space is escaped, not a separator.
echo \\ # prints a single backslash
A backslash at the end of a line escapes the newline, continuing the command on the next line:
ls -l --color=auto \
--group-directories-first \
/etc
This is the standard way to wrap a long command across several lines.
Single quotes
Inside '...', every character is literal. Even backslashes.
Even dollar signs. Even backticks. The only character that
can't appear inside single quotes is another single quote — there's
no way to escape it. To include a literal single quote, end the
quoted string, escape a quote, and start a new quoted string:
echo 'it'''s a trap' # prints: it's a trap
Single quotes are the safest choice for any string with shell metacharacters. When you don't need variable interpolation or command substitution, single-quote.
Double quotes
Inside "...", most characters are literal, but the following
remain active:
$— variable expansion ($VAR,${VAR}), parameter expansion (${VAR:-default}), command substitution ($(...)), arithmetic expansion ($((...))).- ``` — command substitution (legacy form).
\— escape character. Inside double quotes, backslash is special **only before**$,\``,",`, and newline."\d"is the literal two-character sequence\d, but"\$"is a literal$.
echo "Hello, $USER" # interpolated
echo "Today is $(date +%F)" # command substitution works
echo "Cost: \$5.00" # literal dollar sign
echo "Path: $HOME" # variable expansion
echo "He said \"hi\"" # escaped double quote
Double quotes do not prevent word splitting between quoted strings:
var="a b c"
echo "$var" # one argument: "a b c"
echo $var # three arguments: "a", "b", "c" — word-split
echo "${arr[@]}" # one quoted argument per array element (the way you almost always want)
echo "${arr[*]}" # one argument; elements joined by IFS[0]
The "always quote your variables" discipline is real:
"$VAR" for a single variable, "${arr[@]}" for an array,
"$@" for "all positional arguments preserving boundaries".
ANSI-C quoting ($'...')
A bash extension. $'...' is like single-quoting but with
backslash escapes interpreted:
echo $'hello\tworld' # tab between
echo $'line1\nline2' # newline embedded
echo $'\u00e9' # Unicode é (bash 4.2+)
IFS=$'\n\t' # set IFS to newline and tab — strict-mode preamble
The supported escapes: \a, \b, \e, \f, \n, \r, \t,
\v, \\, ', \", \?, \nnn (octal), \xHH (hex),
\uHHHH (Unicode), \UHHHHHHHH (32-bit Unicode), \cX
(control-X).
Locale-aware quoting ($"...")
$"..." is like double-quoting but the string is looked up in
the current locale's translation catalog. Used for
internationalisation. You'll almost never need this in
infrastructure code.
Quoting in scripts (a checklist)
A short procedure for "did I quote this right?":
- Variables expanded for use as one argument:
"$var". - Variables expanded for use as a list of arguments (rarely
what you want): bare
$varwithIFSset appropriately. - Arrays expanded as separate arguments:
"${arr[@]}". - Literal strings with no metacharacters needed: single quotes.
- Strings that interpolate variables but don't have shell metacharacters: double quotes around the whole string.
- Strings that contain backslash escapes:
$'...'(ANSI-C). - Strings that contain both literal
$and interpolated variables: mix quoted regions:echo 'Cost: $5 for '"$USER"
Common quoting mistakes
files=*.log
for f in $files; do ...; done # WRONG: $files is a string "*.log"; it's the for that does the glob
for f in *.log; do ...; done # CORRECT: glob expansion happens in the for itself
if [ $var = "value" ]; then # WRONG-ish: if $var is empty, this becomes [ = value ], a syntax error
if [ "$var" = "value" ]; then # better: empty $var becomes [ "" = value ], well-formed
if [[ $var = "value" ]]; then # BEST: [[...]] doesn't word-split, doesn't glob
echo "$(rm -rf /)" # the $(...) actually runs even though the result will be quoted
ls "$(echo $files)" # double-evaluation: $files word-splits inside echo, then the result is one arg to ls
Quoting and the command line
When you type interactively, the shell parses your line, expands
everything, and runs the command. When you put a script in a
file, the same parsing happens — but the file's bytes are read by
bash, not your terminal. Bash neither knows nor cares about your
terminal's encoding when reading a script. If you have \303\251
in your script source, bash sees it as those two literal bytes
(which form é in UTF-8). The terminal just affects how bash
displays its output back to you.
The practical implication: your script's behavior is determined
by its source bytes, not by your terminal. If you copy a script
from a webpage and pasting introduces "smart quotes" (\u201c
and \u201d), bash will see those as literal Unicode characters
that don't have any special meaning, and you'll get cryptic
syntax errors. Always paste through a tool that doesn't do
auto-correct (vim, cat > file <<'EOF', etc.) when bringing
shell snippets into a file.
Variables
A variable in bash is a name bound to a string. By default, every
variable is global — accessible from anywhere in the same shell
process. Functions can declare variables local to confine them
to the function's scope.
Assignment
name=value # assign — NO spaces around =
name="value" # quoted; useful when value has whitespace
name='value' # single-quoted; no expansion in the value
unset name # remove the variable
readonly name # make immutable; further assignments fail
declare -r name # same
The "no spaces around =" rule is critical. name = value parses
as a command named name with two arguments — almost never what
you want. The error message is helpful: name: command not found.
Multiple assignments on one line:
a=1 b=2 c=3
These are evaluated left-to-right, all at the same shell-prefix level. They're equivalent to three separate assignment statements.
You can also pass variable assignments as a prefix to a command, which sets them only for the command's environment:
PATH=/usr/local/bin:$PATH mycommand # only mycommand sees the modified PATH
LC_ALL=C sort file # the canonical "sort in byte order" invocation
NODE_ENV=production npm start
Reading a variable
echo $var # expand var
echo ${var} # same, with explicit braces
echo "$var" # quoted (recommended)
echo "${var}lovelace" # braces let you butt text up against the var
The braces are optional unless you need to butt the variable
against text that could be confused for part of the name. $varX
expands the variable varX (an underscore-letter-digit sequence is
a valid name), but ${var}X expands var followed by a literal
X.
Variable scope
By default, every assignment creates or modifies a shell variable visible to the current shell only. To make a variable available to child processes (programs the shell exec's), export it:
export PATH=/usr/local/bin:$PATH # commonly seen
PATH=/usr/local/bin:$PATH; export PATH # same effect
declare -x PATH=/usr/local/bin:$PATH # same
export -p shows everything currently exported. printenv and
env show the same.
To remove a variable from the environment without unsetting it locally:
export -n VAR
Variables set without export are visible only to the current
shell, not to commands it runs:
local_var=hello
bash -c 'echo $local_var' # prints empty line — the child shell didn't inherit it
Inside functions: local
Inside a function, local declares a variable scoped to the
function (and the functions it calls). Without local, you'd
modify the parent shell's i or name, which is almost certainly
a bug.
greet() {
local name="$1" # local to greet
echo "Hello, $name"
}
count_files() {
local i count=0
for ((i=0; i<$#; i++)); do
[[ -f "${!i}" ]] && ((count++))
done
echo "$count"
}
local is itself a builtin command and can take any of the
declaration flags that declare does:
local -i n=42 # integer
local -a arr=(a b c) # indexed array
local -A map=([k1]=v1) # associative array
local -r CONST=immutable # readonly within the function
local only works inside functions; using it at the top level of
a script is a syntax error.
Predefined and special variables
Bash maintains a set of automatic variables. The most useful:
| Variable | Meaning |
|---|---|
$0 |
Script name (or bash for an interactive shell) |
$1, $2, … |
Positional parameters (script or function arguments) |
${10}, ${11}, … |
Tenth and beyond — must use braces |
$# |
Number of positional parameters |
$@ |
All positional parameters as separate words. Use "$@" to preserve word boundaries. |
$* |
All positional parameters as one string, joined by IFS[0]. |
$? |
Exit status of the last foreground command. |
$$ |
PID of the current shell. |
$! |
PID of the most recent background command. |
$_ |
Last argument of the previous command (interactive). |
$- |
Currently active option flags (e.g. himBHs). |
$BASH |
Path to the bash binary that's running. |
$BASH_VERSION |
Version string. |
$BASH_VERSINFO |
Array of version components: [major, minor, patch, build, release, machine]. |
$BASH_SOURCE |
Array of source filenames; ${BASH_SOURCE[0]} is the current file. |
$LINENO |
Current line number in the script. |
$FUNCNAME |
Array; ${FUNCNAME[0]} is the current function. |
$RANDOM |
A random integer 0..32767, fresh each read. |
$EPOCHSECONDS, $EPOCHREALTIME |
Bash 5+: seconds since epoch / fractional seconds. |
$SECONDS |
Number of seconds since the shell started. Assigning to it resets the counter. |
$PIPESTATUS |
Array of exit statuses of each command in the most recent pipeline. |
$IFS |
Input Field Separator — controls word splitting. Default: space, tab, newline. |
$PATH |
Colon-separated directories searched for executables. |
$HOME |
Current user's home directory. |
$USER, $LOGNAME |
Username. |
$SHELL |
The user's preferred shell (NOT necessarily the running shell). |
$PWD |
Current working directory. |
$OLDPWD |
Previous working directory (used by cd -). |
$HOSTNAME |
Machine hostname. |
$HOSTTYPE |
Architecture (e.g. x86_64). |
$OSTYPE |
OS name (e.g. linux-gnu, darwin22.0). |
$LANG, $LC_* |
Locale variables. |
$TZ |
Timezone. |
$TERM |
Terminal type. |
$EDITOR, $VISUAL |
Default editors. |
$PAGER |
Default pager. |
The declare builtin
declare (synonym typeset) sets variable attributes:
declare -i n=42 # integer (arithmetic context for assignments)
declare -a arr # indexed array
declare -A map # associative array (bash 4+)
declare -r const=42 # readonly
declare -x VAR=42 # exported
declare -n ref=target # nameref — alias for another variable (bash 4.3+)
declare -f # show all defined functions
declare -F # show function names only
declare -p # print everything in re-importable form
declare -p VAR # show one variable's full state
declare flags can combine: declare -airx VAR makes an integer
indexed array, readonly, exported (though such combinations are
rare).
declare -i is interesting: it puts the variable in
integer mode. Subsequent assignments are evaluated as
arithmetic expressions:
declare -i n
n="2 + 3" # n becomes 5
n=5*2 # n becomes 10
n="hello" # n becomes 0
This is occasionally useful but trips people up — declare a variable as integer for clarity and be sure that's what you want.
Indirect references (namerefs and ${!var})
Sometimes you want one variable to name another. Two mechanisms:
Indirect expansion
target=hello
ref=target
echo "${!ref}" # prints: hello (the value of $target)
${!var} expands to the value of the variable whose name is in
var. Useful for one-off indirection.
Namerefs (bash 4.3+)
target=hello
declare -n ref=target
echo "$ref" # prints: hello
ref="goodbye" # modifies target
echo "$target" # prints: goodbye
unset -n ref # removes the nameref (without removing target)
A nameref is a permanent alias. Useful inside functions for "pass a variable by reference":
populate() {
local -n out=$1 # out is whatever variable name was passed
out=("apple" "banana" "cherry")
}
populate fruits
echo "${fruits[@]}" # apple banana cherry
This is the bash way to write functions that "return" arrays —
something you can't do directly through return (which is for
exit statuses) or through stdout (which would lose array
boundaries).
Variable substitution and parameter expansion
This is one of bash's most powerful features. The general form:
${parameter} # plain expansion
${parameter:-default} # use parameter if set+non-empty; else use default
${parameter-default} # use parameter if set; else use default
${parameter:=default} # assign default to parameter if unset+empty; then expand
${parameter=default} # assign default if unset; then expand
${parameter:?error_message} # if unset+empty, print error and exit (script) / abort (interactive)
${parameter?error_message} # same but only if unset
${parameter:+alt_value} # if set+non-empty, use alt_value; else empty
${parameter+alt_value} # if set, use alt_value; else empty
Concrete examples:
echo "${USER:-anonymous}" # use USER, or "anonymous" if unset/empty
: "${PORT:=8080}" # set PORT to 8080 if unset/empty (the leading : is a no-op command)
echo "${MUST:?must be set}" # error and exit if MUST is unset/empty
echo "${ENABLED:+--enable}" # if ENABLED is set+non-empty, expand to "--enable", otherwise empty
The : variants treat empty strings the same as unset. The
non-: variants distinguish them — useful when an empty string
is a valid value.
Length, substring, search/replace
${#parameter} # length of value
${parameter:offset} # substring from offset to end
${parameter:offset:length} # substring of given length
${parameter#pattern} # strip shortest match of pattern from START
${parameter##pattern} # strip longest match of pattern from START
${parameter%pattern} # strip shortest match of pattern from END
${parameter%%pattern} # strip longest match of pattern from END
${parameter/pattern/replacement} # replace FIRST match
${parameter//pattern/replacement} # replace ALL matches
${parameter/#pattern/replacement} # match must be at START
${parameter/%pattern/replacement} # match must be at END
${parameter^} # uppercase first character (bash 4+)
${parameter^^} # uppercase all (bash 4+)
${parameter,} # lowercase first character (bash 4+)
${parameter,,} # lowercase all (bash 4+)
${parameter@operator} # bash 4.4+: Q (quote), E (expand escapes), P (prompt expansion), A (assignment), a (attribute)
Concrete worked examples on the file report.tar.gz:
f="report.tar.gz"
echo "${#f}" # 13 — length
echo "${f%.gz}" # report.tar — strip shortest match of .gz from end
echo "${f%%.*}" # report — strip longest match of .* from end
echo "${f#*.}" # tar.gz — strip shortest match of *. from start
echo "${f##*.}" # gz — strip longest match of *. from start (the file extension)
echo "${f/tar/TGZ}" # repor.TGZ.gz — replace first match
echo "${f//[aeiou]/_}"# r_p_rt.t_r.gz — replace ALL vowels
echo "${f:0:6}" # report — substring of length 6 from offset 0
echo "${f:7}" # tar.gz — from offset 7 to end
echo "${f^^}" # REPORT.TAR.GZ
Patterns inside ${var#pat} and friends use globs, not regex.
The metacharacters are *, ?, [set], [!set]. Extended
globs (@(...), +(...), *(...), ?(...), !(...)) work
when shopt -s extglob is set.
basename and dirname in pure bash
path="/var/log/syslog.1.gz"
filename="${path##*/}" # syslog.1.gz — same as basename
dir="${path%/*}" # /var/log — same as dirname
ext="${path##*.}" # gz — file extension
stem="${filename%.*}" # syslog.1 — strip last extension
Doing this in pure bash avoids the cost of forking basename and
dirname. For one-offs the cost is negligible; for tight loops
it matters.
Indirection through expansion
${!prefix*} # names of variables that start with prefix
${!prefix@} # same, with quoting context
So if you have FOO_USER, FOO_PASS, FOO_PORT, ${!FOO_*}
expands to those names.
Arrays
Bash supports two array types: indexed arrays (numeric keys starting from 0) and associative arrays (string keys, like a dict or hashmap). Indexed arrays work in any bash; associative arrays require bash 4 or later. macOS ships bash 3.2, so a script that relies on associative arrays will fail there unless you install a modern bash via Homebrew.
Indexed arrays
Creating
arr=(a b c) # literal
arr=("first" "second" "third") # with quoted elements
arr=() # empty array
declare -a arr # declare without assigning
arr=([0]=a [3]=d [5]=f) # explicit indices — sparse array
arr+=("d") # append
arr+=("e" "f" "g") # append several
mapfile -t lines < file # bash 4+: read each line into arr (no trailing \n)
readarray -t lines < file # synonym
mapfile (also called readarray) is the cleanest way to slurp a
file into an array, one line per element.
Accessing
echo "${arr[0]}" # first element
echo "${arr[1]}" # second element
echo "${arr[-1]}" # last element (bash 4.3+)
echo "${arr[@]}" # all elements as separate words
echo "${arr[*]}" # all elements as one string, joined by IFS[0]
echo "${#arr[@]}" # count
echo "${!arr[@]}" # all defined indices
echo "${arr[@]:1:2}" # slice: 2 elements starting at index 1
The crucial distinction: "${arr[@]}" (quoted) expands each
element as a separate quoted word — this is almost always what
you want. "${arr[*]}" joins everything with the first character
of IFS (a space by default) into one word.
Compare:
arr=("one" "two with space" "three")
for x in "${arr[@]}"; do echo "<$x>"; done
# <one>
# <two with space>
# <three>
for x in "${arr[*]}"; do echo "<$x>"; done
# <one two with space three>
Modifying
arr[1]="new value" # replace element 1
arr[20]="far away" # create a sparse element
unset arr[2] # delete element 2 (leaves a hole; the array is sparse now)
arr=("${arr[@]}") # collapse holes by re-creating
Iteration
for x in "${arr[@]}"; do
echo "$x"
done
for i in "${!arr[@]}"; do
echo "$i: ${arr[$i]}"
done
for ((i=0; i<${#arr[@]}; i++)); do
echo "$i: ${arr[$i]}"
done
The first form iterates values; the second iterates indices and
gives you both index and value; the third uses C-style for and
is dense if you really need numeric iteration with arithmetic.
Common operations
# join with a delimiter (printf trick)
printf -v joined '%s,' "${arr[@]}"
joined="${joined%,}" # strip trailing comma
# alternative join via IFS
IFS=, joined="${arr[*]}"
# split a string into an array on a delimiter
IFS=':' read -ra parts <<< "$PATH"
for p in "${parts[@]}"; do echo "$p"; done
# does the array contain a value?
contains() {
local needle=$1; shift
for x; do [[ $x == "$needle" ]] && return 0; done
return 1
}
contains "two" "${arr[@]}" && echo found
# remove duplicates while preserving order
declare -A seen
unique=()
for x in "${arr[@]}"; do
if [[ -z ${seen[$x]:-} ]]; then
seen[$x]=1
unique+=("$x")
fi
done
Associative arrays (bash 4+)
declare -A map # MUST declare first; can't auto-create
map=([key1]=value1 [key2]=value2)
map[name]="alice"
map[count]=42
echo "${map[name]}" # access by key
echo "${map[@]}" # all values
echo "${!map[@]}" # all keys
echo "${#map[@]}" # number of entries
unset 'map[key1]' # remove (must quote — globbing happens)
unset map # remove the whole array
# iterate keys
for k in "${!map[@]}"; do
echo "$k -> ${map[$k]}"
done
# test existence
if [[ -v map[name] ]]; then ... # bash 4.2+
if [[ -n ${map[name]+set} ]]; then ... # portable variant
Notes:
declare -Ais required. Auto-creating an associative array viaarr[key]=valuedoesn't work — bash will treat it as an indexed array with a key of 0 (because the stringkeyevaluates to 0 in arithmetic context).- Keys are arbitrary strings, including the empty string.
- Iteration order is unspecified. Sort the keys explicitly if you need ordered output.
unset 'map[key]'— the quotes matter. Without them, ifkeycontains a*, the shell will glob-expand it.
Common array recipes
# read a CSV-like file into associative arrays keyed by id
declare -A user_email user_role
while IFS=, read -r id email role; do
user_email[$id]=$email
user_role[$id]=$role
done < users.csv
# sum a column from a file
total=0
while IFS=, read -r _ amount _; do
total=$((total + amount))
done < orders.csv
echo "$total"
# count occurrences
declare -A counts
for word in "$@"; do
counts[$word]=$(( ${counts[$word]:-0} + 1 ))
done
for k in "${!counts[@]}"; do
printf '%5d %s\n' "${counts[$k]}" "$k"
done | sort -rn
Tests, conditions, and [[ ... ]]
A condition in bash is anything with an exit status. The shell
treats exit 0 as true and any non-zero as false. So if cmd; then ... runs the then body if cmd exits zero. The [[ ... ]] construct (and the older [ ... ] and test) are just
commands that exit with the appropriate status.
[[ ... ]] is the modern form
[[ ... ]] is a bash-specific keyword (not a regular command).
Compared to the older [ ... ] (which is /usr/bin/test):
- It does not word-split unquoted variables, so
[[ $var = string ]]works even if$varis empty. - It does not glob-expand unquoted patterns on the right side
of
=and==. - It supports
&&,||,<,>directly without escaping. - It has additional operators that
[ ]doesn't:=~(regex match),<and>for string comparison. - Inside
[[ ... ]], the right side of==and=is a glob pattern (unless quoted), and=~is a regex.
The general rule: use [[ ... ]] in bash scripts. Use [ ... ]
or test only if you need POSIX portability (e.g. a script that
needs to work in dash).
File tests
[[ -e path ]] # path exists (any type)
[[ -f path ]] # exists and is a regular file
[[ -d path ]] # exists and is a directory
[[ -L path ]] # exists and is a symbolic link (alternative: -h)
[[ -h path ]] # same as -L
[[ -p path ]] # named pipe (FIFO)
[[ -S path ]] # socket
[[ -b path ]] # block device
[[ -c path ]] # character device
[[ -r path ]] # readable by the current user
[[ -w path ]] # writable
[[ -x path ]] # executable
[[ -s path ]] # exists and is non-empty
[[ -O path ]] # owned by the current user
[[ -G path ]] # group ownership matches the current user's effective group
[[ -k path ]] # has the sticky bit
[[ -u path ]] # has the setuid bit
[[ -g path ]] # has the setgid bit
[[ -N path ]] # has been modified since last read
[[ -t fd ]] # file descriptor fd refers to a terminal
[[ a -nt b ]] # file a is newer than b
[[ a -ot b ]] # file a is older than b
[[ a -ef b ]] # a and b refer to the same inode (same file)
String tests
[[ -z "$s" ]] # string is empty
[[ -n "$s" ]] # string is non-empty
[[ "$a" = "$b" ]] # equal (= and == are equivalent in [[ ]])
[[ "$a" != "$b" ]]# not equal
[[ "$a" < "$b" ]] # lexicographic less-than (only inside [[ ]])
[[ "$a" > "$b" ]] # lexicographic greater-than
[[ "$file" = *.log ]] # GLOB MATCH (right side is a glob pattern)
[[ "$file" == *.log ]] # same
[[ "$file" != *.tmp ]] # negated glob match
[[ "$line" =~ ^[0-9]+$ ]] # REGEX MATCH (extended regex)
# variable existence (bash 4.2+)
[[ -v VAR ]] # VAR is set (even if empty)
[[ ! -v VAR ]] # VAR is unset
A subtle gotcha: in [[ "$file" == *.log ]], the right side must
be unquoted for glob behaviour. Quote it and the = becomes
literal-string equality:
[[ "$file" == *.log ]] # glob: matches anything ending in .log
[[ "$file" == "*.log" ]] # literal: matches the exact string "*.log"
The =~ operator does extended regex matching. Capture groups
end up in $BASH_REMATCH:
if [[ $line =~ ^([A-Z]+):([0-9]+)$ ]]; then
echo "tag: ${BASH_REMATCH[1]}"
echo "number: ${BASH_REMATCH[2]}"
fi
BASH_REMATCH[0] is the whole match, [1] is the first capture
group, and so on.
Numeric tests
Two equivalent options:
# inside [[ ]], use the dash-letter operators
[[ "$n" -eq 5 ]] # equal
[[ "$n" -ne 5 ]] # not equal
[[ "$n" -lt 5 ]] # less than
[[ "$n" -le 5 ]] # less than or equal
[[ "$n" -gt 5 ]] # greater than
[[ "$n" -ge 5 ]] # greater than or equal
# inside (( )), use familiar operators
(( n == 5 )) # equal
(( n != 5 )) # not equal
(( n < 5 )) # less than
(( n <= 5 )) # less than or equal
(( n > 5 )) # greater than
(( n >= 5 )) # greater than or equal
(( n > 0 && n < 100 )) # combined
(( ... )) is arithmetic context — its contents are an
arithmetic expression. Variable expansion happens automatically
without $. Exit status is 0 if the expression is non-zero, 1
otherwise (yes, "non-zero is true" is reversed from the usual
shell convention — because in arithmetic, non-zero is the
mathematical "true").
Boolean composition
[[ -f file && -r file ]] # both
[[ -f file || -L file ]] # either
[[ ! -f file ]] # negation
[[ -f a && ( -r a || -w a ) ]] # grouping with ()
These work inside [[ ]] and (( )). Outside, you can chain
commands with && and ||:
[[ -f file ]] && cmd1 # run cmd1 only if file exists
[[ ! -f file ]] || cmd1 # equivalent: run cmd1 if file exists
[[ -f file ]] && echo found || echo missing
The last form is the bash equivalent of a ternary, but with a
trap: if echo found itself fails (which it almost never does
but theoretically can), the else branch runs too. Prefer an
explicit if/else for non-trivial cases.
Old-style [ ... ] and test
The older form, equivalent to invoking test (which is also
available as /usr/bin/[):
if [ "$var" = "value" ]; then ...
if test "$var" = "value"; then ...
[ -f file ] && cmd
Differences from [[ ... ]]:
- Word-splits and glob-expands unquoted variables — quoting is
mandatory:
[ "$var" = "value" ], never[ $var = "value" ]. - No
&&/||(use-aand-o, but they're awkward and fragile — better to chain with shell&&and||). - No
=~regex. - No
</>string comparison without escaping ([ "$a" \< "$b" ]).
In modern bash, `[[ .
…(truncated)