Bash Fundamentals

Bash (Bourne Again SHell) is the default shell on most Linux distributions and macOS (prior to Catalina). Understanding its core constructs -- variables, quoting, conditionals, loops, and positional parameters -- is essential for anyone who works on the command line. This page covers the foundational building blocks you need to write clear, correct shell scripts.

Variables

Variables in Bash are assigned without spaces around the equals sign. To retrieve a variable's value, prefix its name with $ or wrap it in ${} for clarity and safety.

name="world"
echo "Hello, $name"          # Hello, world
echo "Hello, ${name}!"       # Hello, world!

# Avoid ambiguity with braces
file="report"
echo "${file}_final.txt"     # report_final.txt (without braces, Bash looks for $file_final)

Variable names are case-sensitive and conventionally uppercase for environment or global variables and lowercase for local script variables. You can make a variable read-only with readonly or declare -r:

readonly MAX_RETRIES=5
declare -r APP_NAME="myapp"

Command substitution lets you capture a command's output in a variable:

current_date=$(date +%F)
file_count=$(ls -1 | wc -l)
echo "Today is $current_date, and there are $file_count files here."

Default Values and Parameter Expansion

Bash provides parameter expansion operators for handling missing or empty variables:

# Use a default value if unset or empty
echo "${name:-Anonymous}"

# Assign a default value if unset or empty
echo "${name:=Anonymous}"

# Display an error if unset or empty
echo "${name:?Variable name is required}"

# Substring extraction
greeting="Hello, World"
echo "${greeting:0:5}"     # Hello
echo "${greeting:7}"       # World

# String replacement
path="/home/user/docs/report.txt"
echo "${path%.txt}.pdf"      # /home/user/docs/report.pdf
echo "${path##*/}"           # report.txt  (basename)
echo "${path%/*}"            # /home/user/docs  (dirname)

Quoting: Double vs Single Quotes

This distinction trips up beginners more than almost anything else. Double quotes allow variable expansion and command substitution. Single quotes treat everything literally.

name="Alice"
echo "Hello, $name"    # Hello, Alice   (double quotes: expansion happens)
echo 'Hello, $name'    # Hello, $name   (single quotes: literal text)

today=$(date +%F)
echo "Today is $today"   # Today is 2026-04-16
echo 'Today is $today'   # Today is $today

Rule of thumb: use double quotes unless you explicitly need literal dollar signs or backticks. Always quote variable expansions to prevent word splitting and globbing. Quoting errors are the most common source of bugs in shell scripts and the first thing shellcheck flags.

Arrays

Bash supports indexed arrays. Declare them with parentheses and access elements by index.

fruits=(apple banana cherry)

echo "${fruits[0]}"       # apple
echo "${fruits[2]}"       # cherry
echo "${fruits[@]}"       # apple banana cherry  (all elements)
echo "${#fruits[@]}"      # 3  (array length)

# Add an element
fruits+=(date)
echo "${fruits[@]}"       # apple banana cherry date

# Iterate
for fruit in "${fruits[@]}"; do
    echo "Fruit: $fruit"
done

# Slice: elements 1 through 2
echo "${fruits[@]:1:2}"   # banana cherry

# Remove an element by index
unset 'fruits[1]'
echo "${fruits[@]}"       # apple cherry date (indices are sparse now)

Arrays are zero-indexed. Always use "${arr[@]}" (quoted, with @) when expanding arrays to handle elements that contain spaces. Without quoting, an element like "red apple" would be split into two separate words.

Conditionals: if, elif, else

Bash uses if ... then ... elif ... else ... fi blocks. The [[ ]] construct is preferred over [ ] because it supports pattern matching, logical operators, and does not require quoting variables to prevent word splitting.

file="/etc/hosts"

if [[ -f "$file" ]]; then
    echo "$file exists and is a regular file."
elif [[ -d "$file" ]]; then
    echo "$file is a directory."
else
    echo "$file does not exist."
fi

Common Test Operators

Operator Meaning
-f File exists and is a regular file
-d File exists and is a directory
-e File exists (any type)
-r File is readable
-w File is writable
-x File is executable
-s File exists and is not empty
-z String is empty (zero length)
-n String is non-empty
-eq Integer equality
-ne Integer inequality
-lt Integer less than
-gt Integer greater than
-le Integer less than or equal
-ge Integer greater than or equal
count=5
if [[ $count -eq 5 ]]; then
    echo "Count is five."
fi

value=""
if [[ -z "$value" ]]; then
    echo "Value is empty."
fi

# String comparison
if [[ "$name" == "Alice" ]]; then
    echo "Hello, Alice!"
fi

# Pattern matching with [[ ]]
if [[ "$filename" == *.txt ]]; then
    echo "This is a text file."
fi

# Logical AND and OR
if [[ -f "$file" && -r "$file" ]]; then
    echo "File exists and is readable."
fi

Loops

for Loop

# Iterate over a list
for color in red green blue; do
    echo "Color: $color"
done

# C-style for loop
for ((i = 0; i < 5; i++)); do
    echo "Index: $i"
done

# Loop over files
for f in /var/log/*.log; do
    echo "Processing $f"
done

# Loop over command output
for user in $(cut -d: -f1 /etc/passwd | head -5); do
    echo "User: $user"
done

while Loop

counter=0
while [[ $counter -lt 5 ]]; do
    echo "Counter: $counter"
    ((counter++))
done

# Read a file line by line
while IFS= read -r line; do
    echo "Line: $line"
done < input.txt

until Loop

The until loop runs as long as its condition is false -- the inverse of while.

attempts=0
until [[ $attempts -ge 3 ]]; do
    echo "Attempt $((attempts + 1))"
    ((attempts++))
done

Loop Control: break and continue

for i in {1..10}; do
    if [[ $i -eq 5 ]]; then
        continue   # skip the rest of this iteration
    fi
    if [[ $i -eq 8 ]]; then
        break      # exit the loop entirely
    fi
    echo "i=$i"
done

case / esac

The case statement is Bash's equivalent of a switch. It matches a value against patterns and supports glob-style wildcards.

read -rp "Enter a fruit: " fruit
case "$fruit" in
    apple)
        echo "You picked apple."
        ;;
    banana|plantain)
        echo "You picked a banana family fruit."
        ;;
    c*)
        echo "You picked a fruit starting with c."
        ;;
    *)
        echo "Unknown fruit: $fruit"
        ;;
esac

Reading User Input

The read builtin reads a line from standard input.

read -rp "What is your name? " username
echo "Hello, $username"

# Read with a timeout (5 seconds)
if read -rt 5 -p "Quick, enter a number: " num; then
    echo "You entered: $num"
else
    echo "Too slow!"
fi

# Read into an array
read -ra words <<< "one two three"
echo "${words[1]}"   # two

Always use the -r flag to prevent backslash interpretation.

Positional Parameters

Scripts receive arguments via numbered variables.

#!/usr/bin/env bash
echo "Script name: $0"
echo "First arg:   $1"
echo "Second arg:  $2"
echo "All args:    $@"
echo "Arg count:   $#"

Running ./greet.sh Alice Bob would output:

Script name: ./greet.sh
First arg:   Alice
Second arg:  Bob
All args:    Alice Bob
Arg count:   2

Use "$@" (quoted) to pass all arguments to another command while preserving spaces and special characters. The shift command removes the first positional parameter and shifts the rest down by one, which is useful for processing arguments in a loop.

Exit Codes

Every command returns an integer exit code. Zero means success; non-zero means failure. The special variable $? holds the exit code of the last command.

ls /nonexistent 2>/dev/null
echo "Exit code: $?"   # Exit code: 2 (or 1, depending on implementation)

grep -q "root" /etc/passwd
if [[ $? -eq 0 ]]; then
    echo "Found root user."
fi

# Idiomatic approach -- use the command directly in the if
if grep -q "root" /etc/passwd; then
    echo "Found root user."
fi

You can set your own exit codes with exit:

if [[ ! -f "$1" ]]; then
    echo "Error: file not found: $1" >&2
    exit 1
fi

Redirect error messages to stderr with >&2 so they are separate from normal output. This is critical for scripts whose stdout might be piped or captured.

Putting It All Together

Here is a small script that combines many of the concepts above to validate and process input files:

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

if [[ $# -eq 0 ]]; then
    echo "Usage: $0 <file> [file...]" >&2
    exit 1
fi

total_lines=0
for file in "$@"; do
    if [[ ! -f "$file" ]]; then
        echo "Warning: $file is not a regular file, skipping." >&2
        continue
    fi
    lines=$(wc -l < "$file")
    echo "$file: $lines lines"
    ((total_lines += lines))
done

echo "Total lines across all files: $total_lines"

Next Steps

With these fundamentals under your belt, you are ready to tackle more advanced topics. See Advanced Bash for functions, traps, associative arrays, and debugging. For pattern matching, visit the Regular Expressions Guide. Return to the Shell Scripting hub for all available topics.