Skip to content
Shell & TerminalDeep Dive Published Updated 8 min readViews unavailable

Bash && and || Lists: Equal Precedence, Short-Circuiting, and Safer Branches

Reason about Bash AND-OR lists correctly, avoid rollback bugs from mixed operators, and preserve the intended status flow.

Bash’s && and || operators connect command lists by exit status. Both short-circuit, and they have equal precedence with left-associative evaluation. That rule is easy to forget because many programming languages give logical AND higher precedence than logical OR. In shell code, a compact chain can therefore run a recovery command on a failure the author did not intend.

The shell is not evaluating a Boolean expression over values. It is deciding which commands to run based on process statuses, then returning the status of the last command that ran. Reliable scripts make the grouping visible, especially when a failed prerequisite, a failed operation, and a recovery action require different behavior.

Read an AND-OR list as command flow

An AND list runs its right-hand command only if the preceding command succeeds. An OR list runs its right-hand command only if the preceding command fails. A sequence of these operators is evaluated from left to right at the same precedence. Parentheses can group a subshell, but they also create a process boundary; use them only when that process boundary is acceptable. For clarity without a subshell, use if, elif, and else.

#!/usr/bin/env bash
set -u

preflight() {
    printf 'preflight failed\n' >&2
    return 1
}

deploy() {
    printf 'deployment started\n'
}

preflight && deploy || printf 'recovery path selected\n'

The recovery message runs when preflight fails, but it also runs if preflight succeeds and deploy fails. The expression groups effectively as (preflight && deploy) || recovery, not as preflight && (deploy || recovery). If recovery is intended only for a failed deployment after a successful preflight, the compact chain does not express that policy.

That distinction can be consequential. A cleanup command might delete a previous release when the new release was never prepared. A notification might report rollback after an authorization failure. A fallback data source might be used even though the primary source returned a transient error. Read the operators as a timeline and list every condition that reaches each command.

Use explicit branches when failures mean different things

An if statement makes the controlling status clear and gives preflight and deployment their own failure paths. Each condition is still evaluated as a command list, but the branch structure tells the reader which status controls the action.

if preflight; then
    if deploy; then
        printf 'deployment completed\n'
    else
        printf 'deployment failed; begin rollback\n' >&2
        rollback
    fi
else
    printf 'preflight failed; deployment was not attempted\n' >&2
    exit 1
fi

The nested form says that rollback is conditional on deployment failing after preflight succeeded. It also makes it possible to preserve or translate each failure status intentionally. If rollback itself fails, capture and report that failure rather than allowing it to erase the original deployment error without explanation.

Do not over-compress critical control flow to save a few lines. A reader should be able to tell whether a command ran because a prerequisite succeeded, because an earlier command failed, or because a recovery action itself succeeded. Clear branches are a form of operational documentation.

Understand status propagation through a chain

Each successful AND continuation replaces the status of the preceding command with the status of the command that ran next. An OR continuation does the same when the previous command failed. When the list ends, its status is the status of the last command executed. If a right-hand side does not run due to short-circuiting, the status from the command that prevented it remains the result of that list.

This means that adding a logging command can accidentally change the status returned to a caller. For example, a successful printf after a failed operation makes the whole AND-OR list successful if that printf was the last command executed. Capture the original status before reporting it, or use a branch that returns the intended value explicitly.

if run_task; then
    printf 'task succeeded\n'
else
    task_status=$?
    printf 'task failed with status %s\n' "$task_status" >&2
    exit "$task_status"
fi

The assignment to task_status is the first command in the failure branch, so it captures the status of the condition before the diagnostic output changes it. A function should also document whether it returns the original command status or a normalized application status. Do not depend on the status left behind by a long, mixed-operator chain when the caller needs a specific contract.

Separate fallback from recovery

A fallback supplies an alternative result when a primary lookup has no acceptable result. Recovery attempts to repair or compensate for an operation that may already have changed state. These are different semantics even if both are written with ||. Before using a fallback, decide which failure classes qualify: a missing value, a transient network problem, malformed data, or an authorization error should not automatically be treated the same way.

if load_primary_config; then
    validate_config
else
    primary_status=$?
    printf 'primary configuration source failed (%s); refusing fallback\n' "$primary_status" >&2
    exit "$primary_status"
fi

This example deliberately refuses a silent fallback after a failed source. If a fallback is safe, make the eligibility test explicit, log which source was selected, and ensure the resulting configuration receives the same validation as the primary. A silent alternative can hide a service outage or operate on stale data.

For a destructive operation, do not put rollback after a generic OR merely because the operation returned nonzero. A nonzero status can mean the command failed before changing anything, partially completed, or completed but failed during a later reporting step. Determine the command’s documented failure semantics and use an idempotent or transactional recovery plan where available.

Avoid relying on set -e to define the branches

The errexit option has context-dependent exceptions, including commands used as conditions in AND-OR lists. That behavior is one reason a condition must be written with the intended branch structure rather than expecting set -e to abort at a particular token. If a failure matters, check the command’s status directly and handle it in the code path that owns the decision.

set -e

if generate_manifest && publish_manifest; then
    printf 'manifest published\n'
else
    status=$?
    printf 'manifest workflow failed with status %s\n' "$status" >&2
    exit "$status"
fi

This explicit condition keeps the workflow’s expected failure under direct control. A function called as a condition can behave differently from the same function called as a simple command when errexit is active. Avoid writing a helper whose correctness depends on an undocumented caller context; either handle its failures internally or define and test its status contract.

Also distinguish a function’s status from the status of a pipeline it invokes. Pipeline rules, pipefail, and PIPESTATUS can determine what the condition sees. If an operation pipes output through a formatter, decide whether a failure in the producer, consumer, or either should stop the workflow, and capture status information before another command overwrites it.

Make compact chains safe when they are truly simple

An AND list is appropriate for a short sequence where each later command should run only if the previous one succeeded, such as creating a directory and then entering it. An OR list is appropriate for a direct failure message or an explicitly acceptable default. Keep the chain short, side effects limited, and status meaning obvious.

mkdir -p -- "$cache_dir" && cd -- "$cache_dir" || exit 1

Even this familiar idiom deserves care: when mkdir succeeds but cd fails, the final exit runs; when mkdir fails, cd is skipped and exit runs. The expression handles both failures with the same outcome, so the compact form is defensible. If the caller needs to distinguish them, use an if branch and separate diagnostics.

Quoting and option boundaries remain relevant inside status chains. A failed command is not made safe by short-circuiting. Quote path values, use documented end-of-options markers where supported, and avoid constructing a command line as a string for later evaluation. Control flow cannot repair argument injection or untrusted shell syntax.

Test every edge of the status table

For a mixed AND-OR expression, create a truth table for every command’s status and identify which commands execute. Test each branch with stub functions that log invocation order and return controlled statuses. Include a successful prerequisite followed by a failed operation, a failed prerequisite, a failed recovery, and a successful recovery. Verify both the trace and the final status.

Run the test under the Bash version supported by the application and with the same shell options used in deployment. A command can behave differently if it is a pipeline, a function, or an external executable, and inherited options can change failure handling. Avoid testing only the happy path; a short-circuit bug exists in precisely the paths that ordinary successful runs never exercise.

When a status chain governs a release, database migration, or cleanup, use a disposable environment and record each side effect. Confirm that a preflight failure cannot reach the mutation, that rollback is triggered only for the documented failure class, and that an error report preserves the status the caller needs. Add a regression test that fails if command order changes unexpectedly.

Operational checklist

Treat && and || as left-associative operators at equal precedence. Write down which status reaches each command, then use explicit if branches whenever prerequisite failure and operation failure require different responses. Capture status before logging, test all branch combinations, and do not assume set -e supplies the control flow for you.

Compact shell lists are readable when they express a single, obvious rule. Once a chain mixes preparation, mutation, fallback, and rollback, expand it into branches. The goal is not fewer lines; it is an exact and testable relationship between a process status and the side effect that may follow.

Related:

Sources:

Comments