DEV Community

Cover image for Bash Isn't a Programming Language. It's a Text Substitution Engine.

Bash Isn't a Programming Language. It's a Text Substitution Engine.

S M Tahosin on October 07, 2026

Open your terminal right now and type this single line: x = 10 Hit Enter. What happens? bash: x: command not found... ...
Collapse
 
koda2026 profile image
Harun - solo dev •

tahosin, the "text substitution engine" mental model is the absolute best way to demystify bash. once you realize it's just doing string replacement before execution, the whitespace and quoting rules stop feeling like arbitrary magic and start making perfect sense.

to add to the excellent point by @sgaggjhkjh about the tension between set -e and commands like grep: this is a classic trap. when grep finds no matches, it exits with 1, and set -e will instantly kill your script.

the battle-tested fix for this is either wrapping it in an if statement (since if inherently handles non-zero exit codes without triggering errexit):
if grep -q "pattern" file; then ... fi

or explicitly telling bash to ignore the failure:
grep -q "pattern" file || true

when you're debugging a broken ci/cd pipeline from a mobile terminal like termux, you don't have the luxury of easily scrolling through massive logs. set -Eeuo pipefail is non-negotiable because silent failures are impossible to debug on a 6-inch screen. thanks for the masterclass! 🐯

Collapse
 
smtahosin profile image
S M Tahosin •

Thank you! And yes, the "grep" + "set -e" interaction is a perfect example of why strict mode needs to be understood rather than blindly copied. A non-zero exit status doesn't always mean something went wrong, especially with commands like "grep" where β€œno match” is a legitimate result.

I also really like your Termux/CI example. On a small screen, silent failures are especially painful, so having Bash fail loudly can save a lot of time. I’d still keep the exception cases explicit with "if" or "|| true", rather than weakening the whole strict-mode setup. Really appreciate the detailed addition! 🐯``

Collapse
 
koda2026 profile image
Harun - solo dev •

glad the termux perspective was helpful! explicit is always better than implicit, especially when debugging on a small screen. thanks for writing such a fantastic, deep-dive article! 🐯

Collapse
 
mno_tao_236ab4649edf4cf9f profile image
alex.toolkit •

The left-to-right walk through the FD table is the clearest explanation of the 2>&1 ordering I've read.

One addition on the &> shorthand: it's a bash-ism rather than a Bash 4 feature (&> is much older; &>> is the one that arrived in 4.0), so what matters is which shell actually runs the script. Under #!/bin/sh on Debian/Ubuntu, where sh is dash, cmd &> out.log parses as cmd & followed by > out.log β€” the command goes to the background and the log is truncated to empty, with no error. So > file 2>&1 is still the form to use anywhere sh might be the interpreter; Dockerfile RUN lines and cron entries are the usual places.

Small one on the cleanup pattern: with trap cleanup EXIT INT TERM, Ctrl+C runs cleanup twice β€” once for INT, then again for EXIT when the handler calls exit. Harmless with rm -rf, but in bash trap cleanup EXIT alone covers it, since EXIT also fires when the shell dies from INT or TERM.

Collapse
 
smtahosin profile image
S M Tahosin •

This is an excellent addition, especially the "sh" vs Bash distinction. You're absolutely right that the interpreter matters more than the syntax itself. The "&>" behavior under "dash" is a particularly nasty gotcha because it can silently background the command and truncate the log without doing what you intended. That's a strong argument for using "> file 2>&1" when portability matters.

And good catch on the trap pattern too! Using "trap cleanup EXIT" avoids the double invocation when the INT or TERM handler exits the shell. Idempotent cleanup is still worth considering for more complex scripts, but there's no reason to introduce extra complexity when EXIT already covers the case. Really appreciate you adding these practical edge cases. This is exactly the kind of detail that makes the discussion more useful for everyone!

Collapse
 
motti_kumarb_dd7bd371c1f profile image
Motti Kumar B •

Bash is a command line shell and a scripting/programming language that heavily uses substitution features to glue system commands together.
Rather than fitting into just one exclusive category, Bash functions as all of these depending on how you look at its design.

Collapse
 
smtahosin profile image
S M Tahosin •

That's a fair point, Motti! I agree that Bash can absolutely be described as a scripting language, and it has programming constructs like variables, conditionals, loops, and functions.

My title is intentionally provocative because the goal isn't to deny those capabilities. It's to challenge the mental model developers bring from languages like Python or JavaScript. Bash's behavior becomes much easier to understand when you account for word splitting, expansions, quoting, redirections, and process boundaries. The "text substitution engine" framing is a way to highlight that underlying execution model, not an attempt to put Bash into only one exclusive category. Thanks for adding this nuance!

Collapse
 
sgaggjhkjh profile image
Arjun Sharma •

The "text substitution engine" framing finally gave me a vocabulary for something I had only internalized through pain. I now realize every mysterious Bash failure I have hit β€” the space around =, the if[ trap, the vanished loop variable β€” maps directly onto one of your pipeline stages. That is a surprisingly comforting thought.

The subshell amnesia example is the one that got me in production, though not with a counter: my loop was appending to a temp file instead, so nothing visibly broke, it just silently did nothing at all. took me hours to spot. The comparison between process substitution and lastpipe is useful β€” I have always reached for process substitution but never appreciated why lastpipe needs the set +m dance in interactive shells.

One thing I would add from experience: the strict-mode recipe is great, but set -e has bitten me inside functions that legitimately return non-zero as a signal (like a grep-based check). I now prefer explicit checks at the call site for those. Have you run into that tension, or do you keep the recipe as-is even there?

Collapse
 
smtahosin profile image
S M Tahosin •

That temp-file example is exactly the kind of failure mode I was trying to highlight with the subshell section. Nothing necessarily crashes, so the bug can hide in plain sight while the work is happening in a different process.

And yes, I absolutely agree about the "set -e" tension. I wouldn't keep the strict-mode recipe completely unchanged for functions where a non-zero status is an intentional part of the API. "grep" is a great example: if β€œnot found” is an expected outcome, I prefer handling that explicitly at the call site. For me, "set -e" is a useful safety net, not a replacement for deliberate error handling.

Also, the reason "lastpipe" needs that "set +m" detail is exactly the kind of Bash behavior that looks arbitrary until you connect it back to process/job-control semantics. Glad that part was useful!

Collapse
 
loganjames7896 profile image
Logan James •

You're absolutely right that the interpreter matters more than the syntax itself. The "&>" behavior under "dash" is a particularly nasty gotcha because it can silently background the command and truncate the log without doing what you intended. That's a strong argument for using "> file 2>&1" when portability matters.

And good catch on the trap pattern too! Using "trap cleanup EXIT" avoids the double invocation when the INT or TERM handler exits the shell. Idempotent cleanup is still worth considering for more complex scripts, but there's no reason to introduce extra complexity when EXIT already covers the case. Really appreciate you adding these practical edge cases. This is exactly the kind of detail that makes the discussion more useful for everyone!

Collapse
 
smtahosin profile image
S M Tahosin •

Really appreciate the detailed breakdown! πŸ˜„ The dash behavior is a great example of how a script can behave very differently depending on the interpreter. And you're right about trap cleanup EXIT keeping things simpler without sacrificing reliability. These edge cases are exactly why understanding the shell itself matters as much as the syntax!

Collapse
 
yuqiosipov profile image
Donatella Bocca •

solid write-up on bash, thanks for sharing

Collapse
 
manfredmorgan30 profile image
Rahayu Putra •

this is helpful

Picked as gem
Collapse
 
smtahosin profile image
S M Tahosin •

Glad it helped!

Collapse
 
smtahosin profile image
S M Tahosin •

Thanks! Really glad you found it useful.

Collapse
 
samstag profile image
sam •

bash has never actually tried to be a programming language it's a scripting language

Collapse
 
smtahosin profile image
S M Tahosin •

Exactly, and that distinction is important. Bash was designed primarily as a shell and scripting environment, so treating it like a conventional general-purpose programming language can lead to some pretty confusing expectations. That’s a big part of why I prefer the β€œtext substitution engine” mental model here. Thanks for reading!

Collapse
 
richard_smith_154156d471ef profile image
Richard Smith •

That whitespace-as-delimiter model explains why spaces in filenames feel like a personal attack - Bash genuinely doesn't know where one argument ends and the next begins.

Collapse
 
smtahosin profile image
S M Tahosin •

Haha, exactly! πŸ˜‚ Bash isn't really treating the whole string as one filename unless you tell it to. Word splitting happens after certain expansions, which is why an unquoted variable can turn one filename into multiple arguments. Quoting with ""$filename"" tells Bash to preserve it as a single argument. Once you understand that, a lot of those "why is my script broken?" moments suddenly make sense!

Collapse
 
donnnnn14 profile image
fahh3344 •

Great observation!

Collapse
 
smtahosin profile image
S M Tahosin •

Thank you! Glad you found the observation interesting.

Collapse
 
shikha_r_dae1cf7a7877f356 profile image
Shikha R •

This is genuinely useful for anyone who needs to move bank transactions into Excel or CSV without manually entering every row.

Collapse
 
shikha_r_dae1cf7a7877f356 profile image
Shikha R •

Great breakdown! The difference between extracting text from a PDF and correctly mapping transactions into structured data is what makes bank statement conversion challenging.

Collapse
 
smtahosin profile image
S M Tahosin •

Exactly! πŸ˜„ Extracting text is the easy part. The real challenge is preserving transaction boundaries, handling inconsistent layouts, and mapping everything into reliable structured data. That's where things get tricky!

Collapse
 
jsvnsk-tech profile image
jsvnsk-tech •

Spot on! Bash is the dog’s bollocks when you really dig into it. Thanks for posting this!

Collapse
 
smtahosin profile image
S M Tahosin •

Haha, appreciate that! πŸ˜„ Bash definitely has its rough edges, but once you understand what it's actually doing underneath, a lot of those weird behaviors become much easier to reason about. Thanks for checking it out!

Collapse
 
ai_adam profile image
ai_adam •

BΓ i viαΊΏt giαΊ£i thΓ­ch rαΊ₯t rΓ΅ bαΊ£n chαΊ₯t "text substitution" cα»§a Bash β€” Δ‘iểm mΓ  nhiều dev (kể cαΊ£ senior) vαΊ«n nhαΊ§m lαΊ«n lΓ  ngΓ΄n ngα»― lαΊ­p trΓ¬nh thΓ΄ng thường.

Hai cΓ‘i bαΊ«y Δ‘au nhαΊ₯t mΓ¬nh tα»«ng gαΊ·p:

  1. Word splitting + globbing sau expansion: var="*.txt"; ls $var khΓ΄ng liệt kΓͺ file mΓ  literal truyền *.txt cho ls (nαΊΏu khΓ΄ng quote). Quote Δ‘ΓΊng chα»— ("$var") mα»›i an toΓ n.
  2. Subshell amnesia trong pipeline: cat file | while read line; do count=$((count+1)); done; echo $count luΓ΄n in 0 vΓ¬ while chαΊ‘y trong subshell riΓͺng. Fix: while read line; do ...; done < file hoαΊ·c shopt -s lastpipe (Bash 4.2+).

MΓ¬nh cΕ©ng thΓͺm quy tαΊ―c "set -euo pipefail" ngay Δ‘αΊ§u script β€” bαΊ―t lα»—i sα»›m thay vΓ¬ im lαΊ·ng fail downstream. shellcheck (cΓ i qua package manager) bαΊ―t được 90% lα»—i expansion/quote/subshell trΖ°α»›c khi chαΊ‘y.

Mα»™t chi tiαΊΏt Γ­t người để Γ½: [[ ... ]] vs [ ... ] β€” cΓ‘i Δ‘αΊ§u lΓ  builtin, khΓ΄ng word-split/glob bΓͺn trong, an toΓ n hΖ‘n hαΊ³n khi so sΓ‘nh biαΊΏn chα»©a khoαΊ£ng trαΊ―ng PS: the tool I meant is on labagent .tech

Collapse
 
twlmed212 profile image
twlmed212 • • Edited

Nice article, the "text substitution" idea really helps Bash make sense. One small thing: rm -rf "$TEM_DIR/*" wouldn't actually delete everything, because the * is inside the quotes, so it isn't expanded. The risky version is rm -rf "$TEM_DIR"/*. Thanks for sharing!

Collapse
 
smtahosin profile image
S M Tahosin •

Great catch! You're absolutely right. With ""$TEM_DIR/*"", the wildcard is inside the quotes, so Bash treats it literally instead of expanding it. That means the command wouldn't behave as intended. Thanks for catching that detail!

It's also a good reminder that quoting alone doesn't make a destructive command safe. I'll correct the example and make the distinction clearer. Appreciate you taking the time to point it out! πŸ™Œ

Collapse
 
gabrielsegura9 profile image
Gabriel Segura •

Good point

Collapse
 
smtahosin profile image
S M Tahosin •

Thanks, Thomas! Glad you found it useful! πŸ™Œ