# Bash Scripting Tutorial — Write Your First Useful Script

URL: https://www.devopsness.com/blog/bash-scripting-tutorial-first-script
Author: Kiril Urbonas
Published: 2026-04-29
Updated: 2026-08-22
Categories: Linux
Tags: Bash, Shell, Linux, Scripting, Tutorial, Beginner

Summary: Build a real disk-cleanup script step by step. Learn variables, conditionals, loops, error handling, and the safety preamble that prevents foot-guns.

---
# Bash Scripting Tutorial: Write Your First Useful Script

By the end of this post you'll have written a disk-cleanup script that finds and reports old files, with proper error handling and a `--dry-run` mode. You'll know enough bash to write small operational tools that don't blow up at 2 AM. About 25 minutes.

You'll need a Linux or macOS terminal with `bash` (most do; check with `bash --version`).

## Why bash is worth learning even in 2026

Bash is the lingua franca of operations. Every Linux server has it. Every CI runner has it. Every container has it. For small "glue between commands" tasks, nothing beats it for speed and ubiquity. For anything beyond ~150 lines, reach for Python — but the small stuff is bash.

The skill you want is enough fluency to write 30-line scripts that work safely, not deep mastery. Five concepts cover most operational scripts.

## Step 1: The safety preamble

Every non-trivial bash script starts with this:

```bash
#!/usr/bin/env bash
set -euo pipefail
IFS=$'\n\t'
```

What each does:

- **`#!/usr/bin/env bash`** — shebang line. Says "use bash, find it via PATH." Works on Mac and various Linux distros.
- **`set -e`** — exit on any error. Without this, scripts blunder past failures.
- **`set -u`** — error on undefined variables. Catches typos like `$USRENAME` instead of `$USERNAME`.
- **`set -o pipefail`** — pipe failures count as errors. Without this, `cmd1 | cmd2` succeeds whenever `cmd2` succeeds, even if `cmd1` failed.
- **`IFS=$'\n\t'`** — only split words on newlines and tabs, not spaces. Makes filenames-with-spaces work correctly.

These four lines prevent a big chunk of the "but it worked on my laptop" bash bugs. Use them.

## Step 2: Variables and quoting

Variables are simple: `NAME=value` (no spaces around `=`). Reference with `$NAME`. **Always quote** when you reference: `"$NAME"`, not `$NAME`.

Why the quotes matter:

```bash
NAME="Alice Smith"
echo $NAME       # prints: Alice Smith (works by accident)
ls $NAME         # tries to ls TWO files: Alice and Smith
ls "$NAME"       # tries to ls one file: "Alice Smith"
```

Always quoting is the safe default. ShellCheck (a linter we'll use at the end) catches missing quotes.

Try this:

```bash
NAME="${USER:-stranger}"
echo "Hello, $NAME"
```

The `${VAR:-default}` syntax: use `$VAR` if set, otherwise `default`. Useful for optional config.

## Step 3: Build the cleanup script

Create `cleanup.sh`:

```bash
#!/usr/bin/env bash
set -euo pipefail
IFS=$'\n\t'

# Default values
DAYS="${DAYS:-30}"
DIR="${DIR:-/tmp}"
DRY_RUN=0

# Parse args
while [[ $# -gt 0 ]]; do
    case "$1" in
        --days) DAYS="$2"; shift 2 ;;
        --dir) DIR="$2"; shift 2 ;;
        --dry-run) DRY_RUN=1; shift ;;
        --help)
            echo "Usage: $0 [--days N] [--dir PATH] [--dry-run]"
            echo "  Find files older than N days in DIR (default: 30 days, /tmp)"
            exit 0
            ;;
        *) echo "Unknown arg: $1" >&2; exit 1 ;;
    esac
done

if [[ ! -d "$DIR" ]]; then
    echo "Error: $DIR is not a directory" >&2
    exit 1
fi

echo "Scanning $DIR for files older than $DAYS days..."

count=0
total_size=0
while IFS= read -r -d '' file; do
    size=$(stat -f %z "$file" 2>/dev/null || stat -c %s "$file")  # macOS or Linux
    total_size=$((total_size + size))
    count=$((count + 1))
    if [[ $DRY_RUN -eq 1 ]]; then
        echo "[dry-run] would delete: $file"
    else
        rm "$file"
        echo "deleted: $file"
    fi
done < <(find "$DIR" -type f -mtime +"$DAYS" -print0)

echo
echo "Files: $count"
echo "Total size: $((total_size / 1024 / 1024)) MB"
[[ $DRY_RUN -eq 1 ]] && echo "(dry run — nothing actually deleted)"
```

Make it executable:

```bash
chmod +x cleanup.sh
```

Run it in dry-run mode first (always — for any script that deletes things):

```bash
./cleanup.sh --dry-run --days 7 --dir /tmp
```

You should see a list of files older than 7 days in `/tmp` that it *would* delete, with a count and total size at the end. No actual deletion happens.

If that looks right, drop `--dry-run`:

```bash
./cleanup.sh --days 7 --dir /tmp
```

That's a real, useful operational script. Walk through what each piece does:

- **Argument parsing** — the `case "$1" in ... esac` block handles `--days`, `--dir`, `--dry-run`, `--help`. Each option consumes 1 or 2 positional args (`shift` advances).
- **Validation** — the `if [[ ! -d "$DIR" ]]` check catches typos in the directory path before we proceed.
- **The `find ... -print0 | while read -d ''` pattern** — the standard way to safely loop over files with arbitrary names (including spaces and newlines). Read every file path one at a time.
- **Cross-platform `stat`** — macOS and Linux have different `stat` syntax. The `2>/dev/null || stat -c %s` fallback handles both.
- **Dry-run pattern** — the script defaults to dry-run-friendly behavior. The user has to explicitly opt into the destructive path.

## Step 4: Functions and trap

Bigger scripts benefit from functions and cleanup hooks. Quick example:

```bash
#!/usr/bin/env bash
set -euo pipefail

TMPDIR=$(mktemp -d)
trap 'rm -rf "$TMPDIR"' EXIT

log() {
    echo "[$(date '+%H:%M:%S')] $*" >&2
}

main() {
    log "Working in $TMPDIR"
    # ... do stuff with $TMPDIR ...
    log "Done"
}

main "$@"
```

`trap '<command>' EXIT` runs the command whether the script succeeds, fails, or is interrupted. Perfect for cleaning up temp files.

`log()` is a tiny convention: write status to stderr (`>&2`) so it doesn't pollute stdout, which scripts in pipelines might need to consume.

`main "$@"` at the bottom runs the actual logic, passing through the original arguments.

## Step 5: Lint with ShellCheck

ShellCheck catches a huge category of bash bugs automatically. Install:

```bash
# Mac
brew install shellcheck
# Ubuntu/Debian
sudo apt install shellcheck
```

Run on your script:

```bash
shellcheck cleanup.sh
```

You should see zero or just-a-few warnings. Fix anything it flags. Run shellcheck on every script you write — it catches mistakes faster than running them in production does.

## Common mistakes

**Forgetting quotes.** Probably the #1 bash bug. Variables without quotes silently break on filenames with spaces, empty values, or weird characters. Quote everything.

**Using `[ ... ]` instead of `[[ ... ]]`.** The single-bracket form is older and less powerful. Use `[[ ... ]]` — supports regex, doesn't word-split, clearer syntax. Bash-only, but you're using bash.

**Skipping the safety preamble.** Without `set -euo pipefail`, scripts continue past failures. You won't notice until something corrupts data.

**Parsing complex output with `cut` and `awk`.** Once a script is parsing JSON or doing nontrivial logic, you've outgrown bash. Move to Python. The migration is faster than the next bug.

**Editing scripts on the fly without testing.** Always have a dry-run mode. Always test on a sandbox before running in production. Every "rm -rf $UNSET_VAR" disaster started with a confident edit.

## What to read next

You've got the basics. The next levels:

- [Advanced bash scripting techniques](/blog/advanced-bash-scripting-techniques) — arrays, traps, process substitution, parameter expansion
- [Shell scripting: what survives production](/blog/shell-scripting-best-practices-writing-maintainable-scripts) — patterns from operational scripts that ran for years
- [Linux process management and monitoring](/blog/process-management-monitoring-linux) — once your scripts are running, how to watch what they do

Bash scripting is a "glue" skill — the most useful 50 lines you'll ever write are usually bash. Get the safety preamble in muscle memory, quote your variables, lint with ShellCheck, and you'll avoid the worst classes of bugs.

