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:

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

Run it:

sh
sh script.sh Hello

Result:

shell
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

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

Usage

Run ShellCheck on your script:

bash
shellcheck myscript.sh

Example script with issues:

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

ShellCheck output:

shell
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:

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

Variables

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

Declaring Variables

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

Run:

bash
./script.sh

Result:

shell
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

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

Run:

bash
./script.sh

Result:

shell
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

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

Run:

bash
./script.sh arg1 arg2

Result:

shell
First argument: arg1 Second argument: arg2

Argument Validation

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

Run:

bash
./script_name "arg1" "arg2"

Result:

bash
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.

bash
echo "Hello, World!"

Result:

shell
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.

bash
cd /tmp pwd

Result:

shell
/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.

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

Result:

shell
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).

bash
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.

bash
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.

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

Result:

shell
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.

bash
export MY_VAR="value" echo $MY_VAR

Result:

shell
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.

bash
set -x echo "Debugging enabled" set +x

Result (with debug info):

shell
+ 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.

bash
unset MY_VAR echo $MY_VAR

Result:

bash

Quotes

Quotes control how strings are interpreted.

Double Quotes (")

Double quotes allow variable expansion and command substitution.

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

Result:

shell
Hello, Alice

Single Quotes (')

Single quotes treat everything literally.

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

Result:

shell
Hello, $name

Escape Characters

Use \ to escape special characters.

bash
echo "Hello \"world\""

Result:

shell
Hello "world"

Globbing

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

Examples:

* (Matches all files)

bash
echo *.sh

Result:

shell
script1.sh script2.sh

? (Matches a single character)

bash
echo file?.txt

Result:

shell
file1.txt file2.txt

[] (Matches a set of characters)

bash
echo file[1-3].txt

Result:

shell
file1.txt file2.txt file3.txt

Redirection

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

Redirect Output (>)

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

Result:

shell
Hello

Append Output (>>)

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

Result:

shell
Hello World

Redirect Errors (2>)

bash
ls nonexistentfile 2> error.log cat error.log

Result:

shell
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

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

Result:

shell
1 2 3 4 5

Until Loop

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

Result:

shell
5 4 3 2 1

For Loop

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

Result:

shell
Processing script1.sh Processing script2.sh

Select Loop

bash
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

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

Result:

shell
1 2 3 4

Continue

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

Result:

shell
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

bash
say_hello() { echo "Hello!" } say_hello

Result:

shell
Hello!

Function with Parameters

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

Example: Greeting Function

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

Result:

shell
Hello, Alice! Welcome to Wonderland.

Dynamic Parameter Handling

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

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

Result:

shell
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

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

Result:

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

Example: Using xargs to Count Words

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

Result:

bash
This is a test

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

Example: Combining Commands with xargs

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

Result:

bash
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

bash
ls | grep ".sh"

Result :

bash
script1.sh script2.sh

Example: Counting Files

bash
ls | wc -l

Result :

bash
5

Advanced Example: Combining Commands

bash
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

bash
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 :

bash
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

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

Result :

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

Example: Capturing Output

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

Result :

bash
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

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

Result :

bash
Temporary file created. Cleaning up...

Another Example: Handling Interrupts

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

Result :

bash
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

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

Result :

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

Real-World Example: Debugging a Loop

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

Result :

bash
+ 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

bash
#!/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

bash
# 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

bash
./file_manager.sh ./files

Menu Options

You will see the following menu:

shell
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:

shell
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:

shell
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