skip to content

Why does `if x:` narrow an `int | None` differently from `if x is not None:`?

level: middleimportance: must knowfreq 62%

answer

  1. Two different questions being asked
  2. None is a singleton; falsy is not
  3. Zero and empty string are falsy
  4. Look at the negative branch, not the positive
  5. Zero-means-missing is the classic bug

basics

~20 s

x is not None splits the union exactly: int in the true branch, None in the false one. if x: only proves truthiness, so 0 takes the false branch and the checker still types that branch int | None.

solid answer

~50 s

An identity comparison against `None` is the only check that partitions an optional cleanly, because `None` is a singleton: the true branch is `int`, the false branch is `None`, and both are exact. A truthiness test asks the object instead — `__bool__`, or `__len__` if there is no `__bool__`, defaulting to true — and `0`, `0.0`, `""`, `[]` and `{}` are all falsy. So `if x:` narrows the positive branch to `int` but cannot remove `None` from the negative branch, which stays `int | None`; calling an `int` method there is a type error. The runtime consequence is worse than the static one: a real measured value of `0` is silently treated as "missing". Use `is not None` whenever zero, empty string or empty list are legitimate values, and reserve `if not x:` for the case where empty and missing genuinely mean the same thing.

code

python · 7 lines
python
def effective_budget_ms(p92: float | None) -> float:
    if not p92:              # also true for 0.0
        return 250.0
    return p92


print(effective_budget_ms(None), effective_budget_ms(0.0), effective_budget_ms(180.0))

go deeper

for a junior

Know which values are falsy — zero, empty string, empty list, empty dict, None — and that comparing against None uses is, never ==. Be able to name the bug where a real zero is mistaken for a missing value.

for a middle

Explain the narrowing asymmetry precisely: the truthy branch drops None, the falsy branch cannot, because falsy members of the other type still exist. Mention __bool__ falling back to __len__.

for a senior

Show how you would catch this in review and in production: the failure is silent, well typed, and only visible as a setting that quietly stops taking effect, so it belongs in your defaults-and-config review checklist.

for a principal

Frame the deeper choice: whether "unset" should be modelled as None at all, versus a sentinel, a defaulted value resolved at config load, or a required field — each moves the ambiguity to a different layer.

**Two different questions.** `x is not None` asks about *identity*: is this object the `None` singleton? `if x:` asks about *truth value*: what does this object say when coerced to a bool? Those are different questions with different answers, and confusing them is one of the most common real bugs in typed Python. **How truthiness is computed.** `bool(x)` calls `type(x).__bool__` if it exists; otherwise it calls `type(x).__len__` and treats zero length as false; if neither exists the object is true. That is why `0`, `0.0`, `0j`, `""`, `b""`, `[]`, `{}`, `set()` and `None` are all falsy, while a plain object with no `__bool__` or `__len__` is always truthy — including instances of most classes you write. **What each check proves to a checker.** For a value declared `int | None`: ```python if budget is not None: ... # budget: int else: ... # budget: None ``` The partition is exact because `None` has exactly one inhabitant. Now the truthiness form: ```python if budget: ... # budget: int else: ... # budget: int | None ``` The positive branch is fine — `None` is falsy, so reaching the body proves the value is an `int`. The negative branch is where the asymmetry lives: falsy `int` values exist, so the checker must keep `int` alongside `None`. Anything you do with the value there has to cope with both, and an attribute access will be reported as an error on the `None` member. For `str | None` the same applies with `""`; for `list[str] | None` with `[]`. Only when *no* member of the union has a falsy inhabitant besides `None` — `bool | None` is a special case where checkers may narrow further to the literal `True` — does the truthy branch tell you everything. **The runtime bug is the bigger one.** Consider a log-ingest pipeline where a per-tenant latency budget is a 92nd-percentile figure, typed `float | None`, with `None` meaning "no budget configured". Written as `if not budget: budget = 250.0`, a configured budget of `0.0` — meaning "reject everything slower than instant", a legitimate if aggressive setting — is silently replaced by the default. The checker will not complain, because the code is well typed; only the operator noticing that a strict tenant is behaving loosely will. The same shape wrecks pagination (`page = page or 1` eats page `0`), retry counts, and any field where zero is a real setting. **Early return is narrowing too.** `if budget is None: return DEFAULT` narrows the *rest of the function*, because the checker knows the code after the guard is only reachable when the guard was false. That is often clearer than an `else` block and keeps the happy path unindented — the same effect as an `assert`, without depending on assertions running. **Practical rules.** Use `is not None` (or `is None`) for optionals by default, and let the checker's exact partition do the work. Use a truthiness test deliberately, when "empty" and "absent" should behave identically — filtering a possibly-empty, possibly-missing list is a fair case. Never use `== None`: it invokes `__eq__`, can be overridden to lie, and checkers narrow it less predictably than the identity form. And when a function returns `T | None`, prefer binding it once and guarding once rather than re-testing at each use, because each re-test is another chance to write the wrong one. **Adjacent shapes that hide the same bug.** The `or` operator is a truthiness test in disguise: `budget = configured or 250.0` substitutes the default for a configured `0.0` exactly like the `if not` form, and a checker types the result as the non-falsy part of the left operand joined with the type of the right one. The explicit conditional expression `configured if configured is not None else 250.0` says what you mean and narrows exactly. The same trap sits in `mapping.get(key) or default`, which conflates a stored zero with an absent key, where `mapping.get(key, default)` distinguishes them. And the mutable-default idiom is undone by it: a parameter defaulting to `None` whose body says `if not items: items = []` has thrown away the distinction the sentinel existed to preserve, so write `if items is None:` there. **Narrowing only ever refines the declared type.** None of this rescues a value that was declared too widely in the first place. If a helper returns `float | None` but is annotated `-> float`, no check inside the caller can recover the missing case, and if a value is typed `object` the checker cannot even tell you that `is not None` left something useful. Getting the declaration right at the boundary is what makes the branch checks meaningful.

  • For a value declared `str | None`, what can you do in the `else` branch of `if value:`?
    Very little safely: the branch is still `str | None`, because the empty string is falsy, so `value.upper()` is a type error there. If you need the branch to be exactly `None`, test `if value is None:` instead. If empty and missing should be handled the same way, keep the truthiness test but write the branch against the union — for example returning a default rather than touching the value.
  • When is `if not x:` the right check on an optional?
    When absent and empty are genuinely the same case. A missing list of filters and an empty list of filters both mean "do not filter", so `if not filters:` reads better than two branches. Make it a deliberate choice and say so in a comment or by naming a helper, because the next reader will assume it was an accident.
  • How does an early `return` narrow the rest of a function?
    A checker knows the code after `if x is None: return default` is only reachable when the guard was false, so the remainder of the body sees `x` as the non-optional type with no extra indentation. The same holds for a guard that raises. It is the most readable narrowing form and, unlike `assert`, its check cannot be compiled away.

saying these in an interview costs you the question

  • Says `if x:` removes None from the else branch
  • Treats 0, empty string and empty list as equivalent to missing
  • Claims `if x:` and `if x is not None:` are interchangeable
  • Uses `x == None` instead of the identity check
  • Thinks an object with no __bool__ can be falsy by default
  • Writes `page = page or 1` and calls zero impossible

context