Available for day contractsFrom 21st September I have availability for day and half day contracts. Please contact for more information.

Contact →
mikepreston.org

Bash

A powerful Unix shell and command language for scripting, automation, and system administration.

Bash

A powerful Unix shell and command language for scripting, automation, and system administration.

Overview

Bash (Bourne Again SHell) is the default shell on most Linux distributions and macOS. It provides a command-line interface for interacting with the operating system and a scripting language for automating tasks. Bash scripts are essential for DevOps workflows, system administration, and development tooling.

Script InputVariable ExpansionCommand SubstitutionWord SplittingGlob ExpansionCommand ExecutionExit CodeScript InputVariable ExpansionCommand SubstitutionWord SplittingGlob ExpansionCommand ExecutionExit Code

Variables and Parameter Expansion

Variable Assignment and Usage

# Basic assignment (no spaces around =)
name="value"
count=42

# Using variables
echo "$name"
echo "${name}"  # Braces for clarity or when needed

# Command substitution
current_date=$(date +%Y-%m-%d)
files=$(ls -la)

# Arithmetic
result=$((5 + 3))
((count++))

Parameter Expansion

# Default values
echo "${var:-default}"      # Use default if var is unset or null
echo "${var:=default}"      # Assign default if var is unset or null
echo "${var:+alternate}"    # Use alternate if var is set and not null
echo "${var:?error msg}"    # Exit with error if var is unset or null

# String length
echo "${#name}"             # Length of variable

# Substring extraction
str="Hello World"
echo "${str:0:5}"           # "Hello" - from position 0, length 5
echo "${str:6}"             # "World" - from position 6 to end
echo "${str: -5}"           # "World" - last 5 characters (note space)

# Pattern removal
file="/path/to/file.txt"
echo "${file#*/}"           # "path/to/file.txt" - remove shortest match from start
echo "${file##*/}"          # "file.txt" - remove longest match from start
echo "${file%/*}"           # "/path/to" - remove shortest match from end
echo "${file%%/*}"          # "" - remove longest match from end

# Pattern substitution
text="hello world"
echo "${text/world/universe}"   # "hello universe" - replace first match
echo "${text//l/L}"             # "heLLo worLd" - replace all matches

# Case modification (Bash 4+)
echo "${text^}"             # "Hello world" - capitalise first char
echo "${text^^}"            # "HELLO WORLD" - uppercase all
echo "${text,,}"            # "hello world" - lowercase all

Special Variables

$0          # Script name
$1, $2...   # Positional parameters
$#          # Number of arguments
$@          # All arguments as separate words
$*          # All arguments as single word
$$          # Current process ID
$?          # Exit code of last command
$!          # PID of last background process
$_          # Last argument of previous command

Control Structures

TrueFalseStartConditionExecute BlockSkip/ElseContinueEndTrueFalseStartConditionExecute BlockSkip/ElseContinueEnd

If Statements

# Basic if
if [[ condition ]]; then
    commands
fi

# If-else
if [[ "$name" == "admin" ]]; then
    echo "Welcome, admin"
elif [[ "$name" == "guest" ]]; then
    echo "Limited access"
else
    echo "Unknown user"
fi

# Compound conditions
if [[ "$age" -ge 18 && "$age" -le 65 ]]; then
    echo "Working age"
fi

if [[ "$status" == "active" || "$status" == "pending" ]]; then
    echo "Process continues"
fi

# Negation
if [[ ! -f "$file" ]]; then
    echo "File does not exist"
fi

Case Statements

case "$option" in
    start)
        echo "Starting service"
        ;;
    stop)
        echo "Stopping service"
        ;;
    restart|reload)
        echo "Restarting service"
        ;;
    *)
        echo "Unknown option: $option"
        exit 1
        ;;
esac

# Pattern matching in case
case "$filename" in
    *.txt)
        echo "Text file"
        ;;
    *.sh)
        echo "Shell script"
        ;;
    *.tar.gz|*.tgz)
        echo "Compressed archive"
        ;;
esac

For Loops

# Iterate over list
for item in apple banana cherry; do
    echo "$item"
done

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

# C-style for loop
for ((i=0; i<10; i++)); do
    echo "Iteration $i"
done

# Iterate over array
fruits=("apple" "banana" "cherry")
for fruit in "${fruits[@]}"; do
    echo "$fruit"
done

# Iterate over range
for i in {1..10}; do
    echo "$i"
done

# Iterate with step
for i in {0..100..10}; do
    echo "$i"
done

While and Until Loops

# While loop
count=0
while [[ $count -lt 5 ]]; do
    echo "Count: $count"
    ((count++))
done

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

# Infinite loop with break
while true; do
    read -p "Enter command: " cmd
    if [[ "$cmd" == "quit" ]]; then
        break
    fi
    echo "You entered: $cmd"
done

# Until loop (opposite of while)
until [[ -f /tmp/ready ]]; do
    echo "Waiting for file..."
    sleep 1
done

# Continue to skip iterations
for i in {1..10}; do
    if [[ $((i % 2)) -eq 0 ]]; then
        continue
    fi
    echo "Odd: $i"
done

Functions and Arguments

Function Definition

# Standard syntax
function greet {
    echo "Hello, $1!"
}

# Alternative syntax (POSIX-compatible)
greet() {
    echo "Hello, $1!"
}

# Call function
greet "World"

Function Arguments and Return Values

# Access arguments
process_data() {
    local input="$1"        # First argument
    local output="$2"       # Second argument
    local count="$#"        # Number of arguments
    local all_args="$@"     # All arguments

    echo "Processing $input to $output"
    echo "Total arguments: $count"
}

# Return values (exit codes)
is_valid() {
    if [[ -f "$1" ]]; then
        return 0    # Success
    else
        return 1    # Failure
    fi
}

# Using return value
if is_valid "/etc/passwd"; then
    echo "File exists"
fi

# Capture output
get_timestamp() {
    date +%s
}

timestamp=$(get_timestamp)
echo "Timestamp: $timestamp"

Local Variables and Scope

outer_var="global"

my_function() {
    local inner_var="local"     # Only visible inside function
    outer_var="modified"        # Modifies global variable

    echo "Inner: $inner_var"
    echo "Outer: $outer_var"
}

my_function
echo "After function: $outer_var"   # "modified"
echo "Inner: $inner_var"            # Empty (out of scope)

Advanced Function Patterns

# Default argument values
greet() {
    local name="${1:-Guest}"
    echo "Hello, $name!"
}

# Passing arrays to functions
process_array() {
    local -n arr=$1     # nameref (Bash 4.3+)
    for item in "${arr[@]}"; do
        echo "Item: $item"
    done
}

my_array=("one" "two" "three")
process_array my_array

# Error handling in functions
safe_cd() {
    cd "$1" || {
        echo "Failed to change to $1" >&2
        return 1
    }
}

String Manipulation

Substring Operations

string="Hello, World!"

# Extract substring
echo "${string:0:5}"        # "Hello"
echo "${string:7}"          # "World!"
echo "${string: -6}"        # "World!"

# String length
echo "${#string}"           # 13

# Find and replace
echo "${string/World/Bash}"     # "Hello, Bash!"
echo "${string//o/0}"           # "Hell0, W0rld!"

# Remove prefix/suffix
filename="document.txt.bak"
echo "${filename%.bak}"         # "document.txt"
echo "${filename%.*}"           # "document.txt"
echo "${filename%%.*}"          # "document"
echo "${filename#*.}"           # "txt.bak"
echo "${filename##*.}"          # "bak"

String Splitting

# Split by delimiter using IFS
string="apple,banana,cherry"
IFS=',' read -ra fruits <<< "$string"
for fruit in "${fruits[@]}"; do
    echo "$fruit"
done

# Split path into array
path="/usr/local/bin"
IFS='/' read -ra parts <<< "$path"

# Split and process
data="name:John;age:30;city:London"
IFS=';' read -ra pairs <<< "$data"
for pair in "${pairs[@]}"; do
    IFS=':' read -r key value <<< "$pair"
    echo "$key = $value"
done

String Comparison and Testing

# Equality
if [[ "$str1" == "$str2" ]]; then
    echo "Strings are equal"
fi

# Pattern matching
if [[ "$string" == *"pattern"* ]]; then
    echo "Contains pattern"
fi

# Regex matching
if [[ "$email" =~ ^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$ ]]; then
    echo "Valid email"
fi

# String length checks
if [[ -z "$string" ]]; then
    echo "String is empty"
fi

if [[ -n "$string" ]]; then
    echo "String is not empty"
fi

# Lexicographical comparison
if [[ "$str1" < "$str2" ]]; then
    echo "$str1 comes before $str2"
fi

Array Operations

Array Declaration and Access

# Indexed arrays
fruits=("apple" "banana" "cherry")
numbers=(1 2 3 4 5)

# Access elements
echo "${fruits[0]}"         # "apple"
echo "${fruits[-1]}"        # "cherry" (last element)

# All elements
echo "${fruits[@]}"         # All elements as separate words
echo "${fruits[*]}"         # All elements as single word

# Array length
echo "${#fruits[@]}"        # 3

# Array indices
echo "${!fruits[@]}"        # 0 1 2

Array Modification

# Append elements
fruits+=("date")
fruits+=("elderberry" "fig")

# Modify element
fruits[1]="blueberry"

# Remove element
unset 'fruits[2]'

# Slice array
subset=("${fruits[@]:1:2}")     # 2 elements starting from index 1

# Copy array
copy=("${fruits[@]}")

Associative Arrays (Bash 4+)

# Declare associative array
declare -A user

# Assign values
user[name]="John"
user[age]=30
user[city]="London"

# Access values
echo "${user[name]}"

# All keys
echo "${!user[@]}"

# All values
echo "${user[@]}"

# Iterate
for key in "${!user[@]}"; do
    echo "$key: ${user[$key]}"
done

# Check if key exists
if [[ -v user[name] ]]; then
    echo "Name is set"
fi

Array Iteration and Processing

# Iterate with index
fruits=("apple" "banana" "cherry")
for i in "${!fruits[@]}"; do
    echo "$i: ${fruits[$i]}"
done

# Filter array
filtered=()
for fruit in "${fruits[@]}"; do
    if [[ "${#fruit}" -gt 5 ]]; then
        filtered+=("$fruit")
    fi
done

# Join array elements
IFS=','
joined="${fruits[*]}"
unset IFS
echo "$joined"              # "apple,banana,cherry"

# Search in array
contains() {
    local needle="$1"
    shift
    for item in "$@"; do
        if [[ "$item" == "$needle" ]]; then
            return 0
        fi
    done
    return 1
}

if contains "banana" "${fruits[@]}"; then
    echo "Found banana"
fi

File Tests and Conditionals

File Test Operators

# File existence and type
[[ -e file ]]       # Exists
[[ -f file ]]       # Regular file
[[ -d dir ]]        # Directory
[[ -L link ]]       # Symbolic link
[[ -p pipe ]]       # Named pipe
[[ -S socket ]]     # Socket
[[ -b device ]]     # Block device
[[ -c device ]]     # Character device

# File permissions
[[ -r file ]]       # Readable
[[ -w file ]]       # Writable
[[ -x file ]]       # Executable
[[ -u file ]]       # SUID bit set
[[ -g file ]]       # SGID bit set
[[ -k file ]]       # Sticky bit set

# File properties
[[ -s file ]]       # Size > 0
[[ -O file ]]       # Owned by current user
[[ -G file ]]       # Owned by current group

# File comparison
[[ file1 -nt file2 ]]   # file1 newer than file2
[[ file1 -ot file2 ]]   # file1 older than file2
[[ file1 -ef file2 ]]   # Same inode (hard links)

Practical File Test Examples

# Check before operations
if [[ -f "$config_file" && -r "$config_file" ]]; then
    source "$config_file"
else
    echo "Cannot read config file" >&2
    exit 1
fi

# Create directory if missing
if [[ ! -d "$output_dir" ]]; then
    mkdir -p "$output_dir"
fi

# Check for required commands
for cmd in git docker kubectl; do
    if ! command -v "$cmd" &> /dev/null; then
        echo "Required command not found: $cmd" >&2
        exit 1
    fi
done

# Process only newer files
for file in *.txt; do
    if [[ "$file" -nt "$last_processed" ]]; then
        process_file "$file"
    fi
done

Numeric Comparisons

# Integer comparison
[[ $a -eq $b ]]     # Equal
[[ $a -ne $b ]]     # Not equal
[[ $a -lt $b ]]     # Less than
[[ $a -le $b ]]     # Less than or equal
[[ $a -gt $b ]]     # Greater than
[[ $a -ge $b ]]     # Greater than or equal

# Arithmetic context (preferred for numbers)
if (( count > 10 )); then
    echo "Count exceeds threshold"
fi

if (( value >= min && value <= max )); then
    echo "Value in range"
fi

Command Substitution and Pipelines

PipelinestdoutstdoutstdoutCommand 1Command 2Command 3OutputPipelinestdoutstdoutstdoutCommand 1Command 2Command 3Output

Command Substitution

# Modern syntax (preferred)
current_date=$(date +%Y-%m-%d)
file_count=$(ls -1 | wc -l)

# Nested substitution
backup_name="backup-$(hostname)-$(date +%Y%m%d).tar.gz"

# Capture multi-line output
files=$(find /var/log -name "*.log" -mtime -1)

# Use in conditionals
if [[ $(whoami) != "root" ]]; then
    echo "This script requires root privileges"
    exit 1
fi

# Arithmetic with command output
file_size=$(stat -c %s file.txt)
if (( file_size > 1000000 )); then
    echo "Large file: $file_size bytes"
fi

Pipelines

# Basic pipeline
cat file.txt | grep "error" | sort | uniq -c

# Process substitution
diff <(sort file1.txt) <(sort file2.txt)

# Tee to split output
command | tee output.log | grep "pattern"

# Pipeline with while
find . -name "*.sh" | while read -r script; do
    echo "Checking $script"
    shellcheck "$script"
done

# xargs for parallel processing
find . -name "*.log" -print0 | xargs -0 -P4 gzip

# PIPESTATUS for exit codes
false | true | false
echo "${PIPESTATUS[@]}"     # "1 0 1"

Here Documents and Strings

# Here document
cat << EOF
This is a multi-line
string with variable expansion: $HOME
EOF

# Here document without expansion
cat << 'EOF'
This text is literal: $HOME
EOF

# Here string
grep "pattern" <<< "$variable"

# Indented here document
cat <<- EOF
	Indented content
	Tabs are stripped
EOF

Redirection

# Output redirection
command > file.txt          # Overwrite
command >> file.txt         # Append
command 2> error.log        # Stderr only
command &> all.log          # Both stdout and stderr
command > out.log 2>&1      # Both (alternative)

# Input redirection
command < input.txt

# Discard output
command > /dev/null 2>&1
command &> /dev/null

# Multiple redirections
command > stdout.log 2> stderr.log

# File descriptors
exec 3> custom.log          # Open FD 3 for writing
echo "Message" >&3          # Write to FD 3
exec 3>&-                   # Close FD 3

Exit Codes and Error Handling

Exit Codes

# Exit with specific code
exit 0      # Success
exit 1      # General error
exit 2      # Misuse of shell command

# Check last command's exit code
command
if [[ $? -eq 0 ]]; then
    echo "Command succeeded"
else
    echo "Command failed with code $?"
fi

# Chain based on exit code
command1 && command2        # Run command2 only if command1 succeeds
command1 || command2        # Run command2 only if command1 fails
command1 && command2 || command3

Error Handling Patterns

#!/bin/bash
# Strict mode
set -euo pipefail

# set -e: Exit on error
# set -u: Error on undefined variables
# set -o pipefail: Pipeline fails if any command fails

# Trap for cleanup
cleanup() {
    echo "Cleaning up..."
    rm -f "$temp_file"
}
trap cleanup EXIT
trap cleanup ERR

# Error function
die() {
    echo "ERROR: $1" >&2
    exit "${2:-1}"
}

# Usage
[[ -f "$config" ]] || die "Config file not found: $config"

# Try-catch pattern
{
    risky_command
} || {
    echo "Command failed, trying alternative..."
    alternative_command
}

# Check command exists
require_command() {
    command -v "$1" &> /dev/null || die "Required command not found: $1"
}

require_command docker
require_command kubectl

Trap Signals

# Trap multiple signals
trap 'echo "Interrupted"; exit 1' INT TERM

# Trap with cleanup
temp_dir=$(mktemp -d)
trap 'rm -rf "$temp_dir"' EXIT

# Common signals
# EXIT    - Script exit (any reason)
# ERR     - Command error (with set -e)
# INT     - Ctrl+C
# TERM    - Termination signal
# HUP     - Hangup
# DEBUG   - Before each command

# Ignore signal
trap '' INT          # Ignore Ctrl+C

# Reset to default
trap - INT           # Reset INT handler

Common Utilities

grep - Pattern Matching

# Basic search
grep "pattern" file.txt

# Common options
grep -i "pattern" file         # Case insensitive
grep -r "pattern" directory    # Recursive
grep -n "pattern" file         # Show line numbers
grep -c "pattern" file         # Count matches
grep -l "pattern" *.txt        # List matching files
grep -L "pattern" *.txt        # List non-matching files
grep -v "pattern" file         # Invert match
grep -w "word" file            # Match whole word
grep -A 3 "pattern" file       # Show 3 lines after
grep -B 3 "pattern" file       # Show 3 lines before
grep -C 3 "pattern" file       # Show 3 lines context

# Extended regex
grep -E "pattern1|pattern2" file
grep -E "^start.*end$" file

# Multiple patterns
grep -e "pat1" -e "pat2" file
grep -f patterns.txt file      # Patterns from file

sed - Stream Editor

# Substitute first occurrence
sed 's/old/new/' file

# Substitute all occurrences
sed 's/old/new/g' file

# Edit in place
sed -i 's/old/new/g' file
sed -i.bak 's/old/new/g' file  # With backup

# Delete lines
sed '/pattern/d' file          # Delete matching lines
sed '5d' file                  # Delete line 5
sed '5,10d' file               # Delete lines 5-10

# Print specific lines
sed -n '5p' file               # Print line 5
sed -n '5,10p' file            # Print lines 5-10
sed -n '/pattern/p' file       # Print matching lines

# Multiple operations
sed -e 's/a/b/' -e 's/c/d/' file
sed 's/a/b/; s/c/d/' file

# Address ranges
sed '/start/,/end/s/old/new/g' file

# Insert and append
sed '5i\New line before' file   # Insert before line 5
sed '5a\New line after' file    # Append after line 5

awk - Text Processing

# Print columns
awk '{print $1, $3}' file       # Print 1st and 3rd columns
awk -F: '{print $1}' /etc/passwd # Custom delimiter

# Pattern matching
awk '/pattern/ {print}' file
awk '$3 > 100 {print $1, $3}' file

# Built-in variables
awk '{print NR, NF, $0}' file   # Line number, field count, whole line
awk 'END {print NR}' file       # Total line count

# Calculations
awk '{sum += $1} END {print sum}' file
awk '{sum += $1; count++} END {print sum/count}' file

# Field operations
awk -F: 'BEGIN {OFS=","} {print $1, $3}' /etc/passwd

# Conditionals
awk '{if ($3 > 50) print $1 " is large"}' file

# Format output
awk '{printf "%-20s %10d\n", $1, $2}' file

cut, sort, uniq

# cut - extract columns
cut -d: -f1 /etc/passwd         # Field 1, delimiter :
cut -c1-10 file                 # Characters 1-10
cut -d, -f2,4 file.csv          # Multiple fields

# sort
sort file                       # Alphabetical
sort -n file                    # Numeric
sort -r file                    # Reverse
sort -k2 file                   # By 2nd field
sort -t: -k3 -n /etc/passwd     # Custom delimiter
sort -u file                    # Unique only
sort -h file                    # Human numeric (1K, 2M)

# uniq (requires sorted input)
sort file | uniq                # Remove duplicates
sort file | uniq -c             # Count occurrences
sort file | uniq -d             # Show only duplicates
sort file | uniq -u             # Show only unique

# Common combinations
cut -d: -f7 /etc/passwd | sort | uniq -c | sort -rn
awk '{print $1}' log | sort | uniq -c | sort -rn | head -10

Other Essential Utilities

# tr - translate characters
echo "hello" | tr 'a-z' 'A-Z'   # Uppercase
echo "hello" | tr -d 'aeiou'    # Delete characters
echo "hello" | tr -s ' '        # Squeeze repeats

# wc - word count
wc -l file                      # Line count
wc -w file                      # Word count
wc -c file                      # Byte count

# head and tail
head -n 20 file                 # First 20 lines
tail -n 20 file                 # Last 20 lines
tail -f file                    # Follow file updates

# tee - split output
command | tee output.txt        # Display and save
command | tee -a output.txt     # Append

# xargs - build commands
find . -name "*.tmp" | xargs rm
echo "a b c" | xargs -n1 echo   # One arg per command
find . -print0 | xargs -0 cmd   # Handle spaces

Debugging

Debug Options

#!/bin/bash

# Enable debugging
set -x      # Print commands before execution (xtrace)
set -v      # Print shell input lines (verbose)

# Disable debugging
set +x
set +v

# Debug specific section
set -x
problematic_code
set +x

# Enable via shebang
#!/bin/bash -x

# Enable via command line
bash -x script.sh

Strict Mode

#!/bin/bash
set -euo pipefail

# set -e: Exit immediately on error
# set -u: Treat unset variables as error
# set -o pipefail: Pipeline returns rightmost non-zero exit code

# Handle unset with defaults
echo "${undefined_var:-default}"

# Disable for specific commands
set +e
potentially_failing_command
result=$?
set -e

Debugging Techniques

# Print debug messages
debug() {
    if [[ "${DEBUG:-0}" == "1" ]]; then
        echo "DEBUG: $*" >&2
    fi
}

# Usage
DEBUG=1 ./script.sh

# Trace function calls
PS4='+ ${BASH_SOURCE}:${LINENO}:${FUNCNAME[0]}: '
set -x

# Log to file
exec 2> debug.log       # Redirect stderr
set -x

# Inspect variables
declare -p variable     # Print variable declaration
echo "Array: ${array[*]}"
echo "Array length: ${#array[@]}"

# Interactive debugging
read -p "Press enter to continue..."

# Trap ERR for debugging
trap 'echo "Error at line $LINENO"' ERR

Common Debugging Patterns

# Validate script syntax
bash -n script.sh

# Check with shellcheck
shellcheck script.sh

# Step through script
trap 'read -p "[$BASH_SOURCE:$LINENO] $BASH_COMMAND?"' DEBUG

# Log all commands
log_cmd() {
    echo "$(date '+%Y-%m-%d %H:%M:%S') $*" >> command.log
    "$@"
}

log_cmd mkdir -p /tmp/test
log_cmd cp file.txt /tmp/test/

Interactive History

History expansion belongs to the interactive shell. None of it works in a script, where history is off: bash -c 'echo !!' prints !! verbatim.

Re-running and reusing

# Repeat the previous command
!!

# Repeat it as root, having forgotten the sudo
sudo !!

# Arguments of the previous command
!$                     # last argument (Alt-. inserts it without expanding)
!^                     # first argument
!*                     # all arguments

# Earlier commands
!ls                    # last command starting with 'ls'
!?error?               # last command containing 'error'
!-3                    # three commands back
!42                    # command 42, as numbered by: history

# Repeat the previous command with one substitution
^600^644^              # first '600' in it becomes '644'

!?string? and ^old^new^ may drop their closing delimiter only at the end of the line. Anything after it — a comment, a pipe — is swallowed as part of the search or the replacement, so terminate them whenever the line continues.

Ctrl-R searches backwards through the history as you type; Ctrl-G abandons the search and leaves the line as it was.

Check the expansion before it runs

sudo !! runs whatever came last, as root, and the expansion happens as you press Return. histverify puts the expanded command on the prompt instead, so it runs only once you have read it and pressed Return a second time.

shopt -s histverify    # in ~/.bashrc
$ echo verify me
verify me
$ !!
$ echo verify me       # expansion lands on the prompt; Return runs it

Each expansion goes into the history as the command it expanded to, so a following !! repeats that, not the line you typed.

Quick Reference

Essential Patterns

Pattern Description
${var:-default} Use default if unset
${var:=default} Assign default if unset
${#var} String/array length
${var%pattern} Remove suffix
${var#pattern} Remove prefix
${var/old/new} Replace first match
${var//old/new} Replace all matches
[[ -f file ]] File exists
[[ -d dir ]] Directory exists
[[ -z "$var" ]] String is empty
[[ -n "$var" ]] String is not empty
$(command) Command substitution
$((expr)) Arithmetic evaluation
"${array[@]}" All array elements

Script Template

#!/bin/bash
set -euo pipefail

# Script description
# Usage: script.sh [options] <arguments>

# Constants
readonly SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
readonly SCRIPT_NAME="$(basename "$0")"

# Defaults
VERBOSE=0
OUTPUT_DIR="./output"

# Functions
usage() {
    cat << EOF
Usage: $SCRIPT_NAME [options] <input>

Options:
    -h, --help      Show this help
    -v, --verbose   Verbose output
    -o, --output    Output directory

EOF
    exit "${1:-0}"
}

die() {
    echo "ERROR: $1" >&2
    exit "${2:-1}"
}

cleanup() {
    # Cleanup code here
    :
}

main() {
    trap cleanup EXIT

    # Parse arguments
    while [[ $# -gt 0 ]]; do
        case "$1" in
            -h|--help)
                usage
                ;;
            -v|--verbose)
                VERBOSE=1
                shift
                ;;
            -o|--output)
                OUTPUT_DIR="$2"
                shift 2
                ;;
            *)
                break
                ;;
        esac
    done

    # Validate inputs
    [[ $# -ge 1 ]] || usage 1

    # Main logic here
    echo "Processing: $1"
}

main "$@"

Common Issues and Solutions

Issue: Word Splitting and Glob Expansion

Problem: Variables containing spaces or special characters break commands.

# Wrong - breaks on spaces
for file in $(find . -name "*.txt"); do
    rm $file
done

# Correct - use null delimiter
find . -name "*.txt" -print0 | while IFS= read -r -d '' file; do
    rm "$file"
done

# Or with proper quoting
while IFS= read -r file; do
    rm "$file"
done < <(find . -name "*.txt")

Issue: Unset Variables Cause Silent Failures

Problem: Scripts continue with empty variables.

# Enable strict mode
set -u

# Or use parameter expansion with error
: "${REQUIRED_VAR:?Variable not set}"

# Check critical variables
[[ -n "${API_KEY:-}" ]] || die "API_KEY not set"

Issue: Exit Code Lost in Pipelines

Problem: Only the last command's exit code is captured.

# Enable pipefail
set -o pipefail

# Or capture all exit codes
command1 | command2 | command3
echo "Exit codes: ${PIPESTATUS[*]}"

Issue: Subshell Variable Scope

Problem: Variables modified in subshells don't persist.

# Wrong - count stays 0
count=0
cat file | while read line; do
    ((count++))
done
echo "$count"   # 0

# Correct - use process substitution
count=0
while read line; do
    ((count++))
done < <(cat file)
echo "$count"   # Correct value

Issue: Comparing Numbers vs Strings

Problem: Using wrong operators for comparison type.

# String comparison (use == and !=)
if [[ "$str" == "value" ]]; then
    echo "Match"
fi

# Numeric comparison (use -eq, -lt, -gt, etc.)
if [[ $num -eq 42 ]]; then
    echo "Match"
fi

# Or use arithmetic context for numbers
if (( num == 42 )); then
    echo "Match"
fi

Issue: Here Document Indentation

Problem: Here documents include unwanted whitespace.

# Use <<- with tabs (not spaces)
cat <<- EOF
	Line 1
	Line 2
EOF

# Or dedent manually
cat << EOF | sed 's/^    //'
    Line 1
    Line 2
EOF

Issue: ! in a Double-Quoted String

Problem: An interactive shell reads ! followed by a word as a history expansion, so the line fails before it runs. Scripts are unaffected.

echo "hi!there"        # bash: !there: event not found
echo 'hi!there'        # fine - single quotes suppress expansion
echo "it works!"       # fine - a trailing ! is not an event
echo "hi\!there"       # prints hi\!there - inside double quotes the backslash survives

set +H                 # or turn expansion off for this shell

Related Topics

To expand your shell scripting and command-line skills, explore these related topics:

  • Linux CLI - Essential commands, file operations, process management, and system utilities
  • zsh - Advanced shell with powerful completion, themes, and plugins
  • tmux - Terminal multiplexer for session management and persistent shells
  • vim - Modal text editor commonly used for script editing
  • Git - Version control for tracking script changes and collaboration
  • sed and awk - Advanced text processing for complex transformations