Bash
the GNU shell most scripts are written for
What it is
Bash, the Bourne-Again SHell, distributed as GNU Bash and started as the bash
command, is a program that reads commands, typed at a prompt or saved in a file,
and carries them out: it starts other programs, connects their input and output,
and adds variables, loops, tests and functions so that a sequence of commands
can make decisions. The name is a pun on Stephen Bourne, whose sh shipped with
the Seventh Edition of Research Unix at Bell Labs. Its manual describes Bash as
largely compatible with that shell, with features taken from the Korn shell and
the C shell, and as intended to conform to the shell part of the POSIX standard.
The current release is 5.3, and the 5.3 manual is dated May 2025.
A shell is easy to confuse with the window it runs in: the terminal draws characters and forwards keystrokes, and the shell is the program on the other side that interprets them. Bash runs interactively, with a prompt, history and tab completion, or non-interactively, reading a script from top to bottom unattended, which is where most of its traps live.
Bash is one of several programs that can fill the same role. POSIX defines a
portable shell language, sh, and the program installed as /bin/sh varies by
system. On Debian and Ubuntu it is dash, a small POSIX shell descended from the
Almquist shell, and Debian Policy names bash, dash and ksh as the most common
shells to use there. Zsh has been the default login shell for new accounts on
macOS since version 10.15, although the Mac still carries a /bin/bash. A
script picks its interpreter with its first line, the shebang: #!/bin/sh asks
for whatever POSIX shell the system has, while #!/bin/bash or
#!/usr/bin/env bash asks for Bash by name.
Why it matters
Bash is the connecting language of Unix servers. It is on almost every Linux machine, and the commands an engineer types at a prompt are already Bash, so a handful of them saved in a file becomes a script that runs on what the machine already has. For chaining existing programs, such as dumping a database, compressing the dump and copying it elsewhere, few languages are as brief, because pipes, redirection and exit statuses are its native vocabulary.
The same traits turn into costs as a script grows. Every value is a string, and
arithmetic works only on integers, so anything with structure, such as JSON,
dates or decimal amounts, has to be handed to another tool. Unquoted expansions
are split into words and then matched against filenames, so a value containing a
space or an asterisk changes the meaning of the command it lands in. Error
handling is opt-in: by default a script carries on after a command fails, and
the options that change that, set -e and pipefail, come with exceptions the
manual needs several paragraphs to list. A workable rule is that once a script
needs data structures, a parser or a test suite, it has become a program, and a
language such as Python is cheaper to maintain.
Portability is a decision as well. Debian Policy requires /bin/sh scripts to
keep to POSIX features plus a short list of extensions, and asks any script that
needs more to name its shell on the first line, for example #!/bin/bash. It
suggests checkbashisms or a run under a stricter shell such as posh to find
violations, and advises /bin/bash when in doubt. The standard moves slowly:
Bash has offered pipefail since version 3.0, and POSIX added it only in its
2024 edition.
A shell turns text into commands, which makes it a security boundary wherever
outside text reaches it. Shellshock, recorded as CVE-2014-6271 in September
2014, showed how wide that boundary can be. Bash up to version 4.3 executed
commands that trailed a function definition in an environment variable, so
anything that copied attacker-controlled text into the environment before
starting Bash, including CGI scripts under Apache, the ForceCommand feature of
OpenSSH and some DHCP clients, became a way to run code on the machine. The
original fix was incomplete, and what remained received its own identifier,
CVE-2014-7169. The everyday lesson is smaller: outside text reaches a command as
an argument, never as part of a string handed to eval or sh -c.
What it looks like in practice
Across the company’s work, Bash keeps the job the tech stack assigns it: Python for data work, automation and services, and Bash for the glue, written to be read.
In the six ETL and ELT pipelines that fed Google BigQuery from a data warehouse behind an IPsec VPN, the downloaded archives held Parquet, CSV and .bak files. Bash and Python scripts unpacked them and classified each file by type and column layout before bcp, psql and SSIS loaded them into staging schemas and gzip, Google Cloud Storage and the bq tool carried the results into BigQuery. With the unpacking and sorting scripted, a run that had taken more than three hours by hand finished in under 20 minutes, and the data could be synced every day instead of once a week.
The hundred automated critical data backups show the same division of labour. Barman handled the PostgreSQL side, Google Cloud held the offsite copies, and Bash and Python orchestrated and validated the whole regime, with retention policies keeping the copies current and integrity checks confirming that each one was good rather than merely present. That is the job Bash suits: a sequence of existing tools, where the script’s value lies in checking what each step returned.
Where it goes wrong
A filename with a space becomes two arguments. An unquoted $file is split
on whitespace and then expanded as a glob, so report 2024.csv reaches rm as
two names and a stray * matches a whole directory. Quote every expansion, as
in "$file", pass argument lists on as "$@", keep lists of names in arrays,
and let ShellCheck find the rest.
A backup pipeline reports success after the dump has failed. A pipeline’s
exit status is that of its last command, so in pg_dump app | gzip > app.sql.gz
a failed dump still leaves gzip succeeding on whatever little it received. Turn
on set -o pipefail, which makes the pipeline return the last non-zero status,
and check the result with gzip -t or, better, a test restore.
A script with set -e carries on past a failed command. The option is
ignored in the test of an if or while, in every part of an && or || list
except the last, and inside any function called from those places, so the dump
below can fail and the script still prints its success message:
#!/usr/bin/env bash
set -euo pipefail
backup() {
pg_dump app > /var/backups/app.sql
gzip -f /var/backups/app.sql
}
if backup; then # set -e is ignored inside backup here
echo "backup done"
fi
A related trap is local out=$(cmd), which returns the status of local and
hides a failure in cmd. Inside functions, follow each command that matters
with || return 1, and declare a variable before assigning to it on a separate
line.
A script that works on one machine breaks on another. A file that starts
with #!/bin/sh but uses [[ tests or arrays runs under Bash on one system and
under dash on the next. There [[ is an unknown command, so an if built on it
prints an error and takes its else branch, and an array assignment is a syntax
error that ends the script. On a Mac, /bin/bash reports version 3.2.57, older
than the associative arrays and mapfile that arrived in 4.0, so declare -A
stops with an invalid-option error there. Name the shell the script really needs
in the shebang, and test against the oldest version it has to run on.
A cleanup line deletes far more than it should. With build_dir unset,
rm -rf "$build_dir"/* expands to rm -rf /*. set -u turns an unset variable
into an error, and writing "${build_dir:?}" stops a non-interactive script
whenever the variable is unset or holds an empty string.
Further reading
- GNU Bash Reference Manual
— the 5.3 manual: expansions, quoting, the
setbuiltin and the exact cases whereset -eis ignored. - Shell Command Language
— the portable
shlanguage in POSIX.1-2024, the limit for any#!/bin/shscript. - Debian Policy: Scripts
— what
/bin/shmust support on Debian, when a script must ask for Bash by name, and the advice onset -e. - Bash NEWS — the maintainer’s release-by-release list of new features, for checking which version a construct needs.
- CVE-2014-6271 — the NVD record for Shellshock, with the attack paths it lists and the follow-up identifier.