Bash Scripting for Beginners
Learn Bash scripting from scratch: shebang lines, variables, conditionals, loops, and arguments, plus a real backup script to tie it all together.
Before you start
- ▸A terminal emulator and access to a Bash shell (Bash 4.x or later recommended)
- ▸A text editor such as nano, vim, or VS Code
- ▸Basic familiarity with running commands in a terminal
Bash scripts let you chain commands, automate repetitive tasks, and build tools that run the same way every time. You don't need a programming background — if you can type commands in a terminal, you can write scripts. This guide walks through the core building blocks: the shebang line, variables, conditionals, loops, and command-line arguments, finishing with a small but complete example script.
The Shebang Line
Every Bash script starts with a shebang — the #! characters on line one, followed by the path to the interpreter. This tells the kernel what program should run the file.
#!/usr/bin/env bash
Using /usr/bin/env bash is preferred over the hard-coded /bin/bash because it finds Bash in your PATH, which matters on systems where Bash lives somewhere unexpected (macOS with Homebrew, for example). Always make the script executable before running it:
chmod +x myscript.sh
./myscript.sh
Add set -euo pipefail directly after the shebang in every script you write. -e exits on error, -u treats undefined variables as errors, and -o pipefail catches failures inside pipelines. These three flags prevent a huge class of silent bugs.
#!/usr/bin/env bash
set -euo pipefail
Variables
Variables in Bash are assigned without spaces around the = sign. Quote variable expansions with double quotes to avoid word-splitting surprises when values contain spaces.
name="Alice"
greeting="Hello, ${name}!"
echo "$greeting"
Use curly braces (${name}) when the variable name is followed immediately by text that would otherwise be ambiguous, or just as a consistent habit. Bash has no types by default — everything is a string unless you declare otherwise.
Read-only and Integer Variables
readonly MAX_RETRIES=5
declare -i count=0 # treated as integer; arithmetic ops work directly
count+=1
echo "$count" # prints: 1
Command Substitution
Capture the output of a command into a variable using $(). Avoid the older backtick syntax — it doesn't nest cleanly.
current_date=$(date +%Y-%m-%d)
echo "Today is $current_date"
Conditionals
The if statement tests an exit code. The [[ ]] compound command is the modern Bash test — prefer it over the POSIX [ ] because it handles empty variables safely and supports &&, ||, and regex matching natively.
file="/etc/hostname"
if [[ -f "$file" ]]; then
echo "File exists"
elif [[ -d "$file" ]]; then
echo "That path is a directory"
else
echo "Not found"
fi
Common Test Operators
| Operator | True when |
|---|---|
-f file | File exists and is a regular file |
-d path | Path exists and is a directory |
-z "$var" | String is empty |
-n "$var" | String is non-empty |
"$a" == "$b" | Strings are equal |
"$a" != "$b" | Strings differ |
$a -eq $b | Integers are equal |
$a -lt $b | Integer a less than b |
Arithmetic Conditionals
Use (( )) for integer comparisons — it's cleaner than -lt and friends inside [[ ]].
x=10
if (( x > 5 )); then
echo "x is greater than 5"
fi
Loops
For Loop — Iterating a List
for distro in ubuntu fedora arch debian; do
echo "Distro: $distro"
done
For Loop — C-Style Counter
for (( i=1; i<=5; i++ )); do
echo "Iteration $i"
done
While Loop
count=0
while (( count < 3 )); do
echo "count is $count"
(( count++ ))
done
Looping Over Files
Quote the glob to avoid issues if no files match. Enable nullglob when you want the loop to simply skip if nothing matches.
shopt -s nullglob
for logfile in /var/log/*.log; do
echo "Processing: $logfile"
done
Handling Arguments
Positional parameters $1, $2, etc. hold arguments passed on the command line. $0 is the script name, $# is the argument count, and $@ expands to all arguments as separate words.
#!/usr/bin/env bash
set -euo pipefail
if [[ $# -lt 1 ]]; then
echo "Usage: $0 " >&2
exit 1
fi
echo "Hello, $1!"
Always redirect error and usage messages to stderr with >&2, and exit with a non-zero code on failure. Tools and scripts that call yours depend on those exit codes.
Iterating All Arguments
for arg in "$@"; do
echo "Got argument: $arg"
done
Writing a Complete Script
The following script backs up a directory passed as the first argument, names the archive with the current date, and reports the result. It demonstrates every concept covered above.
#!/usr/bin/env bash
set -euo pipefail
# --- configuration ---
DEST_DIR="${HOME}/backups"
DATE=$(date +%Y-%m-%d)
# --- argument check ---
if [[ $# -ne 1 ]]; then
echo "Usage: $0 " >&2
exit 1
fi
SOURCE_DIR="$1"
# --- validate source ---
if [[ ! -d "$SOURCE_DIR" ]]; then
echo "Error: '$SOURCE_DIR' is not a directory." >&2
exit 1
fi
# --- create destination if needed ---
if [[ ! -d "$DEST_DIR" ]]; then
mkdir -p "$DEST_DIR"
echo "Created backup directory: $DEST_DIR"
fi
# --- build archive name ---
BASENAME=$(basename "$SOURCE_DIR")
ARCHIVE="${DEST_DIR}/${BASENAME}_${DATE}.tar.gz"
# --- run backup ---
echo "Backing up '$SOURCE_DIR' to '$ARCHIVE' ..."
tar -czf "$ARCHIVE" -C "$(dirname "$SOURCE_DIR")" "$BASENAME"
echo "Done. Archive size: $(du -sh "$ARCHIVE" | cut -f1)"
Save this as backup.sh, make it executable, and run it:
chmod +x backup.sh
./backup.sh ~/Documents
Verification
Check your script for syntax errors without running it:
bash -n myscript.sh
For deeper static analysis, install shellcheck — it catches common pitfalls like unquoted variables and deprecated syntax.
# Debian/Ubuntu
sudo apt install shellcheck
# Fedora / RHEL family
sudo dnf install shellcheck
# Arch
sudo pacman -S shellcheck
shellcheck myscript.sh
Run your script with bash -x to print each command as it executes — invaluable for debugging:
bash -x ./backup.sh ~/Documents
Troubleshooting
- "Permission denied" when running the script — you forgot
chmod +x, or you're calling it assh script.sh(which ignores the shebang). Use./script.shafter making it executable. - Script exits unexpectedly mid-run —
set -eis working correctly; a command returned a non-zero exit code. Run withbash -xto see which one, then either fix the command or add|| trueif failure is acceptable at that step. - Variable is empty when you expect a value —
set -uwill catch references to undefined variables. Check spelling and that the variable was exported if it comes from a parent shell. - Loop processes filenames with spaces incorrectly — always quote
"$@"and variable expansions. Never parselsoutput; use globs orfindwith-print0andread -rd.
Frequently asked questions
- What's the difference between [ ] and [[ ]] in Bash?
- [[ ]] is a Bash built-in that handles empty variables safely, supports && and || without quoting issues, and allows pattern and regex matching. [ ] is the POSIX test command and has more edge cases; use [[ ]] in Bash scripts.
- Why does my script run fine manually but fail in cron?
- Cron runs with a minimal environment — your PATH, aliases, and shell functions are not loaded. Use full paths to commands in cron scripts, or source a profile explicitly. Also make sure the script is executable and the shebang is correct.
- Should I use #!/bin/bash or #!/usr/bin/env bash?
- #!/usr/bin/env bash is generally safer because it searches PATH for Bash, which helps on systems where Bash is not at /bin/bash. For system scripts that run before PATH is fully set, /bin/bash is more predictable.
- How do I handle optional arguments or flags in a script?
- For simple cases, check whether $1 equals a flag string inside an if block. For anything more complex — multiple flags, values, optional vs required — use getopts, which is built into Bash and handles argument parsing reliably.
- What does set -euo pipefail actually protect against?
- -e exits the script the moment any command fails, -u causes an error if you reference an unset variable, and -o pipefail makes the script catch failures in any stage of a pipeline rather than only looking at the last command's exit code.
Related guides
Bash Arrays and Associative Arrays
Master bash indexed and associative arrays: declaration, element access, looping, mapfile, namerefs, and practical patterns for real scripting work.
Bash Functions and Variable Scoping
Master Bash function scoping with local variables, source-based libraries, correct use of return codes, and array passing techniques including namerefs.
Bash Loops: for, while and until
Learn all three Bash loop types — for, while, and until — with practical, copy-paste examples covering file iteration, counting, polling, and safe line reading.
Build a Real Command-Line Tool in Shell
Build a proper CLI tool in Bash with strict mode, long/short argument parsing, --help, usage(), and clean packaging via install and a Makefile.