Your First Script
A bash script is a text file containing shell commands that runs top to bottom. The first line — the shebang — tells the OS which interpreter to use.
#!/usr/bin/env bash
# The above line makes the script use whatever bash is in PATH
echo "Hello, World!"
Make it executable and run it:
chmod +x hello.sh # Grant execute permission (one time only)
./hello.sh # Run it
The #!/usr/bin/env bash shebang is more portable than #!/bin/bash because it finds bash in PATH rather than assuming it’s at /bin/bash (which isn’t always true on macOS).
Variables and String Interpolation
# Assign a variable (no spaces around =)
name="Alice"
count=42
# Access with $
echo "Hello, $name" # Hello, Alice
echo "Count is: $count" # Count is: 42
# Curly braces for clarity (required before more text)
echo "Hello, ${name}s team" # Hello, Alices team
# Default value if variable is unset or empty
echo "${name:-Anonymous}" # Alice (uses name)
unset name
echo "${name:-Anonymous}" # Anonymous (uses default)
# String length
echo "${#name}" # 5 (length of "Alice")
Single quotes vs double quotes:
- Double quotes
"..."— variables are expanded:"Hello, $name"→Hello, Alice - Single quotes
'...'— no expansion:'Hello, $name'→Hello, $name(literal)
Command Substitution
Capture the output of a command into a variable using $(...):
# Get the current directory name
current_dir=$(pwd)
echo "You are in: $current_dir"
# Get today's date
today=$(date +%Y-%m-%d)
echo "Today is: $today"
# Count files in a directory
file_count=$(ls -1 src/ | wc -l)
echo "src/ has $file_count files"
# Use in string interpolation
echo "Branch: $(git branch --show-current)"
Conditionals
# Basic if/elif/else
if [ "$name" = "Alice" ]; then
echo "Hello, Alice!"
elif [ "$name" = "Bob" ]; then
echo "Hey, Bob."
else
echo "Who are you?"
fi
# File tests
if [ -f "config.json" ]; then
echo "Config file exists"
fi
if [ -d "logs" ]; then
echo "Logs directory exists"
else
mkdir logs
fi
# Numeric comparisons (-eq, -ne, -lt, -le, -gt, -ge)
if [ "$count" -gt 10 ]; then
echo "Count exceeds 10"
fi
# String emptiness (-z = empty, -n = non-empty)
if [ -z "$name" ]; then
echo "Name is empty"
fi
if [ -n "$API_KEY" ]; then
echo "API key is set"
fi
Use [[ ... ]] (double brackets) for more features: regex matching, no word splitting issues, and &&/|| inside the test:
if [[ "$url" =~ ^https:// ]]; then
echo "Secure URL"
fi
Loops
for loop — iterate over files:
for file in logs/*.log; do
echo "Processing: $file"
gzip "$file"
done
for loop — iterate over a list:
for env in dev staging prod; do
echo "Deploying to $env..."
./deploy.sh "$env"
done
while loop — read lines from a file:
while IFS= read -r line; do
echo "Line: $line"
done < input.txt
while loop — counter:
count=0
while [ $count -lt 5 ]; do
echo "Count: $count"
count=$((count + 1))
done
Functions
# Define a function
greet() {
local name="$1" # local = scoped to function, $1 = first argument
echo "Hello, $name!"
}
# Call it
greet "Alice" # Hello, Alice!
greet "Bob" # Hello, Bob!
# Return a value via echo + command substitution
get_timestamp() {
echo "$(date +%s)"
}
start=$(get_timestamp)
sleep 2
end=$(get_timestamp)
echo "Elapsed: $((end - start)) seconds"
Functions use $1, $2, … for arguments, just like scripts. Always use local for variables inside functions to prevent them from leaking into the global scope.
Exit Codes and set -euo pipefail
Every command returns an exit code: 0 = success, non-zero = failure.
ls /nonexistent 2>/dev/null
echo "Exit code: $?" # 2 (ls failed)
cp file.txt dest/
echo "Exit code: $?" # 0 (success)
Add these at the top of scripts to make failures explicit:
#!/usr/bin/env bash
set -euo pipefail
# -e = exit immediately if any command fails
# -u = treat unset variables as errors
# -o pipefail = if any command in a pipe fails, the whole pipe fails
Without set -e, a failed command is silently ignored and the script keeps running — often producing confusing results.
Accepting Arguments
Scripts receive arguments as $1, $2, etc.:
#!/usr/bin/env bash
set -euo pipefail
# $0 = script name
# $1, $2, ... = positional arguments
# $@ = all arguments as separate words
# $# = number of arguments
if [ "$#" -lt 2 ]; then
echo "Usage: $0 <source> <destination>"
exit 1
fi
source="$1"
destination="$2"
echo "Copying $source to $destination..."
cp "$source" "$destination"
echo "Done."
Run it:
./copy.sh file.txt backup/file.txt
Always quote "$1" and "$@" to handle filenames with spaces correctly.
Make Scripts Boring And Predictable
Good shell scripts are usually boring. They validate input, fail clearly, and do one job. The goal is not to show off clever Bash syntax. The goal is to automate a task so you can trust it when you are tired, rushed, or running it in CI.
Start scripts with a short comment that says what the script does and what arguments it expects. Then validate those arguments before doing work. If a script expects a filename and the filename is missing, print a useful message and exit with a non-zero status.
if [ -z "$1" ]; then
echo "Usage: ./backup.sh <file>"
exit 1
fi
Use Strict Mode Carefully
Many scripts benefit from set -euo pipefail. It makes the script exit on errors, undefined variables, and failed pipeline commands. That is useful, but it also means you need to handle expected failures deliberately.
set -euo pipefail
If a command is allowed to fail, make that explicit with an if statement. Avoid hiding failures with broad || true unless you leave a comment explaining why the failure is safe.
Logging And Dry Runs
For scripts that change files, add a dry-run mode. A dry run prints what would happen without doing it. This is especially useful for cleanup, deployment, backup, and migration scripts.
Even simple logging helps. Print the file or directory being processed, the destination being written, and a final success message. When a script fails halfway through, those messages tell you where to resume.
Common Mistakes
The classic mistake is forgetting quotes around variables. Without quotes, filenames with spaces are split into multiple arguments. Another common mistake is assuming the script runs from a specific directory. If relative paths matter, resolve the script directory first or document where the script must be run.
Avoid parsing complex formats with fragile shell pipelines. For JSON, use jq when available. For YAML, use a real parser. Bash is excellent glue, but it is not the best tool for every data transformation.
What I Would Do In Practice
I would use Bash for small automation tasks: cleaning generated files, running a repeatable local workflow, wrapping a few commands, or wiring CI steps together. Once a script grows complex enough to need nested parsing, retries, or data structures, I would move to Python, TypeScript, or another language with stronger error handling.
The best Bash scripts are easy to delete later. They capture a workflow, make it repeatable, and stay clear enough that another developer can debug them without becoming a shell expert.
A Script Review Checklist
Before sharing a Bash script, read it like a teammate will run it on a fresh machine. Does it say what tools it requires? Does it fail clearly when a command is missing? Does it quote variables that may contain spaces? Does it avoid assuming the user is in a specific directory?
For scripts that change files, print the target paths and support a dry-run mode. For scripts that call external services, make credentials explicit through environment variables and never hard-code secrets. For scripts used in CI, keep output concise enough that failures are easy to find in logs.
The best test is simple: run the script twice. A good automation script should either be idempotent or clearly explain why the second run changes something.
When Not To Use Bash
Bash is excellent for connecting commands, moving files, and running repeatable project tasks. It is weaker for complex data structures, large JSON transformations, user interfaces, and long-running services.
If the script needs nested parsing, retries with backoff, structured logging, or extensive tests, consider a language like Python or TypeScript. Choosing a clearer tool is not a failure. It is part of writing automation that someone else can maintain.