Bashis the fifth most-used programming language in the world, and the one that most programmers refuse to take seriously. This must change.
Nearly half the developers on Earth write Bash. Almost all of them write it badly. I mean, really badly. That is the whole argument, and everything below is commentary.
Some numbers first: According to the 2025 Stack Overflow Developer Survey, 48.7% of developers use Bash. Not “have used at some point”. They use it, actively, as part of their working lives. That puts Bash fifth in the world, behind only JavaScript, HTML/CSS, SQL and Python, and comfortably ahead of TypeScript. Fifth. A rounding error below a language everyone agrees is “serious”.
Now the other number. A 2022 study in ACM Transactions on Software Engineering and Methodology took 1.35 million Bash scripts off GitHub and ran them through static analysis. Eighty per cent carried code smells. Not obscure ones: quoting failures, word-splitting bugs, missing error handling, sloppy structure. The absolute fundamentals of the language, got wrong, at scale. I have been writing Bash professionally for over thirty years, and I can tell you the eighty per cent figure is, if anything, generous.
So here is the paradox. A language used by more developers than TypeScript, running on nearly every web server on the planet, present in every CI/CD pipeline and every container ever built, is treated with less engineering discipline than a weekend Python project written for fun. The tool that deploys the serious code is the one nobody takes seriously. Ridiculous.
Modern Bash Is Not What You Think
The objection I hear most often is that Bash is “just a shell”. A command-line convenience, not a real language. This is usually delivered with a dismissive little wave, as though the matter were settled decades ago and no further thought is required. The people who say it are picturing Bash circa 1995. They are not wrong about 1995; they are badly wrong about now.
And to be clear, I am not defending sh, nor the
fossilised Bash 3.2 that macOS still ships because Apple will not touch
a GPLv3 licence. I am talking about modern Bash, 4.0 and above, the
language as it actually exists on every Linux system built in the last
fifteen years.
Consider what has been added while nobody was looking. Bash 4.0
(2009) brought associative arrays, coprocesses and mapfile.
Bash 4.3 added name references (declare -n), giving the
language proper pointer-like indirection. Bash 5.0 brought epoch-second
timestamps as a builtin. Bash 5.2 (2022) threw out the old ad-hoc
command-substitution parser and replaced it with a recursive bison
grammar. And Bash 5.3 (2025) introduced no-fork command substitution,
${ cmd; }, which captures a command’s output without
spawning a subshell at all. That last one is a genuine performance
primitive (and the sort of thing you would expect a language committee
to boast about).
Modern Bash has typed variables via declare, lexical
scoping via local, strict error handling via
set -euo pipefail, regular-expression matching via
=~, and signal handling via trap. It is
Turing-complete, and academic researchers at POPL
now treat it as a language worthy of formal analysis. The language grew
up, but the people using it did not.
The Objection That Actually Deserves an Answer
“Just a shell” is a lazy jab, and easily swatted. There is a far better objection, and its most respectable form is Google’s. The Google Shell Style Guide states, more or less, that once a script grows past a hundred lines or reaches any real complexity, you should rewrite it in a structured language. Plenty of sensible engineers treat this as settled law.
It is worth taking seriously, and then it is worth dismantling, because the reasoning is backwards. The hundred-line rule is not an observation about Bash. It is a confession about the Bash its authors had seen. They watched undisciplined shell scripts collapse somewhere north of a hundred lines (no error handling, no functions, global variables everywhere, quoting held together with hope) and concluded that the collapse was a property of the language. It was not. It was a property of the scripts. A hundred-line ceiling on Bash is just a speed limit set by the worst driver on the road.
I have production Bash well past a thousand lines that has run without incident for years, because it was written the way you would write anything else: functions, strict mode, local variables, input validation, tests. The ceiling moves the moment the discipline arrives.
Whilst I am on the attack, let me concede the genuine ground, because
a rant that admits nothing persuades nobody. Bash has limits, and they
are worth knowing precisely, if only so you stop attacking the fake
ones. Its data structures stop at scalars, indexed arrays and
associative arrays, and crucially they do not nest: no arrays of arrays,
no records, no structs. Pass anything structured between functions and
you are serialising strings and praying. It has no namespaces; every
function lives in one global pool, and name collisions are your problem
to manage. Native arithmetic is integer-only, so floating point means
shelling out to bc or awk. And
set -e, the thing keeping your script honest, has enough
documented edge cases to fill an afternoon. These are the real
constraints. Respect them, and you will know when Bash is the wrong
tool.
But notice what is not on that list. “Untyped.” The critics
love to call Bash untyped, and it is the tell that they have never
actually read the manual. declare -i gives you a real
integer. Arithmetic evaluation with (( )) treats your flags
as the booleans they are, and it is measurably faster than the string
comparisons most people reach for. while ((1)) beats
while true by a whopping margin over a thousand iterations,
because one is pure arithmetic and the other is a command lookup.
Integer and boolean typing is among the most underused optimisations in
the entire language. The people dismissing Bash as a typeless toy are
not describing Bash; they are describing the extent of their own
reading.
Where Bash Actually Wins
The case for Bash is practical, and the numbers are specific. Bash starts in roughly 2.8 milliseconds. Python takes around 11, four times longer, and often much worse once imports land. A minimal Bash container image runs about 12 megabytes; Python on Alpine runs about 60. When you are building minimal containers, deploying to constrained hardware, or spawning thousands of short-lived processes, none of that is academic.
More important than any of it: Bash has zero dependencies. No runtime
to install, no package manager to configure, no virtual environment to
activate, no lockfile, no supply chain to audit for the language itself.
Compare a “simple” Python deployment script that quietly drags in pip,
virtualenv, a pinned requirements file, and a prayer that none of it
collides with the system Python half your other tools depend on. Anyone
who has lost an afternoon to a ModuleNotFoundError in a
pipeline that worked yesterday knows the shape of this particular hell.
Bash does not have the problem. It does not have dependencies. It
is the dependency.
In air-gapped networks, in regulated shops, in minimal containers where every megabyte is counted, Bash is the language that is already present when nothing else is permitted to be. On the overwhelming majority of the world’s web servers, on cloud virtual machines, in every GitHub Actions runner and every GitLab executor and every Jenkins agent, it is already there, waiting.
And people build serious things with it. nvm, the Node Version Manager, has over 92,000 GitHub stars and is written entirely in Bash. acme.sh, the pure-shell ACME client issuing TLS certificates across the internet, has more than 46,000. pyenv, pi-hole, dokku: all Bash, all critical infrastructure, all used by millions daily. These are not toys.
The “right tool for the right job” line, so often deployed against
Bash, actually favours it. If you are orchestrating CLI commands,
wrangling files, composing pipelines, or scripting a container
entrypoint, Bash is the native language of the problem. Wrapping
aws, kubectl and terraform inside
Python’s subprocess.run() buys you indirection and a
dependency tree and nothing else. The command line was built for the
shell. Use it, ffs!
Bad Bash in the Wild
OK, enough theory. If eighty per cent of Bash is bad, where is it? It is on the machine in front of you, shipped by the largest names in software, running as root.
I went looking on a stock Ubuntu system. Start with the package
maintainer scripts, the postinst files that run
automatically, as root, every time you install or upgrade software. I
ran ShellCheck (the standard static analyser for shell) across four
hundred of them. One hundred and six had findings. Five hundred and
sixty-nine findings in total. The worst were not obscure packages: GRUB,
the bootloader that decides whether your machine starts, with
thirty-nine; CUPS with thirty-five; MySQL with thirty-one; Microsoft
Edge with twenty-seven; Google Chrome with eighteen.
Pick one line, from MySQL’s maintainer script, which handles the database’s own administrative password:
pass="`sed -n 's/^[ ]*password *= *// p' $dc | head -n 1`"
I mean, wtaf?! Legacy backtick substitution (deprecated for two
decades), an unquoted $dc that will break the moment a path
contains a space, and this is the code extracting a password on a
production database, as root. Nobody ran a linter over it. Nobody had
to; it is “just a script”.
Now, a specimen I should frame and hang on a wall:
dovecot-sysreport ships with Dovecot, the mail server
running a serious fraction of the world’s IMAP. It is not some
deprecated corner: it lives in Ubuntu’s supported main
repository, it was security-patched this release cycle, and it is the
tool Dovecot asks users to run and attach to bug reports. Its output
goes straight to Dovecot’s own developers. Seventy ShellCheck
findings in two hundred and sixteen lines. And reading the whole thing,
rather than the linter summary, turns up three bugs that no static
analyser would phrase for you.
The first is a function that hunts through Dovecot’s config for passwords and redacts them before the report is sent:
unwrap_and_hide_pass () {
files=`grep ... $1 ...` # global, not local
for cf in $files; do # cf global, not local
if [ -r "$cf" ]; then
unwrap_and_hide_pass $cf # recurses, clobbering cf
mkdir -p $SRTEMP/conf"$(dirname "$cf")" # uses cf AFTER the call
The function recurses on nested config includes, but neither
files nor the loop variable cf is declared
local. So the recursive call overwrites its caller’s
cf, and every use of cf after that call (the
directory creation, the file that gets read and redacted) operates on
the wrong path. A password-hiding routine that, on any configuration
with nested includes, silently mishandles the very files whose passwords
it exists to hide!
The second: the cleanup routine that deletes the temporary working
directory is installed with trap on the second-to-last line
of the script, after every temporary file has already been created and
used. Any failure in the two hundred lines before it (a missing binary,
an unreadable core file, a full disk) leaves the temp directory orphaned
on disk. A bug-reporting tool that litters the moment it hits a bug.
The third is a parse ambiguity so fine that ShellCheck flags it as a
hard error, not a warning: $((echo ...) at the start of a
line, where the author meant a command substitution wrapping a subshell
but wrote something Bash is entitled to read as arithmetic. It works
today by luck of what follows it.
If you want the failure demonstrated rather than described,
zipgrep obliges. It ships with Info-ZIP, it has been on
essentially every Unix machine for thirty years, and it searches inside
Zip archives. Watch:
$ unzip -Z1 glob.zip # the archive holds ONE member, named:
*.txt
$ zipgrep secret glob.zip # and "secret" is genuinely inside it
caution: filename not matched: my notes.txt
caution: filename not matched: plain.txt
$ echo $?
1
The pattern is in the archive. zipgrep reports no match.
The bug is a single unquoted variable in a for loop: the
member name *.txt underwent filename expansion against the
current directory, so the script went looking for files that were never
in the archive and never searched the one that was. Same command,
different working directory, different answer. To its modest credit the
author had defended against spaces in filenames; the glob slipped
through anyway. Folk knowledge does not scale. Discipline does.
I rewrote zipgrep to the Bash Coding Standard, in a
comparable number of lines, and it is ShellCheck-clean. Same directory,
same archive:
$ zipgrep-bcs secret glob.zip # the disciplined version
*.txt:secret pattern
Correct. The fix was not cleverness. It was quoting a variable and using an array where the original spliced a string. The discipline is the whole difference.
And then there is the specimen that ought to end the argument.
rkhunter is a rootkit and exploit scanner. Its entire
purpose is to detect that your machine has been compromised, and it runs
as root to do it. Twenty-one thousand lines of Bash, three thousand and
twenty-five ShellCheck findings, of which two thousand two hundred and
eighty-six are the same two schoolboy errors the ACM study named:
unquoted variables and legacy backticks. Some of that is age, and
honesty compels me to say the tool has not been seriously maintained
upstream since around 2018. But age does not excuse this, from the
routine that hunts for one particular rootkit:
if `cd ${ROOTKIT_PHALANX2_DIRNAME} >/dev/null 2>&1`; then
That executes the output of cd as a command.
The redirect inside the backticks swallows everything, so the condition
tests an empty string, and the check works only by an obscure accident
of how the shell assigns an exit status to an empty command. A security
scanner, running as root, defending your machine against attackers,
built on the exact command-injection-shaped idioms that let attackers
in. Jesus wept…
Now the counterpoint, because it is the most important paragraph
here. I ran the same linter over the famous install scripts you are told
to curl into your shell. Docker’s get.docker.com: zero
findings. Homebrew’s installer: zero. RVM’s: zero. That is the whole
thesis in one table. Disciplined Bash at scale is not a fantasy; Docker
and Homebrew do it every day. When MySQL, Google, Microsoft and a
root-run security scanner ship the sloppy version, the language is not
what failed them. Their damn attitude to the language is.
The Shebang Nobody Tests
A quick detour, because it is the same disease one layer down. Look
again at Microsoft Edge’s maintainer script, and at Debian’s own
select-editor, the utility every new Ubuntu user runs to
pick a text editor. Both declare #!/bin/sh. Both then use
local, which is not part of POSIX sh at
all.
They run anyway, and only by accident. On Debian and Ubuntu,
/bin/sh is a symlink to dash, and
dash happens to implement local even though
the standard it claims to obey does not require it. Change that symlink
to a stricter shell (busybox ash in an Alpine container,
say) and the script dies. The #!/bin/sh line is a promise
of portability that nobody ever tested. It is the identical failure to
the one this whole rant is about: declare one contract, write to a
different one, and stay upright purely because of which binary the
symlink points at today. ShellCheck will check the promise for you in
one command (shellcheck -s sh). Microsoft, Debian and the
rest do not bother. Two symlink targets, two outcomes, zero
discipline.
The Real Problem
So if Bash is this capable and this ubiquitous, why is so much of the code this poor?
Because Bash is the only mainstream language most developers learn
entirely by copying from Stack Overflow (and guess where our LLMs
trained). There is no university course in shell scripting. There is no
onboarding module, no equivalent of Python’s PEP 8 or Go’s
gofmt that a new hire meets in their first week. Nobody
sits a junior engineer down and explains how to write a proper script.
They hand over a broken deployment pipeline and wish them luck.
And when programmers go looking for guidance, what they find is worse
than silence. The top-ranked Bash tutorials on the open web
(freeCodeCamp, W3Schools, GeeksforGeeks, TutorialsPoint) uniformly teach
unquoted variables, single-bracket conditionals, and scripts with no
error handling whatsoever. Not one of them so much as mentions
set -euo pipefail. The venerable TLDP Advanced
Bash-Scripting Guide, which shaped a generation, still presents backtick
substitution as primary syntax and has not been updated since 2014.
These are not fringe pages. They are the front page of Google, and they
are teaching the language broken.
The result is cargo-cult scripting: patterns copied without comprehension, errors bred through generations of tutorial sites, and a collective shrug when something breaks, because “it’s just a script”.
That shrug has a price. On 30 October 2023, Cloudflare suffered a 37-minute outage of Workers KV, the key-value store underpinning a long list of their products. The root cause, in their own words, was that “a script that had been deployed to the staging account was incorrectly referenced instead of the pre-production version on the production account”. Not a subtle distributed-systems failure. A script that pointed at the wrong environment, reviewed with nothing like the rigour a Python module would have earned.
“It’s just a script” may be the most expensive sentence in DevOps. Every pipeline, every deployment workflow, every container entrypoint, every cron job is “just a script” right up until it takes production down. The same engineer who would never push an untested Python function will happily commit a two-hundred-line deployment script with no error handling, no input validation and no tests, and would sooner resign than skip code review on a Go service. But the Bash that actually ships that Go service? A glance, if that. The double standard is indefensible.
Getting Serious
The tools already exist, and they are free. ShellCheck, with over 37,000
GitHub stars, catches quoting errors, undefined variables and the common
traps before they reach production. Strict mode
(set -euo pipefail) converts Bash from a language that
shrugs off errors into one that fails fast and loud. bats-core brings
structured testing to shell. shfmt enforces consistent style.
Every one of the disasters above would have been caught, in seconds, by
a tool that was sitting right there.
But ShellCheck is just the floor, not the ceiling. It will flag the
unquoted $dc in MySQL’s password line. But it will not tell
you that dovecot-sysreport’s recursive redactor corrupts
its own targets, or that its cleanup trap is installed too late to ever
fire, because those are not lexical mistakes; they are failures of
structure, and structure is exactly what a coding standard is for. This
is why we wrote the Bash
Coding Standard and built an LLM-backed checker to enforce it: to
catch the class of bug that lives above the linter’s reach. When I ran
that checker over dovecot-sysreport, it named all three of
those bugs, in a security-sensitive tool, in Ubuntu’s supported
repository, shipping today. Passing ShellCheck is absolutely necessary,
but it is not sufficient. Get serious about both shellcheck and
bcscheck.
The rule is not complicated, and it is this. If you would not ship an
unreviewed Python function, do not ship an unreviewed Bash script. If
you would not write Go without go vet, do not write Bash
without ShellCheck. If you would not deploy a Java service without
tests, do not deploy the deployment script without tests. The script
that deploys the service is not beneath the standard you hold the
service to. It is the thing that decides whether the service runs at
all.
Bash is not going away. It is the fifth most-used language in the world, it runs on nearly every server you own, and it is the connective tissue of your entire infrastructure. It has earned engineering discipline. The tools to enforce this discipline are already installed on the machine you are reading this on.
Gary Dean is the principal of Okusi Associates and co-founder of YaTTI (Indonesian Open Technology Foundation), and co-creator of the Bash Coding Standard.
Gary Dean