Overview
Bash-the-scripting-language sits directly on top of Bash-the-interactive-shell: the same commands you'd type at a prompt become a script once you add variables, conditionals, loops, and functions to sequence and reuse them. That reuse is exactly where scripts stop looking like a transcript of commands and start behaving like real programs, with the same failure modes worth taking seriously: unquoted variables that word-split unexpectedly, exit statuses that silently get ignored, and subshells whose variables don't leak back out the way a newcomer expects. None of this replaces a real programming language for genuinely complex logic, but for gluing commands together, it's usually the fastest path from "a sequence of steps I keep typing" to "a script I can trust."
Quick Reference
| Concept | What it is | Note |
|---|---|---|
| Shebang | The first line, declaring which interpreter runs the script | #!/usr/bin/env bash is the portable form |
| Positional parameter | An argument passed to a script or function | $1, $2, ... $9, ${10} and beyond need braces |
| Exit status | A command's numeric result; 0 means success | Checked via $?, or directly in if/while |
| Subshell | A child shell running a command substitution or (...) group | Variables set inside it don't leak back to the parent |
The commands below are grouped by what you're trying to do, not an exhaustive reference (see the official GNU Bash Reference Manual for every detail), but the syntax that covers almost all everyday scripting.
#!/usr/bin/env bash
set -euo pipefail # exit on error, unset variable, or failed pipeline stage
echo "Script name: $0"
echo "First argument: $1"
echo "All arguments: $@"
echo "Argument count: $#"name="Ada"
echo "$name" # always quote a variable reference
echo "${name:-Unknown}" # default value if name is unset or empty
echo "${#name}" # length of the string
readonly PI=3.14 # constant - reassigning it is an error
export PATH="$PATH:/opt/bin" # visible to child processes tooif [[ "$name" == "Ada" ]]; then
echo "Hello, Ada"
fi
if [ -f "$file" ]; then
echo "$file exists"
fi
case "$1" in
start) echo "starting" ;;
stop) echo "stopping" ;;
*) echo "usage: $0 start|stop" ;;
esacfor f in *.log; do
echo "Found: $f"
done
while read -r line; do
echo "Line: $line"
done < input.txt
greet() {
local name="$1" # local - doesn't leak into the caller's scope
echo "Hi, $name"
}
greet "Ada"| Command | Description | Copy |
|---|---|---|
arr=(a b c) | Declare an indexed array. | |
echo "${arr[0]}" | Access an element by index. | |
echo "${arr[@]}" | Expand every element as separate words. | |
echo "${#arr[@]}" | Number of elements in the array. | |
arr+=(d) | Append an element. | |
declare -A map=([key]=value) | Declare an associative array (Bash 4+). |
| Command | Description | Copy |
|---|---|---|
cmd > file | Redirect stdout to a file, overwriting it. | |
cmd >> file | Redirect stdout to a file, appending. | |
cmd 2> file | Redirect stderr to a file. | |
cmd > file 2>&1 | Redirect both stdout and stderr to the same file. | |
cmd1 | cmd2 | Pipe cmd1's stdout into cmd2's stdin. | |
cmd < file | Feed a file's contents to a command's stdin. |
Syntax
#!/usr/bin/env bash
set -euo pipefail
backup_dir="/backups/$(date +%Y-%m-%d)"
mkdir -p "$backup_dir"
for file in /data/*.db; do
cp "$file" "$backup_dir/"
done
echo "Backed up $(ls "$backup_dir" | wc -l) files to $backup_dir"Examples
set -euo pipefail
count=0
while read -r line; do
[[ "$line" == *ERROR* ]] && ((++count))
done < app.log
echo "Found $count error lines"find . -name "*.tmp" -mtime +7 -deleteA common cleanup one-liner: find files matching a pattern older than 7 days and delete them, no script file needed.
find . -name "*.tmp" -mtime +7 -delete
Visual Diagram
Common Mistakes
- Leaving variables unquoted (
$fileinstead of"$file"), so a value with a space or glob character silently word-splits or expands into multiple arguments. - Looping over
$(ls)output instead of a glob (for f in *.log), which breaks on filenames containing spaces or newlines. - Omitting
set -euo pipefail, so a failed command partway through a script is silently ignored and the script keeps running against a now-inconsistent state. - Forgetting
localinside a function, so a variable meant to be scoped to that function overwrites a same-named variable in the caller. - Assuming a pipeline's exit status reflects every stage; without
pipefail,false | truereports success because only the last command's status is checked.
Performance
- Every subshell (
$(...), a(...)group, or a pipeline stage) forks a new process; a tight loop that spawns one per iteration is measurably slower than an equivalent loop using Bash builtins. - Prefer parameter expansion (
${var//search/replace}) over piping throughsed/awkfor simple string substitutions; one is a builtin, the other forks an external process. - A
while read -r line; do ... done < fileloop streams a file line by line without loading it entirely into memory, which matters once files get large.
Best Practices
- Start every script with
#!/usr/bin/env bashandset -euo pipefail, and treat leaving either out as something that needs a specific reason. - Quote every variable expansion unless you specifically want word-splitting or globbing to happen.
- Prefer
[[ ]]over[ ]for conditionals in Bash-specific scripts; reach for[ ]only when a script genuinely needs POSIX/bin/shportability. - Run scripts through ShellCheck before relying on them; it catches most of the quoting and word-splitting mistakes above automatically.
- Use
localfor every variable declared inside a function, so functions don't leak state into the caller's scope.