Lewati ke konten
Rama's logo Qisthi Ramadhani
Go back

My Epic Quest Through the Bash Labyrinth: From Multiline Strings to Conquering Chaos

Let me tell you a story. The year was… well, let’s just say it was a Tuesday and I was feeling dangerously overconfident. I was tasked with a “simple” job: write a script to back up a production database, zip it, and upload it to a remote server. “Easy peasy,” I thought, channeling the hubris of a thousand doomed Greek heroes. “This will take, like, 20 minutes.”

Five hours later, I was staring at a terminal window, my hair looking like I’d been struck by lightning, and the server was… not happy. My script, a Frankenstein’s monster of half-understood commands copied from Stack Overflow, had gone rogue. It had tried to create a backup file named "backup_--.sql.gz" (thanks, date command with wrong parameters!), failed to find the file it had just misnamed, and then proceeded to try and upload a non-existent file, filling the logs with a torrent of errors that scrolled by like the closing credits of a horror movie. My “simple” script was a disaster.

That was the day I decided to stop just using Bash and start understanding it. I realized that for all the sleek, modern languages we use daily—hey there, Laravel and React!—Bash is the grizzled, bearded wizard holding the keys to the entire kingdom of automation, deployment, and system administration. It’s the duct tape of the server world, the universal language of getting stuff done. It can be ugly, it can be quirky, and its syntax can sometimes feel like it was designed by a committee of cats walking on a keyboard. But, oh boy, when you tame it? You become unstoppable.

So, this isn’t just another dry, technical tutorial. This is my war journal. My field guide, forged in the fires of late-night debugging sessions and catastrophic rm -rf typos (on a VM, thank goodness!). We’re going on a journey through the treacherous jungles of string manipulation, the foggy swamps of conditional logic, and the high peaks of function wizardry. By the end, you’ll not only know how to write Bash scripts, but why they behave the way they do. Let’s get our hands dirty.

The Art of Taming Strings: More Than Just “Hello, World!”

Strings in Bash are like that one friend who’s super helpful but also incredibly literal. You have to be very specific about what you want, or they’ll take your request and run with it in the most hilariously wrong direction possible. Learning to wrangle them is the first, and most crucial, step towards Bash mastery.

The Saga of the Multiline String: A Poet’s Best Friend

There comes a time in every scripter’s life when you need to embed a large block of text into a script. Maybe it’s an email template, a huge SQL query, or an ASCII art dragon you want to print to the console upon success (don’t judge, it’s for morale). Trying to do this with a bunch of echo statements is just… painful.

echo "This is the first line."
echo "This is the second line."
echo "And, oh gosh, this is tedious."
# And so on for 50 more lines...

That’s a one-way ticket to carpal tunnel and madness. I learned this the hard way while trying to create a dynamic Nginx configuration file from a script. My screen was a sea of escaped quotes and backslashes. It was unreadable.

Then I discovered Here Documents, and the heavens opened up. 🌤️

A bash multiline string is most elegantly handled using a “Here Document.” It sounds fancy, but it’s dead simple. You tell Bash, “Hey, everything from here until you see this special word is one giant string.”

Here’s how you do it:

#!/bin/bash

# A giant, beautiful, multiline string
# The `cat << EOF` part means "output the following text until you see EOF"
read -r -d '' MESSAGE << EOF
Hello Team,

This is a friendly reminder that the deployment for Project Phoenix
will happen this Friday at 10:00 PM.

Please ensure all your pull requests are merged by EOD Thursday.

The following services will be affected:
  - Web Application
  - API Gateway
  - Worker Queues

Thanks,
Your Friendly Neighborhood DevOps Bot 🤖
EOF

# Now, we can just use the variable!
echo "$MESSAGE"

Let’s break that down. << EOF tells Bash to start recording. EOF can be any word you want (I often use SQL or HTML to make it clear what the block contains), as long as the closing word is on a new line, by itself, with no leading or trailing spaces. It’s the “end of file” marker for your string. This is, without a doubt, the cleanest way to handle a multiline string bash situation. You can even embed variables inside it, and they’ll be expanded. It’s glorious.

Concatenation: Sticking Strings Together Without Super Glue

Okay, so you have your strings. Now you need to join them. This is a fundamental task, and thankfully, bash string concatenation is pretty straightforward. You literally just mash the variables together.

My first foray into this was creating dynamic filenames. I needed backup-YYYY-MM-DD.tar.gz. Seemed simple enough.

#!/bin/bash

# This is how you set a variable in bash, by the way. No spaces around the =!
DATE_STAMP=$(date +"%Y-%m-%d")
FILENAME="backup-"
FULL_FILENAME="$FILENAME$DATE_STAMP.tar.gz" # Just smoosh 'em together!

echo "Creating backup: $FULL_FILENAME"
# Output: Creating backup: backup-2025-08-12.tar.gz

Notice the quotes around $FILENAME$DATE_STAMP.tar.gz. SUPER IMPORTANT. If your variables ever contain spaces, forgetting the quotes will lead to a world of pain. Get in the habit of quoting everything.

For a more, shall we say, “programmatic” feel, you can also use the += operator to append to a string. This is great for building up a string inside a loop.

#!/bin/bash

# Let's build a comma-separated list of users
USER_LIST=""
USERS=("rama" "alice" "bob")

for USER in "${USERS[@]}"; do
  # If the list isn't empty, add a comma first
  if [ -n "$USER_LIST" ]; then
    USER_LIST+=", "
  fi
  # Concatenate strings bash style!
  USER_LIST+="$USER"
done

echo "User list: $USER_LIST"
# Output: User list: rama, alice, bob

This approach to concatenate strings bash style is much cleaner than USER_LIST="$USER_LIST, $USER", especially in long loops. It just feels a bit more deliberate.

The Great Divide: The Agony and Ecstasy of the Bash Split String

Now for the reverse: taking a string and tearing it asunder. The bash split string operation is one of those things that’s incredibly powerful but also famously quirky. The secret weapon here is a magical, and often infuriating, environment variable called IFS.

IFS stands for Internal Field Separator. By default, it’s set to space, tab, and newline. This is why when you run a command like for i in $(cat file.txt), it loops over each word, not each line. Bash is using IFS to split the output of cat file.txt.

Let’s say you have a string of comma-separated values, like data="one,two,three". How do you split string in bash? You temporarily change IFS to a comma.

Here’s a story. I once had to parse a CSV file that was… less than perfectly formatted. It was generated by some ancient system and contained a mix of commas, semicolons, and pipes as delimiters. A true nightmare. IFS was my only hope.

#!/bin/bash

# A typical CSV-like string
LOG_ENTRY="2025-08-12T13:00:00,prod-web-01,500,Internal Server Error"

# Save the old IFS so we don't mess up the rest of the script
OLD_IFS=$IFS
# Set the IFS to a comma to split the string
IFS=','

# `read` is a fantastic command for this. -a puts the parts into an array.
read -r -a PARTS <<< "$LOG_ENTRY"

# Restore the old IFS. This is VERY important!
IFS=$OLD_IFS

# Now we can access the parts
TIMESTAMP="${PARTS[0]}"
HOSTNAME="${PARTS[1]}"
STATUS_CODE="${PARTS[2]}"
MESSAGE="${PARTS[3]}"

echo "Error on host '$HOSTNAME' at $TIMESTAMP: $MESSAGE (Code: $STATUS_CODE)"
# Output: Error on host 'prod-web-01' at 2025-08-12T13:00:00: Internal Server Error (Code: 500)

The <<< is a “Here String,” a cousin of the Here Document, which is a great way to pipe a variable’s content into a command. The key takeaway: IFS is your tool for splitting. Change it, do your split, and change it back immediately. Forgetting to restore IFS has caused some of the most bizarre and hard-to-diagnose bugs I’ve ever seen in my career.

A Slice of the Pie: The Magic of the Bash Substring

Sometimes you don’t need to split a whole string; you just need to carve out a little piece. This is where bash substring extraction comes in. Forget awk or cut for simple cases; you can do it right inside Bash.

The syntax is a bit cryptic at first, but you get used to it: ${VARIABLE:offset:length}.

I use this all the time for manipulating filenames and timestamps. Let’s say I have a file named report-20250812-final.docx.

#!/bin/bash

FILENAME="report-20250812-final.docx"

# Let's get the date part. It starts at character 7 and is 8 characters long.
DATE_PART=${FILENAME:7:8}
echo "Date from filename: $DATE_PART" # Output: 20250812

# You can even use this to get the year, month, day
YEAR=${DATE_PART:0:4}
MONTH=${DATE_PART:4:2}
DAY=${DATE_PART:6:2}
echo "Parsed Date: $YEAR-$MONTH-$DAY" # Output: 2025-20-12

# What if you want everything from an offset to the end? Just omit the length.
# Get "final.docx"
SUFFIX=${FILENAME:16}
echo "Suffix: $SUFFIX" # Output: final.docx

This technique for getting a substring in bash is incredibly fast because it’s a shell builtin. It doesn’t need to spawn a new process like cut or sed would, making it ideal for performance-critical loops. Mastering the bash shell substring syntax is a huge level-up for your scripting game.

Formatting Strings: Making Your Output Not Look Like a Ransom Note

echo is fine for simple stuff. But when you need alignment, padding, or specific number formatting, echo throws its hands up and walks away. This is where you bring in the heavy hitter: printf.

If you’ve ever used printf in C or sprintf in languages like PHP, you’ll feel right at home. It’s the secret to making your script’s output look professional instead of like a jumbled mess.

Let’s imagine we’re creating a simple report of server disk usage.

The echo way (yuck):

echo "Server: web01 Usage: 85%"
echo "Server: db01 Usage: 62%"
echo "Server: cache-cluster-node-01 Usage: 30%"

The output is all misaligned and ugly.

The printf way (beautiful ✨):

#!/bin/bash

# Let's use an associative array to store our data
declare -A USAGE
USAGE["web01"]="85"
USAGE["db01"]="62"
USAGE["cache-cluster-node-01"]="30"

# The header for our report
printf "%-25s | %s\n" "Server Name" "Usage"
printf "--------------------------|-------\n"

# The loop
for SERVER in "${!USAGE[@]}"; do
  # This is where the magic happens
  printf "%-25s | %d%%\n" "$SERVER" "${USAGE[$SERVER]}"
done

That printf command is the key to mastering bash format string. Let’s decode it:

The output is a thing of beauty:

Server Name               | Usage
--------------------------|-------
db01                      | 62%
web01                     | 85%
cache-cluster-node-01     | 30%

See? So much cleaner. printf is your best friend for any kind of structured text output.

The Logic Maze: Conditionals, Booleans, and Comparisons

If strings are the building materials of your script, then logic is the architectural plan. This is where you make decisions, react to different situations, and prevent your script from blindly running off a cliff.

To Be or Not to Be: The Curious Case of the Bash Boolean

Here’s the first mind-bending concept for anyone coming from a traditional programming language: there is no native boolean in bash.

Let that sink in. There’s no true or false keyword that a variable can hold.

So how does if work? It’s all about exit codes.

Every command you run in Linux finishes with an exit code, a number between 0 and 255. By convention:

You can see the exit code of the last command by checking the special variable $?.

ls /etc/passwd   # This file exists, so the command will succeed
echo $?          # Output: 0 (True)

ls /not/a/real/file  # This will fail
echo $?             # Output: some non-zero number, probably 1 or 2 (False)

The if statement doesn’t check a boolean value; it executes a command and checks its exit code.

# `grep` returns 0 if it finds a match, and 1 if it doesn't.
if grep "root" /etc/passwd > /dev/null; then
  echo "Found root user!"
fi

We redirect the output of grep to /dev/null because we don’t care about what it found, only that it found something (i.e., that its exit code was 0). This is the fundamental concept behind booleans in bash. It’s a shift in thinking from “is this variable true?” to “did this command succeed?”

There are, confusingly, commands named true and false. The true command does nothing and exits with 0. The false command does nothing and exits with 1. They are sometimes used to create infinite or controlled loops.

The Showdown: Comparing Strings in the Bash Arena

Okay, so how do you compare things if you don’t have booleans? You use a command that’s designed to compare things and return an exit code of 0 (true) or 1 (false). The most common of these commands is test, which can also be written as [ ... ]. The more modern and recommended version is [[ ... ]] (double brackets).

Let’s get this straight because it confuses EVERYONE at first.

My advice: Always use [[ ... ]] unless you need your script to be portable to ancient, non-Bash shells (like sh). For Bash scripting, [[ ... ]] is safer and more powerful.

Here’s how you bash compare strings:

#!/bin/bash

# How to set a variable, a quick refresher!
MY_SHELL="bash"
YOUR_SHELL="zsh"

# Using == for string comparison (works in [[ ]], = is more portable for [ ])
if [[ "$MY_SHELL" == "bash" ]]; then
  echo "We're speaking the same language."
fi

# The all-important "bash not equals" check
if [[ "$YOUR_SHELL" != "bash" ]]; then
  echo "You're one of those cool Zsh kids, huh?"
fi

CRITICAL GOTCHA ALERT: Inside [[ ... ]], you use == or != for strings. For numbers, you MUST use -eq (equal), -ne (not equal), -gt (greater than), -lt (less than), etc.

I once spent two hours debugging a script that was supposed to check if a count was greater than 100. I wrote if [[ "$COUNT" > "100" ]]. This does a lexical (alphabetical) comparison, not a numerical one. So, from Bash’s perspective, “50” is greater than “100” because “5” comes after “1”. 🤦‍♂️

The correct way:

COUNT=50
if [[ "$COUNT" -gt 100 ]]; then
  echo "Count is over 100."
else
  echo "Count is not over 100." # This is what will be printed
fi

This distinction between string and integer comparison is a rite of passage. Suffer it once, remember it forever.

Is This Thing On? The Many Ways to Check if a File Exists

This is one of the most common tasks in scripting. Before you read, write, or delete something, you’d better make sure it’s there (or not there). This is a keyword bonanza, so let’s hit them all: bash test if file exists, check if a file exists bash, bash check if file exists, and bash if file exists. They all boil down to using the test ([ ... ]) or [[ ... ]] command with special file operators.

Here are the greatest hits:

My personal horror story involves a script that was supposed to clean up old log files in /var/log/my-app/. One day, it was accidentally run from / as root. It was looking for a directory named my-app to cd into. It didn’t have a check to see if that directory existed. The cd failed, but the script kept running. The next command was rm -rf *.log. From the / directory.

Luckily, it was on a staging server. But that was the day I tattooed if [[ -d "$TARGET_DIR" ]] on my brain.

Here’s a robust example to check if a file exists bash style:

#!/bin/bash

CONFIG_FILE="/etc/my-app/config.toml"

# First, check if the file exists and is a regular file
if [[ -f "$CONFIG_FILE" ]]; then
  echo "Found config file. Checking if it's readable and not empty..."

  # Now let's use a `bash if and` condition!
  if [[ -r "$CONFIG_FILE" && -s "$CONFIG_FILE" ]]; then
    echo "Config file is valid and ready to be parsed."
    # ... do parsing logic here ...
  else
    echo "Error: Config file exists but is not readable or is empty." >&2
    exit 1 # Exit with an error code
  fi
else
  echo "Error: Config file not found at $CONFIG_FILE" >&2
  echo "Please create it and try again."
  exit 1
fi

This is how you write safe, defensive scripts. Check your assumptions!

And, Or, But… What? Mastering Complex Conditions

As you saw above, you often need to check more than one thing at a time. This is where && (AND) and || (OR) come into play within [[ ... ]].

Let’s say we need to run a task only if it’s a weekday AND the user is root.

#!/bin/bash

DAY_OF_WEEK=$(date +%u) # 1=Monday, 7=Sunday
CURRENT_USER=$(whoami)

# We want to run if day is between 1 and 5 AND user is root.
if [[ "$DAY_OF_WEEK" -ge 1 && "$DAY_OF_WEEK" -le 5 && "$CURRENT_USER" == "root" ]]; then
  echo "Running scheduled weekday root task..."
  # ... do the important work ...
else
  echo "Skipping task. Either it's the weekend or you're not root."
fi

Using && and || inside [[ ... ]] is clean and easy to read. It’s the modern way to do a bash if and.

The “Otherwise” Clause: Don’t Forget elif!

Sometimes you have a cascade of conditions. “If this is true, do A. Otherwise, if this other thing is true, do B. Otherwise, do C.” You could nest your if statements, but that gets ugly fast.

Enter elif bash, the “else if” of the shell world.

#!/bin/bash

HTTP_STATUS=404 # Let's pretend we got this from a curl command

if [[ "$HTTP_STATUS" -ge 200 && "$HTTP_STATUS" -lt 300 ]]; then
  echo "Success! ($HTTP_STATUS)"
elif [[ "$HTTP_STATUS" -ge 400 && "$HTTP_STATUS" -lt 500 ]]; then
  echo "Client Error! We messed up. ($HTTP_STATUS)"
elif [[ "$HTTP_STATUS" -ge 500 && "$HTTP_STATUS" -lt 600 ]]; then
  echo "Server Error! They messed up. ($HTTP_STATUS)"
else
  echo "Unknown status code: $HTTP_STATUS"
fi

This structure is flat, readable, and much easier to debug than a tangled mess of nested if/else blocks. Embrace the elif.

Building Blocks of Power: Functions, Arguments, and Variables

Now we’re getting to the good stuff. Structuring your code properly is what separates a quick-and-dirty script from a maintainable piece of automation.

Variables 101: How to Set a Variable and Not Lose Your Mind

We’ve done this a few times, but it’s worth its own section because it’s the source of so much beginner pain. To set variable in bash, you use the syntax VAR_NAME="value".

The two golden rules of bash how to set a variable:

  1. NO SPACES around the equals sign. VAR = "value" is wrong. It will try to run a command named VAR with arguments = and "value". It must be VAR="value".
  2. QUOTE YOUR VALUES, especially if they contain spaces or special characters. MESSAGE="Hello, world" is safe. MESSAGE=Hello, world will assign “Hello,” to MESSAGE and then try to run a command named world.

I will confess, even after years of writing Bash, my fingers will sometimes betray me and type a space around the =. The resulting “command not found” error is an old, familiar, and humbling friend.

The Mysterious Return: Getting Values Out of Bash Functions

This is probably the most unintuitive part of Bash for developers coming from other languages. You see the return keyword and think, “Great, I’ll just return "my value"”.

WRONG.

In Bash, return can only do one thing: set the function’s exit code (a number between 0 and 255). It’s for signaling success (0) or failure (>0), just like any other command.

So, how in the world do you get a value out of a function? There are two primary methods for handling a bash functions return value:

Method 1: Command Substitution (The Preferred Way)

The function echos or printfs its result to standard output, and the caller captures it.

#!/bin/bash

# This function "returns" a formatted date string.
get_timestamp() {
  date +"%Y-%m-%d %H:%M:%S"
}

echo "Script started."
# Capture the output of the function into a variable
START_TIME=$(get_timestamp)
echo "Start time was: $START_TIME"

# ... do some long-running task ...
sleep 2

echo "Script finished."
END_TIME=$(get_timestamp)
echo "End time was: $END_TIME"

This is clean, composable, and follows the Unix philosophy of programs communicating via standard streams. The $(...) syntax is called “command substitution.” It runs the command inside and replaces the $(...) with its output.

Method 2: Using a Global Variable (Use with Caution)

The function modifies a variable that is visible in the global scope.

#!/bin/bash

# This function modifies a global variable named RESULT
calculate_sum() {
  local num1=$1 # Use local to keep num1 and num2 from polluting the global scope
  local num2=$2
  # RESULT is global by default
  RESULT=$((num1 + num2))
}

echo "Calculating..."
# Call the function. It doesn't output anything.
calculate_sum 10 32
# The global variable RESULT now holds the value.
echo "The sum is: $RESULT" # Output: The sum is: 42

This works, but it can get messy fast. It’s much harder to reason about functions that have “side effects” like modifying global state. I strongly recommend the command substitution method whenever possible.

Passing the Baton: Function Arguments

Passing arguments to a function is much more straightforward. Inside your function, you can access them using special positional parameters:

Here’s a practical bash function argument example:

#!/bin/bash

# A function to greet multiple people
greet() {
  if [[ "$#" -eq 0 ]]; then
    echo "You didn't tell me who to greet!" >&2
    return 1 # Return a failure exit code
  fi

  local prefix=$1 # The first argument is the prefix
  shift # This command "shifts" all arguments to the left. $2 becomes $1, etc.

  echo "Greeting everyone with prefix '$prefix':"
  # Loop through the REMAINING arguments
  for name in "$@"; do
    echo "$prefix, $name!"
  done
}

greet "Hello" "Rama" "Alice" "Bob"
echo "---"
greet "Howdy" # This works too! But it just prints the prefix line.

Going Pro with Arguments: getopts for Civilized Scripts

Manually parsing $1, $2, etc. is fine for one or two simple arguments. But what if you want to write a real command-line tool with options (flags) like ls -l -a? Trying to parse that manually is a recipe for a migraine.

This is where bash getopts comes to the rescue. It’s a shell builtin designed to parse these kinds of options in a standard way.

It’s a bit weird to use at first, as it’s designed to be used inside a while loop. Here’s a commented skeleton you can adapt. It’s my go-to template.

#!/bin/bash

# Default values for our options
VERBOSE=0
OUTPUT_FILE=""
MODE="default"

usage() {
  echo "Usage: $0 [-v] [-f filename] [-m mode]"
  echo "  -v: Verbose mode"
  echo "  -f: Specify output file"
  echo "  -m: Specify mode (test or prod)"
  exit 1
}

# The getopts magic string: "vf:m:"
# A letter by itself (like v) is a boolean flag.
# A letter followed by a colon (like f:) requires an argument.
while getopts "vf:m:" opt; do
  case "$opt" in
    v)
      VERBOSE=1
      ;;
    f)
      OUTPUT_FILE="$OPTARG"
      ;;
    m)
      MODE="$OPTARG"
      ;;
    ?) # The '?' handles unknown options
      usage
      ;;
  esac
done

# This moves past the parsed options, so $1 is now the first non-option argument
shift $((OPTIND - 1))

# Now we can use our variables
echo "--- Configuration ---"
echo "Verbose: $VERBOSE"
echo "Output File: $OUTPUT_FILE"
echo "Mode: $MODE"
echo "Remaining arguments: $@"
echo "---------------------"

# Example of how you'd run it:
# ./myscript.sh -v -m prod -f /tmp/output.log some_other_arg

Learning getopts is what elevates your script from a simple automate-o-matic to a professional-feeling command-line utility.

The Nitty-Gritty: Execution, Control, and Advanced Tools

We’ve covered the code. Now let’s talk about the environment it runs in.

Let’s Get This Party Started: How to Execute a Bash Script

So you’ve written your masterpiece. How do you run a bash script? There are two main ways.

Method 1: The Explicit Interpreter You can tell the bash program to execute your script file directly.

bash my_script.sh

This works even if the script file itself isn’t marked as executable. You’re explicitly saying, “Bash, run this.”

Method 2: Making the Script Executable This is the more common and “Unix-y” way.

  1. Add a Shebang: The very first line of your script should be #!/bin/bash. This is called a “shebang.” It tells the operating system, “If someone tries to execute this file, don’t run it yourself; hand it over to the program located at /bin/bash.”

  2. Set the Executable Permission: You need to tell the filesystem that this file is a program that can be run.

    chmod +x my_script.sh
  3. Run it! Now you can execute it directly.

    ./my_script.sh

    The ./ is important. It tells the shell to look for the script in the current directory. For security reasons, the current directory isn’t usually in the system’s PATH.

Knowing how to execute a bash script properly, especially with the shebang and permissions, is a fundamental skill.

PULL THE PLUG! How to Gracefully (or not-so-gracefully) Exit a Script

Your script doesn’t have to run to completion. You can bail out at any time using the exit command. This is crucial for error handling.

The most important thing about exit in bash script is to use an exit code.

This allows other scripts or tools (like Jenkins or cron) to know if your script succeeded or failed.

#!/bin/bash

# A simple backup script with proper exits
SOURCE_DIR="/home/rama/documents"
DEST_DIR="/mnt/backups/rama"

echo "Starting backup..."

# A classic 'bash check file exists' pattern
if ! [[ -d "$SOURCE_DIR" ]]; then
  echo "Error: Source directory $SOURCE_DIR does not exist." >&2
  exit 1 # Stop the script with a failure code
fi

if ! [[ -d "$DEST_DIR" ]]; then
  echo "Error: Destination directory $DEST_DIR does not exist." >&2
  exit 1 # A different failure, but still a failure
fi

# If we get here, the checks passed
rsync -a "$SOURCE_DIR/" "$DEST_DIR/"

echo "Backup completed successfully."
exit 0 # Explicitly exit with success

To truly terminate bash script execution cleanly, especially if you create temporary files, you can use trap. trap lets you run a command when the script receives a signal, like EXIT (any exit), INT (Ctrl+C), or TERM (kill command).

#!/bin/bash

# Create a temporary file with a unique name
TEMP_FILE="/tmp/my_script.$$"

# The cleanup function
cleanup() {
  echo "Cleaning up temporary file..."
  rm -f "$TEMP_FILE"
}

# Set the trap: call the 'cleanup' function on any exit
trap cleanup EXIT

echo "Doing some work and writing to $TEMP_FILE"
date > "$TEMP_FILE"
sleep 5

echo "Work done. Exiting."
# The 'cleanup' function will be called automatically here.

Using trap is an advanced technique that shows you’re thinking about robustness.

Who Am I? The Meaning of $$ and Other Arcane Symbols

You saw $$ in the trap example. So, bash $$ meaning? It’s one of several special parameters:

Using $$ for temp files is a classic and very effective pattern: TMP_DIR="/tmp/my-app.$$" ensures that even if you run the script twice at the same time, they won’t step on each other’s toes.

Just the Name, Please: Stripping Paths with basename

Often, you’ll have a full path to a file, like /var/log/nginx/access.log, but you only care about the access.log part. You could use fancy substring manipulation, but there’s a command for that: basename bash.

#!/bin/bash

FULL_PATH="/home/rama/projects/laravolt/readme.md"

# Use the basename command
FILENAME=$(basename "$FULL_PATH")
echo "The filename is: $FILENAME" # Output: The filename is: readme.md

# You can also strip a specific suffix
FILENAME_NO_EXT=$(basename "$FULL_PATH" .md)
echo "Without extension: $FILENAME_NO_EXT" # Output: Without extension: readme

There’s also a partner command, dirname, which does the opposite and gives you the directory part of the path.

The Ultimate Weapon: A Crash Course in Bash Regex

Now for the final boss. When string manipulation with substrings and splitting isn’t enough, you need regex bash. Bash’s built-in regular expression matching is powerful, if a little clunky.

The magic happens inside [[ ... ]] with the =~ operator.

Let’s try a simple validation. Is a variable a valid (simplified) email address?

#!/bin/bash

EMAIL="rama@qisthi.dev"
INVALID_EMAIL="not-an-email"

# The regex: starts with one or more "word" chars, then @, then more word chars,
# a dot, and more word chars. This is a simplified regex!
REGEX="^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$"

# Use the =~ operator for bash regex matching
if [[ "$EMAIL" =~ $REGEX ]]; then
  echo "$EMAIL looks like a valid email address."
else
  echo "$EMAIL does NOT look like a valid email address."
fi

if [[ "$INVALID_EMAIL" =~ $REGEX ]]; then
  echo "$INVALID_EMAIL looks like a valid email address."
else
  echo "$INVALID_EMAIL does NOT look like a valid email address."
fi

When a match is successful, Bash populates a special array called BASH_REMATCH. ${BASH_REMATCH[0]} is the entire matched string, ${BASH_REMATCH[1]} is the first capture group (...), and so on. This is how you extract parts of a string with regex.

Putting It All Together: My “Digital Doom-Scroller’s Downloads” Organizer

Okay, theory is great, but let’s build something. My Downloads folder is a digital wasteland. It’s a mix of PNGs, ZIPs, PDFs, and disk images (.dmg) from three years ago. It’s time to clean it up.

Here’s a script that combines almost everything we’ve talked about.

#!/bin/bash
#
# Downloads Organizer - a script to bring sanity to the chaos.
#

# --- Configuration & Defaults ---
# Use `getopts` for command-line flags.
DRY_RUN=0
VERBOSE=0
TARGET_DIR="${HOME}/Downloads" # Default to the user's Downloads folder

# --- Functions ---

# A simple logging function demonstrating arguments and printf
log() {
  local level="$1"
  local message="$2"
  local timestamp
  timestamp=$(date +"%Y-%m-%d %H:%M:%S")

  # Use printf for nice formatting!
  if [[ "$VERBOSE" -eq 1 || "$level" == "ERROR" ]]; then
    printf "[%-5s] [%s] %s\n" "$level" "$timestamp" "$message"
  fi
}

# Function to show usage, demonstrating exit
usage() {
  # Here Document for our multiline usage string
  cat << EOF
Usage: $(basename "$0") [-n] [-v] [-d /path/to/target]
A script to organize your Downloads folder.

  -n          Dry-run mode. Won't move any files, just shows what it would do.
  -v          Verbose mode. Prints out every action.
  -d <path>   Specify a different directory to organize.
EOF
  # exit in bash script with an error code
  exit 1
}

# --- Main Script Logic ---

# 1. Parse options with getopts
while getopts "nvd:" opt; do
  case "$opt" in
    n) DRY_RUN=1 ;;
    v) VERBOSE=1 ;;
    d) TARGET_DIR="$OPTARG" ;;
    ?) usage ;;
  esac
done

# 2. Initial checks and setup
log "INFO" "Starting the organizer script."
if [[ "$DRY_RUN" -eq 1 ]]; then
  log "WARN" "DRY RUN MODE IS ENABLED. NO FILES WILL BE MOVED."
fi

# bash check if file exists (as a directory)
if ! [[ -d "$TARGET_DIR" ]]; then
  log "ERROR" "Target directory '$TARGET_DIR' not found!"
  # terminate bash script
  exit 1
fi

# Define destination folders using string concatenation
IMAGES_DIR="${TARGET_DIR}/Images"
DOCS_DIR="${TARGET_DIR}/Documents"
ARCHIVES_DIR="${TARGET_DIR}/Archives"
OTHER_DIR="${TARGET_DIR}/Other"

# 3. Create destination directories if they don't exist
for dir in "$IMAGES_DIR" "$DOCS_DIR" "$ARCHIVES_DIR" "$OTHER_DIR"; do
  # bash if file exists check before creating
  if ! [[ -d "$dir" ]]; then
    log "INFO" "Creating directory: $dir"
    if [[ "$DRY_RUN" -eq 0 ]]; then
      mkdir "$dir"
    fi
  fi
done

# 4. The main loop - process files
log "INFO" "Scanning '$TARGET_DIR' for files to organize..."

# Use a find command piped to a while read loop for safety with weird filenames
find "$TARGET_DIR" -maxdepth 1 -type f | while read -r file; do
  # Use basename to get just the filename
  filename=$(basename "$file")
  # Use substring/parameter expansion to get the extension
  extension="${filename##*.}"
  extension_lower=$(echo "$extension" | tr '[:upper:]' '[:lower:]')

  dest_dir=""

  # Use a case statement (cleaner than lots of elifs) for file types
  case "$extension_lower" in
    jpg|jpeg|png|gif|webp|svg)
      dest_dir="$IMAGES_DIR"
      ;;
    pdf|docx|doc|txt|md|pages)
      dest_dir="$DOCS_DIR"
      ;;
    zip|gz|tar|bz2|rar|7z)
      dest_dir="$ARCHIVES_DIR"
      ;;
    *)
      # Use a bash if and condition for special cases
      if [[ "$filename" == "README" || "$filename" == "LICENSE" ]]; then
        log "INFO" "Skipping special file: $filename"
        continue # Skip to the next iteration of the loop
      fi
      dest_dir="$OTHER_DIR"
      ;;
  esac

  log "ACTION" "Moving '$filename' to '$dest_dir'"
  if [[ "$DRY_RUN" -eq 0 ]]; then
    # The actual move command
    mv "$file" "$dest_dir/"
  fi
done

log "INFO" "Organization complete!"
# Exit script bash with a success code
exit 0

This script is a microcosm of a real-world Bash utility. It has argument parsing, error checking, functions, logging, and robust file handling. It’s the kind of tool that, once written, can save you hours of manual work.

The Labyrinth Has an Exit

And there you have it. We’ve journeyed from the simple act of sticking two strings together to building a genuinely useful utility. We’ve seen that Bash isn’t just a command line; it’s a full-fledged programming environment, albeit a quirky and sometimes cantankerous one.

The real power of Bash lies in its ubiquity and its role as the universal glue of the command-line world. It’s what lets you chain together powerful tools like grep, awk, sed, curl, and jq into automated workflows that can do almost anything.

My advice? Don’t be afraid to experiment. Spin up a Docker container or a VM and just… try stuff. Write a script to automate a boring part of your day. Write a function to generate a silly report. The moment you go from copying and pasting commands to saving them in a .sh file, you’ve become a scripter. And the journey from there is one of the most rewarding in a developer’s career.

The labyrinth might be vast, but you have the map now. Go get lost in it.

Now, it’s your turn. What’s the most face-palm-worthy, hilarious, or catastrophic mistake you’ve ever made in a Bash script? Share your war stories in the comments below! 👇


Share this post on:
LLM-friendly version:
Open in ChatGPT Open in Claude

Related Posts


Previous Post
Short Queries, Massive Tables: Indexes That Make Laravel Fly (Laravel + PostgreSQL Performance Part 3)
Next Post
Database Indexing Pitfalls: The Boolean Flag Trap, We Should Know