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.