- Bash Scripting Best Practices
- Guidance for writing reliable, maintainable bash scripts following modern best practices. Emphasises simplicity, automated tooling, and defensive programming without over-engineering.
- When to Use Shell (and When Not To)
- Use Shell For:
- Small utilities and simple wrapper scripts (<100 lines)
- Orchestrating other programmes and tools
- Simple automation tasks
- Build/deployment scripts with straightforward logic
- Quick data transformation pipelines
- Do NOT Use Shell For:
- Complex business logic or data structures
- Performance-critical code
- Scripts requiring extensive error handling
- Anything over ~100 lines or with non-straightforward control flow
- When you need proper data structures beyond arrays
- Critical
- If your script grows too large (1000+ lines) or complex, consider offering to rewrite it in a proper language (Python, Go, etc.) before it becomes unmaintainable. Mandatory Foundations Every bash script must have these elements: 1. Proper Shebang
!/usr/bin/env bash
- Why
- Portable across systems where bash may not be at /bin/bash (e.g., macOS, BSD, NixOS). Alternative :
!/bin/bash
- if you know the script only runs on Linux and prefer explicit paths.
- 2. Strict Mode
- set
- -euo
- pipefail
- What each flag does:
- -e
-
- Exit immediately if any command fails (non-zero exit)
- -u
-
- Treat unset variables as errors
- -o pipefail
-
- Pipe fails if ANY command in pipeline fails (not just the last)
- When to add
- -x
- Only for debugging, not in production scripts (makes output noisy). 3. ShellCheck Compliance Run ShellCheck on EVERY script before committing: shellcheck script.sh Fix all warnings. ShellCheck catches: Unquoted variables Deprecated syntax Common bugs and pitfalls Portability issues 4. Basic Script Structure
!/usr/bin/env bash
set -euo pipefail
Brief description of what this script does
Simple error reporting
die ( ) { echo "Error: ${1} "
&2 exit 1 }
Your code here
- Core Safety Patterns
- Always Quote Variables
- Why
- Prevents word splitting and globbing disasters.
Wrong - dangerous
cp $source $destination rm -rf $prefix /bin
Correct - safe
cp " ${source} " " ${destination} " rm -rf " ${prefix} /bin"
Special case: Always use braces with variables
echo " ${var} "
Good
echo " $var "
Acceptable but less consistent
echo $var
Bad - unquoted
Check Required Variables
Fail fast if required variables aren't set
: " ${REQUIRED_VAR :? REQUIRED_VAR must be set} "
Or with custom message
: " ${DATABASE_URL :? DATABASE_URL is required. Set it in .env} " Validate Inputs
Check file exists before operating on it
[ [ -f " ${config_file} " ] ] || die "Config file not found: ${config_file} "
Check command exists before using it
command -v jq
/dev/null 2
&1 || die "jq is required but not installed"
Validate directory before cd
[ [ -d " ${target_dir} " ] ] || die "Directory does not exist: ${target_dir} " Essential Patterns Pattern 1: Simple Script Template Use this for straightforward scripts:
!/usr/bin/env bash
set -euo pipefail
Description: Process log files and extract errors
die ( ) { echo "Error: ${1} "
&2 exit 1 }
Check dependencies
command -v jq
/dev/null 2
&1 || die "jq required"
Validate arguments
[
[
$#
-eq
1
]
]
||
die
"Usage:
${0}
Main logic
grep ERROR " ${logfile} " | jq -r '.message' Pattern 2: Cleanup on Exit Use trap for guaranteed cleanup:
!/usr/bin/env bash
set -euo pipefail
Create temp directory and ensure cleanup
tmpdir
$( mktemp -d ) trap 'rm -rf "${tmpdir}"' EXIT
Now use tmpdir safely - cleanup happens automatically
echo "Working in: ${tmpdir} " Pattern 3: Safe Function Definition Functions should be simple and focused:
Good: Simple, single-purpose function
check_dependency ( ) { local cmd = " ${1} " command -v " ${cmd} "
/dev/null 2
&1 || die " ${cmd} not installed" }
Good: Local variables, clear purpose
process_file ( ) { local file = " ${1} " local output = " ${2} " [ [ -f " ${file} " ] ] || die "Input file missing: ${file} "
Do processing
- sed
- 's/foo/bar/g'
- "
- ${file}
- "
- >
- "
- ${output}
- "
- }
- Important
- Declare and set variables from command substitution separately to catch errors:
Wrong - hides errors
local result = " $( failing_command ) "
Correct - catches errors
local result result = " $( failing_command ) "
Will fail properly with set -e
Pattern 4: Safe Array Handling Arrays are useful for handling lists with spaces:
Create array
declare -a files = ( "file one.txt" "file two.txt" "file three.txt" )
Iterate safely - always quote with [@]
for file in " ${files [ @ ] } " ; do echo "Processing: ${file} " done
Build command arguments safely
declare -a flags = ( --verbose --output " ${output_file} " ) mycommand " ${flags [ @ ] } " " ${input} "
Read command output into array
mapfile -t lines < < ( grep pattern " ${file} " ) Pattern 5: Conditional Testing Use [[ ]] for bash (safer and more features):
File tests
[ [ -f " ${file} " ] ]
File exists
[ [ -d " ${dir} " ] ]
Directory exists
[ [ -r " ${file} " ] ]
File readable
[ [ -w " ${file} " ] ]
File writable
[ [ -x " ${binary} " ] ]
File executable
String tests
[ [ -z " ${var} " ] ]
String is empty
[ [ -n " ${var} " ] ]
String is not empty
[ [ " ${a} " == " ${b} " ] ]
String equality (use ==, not =)
Numeric comparison (use (( )) for numbers)
(( count
0 )) (( total = minimum ))
Combined conditions
[ [ -f " ${file} " && -r " ${file} " ] ] || die "File not readable: ${file} " Pattern 6: Simple Argument Handling For simple scripts, prefer positional arguments:
!/usr/bin/env bash
set -euo pipefail
For 1-3 arguments, just use positional parameters
[
[
$#
-eq
2
]
]
||
die
"Usage:
${0}
Use environment variables instead of complex flag parsing
VERBOSE
" ${VERBOSE :- false} " DRY_RUN = " ${DRY_RUN :- false} "
Run like: VERBOSE=true DRY_RUN=true ./script.sh input.txt
Pattern 7: Process Substitution Over Temp Files Avoid creating temporary files when possible:
Instead of:
first_command
/tmp/output.txt second_command < /tmp/output.txt rm /tmp/output.txt
Use process substitution:
second_command < ( first_command )
For multiple inputs:
diff < ( sort file1.txt ) < ( sort file2.txt ) Pattern 8: Prefer Builtins Over External Commands Builtins are faster and more reliable:
Use bash parameter expansion over sed/awk for simple cases
filename
" ${path
* / } "
basename
dirname
" ${path % / *} "
dirname
extension
" ${filename
*.} "
get extension
name
" ${filename % .*} "
remove extension
Use (( )) for arithmetic over expr
count
$(( count + 1 ))
Not: count=$(expr ${count} + 1)
Use [[ ]] over [ ] or test
[ [ -f " ${file} " ] ]
Not: test -f "${file}"
Use ${#var} for string length
length
" ${
string} "
Not: length=$(echo "${string}" | wc -c)
Intermediate Patterns Pattern 9: Structured Logging Keep logging simple and consistent: log ( ) { echo "[ $( date + '%Y-%m-%d %H:%M:%S' ) ] ${1} "
&2 } error ( ) { echo "[ $( date + '%Y-%m-%d %H:%M:%S' ) ] ERROR: ${1} "
&2 }
Usage
log "Starting process" error "Failed to connect to database" Pattern 10: Main Function Pattern For longer scripts (50+ lines), use a main function:
!/usr/bin/env bash
set -euo pipefail setup ( ) {
Dependency checks, variable initialisation
command -v jq
/dev/null 2
&1 || die "jq required" } process ( ) {
Main logic here
log "Processing data" } cleanup ( ) {
Cleanup if needed
log "Cleanup complete" } main ( ) { setup process cleanup }
Call main with all script arguments
main " ${@} " Pattern 11: Idempotent Operations Scripts should be safe to run multiple times:
Check before creating
if [ [ ! -d " ${target_dir} " ] ] ; then mkdir -p " ${target_dir} " fi
Check before writing config
if [ [ ! -f " ${config_file} " ] ] ; then echo "DEFAULT_VALUE=true"
" ${config_file} " fi
Use atomic operations
mv " ${source} " " ${dest} "
Atomic on same filesystem
Pattern 12: Safe While Loop Reading Don't pipe to while (creates subshell):
Wrong - variables modified in subshell are lost
count
0 cat file.txt | while read -r line ; do (( count ++ )) done echo " ${count} "
Will be 0!
Correct - use process substitution
count
0 while read -r line ; do (( count ++ )) done < < ( cat file.txt ) echo " ${count} "
Correct count
Or use mapfile for simple cases
mapfile -t lines < file.txt count = " ${
- lines
- [
- @
- ]
- }
- "
- Style Guidelines
- Formatting
- Indentation
-
- 2 spaces, never tabs
- Line length
-
- Maximum 120 characters
- Long strings
- Use here-documents or embedded newlines
Long command - break at logical points
docker run \ --name my-container \ --volume " ${ PWD } :/data" \ --env "FOO=bar" \ my-image:latest
Long string - use here-doc
cat << EOF This is a long message that spans multiple lines and is more readable this way. EOF Naming Conventions
Functions: lowercase with underscores
check_dependencies ( ) { .. . } process_files ( ) { .. . }
Local variables: lowercase with underscores
local input_file = " ${1} " local line_count = 0
Constants/environment variables: UPPERCASE with underscores
readonly MAX_RETRIES = 3 readonly CONFIG_DIR = "/etc/myapp"
Source files: lowercase with underscores
my_library.sh
- File Extensions
- Executables
- :
- .sh
- extension OR no extension (prefer no extension for user-facing commands)
- Libraries
- Always .sh extension and NOT executable Function Documentation Document functions that aren't obvious:
Good: Simple function, no comment needed (name says it all)
check_file_exists ( ) { [ [ -f " ${1} " ] ] }
Good: Complex function, documented
Processes log files and extracts error messages
Arguments:
$1 - Input log file path
$2 - Output directory
Returns:
0 on success, 1 on failure
process_logs ( ) { local logfile = " ${1} " local output_dir = " ${2} "
Implementation
} What to Avoid Don't Use These
Don't use backticks - use $()
output
command
Old style
output
$( command )
Correct
Don't use eval - almost always wrong
eval " ${user_input} "
Dangerous!
Don't use expr - use (( ))
result
$( expr 5 + 3 )
Old style
result
$(( 5 + 3 ))
Correct
Don't use [ ] when [[ ]] is available
[ -f " ${file} " ]
POSIX compatible, less features
[ [ -f " ${file} " ] ]
Bash, safer and more features
Don't use $[ ] for arithmetic - deprecated
result
$ [ 5 + 3 ]
Deprecated
result
$(( 5 + 3 ))
Correct
Don't use function keyword unnecessarily
function foo ( ) { .. . }
Redundant
foo ( ) { .. . }
Cleaner
Anti-Patterns
Don't glob or split unquoted
rm ${files}
DANGEROUS
rm " ${files} "
Safe
Don't use ls output in scripts
for file in $( ls ) ; do
Breaks with spaces
for file in * ; do
Correct
Don't pipe yes to commands
yes | risky-command
Bypasses important prompts
Don't ignore error codes
make build
Did it work?
make build || die "Build failed"
Better
Complexity Warning Signs If your script has any of these, consider rewriting in Python/Go: More than 100 lines Complex data structures beyond simple arrays Nested loops over arrays of arrays Heavy string manipulation logic Complex state management Mathematical calculations beyond basic arithmetic Need for unit testing individual functions JSON/YAML parsing beyond simple jq queries Advanced: Dry-Run Pattern For scripts that modify things: DRY_RUN = " ${DRY_RUN :- false} " run ( ) { if [ [ " ${DRY_RUN} " == "true" ] ] ; then echo "[DRY RUN] ${*} "
&2 return 0 fi " ${@} " }
Usage
run cp " ${source} " " ${dest} " run rm -f " ${old_file} "
Run script: DRY_RUN=true ./script.sh
Quick Reference Checklist Before considering a bash script complete: ShellCheck passes with no warnings Has proper shebang (
!/usr/bin/env bash
- )
- Has strict mode (
- set -euo pipefail
- )
- All variables quoted (
- "${var}"
- )
- Required dependencies checked
- Proper error messages to stderr
- Cleanup trap if using temp files
- Script is idempotent where possible
- Under 100 lines (or has strong justification)
- Uses
- command -v
- not
- which
- Arrays used for lists with spaces
- No
- eval
- ,
- ls
- parsing, or backticks
- Functions have local variables
- Summary
- Start simple
-
- Don't over-engineer. Most scripts should be <50 lines.
- Use ShellCheck
-
- It catches most problems automatically.
- Quote everything
- :
- "${var}"
- not
- $var
- .
- Fail fast
- :
- set -euo pipefail
- and validate inputs.
- Know when to stop
-
- If it's getting complex, use a real language.
- Compose don't complicate
-
- Use pipes and process substitution.
- Be idempotent
-
- Scripts should be safe to run multiple times.
- Test error paths
- Make sure your script fails safely. Remember: Shell scripts are for gluing things together, not building complex logic. Keep them simple, safe, and focused.