Skip to content

set, shopt — Shell Option Modifiers


Bash is permissive by default, and that can hide problems: a failed command may not stop your script, a pipeline may report success even when something inside it failed, and an unset variable may quietly become an empty string. set -e, set -u, set -o pipefail, and shopt exist to change those defaults and make Bash behave more predictably and safely.

set -e — Exit On Error

By default, a script keeps running even after a command fails — you’ve relied on this constantly throughout this course, checking $? or using if to react to failure deliberately. set -e (also called errexit) changes that default: the script exits immediately the moment any command fails, without you having to check for it explicitly.

#!/usr/bin/env bash
set -e

echo "About to fail"
false
echo "This line never runs"
About to fail

Without set -e, that last echo would have run anyway, printing right after the silent failure of false — exactly the behavior every script in this course has had by default up to this point.

The Surprising Cases Where set -e Doesn’t Trigger

set -e has several well-known exceptions, and not knowing them is a common source of scripts that seem to have set -e enabled but don’t actually stop when you’d expect:

A command tested by if, while, or until doesn’t trigger set -e, because its failure is the whole point of the test:

set -e
if false; then
    echo "won't print"
fi
echo "Script keeps going — this is expected"
Script keeps going — this is expected

The left-hand side of && or || doesn’t trigger set -e either, for the same reason — its exit status is already being deliberately handled:

set -e
false || echo "handled — script continues"
echo "Still running"
handled — script continues
Still running

Only the last command in a pipeline determines whether set -e fires, by default — an earlier stage’s failure is invisible to set -e unless pipefail is also enabled, covered next.

And the single most surprising case, worth its own full example: the exit status of a command substitution used to initialize a local variable is masked, because the exit status Bash actually sees is local’s own (which almost always succeeds), not the command substitution’s:

set -e

get_value() {
    local result=$(false)
    echo "This still runs, even though false clearly failed"
}

get_value
echo "Script continued past a real failure"
This still runs, even though false clearly failed
Script continued past a real failure

This exact pattern is worked through in full, along with its fix, in this chapter’s Shell-Safety Considerations section.

set -u — Error On Unset Variables

set -u (also nounset) turns referencing an unset variable into an immediate error, instead of silently treating it as an empty string:

set -u
echo "$undefined_variable"
bash: undefined_variable: unbound variable

This is genuinely useful for catching typos — $flie instead of $file silently expands to nothing without set -u, potentially producing a confusing downstream failure far from the actual mistake, but errors immediately and clearly with it enabled.

Positional parameters are covered by this too: a script that expects $1 but was called with no arguments will error immediately under set -u, rather than quietly treating $1 as empty — which is exactly the kind of missing-argument bug you use to check with $# for explicitly.

See the failure on script:

cat > fail.sh << 'EOF'
set -u
echo "$1"
EOF
chmod +x fail.sh
./fail.sh
./fail.sh: line 2: $1: unbound variable

If you genuinely want to allow a variable to be unset and default to empty, use ${var:-} to opt into that deliberately.

set -o pipefail — Making Pipeline Failures Visible

As covered previously, a pipeline’s exit status is normally just its last command’s — an earlier stage failing is invisible unless you manually check PIPESTATUS. pipefail changes the pipeline’s overall exit status to reflect the rightmost failing stage, not just the last one:

false | true
echo "Without pipefail: $?"

set -o pipefail
false | true
echo "With pipefail: $?"
Without pipefail: 0
With pipefail: 1

With pipefail enabled, set -e can now correctly detect a failure anywhere in a pipeline automatically — without it, set -e would see only true’s successful exit status and have no idea false failed at all.

Putting It Together: set -euo pipefail

This combined form shows up constantly at the top of production Bash scripts. Here’s exactly how it parses: -e and -u are short flags, -o takes a following word as its argument, and pipefail is that argument — so set -euo pipefail is really set -e -u -o pipefail, just written more compactly. pipefail has no short-flag form of its own, which is why it always has to be spelled out after -o.

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

echo "Strict mode is active"

With all three enabled: any command failure stops the script (except in the documented exception cases above), any unset variable reference is an error, and pipeline failures are no longer hidden behind a successful last stage. This is a genuinely strong default for most scripts — the exceptions above are worth knowing precisely because this combination is so commonly used.

shopt — Additional Shell Behavior Options

shopt is a separate mechanism from set, controlling a different set of Bash-specific behavioral toggles. Enable an option with -s (set), disable one with -u (unset):

shopt -s nullglob

Note

shopt -u and set -u are unrelated despite sharing a letter — shopt -u optionname disables a specific shopt option, while set -u (covered above) is the entirely separate “error on unset variables” behavior. This naming overlap trips people up occasionally; keep the two mechanisms mentally separate.

nullglob

An unmatched glob pattern, by default, runs a for loop once with the literal pattern text rather than zero times. nullglob fixes exactly that:

shopt -s nullglob
for f in /tmp/shell-course-demo/*.nonexistent; do
    echo "Found: $f"
done
echo "Loop completed with zero iterations, as expected"
Loop completed with zero iterations, as expected

failglob

Stricter still: with failglob enabled, an unmatched glob pattern makes the command itself fail with an error, rather than silently expanding to nothing or to the literal pattern text — useful when a script should treat “no matches” as a real problem worth stopping for, not a case to quietly continue past.

globstar

Enables ** for recursive directory matching:

shopt -s globstar
echo **/*.md

With globstar enabled, ** matches through any depth of subdirectories, not just the current one — **/*.md finds .md files anywhere underneath the current directory, recursively, without needing to shell out to find for a task this simple. You can combine with tr if readability matters:

echo **/*.md | tr ' ' '\n'

Best Practices

  • Start most scripts with set -euo pipefail, and know the exceptions above well enough to recognize when a command’s failure genuinely won’t be caught.
  • Always split local var and var=$(command) onto two separate lines, in every script, regardless of whether set -e is active — this fully sidesteps the local/command-substitution masking problem covered in this chapter’s Shell-Safety Considerations section.
  • Use ${var:-} deliberately when a variable is genuinely allowed to be unset under set -u, rather than disabling set -u entirely just to accommodate one variable.
  • Enable nullglob for any script that loops over a glob pattern that might not match anything — it’s a one-line fix for a genuinely surprising default behavior.

Shell-Safety Considerations

The local/command-substitution masking bug from earlier in this chapter is worth seeing fixed directly — it’s the single most cited set -e surprise for exactly this reason: it looks like set -e isn’t working at all, when really it’s working exactly as documented, just not in the place you’d expect.

Naive (broken) version:

set -e

get_value() {
    local result=$(false)
    echo "Reached this line despite a real failure above"
}

get_value
echo "Script kept running"
Reached this line despite a real failure above
Script kept running

false genuinely failed inside the command substitution — but the exit status Bash actually evaluates for set -e’s purposes is the exit status of the whole local result=$(false) statement, and that status belongs to local itself, which succeeded at declaring the variable regardless of what its assigned value’s command produced.

Corrected version — declare and assign on separate lines:

set -e

get_value() {
    local result
    result=$(false)
    echo "This line is never reached now"
}

get_value
echo "This never prints either"

Nothing prints.f

No output at all — result=$(false) on its own line has its own real exit status, exactly matching false’s, and set -e correctly stops the script right there. This two-line pattern — local var followed by a separate assignment — is worth using as a fixed habit anywhere local meets a command substitution, whether or not set -e happens to be active in that particular script.

Hands-On: Strict Mode, Piece By Piece

1. Set up a working directory.

mkdir -p ~/shell-course/ch18
cd ~/shell-course/ch18

2. Confirm set -e’s basic behavior and one of its exceptions.

cat > basic-e.sh << 'EOF'
#!/usr/bin/env bash
set -e
echo "Before"
false
echo "After — should NOT print"
EOF
chmod +x basic-e.sh
./basic-e.sh

cat > exception-if.sh << 'EOF'
#!/usr/bin/env bash
set -e
if false; then
    echo "won't print"
fi
echo "Script continues — this SHOULD print"
EOF
chmod +x exception-if.sh
./exception-if.sh

3. Trigger set -u on a typo’d variable name.

cat > strict-u.sh << 'EOF'
#!/usr/bin/env bash
set -u
file_name="report.txt"
echo "$flie_name"
EOF
chmod +x strict-u.sh
./strict-u.sh

4. Compare pipeline exit status with and without pipefail.

false | true
echo "Without pipefail: $?"

set -o pipefail
false | true
echo "With pipefail: $?"
set +o pipefail

5. Reproduce the local masking bug, then fix it.

cat > masking-broken.sh << 'EOF'
#!/usr/bin/env bash
set -e
get_value() {
    local result=$(false)
    echo "Reached despite failure"
}
get_value
echo "Script kept running"
EOF
chmod +x masking-broken.sh
./masking-broken.sh

cat > masking-fixed.sh << 'EOF'
#!/usr/bin/env bash
set -e
get_value() {
    local result
    result=$(false)
    echo "Never reached"
}
get_value
echo "Never reached either"
EOF
chmod +x masking-fixed.sh
./masking-fixed.sh; echo "Exit status: $?"

6. Try nullglob against a pattern with no matches.

shopt -s nullglob
for f in ./*.nonexistent; do
    echo "Found: $f"
done
echo "Zero-iteration loop completed cleanly"
shopt -u nullglob

7. Clean up.

cd ~
rm -rf ~/shell-course/ch18

You’ve now enabled set -e, set -u, and pipefail individually, seen exactly where set -e doesn’t trigger — including the local/command-substitution case in full, both broken and fixed — and used nullglob to fix the empty-glob surprise from earlier chapters. The next chapter covers traps and cleanup: running code automatically on exit, error, or signal, and the patterns for safely cleaning up temporary resources no matter how a script ends.

Last updated on