skip to content

Decision Structures

The shapes a branch takes once a plain if is not enough: an expression that chooses between two values, one that tests and binds at once, and a table of callables replacing a long chain.

part ofPythonoverview, primer and where to startread it →
on this pageshow

questions

16

In Python's `a if cond else b`, what is evaluated and in what order?

level: juniorimportance: must knowfreq 70%

answer

  1. It produces a value, not a step
  2. The test runs before either branch
  3. One side never executes at all
  4. No one-armed form exists
  5. Unlike a call, not both operands

basics

~20 s

Python evaluates cond first. If it is truthy, only a is evaluated and becomes the value; otherwise only b is. The branch that is not chosen never runs, so its side effects and its exceptions never happen.

solid answer

~50 s

The form is `a if cond else b`, and it is an **expression**: it produces a value rather than performing a step. Evaluation order is condition first, chosen branch second — the middle operand `cond` is tested for truth, then exactly one of `a` or `b` is evaluated and returned. The other operand is never evaluated at all, which is why `10 / x if x else None` is safe: the division is skipped when `x` is zero, instead of raising `ZeroDivisionError`. This laziness is what separates it from an ordinary function call, where every argument is evaluated before the call happens. The `else` part is mandatory — there is no one-armed conditional expression — and because it yields a value, it can sit where a statement cannot: inside a call argument, a comprehension, an f-string replacement field, a `return`, or a parameter's default expression.

code

python · 8 lines
python
def loud(label, value):
    print("evaluated", label)
    return value


flag = True
result = loud("left", 1) if flag else loud("right", 2)
print("result", result)

go deeper

for a junior

Recall the shape and the order: condition first, then exactly one branch, and the else is required. Be able to rewrite a short if/else assignment as one line and back again.

for a middle

Explain that the untaken branch never executes, and use that to guard a division or an attribute access. Know that a call evaluates all its arguments first, so wrapping the choice in a helper destroys the guard.

for a senior

Show judgement about when the one-liner helps and when an if statement reads better, and point out the latent-bug risk: both branches compile, so a name error in the rarely taken side surfaces only in production unless tests cover both paths.

for a principal

Own the convention for the codebase — where the form is welcome (defaults, arguments, comprehension elements) and where it is banned (anything a reviewer must re-read), and make "both branches covered by tests" part of the review expectation rather than a matter of taste.

### The form and its grammar Python's conditional expression is written `a if cond else b`, sometimes called the ternary operator because it takes three operands. Unlike an `if` statement, it is an **expression**: it evaluates to a value, so it can appear anywhere a value is allowed. The word order is deliberate — the common-case result comes first, the test second — which reads like English ("return the cached value if it is present, otherwise recompute") and is why Python did not adopt C's `cond ? a : b` punctuation. ### Evaluation order The order is fixed and worth stating precisely, because it is not the left-to-right order of the written text: 1. `cond` is evaluated first, even though it is written in the middle. 2. Its result is tested for truth. 3. If the test passes, `a` is evaluated and its value becomes the value of the whole expression. 4. Otherwise `b` is evaluated and its value becomes the value of the whole expression. Step 3 and step 4 are exclusive. Exactly one of the two branches runs — never both, never neither. ### Laziness is the point The untaken branch is not merely discarded after being computed; it is never computed. Nothing in it executes: no function is called, no attribute is looked up, no exception is raised, no counter is incremented. That makes the conditional expression a **guard**, not just a chooser: ```python rate = total / count if count else 0.0 ``` When `count` is zero, the division does not happen, so there is no `ZeroDivisionError` to catch. Contrast this with a function call: ```python def pick(first, second, use_first): return first if use_first else second value = pick(total / count, 0.0, count > 0) # still raises when count == 0 ``` Here both arguments are evaluated before `pick` is entered, because argument evaluation in a call is eager. Wrapping a choice in a helper function throws the laziness away — a common and subtle regression when someone "tidies" a conditional expression into a utility. If you need deferred evaluation across a function boundary you must pass callables and invoke the chosen one, which is usually more machinery than simply keeping the conditional expression at the call site. ### Both branches must still be valid code Laziness is a runtime property, not a compile-time one. Both operands are compiled, so both must parse and both must reference names that exist by the time they might be evaluated. `x if flag else undefined_name` compiles fine and only raises `NameError` when `flag` is false — a latent bug that a test exercising one branch will not catch. Cover both branches in tests. ### The `else` is mandatory There is no one-armed form. `value = a if cond` is a `SyntaxError`. If you genuinely want "use `a` when the condition holds, otherwise leave the name alone", that is an `if` statement, not an expression — the expression must produce a value in every case, so it must say what that value is when the test fails. ### Where it fits that a statement cannot Because it is an expression, it goes in positions that forbid statements: - a call argument: `send(payload, timeout=fast if urgent else slow)` - an element expression inside a comprehension - an f-string replacement field: `f"{count} item{'' if count == 1 else 's'}"` - a `return` line, a `lambda` body, a `yield` value - a parameter's default expression in a `def` header, evaluated once at definition time like any other default That last one is worth care: putting a conditional expression in a default expression does not make the default lazy. `def f(limit=HIGH if DEBUG else LOW)` evaluates the whole conditional expression once, when the `def` executes, and stores the single resulting value on the function. If you want the choice to be made per call, make the default a sentinel such as `None` and resolve it in the body, where the conditional expression is re-evaluated on every call. ### Truth testing, briefly The condition is tested for truth the same way an `if` statement tests it — an empty container, zero, `None` and `False` all fail the test. Note the consequence for the result value: `count if count else default` returns `default` for a legitimate zero, while `count if count is not None else default` distinguishes "zero" from "missing". Choosing the right test is usually more important than choosing the right branch values. ### Cost There is no hidden overhead. CPython compiles the form into a truth test and a jump, exactly as it compiles the corresponding `if` statement; picking one over the other is a readability decision, not a performance one.

  • Why does moving `a if cond else b` into a helper function change its behaviour?
    Because argument evaluation in a call is eager. `pick(risky(), fallback, cond)` evaluates `risky()` before `pick` is entered, so the guard is gone and the exception you were avoiding is raised again. The conditional expression only defers work while it stays at the point of use; to defer across a call you must pass callables and invoke the chosen one.
  • Is there a one-armed version, `x = a if cond`?
    No — that is a `SyntaxError`. An expression must produce a value on every path, so the `else` branch is required. "Do something only when the condition holds" is a job for an `if` statement, which performs an action rather than producing a value.
  • Does putting a conditional expression in a parameter's default make the default lazy?
    No. A default expression in a `def` header is evaluated once, when the `def` statement runs, and the single resulting value is stored on the function. The conditional expression picks a branch at definition time, not per call. For a per-call decision, default to a sentinel such as `None` and resolve it inside the body.

It is a fork in a road, not a menu: you read the signpost first, then walk exactly one way, and the road you did not take is never travelled.

saying these in an interview costs you the question

  • Says both branches are evaluated and one result discarded
  • Thinks the branches are read left to right before the test
  • Believes `x = a if cond` is legal without an else
  • Claims the form is faster than an equivalent if statement
  • Assumes an undefined name in the unused branch is harmless forever
  • Thinks wrapping it in a helper function preserves the laziness

context

open as a page

Why does a Python dict dispatch table store `handler` rather than `handler()` as its values?

level: juniorimportance: must knowfreq 60%

basics

~20 s

A dict literal evaluates every value eagerly while the dict is being built, so handler() would run the handler right there and store its return value. Storing the bare name keeps the function object, which you call after the lookup.

open as a page

How does Python decide which branch of an if/elif/else chain executes?

level: juniorimportance: must knowfreq 80%

basics

~10 s

Conditions are tested top to bottom and the first truthy one wins: its block runs and the whole rest of the chain is skipped. If none is true, the optional else block runs.

open as a page

What does Python's walrus operator `:=` do, and how does it differ from `=`?

level: juniorimportance: must knowfreq 65%

basics

~20 s

:= is an assignment expression: it binds a name and also evaluates to the assigned value, so it can be used inside a condition or a call. Plain = is a statement, which produces no value at all.

open as a page

How does Python parse `'Result: ' + status if ok else 'fail'`, and why is that a bug?

level: middleimportance: must knowfreq 50%

basics

~20 s

The conditional expression binds looser than +, so Python reads it as ('Result: ' + status) if ok else 'fail'. When ok is falsy the prefix silently disappears. Parenthesize the conditional expression so it is a single operand of the concatenation.

open as a page

How do early-return guard clauses flatten a deeply nested if statement?

level: middleimportance: must knowfreq 60%

basics

~20 s

Invert each precondition, return or raise immediately when it fails, and delete the else. Every failure case leaves at the top, so the main work drops to the outermost indentation with all its preconditions already established.

open as a page

Why must Python's `:=` be parenthesized in `if (n := len(s)) > 10:`?

level: middleimportance: must knowfreq 50%

basics

~20 s

:= binds looser than any other operator, so if n := len(s) > 10: parses as n := (len(s) > 10) and stores a boolean instead of the length. The parentheses force the binding to happen first.

open as a page

How do `[f(x) if c(x) else g(x) for x in xs]` and `[f(x) for x in xs if c(x)]` differ?

level: middleimportance: should knowfreq 45%

basics

~20 s

The first maps every element, choosing per element between f and g, so the output has one item per input. The second filters: elements failing c are dropped, so the output is shorter. Position decides which you get.

open as a page

How do you handle an unknown key in a Python dict dispatch table without masking errors raised by the handler?

level: middleimportance: should knowfreq 46%

basics

~20 s

Separate the lookup from the call: handlers.get(key, fallback) or a get returning None followed by an explicit raise, then invoke the handler on its own line. Wrapping handlers[key]() in try/except KeyError also catches a KeyError raised inside the handler.

open as a page

How can a branch in a Python if/elif chain become unreachable?

level: middleimportance: should knowfreq 45%

basics

~20 s

A branch is unreachable when an earlier condition is true for every input that would have matched it. Broad-before-narrow thresholds and a base class tested before its subclass are the usual causes, and Python never warns.

open as a page

How does Python's `:=` turn a chunked read-until-EOF loop into one condition?

level: middleimportance: should knowfreq 45%

basics

~10 s

while chunk := src.read(8192): performs the read, binds the result and tests it in one place, replacing the while True / read / break shape or the duplicated priming read that Python otherwise forces.

open as a page

When should a dict dispatch table in an ETL export give way to objects with methods instead?

level: seniorimportance: should knowfreq 40%

basics

~20 s

When each branch stops being one stateless verb. Once a record kind needs setup, staged state, a commit and a rollback that belong together, a dict of functions has nowhere to hold the pairing; a table of classes whose instances carry that lifecycle does.

open as a page

A feature-flag service's if/elif rule chain leaves some requests with no decision. How do you diagnose and prevent that?

level: seniorimportance: should knowfreq 38%

basics

~20 s

A chain with no final else falls through silently, so the function returns None instead of a decision. Reproduce the input, confirm nothing matched, then make the chain total: an else that logs and returns an explicit default, or raises.

open as a page

What does functools.singledispatch give you that a dict keyed on type(obj) does not?

level: middleimportance: nice to knowfreq 26%

basics

~20 s

functools.singledispatch resolves through the argument's method resolution order and registered abstract base classes, so subclasses and virtual subclasses find a handler. A dict keyed on type(obj) matches the exact class only and misses every subclass.

open as a page

How does Python group `a if p else b if q else c`, and when should it become an if statement?

level: seniorimportance: nice to knowfreq 16%

basics

~20 s

Conditional expressions are right-associative, so Python groups it as a if p else (b if q else c): a first-match-wins chain read left to right. Once there are more than two tests, or a branch needs a statement, use an if statement.

open as a page

Where does a name bound by Python's `:=` in an `if` condition live afterwards?

level: seniorimportance: nice to knowfreq 25%

basics

~20 s

In the ordinary enclosing scope — a function local or a module global — exactly where a plain = would put it. No new scope is created, and the name outlives the if, because Python blocks do not scope names.

open as a page