Skip to Content
Edmond Kacaj

26 mins read


Mastering Bash Scripting: A Practical Guide

Bash scripting is an essential skill for anyone working with Linux or Unix systems. It's a powerful way to automate tasks, manipulate data, and manage system operations. In this guide, we’ll take a hands-on approach to learning bash scripting, covering essential concepts and culminating in a complete script that demonstrates their combined power


Why I Like Bash

Bash (Bourne Again Shell) is one of the most popular Unix shells. It’s an improved version of the Bourne shell (sh), offering more features while remaining backward-compatible. Other shells like dash, zsh, and fish have their strengths, but bash is often the default on Linux systems, making it a go-to for scripting.

How Shells Work

A shell is an interface between the user and the operating system. It reads and executes commands entered by the user or provided in a script. Bash is both a command-line shell and a scripting language, providing tools to automate repetitive tasks and handle complex workflows.

Differences Between Shells

  • sh (Bourne Shell): The original Unix shell, limited features, POSIX-compliant.
  • bash (Bourne Again Shell): Enhancements over sh, including arrays, better scripting capabilities, and additional built-in commands.
  • zsh: Advanced features for interactivity, plugins, and themes.
  • dash: Lightweight, fast, POSIX-compliant shell often used for system scripts.
  • fish: User-friendly, but not POSIX-compliant.

Understanding these differences helps choose the right shell for your needs.


POSIX Compatibility

POSIX (Portable Operating System Interface) defines standards for ensuring compatibility across Unix-like systems. Writing POSIX-compliant scripts ensures they work across different environments.

Writing POSIX-Compatible Scripts

  • Start scripts with #!/bin/sh.
  • Avoid bash-specific features (e.g., arrays, [[ ]] conditionals).
  • Test scripts using dash for compliance.

Example of POSIX-compliant script:

#!/bin/sh
# POSIX-compatible script
 
if [ "$1" ]; then
  echo "Argument provided: $1"
else
  echo "No arguments provided."
fi

Run it:

sh script.sh Hello

Result:

Argument provided: Hello

ShellCheck: Lint Your Scripts

ShellCheck is a static analysis tool for shell scripts. It identifies syntax errors, potential bugs, and best practices, making it invaluable for writing reliable scripts.

Installation

sudo apt install shellcheck # On Debian-based systems
brew install shellcheck     # On macOS

Usage

Run ShellCheck on your script:

shellcheck myscript.sh

Example script with issues:

#!/bin/bash
echo Hello $USER
ls $1

ShellCheck output:

Line 2: echo Hello $USER
     ^-- SC2086: Double quote to prevent globbing and word splitting.
Line 3: ls $1
     ^-- SC2086: Double quote to prevent globbing and word splitting.

Fixed script:

#!/bin/bash
echo "Hello $USER"
ls "$1"

Variables

Variables store data for reuse. They’re declared without spaces around the = sign.

Declaring Variables

#!/bin/bash
name="Alice"
age=30
location="Wonderland"
echo "$name, age $age, lives in $location."

Run:

./script.sh

Result:

Alice, age 30, lives in Wonderland.

Environment Variables

Environment variables are system-wide and accessible to all processes. Common examples are $PATH, $USER, and $HOME.

Setting and Using Environment Variables

#!/bin/bash
export MY_VAR="CustomValue"
echo "MY_VAR is: $MY_VAR"

Run:

./script.sh

Result:

MY_VAR is: CustomValue

Arguments

Arguments allow passing data to scripts from the command line. Access them using $1, $2, etc., and $@ or $* for all arguments.

Basic Example

#!/bin/bash
echo "First argument: $1"
echo "Second argument: $2"

Run:

./script.sh arg1 arg2

Result:

First argument: arg1
Second argument: arg2

Argument Validation

#!/bin/bash
if [ "$#" -lt 2 ]; then
  echo "Usage: $0 <arg1> <arg2>"
  exit 1
fi
 
echo "Arguments received: $1, $2"

Run:

  ./script_name "arg1" "arg2"

Result:

    Arguments received: arg1, arg2

Built-ins

Built-in commands like echo, cd, read, exit, pwd, test, export, set, and unset are part of the shell and do not require external binaries. Below is a list of commonly used built-ins with examples:

echo

The echo command is used to output text, variables, or other information to the terminal. It is commonly used for displaying messages or the values of variables. The command can be customized with flags to format the output, such as enabling or disabling newline characters.

echo "Hello, World!"

Result:

Hello, World!

cd

The cd (change directory) command is used to navigate between directories in the file system. It allows you to change the current working directory to a specified path, making it easier to access files or run commands in different locations.

cd /tmp
pwd

Result:

/tmp

read

The read command is used to accept input from the user or from a file and store it in a variable. It can be used to prompt users for input in scripts, allowing for dynamic interaction.

read -p "Enter your name: " name
echo "Hello, $name!"

Result:

Enter your name: Alice
Hello, Alice!

exit

The exit command is used to terminate the current shell or script. It can optionally accept an exit status code to indicate whether the shell or script has executed successfully (typically a code of 0) or encountered an error (non-zero codes).

exit 0

Exits the script with a status code of 0.

pwd

The pwd (print working directory) command is used to display the absolute path of the current working directory in the file system. This helps users confirm their current location within the file system hierarchy.

pwd

Displays the current directory.

test

The test command evaluates conditional expressions. It is commonly used to check for the existence of files, compare numbers, or evaluate strings within shell scripts. It returns a status code to indicate the result of the evaluation.

test -d /tmp && echo "Directory exists"

Result:

Directory exists

export

The export command is used to set environment variables or mark variables to be passed to child processes. By exporting a variable, it becomes accessible to any subsequently launched programs or scripts.

export MY_VAR="value"
echo $MY_VAR

Result:

value

set

The set command is used to set or unset shell options and variables. It can modify the behavior of the shell, configure the environment, and control various shell features, such as enabling or disabling certain features like debugging or error handling.

set -x
echo "Debugging enabled"
set +x

Result (with debug info):

+ echo Debugging enabled
Debugging enabled

unset

The unset command is used to remove variables or functions in the shell. It can be used to clear a variable or environment setting so that it no longer exists or is accessible in the current session.

unset MY_VAR
echo $MY_VAR

Result:

 

Quotes

Quotes control how strings are interpreted.

Double Quotes (")

Double quotes allow variable expansion and command substitution.

name="Alice"
echo "Hello, $name"

Result:

Hello, Alice

Single Quotes (')

Single quotes treat everything literally.

name="Alice"
echo 'Hello, $name'

Result:

Hello, $name

Escape Characters

Use \ to escape special characters.

echo "Hello \"world\""

Result:

Hello "world"

Globbing

Globbing matches filenames using wildcards (*, ?, []).

Examples:

* (Matches all files)

echo *.sh

Result:

script1.sh script2.sh

? (Matches a single character)

echo file?.txt

Result:

file1.txt file2.txt

[] (Matches a set of characters)

echo file[1-3].txt

Result:

file1.txt file2.txt file3.txt

Redirection

Redirect output or errors using >, >>, or 2>.

Redirect Output (>)

echo "Hello" > output.txt
cat output.txt

Result:

Hello

Append Output (>>)

echo "World" >> output.txt
cat output.txt

Result:

Hello
World

Redirect Errors (2>)

ls nonexistentfile 2> error.log
cat error.log

Result:

ls: cannot access 'nonexistentfile': No such file or directory

Loops

Loops in Bash scripts let you repeat tasks automatically. Think of it like packing boxes: instead of saying “put item 1, item 2, item 3,” you tell Bash, “for every item on this list, pack it.”

In Bash, you have four main types: while, for, until, and select loops.

While Loop

count=1
while [ "$count" -le 5 ]; do
  echo "$count"
  count=$((count + 1))
done

Result:

1
2
3
4
5

Until Loop

count=5
until [ "$count" -le 0 ]; do
  echo "$count"
  count=$((count - 1))
done

Result:

5
4
3
2
1

For Loop

for file in *.sh; do
  echo "Processing $file"
done

Result:

Processing script1.sh
Processing script2.sh

Select Loop

select option in "Option 1" "Option 2" "Quit"; do
  case $option in
    "Option 1") echo "You selected Option 1";;
    "Option 2") echo "You selected Option 2";;
    "Quit") break;;
    *) echo "Invalid option";;
  esac
done

Run and select options interactively.

Break and Continue

break and continue are used to control loops in Bash scripts:

  • break: Stops the loop entirely, like saying, “I’m done, move on to the next part of the script.”
  • continue: Skips the current loop iteration and moves to the next one, like saying, “Skip this item, go to the next.”

Break

for i in {1..10}; do
  if [ "$i" -eq 5 ]; then
    break
  fi
  echo "$i"
done

Result:

1
2
3
4

Continue

for i in {1..5}; do
  if [ "$i" -eq 3 ]; then
    continue
  fi
  echo "$i"
done

Result:

1
2
4
5

Functions

Functions in Bash are reusable blocks of code that make your scripts cleaner and more efficient. They let you group commands together, so instead of repeating the same steps, you can just call the function whenever you need it. Think of it like a shortcut for tasks you use often.

Basic Function

say_hello() {
  echo "Hello!"
}
 
say_hello

Result:

Hello!

Function with Parameters

Functions can accept parameters, accessed as $1, $2, etc., similar to script arguments.

Example: Greeting Function

greet() {
  echo "Hello, $1! Welcome to $2."
}
 
greet "Alice" "Wonderland"

Result:

  Hello, Alice! Welcome to Wonderland.

Dynamic Parameter Handling

Using $@ or $* to handle an unknown number of arguments.

 
    process_arguments() {
        echo "You passed $# arguments."
        for arg in "$@"; do
            echo "Argument: $arg"
        done
    }
 
    process_arguments "arg1" "arg2" "arg3"

Result:

    You passed 3 arguments.
    Argument: arg1
    Argument: arg2
    Argument: arg3

xargs

xargs is a powerful command in Bash that allows you to take input (often from another command or a file) and pass it as arguments to another command. It is often used to handle cases where commands don’t accept input in a way you want, or when you have a large number of arguments.

Example: Using xargs to Remove Files

    find . -name "*.log" | xargs rm

Result:

   This will find all .log files in the current directory and remove them.

Example: Using xargs to Count Words

        echo "This is a test" | xargs -n 1

Result:

        This
        is
        a
        test

The -n 1 option makes xargs pass one word at a time to the command.

Example: Combining Commands with xargs

        cat files.txt | xargs -I {} cp {} /backup/

Result:

    This will copy all files listed in files.txt to the /backup/ directory using xargs.

Pipes

Pipes in Bash (|) allow you to take the output of one command and pass it as input to another. They’re like a conveyor belt, connecting tools together to perform complex tasks step by step. For example, you could list files and then filter the results, all in one go.

Example: Filtering Text

    ls | grep ".sh"

Result :

    script1.sh
    script2.sh

Example: Counting Files

    ls | wc -l

Result :

    5

Advanced Example: Combining Commands

    ps aux | grep "bash" | awk '{print $2, $11}'

Result : Displays the process IDs and commands for bash processes.

Background Processes

In Bash, you can run tasks in the background by appending & to a command. This lets your script continue executing other commands without waiting for the background task to finish. It's useful when you want to run multiple tasks simultaneously or free up your terminal for other work.

Example: Running a Command in the Background

    long_task() {
        sleep 5
        echo "Task completed!"
    }
 
    long_task &
    bg_pid=$!
    echo "Waiting for process $bg_pid..."
    wait $bg_pid
    echo "Process $bg_pid finished."

Result :

    Waiting for process 1234...
    Task completed!
    Process 1234 finished.

Subshells

A subshell is a separate instance of the shell that runs commands in isolation from the main shell. You can create a subshell by enclosing commands in parentheses (). Any variables or changes made within the subshell won’t affect the parent shell, making it useful for testing, temporary changes, or running commands in isolation.

Example: Isolated Environment

    (cd /tmp && echo "Inside subshell: $(pwd)")
    echo "Outside subshell: $(pwd)"

Result :

    Inside subshell: /tmp
    Outside subshell: /current/directory

Example: Capturing Output

    output=$(ls | grep ".sh")
    echo "Script files: $output"

Result :

    Script files: script1.sh script2.sh

Trap

The trap command in Bash allows you to capture and respond to signals or script events. It’s commonly used to clean up resources, handle errors, or execute specific commands when a script exits or receives a signal (like SIGINT for Ctrl+C). You can specify the commands to execute and the signal(s) to watch for.

For example, trap is useful to ensure temporary files are deleted when a script is interrupted or to gracefully exit processes.

Example: Cleaning Up Temporary Files

    trap "echo 'Cleaning up...'; rm -f /tmp/tempfile" EXIT
 
    echo "Creating temporary file..."
    touch /tmp/tempfile
    echo "Temporary file created."

Result :

    Temporary file created.
    Cleaning up...

Another Example: Handling Interrupts

    trap "echo 'Interrupt received. Exiting...'; exit" INT
 
    echo "Running script. Press Ctrl+C to interrupt."
    while true; do
        sleep 1
    done

Result :

    Interrupt received. Exiting...
 

Debugging

Debugging in Bash helps you find and fix errors in your script. It involves running your script with additional options to track what’s happening step-by-step. One of the most common ways to debug is by using the -x flag when running your script, which prints each command as it is executed. This gives you insight into what’s going wrong and where.

Another useful tool is set -e, which stops the script immediately if any command fails. Combining these options makes it easier to troubleshoot and ensure your script runs smoothly.

Example: Enabling Debug Mode

    set -x
    echo "Debugging this script"
    set +x
    echo "Debugging off"

Result :

    + echo Debugging this script
    Debugging this script
    + set +x
    Debugging off
 

Real-World Example: Debugging a Loop

    set -x
    for file in *.sh; do
        echo "Processing $file"
    done
    set +x
 

Result :

    + for file in *.sh              # Debug: The loop starts and `file` is assigned the first match.
    + echo 'Processing script1.sh'  # Debug: The echo command is about to run.
    Processing script1.sh           # Output: The echo command's result.
    + for file in *.sh              # Debug: The loop continues with the next match.
    + echo 'Processing script2.sh'  # Debug: The echo command is about to run.
    Processing script2.sh           # Output: The echo command's result.
    + set +x                        # Debug: Debugging is disabled.
 
 

Final Application: File Management Utility

Now, let’s bring everything together into a complete Bash script that manages files efficiently.

Problem:

This Bash script serves as a file management utility with the following features:

  1. Organize Files by Type: Automatically sorts files in a directory into categories (e.g., images, documents, scripts).
  2. Log Operations and Errors: Keeps track of all actions and errors in a log file for accountability and troubleshooting.
  3. Interactive Menu: Offers an interactive menu for easy file management tasks like viewing, deleting, and archiving files.
  4. Error Handling: Gracefully handles errors and ensures resources are cleaned up on script exit using trap.

The Script

  #!/bin/bash
 
  # Uncomment to enable debugging mode
  # set -x
 
  # Variables
  LOG_FILE="file_manager.log"
  ARCHIVE_FILE="archive.tar.gz"
 
  # Trap to clean up and handle exits
  trap cleanup EXIT
  trap 'echo "Operation interrupted"; exit 1' INT
 
  # Functions
 
  # Log a message with timestamp
  log_message() {
      local message="$1"
      echo "$(date +'%Y-%m-%d %H:%M:%S') - $message" | tee -a "$LOG_FILE"
  }
 
  # Cleanup function
  cleanup() {
      log_message "Cleaning up temporary files and resources..."
      rm -f "$TARGET_DIR/$ARCHIVE_FILE"
      log_message "Exiting script."
  }
 
  # Organize files by extension dynamically (copy instead of move)
  organize_files() {
      if [ -z "$1" ]; then
          echo "Usage: $0 <directory_path>"
          exit 1
      fi
 
      TARGET_DIR="$1"
 
      # Verify the target directory exists
      if [ ! -d "$TARGET_DIR" ]; then
          echo "Error: Directory '$TARGET_DIR' does not exist."
          exit 1
      fi
 
      # Create a base directory for organized files
      ORGANIZED_DIR="$TARGET_DIR/organized"
      mkdir -p "$ORGANIZED_DIR"
 
      # Iterate over files in the target directory (not including subdirectories)
      for file in "$TARGET_DIR"/*; do
          if [ -f "$file" ]; then
              # Extract file extension
              ext="${file##*.}"
 
              # Handle files without an extension
              if [ "$file" == "$ext" ]; then
                  ext="no_extension"
              fi
 
              # Create a directory for the extension and copy the file
              mkdir -p "$ORGANIZED_DIR/$ext"
              cp "$file" "$ORGANIZED_DIR/$ext/"
          fi
      done
 
      log_message "Files organized into '$ORGANIZED_DIR'."
  }
 
  # View organized files
  view_files() {
      if [ ! -d "$ORGANIZED_DIR" ]; then
          log_message "No organized files found in '$ORGANIZED_DIR'."
          return
      fi
      log_message "Displaying organized files in '$ORGANIZED_DIR':"
      find "$ORGANIZED_DIR" -type f
  }
 
  # Delete organized files
  delete_files() {
      if [ ! -d "$ORGANIZED_DIR" ]; then
          log_message "No organized files to delete in '$ORGANIZED_DIR'."
          return
      fi
      read -p "Are you sure you want to delete the entire '$ORGANIZED_DIR' folder? [y/N]: " confirm
      if [[ "$confirm" =~ ^[Yy]$ ]]; then
          rm -rf "$ORGANIZED_DIR"
          log_message "Deleted the folder '$ORGANIZED_DIR'."
      else
          log_message "Deletion canceled."
      fi
  }
 
  # Archive organized files
  archive_files() {
      if [ ! -d "$ORGANIZED_DIR" ]; then
          log_message "No organized files to archive in '$ORGANIZED_DIR'."
          return
      fi
      tar -czf "$TARGET_DIR/$ARCHIVE_FILE" -C "$ORGANIZED_DIR" .
      log_message "Archived files into '$TARGET_DIR/$ARCHIVE_FILE'."
  }
 
  # Clean the archive
  clean_archive() {
      if [ -f "$TARGET_DIR/$ARCHIVE_FILE" ]; then
          rm -f "$TARGET_DIR/$ARCHIVE_FILE"
          log_message "Removed the archive file '$TARGET_DIR/$ARCHIVE_FILE'."
      else
          log_message "No archive file to clean."
      fi
  }
 
  # Menu system
  menu() {
      while true; do
          echo
          echo "File Management Utility"
          echo "-----------------------"
          echo "1. Organize files"
          echo "2. View organized files"
          echo "3. Delete organized files"
          echo "4. Archive organized files"
          echo "5. Clean archive"
          echo "6. Exit"
          read -p "Choose an option: " choice
 
          case $choice in
              1) organize_files "$TARGET_DIR" ;;
              2) view_files ;;
              3) delete_files ;;
              4) archive_files ;;
              5) clean_archive ;;
              6) break ;;
              *) echo "Invalid choice. Please try again." ;;
          esac
      done
  }
 
  # Main Script Execution
  if [ -z "$1" ]; then
      echo "Usage: $0 <directory_path>"
      exit 1
  fi
 
  TARGET_DIR="$(realpath "$1")"
  ORGANIZED_DIR="$TARGET_DIR/organized"
  log_message "Starting File Management Utility for directory: $TARGET_DIR..."
  menu
 

Initial State: Assume the current directory contains

  # Let's create some empty files, only for test
  touch files/image1.jpg files/script1.sh files/report.pdf files/notes.txt files/miscfile
 
  # List all files inside the 'files' folder
  tree -F files/
 
  # Output
 
  files/
  ├── image1.jpg
  ├── script1.sh
  ├── report.pdf
  └── notes.txt
  └── miscfile

Run the Script

    ./file_manager.sh ./files

Menu Options

You will see the following menu:

  File Management Utility
   -----------------------
   1. Organize files
   2. View organized files
   3. Delete organized files
   4. Archive organized files
   5. Clean archive
   6. Exit

Option 1: Organize Files

Files are moved into the organized/subdirectory:

    files/organized/jpg/image1.jpg
    files/organized/sh/script1.sh
    files/organized/pdf/report.pdf
    files/organized/txt/notes.txt
    files/organized/no_extension/miscfile

Option 2: View Organized Files

Select option 2 to view files:

    files/organized/jpg/image1.jpg
    files/organized/sh/script1.sh
    files/organized/pdf/report.pdf
    files/organized/txt/notes.txt
    files/organized/no_extension/miscfile

You can find the complete source code for the file manager script on GitHub. Check it out here: file_manager