Skip to content
shellhacks.dev
Software Design

What Is Technical Debt in Shell Scripts With Real Output

Spot technical debt in shell scripts: missing strict mode, unquoted variables, copied blocks, hardcoded paths and no tests, with commands that measure each.

6 min read
Summarize in AI
Illustration for "What Is Technical Debt in Shell Scripts With Real Output": a shell script in an editor, test results, a loop arrow.

Shell scripts rarely start as a mistake. A ten-line helper solves a problem, gets copied to another server, gains a flag and an if, and three years later runs production deployments with no tests and no one who understands all of it. That drift is technical debt: the cost of the shortcuts taken, paid as interest every time someone has to change or trust the script. Unlike most code, scripts give you quick ways to measure it. This post lists the signs, the commands that count them and an order for paying down. It connects to how scripts grow into systems and anti-patterns.

Count what the linter finds

Shell
shellcheck -f gcc scripts/*.sh | awk -F': ' '{print $NF}' | sed 's/.*\[//;s/\]//' | sort | uniq -c | sort -rn | head
shellcheck -f gcc scripts/*.sh | wc -l
Text
     84 SC2086
     31 SC2155
     12 SC2164
    147 total

ShellCheck reports unquoted expansions (SC2086), declaring and assigning in one command which hides failures (SC2155), and cd without a failure check (SC2164). A count per file, tracked over time, is a simple and repeatable gauge of safety debt. Not every finding is a bug, but a file with fifty is a file nobody has taken care of. See ShellCheck warnings explained.

Missing safety

Shell
grep -L 'set -[a-z]*e' scripts/*.sh
grep -L 'set -[a-z]*u' scripts/*.sh
grep -LE '^#!.*(bash|sh)$|^#!/usr/bin/env' scripts/*.sh

The first lists scripts without set -e semantics anywhere in a set line, the second those without -u, the third those without a proper shebang. A script that keeps running after a failed command and expands an empty variable into rm -rf "$DIR/"* is the pattern behind most horror stories; see strict mode. These greps are blunt, since a script might set options another way, and they find the obvious cases.

Hardcoded everything

Shell
grep -nE '/home/[a-z]+|/Users/[a-z]+|10\.[0-9]+\.[0-9]+\.[0-9]+|[a-z]+\.internal\.example\.org' scripts/*.sh
grep -nEi '(password|token|secret|api_key) *=' scripts/*.sh

Absolute paths to one person's home directory, fixed IP addresses and host names mean the script works only on the machine it was written on. Credentials in a script are the most serious sort of debt, as they are also a leak: see secrets in deploy scripts. The remedy is configuration through variables and arguments, with defaults.

Duplication

Shell
cat scripts/*.sh | grep -v '^\s*#' | sed 's/^[[:space:]]*//' | grep -v '^$' | sort | uniq -c | awk '$1 > 3 && length($0) > 40' | sort -rn | head

Lines that occur many times across scripts are copy-paste. If the same twelve-line block that parses arguments or sends a notification appears in nine scripts, a fix needs nine edits and will be made in five. Shared code belongs in one sourced file or a small tool; see splitting a script into modules.

No tests, no usage, no owner

Shell
for f in scripts/*.sh; do
  tests=$(grep -rl "$(basename "$f")" tests 2>/dev/null | wc -l)
  usage=$(grep -c -E 'usage|--help' "$f")
  lines=$(wc -l < "$f")
  printf '%-30s %5d lines  %d tests  usage:%d\n' "$f" "$lines" "$tests" "$usage"
done | sort -k2 -nr | head

The longest scripts with no tests and no usage text are the risk list. A script of 600 lines that no test mentions is one nobody can change with confidence, which is exactly what makes it debt: you can see the repayment is not happening. Also look at git log --format=%an -- script.sh | sort | uniq -c; a script that only one person has ever edited has a bus factor of one.

Pay it down in order

  1. Safety: quote variables, handle errors, remove credentials. Cheap, mechanical, and the biggest risk reduction.
  2. Configuration: replace hardcoded values with variables and flags.
  3. Tests around the behavior that matters, before refactoring; see tests before refactoring.
  4. Structure: extract functions, share common code, remove duplication.
  5. Replacement: if the script has outgrown the shell, port it, as in signs it is time to rewrite.

Where this does not apply

Not all debt is worth paying. A script that runs once a quarter, is read by its owner and does no harm can stay messy. Debt is a decision about interest: pay down the scripts that run often, run unattended, touch production or are edited by several people. Measures are guides; a script with zero ShellCheck findings can still be badly designed.

Measuring the debt instead of arguing about it

Technical debt in a script is easier to discuss when it has numbers. Run shellcheck over the repository and count warnings by severity and code, then keep the count and watch whether it rises or falls. Count the scripts that lack set -euo pipefail or an equivalent, the ones with hard-coded hostnames, paths or credentials, and the ones nobody has changed for years and nobody understands. Check for the markers of risk: unquoted variables, eval, rm -rf with a variable, curl | sh, parsing of ls output, and temporary files with predictable names. Note how many scripts have tests, how many have a usage message, and how many have an owner who can say what they are for. The numbers are crude, and they turn a vague feeling that the scripts are a mess into a list that can be prioritized and a trend that shows whether work is helping. They also make the cost visible to people who decide where time goes.

Not all debt is equal. A messy script that runs once a year on a test machine matters less than a tidy-looking one that deletes files on production every night, so weigh each item by what it can break and how often it runs.

Where shell debt hurts most

Shell has particular failure modes that make debt expensive. Errors are ignored by default, so a failed step does not stop the script, and later steps run on bad state. Variables are untyped and unquoted expansion splits and globs, so correct behavior depends on the data never containing a space. There is no compiler to catch a typo in a variable name, which expands to an empty string. Portability differs between systems, so a script that works on the author's laptop breaks in a container. And the language invites growth: a ten-line script gains options and branches until it is a program with no structure. The result is code that is risky to change and so is never changed, which is the real meaning of debt, the interest paid in fear. Spotting it is partly a matter of asking how confident a person feels making a change, and whether they can say what a script does without running it.

Hard-coded secrets, in particular, are debt with a security bill. A token in a script ends up in version control and backups, and finding and rotating them is work that grows the longer they sit there.

Paying it down in a sensible order

Start with what can hurt most for least effort: add strict mode and quote variables where scripts delete or overwrite, fix the findings that shellcheck marks as errors, and remove secrets from the files, rotating those that were exposed. Next, add tests around the scripts that matter before changing them, even a coarse test that runs a script on sample input and checks the output, so a refactor has a safety net. Then reduce duplication by moving repeated logic into a function or a sourced file, and replace hard-coded values with configuration. Add a usage message and a comment saying what each script is for and who owns it. Only after this consider whether the script should be rewritten in a language with better structure, and decide with evidence, using the signs that a rewrite is due. Make the work visible by adding the shellcheck run to CI with a baseline, so new debt is stopped and the old is paid down gradually.

Do it in small steps, in the same changes that touch the scripts for other reasons, and not as one large cleanup that nobody can review and everyone dreads.

See the ShellCheck wiki and Ward Cunningham's original explanation of the debt metaphor.

Taggedscripting
Share

Keep reading

Get new posts by email

One email when a new post is published. No spam.

Unsubscribe in one click. No spam, ever.