Open your terminal right now and type this single line:
x = 10
Hit Enter. What happens?
bash: x: command not found...
...
For further actions, you may consider blocking this person and/or reporting abuse
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 -eand commands likegrep: this is a classic trap. whengrepfinds no matches, it exits with 1, andset -ewill instantly kill your script.the battle-tested fix for this is either wrapping it in an
ifstatement (sinceifinherently handles non-zero exit codes without triggeringerrexit):if grep -q "pattern" file; then ... fior explicitly telling bash to ignore the failure:
grep -q "pattern" file || truewhen 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 pipefailis non-negotiable because silent failures are impossible to debug on a 6-inch screen. thanks for the masterclass! π―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! π―``
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! π―
The left-to-right walk through the FD table is the clearest explanation of the
2>&1ordering 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/shon Debian/Ubuntu, where sh is dash,cmd &> out.logparses ascmd &followed by> out.logβ the command goes to the background and the log is truncated to empty, with no error. So> file 2>&1is still the form to use anywhere sh might be the interpreter; DockerfileRUNlines and cron entries are the usual places.Small one on the cleanup pattern: with
trap cleanup EXIT INT TERM, Ctrl+C runscleanuptwice β once for INT, then again for EXIT when the handler callsexit. Harmless withrm -rf, but in bashtrap cleanup EXITalone covers it, since EXIT also fires when the shell dies from INT or TERM.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!
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.
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!
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?
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!
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!
Really appreciate the detailed breakdown! π The
dashbehavior is a great example of how a script can behave very differently depending on the interpreter. And you're right abouttrap cleanup EXITkeeping things simpler without sacrificing reliability. These edge cases are exactly why understanding the shell itself matters as much as the syntax!solid write-up on bash, thanks for sharing
this is helpful
Glad it helped!
Thanks! Really glad you found it useful.
bash has never actually tried to be a programming language it's a scripting language
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!
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.
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!
Great observation!
Thank you! Glad you found the observation interesting.
This is genuinely useful for anyone who needs to move bank transactions into Excel or CSV without manually entering every row.
Great breakdown! The difference between extracting text from a PDF and correctly mapping transactions into structured data is what makes bank statement conversion challenging.
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!
Spot on! Bash is the dogβs bollocks when you really dig into it. Thanks for posting this!
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!
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:
var="*.txt"; ls $varkhΓ΄ng liα»t kΓͺ file mΓ literal truyα»n*.txtcho ls (nαΊΏu khΓ΄ng quote). Quote ΔΓΊng chα» ("$var") mα»i an toΓ n.cat file | while read line; do count=$((count+1)); done; echo $countluΓ΄n in 0 vΓ¬whilechαΊ‘y trong subshell riΓͺng. Fix:while read line; do ...; done < filehoαΊ·cshopt -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 .techNice 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 isrm -rf "$TEM_DIR"/*. Thanks for sharing!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! π
Good point
Thanks, Thomas! Glad you found it useful! π