Defensive Scripting with Bash Strict Mode and PowerShell Error Handling

Quote

“The most dangerous phrase in the language is, ‘We’ve always done it this way.‘”

Grace Hopper, attributed remark (c. 1980s)

“Bash without set -euo pipefail is a loaded gun pointed at your data.”

— Shell scripting proverb

Production automation fails safely only when the shell is told how to treat errors, missing input, and cleanup. Bash strict mode and PowerShell strict error handling make those rules explicit instead of leaving them to defaults designed for interactive use.

Related reading: 08_py_errorhandling covers Python exception handling, 08_cs_errorhandling covers C# try/catch/finally, and airflow-dag-patterns applies the same failure-propagation logic to orchestrated jobs.

The diagram below shows how the three Bash flags and trap interact as a layered defense. PowerShell equivalents map to the same layers.


flowchart TD
    A([Script starts]) --> B[set -e<br>exit on command failure]
    B --> C[set -u<br>error on unset variable]
    C --> D[set -o pipefail<br>propagate pipeline failures]
    D --> E[trap EXIT<br>guaranteed cleanup]
    E --> F([Safe execution])

    B -- "command fails" --> G[[Script exits<br>non-zero]]
    C -- "unset variable" --> G
    D -- "pipeline stage fails" --> G
    G --> E

Linux | bash | defensive scripting tools

Bash strict mode is a set of shell behaviors that converts silent failure into explicit failure. Use it in production scripts, not as a blanket default for interactive sessions.

Linux | set | configure shell safety flags

The set builtin changes the behavior of the current shell. The strict-mode flags below are normally enabled together, but each one protects a different failure mode.

Strict mode belongs in scripts, not your everyday shell

set -euo pipefail is a script policy. It turns normal exploratory behavior such as grep misses, scratch variables, and partial pipelines into failures. Keep it in entrypoint scripts and tested functions instead of dropping it into a login shell profile.

Enable the full strict mode header

Every production script should put the shebang first and the strict-mode header immediately after it. The header itself has no output, so the verification command below inspects the shell option state after enabling the flags.

Run the commands in this section to enable the full strict mode header.

#!/usr/bin/env bash
set -euo pipefail

Run the commands in this section to enable the full strict mode header.

set -euo pipefail
set -o | grep -E "errexit|nounset|pipefail"
errexit        	on
nounset        	on
pipefail       	on

set -e — stop on the first failing command

Without set -e, a command can fail and the script still keeps running. The first example shows the failure being observed but ignored. The second example enables set -e; the command after false never runs, and the caller receives a non-zero exit status.

Run the commands in this section to set -e — stop on the first failing command.

printf "before\n"
if false; then
    result=0
else
    result=1
fi
printf "after\n"
printf "failed_command=%s\n" "$result"
before
after
failed_command=1

Run the commands in this section to set -e — stop on the first failing command.

bash -c 'set -e; printf "before\n"; false; printf "after\n"'
printf "script_exit=%s\n" "$?"
before
script_exit=1

Use the escape hatches deliberately. if cmd ..., cmd || true, and subshell boundaries are explicit ways to handle expected non-zero statuses without disabling strict mode for the whole script.

set -u — stop on unset variables

set -u turns a missing variable into an immediate failure. That matters most when the variable is about to influence a path, connection string, or destructive command.

The classic destructive pattern looks like this. The existing captured output is preserved because it already demonstrates the failure being caught before rm expands the path.

Run the commands in this section to set -u — stop on unset variables.

rm -rf "$STAGING_DIR"/*
bash: STAGING_DIR: unbound variable

For a safe console check, probe the missing variable directly.

Run the commands in this section to set -u — stop on unset variables.

set -u
printf '%s\n' "$STAGING_DIR"
bash: line 3: STAGING_DIR: unbound variable

Supply defaults for optional variables with ${VAR:-default}

${VAR:-default} keeps set -u enabled while still allowing optional configuration. It is the right tool for settings that have a safe operational fallback.

Run the commands in this section to supply defaults for optional variables with ${VAR:-default}.

DB_PORT="${DB_PORT:-1433}"
LOG_LEVEL="${LOG_LEVEL:-INFO}"
CACHE_MODE="${CACHE_MODE:-read-only}"
printf "DB_PORT=%s\nLOG_LEVEL=%s\nCACHE_MODE=%s\n" "$DB_PORT" "$LOG_LEVEL" "$CACHE_MODE"
DB_PORT=1433
LOG_LEVEL=INFO
CACHE_MODE=read-only

Require startup configuration with ${VAR:?error}

${VAR:?message} is the hard-stop version of parameter expansion. Use it near the top of the script so required configuration fails before the script touches data or infrastructure.

Run the commands in this section to require startup configuration with ${VAR:?error}.

: "${DB_HOST:?ERROR: DB_HOST must be set}"
/bin/bash: line 1: DB_HOST: ERROR: DB_HOST must be set

set -o pipefail — surface upstream pipeline failures

Without pipefail, a pipeline is treated as successful if its last command succeeds. The first run shows a failed first stage still producing pipeline_exit=0. The second run enables pipefail, so the same upstream failure becomes visible to the caller.

Run the commands in this section to set -o pipefail — surface upstream pipeline failures.

if false | cat >/dev/null; then
    echo "pipeline_exit=0"
else
    echo "pipeline_exit=1"
fi
pipeline_exit=0

Run the commands in this section to set -o pipefail — surface upstream pipeline failures.

set -o pipefail
if false | cat >/dev/null; then
    echo "pipeline_exit=0"
else
    echo "pipeline_exit=1"
fi
pipeline_exit=1

The related set switches below are still useful as a compact lookup table.

FlagSyntaxDescription
-eset -eExit immediately when any command exits with a non-zero status
-uset -uTreat unset variables as errors and exit immediately
-o pipefailset -o pipefailPipeline exit code is the exit code of the first failed command
-xset -xPrint each command and its arguments before executing
-nset -nRead commands but do not execute them
-fset -fDisable filename expansion (globbing)
-Cset -CPrevent redirection from overwriting existing files
+eset +eDisable exit-on-error temporarily
+uset +uDisable unset-variable errors temporarily

Use set -x only for temporary debugging

set -x is useful because it prints each command before execution, but it will also print arguments and can leak secrets. Turn it on only around the block you are diagnosing and remove it before committing the script.

Run the commands in this section to use set -x only for temporary debugging.

set -x
printf 'safe_demo\n'
safe_demo
+ printf 'safe_demo\n'

Disable strict mode only around an intentional failure

When a command is allowed to fail, scope that exception narrowly with set +e and then restore set -e immediately. That preserves the default fail-fast behavior everywhere else.

Run the commands in this section to disable strict mode only around an intentional failure.

set -e
set +e
false
allowed=$?
set -e
printf 'allowed_failure=%s\n' "$allowed"
echo 'still_running'
allowed_failure=1
still_running

Linux | trap | guarantee cleanup on exit

trap is the shell equivalent of a finally block. Register cleanup before the risky part of the script so temporary files and other resources are released even when set -e stops execution.

Register cleanup with trap ... EXIT

EXIT fires on normal completion, explicit exit, or shell termination caused by set -e. The demo below fails on purpose, but the cleanup handler still runs and preserves the original exit code.

Run the commands in this section to register cleanup with trap ... EXIT.

bash -c 'set -euo pipefail
TEMP_FILE=$(mktemp)
cleanup() {
    exit_code=$?
    rm -f "$TEMP_FILE"
    printf "cleanup_ran exit=%s\n" "$exit_code"
}
trap cleanup EXIT
false'
printf "script_exit=%s\n" "$?"
cleanup_ran exit=1
script_exit=1

Handle specific signals when the script must react

Trap INT when you need to respond to Ctrl+C and TERM when you need to react to orchestrator-initiated shutdown. HUP can be ignored if the script must outlive a disconnected terminal, but SIGKILL can never be trapped.

Run the commands in this section to handle specific signals when the script must react.

trap 'echo Interrupted' INT
trap 'echo Terminated' TERM
kill -INT $$
kill -TERM $$
echo 'handlers_complete'
Interrupted
Terminated
handlers_complete

Linux | bash | production script template

The next example writes a short script to /tmp and runs it so BASH_SOURCE[0] behaves the same way it would in production. It combines strict mode, required-variable checks, a temp file, and an EXIT trap.

Run the commands in this section to handle specific signals when the script must react.

script=/tmp/defensive-template-demo.sh
cat > "$script" <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
 
cleanup() {
    local exit_code=$?
    rm -f "$TEMP_FILE" 2>/dev/null
    printf '[LOG] cleanup exit=%s\n' "$exit_code"
}
trap cleanup EXIT
 
readonly SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
readonly TEMP_FILE="$(mktemp)"
readonly DB_HOST="${DB_HOST:-db.internal}"
readonly DB_PORT="${DB_PORT:-1433}"
 
log() { printf '[LOG] %s\n' "$*"; }
 
log "SCRIPT_DIR=$SCRIPT_DIR"
log "Connecting to $DB_HOST:$DB_PORT"
log 'Pipeline complete'
EOF
bash "$script"
rm -f "$script"
[LOG] SCRIPT_DIR=/tmp
[LOG] Connecting to db.internal:1433
[LOG] Pipeline complete
[LOG] cleanup exit=0

PowerShell | defensive scripting tools

PowerShell has equivalents for every Bash mechanism above, but the defaults are different. Cmdlet errors are often non-terminating by default, and native executable exit codes require explicit inspection.

PowerShell | $ErrorActionPreference | set global error behavior

$ErrorActionPreference is the PowerShell control for whether non-terminating errors keep execution moving. Set it to Stop at the top of every script so cmdlet and provider failures enter normal exception handling.

Set $ErrorActionPreference = 'Stop' at the top of every script

The demo below forces a missing-path error. With Stop enabled, the error becomes terminating and flows into catch instead of allowing the script to continue.

Run the commands in this section to set $ErrorActionPreference = 'Stop' at the top of every script.

& {
    $ErrorActionPreference = 'Stop'
    try {
        Get-Item -LiteralPath '.\definitely-missing-file' | Out-Null
        'after'
    }
    catch {
        "caught=$($_.Exception.GetType().Name)"
    }
}
caught=ItemNotFoundException

The common preference values below remain a useful compact lookup.

ValueBehavior
ContinuePrint error message and continue executing (default)
StopTreat every error as terminating
SilentlyContinueSuppress error output and continue
InquirePrompt the user on each error

PowerShell | Set-StrictMode | detect uninitialized variables and bad expressions

Set-StrictMode -Version Latest is the PowerShell equivalent of refusing to let missing state slide by as $null. It catches uninitialized variables, bad property access, and related structural mistakes early.

Enable Set-StrictMode -Version Latest

The preserved example below shows the kind of failure strict mode prevents in destructive code.

Run the commands in this section to enable Set-StrictMode -Version Latest.

Remove-Item "$StagingDir\*" -Recurse -Force
The variable '$StagingDir' cannot be retrieved because it has not been set.
At line:1 char:13

For a safe verification run, read the missing variable directly inside a try/catch.

Run the commands in this section to enable Set-StrictMode -Version Latest.

& {
    Set-StrictMode -Version Latest
    try {
        $null = $StagingDir
        'after'
    }
    catch {
        $_.Exception.Message
    }
}
The variable '$StagingDir' cannot be retrieved because it has not been set.

The version table below is still useful as a factual lookup.

VersionAdditional checks enabled
1.0Prohibits references to uninitialized variables
2.0Adds uninitialized properties and calls to non-existent functions
LatestEnables all checks available in the current PowerShell version

PowerShell | try/catch/finally | structured error handling and cleanup

try/catch/finally is the PowerShell equivalent of Bash set -e plus trap EXIT. Use catch for terminating failures and finally for cleanup that must always happen.

Native exit codes bypass PowerShell's error preference

$ErrorActionPreference = 'Stop' only upgrades PowerShell errors. Native tools still signal failure through $LASTEXITCODE, so defensive scripts must inspect that value and throw explicitly when a non-zero exit should stop the run.

Wrap the script body in try/catch/finally

This run creates a temp file, triggers a terminating error, and then proves that the finally block still executed cleanup.

Run the commands in this section to wrap the script body in try/catch/finally.

& {
    $ErrorActionPreference = 'Stop'
    Set-StrictMode -Version Latest
    $TempFile = New-TemporaryFile
    try {
        'starting'
        Get-Item -LiteralPath '.\definitely-missing-file' | Out-Null
        'after'
    }
    catch {
        "caught=$($_.Exception.GetType().Name)"
    }
    finally {
        $CleanupRan = Test-Path -LiteralPath $TempFile
        Remove-Item -LiteralPath $TempFile -ErrorAction SilentlyContinue
        "cleanup_ran=$CleanupRan"
    }
}
starting
caught=ItemNotFoundException
cleanup_ran=True

Throw on non-zero $LASTEXITCODE after native tools

Native executables do not honor $ErrorActionPreference. The native command below fails with exit code 7; the explicit throw converts that exit code into a terminating PowerShell error that catch can handle.

Run the commands in this section to throw on non-zero $LASTEXITCODE after native tools.

& {
    $ErrorActionPreference = 'Stop'
    try {
        cmd /c exit 7
        if ($LASTEXITCODE -ne 0) {
            throw "native_exit=$LASTEXITCODE"
        }
        'after'
    }
    catch {
        $_.Exception.Message
    }
}
native_exit=7

PowerShell | production script template

This example writes a short .ps1 file to the temp directory and runs it so $PSScriptRoot is populated the same way it would be in production.

Run the commands in this section to throw on non-zero $LASTEXITCODE after native tools.

$ScriptPath = Join-Path $env:TEMP 'defensive-template-demo.ps1'
@'
#Requires -Version 5.1
$ErrorActionPreference = 'Stop'
Set-StrictMode -Version Latest
$TempFile = New-TemporaryFile
$ScriptDir = $PSScriptRoot
$env:DB_HOST = 'db.internal'
$DbHost = $env:DB_HOST
if (-not $DbHost) { throw 'ERROR: DB_HOST environment variable must be set' }
$DbPort = if ($env:DB_PORT) { $env:DB_PORT } else { '1433' }
function Write-Log {
    param([string]$Message)
    "[LOG] $Message"
}
try {
    Write-Log "ScriptDir=$ScriptDir"
    Write-Log "Connecting to ${DbHost}:${DbPort}"
    Write-Log 'Pipeline complete'
}
finally {
    Remove-Item -LiteralPath $TempFile -ErrorAction SilentlyContinue
    Write-Log 'Cleanup complete'
    Remove-Item Env:DB_HOST -ErrorAction SilentlyContinue
    Remove-Item Env:DB_PORT -ErrorAction SilentlyContinue
}
'@ | Set-Content -LiteralPath $ScriptPath
& $ScriptPath
Remove-Item -LiteralPath $ScriptPath
[LOG] ScriptDir=C:\Users\aperi\AppData\Local\Temp
[LOG] Connecting to db.internal:1433
[LOG] Pipeline complete
[LOG] Cleanup complete

Defensive Scripting Troubleshooting

The symptoms below are the most common reasons strict scripts feel “too aggressive” at first. In each case, the fix is to keep the safety mechanism and make the expected edge case explicit.

Linux | defensive scripting troubleshooting | expected non-zero exits and trap timing

A command that is allowed to return 1 now stops the script

grep returns 1 when it finds no match. Under set -e, that is still a non-zero exit code, so the script stops unless you mark the case as expected.

Run the commands in this section to a command that is allowed to return 1 now stops the script.

if bash -lc 'set -e; grep -q needle /dev/null; echo after'; then
    echo 'script_exit=0'
else
    echo 'script_exit=1'
fi
script_exit=1

Run the commands in this section to a command that is allowed to return 1 now stops the script.

bash -lc 'set -e; grep -q needle /dev/null || true; echo after'
after

Use if grep ...; then ... fi when the result controls branching, and use || true only when you are explicitly absorbing a benign non-zero status.

An optional variable is unbound under set -u

If a variable is genuinely optional, do not disable set -u; give that variable an explicit fallback.

Run the commands in this section to an optional variable is unbound under set -u.

printf 'OPTIONAL_VAR=%s\n' "${OPTIONAL_VAR:-fallback}"
OPTIONAL_VAR=fallback

A pipeline looks successful even though the first stage failed

pipefail makes the pipeline fail, and PIPESTATUS tells you which stage returned which exit code. Use both when diagnosing a multi-stage pipeline.

Run the commands in this section to a pipeline looks successful even though the first stage failed.

set -o pipefail
false | cat >/dev/null
printf 'PIPESTATUS=%s %s\n' "${PIPESTATUS[0]}" "${PIPESTATUS[1]}"
PIPESTATUS=1 0

Cleanup never runs

The most common cause is that the trap was registered too late. If the script fails before trap cleanup EXIT, there is nothing to run. Even when the trap is registered correctly, SIGKILL remains uncatchable.

Run the commands in this section to cleanup never runs.

if bash -lc 'set -e; false; trap '\''echo cleanup'\'' EXIT'; then
    echo 'script_exit=0'
else
    echo 'script_exit=1'
fi
script_exit=1

Run the commands in this section to cleanup never runs.

if bash -lc 'set -e; trap '\''echo cleanup'\'' EXIT; false'; then
    echo 'script_exit=0'
else
    echo 'script_exit=1'
fi
cleanup
script_exit=1

PowerShell | defensive scripting troubleshooting | non-terminating and native-tool failures

catch never runs because the error is still non-terminating

With the default Continue behavior, non-terminating errors stay non-terminating. The script below captures the emitted error record and still reaches the line after it, which is the symptom to look for.

Run the commands in this section to catch never runs because the error is still non-terminating.

& {
    $ErrorActionPreference = 'Continue'
    try {
        $Records = Write-Error 'boom' 2>&1
        "error_records=$($Records.Count)"
        'after'
    }
    catch {
        'caught'
    }
}
error_records=1
after

Run the commands in this section to catch never runs because the error is still non-terminating.

& {
    $ErrorActionPreference = 'Stop'
    try {
        Write-Error 'boom'
        'after'
    }
    catch {
        'caught'
    }
}
caught

A native executable fails but the script keeps going

Native tools report failure through $LASTEXITCODE, not through PowerShell’s error preference system. The first example shows the script continuing after cmd /c exit 7; the second turns that exit code into a terminating error.

Run the commands in this section to a native executable fails but the script keeps going.

& {
    $ErrorActionPreference = 'Stop'
    try {
        cmd /c exit 7
        "LASTEXITCODE=$LASTEXITCODE"
        'after_native_tool'
    }
    catch {
        'caught'
    }
}
LASTEXITCODE=7
after_native_tool

Run the commands in this section to a native executable fails but the script keeps going.

& {
    $ErrorActionPreference = 'Stop'
    try {
        cmd /c exit 7
        if ($LASTEXITCODE -ne 0) {
            throw "native_exit=$LASTEXITCODE"
        }
    }
    catch {
        $_.Exception.Message
    }
}
native_exit=7

Defensive Scripting Cross-References

Defensive Scripting References