Linux Basic — Common Scripting Bugs

4 minute read

Common Scripting Bugs

After enough debugging sessions, the same handful of bugs come up over and over. Here are the ten you’ll encounter most — and the fix for each.


1. Unquoted Variables

# ❌ Breaks on any value with whitespace, globs, or empty string
file=$1
cp $file /tmp/

# ✅ Always quote
cp "$file" /tmp/

Single most common bug, single biggest source of weird behaviour. ShellCheck SC2086. Quote everything unless you specifically need word-splitting or glob expansion.

# Special exceptions where unquoted is right
[[ -n $var ]]           # [[ ]] doesn't word-split — safe
((  count++  ))         # arithmetic context — safe
local -a parts; parts=($string)   # intentional word-splitting

2. Parsing ls

# ❌
for f in $(ls); do
    rm "$f"
done

# ✅
for f in *; do
    [[ -e "$f" ]] || continue
    rm "$f"
done

ls output is for humans. Use globs or find -print0 for code. ShellCheck SC2010.


3. The while read Subshell

# ❌ count is lost — while is in a subshell
count=0
some_cmd | while read -r line; do
    (( count++ ))
done
echo "$count"
# 0

# ✅ Process substitution avoids the subshell
count=0
while read -r line; do
    (( count++ ))
done < <(some_cmd)
echo "$count"

The right side of a pipe runs in a subshell. Variable assignments don’t escape. Use process substitution or lastpipe.


4. cd Without Failure Check

# ❌ If /opt/myapp doesn't exist, you'll rm -rf the wrong dir
cd /opt/myapp
rm -rf *

# ✅ Verify before catastrophe
cd /opt/myapp || exit 1
rm -rf *

# ✅ Better: shellcheck-clean
cd /opt/myapp || { echo "no such dir" >&2; exit 1; }
rm -rf -- *

ShellCheck SC2164 catches this. Always pair cd with || exit or || die ….


5. [ vs [[ — Word Splitting

# ❌ With empty $var, becomes [ -z ]
if [ -z $var ]; then …

# ✅ Quote inside single brackets
if [ -z "$var" ]; then …

# ✅ Or use double brackets — no quoting needed inside
if [[ -z $var ]]; then …

Use [[ ]] for new scripts — it doesn’t word-split and supports =~, &&, ||, glob ==. Reserve [ ] for POSIX portability.


6. Integer Comparison vs String

# ❌ String comparison — "10" < "2" lexically
if [[ "$a" < "$b" ]]; then …

# ✅ Numeric — use (( )) or -lt
if (( a < b )); then …
if [[ "$a" -lt "$b" ]]; then …
Goal Use
Compare integers (( a < b )) or [[ a -lt b ]]
Compare strings [[ "$a" < "$b" ]] (lexical)
Equality (int) (( a == b ))
Equality (string) [[ "$a" == "$b" ]]

7. Array Confusion

arr=(a b c)

# ❌ Old: assigning a single-element scalar instead of array
copy=$arr            # copy = "a" only!

# ✅ Full copy
copy=( "${arr[@]}" )
# ❌ Loop variant
for x in $arr; do …          # only iterates over arr[0]
for x in ${arr[@]}; do …     # iterates but word-splits each element
for x in "${arr[@]}"; do …   # ✅ correct

The mantra: "${arr[@]}" — quoted, with @.


8. Locale Surprises

# Sorting in en_US.UTF-8 vs C
$ LC_ALL=en_US.UTF-8 sort <<EOF
a
B
c
EOF
# a
# B
# c

$ LC_ALL=C sort <<EOF
a
B
c
EOF
# B
# a
# c

[[:upper:]] and friends are locale-dependent. For deterministic scripts:

export LC_ALL=C

at the top.


9. $(command) Trailing Newline Strip

# Capture loses trailing newlines
content=$(cat file_ending_in_newline.txt)
echo "$content" > copy.txt
# copy.txt has NO trailing newline now

Bash strips trailing newlines from $(…). If you need them, append a sentinel:

content=$(cat file.txt; printf x)
content=${content%x}

Or use mapfile.


10. set -e Inside Functions Called In Conditions

set -e

doit() {
    false                # would normally abort
    echo "still here?"   # but we get here anyway
}

if doit; then …         # set -e disabled inside doit because it's in a condition

Two fixes:

# A. Check explicitly inside the function
doit() {
    false || return $?
    echo "won't run"
}

# B. Use 'trap ERR' which fires regardless of context
set -eE
trap 'echo "ERR at line $LINENO"' ERR

Bonus — Five More to Watch For

11. read Without -r

echo 'C:\Users' | read -r path           # ✅ literal backslashes preserved
echo 'C:\Users' | read    path           # ❌ backslashes eaten

Always read -r.

12. Globs in == Pattern Matching

# Glob — works as expected
[[ "$f" == *.log ]] && echo "log"

# Quoted pattern — literal match, no glob!
[[ "$f" == "*.log" ]] && echo "log"      # only matches the literal string *.log

Don’t quote the right-hand pattern unless you want a literal match.

13. echo With -e or Special Chars

echo "$value"            # behaviour depends on $value content and shell variant
printf '%s\n' "$value"   # ✅ predictable

Use printf whenever the content is arbitrary.

14. Trap That Captures $tmpdir at Trap-Set Time

# ❌ $tmpdir expanded NOW (might be empty here)
trap "rm -rf $tmpdir" EXIT

# ✅ Single quotes — expansion happens when trap fires
trap 'rm -rf "$tmpdir"' EXIT

15. mktemp Without -d or -p

# ✅ Portable, secure temp file
tmp=$(mktemp)
trap 'rm -f "$tmp"' EXIT

# ✅ Temp directory
tmpdir=$(mktemp -d)
trap 'rm -rf "$tmpdir"' EXIT

# ❌ Never roll your own
tmp=/tmp/myfile.$$         # predictable, race-prone

How to Avoid All of These

Three habits:

  1. Run ShellCheck on every script. It catches 1–9 directly.
  2. Use strict mode — set -euo pipefail plus IFS=$'\n\t'.
  3. Test with weird inputs — filenames with spaces, empty strings, unicode, missing files. The bugs hide in the corners.

Next → Practice Exercises

Updated: