Skip to main content

🔄 Lesson 8.3: Loops and Functions

Repeat actions efficiently and organize your code into reusable building blocks.

🎯 Learning Objectives

  • Iterate over lists and ranges with for loops
  • Repeat actions with while and until loops
  • Control loop flow with break and continue
  • Define and call functions
  • Pass arguments to functions and return values
  • Use local variables to avoid side effects
  • Build a complete, practical script combining everything

Estimated Time: 45 minutes

📑 In This Lesson

for Loops

A for loop repeats commands for each item in a list.

Looping Over a List

#!/bin/bash

# Loop over explicit values
for COLOR in red green blue; do
    echo "Color: $COLOR"
done

# Loop over files in a directory
for FILE in *.txt; do
    echo "Found text file: $FILE"
done

# Loop over command output
for USER in $(who | awk '{print $1}' | sort -u); do
    echo "Logged in: $USER"
done

C-Style for Loop

# Count from 1 to 10
for ((i = 1; i <= 10; i++)); do
    echo "Number: $i"
done

# Count by 2s
for ((i = 0; i <= 20; i += 2)); do
    echo "$i"
done

Using {start..end} Ranges

# Brace expansion — generates a sequence
for i in {1..5}; do
    echo "Step $i"
done

# With a step value (Bash 4+)
for i in {0..100..10}; do
    echo "$i%"
done

# Letters work too
for letter in {a..z}; do
    echo -n "$letter "
done
echo ""
graph TD A["Start: items = red, green, blue"] --> B["Pick next item"] B --> C["Run commands
with current item"] C --> D{"More items?"} D -->|Yes| B D -->|No| E["Done"] style A fill:#3b82f6,stroke:#2563eb,color:#fff style B fill:#6366f1,stroke:#4338ca,color:#fff style C fill:#f59e0b,stroke:#d97706,color:#fff style D fill:#6366f1,stroke:#4338ca,color:#fff style E fill:#22c55e,stroke:#166534,color:#fff

⚠️ Don't Loop Over ls Output

Filenames with spaces break for f in $(ls). Use glob patterns instead:

# ❌ Fragile — breaks on filenames with spaces
for f in $(ls *.txt); do echo "$f"; done

# ✅ Correct — glob handles spaces properly
for f in *.txt; do echo "$f"; done

while Loops

A while loop repeats as long as a condition is true.

#!/bin/bash

# Count down from 5
COUNT=5
while [ "$COUNT" -gt 0 ]; do
    echo "$COUNT..."
    ((COUNT--))
done
echo "Liftoff!"

# Read a file line by line (the proper way)
while IFS= read -r LINE; do
    echo "Line: $LINE"
done < /etc/hostname

# Infinite loop with exit condition
while true; do
    read -p "Enter 'quit' to exit: " INPUT
    if [ "$INPUT" = "quit" ]; then
        break
    fi
    echo "You said: $INPUT"
done

💡 Reading Files Line by Line

The while IFS= read -r LINE pattern is the safe way to process files line by line:

  • IFS= — prevents leading/trailing whitespace from being stripped
  • -r — prevents backslash interpretation
  • < file — redirects the file into the loop's stdin

Processing Command Output with a Pipe

# Process each line from a command
df -h | tail -n +2 | while read -r LINE; do
    USAGE=$(echo "$LINE" | awk '{print $5}' | tr -d '%')
    MOUNT=$(echo "$LINE" | awk '{print $6}')
    if [ "$USAGE" -gt 80 ] 2>/dev/null; then
        echo "⚠️  WARNING: $MOUNT is ${USAGE}% full!"
    fi
done

until Loops

until is the opposite of while — it loops until the condition becomes true.

#!/bin/bash

# Wait for a file to appear
echo "Waiting for /tmp/signal.txt to be created..."
until [ -f /tmp/signal.txt ]; do
    sleep 1
done
echo "File found!"

# Count up to 5
COUNT=1
until [ "$COUNT" -gt 5 ]; do
    echo "Count: $COUNT"
    ((COUNT++))
done

💡 while vs until

They're interchangeable — until [ condition ] is the same as while [ ! condition ]. Use whichever reads more naturally. In practice, while is used far more often. until shines for "wait for something to happen" patterns.

break and continue

#!/bin/bash

# break — exit the loop entirely
for i in {1..100}; do
    if [ "$i" -gt 5 ]; then
        break   # Stop at 5
    fi
    echo "$i"
done
# Output: 1 2 3 4 5

# continue — skip the rest of THIS iteration
for i in {1..10}; do
    if [ $((i % 2)) -eq 0 ]; then
        continue   # Skip even numbers
    fi
    echo "$i"
done
# Output: 1 3 5 7 9
graph TD A["Loop iteration"] --> B{"Check condition"} B -->|"continue"| C["Skip to next iteration"] C --> A B -->|"break"| D["Exit loop entirely"] B -->|"normal"| E["Run remaining commands"] E --> A style B fill:#6366f1,stroke:#4338ca,color:#fff style C fill:#f59e0b,stroke:#d97706,color:#fff style D fill:#ef4444,stroke:#b91c1c,color:#fff style E fill:#22c55e,stroke:#166534,color:#fff

Functions

Functions let you group commands into reusable blocks with a name. Define once, call anywhere.

#!/bin/bash

# Define a function (two equivalent syntaxes)
greet() {
    echo "Hello, World!"
}

function say_goodbye {
    echo "Goodbye!"
}

# Call the functions
greet            # Hello, World!
say_goodbye      # Goodbye!

# Functions must be defined BEFORE they're called
# (Bash reads top-to-bottom)

💡 Function Naming

Use lowercase_with_underscores for function names — the standard Bash convention. The function keyword is optional; name() { } is the POSIX-compatible form and works everywhere.

Function Arguments and Return Values

Functions receive arguments the same way scripts do — via $1, $2, $@, etc.

#!/bin/bash

greet() {
    local name="${1:-World}"    # First argument, default "World"
    echo "Hello, $name!"
}

greet "Ray"     # Hello, Ray!
greet           # Hello, World!

# A function with multiple arguments
add() {
    echo $(( $1 + $2 ))
}

RESULT=$(add 10 20)
echo "Sum: $RESULT"    # Sum: 30

Return Values

Bash functions have two ways to "return" data:

#!/bin/bash

# Method 1: return — sets an exit code (0-255 only)
is_even() {
    if [ $(( $1 % 2 )) -eq 0 ]; then
        return 0    # Success = true
    else
        return 1    # Failure = false
    fi
}

if is_even 42; then
    echo "42 is even"    # This runs
fi

# Method 2: echo — outputs text (capture with $())
get_extension() {
    local filename="$1"
    echo "${filename##*.}"    # Parameter expansion: remove everything before last dot
}

EXT=$(get_extension "report.pdf")
echo "Extension: $EXT"    # Extension: pdf

🐧 return vs echo

return sets the exit status (like exit but for functions, not the whole script). Use it for success/failure signals. echo (captured with $()) is how you return actual data — strings, numbers, computed results. Most real-world functions use echo.

Local Variables

By default, variables in Bash are global — even inside functions. Use local to keep variables contained.

#!/bin/bash

GLOBAL_VAR="I'm global"

my_function() {
    local LOCAL_VAR="I'm local"
    GLOBAL_VAR="Modified by function"    # Changes the global!
    echo "Inside: LOCAL_VAR=$LOCAL_VAR"
    echo "Inside: GLOBAL_VAR=$GLOBAL_VAR"
}

my_function
echo "Outside: LOCAL_VAR=$LOCAL_VAR"       # Empty — local is gone
echo "Outside: GLOBAL_VAR=$GLOBAL_VAR"     # "Modified by function"

Output

Inside: LOCAL_VAR=I'm local
Inside: GLOBAL_VAR=Modified by function
Outside: LOCAL_VAR=
Outside: GLOBAL_VAR=Modified by function

⚠️ Always Use local in Functions

Without local, a variable inside a function can accidentally overwrite a variable with the same name in the calling code. Always declare function variables with local unless you specifically intend to modify a global.

Capstone Script: Project Initializer

Here's a complete script that uses everything from this module — variables, input, conditionals, loops, and functions:

#!/bin/bash
# ============================================
# init-project.sh — Set up a new project directory
# Usage: ./init-project.sh [project-name]
# ============================================

# --- Configuration ---
PROJECTS_DIR="$HOME/projects"
TEMPLATES=("README.md" ".gitignore" "LICENSE")

# --- Functions ---

print_header() {
    echo ""
    echo "=============================="
    echo "  🚀 Project Initializer"
    echo "=============================="
    echo ""
}

create_readme() {
    local name="$1"
    local dir="$2"
    cat > "$dir/README.md" << EOF
# $name

## Description
A new project.

## Getting Started
\`\`\`bash
cd $name
\`\`\`

## Author
$(whoami)

## Created
$(date +%Y-%m-%d)
EOF
}

create_gitignore() {
    local dir="$1"
    cat > "$dir/.gitignore" << 'EOF'
# OS files
.DS_Store
Thumbs.db

# Editor files
*.swp
*.swo
.vscode/
.idea/

# Dependencies
node_modules/
__pycache__/
*.pyc

# Build output
dist/
build/
EOF
}

create_subdirs() {
    local dir="$1"
    shift   # Remove first argument, leaving the rest
    for subdir in "$@"; do
        mkdir -p "$dir/$subdir"
        echo "  📁 Created $subdir/"
    done
}

# --- Main Script ---

print_header

# Get project name from argument or prompt
PROJECT_NAME="${1:-}"
if [ -z "$PROJECT_NAME" ]; then
    read -p "Project name: " PROJECT_NAME
fi

# Validate name (letters, numbers, hyphens, underscores only)
if [[ ! "$PROJECT_NAME" =~ ^[a-zA-Z0-9_-]+$ ]]; then
    echo "❌ Invalid name. Use only letters, numbers, hyphens, underscores." >&2
    exit 1
fi

PROJECT_DIR="$PROJECTS_DIR/$PROJECT_NAME"

# Check if it already exists
if [ -d "$PROJECT_DIR" ]; then
    echo "❌ Directory already exists: $PROJECT_DIR" >&2
    exit 1
fi

# Ask what type of project
echo "What kind of project?"
echo "  1) Basic (just README and .gitignore)"
echo "  2) Web (adds src/, css/, js/ directories)"
echo "  3) Python (adds src/, tests/, requirements.txt)"
read -p "Choice [1]: " PROJECT_TYPE
PROJECT_TYPE="${PROJECT_TYPE:-1}"

# Create the project
echo ""
echo "Creating project: $PROJECT_NAME"
echo "Location: $PROJECT_DIR"
echo "---"

mkdir -p "$PROJECT_DIR"
echo "  📁 Created project directory"

# Create base files
create_readme "$PROJECT_NAME" "$PROJECT_DIR"
echo "  📄 Created README.md"

create_gitignore "$PROJECT_DIR"
echo "  📄 Created .gitignore"

# Create type-specific structure
case "$PROJECT_TYPE" in
    2)
        create_subdirs "$PROJECT_DIR" "src" "css" "js" "images"
        touch "$PROJECT_DIR/src/index.html"
        echo "  📄 Created src/index.html"
        ;;
    3)
        create_subdirs "$PROJECT_DIR" "src" "tests"
        touch "$PROJECT_DIR/requirements.txt"
        touch "$PROJECT_DIR/src/__init__.py"
        echo "  📄 Created requirements.txt"
        echo "  📄 Created src/__init__.py"
        ;;
    *)
        # Basic — no extra directories
        ;;
esac

# Initialize git if available
if command -v git &> /dev/null; then
    cd "$PROJECT_DIR" && git init --quiet
    echo "  🔀 Initialized git repository"
fi

# Done!
echo ""
echo "✅ Project '$PROJECT_NAME' is ready!"
echo "   cd $PROJECT_DIR"

💡 Concepts Used

  • Functions: print_header, create_readme, create_gitignore, create_subdirs
  • Variables & defaults: ${1:-}, ${PROJECT_TYPE:-1}
  • Conditionals: if, [[ regex ]], case
  • Loops: for subdir in "$@"
  • Local variables: local name="$1"
  • Heredocs: cat > file << EOF
  • Exit codes: exit 1 on errors

Exercises

🏋️ Exercise 1: Loop Practice

  1. Write a for loop that prints the multiplication table for 7 (7×1 through 7×12)
  2. Write a while loop that reads /etc/passwd line by line and counts the lines
  3. Write a for loop that renames all .txt files in a directory to .bak

🏋️ Exercise 2: Function Library

Create a file called utils.sh with these functions:

  1. to_upper "text" — converts text to uppercase
  2. to_lower "text" — converts text to lowercase
  3. file_size "path" — returns the size of a file in human-readable format
  4. confirm "question" — asks yes/no, returns 0 for yes, 1 for no

Hint: source utils.sh to load it into another script!

🏋️ Exercise 3: Batch File Processor

Write a script that:

  1. Takes a directory as an argument
  2. Loops through all .log files in that directory
  3. For each file, counts the lines and prints the filename + count
  4. At the end, prints the total line count across all files
  5. Uses a function for counting lines in a single file

🏋️ Exercise 4: The Complete Guessing Game

Build on the guessing game from Lesson 19. Add:

  1. A function play_round() that handles one game
  2. A function show_stats() that tracks best score across rounds
  3. A while loop that asks "Play again?" after each round
  4. Input validation (reject non-numbers)
  5. A difficulty selector: Easy (1-50), Medium (1-100), Hard (1-500)

Knowledge Check

❓ Question 1

What's the safe way to loop over files in a directory?

❓ Question 2

What does break do inside a loop?

❓ Question 3

Why should you use local for variables inside functions?

❓ Question 4

How do you return a string value from a function?

Summary

🎉 Key Takeaways

  • for item in list iterates over items; for ((i=0; i<n; i++)) for C-style counting
  • while [ condition ] repeats while true; until [ condition ] repeats until true
  • while IFS= read -r line is the safe way to process files line by line
  • break exits a loop; continue skips to the next iteration
  • Functions: name() { commands; } — define before calling
  • Function arguments use $1, $2, $@ — same as script arguments
  • return sets exit status (0-255); echo + $() returns actual data
  • Always use local for variables inside functions

🍎 On macOS

Loops and functions in this lesson all work with #!/bin/bash on macOS. Note that the seq command works on macOS, but {1..10} brace expansion for ranges also works and avoids spawning an extra process. If you installed Bash 5 via Homebrew (brew install bash), you'll also have access to declare -n (namerefs) and other Bash 4+/5 features.

🎓 Course Complete!

Congratulations! You've completed Linux for Beginners. You now have the skills to navigate the terminal, manage files and permissions, install software, administer services, connect to remote machines, and automate tasks with shell scripts. Whether you're on Ubuntu, WSL, or macOS, these skills transfer everywhere. Keep practicing, keep exploring, and remember — the terminal is your superpower.

🚀 Where to Go from Here

  • Practice daily — use the terminal for everyday tasks, even when a GUI exists
  • Read man pagesman command is your always-available reference
  • Explore further — cron jobs, advanced shell scripting, Docker, and Linux server administration
  • Contribute — many open-source projects welcome Linux-savvy contributors