Best Practices for Modern Bash Scripting in 2026
Modern Bash Scripting Best Practices (2026 Edition)
You probably live in ZSH day-to-day (oh-my-zsh, Starship prompt, all the creature comforts), but when it comes to writing scripts that run reliably across servers, CI pipelines, and your teammates' machines, write Bash. It's everywhere, it's predictable, and it's not going anywhere.
Here's an updated take on the classic best practices, with a few new ones for the modern era.
Shebang: Still #!/bin/bash, Still Non-Negotiable, but
#!/usr/bin/env bash
is more reliable than
#!/bin/bash
ZSH is your interactive shell. Bash is your scripting shell. The shebang pins execution to /bin/bash regardless of what shell the person running it prefers, so your script behaves identically on your Mac, in GitHub Actions, and on a bare Ubuntu server. Don't skip it, don't use #!/bin/sh unless you're consciously targeting POSIX portability.
Modern tip: On macOS,
/bin/bashis still Bash 3.2 (Apple won't ship GPL v3). If your script needs Bash 4+ features (associative arrays,mapfile, etc.), document the dependency clearly or install via Homebrew and use#!/usr/bin/env bash, but understand the trade-offs.
Set Your Safety Net at the Top, Every Time
The classic trio is still gold, but treat them as a unit:
#!/bin/bash
set -euo pipefail
Here is what each flag does:
-e: Exit immediately if any command returns a non-zero exit code. Without this, Bash happily continues running even after a command fails, which can lead to scripts that appear to succeed but leave things in a broken state.-u: Treat any reference to an unset variable as an error and exit. Without this, a typo like${ENVIRNOMENT}silently expands to an empty string, which can cause subtle and hard-to-debug failures.-o pipefail: By default, a pipeline likecat file.txt | grep "thing"returns the exit code of the last command only (grep). Withpipefailset, if any command in the pipeline fails, the whole pipeline fails. This matters a lot when piping into commands likesort,awk, orxargs.
You will also occasionally see set -eEuo pipefail (capital E) or set -eeuo pipefail (double e). These are both equivalent to set -euo pipefail for most scripts. The capital -E is only relevant if you are using ERR traps and want that trap to be inherited by functions and subshells. The double e is simply redundant and has no extra effect. Stick with set -euo pipefail unless you have a specific reason to do otherwise.
These three together should be muscle memory. If you are writing a script longer than 10 lines and these are not at the top, go back and add them.
Trace Logging: set -x Is Great, But Be Intentional
set -x prints every command as it executes, which is invaluable for debugging. But blasting it across an entire script creates noisy logs and can leak sensitive values (tokens, passwords) into your output.
A more surgical approach:
# Turn tracing on only for the section you care about
set -x
some_complex_function
set +x
Or gate it on an environment variable so CI or a teammate can opt in:
[[ "${TRACE:-}" == "1" ]] && set -x
Now debugging is TRACE=1 ./deploy.sh with no code changes required.
Name Your Parameters Immediately
$1, $2, $3 are meaningless by line 50. Assign them to named variables at the very top of the script, right after your safety flags:
#!/bin/bash
set -euo pipefail
readonly ENVIRONMENT="${1:?Usage: deploy.sh <environment> <version>}"
readonly VERSION="${2:?Usage: deploy.sh <environment> <version>}"
The :? syntax does double duty: it provides an error message and triggers the -u unset variable check if the argument is missing. No separate argument validation needed for simple cases.
Use readonly for variables that should never be reassigned. It makes intent clear and prevents accidental mutation.
Prefer [[ ]] Over [ ] for Conditionals
Using [[ ]] is worth being explicit about:
# Good — [[ ]] is a Bash builtin, safer and more powerful
if [[ $(docker container ps) == *"container_name"* ]]; then
docker rm -f container_name
fi
# Avoid — [ ] is a POSIX command, more footguns, no pattern matching
if [ "$(docker container ps)" = "*container_name*" ]; then
...
fi
[[ ]] gives you pattern matching with ==, regex matching with =~, and doesn't word-split or glob-expand variables. Use it by default in Bash scripts.
Looping: Use Arrays, Not Space-Delimited Strings
The space-delimited variable trick is a classic, but it breaks the moment a value contains a space (think file paths, environment names, anything user-supplied). Bash arrays are the right tool:
# Fragile
CONTAINERS="container1 container2 container3"
for CONTAINER in ${CONTAINERS}; do
docker run "${CONTAINER}"
done
# Better — use a proper array
containers=("container1" "container2" "container3")
for container in "${containers[@]}"; do
docker run "${container}"
done
Notice the lowercase variable names. That's the Google Shell Style Guide convention: environment variables and exports are UPPER_CASE, local script variables are lower_case.
Quote Everything (Seriously, Everything)
Unquoted variables are the source of a huge class of subtle bugs. Word splitting and glob expansion will bite you at the worst possible time.
# This will break if FILE contains spaces
rm $FILE
# This is safe
rm "${FILE}"
Make "${variable}" your default. The curly braces also let you do inline default values and transformations:
# Use a default if the variable is unset or empty
readonly LOG_LEVEL="${LOG_LEVEL:-info}"
# String manipulation
echo "${FILENAME%.tar.gz}" # Strip suffix
echo "${PATH_VAR##*/}" # Get basename
Write a cleanup Trap and Make It Robust
The cleanup trap pattern is essential. Here's a complete version:
cleanup() {
local exit_code=$?
echo "Cleaning up..."
docker container stop container1 2>/dev/null || true
docker container rm container1 2>/dev/null || true
exit "${exit_code}"
}
trap cleanup SIGINT SIGTERM EXIT
A few things worth noting here:
- Capture the exit code at the start of cleanup so you can pass it through, otherwise your script always exits 0.
- Use
|| trueon cleanup commands so a failure to clean up doesn't mask the original error. - Redirect cleanup stderr to
/dev/nullfor expected "not found" cases.
Idempotency Is a Team Sport
Scripts shared across a team or run in CI need to be re-runnable without side effects. Ask yourself: what happens if this script is run twice in a row?
# Check before acting — don't just attempt and hope
if ! docker network ls | grep -q "my_network"; then
docker network create my_network
fi
This is especially important for setup scripts, database migrations, and infrastructure provisioning. Think of it as writing scripts that are polite to the next person who runs them.
Logging: Give Your Scripts a Voice
Plain echo output is hard to scan. A simple logging pattern makes scripts much easier to follow and debug in CI:
log::info() { echo "[INFO] $(date '+%Y-%m-%dT%H:%M:%S') $*"; }
log::warn() { echo "[WARN] $(date '+%Y-%m-%dT%H:%M:%S') $*" >&2; }
log::error() { echo "[ERROR] $(date '+%Y-%m-%dT%H:%M:%S') $*" >&2; }
log::info "Starting deployment of ${VERSION} to ${ENVIRONMENT}"
Errors and warnings go to stderr (>&2), so they don't pollute captured output and show up in the right place in CI logs.
Know When to Stop Writing Bash
This is the most important tip. Every time you reach for Bash to solve a complex problem, ask yourself:
- Do I need data structures more complex than arrays?
- Am I parsing JSON or YAML?
- Does this script need to be maintained by people who don't know Bash well?
- Is this script over ~200 lines and still growing?
Python is the next comon denominator as it is installed on 99% of linux and BSD based operating systems. But any language you are most comfortable works.
Bash is brilliant for gluing tools together, automating sequences of commands, and writing short, focused scripts. It's a poor choice for business logic, complex data processing, or anything that needs real error handling.
A good rule of thumb: if you're writing functions that call other functions that call other functions, you've probably outgrown Bash.
Next Step or Further Reading
If you're drowning in a sea of bash, our team is here to help you wade through it all. Let's have a chat!
- Google Shell Style Guide — The definitive style reference. Read it once, bookmark it forever.
- ShellCheck — Lint your scripts. Add it to your CI pipeline. It will catch things you'd never think to check.
- Bash Strict Mode — A deeper dive into
set -euo pipefailand the thinking behind it. - explainshell.com — Paste any Bash command and get a plain-English breakdown. Great for onboarding teammates. This does use LLM's so do not paste proprietary code
- The Bash Hackers Wiki — Deep reference material when you need to go beyond the basics.