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:

PartMeaningPurpose
#!"Magic number"Tells the kernel "this is a script, not binary code"
/bin/bashInterpreter pathThe 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
  • exit terminates 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
  • exit terminates 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:

CodeMeaning
0Success
1General error
2Misuse of shell command
126Command cannot execute (permission issue)
127Command not found
128Invalid exit argument
128+nFatal error signal "n" (e.g., 130 = killed by Ctrl+C)
255Exit 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(( )) SyntaxMeaning
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
 
Was this page helpful?
SR

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 →