# Bash

## 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](/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](/portfolio/designed-implemented-and-administered-6-etl-elt-pipelines-8/)
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](/portfolio/automated-100-critical-data-backups-using-barman-google-18/)
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:

```bash
#!/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](https://www.gnu.org/software/bash/manual/bash.html)
  — the 5.3 manual: expansions, quoting, the `set` builtin and the exact cases
  where `set -e` is ignored.
- [Shell Command Language](https://pubs.opengroup.org/onlinepubs/9799919799/utilities/V3_chap02.html)
  — the portable `sh` language in POSIX.1-2024, the limit for any `#!/bin/sh`
  script.
- [Debian Policy: Scripts](https://www.debian.org/doc/debian-policy/ch-files.html#scripts)
  — what `/bin/sh` must support on Debian, when a script must ask for Bash by
  name, and the advice on `set -e`.
- [Bash NEWS](https://tiswww.case.edu/php/chet/bash/NEWS) — the maintainer's
  release-by-release list of new features, for checking which version a
  construct needs.
- [CVE-2014-6271](https://nvd.nist.gov/vuln/detail/CVE-2014-6271) — the NVD
  record for Shellshock, with the attack paths it lists and the follow-up
  identifier.

<https://engineer.company/glossary/bash/>
