Why does `threshold = config.threshold or 92` ignore a configured 0, and what replaces it?
answer
- Two different questions, one operator
- Absent is not the same as falsy
- Zero and empty string are legal data
- Ask presence with `is not None`
- Move the default into `.get(key, default)`
basics
~20 sor cannot distinguish absent from falsy: a configured 0 is falsy, so the default 92 wins. Use an explicit presence test — cfg.threshold if cfg.threshold is not None else 92 — whenever 0 or '' is legal data.
solid answer
~40 s`x or default` means "use the default unless `x` is truthy", but the author almost always meant "unless the caller supplied something". Every falsy legal value — `0`, `0.0`, `""`, `[]`, `False` — is silently replaced. In a nightly report generator, a configured `retries = 0` meaning *do not retry* became `3` through `cfg.retries or 3`, and each extra aborted attempt left its temp output handle unclosed until the process exited. The fix is to ask about presence: `cfg.retries if cfg.retries is not None else 3`. For mappings, move the default into the lookup — `settings.get("retries", 3)` fires only on a missing key, unlike `settings.get("retries") or 3`. `getattr(obj, "name", default)` behaves the same way. `or` is still correct when every falsy value genuinely should be replaced, such as `name.strip() or "anonymous"`.
code
python · 7 linesclass Config:
def __init__(self, threshold=None):
self.threshold = threshold
cfg = Config(threshold=0)
print(cfg.threshold or 92) # 92 -- the 0 vanished
print(cfg.threshold if cfg.threshold is not None else 92) # 0 -- honouredgo deeper
Remember that 0 and "" are falsy, so x or default replaces them just as it replaces None. When zero is a real answer, test for None explicitly instead.
Explain the mechanics: or returns the right operand for any falsy left operand, so absent and falsy collapse. Show the is not None conditional and the dict.get(key, default) form, and say why the latter is not affected.
Diagnose it from the symptom — a configured value that has no effect and raises nothing — and describe the blast radius when the eaten value is a retry count, a timeout or a limit. Show the boundary test that catches the whole class and the review question you ask about the field's domain.
Decide the convention: whether optional fields in your config and API schemas distinguish absent from empty at all, and encode that in the schema and types rather than in each call site. Where the distinction does not exist by design, the or idiom stops being a hazard.
## The bug in one line `or` cannot tell "the caller supplied nothing" from "the caller supplied a falsy value". Both take the right-hand branch. So `threshold = config.threshold or 92` means *"use 92 unless `config.threshold` is truthy"*, while the author almost always meant *"use 92 unless the operator configured something"*. Every falsy legal value — `0`, `0.0`, `""`, `[]`, `{}`, `False` — is silently overwritten by the default. ## What it looks like in production A nightly report generator computes a 92nd-percentile latency budget and reads its knobs from a config object. Two of them are written with the `or` idiom: ```python class Config: def __init__(self, threshold=None): self.threshold = threshold cfg = Config(threshold=0) print(cfg.threshold or 92) # 92 -- the 0 vanished print(cfg.threshold if cfg.threshold is not None else 92) # 0 -- honoured ``` An operator sets `retries = 0`, meaning *do not retry this job*. `retries = cfg.retries or 3` turns that into three attempts. The failure is not the retry count itself; it is what the extra attempts do. Each aborted attempt of the report writer had already opened its temp output, and the abort path left that handle unclosed until the process exited, so a run that was configured to fail fast instead accumulated three open descriptors per failing job every night. The config file says one thing, the logs say another, and nothing raises — which is what makes this class of bug expensive rather than merely wrong. The same shape recurs with strings: `label = cfg.label or "unnamed"` erases a deliberately empty label, and `columns = cfg.columns or DEFAULT_COLUMNS` erases a deliberate `[]` meaning "emit no optional columns". ## The fix: ask the question you actually mean The narrow question is presence, and the operator for presence is `is None`: ```python threshold = cfg.threshold if cfg.threshold is not None else 92 ``` For mappings, `dict.get` already draws the distinction correctly — its default fires on a **missing key**, not on a falsy value — so the fix is to move the default into the lookup instead of after it: ```python settings = {"retries": 0} print(settings.get("retries") or 3) # 3 -- wrong print(settings.get("retries", 3)) # 0 -- right ``` `getattr(obj, "name", default)` behaves the same way for attributes. For function parameters, the idiomatic form is a `None` default plus an explicit `is None` test in the body, which keeps "caller passed nothing" separate from "caller passed zero". ## When `or` is still the right tool This is not a rule against `or`-as-default. It is correct — and more readable than the alternatives — whenever *every* falsy value genuinely should be replaced. `name.strip() or "anonymous"` is right, because an empty name and a whitespace-only name should both fall back. `parts = line.split(",") or ["<empty>"]` is right for the same reason. The decision is a single question asked per field: > **Is `0`, `""` or `[]` a legal, meaningful value here?** If yes, `or` is a bug waiting for the first operator who types a zero. If no, `or` is fine and concise. ## The mirror bug and its cousins `and` has the symmetric failure: `count = supplied and compute()` yields `0` rather than running `compute()` when `supplied` is `0`. And `if x:` used as a presence check is the same mistake in statement form — `if payload:` skips a legitimately empty payload that the protocol says must still be acknowledged, whereas `if payload is not None:` does not. Three habits keep this out of a codebase. **Review the field's domain, not the expression**: the `or` is only wrong when zero is meaningful, so the reviewable question is what the field can hold. **Type checkers help partially** — the result of `x or default` is typed as the union of both operand types, so a checker will flag `int | None` leaking through, though it cannot know that your `0` was intentional. **Test the boundary explicitly**: a test that passes `0` and `""` for every optional numeric and string knob catches the entire class in one pass, and is cheap to write. The semantics described here are stable across the whole Python 3 line and unchanged on **3.14**; there is no flag or future import that makes `or` distinguish absent from falsy.
- When is `x or default` still the right thing to write?Whenever every falsy value should genuinely fall back. `name.strip() or "anonymous"` is correct because an empty and a whitespace-only name should both be replaced, and `parts or ["<empty>"]` is correct for the same reason. The decision is one question per field: is `0`, `""` or `[]` a legal, meaningful value here? If not, `or` is concise and clear.
- Does `settings.get("retries", 3)` have the same problem as `settings.get("retries") or 3`?No. `dict.get`'s default fires only when the key is absent, so a stored `0` is returned unchanged. The `or` form applies its default to any falsy stored value, including `0` and `""`. The same distinction holds for `getattr(obj, "name", default)`. Moving the default into the lookup is usually the smallest correct fix.
- How would you stop this bug class from recurring across a codebase?Test the boundary: pass `0` and `""` for every optional numeric and string knob, which catches the whole class in one pass. A type checker helps partially — `x or default` is typed as the union of both operand types, so leaked `int | None` shows up — but it cannot know your `0` was intentional. In review, ask what the field can legally hold rather than inspecting the expression.
- Does `and` have a symmetric failure mode?Yes. `count = supplied and compute()` returns `0` instead of calling `compute()` when `supplied` is `0`, because `and` returns its falsy left operand. The statement-level version is the same mistake: `if payload:` skips a legitimately empty payload that still needs handling, where `if payload is not None:` would not.
It is a form that treats a blank box and a box containing "0" as the same answer — the person who deliberately wrote zero gets the office default anyway, and nobody finds out until the totals look wrong.
saying these in an interview costs you the question
- Calls it a Python quirk rather than plain falsiness
- Fixes it with `if x != None:` instead of `is not None`
- Says `.get(k) or d` and `.get(k, d)` are equivalent
- Bans `or`-defaults outright instead of judging the field
- Only checks for None and forgets empty string cases
- Assumes a type checker catches the intentional zero