Andrew Mercer
on this page

Bash (Bourne Again SHell) was written by Brian Fox for the GNU Project in 1989 as a free replacement for the Bourne shell (sh). It is maintained by Chet Ramey at Case Western Reserve University, who has been the primary maintainer since the early 1990s. It is the default login shell on most Linux distributions and was the default on macOS until Catalina switched to zsh in 2019.

Bash is four things at once: a command interpreter, a scripting language, a job controller, and a readline-based interactive environment. Knowing which role it is playing at a given moment explains most of its surprising behaviour.

The first half of this page covers how bash works; the second half is practical recipes. For other shells, see sh, dash, ash, zsh, and fish.

Architecture

Every line of input goes through lexing, parsing, expansion, and execution. Expansion happens in this order:

  1. Brace expansion: {a,b,c}, {1..10}
  2. Tilde, parameter, arithmetic, and command substitution, plus process substitution: ~, $VAR, ${VAR:-default}, $(( expr )), $(command), <(command). These are performed in a single left-to-right pass, not as separate ordered steps.
  3. Word splitting on $IFS, applied only to the results of unquoted expansions from step 2
  4. Pathname expansion (globbing): *, ?, [...]
  5. Quote removal

Many subtle bugs come from wrong assumptions about this order. Brace expansion runs before variables are expanded, so {1..$n} does not work; use seq 1 "$n" or a C-style for (( i=1; i<=n; i++ )) loop. Word splitting and globbing run on unquoted expansions, which is why "$var" should be quoted by default.

Builtins: what they are and why they exist

A builtin is a command implemented inside the bash process rather than as a separate executable on disk. Builtins exist for two reasons. Either the operation is impossible as an external process (a child process cannot change its parent's working directory, so cd must be a builtin), or forking a process would be too slow for something called constantly.

When you type a command, bash resolves the name in this order:

  1. Aliases
  2. Functions
  3. Builtins
  4. External commands, found via the hash table and then $PATH

In POSIX mode, special builtins (export, set, eval, exec, and a few others) are found before functions.

Force the external version with a full path (/bin/echo) or command echo. Force the builtin with builtin echo. type -a echo shows every match.

Reserved words are not builtins

if, then, elif, else, fi, case, esac, for, select, while, until, do, done, in, function, time, !, {, }, [[, and ]] are keywords parsed by the shell grammar, not commands. type if reports if is a shell keyword. List them with compgen -k, and list builtins with compgen -b. Don't use either as function or variable names.

Builtins by category

Navigation and environment - cd: change the current directory (must be a builtin) - pwd: print the working directory (/bin/pwd also exists) - export: mark variables for export to child processes - unset: remove variables or functions - set: set shell options or positional parameters - declare / typeset: declare variables with attributes (integer, array, readonly, etc.) - local: declare function-scoped variables - readonly: mark variables as read-only - shift: shift positional parameters left - Not builtins: env (coreutils) runs a command with a modified environment

I/O - echo: print arguments (behaviour differs from /bin/echo on -e) - printf: formatted output; more portable than echo - read: read a line from stdin into variables - readarray / mapfile: read lines from stdin into an array

Control flow (builtins that work with the keywords above) - break / continue: loop control - return: return from a function or sourced script - exit: exit the shell with a status

Test and evaluation - test / [: evaluate conditional expressions (POSIX) - [[ ]]: extended conditional expression (bash keyword, not POSIX) - (( )): arithmetic evaluation (a compound command) - $[ ]: obsolete arithmetic syntax; use $(( )) - true / false: return 0 or 1 (coreutils versions also exist; the builtins shadow them)

Job control - jobs: list active jobs - fg / bg: move a job to the foreground or background - wait: wait for a job or process to finish - kill: send a signal (/bin/kill from util-linux or procps-ng also exists; the builtin can take job specs like %1) - disown: remove a job from the job table so it survives the shell exiting - suspend: suspend the shell

Functions and sourcing - source / .: execute a script in the current shell context (must be a builtin) - function: keyword for defining a function; name() { ...; } is the POSIX form

History and readline - history: display or manipulate the command history - fc: edit and re-execute history entries - bind: bind a readline key sequence to a function

Shell introspection - type: describe how a name would be interpreted (alias, keyword, function, builtin, file) - command: run a command, bypassing aliases and functions - builtin: run a shell builtin explicitly - enable: enable or disable builtins (enable -n echo makes echo resolve to /bin/echo) - help: help for builtins - caller: the context of the current subroutine call (useful for stack traces) - hash: remember or report full paths of commands (hash -r after moving a binary)

Options and debugging - set -e: exit on error - set -u: treat unset variables as errors - set -x: print commands as they execute (debug trace) - set -o pipefail: a pipeline fails if any command in it fails - shopt: set or unset bash-specific options (separate from set) - trap: set signal handlers or cleanup functions (trap cleanup EXIT) - times: print accumulated user and system times

Misc - alias / unalias: define and remove aliases - getopts: parse short options in scripts - eval: evaluate a string as a shell command (use carefully) - exec: replace the shell with a command, or redirect the shell's own file descriptors - ulimit: set or display resource limits - umask: set the file creation mask - compgen / complete / compopt: programmable completion - Not builtins: xargs (findutils)

Key behavioural distinctions

[ vs [[: [ is a POSIX builtin that also exists as /usr/bin/[. [[ is a bash keyword and handles more cases safely. Unquoted variables don't word-split inside [[, regex matching with =~ works, and && / || work directly instead of -a / -o. Prefer [[ in bash-only scripts and [ in scripts targeting /bin/sh or dash.

echo portability: echo -e is bash behaviour. Dash's echo prints -e literally and always interprets escapes, and POSIX does not define -e at all. Use printf in anything meant to run under sh.

source vs .: identical in bash. . is the POSIX form and works in sh; source works in bash and zsh.

declare vs local: local only works inside functions. declare inside a function is equivalent to local, also works at global scope, and supports type attributes like -i (integer), -a (indexed array), -A (associative array), -r (readonly), -x (export), and -g (force global from inside a function).

(( )) vs $(( )): (( expr )) is a command that returns exit status 0 if the result is non-zero, which makes it useful in if and while. $(( expr )) is an expansion that substitutes the numeric result. Under set -e, a bare (( count++ )) when count is 0 exits the script, because the expression evaluates to 0.

Startup files and execution modes

Bash reads different files depending on how it is invoked, which explains a large class of "works in my terminal but not in my script" bugs.

Mode Files read
Interactive login shell /etc/profile, then the first found of ~/.bash_profile, ~/.bash_login, ~/.profile; ~/.bash_logout on exit
Interactive non-login shell ~/.bashrc (Debian/Ubuntu also read /etc/bash.bashrc first; Fedora/RHEL source /etc/bashrc from ~/.bashrc)
Non-interactive (script) Only the file named in $BASH_ENV, if set
Invoked as sh Login: /etc/profile, ~/.profile. Interactive: the file named in $ENV

Most distributions' default ~/.bash_profile sources ~/.bashrc, so login shells end up with both. The practical consequence: anything in ~/.bashrc is not available in cron jobs, systemd units, or bash script.sh unless the script sources it explicitly.

Versions

The current release is 5.3 (July 2025). Check with bash --version or echo "$BASH_VERSION". Most distributions ship 5.x. macOS still ships 3.2 because bash 4.0 moved to GPLv3, and Apple has not updated it in nearly two decades, which is why brew install bash is a common first step on a Mac. Scripts that must run on stock macOS can't use anything from 4.0 onwards.

Notable milestones: - 4.0: associative arrays (declare -A), mapfile / readarray, case modification (${var,,}, ${var^^}), globstar (**), coproc, |& - 4.2: lastpipe option, declare -g, negative array subscripts, \u Unicode escapes - 4.3: namerefs (declare -n), wait -n - 4.4: ${parameter@operator} transformations (@Q, @E, @P, @A, @a) - 5.0: $EPOCHSECONDS, $EPOCHREALTIME, $BASH_ARGV0 - 5.1: $SRANDOM, wait -p, @U / @u / @L / @K transformations - 5.2: @k transformation, patsub_replacement and globskipdots shell options - 5.3: in-shell command substitution (${ cmd; } captures output without forking a subshell; ${| cmd; } returns $REPLY), GLOBSORT variable, read -E (readline completion while reading), source -p PATH, compgen -V (results into a variable)

Safer scripts

#!/bin/bash
set -euo pipefail            # exit on error, on unset variables, and on failures inside pipelines
IFS=$'\n\t'

Quote every variable expansion ("$var") and use -- before user-supplied file names. Run scripts through ShellCheck (shellcheck script.sh) to catch quoting and portability bugs.

History

history | grep systemctl             # find an old command
!1864                                # re-run history entry 1864
fc -s 1864                           # same, via fc
!!                                   # the previous command
sudo !!                              # ... with sudo
^status^reload                       # repeat the last command, replacing "status" with "reload"
!$                                   # last argument of the previous command

set +o history                       # stop recording this shell session's history
# ...commands you don't want recorded...
set -o history                       # resume

Put a space before a command to keep it out of history if HISTCONTROL=ignorespace (or ignoreboth) is set.

Timestamp every history entry by adding this to ~/.bashrc:

export HISTTIMEFORMAT="%Y-%m-%d %T "   # history now shows: 1864  2026-10-03 14:02:11 systemctl status nginx

Getting user input

read -rp  "Postgres user name: " name
read -rsp "Postgres user password: " password; echo    # -s hides input; echo restores the newline
export POSTGRES_USER="$name" POSTGRES_PASSWORD="$password"

read -rp "Continue? [y/N] " answer
[[ ${answer,,} == y* ]] || exit 1                       # ,, lowercases

-r stops backslashes being treated as escapes. -t 10 adds a timeout and -n 1 returns after a single keypress.

Emptying a file

: > file.txt                         # truncate to zero bytes
cat /dev/null > file.txt
truncate -s 0 file.txt

Show only "real" lines of a config

grep -Ev '^\s*(#|$)' file.conf      # drop comments and blank lines
awk '!/^ *#/ && NF' file.conf        # awk equivalent: not a comment AND has fields

Loops

# Same command on several hosts
hosts=(app01 app02 app03)
for h in "${hosts[@]}"; do
    printf '#--- %s\n' "$h"
    ssh "$h" 'rpm -qa | grep bash-completion'
done

# Numbered sequence: galera0, galera1, galera2
for n in $(seq 0 2); do virsh dominfo "galera${n}"; done
for n in {0..2}; do echo "node${n}"; done         # brace expansion; {01..10} zero-pads

# Files whose names contain spaces: glob, don't parse ls
for f in *; do printf '%s\n' "$f"; done
for f in /media/music/*; do printf '%s\n' "$f"; done

for i in $(ls) splits on whitespace, so names with spaces break into pieces. Loop over the glob instead.

Command output into an array

var=$(cmd) gives a single string, not an array, so "${var[@]}" loops once over the whole thing. Use mapfile to get one element per line:

mapfile -t pods < <(kubectl -n openstack get pods --no-headers -o custom-columns=:metadata.name | grep neutron-server)

for p in "${pods[@]}"; do
    kubectl -n openstack delete pod "$p"
done

Arrays primer: You don't know Bash: arrays (opensource.com).

One-liners

# Convert flac to mp3
for f in *.flac; do ffmpeg -i "$f" -codec:a libmp3lame -qscale:a 2 "${f%.flac}.mp3"; done

# Symlink a directory of files into another directory
for f in ~/Pictures/Trip/*; do ln -s "$f" ~/Pictures/; done

# Strip the first 3 and last 4 lines of several files
for f in host*_chkconfig.txt; do tail -n +4 "$f" | head -n -4 > "$f.new" && mv "$f.new" "$f"; done

# Rename every readme.md in a tree to home.md (-execdir runs in each file's own directory)
find . -type f -name 'readme.md' -execdir mv -- {} home.md \;

Add echo before destructive commands to preview them. See rename.

Subcommand dispatch with case

The classic SysV init-script shape is still a handy skeleton for any script that takes a verb. For an actual service, write a systemd unit instead.

#!/bin/bash
set -euo pipefail

PIDFILE=/run/myapp/myapp.pid
LOGFILE=/var/log/myapp.log

is_running() { [[ -f $PIDFILE ]] && kill -0 "$(<"$PIDFILE")" 2>/dev/null; }

start() {
    if is_running; then echo "already running" >&2; return 1; fi
    nohup /opt/myapp/server.sh >>"$LOGFILE" 2>&1 &
    echo $! > "$PIDFILE"
}

stop() {
    if ! is_running; then echo "not running" >&2; return 1; fi
    kill -TERM "$(<"$PIDFILE")" && rm -f "$PIDFILE"
}

case "${1:-}" in
    start)   start ;;
    stop)    stop ;;
    restart) stop || true; start ;;
    status)  if is_running; then echo "running"; else echo "stopped"; exit 3; fi ;;
    *)       echo "Usage: ${0##*/} {start|stop|restart|status}" >&2; exit 1 ;;
esac

Copy a file with a backup suffix (brace expansion)

cp config.txt{,.bak}                                   # config.txt.bak
cp config.txt{,.bak.$(date +%Y%m%d.%H%M)}              # dated backup
cp config.txt{,.bak.$(date +%Y%m%d)} ~/backup/         # ... into a directory (put the destination last)

Awkward file and directory names

cd -- '-[   Shaman'                  # names starting with a dash: use --
ls | grep -v '\.txt$'                # not ending in .txt
shopt -s extglob; printf '%s\n' !(*.txt)        # extended glob for the same
ls -d *Arcade*                       # -d lists matching directories without descending into them
ls {745..769}*                       # number ranges in names (swap ls for scp to copy them)

A directory whose name contains newlines can be removed by pasting exactly the same quoted string that created it, or by matching with a glob: rm -rf ./Introducing*. Inspect with ls -b.

Subshells

( ... ) runs commands in a child shell. Changes to variables and the working directory do not leak out, which is handy for cd inside a script ((cd /tmp && tar xf x.tar)). $( ... ) is command substitution, which also runs in a subshell. Overview: Linux subshells for beginners.

Pretty-print a messy CSV

column -t -s, file.csv | less -S

Compare directories on two hosts

diff -b <(ssh host1 'ls -l /srv/app') <(ssh host2 'ls -l /srv/app')

String manipulation and parameter expansion

References: Advanced Bash Guide: string manipulation, Advanced Bash Guide: parameter substitution, Bash parameter expansion (opensource.com), Bash reference manual: shell parameter expansion.

f="report.final.txt"
echo "${f%.txt}"      # report.final          (remove suffix, shortest)
echo "${f%%.*}"       # report                (remove suffix, longest)
echo "${f#*.}"        # final.txt             (remove prefix, shortest)
echo "${f##*.}"       # txt                   (extension)
echo "${f/final/v2}"  # report.v2.txt
echo "${f^^}"         # REPORT.FINAL.TXT      (uppercase)
echo "${#f}"          # 16                    (length)
echo "${f:0:6}"       # report                (substring)
echo "${var:-default}"  # value or default if unset/empty
echo "${PWD##*/}"     # name of the current directory (like basename "$PWD")

Directory colours in ls

ls colours come from LS_COLORS. Override entries in ~/.bashrc, then source ~/.bashrc:

export LS_COLORS="$LS_COLORS:di=1;33:ln=36"    # directories bold yellow, symlinks cyan

For a full custom scheme, dump the defaults, edit, and load them (most distros' .bashrc already runs dircolors if ~/.dircolors exists):

dircolors -p > ~/.dircolors
eval "$(dircolors -b ~/.dircolors)"

On Fedora/RHEL the system-wide file is /etc/DIR_COLORS:

#DIR 01;34      # default: bold blue
DIR 32          # plain green

A value is style;foreground;background, separated by semicolons, e.g. di=1;4;31;42 is bold, underlined red on green.

Style Foreground Background
0 default 30 / 90 black / dark grey 40 / 100 black / dark grey
1 bold 31 / 91 red / light red 41 / 101 red / light red
4 underline 32 / 92 green / light green 42 / 102 green / light green
5 blink 33 / 93 brown / yellow 43 / 103 brown / yellow
7 reverse 34 / 94 blue / light blue 44 / 104 blue / light blue
8 concealed 35 / 95 purple / light purple 45 / 105 purple / light purple
36 / 96 cyan / turquoise 46 / 106 cyan / turquoise
37 / 97 grey / white 47 / 107 grey / white

Common keys: di directory, fi regular file, ln symlink (target colours it like its target), or broken symlink, mi missing link target, ex executable, pi FIFO, so socket, bd/cd block/char device, su/sg setuid/setgid, st sticky dir, ow other-writable dir (often unreadable by default; worth recolouring), tw sticky + other-writable, *.ext any extension.

Preview every combination in your terminal:

for i in 00{2..8} {0{3,4,9},10}{0..7}; do
    for j in 0 1; do echo -e "$j;$i \e[$j;${i}mSample text\e[00m"; done
done

Sources: Ask Ubuntu: change directory colour with ls, LS_COLORS reference.

Quiet logins

touch ~/.hushlogin suppresses the MOTD and "last login" banner, leaving just the prompt.

Documentation and further reading

Primary - man bash: one of the longest man pages in existence, and the definitive description of the shell - GNU bash manual, available as single-page HTML, PDF, or locally via info bash - Source repository and release tarballs and changelogs

Builtins specifically

help              # list all builtins
help cd           # help for one builtin
help set          # all set options
help shopt        # all shopt options

help is the fastest reference for builtin syntax, quicker than searching man bash when you only need to check a flag.

Chet Ramey's pages - Bash home page: current source, documentation, and the official bash FAQ

Community references (often more practical than the manual) - BashGuide (Greg's wiki): the most respected community guide - BashPitfalls: a catalogue of common mistakes with explanations - BashFAQ: community FAQ on Greg's wiki (separate from Chet's) - Advanced Bash-Scripting Guide: very comprehensive, but some examples use older style; treat it as reference, not gospel - ShellCheck: static analysis for shell scripts, online or as the local shellcheck tool

Books - Learning the bash Shell by Cameron Newham (O'Reilly): introductory reference - bash Cookbook by Carl Albing and JP Vossen (O'Reilly): practical recipes - The Linux Command Line by William Shotts: free online, covers bash thoroughly in a practical context - Wicked Cool Shell Scripts by Dave Taylor and Brandon Perry (No Starch Press): practical scripts. It is commercial, so buy it from the publisher instead of keeping copies of the ebook files here.

Portability and POSIX - POSIX Shell Command Language: the shell grammar and special builtins specification, the definitive reference for what is portable versus bash-specific - dash: reading what dash implements versus what bash adds is a fast way to learn the POSIX/bash divide. Run checkbashisms (from Debian's devscripts) to find bash-only syntax in #!/bin/sh scripts.

Cheatsheets, style, and libraries - Google Shell Style Guide - Bash cheatsheet (devhints) and Learn X in Y minutes: bash - Function libraries: shelf, applemcg/bash, Shell functions library (cyberciti), Bash shell scripting libraries - terminals-are-sexy, a curated list of terminal tools and resources - Related: xargs, parallel, find, text processing tools