$linuxjunkies
>

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.

BeginnerUbuntuDebianFedoraArch9 min readUpdated June 7, 2026

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

OperatorTrue when
-f fileFile exists and is a regular file
-d pathPath 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 $bIntegers are equal
$a -lt $bInteger 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 as sh script.sh (which ignores the shebang). Use ./script.sh after making it executable.
  • Script exits unexpectedly mid-runset -e is working correctly; a command returned a non-zero exit code. Run with bash -x to see which one, then either fix the command or add || true if failure is acceptable at that step.
  • Variable is empty when you expect a valueset -u will 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 parse ls output; use globs or find with -print0 and read -rd.
tested on:Ubuntu 24.04Fedora 40Arch rollingDebian 12

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