Semicolons, `&&`, and `||`: The Command Chains We Type Ten Thousand Times a Day but Rarely Understand
Semicolons, && , and || : The Command Chains We Type Ten Thousand Times a Day but Rarely Understand Every morning, I receive pleas for help like &quo…
- Added to the lab
- Downloads
- 0
- Human review
- Human reviewed
Semicolons, &&, and ||: The Command Chains We Type Ten Thousand Times a Day but Rarely Understand
Every morning, I receive pleas for help like "my shell script crashed." Eighty percent of the time, the issue isn't logic—it's this single line:
git pull && deploy.sh || echo "Deployment failed"
It seems perfectly logical: if pulling the code succeeds, deploy; if it fails, shout out an error. But if you actually test it, you’ll find two pitfalls—the "Deployment failed" message might appear even when deployment succeeds, and if the deployment script itself crashes, it might not trigger that branch at all. This is one of the most commonly used yet misunderstood mechanisms in the CLI, worth five minutes to clarify.
Core Rule: Check the Previous Command’s Exit Code First
The difference between these three connectors can be summarized in one sentence:
;(Semicolon): Runs the next command after the previous one finishes, regardless of success or failure. It is an "unconditional relay."&&(AND): Executes the next command only if the previous one succeeds (exit code 0); otherwise, the entire chain stops.||(OR): Executes the next command only if the previous one fails; if it succeeds, the next command is skipped.
Exit codes are the foundation of everything: 0 means success, non-zero means failure. You can check the exit code of the last command anytime using echo $?, or use # in comments to note "why it was non-zero."
git pull
echo "Git exit code: $?" # 0 = success, 128+ usually indicates a fatal error
Scenario: Chaining Three Operations
Suppose you need to "backup database → run migrations → send notification," and migration failures must terminate the process, otherwise bad code will be deployed:
pg_dump mydb > backup.sql && migrate-v2.0 && notify.sh "Migration complete"
The benefit of &&: If pg_dump fails (disk full, permission errors), subsequent commands won’t run, containing the fault within the smallest possible radius. While set -e can abort the entire script on any failure, it is global and prone to false positives (e.g., grep returning no matches also yields a non-zero exit code). For fine-grained control, explicit && is more comfortable.
When to Use What
- Need "continue only on success": Use
&&. Builds, migrations, and deployments—failure at any step pollutes the next. - Need "don't stop on failure, just log it": Use
;.
Or, more elegantly, handle the failure branch withnpm run build; echo "Build finished (printed regardless of success)"||:npm run build || { echo "Build failed" >&2; exit 1; } - Need "either/or": Use
||as a fallback.command1 || command2: Ifcommand1succeeds, skip; if it fails, runcommand2.- However, use the
A && B || Cpattern seen at the beginning with caution. Intuitively, "success goes to B, failure goes to C" seems correct, but there is a third scenario: if A succeeds but B fails, C will also run. If B isdeployand C is a "deployment failure alert," this is fine. But if C has other side effects, you may encounter weird bugs where C executes even though deployment succeeded. The safe approach:
if git pull; then deploy.sh else echo "Pull failed, aborting" >&2 exit 1 fiThe semantics are clear and unambiguous.
A Real Pitfall: echo Masks Failure
Many people write:
$TOOL_MIGRATE || echo "Secondary data migration error"
It looks like || only outputs the alert when $TOOL_MIGRATE fails. If you add set -x or debug logs, you’ll see that $TOOL_MIGRATE indeed failed and the alert printed—correct so far. The problem is that many teams’ alert channels don’t work on that specific machine (network isolation, log service restarts). The echo merely "looks like it caught the error," but in reality, it’s a silent failure. || does not guarantee your fallback succeeds; it only guarantees "don’t run on success, run on failure." Therefore, alerts should have dual channels, use echo ... >&2, and rely on cron/atop/logwatch for final fallback monitoring.
Combining Pipes with ;
Pipes | pass stdout, and the exit code defaults to that of the last command:
cat data.json | jq '.items' > out.json && echo "OK"
If jq errors out (field doesn’t exist), echo "OK" will still print, even if out.json is empty or contains an error. To ensure "any failure in the chain causes the whole chain to fail," you need pipefail:
set -o pipefail
cat data.json | jq '.items' > out.json && echo "OK"
Now, if jq fails, the pipeline returns a non-zero exit code, and the command after && stops. This is the most commonly overlooked line when handling data pipelines (log collection → transformation → storage).
When Not to Use Command Chains
- Don’t write chains longer than 3–5 steps on a single line. The longer the line, the worse the readability, and with multiple exit codes, it becomes hard to trace. For more than 3 steps, write a
.shfile, define a function for each step, start functions withset -euo pipefail, and log a[step]message at the end of each step. - State, error handling, and retries for foreground chains: Chained commands lack retry logic, timeouts, or segmented state tracking. If this is a production script, at least add
timeoutand manual structures likefor i in 1 2 3; do ... && break; done. - Debugging chained commands in interactive shells:
Ctrl-Zsuspends the entire chain, and resuming can cause disorder. Break them into single steps for debugging.
Common Misjudgments
- "Skip on failure" is
||, not;.;always executes the next command; it has no concept of "skipping." set -eis not a silver bullet. It globally exempts conditional statements involving&&,||, andif(these are normal control flow, not errors), which often causes pitfalls inmakerecipes or Ansible inline tasks. Explicit&&/||always takes precedence.- Pipeline exit codes default to the last command only. The
&& jq ... && echo OKexample above won’t stop ifjqfails unless you addpipefail. $?/$statusretrieves the exit code of the previous command. Note it is the immediately preceding command. After writingecho,$?becomes the exit code ofecho(which is almost always 0). Capture exit codes immediately; don’t insert any commands in between.
Checklist: Review Before Writing Chains
- If this step fails, should the next step continue? Yes →
;; No →&& - Can the fallback branch of
||itself fail? Yes → Encapsulate in a function with internal fallbacks - Are there pipes? Did you add
pipefail? - Do critical error codes need separate handling (don’t rely solely on the binary non-zero/0 distinction)?
- More than 3 steps? Consider writing a script instead of adding more
&& - Running in an environment with
set -e/set -x? Could it be silently ignored (due to conditional statement exemptions)?
Summary
Command chains are lightweight, but they have many silent paths. Treat && as a "guard gate," ; as a "mindless relay," and || as a "fallback channel." Use explicit if/else for binary choices and pipefail for pipelines, and you can elevate your scripts from "just running" to "immediately identifying where it broke when it crashes."
One final counter-intuitive but crucial point: If a branch can be written as if/else, avoid the implicit semantics of ||. The next person maintaining your script—including yourself six months from now—will thank you.