What is Bash scripting? Syntax, variables, control flow, and best practices explained
Bash is the default Unix shell on most Linux distributions and on macOS. Learn how Bash scripts work: variables, parameter expansion, conditionals, loops, functions, exit codes, and best practices for reliable, safe scripts.
On this page
- What is Bash and where it came from
- How a shell script runs
- Variables, parameters, and expansion
- Control flow: conditionals, case, and loops
- Functions and exit codes
- Input, output, redirection, and pipelines
- Best practices and common pitfalls
- FAQ
- What is the difference between Bash and sh?
- Why should I use [[ instead of [?
- How do I handle errors in a script?
- Are Bash scripts portable to macOS and Windows?
Bash (Bourne Again Shell) is the default Unix shell on most Linux distributions and on macOS. A shell is a program that reads commands from the user, runs them, and prints the results. Besides its interactive mode, Bash can also read commands from a text file called a shell script — a sequence of commands executed line by line, just as if you had typed them in the terminal.
Bash scripts are the backbone of automation on Linux servers: cron jobs, CI/CD pipelines, provisioning config, and one-off file processing. Unlike Python or JavaScript, Bash is not compiled — the interpreter executes each line directly. This makes writing and testing scripts fast, but it also means small mistakes (an unbalanced quote, a misplaced space, an unquoted variable) can produce subtle bugs. A solid understanding of how Bash interprets a command line is the main prerequisite for writing correct scripts.
What is Bash and where it came from
Bash was created by Brian Fox for the GNU Project and released on 8 June 1989. Its name is a play on the Bourne shell (sh) it succeeds — Bash is the "Bourne Again shell". Its original goal was to provide a free, POSIX-compatible shell for the GNU system, because at the time POSIX set the standard yet free sh implementations were limited.
Over the years Bash kept backward compatibility with sh scripts while adding new features: arrays, parameter expansion, arithmetic expansion, [[ ... ]], process substitution, and job control. Modern versions (5.x, maintained by Chet Ramey) are the default interpreter on mainstream Linux distributions and the default interactive shell on macOS up to Catalina. Today Bash is available on nearly every Unix-like system, and through WSL or Cygwin on Windows too.
How a shell script runs
A shell script is an ordinary text file. The first line starting with #! (called the shebang) tells the kernel which interpreter to use:
#!/usr/bin/env bash
echo "Hello, world"
Without a shebang, the kernel may refuse to execute the script. When a script runs, Bash reads it line by line. For each line, Bash performs several steps: tokenizing (splitting the line into words and operators), alias expansion, brace expansion, tilde expansion, parameter and variable expansion, command substitution, arithmetic expansion, word splitting, and pathname expansion (globbing) — all before the command is actually executed.
This expansion order matters. Because word splitting happens after variable expansion, a variable containing a space gets split into multiple arguments. That is why the idiom echo "$var" (with quotes) differs from echo $var: the quotes prevent word splitting.
Variables, parameters, and expansion
Bash distinguishes shell variables (local to the shell) from environment variables (inherited by child processes). You assign both with name=value, mark a variable for export with export VAR="value", and read them with a $ prefix.
Parameter expansion is Bash's main power tool. Some common forms:
${var}— the value ofvar. The form${var:-default}returns a default ifvaris unset or empty.${var:=default}— set a default ifvaris unset.${var#pattern}— remove the shortest matching prefix.${var##pattern}removes the longest.${var%suffix}— remove the shortest matching suffix.%%removes the longest.${var/from/to}— replace the first match;${var//from/to}replaces all.${#var}— string length.${array[i]}— an array element.${array[@]}all elements.${!array[@]}the keys.
Always quote variable expansions with double quotes unless you explicitly want word splitting or globbing. A handy rule: echo "$var" is almost always right; echo $var often introduces bugs.
Control flow: conditionals, case, and loops
Bash's control structures use reserved words: if, then, else, elif, fi, case, esac, for, while, until, do, done, break, and continue. These keywords cannot be used as variable names, which is why you see naming conventions like my_count or result rather than count.
The if conditional tests a command, not an expression. The exit status (0 means true, nonzero means false) decides which branch runs. For tests on numbers, strings, and files, use [[ ... ]]:
if [[ -f "$path" && -r "$path" ]]; then
echo "file is readable"
elif [[ -d "$path" ]]; then
echo "it's a directory"
else
echo "not found"
fi
For many patterns, case is cleaner than a long if/elif chain. The for loop iterates over a word list, brace expansion, or a glob result: for f in *.log; do ...; done. while tests a condition each iteration. break exits a loop; continue jumps to the next iteration. Understanding exit status is fundamental: every command produces a code from 0–255, and &&/|| compose them.
Functions and exit codes
Functions group reusable commands. The syntax:
greet() {
local name="${1:-world}"
echo "Hello, $name"
}
greet "Bash"
Always declare local variables with local to prevent them leaking into the caller's environment. Positional parameters ($1, $2, ... $@) carry arguments. $# is the argument count; "$@" (quoted) expands to the arguments as separate words.
Every command, including a function, returns an exit code between 0 and 255. Zero means success; nonzero indicates an error. exit N sets the script's exit code; bare exit returns the last command's code. Convention: 0 success, 1 general error, 2 usage error (such as a missing argument), 126 command not executable, 127 command not found, 128+N a process killed by signal N. Consistent exit codes make scripts composable with && and usable in CI pipelines.
Input, output, redirection, and pipelines
Bash treats three standard streams: stdin (0), stdout (1), and stderr (2). Redirection rearranges these streams:
cmd > file— write stdout tofile(overwrite).cmd >> file— append stdout tofile.cmd 2> err.log— write stderr toerr.log.cmd > out 2>&1— merge stderr into stdout.cmd < input— read stdin frominput.cmd1 | cmd2— connect cmd1's stdout to cmd2's stdin.
A pipeline (|) runs commands in parallel; the kernel handles the buffering. $PIPESTATUS is an array of each stage's exit codes. xargs turns stdin into arguments for another command; without -d it splits on spaces, a common source of bugs.
A heredoc (<<EOF) lets you embed multi-line text blocks inside a script:
cat <<'USAGE' >&2
Usage: deploy.sh <env>
USAGE
Quoting the delimiter ('EOF') disables expansion inside the heredoc, useful for help messages and templates.
Best practices and common pitfalls
Reliable Bash scripts follow a few ground rules:
- Always quote variables:
echo "$var", notecho $var. Spaces and globs in values will surface bugs. - Use
set -euo pipefail:-estops on error,-ucatches unset variables,-o pipefailcatches failures inside a pipeline. - Perform input validation: check the argument count (
$#), test types with[[ ]], usegetoptsfor options. - Log to stderr (
>&2), not stdout, so output can be piped to other programs. - Use
localinside functions, andreadonlyfor constants. - Format with
shellcheck: a static linter that catches most common pitfalls.
The most common pitfalls: unquoted comparisons, [ vs [[ confusion, cd without checking success, for f in $(ls) which breaks file names with spaces, and cat file | grep when grep ... file is correct. Recognizing these patterns saves debugging time.
FAQ
What is the difference between Bash and sh?
sh is the POSIX name for the Bourne shell family. Bash is POSIX-compliant but adds many features (arrays, [[ ]], $RANDOM, brace expansion, process substitution, and more). On most Linux systems /bin/sh is a symlink to dash or to bash in POSIX mode. For scripts that must run across many shells, write to sh; for scripts targeting modern Linux, use bash with the #!/usr/bin/env bash shebang.
Why should I use [[ instead of [?
[[ ... ]] is a Bash keyword that offers safer, richer features: pattern matching ([[ $f == *.log ]]), &&/|| logical operators without escaping, and no word splitting or pathname expansion on variables. [ (alias test) is a portable command that receives arguments after word splitting, so unquoted variables often break. Use [[ in Bash; use [ only if you must write a POSIX sh script.
How do I handle errors in a script?
Enable strict mode at the top: set -euo pipefail. Handle expected errors explicitly with || or if (for example, mkdir -p "$dir" || exit 1). Check the success of critical commands (cd, cp, curl) before continuing. Write error messages to stderr (echo "failed" >&2) and use meaningful exit codes. For cleanup, use trap 'rm -f "$tmp"' EXIT so resources are always cleaned up.
Are Bash scripts portable to macOS and Windows?
On macOS, an older Bash (3.2) ships for licensing reasons; install 5.x via Homebrew for modern features. On Windows, Bash is available through WSL (Windows Subsystem for Linux) or Git Bash (MSYS). Scripts using [[, == inside [[, and associative arrays (declare -A) are not portable to pure POSIX sh, but work across all modern Bash installations on Unix, Linux, and macOS with Bash 4+.
Sources
- Wikipedia — Bash (en.wikipedia.org/wiki/Bash_(Unix_shell)
- CC BY-SA 4.0)
- GNU Bash Manual (gnu.org/software/bash/manual
- GNU Free Documentation License)
- Advanced Bash-Scripting Guide
- Mendel Cooper (tldp.org/LDP/abs
- public domain)
Share this guide
Related guides
- What is a cron job? How cron works and how to schedule tasks
What is a cron job? Learn how cron and crontab work, the five-field schedule syntax with examples, common use cases, and modern alternatives like systemd timers and managed schedulers.
- Containers vs Virtual Machines: which should you choose?
Containers and virtual machines both isolate workloads, but they do it in fundamentally different ways. Compare architecture, performance, isolation, security, and use cases to pick the right one for your application.
- What is observability? Logs, metrics, and traces explained
Observability is the ability to infer a system's internal state from its external outputs. Learn how the three pillars — logs, metrics, and traces — work together, the difference between monitoring and observability, and the four golden signals (latency, traffic, errors, saturation).