A shell script's default behaviour is to keep going after an error and carry on with an empty variable. That combination is how a backup script deletes a directory. Four habits remove almost all of it.
1. Watch the default behaviour fail
mkdir -p ~/labs/script && cd ~/labs/script
mkdir -p data && touch data/keep.txt
cat > bad.sh <<'SCRIPT'
#!/bin/bash
DEST=$1
mkdir $DEST/output
cp data/*.txt $DEST/output/
echo "copied to $DEST/output"
rm -rf $DEST/tmp
echo "done"
SCRIPT
chmod +x bad.sh
./bad.sh
lsRun with no argument, $1 is empty. mkdir /output fails with permission denied — and the script keeps going. cp fails. Then rm -rf /tmp runs against the real /tmp, because $DEST/tmp expanded to /tmp.
It printed "done". Every step failed and the exit status was zero.
Verify
2. Turn on strict mode
cat > better.sh <<'SCRIPT'
#!/bin/bash
set -euo pipefail
DEST=$1
mkdir "$DEST/output"
echo "done"
SCRIPT
chmod +x better.sh
./better.sh; echo "exit=$?"Three settings, each closing a different hole:
-eexits on any command that returns non-zero-utreats an unset variable as an error instead of an empty string-o pipefailmakes a pipeline fail if any stage fails, not just the last
Without pipefail, false | true succeeds — which is why a broken curl piped into jq can look fine.
Verify
3. Check arguments properly and quote everything
cat > good.sh <<'SCRIPT'
#!/bin/bash
set -euo pipefail
usage() {
echo "usage: $(basename "$0") <destination-dir>" >&2
exit 2
}
[[ $# -eq 1 ]] || usage
DEST=$1
[[ -d $DEST ]] || { echo "error: '$DEST' is not a directory" >&2; exit 1; }
mkdir -p "$DEST/output"
cp data/*.txt "$DEST/output/"
echo "copied $(ls -1 "$DEST/output" | wc -l) file(s) to $DEST/output"
SCRIPT
chmod +x good.sh
./good.sh; echo "exit=$?"
./good.sh /nonexistent; echo "exit=$?"
mkdir -p out && ./good.sh out; echo "exit=$?"Every variable is quoted. An unquoted $DEST with a space in it becomes two arguments, and unquoted is also how rm -rf $DEST/tmp became rm -rf /tmp above. Usage goes to stderr and exits non-zero, so a caller can tell the difference between "wrong invocation" and "worked".
Verify
Verify
4. Clean up after yourself with a trap
A script that creates a temp directory must remove it even when it fails halfway.
cat > trapped.sh <<'SCRIPT'
#!/bin/bash
set -euo pipefail
WORK=$(mktemp -d)
cleanup() {
local code=$?
rm -rf "$WORK"
if [[ $code -eq 0 ]]; then
echo "ok, cleaned $WORK"
else
echo "failed ($code), cleaned $WORK"
fi
exit $code
}
trap cleanup EXIT
echo "working in $WORK"
touch "$WORK/artifact"
[[ "${1:-}" == "--fail" ]] && exit 7
echo "work finished"
SCRIPT
chmod +x trapped.sh
./trapped.sh
./trapped.sh --fail; echo "outer exit=$?"
ls /tmp | grep -c "^tmp\." || echo "no leftovers"trap cleanup EXIT runs on any exit — success, error, or set -e abort. Capturing $? as the first line of the handler preserves the original status; doing anything before that overwrites it. ${1:-} is how you read a possibly-unset argument while -u is on.
Verify
5. Lint it, because you will miss something
sudo apt-get install -y shellcheck >/dev/null 2>&1 || true
shellcheck bad.sh || true
shellcheck good.sh && echo "good.sh is clean"ShellCheck finds unquoted expansions, useless cat, and the $?-after-something bug. Run it in CI on every script you commit; it catches the class of fault this whole lab is about, before review.
Verify
The four habits
set -euo pipefailon line two, always.- Quote every expansion.
"$var","$@","${arr[@]}". - Validate arguments and exit non-zero with a message on stderr.
trap cleanup EXITwhenever the script creates anything temporary.
Clean up
cd ~ && rm -rf ~/labs/scriptWhere this goes next
You have a script worth running unattended. Next: making something run it on a schedule, and finding out when it did not.