datarekha

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.

10 min read Intermediate Command Line Lesson 13 of 14

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:

FlagWhat it does
-eExit immediately if any command returns a non-zero status
-uTreat an unset variable as an error instead of silently expanding to nothing
-o pipefailFail 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:

VariableMeaning
$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:

TestMeaning
-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 $mIntegers are equal
$n -gt $mInteger n is greater than m
$n -lt $mInteger 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 $1 is missing, bash prints the message and exits immediately. No if block needed.
  • wc -l < "$csv_file" redirects the file into wc so 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

shebang#!/usr/bin/env bashsafety preambleset -euo pipefailvariables + args$1, $name, $(…)logic + exit codeif / for / exit 0Every path variable is double-quoted.Exit 0 = success. Exit non-zero = failure.set -euo pipefail stops silent failures.
The four zones of a well-structured bash script.

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.
  • shellcheck is a free linter that catches quoting bugs and common mistakes before you run the script. Install it with brew install shellcheck or your package manager.
  • The cli/environment-and-path lesson explains how to put your scripts on PATH so you can call them without ./ from any directory.

Quick check

0/3
Q1Which line must appear first in a bash script so the operating system knows how to run it?
Q2A variable holds the value 'hello world' (with a space). What happens when you run: rm $filename
Q3You inherit a script that processes files in a loop. Halfway through, one command silently fails and corrupts an output file — but the script keeps running and overwrites several more files. Which addition at the top of the script would have stopped execution at the first failure?

Sign in to track your progress

Completed lessons, your XP, level, and streak save to your account — it's free and takes a few seconds.

Explore further

Cheat sheets

Related lessons

Skip to content