Module 3: Pipes, Redirection, and Composition
7. Exit codes and chaining: $?, &&, ||, and ;
Description
By the end of this lesson you will be able to read the number any command leaves behind when
it finishes, use that number to decide whether a chain's next step should run or not, and
choose between ;, &&, and || based on what you actually want to happen when something
fails — instead of assuming everything went fine because the terminal did not show anything in
red.
This is the skill that separates a one-liner from real automation. A deployment script that
runs git pull; npm run build; systemctl restart myapp with no check between steps restarts
the service even if the previous build failed halfway through. The same chain written with
&& stops at the first failure. The difference between those two lines — one character — is
the difference between a safe deployment and a night spent fixing production.
Connection to the module: you already know how to build pipelines that count, sort, and transform text (previous lesson). Now you are going to learn to ask each link in that pipeline, or any command, "did you really work?", and to react based on the answer. It is the missing piece before this module's project, and the foundation for everything you are going to script in module 5.
The mental model: every command hands you a receipt, not just a result
When you take cash out of an ATM, the screen shows you the bills, but it always also hands you a receipt: approved, insufficient funds, card declined. You almost never read it because it almost always says "approved." But it is there, with a specific code, every time you use the machine.
Every command you run in the terminal does the same thing: besides whatever it prints on screen (or not printing anything), it hands the shell a number between 0 and 255 when it finishes. That number is its exit code (exit status). By universal Unix convention, 0 means success and any value other than 0 means something failed, and often the specific number tells you what failed.
The shell always stores the last command's exit code in the $? variable. You check it with
echo $? right after the command you care about: anything else you run in between, including
another echo, overwrites that value with its own.
Worked example
grep is a good starting point because it precisely distinguishes three different situations
using three codes:
grep "localhost" /etc/hosts
echo $?
What to expect:
127.0.0.1 localhost
::1 localhost
0
It found matches: code 0. Now a search that finds nothing:
grep "not-present-in-this-file" /etc/hosts
echo $?
What to expect: no line printed by grep — no matches — and then:
1
It is not a real error: grep did its job perfectly, it searched and did not find anything.
Code 1 is its way of telling you that. Now a real error, a file that does not exist:
grep "localhost" /no/such/file.log
echo $?
What to expect:
grep: /no/such/file.log: No such file or directory
2
Three questions, three different answers: 0 (I found it), 1 (I searched and there was
none), and 2 (I could not even search). No on-screen message gives you that distinction with
the same precision as $?.
; — chains without asking questions
; runs one command after another no matter how the first one ended. It is, literally, "do
this, and then this other thing, no matter what."
mkdir project; cd project; git init
If mkdir fails because the directory already exists, the shell still tries cd project, and
git init runs there anyway. When the steps are independent, or one's failure does not change
what you want from the next, ; is exactly what you need.
The problem shows up when the steps do depend on each other, and using ; instead of &&
can destroy data:
cd /var/www/old-project-v1; rm -rf *
What to expect if the directory does not exist (a typo, it already got deleted, different permissions than you thought): something like
cd: /var/www/old-project-v1: No such file or directory
And after that message, rm -rf * still runs, because ; does not check whether cd
worked. Except now you are not in /var/www/old-project-v1: you are still in whichever
directory your terminal was standing in before you ran the line. If that was your working
folder, your home, or anywhere with files you care about, rm -rf * deletes all of them,
with no prompt and no recycle bin. This is, literally, one of the most documented and most
expensive terminal error patterns in Unix history.
The fix is a two-character word:
cd /var/www/old-project-v1 && rm -rf *
&& — only if the previous one worked
&& runs the second command only if the first one ended with code 0. In the example
above: if cd fails (a code other than 0), rm -rf * never runs. The shell cuts the chain
at the first broken link.
cd /var/www/old-project-v1 && rm -rf *
What to expect if the directory does not exist: the same cd error message as before, and
that is where it ends. No file gets touched, because rm never got to run.
&& is also the right way to chain a multi-step automation where each step depends on the
previous one:
git pull && npm run build && systemctl restart myapp
What to expect if git pull fails (no connection, a merge conflict): neither npm run build nor systemctl restart runs. The service keeps running with the code it already had,
instead of restarting with an incomplete or nonexistent build.
|| — only if the previous one failed
|| is &&'s mirror: it runs the second command only if the first one ended with a code
other than 0. It is the tool for reacting to a failure: a plan B, an alert, a diagnostic
message.
tar -czf backup.tar.gz /data || echo "ERROR: backup failed" >> alert.log
What to expect if tar works: nothing gets added to alert.log; the echo never runs,
because tar ended with code 0.
What to expect if tar fails (full disk, permissions, a path that does not exist): the
error line gets logged into alert.log, right at the moment the problem happened, instead of
you finding out hours later by accident.
Grouping with () and { }
Sometimes you need several commands to count as a single unit in front of an &&, an
||, or a redirection. Bash and zsh give you two ways to group, and they are not
interchangeable.
() groups in a subshell: the commands run in a separate child process. Any cd or
variable you change in there does not affect your current shell once the group ends.
pwd
(cd /tmp && pwd)
pwd
What to expect:
/Users/your-user
/tmp
/Users/your-user
You entered /tmp only inside the parentheses; your real shell never moved from where it was.
It is useful when you want to "go, do something, and come back" without remembering to write a
cd back.
{ } groups in your current shell: with no new process created, so a cd or a variable
defined inside it does persist outside. The syntax requires a space after { and a ;
before }; without that, the shell cannot tell where the block ends.
pwd
{ cd /tmp && pwd; }
pwd
What to expect:
/Users/your-user
/tmp
/tmp
This time the cd did stick: your shell ended up standing in /tmp.
Both forms share something useful: the group's exit code is the one from the last command
that actually ran inside it, so you can chain an && or an || after the entire group:
(mkdir build && cd build && make) || echo "build failed at some step"
If mkdir fails, cd and make never run, the group ends with mkdir's code (other than
0), and the echo outside fires.
Common exit codes: the table you need
There is no universal standard assigning a fixed meaning to every number: each program decides
which code it returns for each situation (you already saw grep uses 2 for "could not even
search," but other programs use 2 for something else). However, the shell and the operating
system do reserve a handful of codes with a consistent meaning, no matter which program
produces them:
| Code | Meaning |
|---|---|
0 | Success. The command did what it was supposed to do. |
1 | General failure. The exact meaning depends on the program (for grep, "no matches"; for many others, a generic error). |
2 | Incorrect command usage, or an error before even starting (invalid argument, nonexistent file). |
126 | The command was found, but could not be run; typically it is missing the execute permission. |
127 | The command was not found: a typo, something not installed, or something outside the PATH. |
130 | The process ended because you sent it Ctrl+C (the SIGINT signal). |
The last three can be reproduced from memory:
printf '#!/bin/bash\necho "done"\n' > deploy.sh
chmod -x deploy.sh
./deploy.sh
echo $?
What to expect (in zsh, macOS's default shell):
zsh: permission denied: ./deploy.sh
126
(In bash the message says bash: ./deploy.sh: Permission denied; the number is the same.)
notarealcommand123
echo $?
What to expect:
zsh: command not found: notarealcommand123
127
And you can see 130 yourself right now, on your own terminal: it cannot be captured in a
single-line command because it needs you to interrupt something in progress.
sleep 20
Press Ctrl+C before it finishes, and then run echo $?. What to expect: 130. It is not
arbitrary: the convention is 128 + the signal's number, and SIGINT (the signal Ctrl+C
sends) is signal number 2 → 128 + 2 = 130.
The professional habit: verify instead of assume
The difference between someone who automates with confidence and someone who automates with
fear is not how much syntax they have memorized. It is a habit: before chaining a
destructive or irreversible step to another command's result, ask yourself what exit code you
expect, and link them with &&, never with ;.
A CI/CD pipeline that runs run_tests.sh; deploy.sh deploys even if the tests failed. A
cleanup script that runs cd $TARGET_DIR; rm -rf * can delete the wrong directory if the
$TARGET_DIR variable was never defined: with no quotes, a cd with an empty variable is the
same as a cd with no argument, which sends you straight to your home, and there you really
do have something to lose. Neither of these two examples is hypothetical: they are why "check
the exit code before continuing" shows up in almost every safe-deployment checklist.
Common mistakes
1. Believing A && B || C is a real if/then/else (conceptual). What happens: you write
migrate_db && echo "Migration OK" || echo "Migration FAILED" expecting the error message to
show up only if migrate_db fails, and you see "FAILED" even when the migration worked fine.
Why: || knows nothing about A; it only looks at the immediately preceding command's
exit code, which in this case is the echo "Migration OK". If for any reason that echo
fails (for example, because the file it redirects to has no write permission), ||
interprets that as the failure and fires C, with migrate_db never having had a problem.
How to spot it: you see ||'s branch's error message in a case where you know for certain the
original step worked. How to fix it: if you really need if/then/else-style conditions, use the
if command; then ...; else ...; fi construct you are going to see in the scripting module;
it is explicit and does not depend on the "then" branch never failing.
2. Chaining dependent steps with ;. What happens: you write a sequence like cd folder; rm -rf * or docker stop app; docker rm app; docker run ... assuming that if the first
command fails, the rest will simply "have no effect." Why: ; checks nothing; it runs the
second command whether or not there is a reason to, even in the wrong directory or state. How
to spot it: ask yourself, for every ; in a line you wrote, "would I care if the command on
the left had failed silently?" If the answer is yes, that ; is a bug candidate. How to fix
it: change ; to && every time the next step only makes sense if the previous one worked.
3. Checking $? after something else overwrote it. What happens: you run a command, print
a diagnostic message in between (echo "Checking result..."), and then check $?, and
the value you see is the echo's, not the command you actually wanted to inspect. Why: $?
always reflects the last command run's exit code, no exceptions; it does not "remember"
anything from before. How to spot it: if $? gives 0 when you are certain something failed,
suspect something ran in between. How to fix it: save the value the moment it matters to you,
into your own variable (result=$?), and use that variable afterward, instead of checking
$? again once another command has already run.
Exercises
1. Predict exactly what happens with this command if /tmp/does-not-exist does not exist
on your system, and which directory marker.txt ends up created in:
cd /tmp/does-not-exist; touch marker.txt
See solution
cd /tmp/does-not-exist fails and prints something like cd: /tmp/does-not-exist: No such file or directory, but since the separator is ;, not &&, the shell still runs touch marker.txt. Since the cd never took effect, you are still standing in the directory you ran
the line from, and marker.txt gets created there, not in /tmp/does-not-exist, which does
not even exist.
Why it works: ; never checks the previous command's exit code; it chains
unconditionally. If the intent was for touch to only run inside
/tmp/does-not-exist, the correct line was cd /tmp/does-not-exist && touch marker.txt,
which aborts if the cd fails.
2. This deployment line has a serious problem:
git pull; npm run build; systemctl restart myapp
What is the worst possible scenario if npm run build fails? Rewrite the line to make it
safe.
See solution
The worst scenario: git pull brings in new code, npm run build fails halfway through (for
example, due to a syntax error) and leaves a corrupt or incomplete build folder, and
systemctl restart myapp still runs anyway, restarting the service with a broken or
nonexistent build and taking down the application in production with no prior warning.
The safe version chains each step to the previous one having worked:
git pull && npm run build && systemctl restart myapp
Why it works: with &&, if git pull or npm run build end with a code other than 0,
the rest of the chain never runs. The service keeps running the previous version, which at
least works, instead of a half-built one.
3. A coworker runs a script and sees this message:
zsh: command not found: deploy-app
They check $? and see 127. They ask you what would happen if instead the deploy-app file
existed in the current directory but had no execute permission. What code would they see, and
how is it fixed?
See solution
They would see 126, not 127. The difference: 127 means the shell did not find the
command at all (typo, not installed, not in the PATH); 126 means it did find it, but
could not run it because it is missing the execute permission. The fix for 126 is adding the
permission with chmod +x deploy-app (and, if the script is in the current directory and not
in the PATH, running it as ./deploy-app).
Why it works: chmod +x gives the file the execute bit the system requires before it can
be run as a program; without it, the kernel refuses to run it even if the file exists and has
the right content.
4. Write a single command that enters /var/log, counts how many .log files are there
with ls *.log | wc -l, and leaves your shell exactly in the directory it was in before
running it, with no explicit cd back at the end.
See solution
(cd /var/log && ls *.log | wc -l)
Why it works: the parentheses run the group in a subshell, a separate child process. The
cd does happen, but only inside that child process; when the group ends, the child process
disappears along with its directory change, and your real shell never moved from where it
was. The && inside also keeps ls *.log | wc -l from running if the cd to /var/log
happened to fail.
Summary and next step
You now know how to read the receipt every command leaves behind ($?, 0 for success, any
other number for a specific failure), and how to choose with judgment between chaining
unconditionally (;), chaining only if the previous one worked (&&), reacting only if it
failed (||), and grouping several commands as a single unit with () or { } when you need
to.
Everything you saw in this module — the three streams, redirection, pipes, the text toolkit, and now exit codes — are the loose pieces of a language. What comes next is putting them together on a single real problem: a log analysis pipeline that answers several questions about a file with tens of thousands of lines, saving the report only if every step worked.
Before moving on you should be able to:
- explain, without looking at this lesson, why
cd folder; rm -rf *is dangerous andcd folder && rm -rf *is not; - say from memory which exit code corresponds to "command not found" and which one to "command found but no execute permission";
- write a chain of two or three commands using
&&so that a failure halfway through stops everything that comes after.
Resources
- Exit Status — Bash Reference Manual — bash's official reference on what an exit code is and how it gets determined.
- Lists of Commands — Bash Reference Manual
— official documentation for
;,&&,||, and their precedence. - Command Grouping — Bash Reference Manual
— the exact difference between
()(subshell) and{ }(current shell), with their syntax. - Appendix E. Exit Codes With Special Meanings — Advanced Bash-Scripting Guide — the reference table for reserved codes (126, 127, 130, among others) and why they exist.