Shell scripting basics
Turn a sequence of commands into a reusable script you can run with one word — variables, arguments, conditionals, loops, and exit codes explained.
What you'll learn
- How to write and run a bash script with the shebang line and executable bit
- Variables, positional arguments, and command substitution
- Conditionals, loops, exit codes, and the safety preamble
Before you start
A shell script is nothing exotic. It is just the commands you already know, saved to a file, with a little logic gluing them together. Instead of retyping (and occasionally mistyping) the same sequence, you write it once, make it executable, and invoke it by name. That is the entire idea.
The shebang — telling the system which interpreter to use
Create a new file called backup.sh. The very first line must be a shebang (pronounced shuh-bang), which tells the operating system what program should read the script:
#!/usr/bin/env bash
The #! characters are the signal; /usr/bin/env bash asks the system to find bash in your PATH rather than hard-coding a path that might differ between machines. Every script in this lesson starts with that line.
Making a script executable
A brand-new file has no execute permission. The chmod command (change mode) adds it:
chmod +x backup.sh
Now run it with ./ in front — the ./ tells the shell “look in the current directory”:
./backup.sh
Without ./, the shell only searches directories listed in PATH and will not find your script unless you have deliberately added the current directory there (which is a security risk).
Variables
A variable stores a value you want to reuse. The assignment syntax has no spaces around =:
name="Alice"
count=42
Retrieve the value by prefixing the name with $. Always wrap it in double quotes when you use it — this is the single most important habit in shell scripting:
echo "Hello, $name" # Hello, Alice
echo "Count is $count" # Count is 42
The safety preamble
Place this immediately after the shebang in every script you write:
set -euo pipefail
It does three things:
| Flag | What it does |
|---|---|
-e | Exit immediately if any command returns a non-zero status |
-u | Treat an unset variable as an error instead of silently expanding to nothing |
-o pipefail | Fail the whole pipeline if any command in it fails, not just the last one |
Without this, a script cheerfully continues after a failed step, often making a mess that is hard to untangle. With it, the script stops at the first sign of trouble.
Positional arguments
When a user passes arguments to your script, bash stores them automatically:
| Variable | Meaning |
|---|---|
$1, $2, … | The first argument, second argument, and so on |
$@ | All arguments as separate words |
$# | The count of arguments |
#!/usr/bin/env bash
set -euo pipefail
echo "Script name : $0"
echo "First arg : $1"
echo "All args : $@"
echo "Arg count : $#"
Run it as ./args.sh hello world and you get:
Script name : ./args.sh
First arg : hello
All args : hello world
Arg count : 2
Command substitution
Command substitution captures the output of a command and assigns it to a variable. Use $( ):
today=$(date +%Y-%m-%d)
file_count=$(ls *.csv | wc -l)
echo "Today is $today and there are $file_count CSV files."
The backtick syntax `command` is older and does the same thing, but $( ) is preferred because it nests cleanly.
Conditionals
The if statement tests a condition and runs different blocks based on the result. Bash uses double brackets [[ ]] for the test:
if [[ condition ]]; then
echo "true branch"
elif [[ other_condition ]]; then
echo "elif branch"
else
echo "false branch"
fi
Common tests:
| Test | Meaning |
|---|---|
-f "$path" | File exists and is a regular file |
-d "$path" | Path exists and is a directory |
-z "$var" | Variable is empty (zero length) |
"$a" = "$b" | Strings are equal |
"$a" != "$b" | Strings are not equal |
$n -eq $m | Integers are equal |
$n -gt $m | Integer n is greater than m |
$n -lt $m | Integer n is less than m |
Example — check that the user supplied at least one argument:
if [[ $# -lt 1 ]]; then
echo "Usage: $0 <folder>" >&2
exit 1
fi
Loops
A for loop iterates over a list of words:
for item in apple banana cherry; do
echo "Fruit: $item"
done
More usefully, loop over files that match a pattern:
for f in *.csv; do
echo "Processing $f"
done
Exit codes
Every command reports a exit code (also called a return status) when it finishes: 0 means success, anything else means failure. You can read the exit code of the last command from the special variable $?:
grep "error" logfile.txt
echo "grep exited with: $?"
Your own script should exit with a meaningful code. Use exit 0 for success and exit 1 (or any non-zero value) for failure. Callers — including other scripts, CI pipelines, and make — rely on this.
A complete real script: back up CSV files
Here is a script that copies every .csv file in a source folder into a timestamped backup folder, then reports how many lines each file contains.
#!/usr/bin/env bash
set -euo pipefail
# ---------- configuration ----------
SOURCE_DIR="${1:?Usage: $0 <source-folder>}"
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
BACKUP_DIR="${SOURCE_DIR}_backup_${TIMESTAMP}"
# ---------- guard: source must exist ----------
if [[ ! -d "$SOURCE_DIR" ]]; then
echo "Error: '$SOURCE_DIR' is not a directory." >&2
exit 1
fi
# ---------- create the backup folder ----------
mkdir -p "$BACKUP_DIR"
echo "Backing up CSV files from '$SOURCE_DIR' -> '$BACKUP_DIR'"
# ---------- copy files and report line counts ----------
copied=0
for csv_file in "$SOURCE_DIR"/*.csv; do
# If the glob matches nothing, the literal string is returned; skip it.
if [[ ! -f "$csv_file" ]]; then
echo "No CSV files found in '$SOURCE_DIR'." >&2
exit 0
fi
cp "$csv_file" "$BACKUP_DIR/"
line_count=$(wc -l < "$csv_file")
echo " Copied $(basename "$csv_file") ($line_count lines)"
(( copied++ ))
done
echo "Done. $copied file(s) backed up."
exit 0
Save this as csv-backup.sh, run chmod +x csv-backup.sh, then call it:
./csv-backup.sh ~/data/reports
Sample output:
Backing up CSV files from '/Users/alice/data/reports' -> '/Users/alice/data/reports_backup_20260605_091530'
Copied sales_q1.csv (1042 lines)
Copied sales_q2.csv (987 lines)
Copied summary.csv (14 lines)
Done. 3 file(s) backed up.
A few things to notice in the script:
"${1:?Usage: $0 ...}"is a parameter expansion with a required-value check — if$1is missing, bash prints the message and exits immediately. Noifblock needed.wc -l < "$csv_file"redirects the file intowcso the output is a bare number with no filename attached.(( copied++ ))is integer arithmetic; double parentheses are the right tool for that.- Every variable that holds a path is double-quoted.
Script anatomy at a glance
What to learn next
- Functions let you name and reuse blocks inside a single script — the natural next step once your script grows past ~30 lines.
shellcheckis a free linter that catches quoting bugs and common mistakes before you run the script. Install it withbrew install shellcheckor your package manager.- The
cli/environment-and-pathlesson explains how to put your scripts onPATHso you can call them without./from any directory.