Scripting Basics
Summary: in this tutorial, you will learn write your first bash script. learn shebang lines, comments, exit status, and conditional statements (if/elif/else, test, case).
Scripting Basics
A shell script is simply a text file containing commands you want to run together. Instead of typing commands one by one at the prompt, you write them once in a file and execute them repeatedly. This transforms Bash from an interactive tool into a full programming language capable of complex automation.
Why scripting matters:
- Automation: Run repetitive tasks with one command
- Consistency: Same steps every time, no human error
- Portability: Share your work—scripts run the same way on different systems
- Documentation: Scripts serve as executable documentation
- Power: Combine simple commands into sophisticated workflows
This tutorial covers everything you need to write effective Bash scripts: structure, conditionals, loops, functions, user input, and best practices.
Your First Script
Creating a Script
Let's start with the classic "Hello World" script:
# Create the script file
cat > hello.sh << 'EOF'
#!/bin/bash
# My first Bash script
# This script greets the user and displays system information
echo "Hello, World!"
echo "Today is $(date)"
echo "You are $(whoami) on $(hostname)"
EOF
# Make it executable
chmod +x hello.sh
# Run it
./hello.sh
Output:
Hello, World!
Today is Mon Feb 11 14:30:45 UTC 2026
You are alice on laptop
The Shebang Line
The first line of every script is the shebang (also called hashbang):
#!/bin/bash
Anatomy of the shebang:
| Part | Meaning | Purpose |
|---|---|---|
#! | "Magic number" | Tells the kernel "this is a script, not binary code" |
/bin/bash | Interpreter path | The program that will execute this script |
Common shebangs:
#!/bin/bash # Use Bash (most common for Bash scripts)
#!/bin/sh # Use POSIX shell (more portable, fewer features)
#!/usr/bin/env bash # Find bash in PATH (MOST portable)
#!/usr/bin/env python3 # Python script
#!/usr/bin/env node # Node.js script
#!/usr/bin/env ruby # Ruby script
💡 Use #!/usr/bin/env bash for portability
#!/usr/bin/env bash is the most portable shebang. It searches your PATH for bash instead of assuming it's at /bin/bash, which can vary:
- Linux usually has bash at
/bin/bash - macOS might have a newer bash at
/usr/local/bin/bash - BSD systems might have bash at
/usr/pkg/bin/bash
#!/usr/bin/env bash finds whichever bash is first in your PATH, making scripts work across systems.
Script Structure Best Practices
#!/usr/bin/env bash
#====================================
# Script: backup_files.sh
# Description: Creates timestamped backups of important directories
# Author: Alice
# Date: 2026-02-11
# Version: 1.0
#====================================
# Exit on error
set -e
# Exit on undefined variable
set -u
# Exit on pipe failures
set -o pipefail
# Configuration constants (uppercase by convention)
BACKUP_DIR="$HOME/backups"
DATE=$(date +%Y%m%d_%H%M%S)
# Functions
function main() {
echo "Backup script started at $(date)"
# Main logic here
}
# Run main function
main "$@"
Ways to Run a Script
# Method 1: Execute directly (requires chmod +x and shebang)
chmod +x script.sh
./script.sh
# Method 2: Explicit interpreter (shebang is ignored)
bash script.sh
sh script.sh
# Method 3: Source it (runs in CURRENT shell, changes persist)
source script.sh
. script.sh # Shorthand for 'source'
# Method 4: Pipe to interpreter
cat script.sh | bash
⚠️ ./script.sh vs source script.sh — Critical difference
./script.sh — Runs in a subprocess (new shell):
- Variable changes don't affect your current shell
- Directory changes (
cd) don't persist exitterminates the script, not your shell- Use for standalone scripts
source script.sh or . script.sh — Runs in current shell:
- Variable changes affect your shell
- Directory changes persist
exitterminates your shell (careful!)- Use for configuration files (
.bashrc,.profile, environment setup)
Example:
# script.sh:
cd /tmp
MY_VAR="hello"
# Run as subprocess:
./script.sh
echo $MY_VAR # Empty (variable not set in parent shell)
pwd # Original directory (not /tmp)
# Source it:
source script.sh
echo $MY_VAR # hello (variable is set)
pwd # /tmp (directory changed)
Comments
Comments document your code for future readers (including future you):
# This is a single-line comment
echo "Hello" # Inline comment after a command
# Multiple single-line comments
# spanning several lines
# are the standard approach
# Multi-line comment workaround using heredoc
: << 'COMMENT'
This is a multi-line comment block.
It can span as many lines as you need.
None of this text will be executed.
The ':' is a no-op command that does nothing.
COMMENT
# Documentation block at start of script
: << 'DOCUMENTATION'
================================================================================
Script Name: backup_system.sh
Description: Creates full system backup to remote server
Usage: ./backup_system.sh [options] <destination>
Author: Alice Smith
Created: 2026-02-11
Last Modified: 2026-02-11
Version: 1.0.0
Dependencies:
- rsync (for incremental backups)
- ssh (for remote connection)
- tar (for compression)
Notes:
- Requires SSH key authentication to be configured
- Destination must have sufficient disk space
- Estimated runtime: 30-60 minutes for full backup
================================================================================
DOCUMENTATION
Commenting best practices:
# GOOD: Explain WHY, not WHAT
# Use short-circuit AND to create directory if it doesn't exist
[[ -d "$backup_dir" ]] || mkdir -p "$backup_dir"
# BAD: Repeating what the code obviously does
# Check if backup_dir exists, and if not, create it
[[ -d "$backup_dir" ]] || mkdir -p "$backup_dir"
# GOOD: Warn about gotchas
# Note: Variable must be unquoted here for word splitting to work
for file in $files_list; do
process "$file"
done
# GOOD: Document complex logic
# Calculate days remaining: (expiration timestamp - current timestamp) / seconds per day
days_left=$(( (expiration_date - $(date +%s)) / 86400 ))
Exit Status
Every command in Unix returns an exit status (also called return code or exit code)—an integer from 0 to 255:
- 0 = Success, no errors
- 1-255 = Failure (various error types)
# Check exit status of last command with $?
ls /etc/passwd
echo $? # 0 (success)
ls /nonexistent/file
echo $? # 2 (error: No such file or directory)
# Set exit status in your script
exit 0 # Script succeeded
exit 1 # Script failed (generic error)
exit 2 # Script failed (misuse of command)
exit 127 # Command not found
exit 130 # Script terminated by Ctrl+C
# Implicit exit status (last command executed)
#!/bin/bash
echo "Hello"
ls /nonexistent
# Script exits with code 2 (from failed ls)
Common exit codes:
| Code | Meaning |
|---|---|
| 0 | Success |
| 1 | General error |
| 2 | Misuse of shell command |
| 126 | Command cannot execute (permission issue) |
| 127 | Command not found |
| 128 | Invalid exit argument |
| 128+n | Fatal error signal "n" (e.g., 130 = killed by Ctrl+C) |
| 255 | Exit status out of range |
Using Exit Status for Control Flow
# && runs next command ONLY if previous succeeded (exit 0)
mkdir new_directory && cd new_directory && echo "Setup complete"
# || runs next command ONLY if previous failed (exit non-zero)
cd /myproject || { echo "Project directory not found"; exit 1; }
# Combined: try something, handle success or failure
cd /myproject && echo "In project directory" || echo "Failed to enter project"
# Common idiom: ensure something exists
[[ -d "backup" ]] || mkdir backup
[[ -f "config.txt" ]] || cp config.default.txt config.txt
# Check command success without if
if grep -q "error" logfile.txt; then
echo "Errors found in log"
exit 1
fi
# Equivalent short-circuit version
grep -q "error" logfile.txt && { echo "Errors found in log"; exit 1; }
set -e: Exit on Error
#!/bin/bash
set -e # Exit immediately if any command fails
mkdir /tmp/mytest
cd /tmp/mytest
touch file.txt
rm nonexistent_file.txt # Script exits here (error)
echo "This never runs"
Options for error handling:
set -e # Exit on error
set -u # Exit on undefined variable
set -o pipefail # Exit if any command in a pipeline fails
set -x # Print each command before executing (debug mode)
# Combine them (common at start of scripts)
set -euo pipefail
# Disable temporarily
set +e
risky_command_that_might_fail
exit_code=$?
set -e
if [[ $exit_code -ne 0 ]]; then
echo "Command failed but we handled it"
fi
Conditionals
Conditionals let your scripts make decisions based on conditions.
if / elif / else
# Basic if statement
if [[ -f "/etc/passwd" ]]; then
echo "passwd file exists"
fi
# if-else
age=25
if [[ $age -ge 18 ]]; then
echo "You are an adult"
else
echo "You are a minor"
fi
# if-elif-else (multiple conditions)
score=75
if [[ $score -ge 90 ]]; then
echo "Grade: A (Excellent!)"
elif [[ $score -ge 80 ]]; then
echo "Grade: B (Good)"
elif [[ $score -ge 70 ]]; then
echo "Grade: C (Average)"
elif [[ $score -ge 60 ]]; then
echo "Grade: D (Needs improvement)"
else
echo "Grade: F (Failed)"
fi
# One-line if (rare, but useful for simple cases)
[[ -f "config.txt" ]] && echo "Config found" || echo "Config missing"
# Nested if statements
if [[ -d "$directory" ]]; then
if [[ -w "$directory" ]]; then
echo "Directory exists and is writable"
else
echo "Directory exists but is not writable"
fi
else
echo "Directory does not exist"
fi
Test Syntax: [ ] vs [[ ]]
Bash provides two test syntaxes with different capabilities:
Single brackets [ ] (POSIX, portable):
if [ "$name" = "Alice" ]; then
echo "Hello Alice"
fi
# Requires quoting variables (fails if unquoted and empty)
if [ -f "$file" ]; then
echo "File exists"
fi
# Cannot use && and || inside (must use -a and -o)
if [ -f "$file" -a -r "$file" ]; then
echo "File exists and is readable"
fi
Double brackets [[ ]] (Bash-specific, recommended):
if [[ "$name" == "Alice" ]]; then
echo "Hello Alice"
fi
# Safer with unquoted variables (no word splitting)
if [[ -f $file ]]; then # Safer, but still quote as habit
echo "File exists"
fi
# Supports && and || inside
if [[ -f "$file" && -r "$file" ]]; then
echo "File exists and is readable"
fi
# Pattern matching
if [[ "$filename" == *.txt ]]; then
echo "Text file"
fi
# Regular expressions
if [[ "$email" =~ ^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$ ]]; then
echo "Valid email"
fi
💡 Prefer [[ ]] over [ ]
Use [[ ]] for all new scripts because it's:
- Safer: No word splitting or glob expansion (even with unquoted vars)
- More powerful: Supports
&&,||, pattern matching, regex - Cleaner syntax: More intuitive operators
- Better errors: Clearer error messages
Use [ ] only when you need strict POSIX compatibility for scripts that must run on minimal shells like dash.
String Comparisons
str1="hello"
str2="world"
# Equal (use == or =, both work)
if [[ "$str1" == "$str2" ]]; then
echo "Strings are equal"
fi
# Not equal
if [[ "$str1" != "$str2" ]]; then
echo "Strings are different"
fi
# Less than (alphabetically)
if [[ "$str1" < "$str2" ]]; then
echo "'$str1' comes before '$str2'"
fi
# Greater than (alphabetically)
if [[ "$str1" > "$str2" ]]; then
echo "'$str1' comes after '$str2'"
fi
# String is empty
if [[ -z "$empty_string" ]]; then
echo "String is empty"
fi
# String is NOT empty
if [[ -n "$str1" ]]; then
echo "String has content"
fi
# Pattern matching (wildcard)
filename="report.txt"
if [[ "$filename" == *.txt ]]; then
echo "This is a text file"
fi
if [[ "$filename" == report* ]]; then
echo "This is a report file"
fi
# Regex matching
email="alice@example.com"
if [[ "$email" =~ ^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$ ]]; then
echo "Valid email address"
fi
# Case-insensitive comparison
name="ALICE"
if [[ "${name,,}" == "alice" ]]; then # ${name,,} converts to lowercase
echo "Name is Alice (case-insensitive match)"
fi
Numeric Comparisons
a=10
b=20
# Equal
if [[ $a -eq $b ]]; then
echo "$a equals $b"
fi
# Not equal
if [[ $a -ne $b ]]; then
echo "$a does not equal $b"
fi
# Greater than
if [[ $a -gt $b ]]; then
echo "$a is greater than $b"
fi
# Greater than or equal
if [[ $a -ge $b ]]; then
echo "$a is greater than or equal to $b"
fi
# Less than
if [[ $a -lt $b ]]; then
echo "$a is less than $b"
fi
# Less than or equal
if [[ $a -le $b ]]; then
echo "$a is less than or equal to $b"
fi
# C-style arithmetic comparison (( )) — cleaner syntax
if (( a > b )); then
echo "$a is greater than $b"
fi
if (( a == 10 )); then
echo "a equals 10"
fi
if (( score >= 90 && score <= 100 )); then
echo "A grade"
fi
# Arithmetic comparisons support standard operators
if (( (a + b) >= 30 )); then
echo "Sum is at least 30"
fi
Comparison operator summary:
| Test | [[ ]] Syntax | (( )) Syntax | Meaning |
|---|---|---|---|
| Equal | -eq | == or = | Numbers are equal |
| Not equal | -ne | != | Numbers are not equal |
| Greater than | -gt | > | First is greater |
| Greater or equal | -ge | >= | First is greater or equal |
| Less than | -lt | < | First is less |
| Less or equal | -le | <= | First is less or equal |
File Test Operators
Test files and directories with these operators:
file="/etc/passwd"
directory="/tmp"
link="/usr/bin/python"
# File exists (any type)
if [[ -e "$file" ]]; then
echo "File exists"
fi
# Regular file exists
if [[ -f "$file" ]]; then
echo "Regular file exists"
fi
# Directory exists
if [[ -d "$directory" ]]; then
echo "Directory exists"
fi
# Symbolic link exists
if [[ -L "$link" ]]; then
echo "Symbolic link exists"
fi
# File is readable
if [[ -r "$file" ]]; then
echo "File is readable"
fi
# File is writable
if [[ -w "$file" ]]; then
echo "File is writable"
fi
# File is executable
if [[ -x "$file" ]]; then
echo "File is executable"
fi
# File is not empty (has content)
if [[ -s "$file" ]]; then
echo "File has content"
fi
# File1 is newer than file2
if [[ "$file1" -nt "$file2" ]]; then
echo "file1 is newer"
fi
# File1 is older than file2
if [[ "$file1" -ot "$file2" ]]; then
echo "file1 is older"
fi
# Files have same device and inode (hard links)
if [[ "$file1" -ef "$file2" ]]; then
echo "Same file (hard link)"
fi
# Block device
if [[ -b "/dev/sda" ]]; then
echo "Block device"
fi
# Character device
if [[ -c "/dev/tty" ]]; then
echo "Character device"
fi
# Socket
if [[ -S "/var/run/docker.sock" ]]; then
echo "Socket"
fi
# Named pipe
if [[ -p "$fifo" ]]; then
echo "Named pipe"
fi
# File is owned by current user
if [[ -O "$file" ]]; then
echo "You own this file"
fi
# File's group matches current user's group
if [[ -G "$file" ]]; then
echo "File belongs to your group"
fi
# Setuid bit is set
if [[ -u "$file" ]]; then
echo "Setuid bit set"
fi
# Setgid bit is set
if [[ -g "$file" ]]; then
echo "Setgid bit set"
fi
# Sticky bit is set
if [[ -k "$directory" ]]; then
echo "Sticky bit set"
fi
Practical file test examples:
# Check if config file exists before sourcing
config_file="$HOME/.myapp/config"
if [[ -f "$config_file" ]]; then
source "$config_file"
else
echo "Warning: Config file not found, using defaults"
fi
# Ensure directory exists and is writable
log_dir="/var/log/myapp"
if [[ ! -d "$log_dir" ]]; then
mkdir -p "$log_dir" || { echo "Cannot create log directory"; exit 1; }
fi
if [[ ! -w "$log_dir" ]]; then
echo "Error: Cannot write to $log_dir"
exit 1
fi
# Check if file needs updating
if [[ "$source_file" -nt "$target_file" ]]; then
echo "Source is newer, rebuilding..."
build "$source_file" "$target_file"
fi
Logical Operators
Combine multiple conditions:
# AND (both must be true)
if [[ -f "$file" && -r "$file" ]]; then
echo "File exists and is readable"
fi
if [[ $age -ge 18 && $age -le 65 ]]; then
echo "Working age"
fi
# OR (at least one must be true)
if [[ -z "$name" || "$name" == "anonymous" ]]; then
echo "Please provide your name"
fi
if [[ "$ext" == "jpg" || "$ext" == "png" || "$ext" == "gif" ]]; then
echo "Image file"
fi
# NOT (inverts the condition)
if [[ ! -d "$directory" ]]; then
echo "Directory does not exist, creating..."
mkdir -p "$directory"
fi
if [[ ! -f "$lock_file" ]]; then
echo "Not locked, proceeding..."
fi
# Complex nested conditions with parentheses
if [[ ( "$age" -ge 18 && "$age" -le 65 ) || "$is_retired" == "yes" ]]; then
echo "Eligible for program"
fi
if [[ ( -f "$file" && -r "$file" ) || -f "$backup_file" ]]; then
echo "At least one readable file available"
fi
# Mixing file tests and string comparisons
if [[ -d "$project_dir" && -f "$project_dir/Makefile" ]]; then
echo "Valid project with Makefile"
fi
case Statement
The case statement matches a value against multiple patterns—cleaner than many elif chains:
#!/bin/bash
echo -n "Enter a fruit: "
read fruit
case "$fruit" in
apple|Apple)
echo "Apples are red or green"
;;
banana|Banana)
echo "Bananas are yellow"
;;
orange|Orange)
echo "Oranges are orange!"
;;
grape|Grape)
echo "Grapes grow in bunches"
;;
*)
echo "I don't know about $fruit"
;;
esac
Pattern matching in case:
# Extract archives based on extension
case "$filename" in
*.tar.gz|*.tgz)
echo "Extracting gzipped tar archive..."
tar xzf "$filename"
;;
*.tar.bz2|*.tbz2)
echo "Extracting bzipped tar archive..."
tar xjf "$filename"
;;
*.tar.xz)
echo "Extracting xz-compressed tar archive..."
tar xJf "$filename"
;;
*.zip)
echo "Extracting ZIP archive..."
unzip "$filename"
;;
*.rar)
echo "Extracting RAR archive..."
unrar x "$filename"
;;
*.7z)
echo "Extracting 7-Zip archive..."
7z x "$filename"
;;
*)
echo "Error: Unknown file type: $filename"
exit 1
;;
esac
# Handle command-line options
case "$1" in
start)
echo "Starting service..."
;;
stop)
echo "Stopping service..."
;;
restart)
echo "Restarting service..."
;;
status)
echo "Checking status..."
;;
-h|--help)
echo "Usage: $0 {start|stop|restart|status}"
;;
*)
echo "Invalid option: $1"
echo "Usage: $0 {start|stop|restart|status}"
exit 1
;;
esac
Advanced case patterns:
case "$variable" in
[Yy]|[Yy][Ee][Ss])
echo "User said yes"
;;
[Nn]|[Nn][Oo])
echo "User said no"
;;
[0-9])
echo "Single digit"
;;
[0-9][0-9])
echo "Two digits"
;;
[a-zA-Z]*)
echo "Starts with a letter"
;;
*.txt|*.log)
echo "Text or log file"
;;
/*)
echo "Absolute path"
;;
*)
echo "Something else"
;;
esac
Written by the ShellRAG Team
The ShellRAG editorial team writes practical, beginner-friendly Bash Shell tutorials with tested code examples and real-world use cases. Every article is technically reviewed for accuracy and updated regularly.
Learn more about us →