Bash Scripting

Guide to quickly build bash scripts

Integer variable

declare -i ivar

  • if we the assign a value to this variable, the expression will automatically be evaluated

however be careful :

  • integer variables will calculate math for us - it is possible to omit the round brackets in certain cases, but not always x=4; y=5 ivar=x+y echo $ivar

Thus, best practice:

  • (( ivar = x + y ))
  • ivar=$(( x + y ))

Read integers:

echo -n "Pleae enter and integer: "
read value

declare -i ivalue="${value//[^0-9]/}"
echo "${ivalue}"

Decimal numbers in Bash

  • Bash can only calculate with integers!

  • There is no way to calculate on decimal numbers

  • This just return 2 echo “$(( 5 / 2 ))”

  • However we could use the modulo operator (%) to get the reminder in the integer division: echo “$(( 5 % 2 ))” return 1 Because: 5/2=2 (integer division) and the reminder is 5-2*2=1

  • But you can use bc program to calculate decimal numbers:

[tomik@localhost Almaroot_shared]$ echo "5 + 5" | bc
10
[tomik@localhost Almaroot_shared]$ clear
[tomik@localhost Almaroot_shared]$ echo "5 + 5" | bc
10
[tomik@localhost Almaroot_shared]$ echo "5.4 + 5.1" | bc
10.5
[tomik@localhost Almaroot_shared]$ echo "10 / 8" | bc
1
[tomik@localhost Almaroot_shared]$ echo 'scale=4; 10 / 8' | bc
1.2500
[tomik@localhost Almaroot_shared]$ number=25
[tomik@localhost Almaroot_shared]$ echo "scale=4; ${number}/ 8" | bc
3.1250
[tomik@localhost Almaroot_shared]$

AWK

[tomik@localhost ~]$ awk '{print $1}' population.txt
Budapest;
New
London;
[tomik@localhost ~]$ awk -F ';' '{print $2}' population.txt
 1800000
 8500000
 900000
[tomik@localhost ~]$ awk -F ';' '{sum+=$2} END {print sum}' population.txt
11200000

curl

  • -v / –verbose:
    • Display detailed information about the request and response, useful for debugging
    • o / –output:
      • Saves the response to a file instead of displaying it in the console
  • -L / –location:
    • Follow HTTP redirects (3xx0 to retrieve the final resource
  • -s / –silent:
    • Silent mode: Do not display progress meter / error data

.json

  • Program jq will help you to read file perfectly parcing structured json way:

[tomik@localhost ~]$ cat json.json
{
        "firstname": "Max",
        "lastname": "Smith",
        "age": 35,
        "courses": [
                {"title": "Bash course", "url": "/bash-course"},
                {"title": "Linux couse", "url": "/linux-course"}
        ]
}

[tomik@localhost ~]$ cat json.json | jq
{
  "firstname": "Max",
  "lastname": "Smith",
  "age": 35,
  "courses": [
    {
      "title": "Bash course",
      "url": "/bash-course"
    },
    {
      "title": "Linux couse",
      "url": "/linux-course"
    }
  ]
}

[tomik@localhost ~]$ cat json.json | jq '[.firstname, .lastname]'
[
  "Max",
  "Smith"
]

[tomik@localhost ~]$ cat json.json | jq '[.firstname, .lastname, .courses[].title]'
[
  "Max",
  "Smith",
  "Bash course",
  "Linux couse"
]
[tomik@localhost ~]$ cat json.json | jq '[.firstname, .lastname, .courses[0].title]'
[
  "Max",
  "Smith",
  "Bash course"
]
[tomik@localhost ~]$ cat json.json | jq '.courses'
[
  {
    "title": "Bash course",
    "url": "/bash-course"
  },
  {
    "title": "Linux couse",
    "url": "/linux-course"
  }
]
[tomik@localhost ~]$ cat json.json | jq '.courses' | jq '.[].url'
"/bash-course"
"/linux-course"
[tomik@localhost ~]$

to get number of items in array you can use command:

[tomik@localhost ~]$ cat json.json | jq '.courses'
[
  {
    "title": "Bash course",
    "url": "/bash-course"
  },
  {
    "title": "Linux couse",
    "url": "/linux-course"
  }
]
[tomik@localhost ~]$ cat json.json | jq '.courses | length'
2

weather script

#!/usr/bin/env bash

api_url='https://downloads.codingcoursestv.eu/055%20-%20bash/api/api.php'

read -rp 'Which city you are interesting for? ' city
response="$(curl "${api_url}" -s -G --data-urlencode 'city='"${city}")"

temperature="$(echo "${response}" | jq '.temperature')"


echo "In the ${city}, the current temperature is ${temperature}°C"

# formula: fahrenheit = celsius * (9 / 5) + 32
fahrenheit="$(echo "scale=1; ${temperature} * (9 / 5 ) + 32" | bc)"

echo "And this is ${fahrenheit}°F"

Chaining commands with && or ;

  • We can combine multiple commands in the following way:
    • [command 1] && [command 2]
    • [command1]; [command2]
    • This works on CLI, but also in Bash scripts
  • But there are differeces
    • ;
      • Always execute the next command
    • &&
      • Only executes the next command, if the first commands exit code is 0

Chaining commands with OR: ||

  • [command1] || [command2]
  • in this case, a logical OR is being applied
  • **This means:
    • If the first command executes successfully (exit code 0):
      • The second command will not be executed
    • If the first command executes unsuccessfully (exit code not equal to 0)
      • The second command will be executed

Testing values

  • If we want to test values, we can use [[ … ]] for this:
  • This will then set the exit code:
    • 0: If the condition has been fulfilled
    • 1: If the condition has not been fulfilled
[tomik@localhost ~]$ [[ "hello" == "hello" ]]
[tomik@localhost ~]$ echo $?
0
[tomik@localhost ~]$ [[ "hello" == "helo" ]]
[tomik@localhost ~]$ echo $?
1
[tomik@localhost ~]$ [[ "hello" != "helo" ]]
[tomik@localhost ~]$ echo $?
0
[tomik@localhost ~]$

[tomik@localhost ~]$ name="Jan"
[tomik@localhost ~]$ [[ "${name}" == "Max" ]]
[tomik@localhost ~]$ echo $?
1
[tomik@localhost ~]$ [[ "${name}" == "Jan" ]]
[tomik@localhost ~]$ echo $?
0
[tomik@localhost ~]$

If

Basic structure (if - then - fi)

if [[ condition ]]; then
    # Code to run if the condition is TRUE
fi

Full Structure (if - elif -else - fi)

if [[ condition1 ]]; then
    echo "First condition is true"
elif [[ condition2 ]]; then
    echo "Second condition is true"
else
    echo "None of the conditions are true"
fi

Testing Strings in Bash

In Bash, the modern and safest way to test or compare strings is using double square brackets [[ ... ]].


1. Basic Comparison (Equality and Inequality)

Operator Meaning Example
== (or =) Equals [[ "$city" == "London" ]]
!= Does not equal [[ "$city" != "Paris" ]]

Code Example:

if [[ "${city}" == "New York" ]]; then
    echo "You selected the Big Apple!"
fi

2. Checking for Empty or Non-Empty Strings

These operators are extremely useful for checking if a user actually typed something or left the input blank.

  • -z (zero length) – True if the string is empty.
  • -n (nonzero length) – True if the string is NOT empty (contains text).

Code Example:

# Check if the user left the input blank
if [[ -z "${city}" ]]; then
    echo "Error: City name cannot be empty!"
fi

# Check if the variable actually contains text
if [[ -n "${city}" ]]; then
    echo "The chosen city is: $city"
fi

3. Advanced String Matching (Wildcards and RegEx)

Using [[ ... ]] allows you to perform powerful pattern matching.

Wildcard Matching (Pattern Matching)

If you do not put quotes around the pattern on the right side, you can use * as a wildcard.

# Checks if the city starts with "San" (e.g., San Francisco, San Diego)
if [[ "${city}" == San* ]]; then
    echo "The city name starts with 'San'."
fi

Pattern Matching in Bash

Pattern matching allows you to check if a string matches a specific format or contains certain characters without using heavy tools like sed or grep. In Bash, this is done inside double square brackets [[ ... ]].

There are two types of pattern matching in Bash:

  1. Globbing (Standard Wildcards) using == or !=
  2. Regular Expressions (RegEx) using the =~ operator

1. Standard Pattern Matching (Globbing)

This method uses simple wildcards like * (any characters) and ? (any single character).

The Golden Rule of Glob Matching:

The pattern on the right side must NOT be enclosed in quotes. If you put it in quotes, Bash will treat it as a literal string instead of a wildcard pattern.

Pattern Meaning Example
San* Starts with “San” [[ "$city" == San* ]] (Matches San Francisco, San Jose)
*txt Ends with “txt” [[ "$file" == *txt ]] (Matches notes.txt, data.txt)
?? Exactly two characters [[ "$country_code" == ?? ]] (Matches US, CZ, UK)
[A-Z]* Starts with an uppercase letter [[ "$name" == [A-Z]* ]]

Code Example:

city="San Francisco"

if [[ "${city}" == San* ]]; then
    echo "The city name starts with 'San'."
fi

2. Advanced Pattern Matching (Regular Expressions)

If you need complex validation (like checking for email formats, IP addresses, or specific character types), use the Regular Expression operator (=~).

Common RegEx Anchors and Classes:

  • ^ – Matches the start of the string.
  • $ – Matches the end of the string.
  • + – Matches one or more of the preceding element.
  • [0-9] or [[:digit:]] – Matches any number.
  • [A-Za-z] or [[:alpha:]] – Matches any letter.

Code Examples:

A) Check if input is a pure number:

read -p "Enter your age: " age

# Checks if the string contains ONLY digits from start to finish
if [[ "${age}" =~ ^[0-9]+$ ]]; then
    echo "Valid number!"
else
    echo "Error: Please enter numbers only."
fi

B) Check if input is a valid .sh filename:

filename="backup.sh"

# Escaping the dot (\.) means we look for a literal dot, not "any character"
if [[ "${filename}" =~ \.sh$ ]]; then
    echo "This is a shell script file."
fi

Summary of the Difference

  • Use == with unquoted wildcards (like * or ?) for quick, simple checks.
  • Use =~ for powerful regular expressions when you need strict text validation.

Checking Files and Directories in Bash

In Bash, you can easily test the properties of files and folders (e.g., if a file exists, if it is readable, or if it is a directory) inside double square brackets [[ ... ]].


1. Most Common File Checks

Operator Meaning Example
-f True if the file exists and is a regular file (not a folder). [[ -f "config.txt" ]]
-d True if the directory (folder) exists. [[ -d "/var/log" ]]
-e True if anything exists (file, folder, symlink, etc.) with that name. [[ -e "$path" ]]
-s True if the file exists and is not empty (size is greater than 0 bytes). [[ -s "data.log" ]]

Code Example:

file_path="backup.sh"

if [[ -f "$file_path" ]]; then
    echo "The file $file_path exists and is a regular file."
else
    echo "Error: File not found!"
fi

2. Checking Permissions

You can check what your script is actually allowed to do with a specific file.

  • -r – True if the file is readable by the user running the script.
  • -w – True if the file is writable by the user running the script.
  • -x – True if the file is executable (can be run as a script/program).

Code Example:

script_file="run.sh"

# Check if we can execute the file
if [[ -x "$script_file" ]]; then
    echo "Running the script..."
    ./"$script_file"
else
    echo "Error: Script is not executable! Run 'chmod +x $script_file' first."
fi

3. Comparing Two Files

Bash also allows you to compare two files against each other based on their modification timestamps.

  • -nt (newer than) – True if file1 is newer than file2.
  • -ot (older than) – True if file1 is older than file2.

Code Example:

# Useful for backup scripts to check if source changed
if [[ "source.txt" -nt "backup.txt" ]]; then
    echo "Source file has been modified. Updating backup..."
    cp source.txt backup.txt
fi

Important Best Practice

Always wrap file paths in double quotes (e.g., "$file_path"). If a folder or file name contains a space (like My Documents), missing quotes will cause the script to throw a syntax error or behave unpredictably.

Numeric Tests in Bash

Bash handles numbers differently than strings. To compare numbers, you can either use alphanumeric operators inside [[ ... ]] or standard mathematical symbols inside (( ... )).


1. Using Standard Brackets [[ ... ]]

Inside [[ ... ]], you cannot use < or > for numbers (because Bash will treat them as string comparison or redirection). Instead, you must use these shorthand operators:

Operator Meaning Example
-eq Equal to [[ $age -eq 18 ]]
-ne Not equal to [[ $age -ne 18 ]]
-gt Greater than [[ $age -gt 18 ]]
-ge Greater than or equal to [[ $age -ge 18 ]]
-lt Less than [[ $age -lt 18 ]]
-le Less than or equal to [[ $age -le 18 ]]

Code Example:

attempts=3

if [[ "${attempts}" -le 5 ]]; then
    echo "You have remaining attempts."
fi

2. The Modern Way: Arithmetic Evaluation (( ... ))

If you prefer standard math symbols (<, >, ==, !=), you can use double parentheses (( ... )). This environment is built specifically for numbers and math.

Advantages of (( ... )):

  1. You can use standard symbols: >, <, >=, <=, ==, !=.
  2. You do not need to use the $ sign for variables inside the parentheses.
  3. Spaces are not strictly parsed like in [[ ... ]].
Symbol Meaning Example
== Equal to (( score == 100 ))
!= Not equal to (( score != 0 ))
> Greater than (( score > 50 ))
>= Greater than or equal to (( score >= 50 ))
< Less than (( score < 10 ))
<= Less than or equal to (( score <= 10 ))

Code Example:

score=45
bonus=10

# You can even do math operations inside the condition!
if (( score + bonus >= 50 )); then
    echo "Level passed!"
fi

Critical Reminder: Integers Only!

Bash only supports integers (whole numbers like 1, 50, -10). It cannot natively handle floating-point decimal numbers (like 3.14 or 0.5).

If you try to run [[ 3.14 -gt 2 ]], Bash will crash with a integer expression expected error. If you need to compare decimals, you must pipe the numbers into an external tool like bc:

if (( \$(echo "3.14 > 2.0" | bc) )); then
    echo "This works for decimals!"
fi

Complex Conditions in Bash

When writing robust scripts, you often need to check multiple things at once—for example, verifying if a user is an admin AND if a specific file exists, or if a variable equals “yes” OR “y”.

In Bash, you can combine conditions using Logical Operators inside [[ ... ]] or (( ... )).


1. Logical Operators Quick Reference

Operator Type Meaning [[ ... ]] Syntax (( ... )) Syntax
AND Conjunction BOTH must be true && &&
OR Disjunction At least ONE must be true || ||
NOT Negation Inverts the result ! !

2. Using Complex Conditions in [[ ... ]]

You can chain text, file, and numeric checks together seamlessly.

A) Logical AND (&&)

Both conditions must evaluate to true for the then block to run.

role="admin"
config_file="settings.conf"

# Check if the user is an admin AND the file actually exists
if [[ "$role" == "admin" && -f "$config_file" ]]; then
    echo "Access granted. Loading configuration..."
fi

B) Logical OR (||)

The code inside runs if either the first condition or the second condition (or both) is true.

read -p "Do you want to continue? (y/n): " answer

# Convert answer to lowercase using parameter expansion, then check if it is 'y' OR 'yes'
if [[ "${answer,,}" == "y" || "${answer,,}" == "yes" ]]; then
    echo "Proceeding..."
fi

C) Grouping with Parentheses ( )

If you mix && and || together, you must use parentheses to control the order of evaluation (just like in math). Note: Inside [[ ... ]], parentheses must be separated by spaces.

# True if user is admin OR (user is manager AND has a valid token)
if [[ "$role" == "admin" || ( "$role" == "manager" && -n "$token" ) ]]; then
    echo "Access approved."
fi

3. Complex Conditions in Arithmetic (( ... ))

When dealing strictly with numbers, (( ... )) is much cleaner for complex math ranges.

Example: Checking if a number is within a range (e.g., 10 to 50 inclusive)

score=35

if (( score >= 10 && score <= 50 )); then
    echo "The score is between 10 and 50."
fi

4. Reversing Logic with NOT (!)

The exclamation mark ! flips true to false and vice versa. It is placed right before the condition you want to invert.

target_dir="/var/www/html"

# If the directory does NOT exist, create it
if [[ ! -d "$target_dir" ]]; then
    echo "Directory missing. Creating it now..."
    mkdir -p "$target_dir"
fi

Pro Tip: Inline Commands (Short-circuiting)

You don’t always need a full if/then/fi structure for simple checks. You can use && and || directly on the command line or in scripts as one-liners:

  • Execute if successful (&&): [[ -f "file.txt" ]] && echo "File is here!"
  • Execute if failed (||): [[ -d "logs" ]] || mkdir "logs"

The case Statement in Bash

The case statement is Bash’s version of the switch statement found in other programming languages. It allows you to match a variable against multiple patterns cleanly and efficiently, without chaining endless if - elif - else blocks.


1. Basic Syntax Structure

A case block starts with the word case and ends with esac (case spelled backwards). Each pattern block must end with a double semicolon ;;.

case "$variable" in
    pattern1)
        # Code to run if variable matches pattern1
        ;;
    pattern2)
        # Code to run if variable matches pattern2
        ;;
    *)
        # Default code (catch-all) if no other pattern matches
        ;;
esac

2. Real-World Examples

A) Processing User Input (e.g., Your City Script)

You can use case to quickly check what city a user entered and trigger different logic for each.

#!/usr/bin/env bash

read -r -p "Which city are you interested in? " city

case "${city,,}" in
    "london" | "manchester")
        echo "You selected a city in the United Kingdom."
        currency="GBP"
        ;;
    "prague" | "brno" | "ostrava")
        echo "You selected a city in the Czech Republic."
        currency="CZK"
        ;;
    "new york" | "los angeles")
        echo "You selected a city in the United States."
        currency="USD"
        ;;
    *)
        echo "Unknown city. Defaulting to global settings."
        currency="EUR"
        ;;
esac

echo "Target currency set to: $currency"

Note: The pipe symbol | acts as a logical OR, allowing you to group multiple patterns together (e.g., "london" | "manchester").


3. Pattern Matching (Globbing) inside case

Just like [[ ... ]], the case statement natively supports standard Bash wildcards (*, ?, [ ]). You do not need to worry about quotes removing the magic of wildcards here.

filename="backup.tar.gz"

case "$filename" in
    *.txt)
        echo "This is a plain text file."
        ;;
    *.tar.gz | *.zip)
        echo "This is a compressed archive."
        ;;
    image.[0-9]*)
        echo "This is a numbered image file."
        ;;
    *)
        echo "Unknown file type."
        ;;
esac

4. Other examples wit ;&

case "$weather" in
    "Sunny")
        echo -n "It's a beautiful day for outdoor activities." ;&
    "Cloudy")
        echo -n "The weather is moderate and pleasant." ;&
    "Rainy")
        echo -n "Don't forget your umbrella today!" ;;
esac
#!/usr/bin/env bash

read -p 'Please enter your OS: ' os

case "${os}" in
        Mac)
                echo 'On a Mac, you need to manually install this program'
                ;;
        Debian|Ubuntu)
                echo 'On a Ubuntu, you need to use apt to install progream'
                ;;
        RHEL)
                ;&
        CentOS)
                echo 'You need to use dnf to install program'
                ;;
        L*)
                echo 'We do not know what to do. At least your os starts with an L'
                ;;
        *)
                echo 'This is uknown system to us'
esac

Key Rules to Remember

  1. The Double Semicolon ;;: Every single case block must be terminated with ;;. Forgetting this will cause a syntax error.
  2. The Default Catch-All *): Always include *) at the very end. It acts exactly like the else block in an if statement, catching anything that didn’t match the specific options above it.
  3. Case Sensitivity: By default, Bash is case-sensitive. It is a best practice to normalize your strings using parameter expansion (like ${variable,,} for lowercase) before passing them into the case statement.

The while Loop in Bash

A while loop repeatedly executes a block of code as long as the specified condition remains TRUE. The moment the condition becomes FALSE, the loop stops.


1. Basic Syntax Structure

A while block starts with the word while, followed by a condition. The code inside is wrapped between do and done.

while [[ condition ]]; do
    # Code to run repeatedly while condition is TRUE
done

2. Real-World Examples

A) Counting Loop (Using Arithmetic (( ... )))

Perfect for running a block of code a specific number of times.

#!/usr/bin/env bash

counter=1

# Run as long as counter is less than or equal to 5
while (( counter <= 5 )); do
    echo "Loop iteration: $counter"
    # Increment the counter by 1
    (( counter++ ))
done

B) Reading a File Line by Line (The while read Loop)

This is one of the most powerful and efficient ways to process text files in Bash. It uses the read command combined with a file redirected at the end.

#!/usr/bin/env bash

file_path="servers.txt"

# Ensure the file exists before reading
if [[ -f "$file_path" ]]; then
    # Read line by line, preserving whitespace with -r
    while read -r line; do
        echo "Pinging server: $line"
        ping -c 1 "$line"
    done < "$file_path"
else
    echo "Error: $file_path not found."
fi

C) Infinite Loops (Daemon / Monitoring)

Sometimes you want a script to run forever (e.g., checking system status every X seconds). You can pass true as the condition.

#!/usr/bin/env bash

# This loop runs forever until you press Ctrl+C
while true; do
    echo "Checking system memory status..."
    free -h
    echo "---------------------------------"
    # Wait for 10 seconds before the next check
    sleep 10
done

3. Controlling the Loop: break and continue

You can alter the flow of a loop from the inside using two keywords:

  • break: Instantly terminates the loop and jumps to the code after done.
  • continue: Skips the rest of the current iteration and jumps straight to the next condition check.
#!/usr/bin/env bash

while true; do
    read -r -p "Enter a number (or type 'exit'): " input
    
    if [[ "${input,,}" == "exit" ]]; then
        echo "Goodbye!"
        break # Exit the infinite loop
    fi
    
    if [[ ! "$input" =~ ^[0-9]+$ ]]; then
        echo "That's not a number! Try again."
        continue # Skip the print below and restart the loop
    fi
    
    echo "Double of your number is: $(( input * 2 ))"
done

Key Rules to Remember

  1. Avoid Infinite Loops by Accident: When using a counter or a state variable, always remember to update it inside the loop (e.g., (( counter++ )}). If you forget, the condition will stay TRUE forever, freezing your terminal.
  2. The done < file Syntax: When reading files, the input redirection symbol < must be placed right after the done keyword, followed by the file path.

Advanced break and continue with Levels

By default, break and continue only affect the innermost loop they are currently running in. However, Bash allows you to pass a numeric argument to these keywords to control outer (higher-level) loops.

The syntax is: break N or continue N, where N is the loop level (1 is the current loop, 2 is the outer loop, 3 is the loop above that, etc.).


1. Continue and Break on single Loop

!#/usr/bin/env bash

counter=0
while (( counter < 10 )); do
        counter=$(( counter + 1 ))
        if (( counter == 5 )); then
                continue # come again on the start of the while loop
        fi
        if (( counter == 8 )); then
                break # immediatelly jump of the loop
        fi
        echo "Hello world: ${counter}"

done

[tomik@localhost Almaroot_shared]$ ./continue-and-break
./continue-and-break: line 1: !#/usr/bin/env: No such file or directory
Hello world: 1
Hello world: 2
Hello world: 3
Hello world: 4
Hello world: 6
Hello world: 7

2. Breaking Out of Multiple Loops (break 2)

Imagine you are searching through multiple folders (outer loop) for a specific file, and looking at files inside them (inner loop). Once you find it, you want to stop the entire script, not just the inner search.

#!/usr/bin/env bash

# Loop Level 2 (Outer loop)
while true; do
    echo "Outer loop started..."
    
    # Loop Level 1 (Inner loop)
    while true; do
        echo "  Inner loop running..."
        read -r -p "  Stop everything? (y/n): " ans
        
        if [[ "$ans" == "y" ]]; then
            echo "Breaking out of BOTH loops!"
            break 2  # Closes level 1 AND level 2 loops instantly
        fi
        
        break # A normal break would only exit the inner loop
    done
    
    echo "This line will be skipped if you use 'break 2'."
done

echo "Script continued here."

3. Skipping to the Next Outer Iteration (continue 2)

This is useful when an inner loop encounters a condition that makes the rest of the outer loop irrelevant for that iteration.

#!/usr/bin/env bash

outer_counter=1

# Loop Level 2 (Outer loop)
while (( outer_counter <= 3 )); do
    echo "Checking Server Group $outer_counter..."
    inner_counter=1
    
    # Loop Level 1 (Inner loop)
    while (( inner_counter <= 3 )); do
        echo "  Testing Node $inner_counter..."
        
        if (( outer_counter == 2 && inner_counter == 2 )); then
            echo "Critical error on Group 2! Skipping the whole group."
            (( outer_counter++ ))
            continue 2 # Jumps straight to the next iteration of the outer loop
        fi
        
        (( inner_counter++ ))
    done
    
    echo "Group $outer_counter checks completed successfully."
    (( outer_counter++ ))
done

Quick Summary of Levels

  • break or break 1 = Stop the current loop.
  • break 2 = Stop the current loop AND the loop wrapping it.
  • continue or continue 1 = Jump to the next turn of the current loop.
  • continue 2 = Jump to the next turn of the outer loop.

Reading a File Line by Line

#!/usr/bin/env bash

while IFS= read -r my_line; do
        echo "${my_line}"
done < students.txt
  • IFS

    • defines how whitespace is treated in Bash
    • for the read only, we set it to empty - this preservers whitespace at the behinning / end of each line
    • you can temorarlily set IFS to different delimiter with command:
      • IFS=‘value’ env
  • read -r my_line

    • reads a single line of the file and provides the contents in variable called “my_line”

Bulk images downloader example


url='https://downloads.codingcoursestv.eu/055%20-%20bash/while/images/image-%%.jpg'

notfound=0
counter=1
while true; do
        echo "Downloading:${url/\%\%/$counter}"
        if curl --fail "${url/\%\%/$counter}" -o "image-${counter}.jpg"; then
           echo '...'
           (( counter = counter + 1 ))
           notfound=0
        else
           (( notfound = notfound + 1 ))
           if (( notfound >= 20 )); then
           break
           fi
        fi
        (( counter = counter + 1 ))
done

The for Loop in Bash

Unlike a while loop which runs based on a condition, a for loop is used to iterate over a fixed list of items (such as words, variables, filenames, or arrays) or to run a specific number of times using a counter.


1. Style A: Iterating Over a List (The Bash Way)

This is the most common use case in shell scripting. The loop takes a list of items and processes them one by one.

Syntax:

for item in item1 item2 item3; do
    # Code to run for each item
done

Real-World Examples:

A) Iterating Over a List of Strings

#!/usr/bin/env bash

# Loop through a predefined list of services
for service in nginx mysql docker ssh; do
    echo "Restarting service: $service"
    # systemctl restart "$service"
done

B) Iterating Over Files (Using Globbing/Wildcards)

This is incredibly powerful for batch file operations (like renaming or moving files). Do not put quotes around the wildcard here.

#!/usr/bin/env bash

# Loop through all text files in the current directory
for file in *.txt; do
    # Check if files actually exist to avoid running on literal '*.txt'
    [[ -e "$file" ]] || continue
    
    echo "Archiving file: $file"
    mv "$file" "${file}.bak"
done

C) Using Braces for Ranges {start..end}

You can generate a sequential list of numbers or letters automatically.

#!/usr/bin/env bash

# Generates numbers from 1 to 5
for i in {1..5}; do
    echo "User id: $i"
done

# Generates numbers with a step (start..end..step)
# This will print 0, 2, 4, 6, 8, 10
for num in {0..10..2}; do
    echo "Even number: $num"
done

2. Brace expansion

  • We can use the it to generate additional elements
  • Example
for file in names{.csv,.txt,.docx}; do
	echo "${file}
done
  • when using brace expansion, we are only generating elements / strings
  • it does not matter if the files exist or not
  • brace expansion never checks for file existence

3. Filename expansion

  • in for loop we can also use the filename expansion
    • this one will check our filesystem and only provide the elements if the corresponding files exist Example:
for file in *.txt; do
	if [[ -f "${file}" ]]; then
		echo "${file}"
	fi
done

for file in *'.'{txt,jpg}; do
	if [[ -f "${file}" ]]; then
		echo "${file}"
	fi
done

4. for loop + command substitution

  • can be used in combination with command substitution
    • quite often, we can call the seq program
      • seq $start $end
      • this generates the range for us Example:
start=1; end=5
for i in $(seq $start $end); do
	echo "$i"
done

5. Style B: The Three-Expression Loop (C-Style)

If you have a background in C, C++, Java, or JavaScript, this syntax will feel very familiar. It uses double parentheses (( ... )) and is purely math-driven.

Syntax:

for (( initialization; condition; increment/decrement )); do
    # Code to run
done

Real-World Example:

#!/usr/bin/env bash

# Count from 1 to 5. Note that we don't use '$' for variables inside (( ))
for (( i=1; i<=5; i++ )); do
    echo "Count is: $i"
done

6. Style C: Iterating Over Command Arguments (The "$@" Trick)

If you don’t provide the in list part at all, a for loop defaults to iterating over the arguments passed to the script by the user (stored in "$@").

#!/usr/bin/env bash

echo "Processing all files provided as script arguments..."

# 'for file' is a shorthand for 'for file in "$@"'
for file; do
    echo "File passed: $file"
done

If you run this script as ./script.sh a.txt b.txt c.txt, it will loop through those three files.


Critical Best Practice: Always Quote the Variable Inside the Loop

When you use the loop variable inside the do block, always wrap it in double quotes. If one of your filenames contains spaces (e.g., My Document.txt), missing quotes will cause the script to treat it as two separate files (My and Document.txt) and crash.

for file in *.txt; do
    # CORRECT:
    cat "$file"
    
    # WRONG (will break on spaces):
    # cat $file
done

Extracting Filename and Extension in Bash

When writing automation or backup scripts, you frequently need to parse a full file path (like /home/user/data/report.tar.gz) to extract just the folder path, the filename, or the file extension.

Here are the best and fastest ways to achieve this.


1. The Fast Way: Parameter Expansion (Brace Expansion)

Using string manipulation inside ${...} is the most efficient method because it happens entirely in memory without spawning external processes.

We will use this example path:

filepath="/home/user/documents/backup.tar.gz"

A) Extract the Filename with Extension

To strip away the directory path (everything from the start up to the last slash /), use ##*/:

filename="${filepath##*/}"
echo "$filename"
# Output: backup.tar.gz

How it works: ##*/ means “Delete the longest match from the front that ends with a slash”.

B) Extract Only the Extension

To get the extension (everything after the last dot .), use ##*.:

ext="${filepath##*.}"
echo "$ext"
# Output: gz

C) Extract the Full Extension (For double extensions like .tar.gz)

To get the full extension starting from the first dot instead of the last, use a single # instead of two (shortest match instead of longest):

full_ext="${filepath#*.}"
echo "$full_ext"
# Output: tar.gz

D) Extract the Filename WITHOUT Extension

To get just the base name, strip the extension from the back of the filename using %/* or %.*:

# First get the filename, then strip from the last dot to the end
filename="${filepath##*/}"
base_name="${filename%.*}"
echo "$base_name"
# Output: backup.tar

2. The Readable Way: Using basename and dirname

If you find the brace syntax hard to remember, Linux provides dedicated command-line utilities that are much easier to read.

filepath="/home/user/documents/backup.tar.gz"

A) Get the Directory Path (dirname)

dir_path=$(dirname "$filepath")
echo "$dir_path"
# Output: /home/user/documents

B) Get the Filename (basename)

filename=$(basename "$filepath")
echo "$filename"
# Output: backup.tar.gz

C) Get the Filename without a specific extension

basename allows you to pass the extension you want to strip as a second argument:

# Strip '.tar.gz' from the end
base_name=$(basename "$filepath" ".tar.gz")
echo "$base_name"
# Output: backup

Practical Cheatsheet / Summary

What you want to get Parameter Expansion Syntax Command Tool Alternative Result Example
Directory ${filepath%/*} dirname "$filepath" /home/user/documents
Full Filename ${filepath##*/} basename "$filepath" backup.tar.gz
Final Extension ${filepath##*.} N/A gz
Base (no ext) ${filename%.*} basename "$filepath" ".gz" backup.tar

Important Best Practice

Always wrap your variables in double quotes when passing them to basename, dirname, or when echoing them. If a folder or a filename contains a space (e.g., /home/My Documents/photo 1.jpg), missing quotes will break your script immediately.

The select Loop in Bash

The select construct is a built-in Bash feature designed specifically for creating interactive text menus. It automatically generates a numbered list of choices, prompts the user for input, and loops until you explicitly break out of it.

It is heavily used for building simple Command Line Interfaces (CLIs).


1. Basic Syntax Structure

The select syntax looks very similar to a for loop, but it behaves like an infinite loop that waits for user input on each turn.

select item in option1 option2 option3; do
    # Code to run based on the selected item
    # You MUST use 'break' to exit the menu
done

2. Real-World Example: Interactive City Menu

Let’s combine select with the case statement we learned earlier to build a clean interactive menu for your city script.

#!/usr/bin/env bash

# 1. Customize the prompt message (default is '#?')
PS3="Please enter the number of your target city: "

echo "--- Welcome to the Server Provisioning Script ---"

# 2. Define the menu options
select city in "London" "Prague" "New York" "Exit Script"; do

    # 3. Process the choice
    case "$city" in
        "London")
            echo "-> Deploying servers to United Kingdom..."
            ;;
        "Prague")
            echo "-> Deploying servers to Czechia..."
            ;;
        "New York")
            echo "-> Deploying servers to United States..."
            ;;
        "Exit Script")
            echo "Exiting. Goodbye!"
            break # Closes the select loop and exits
            ;;
        *)
            echo "Invalid choice '$REPLY'. Please select a number from 1 to 4."
            ;;
    esac
    
    echo "------------------------------------------------"
done

3. How it Works Under the Hood ($item vs $REPLY)

When a user selects an option (for example, they type 2 and hit Enter):

  • $city (the loop variable) gets assigned the literal text of that option ("Prague").
  • $REPLY (a built-in Bash variable) stores the exact raw text/number typed by the user (2).

This allows you to easily catch invalid inputs in the fallback *) block by checking if $city is empty or if $REPLY is out of bounds.


4. Using Dynamically Generated Lists

You don’t have to hardcode the options. You can pass wildcards (globbing) or arrays into the select loop to create dynamic file-picker menus.

Example: Interactive Log File Picker

#!/usr/bin/env bash

PS3="Select a log file to view (or choose Exit): "

# Generate a menu from all .log files in the current folder, plus an exit option
select logfile in *.log "Exit"; do
    if [[ "$logfile" == "Exit" ]]; then
        break
    elif [[ -n "$logfile" ]]; then
        echo "Displaying last 10 lines of: $logfile"
        tail -n 10 "$logfile"
        break
    else
        echo "Invalid selection."
    fi
done

Golden Rules of the select Loop

  1. The PS3 Variable: Always set the PS3 variable right before the loop. This changes the generic #? prompt to a user-friendly message.
  2. Always Use break: The select loop is an infinite loop by default. Even after a user chooses a valid option and the code runs, the menu will reappear. You must use break to exit the menu once the action is done.

Handling Script Arguments in Bash

When you run a Bash script, you can pass data into it by appending words, flags, or file paths after the script name. Bash automatically stores these inputs into special variables called positional parameters.

For example, if you run: ./deploy.sh prague production web-76h


1. Special Argument Variables Reference

Variable Meaning What it returns in the example above
$0 The name of the script itself ./deploy.sh
$1 The first argument prague
$2 The second argument production
$3 The third argument web-76h
${10} The 10th argument (braces {} are mandatory from 10 onwards) N/A
$# The total number of arguments passed 3
$@ All arguments as a list of separate words prague production web-76h
$* All arguments as a single joint string "prague production web-76h"

2. Real-World Code Examples

A) Basic Validation (Checking Argument Counts)

It is a best practice to check if the user actually passed the required arguments before running the rest of the script.

#!/usr/bin/env bash

# Check if the user passed fewer than 2 arguments using numeric checks (( ... ))
if (( $# < 2 )); then
    echo "Error: Missing arguments!"
    echo "Usage: $0 <city_name> <environment>"
    exit 1 # Terminate script with an error code
fi

# Assign arguments to readable variables
TARGET_CITY="$1"
ENV_TYPE="$2"

echo "Deploying to $TARGET_CITY on the $ENV_TYPE environment..."

B) Looping Through All Arguments (The "$@" Trick)

If you want to perform the same action on an unknown number of files or arguments passed to your script, use "$@".

#!/usr/bin/env bash

echo "Total files to process: $#"
echo "------------------------"

# Loop through each argument one by one
for file in "$@"; do
    if [[ -f "$file" ]]; then
        echo "Processing valid file: $file"
    else
        echo "Skipping: $file (Not a valid file)"
    fi
done

3. Shifting Arguments (shift)

The shift command is a built-in tool that “consumes” the first argument ($1), drops it, and moves every other argument down by one position ($2 becomes $1, $3 becomes $2, etc.).

This is extremely useful when your script expects a specific main setting first, followed by a list of files.

#!/usr/bin/env bash

# We expect the first argument to be the action type
ACTION="$1"
shift # Drops the action name, shifting the rest of the arguments left

echo "Action to perform: $ACTION"
echo "Remaining targets to process: $@"

for target in "$@"; do
    echo "-> Applying $ACTION to $target"
done

If you run this as ./script.sh DELETE file1.txt file2.txt, the shift command separates DELETE from the files.


The Golden Rule: Always Double Quote "$@"

Never use $* or unquoted $@ to loop through arguments.

If a user passes an argument with a space (e.g., ./script.sh "My Document.txt"):

  • "$@" (with quotes) preserves it as one single argument: "My Document.txt".
  • $@ or $* (without quotes) will split it into two separate arguments: "My" and "Document.txt", which will break your file checks.

Advanced Argument Parsing: getopts and while + shift

When your scripts grow, passing arguments purely by their position ($1, $2) becomes difficult to manage. Users expect standard CLI flags like -c <city> or --help.

To handle this, you can use either the built-in getopts tool (for short flags) or a manual while + shift loop (for full flexibility).


1. The Standard Way: Using Built-in getopts

getopts is a native Bash utility designed to parse short options (single letters with a single dash, like -c or -v). It automatically handles grouped flags (like -vh) and handles arguments seamlessly.

How it works:

  • You define a string of allowed letters (e.g., c:e:h).
  • A colon : after a letter means that flag requires an argument (e.g., -c Prague).
  • $OPTARG stores the value passed to the flag.
  • $OPTIND keeps track of the argument index (used to clean up at the end).

Real-World Example:

#!/usr/bin/env bash

# Default values
CITY=""
ENV="production"

# The leading colon ':' turns on silent error mode (we handle errors ourselves)
while getopts ":c:e:h" opt; do
    case "$opt" in
        c)
            CITY="$OPTARG"
            ;;
        e)
            ENV="$OPTARG"
            ;;
        h)
            echo "Usage: $0 [-c city] [-e environment]"
            echo "  -c : Target city name (Required)"
            echo "  -e : Environment type (Default: production)"
            echo "  -h : Display this help message"
            exit 0
            ;;
        \?)
            echo "Invalid option: -$OPTARG" >&2
            exit 1
            ;;
        :)
            echo "Option -$OPTARG requires an argument." >&2
            exit 1
            ;;
    esac
done

# Clean up processed flags so $1, $2 now refer to any remaining positional arguments
shift $((OPTIND - 1))

# Validation
if [[ -z "$CITY" ]]; then
    echo "Error: City (-c) is required!" >&2
    exit 1
fi

echo "Deploying to city: $CITY ($ENV)"
echo "Remaining unprocessed arguments (e.g. files): $@"

2. The Custom Way: Manual while + shift (Supports Long Flags)

getopts natively does not support long options (like --city or --env). If you want to support both short and long options, the best approach is to create a manual while loop that checks $1 and shifts through the arguments one by one.

How it works:

  • The loop runs as long as $# (number of arguments) is greater than 0.
  • Inside case, we check the flag name.
  • If a flag takes a value, we grab it from $2, and then run shift 2 to consume both the flag and its value.

Real-World Example:

#!/usr/bin/env bash

CITY=""
ENV="production"

while (( $# > 0 )); do
    case "$1" in
        -c|--city)
            if [[ -z "$2" || "$2" == -* ]]; then
                echo "Error: --city requires a value." >&2
                exit 1
            fi
            CITY="$2"
            shift 2 # Consume both flag and value
            ;;
        -e|--env)
            ENV="$2"
            shift 2
            ;;
        -h|--help)
            echo "Usage: $0 [options]"
            echo "  -c, --city <name>   Target city (Required)"
            echo "  -e, --env <type>    Environment type"
            exit 0
            ;;
        *)
            echo "Unknown argument: $1" >&2
            exit 1
            ;;
    esac
done

if [[ -z "$CITY" ]]; then
    echo "Error: --city is a required parameter." >&2
    exit 1
fi

echo "Successfully parsed parameters via while+shift loop:"
echo "City: $CITY"
echo "Env:  $ENV"

Quick Summary: Which one should you use?

Feature getopts (Built-in) while + shift (Custom Loop)
Short flags (-c) Yes (excellent handling) Yes
Long flags (--city) No (requires external getopt) Yes (native & flexible)
Combined flags (-vh) Yes (handles -v -h automatically) No (must be explicitly written)
Complexity Low (clean built-in logic) Medium (requires manual shifting)