DEV Community

Malhar Gupte
Malhar Gupte

Posted on

Shell Scripting for Beginners: Control Flow, Functions and Real Automation (Part 2)

Series: Shell Scripting for Beginners | Part: 2 of 2
Level: Beginner–Intermediate | Time to read: ~18 minutes
Part 1: Shell Scripting for Beginners: From Zero to Automating Your First Tasks


Quick Recap of Part 1

In Part 1, we covered the building blocks:

  • Writing and running your first script (chmod +x, ./script.sh)
  • The shebang line (#!/bin/bash) and what it does
  • Variables, curly brace syntax, and command substitution
  • Passing arguments with $1, $2, $#
  • Reading user input with read and read -p
  • Arithmetic using $(( ))

If any of that sounds unfamiliar, go through Part 1 first — everything in this article builds on it.

In Part 2, we're adding the logic layer. By the end of this, your scripts will be able to make decisions, repeat tasks, handle errors, and be structured enough that you'd actually want to maintain them.

Let's get into it.


Conditional Logic (if Statements)

A script that just runs commands top to bottom is useful. A script that can decide what to do based on conditions is powerful.

Basic if

#!/bin/bash

age=20

if [ $age -ge 18 ]; then
  echo "You're an adult."
fi
Enter fullscreen mode Exit fullscreen mode

The structure is always:

if [ condition ]; then
  # commands to run if condition is true
fi
Enter fullscreen mode Exit fullscreen mode

The spaces inside [ ] are not optional. [$age -ge 18] will fail. Always write [ $age -ge 18 ] with spaces.

if-else

#!/bin/bash

read -p "Enter your age: " age

if [ $age -ge 18 ]; then
  echo "Access granted."
else
  echo "Access denied. Come back in a few years."
fi
Enter fullscreen mode Exit fullscreen mode

if-elif-else

#!/bin/bash

read -p "Enter your score: " score

if [ $score -ge 90 ]; then
  echo "Grade: A"
elif [ $score -ge 75 ]; then
  echo "Grade: B"
elif [ $score -ge 60 ]; then
  echo "Grade: C"
else
  echo "Grade: F — study harder."
fi
Enter fullscreen mode Exit fullscreen mode

Comparison Operators

Bash uses different operators for numbers and strings — this trips people up constantly.

For numbers:

Operator Meaning
-eq equal to
-ne not equal to
-gt greater than
-lt less than
-ge greater than or equal
-le less than or equal

For strings:

Operator Meaning
= equal
!= not equal
-z is empty
-n is not empty
#!/bin/bash

name="Malhar"

if [ "$name" = "Malhar" ]; then
  echo "Hey, I know you."
fi

if [ -z "$name" ]; then
  echo "Name is empty."
else
  echo "Name is: $name"
fi
Enter fullscreen mode Exit fullscreen mode

For files and directories:

Operator Meaning
-f file exists
-d directory exists
-e file or directory exists
-r file is readable
-w file is writable
-x file is executable
#!/bin/bash

if [ -f "/etc/passwd" ]; then
  echo "File exists."
fi

if [ -d "/tmp" ]; then
  echo "/tmp directory exists."
fi
Enter fullscreen mode Exit fullscreen mode

Tip: Always quote your variables inside [ ]. Write [ "$name" = "Malhar" ], not [ $name = "Malhar" ]. If $name is empty or has spaces, the unquoted version breaks.

Double Brackets [[ ]]

Bash also supports [[ ]] — a more modern, forgiving syntax that handles edge cases better:

#!/bin/bash

name="Malhar Gupte"

# This works safely with spaces in the variable
if [[ $name == *"Malhar"* ]]; then
  echo "Name contains Malhar"
fi
Enter fullscreen mode Exit fullscreen mode

[[ ]] also supports && and || directly inside it, while [ ] doesn't. Once you're comfortable with the basics, prefer [[ ]] for new scripts.


For Loops

When you need to repeat something a fixed number of times or iterate over a list — that's a for loop.

Looping Through a List of Values

#!/bin/bash

for language in Python Bash Java Go Rust; do
  echo "Language: $language"
done
Enter fullscreen mode Exit fullscreen mode

Output:

Language: Python
Language: Bash
Language: Java
Language: Go
Language: Rust
Enter fullscreen mode Exit fullscreen mode

Looping Through a Number Range

#!/bin/bash

for i in {1..5}; do
  echo "Iteration: $i"
done
Enter fullscreen mode Exit fullscreen mode

Or with a step:

for i in {0..20..5}; do
  echo "$i"   # 0, 5, 10, 15, 20
done
Enter fullscreen mode Exit fullscreen mode

C-Style For Loop

#!/bin/bash

for ((i=1; i<=5; i++)); do
  echo "Count: $i"
done
Enter fullscreen mode Exit fullscreen mode

Looping Through Files

This is where for loops get genuinely useful:

#!/bin/bash

# Loop through all .log files in a directory
for file in /var/log/*.log; do
  echo "Processing: $file"
  echo "Size: $(du -sh "$file" | cut -f1)"
done
Enter fullscreen mode Exit fullscreen mode

Practical Example: Batch Rename Files

#!/bin/bash

# Add a "backup_" prefix to all .txt files in current directory
for file in *.txt; do
  mv "$file" "backup_${file}"
  echo "Renamed: $file → backup_${file}"
done
Enter fullscreen mode Exit fullscreen mode

Tip: Always test loops with echo first before running destructive commands like mv, rm, or cp. Replace the actual command with echo "would run: mv $file ..." and verify the output looks right.


While Loops

while loops keep running as long as a condition is true. They're perfect when you don't know in advance how many iterations you need.

Basic While Loop

#!/bin/bash

count=1

while [ $count -le 5 ]; do
  echo "Count: $count"
  ((count++))
done
Enter fullscreen mode Exit fullscreen mode

Output:

Count: 1
Count: 2
Count: 3
Count: 4
Count: 5
Enter fullscreen mode Exit fullscreen mode

Reading a File Line by Line

One of the most common uses of while in real scripts:

#!/bin/bash

while IFS= read -r line; do
  echo "Line: $line"
done < /etc/hosts
Enter fullscreen mode Exit fullscreen mode

IFS= prevents leading/trailing whitespace from being stripped. -r prevents backslashes from being interpreted. Both are good habits.

Waiting for Something to Happen

#!/bin/bash

echo "Waiting for server to come online..."

while ! curl -s http://localhost:8080 > /dev/null; do
  echo "Not ready yet. Retrying in 3 seconds..."
  sleep 3
done

echo "Server is up!"
Enter fullscreen mode Exit fullscreen mode

This kind of loop is legitimately useful in deployment scripts where you need to wait for a service to start before proceeding.

Tip: Always make sure your while loop has a way to end. A missing increment or a condition that never becomes false will give you an infinite loop that you'll have to kill with Ctrl+C.


Case Statements

When you have one variable and multiple possible values to check against, case is far cleaner than a chain of if-elif statements.

The Problem case Solves

Imagine checking what day of the week it is:

# Verbose and repetitive with if-elif
if [ "$day" = "Monday" ]; then
  ...
elif [ "$day" = "Tuesday" ]; then
  ...
elif [ "$day" = "Wednesday" ]; then
  ...
Enter fullscreen mode Exit fullscreen mode

With case:

#!/bin/bash

read -p "Enter day: " day

case $day in
  Monday)
    echo "Start of the week. Let's go." ;;
  Tuesday | Wednesday | Thursday)
    echo "Mid-week grind." ;;
  Friday)
    echo "Almost there." ;;
  Saturday | Sunday)
    echo "Weekend. Close the laptop." ;;
  *)
    echo "That's not a valid day." ;;
esac
Enter fullscreen mode Exit fullscreen mode

Each pattern ends with ), the commands end with ;;, and *) is the catch-all default (like else). The block ends with esac — that's case backwards, a classic Unix move.

Practical Example: Script Menu

#!/bin/bash

echo "==============================="
echo "     Server Management Menu"
echo "==============================="
echo "1) Check disk usage"
echo "2) Check memory usage"
echo "3) List running processes"
echo "4) Exit"
echo ""

read -p "Choose an option: " option

case $option in
  1)
    df -h ;;
  2)
    free -h ;;
  3)
    ps aux | head -20 ;;
  4)
    echo "Goodbye."
    exit 0 ;;
  *)
    echo "Invalid option." ;;
esac
Enter fullscreen mode Exit fullscreen mode

This kind of menu-driven script is a pattern you'll see all over real DevOps tooling.


Exit Codes

Every command you run in a terminal exits with a number. That number is the exit code, and it tells you whether the command succeeded or failed.

  • 0 = success
  • Any non-zero value = failure (the specific number often indicates the type of error)

Checking $?

$? holds the exit code of the last command that ran:

#!/bin/bash

ls /tmp
echo "Exit code: $?"   # 0 — /tmp exists

ls /nonexistent_directory
echo "Exit code: $?"   # 2 — directory not found
Enter fullscreen mode Exit fullscreen mode

Using Exit Codes in Conditionals

#!/bin/bash

ping -c 1 google.com > /dev/null 2>&1

if [ $? -eq 0 ]; then
  echo "Internet is up."
else
  echo "No internet connection."
fi
Enter fullscreen mode Exit fullscreen mode

Or more concisely — you can use the command directly in the if:

#!/bin/bash

if ping -c 1 google.com > /dev/null 2>&1; then
  echo "Internet is up."
else
  echo "No internet connection."
fi
Enter fullscreen mode Exit fullscreen mode

Setting Exit Codes in Your Scripts

When writing scripts that other scripts or tools will call, always set meaningful exit codes:

#!/bin/bash

backup_file="/backups/db.sql"

if [ ! -f "$backup_file" ]; then
  echo "ERROR: Backup file not found." >&2
  exit 1
fi

echo "Backup file found. Proceeding..."
exit 0
Enter fullscreen mode Exit fullscreen mode

>&2 redirects error messages to stderr instead of stdout — the right place for errors. exit 1 signals failure to whatever called your script.

Tip: In CI/CD pipelines, exit codes are everything. A script that exits with 0 tells the pipeline "all good, move on." A non-zero exit code stops the pipeline and flags the step as failed. Always think about your exit codes when writing automation scripts.


Functions in Bash

Once your scripts get longer than 30-40 lines, repeating the same blocks of code starts to hurt. Functions let you name a block of code and call it whenever you need it.

Defining and Calling a Function

#!/bin/bash

greet() {
  echo "Hello, $1!"
}

greet "Malhar"
greet "World"
Enter fullscreen mode Exit fullscreen mode

Output:

Hello, Malhar!
Hello, World!
Enter fullscreen mode Exit fullscreen mode

The function is defined first, then called by name. Arguments passed to the function are available as $1, $2, etc. — just like script arguments, but scoped to the function.

A More Realistic Function

#!/bin/bash

log() {
  local level=$1
  local message=$2
  local timestamp=$(date "+%Y-%m-%d %H:%M:%S")
  echo "[$timestamp] [$level] $message"
}

log "INFO"  "Script started."
log "INFO"  "Checking disk usage..."
log "WARN"  "Disk usage above 80%."
log "ERROR" "Backup directory not found."
Enter fullscreen mode Exit fullscreen mode

Output:

[2025-03-10 10:42:01] [INFO] Script started.
[2025-03-10 10:42:01] [INFO] Checking disk usage...
[2025-03-10 10:42:01] [WARN] Disk usage above 80%.
[2025-03-10 10:42:01] [ERROR] Backup directory not found.
Enter fullscreen mode Exit fullscreen mode

This log function is something you'd actually copy into real scripts.

local Variables

Notice the local keyword. Variables inside functions are global by default in Bash, which can cause bugs in longer scripts. Declaring them with local keeps them scoped to the function:

#!/bin/bash

count=10   # global

increment() {
  local count=0   # local — doesn't touch the global $count
  ((count++))
  echo "Inside function: $count"
}

increment
echo "Outside function: $count"
Enter fullscreen mode Exit fullscreen mode

Output:

Inside function: 1
Outside function: 10
Enter fullscreen mode Exit fullscreen mode

Functions with Return Values

Bash functions don't return values the way other languages do. They return exit codes (0-255). To "return" a value, you either echo it and capture it with $(), or use a global variable:

#!/bin/bash

get_disk_usage() {
  local path=${1:-"/"}
  df -h "$path" | awk 'NR==2 {print $5}' | tr -d '%'
}

usage=$(get_disk_usage "/")
echo "Disk usage on /: ${usage}%"

if [ "$usage" -gt 80 ]; then
  echo "Warning: disk usage is high."
fi
Enter fullscreen mode Exit fullscreen mode

Real Automation Script: System Health Check

Let's put everything together — conditionals, loops, functions, exit codes — into something you'd actually run on a server.

This script performs a basic system health check and prints a summary report.

#!/bin/bash

# ─────────────────────────────────────────────────────
# system_health_check.sh
# Checks disk, memory, CPU load, and running services
# Usage: ./system_health_check.sh
# ─────────────────────────────────────────────────────

# ── Thresholds ────────────────────────────────────────
DISK_THRESHOLD=80
MEM_THRESHOLD=85
SERVICES=("nginx" "ssh")

# ── Colors (optional but makes output readable) ───────
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'   # No Color (reset)

# ── Logging Function ──────────────────────────────────
log() {
  local level=$1
  local message=$2
  local timestamp=$(date "+%Y-%m-%d %H:%M:%S")

  case $level in
    INFO)  echo -e "${GREEN}[$timestamp] [INFO]${NC}  $message" ;;
    WARN)  echo -e "${YELLOW}[$timestamp] [WARN]${NC}  $message" ;;
    ERROR) echo -e "${RED}[$timestamp] [ERROR]${NC} $message" ;;
  esac
}

# ── Check Disk Usage ──────────────────────────────────
check_disk() {
  log "INFO" "Checking disk usage..."

  while IFS= read -r line; do
    usage=$(echo "$line" | awk '{print $5}' | tr -d '%')
    mount=$(echo "$line" | awk '{print $6}')

    if [ "$usage" -ge "$DISK_THRESHOLD" ]; then
      log "WARN" "Disk usage on $mount is at ${usage}% — above threshold of ${DISK_THRESHOLD}%"
    else
      log "INFO" "Disk usage on $mount: ${usage}% — OK"
    fi
  done < <(df -h | awk 'NR>1 && $1 ~ /^\/dev/')
}

# ── Check Memory Usage ────────────────────────────────
check_memory() {
  log "INFO" "Checking memory usage..."

  local total=$(free | awk '/^Mem:/ {print $2}')
  local used=$(free | awk '/^Mem:/ {print $3}')
  local usage=$(( used * 100 / total ))

  if [ "$usage" -ge "$MEM_THRESHOLD" ]; then
    log "WARN" "Memory usage is at ${usage}% — above threshold of ${MEM_THRESHOLD}%"
  else
    log "INFO" "Memory usage: ${usage}% — OK"
  fi
}

# ── Check CPU Load ────────────────────────────────────
check_cpu() {
  log "INFO" "Checking CPU load..."

  local load=$(uptime | awk -F'load average:' '{print $2}' | awk -F',' '{print $1}' | xargs)
  log "INFO" "1-minute load average: $load"
}

# ── Check Services ────────────────────────────────────
check_services() {
  log "INFO" "Checking critical services..."

  for service in "${SERVICES[@]}"; do
    if systemctl is-active --quiet "$service" 2>/dev/null; then
      log "INFO" "Service [$service] is running — OK"
    else
      log "ERROR" "Service [$service] is NOT running!"
    fi
  done
}

# ── Main ──────────────────────────────────────────────
main() {
  echo ""
  echo "=============================================="
  echo "       System Health Check"
  echo "       $(date)"
  echo "       Host: $(hostname)"
  echo "=============================================="
  echo ""

  check_disk
  echo ""
  check_memory
  echo ""
  check_cpu
  echo ""
  check_services

  echo ""
  echo "=============================================="
  echo "  Health check complete."
  echo "=============================================="
  echo ""
}

main
exit 0
Enter fullscreen mode Exit fullscreen mode

Make it executable and run it:

chmod +x system_health_check.sh
./system_health_check.sh
Enter fullscreen mode Exit fullscreen mode

Sample output:

==============================================
       System Health Check
       Mon Mar 10 10:45:01 IST 2025
       Host: dev-server
==============================================

[2025-03-10 10:45:01] [INFO]  Checking disk usage...
[2025-03-10 10:45:01] [INFO]  Disk usage on /: 43% — OK
[2025-03-10 10:45:01] [WARN]  Disk usage on /data is at 87% — above threshold of 80%

[2025-03-10 10:45:01] [INFO]  Checking memory usage...
[2025-03-10 10:45:01] [INFO]  Memory usage: 61% — OK

[2025-03-10 10:45:01] [INFO]  Checking CPU load...
[2025-03-10 10:45:01] [INFO]  1-minute load average: 0.42

[2025-03-10 10:45:01] [INFO]  Checking critical services...
[2025-03-10 10:45:01] [INFO]  Service [nginx] is running — OK
[2025-03-10 10:45:01] [ERROR] Service [ssh] is NOT running!

==============================================
  Health check complete.
==============================================
Enter fullscreen mode Exit fullscreen mode

This is the kind of script that runs daily via cron on real servers. Add an email or Slack notification at the end and you have a basic monitoring system.


Best Practices for Shell Scripts

Writing a script that works is one thing. Writing a script that someone else (or future you) can read, debug, and maintain is another.

Always Quote Your Variables

# Bad — breaks if filename has spaces
rm $filename

# Good
rm "$filename"
Enter fullscreen mode Exit fullscreen mode

This is the single most common source of bugs in shell scripts. Filenames, paths, and user input can all contain spaces. Always quote.

Use Meaningful Names

# Hard to read
for f in *.log; do
  s=$(wc -l < "$f")
  echo "$f: $s"
done

# Clear
for log_file in *.log; do
  line_count=$(wc -l < "$log_file")
  echo "$log_file: $line_count lines"
done
Enter fullscreen mode Exit fullscreen mode

Use set -e and set -u at the Top

#!/bin/bash
set -e   # Exit immediately if any command fails
set -u   # Treat unset variables as errors
Enter fullscreen mode Exit fullscreen mode

set -e means your script won't silently continue after a failed command. set -u catches typos in variable names. Add these to every script you write seriously.

Use ShellCheck

ShellCheck is a free static analysis tool for shell scripts. Paste your script in and it'll catch bugs, bad practices, and portability issues before you even run the script.

You can also install it locally:

# Ubuntu/Debian
sudo apt install shellcheck

# Then run it on your script
shellcheck your_script.sh
Enter fullscreen mode Exit fullscreen mode

It's the bash equivalent of a linter. Use it.

Add a Usage Comment at the Top

#!/bin/bash
# ─────────────────────────────────────────
# backup.sh
# Description: Backs up a directory to a target location
# Usage: ./backup.sh <source_dir> <target_dir>
# Author: Malhar Gupte
# Last updated: 2025-03-10
# ─────────────────────────────────────────
Enter fullscreen mode Exit fullscreen mode

Takes 30 seconds and saves a lot of confusion when you revisit the script months later.

Redirect Error Messages to stderr

# Good practice for error messages
echo "ERROR: File not found." >&2
exit 1
Enter fullscreen mode Exit fullscreen mode

This means error messages won't pollute the stdout output of your script, which matters when other scripts or pipelines consume your script's output.


Final Thoughts

If you've made it through both parts of this series, you can now write scripts that:

  • Make decisions with if, elif, case
  • Repeat tasks with for and while
  • Handle success and failure with exit codes
  • Stay organized with functions
  • Check real system health on a live server

That's not beginner knowledge anymore.

For DevOps, shell scripting is the connective tissue between tools. It's how you glue together Docker commands, AWS CLI calls, database backups, log rotations, and deployment steps into a single automated workflow. Even if you work primarily with Python or Go for automation eventually, knowing how to read and write Bash means you can work on any server, in any environment, without needing anything installed.

The best way to get better at this is to find something you do manually and automate it. Clean up old files? Script it. SSH into a server and run the same three commands? Script it. Check if your services are running? You just wrote that script.

Start small. The scripts you write for yourself are the ones you'll actually learn from.


That's a wrap on this series. If something wasn't clear or you ran into an issue, drop a comment — happy to help debug. And if you're building something interesting with what you learned, share it.

Part 1 is here if you missed it: Shell Scripting for Beginners — Part 1

Top comments (0)