Why do type checkers reject `def load(path: str = None)` in Python?
answer
- Compare the annotation with the default
- Two claims on one line disagree
- A convenience the specification withdrew
- The declared type has to admit None
basics
~20 sBecause the annotation says path is always a str while the default hands it None. Under the no-implicit-Optional rule a None default no longer widens the declared type, so you must write str | None = None yourself.
solid answer
~50 sThe line makes two contradictory claims: the annotation promises a `str` on every call, and the default supplies `None` whenever the caller omits the argument. PEP 484 once let checkers widen such a parameter implicitly to `Optional[str]`; the typing specification withdrew that, and checkers have defaulted to rejecting implicit Optional for years, keeping only an opt-in legacy setting for migrations. Nothing at runtime compares a default with an annotation, so the code runs — the damage is that callers and the checker are told `None` is impossible, and the real failure lands later as an `AttributeError` deep in the call chain. The fix is to say what you mean: `path: str | None = None` if `None` is a real value, a genuine default such as `encoding: str = "utf-8"` if it was standing in for one, or no default at all if the parameter is required.
code
python · 10 linesdef rebuild(shard: str, encoding: str = None) -> str:
return f"{shard}:{encoding or 'utf-8'}"
def rebuild_fixed(shard: str, encoding: str | None = None) -> str:
return f"{shard}:{encoding or 'utf-8'}"
print(rebuild("docs"), rebuild_fixed("docs", "latin-1"))
print(rebuild.__annotations__["encoding"])go deeper
Recall that None is not a str, so a parameter annotated str cannot have None as its default. Learn the corrected spelling str | None = None and use it whenever the default really is None.
Explain the contradiction between annotation and default, that implicit Optional was permitted and then withdrawn, and that nothing at runtime compares the two - the harm is a false promise to every caller.
Demonstrate judgment about the fix: often None was a stand-in for a real default and the union is the wrong repair. Be able to describe migrating a codebase with the rule newly enforced without a big-bang change.
Own the policy: whether the legacy opt-in is allowed anywhere, how nullability is expressed at package boundaries, and how you sequence enabling strict settings across services so teams get a finite, reviewable amount of work each.
### Two statements that contradict each other `def load(path: str = None)` makes two claims on one line. The annotation says: every value this parameter ever holds is a `str`. The default says: when the caller omits the argument, this parameter holds `None`. `None` is not a `str`, so the function contradicts itself before its body starts. A static checker reports it on the `def` line, usually as an incompatible default for the declared type. ### Implicit Optional, and why it was withdrawn The rule was not always this way. PEP 484 originally allowed a checker to treat a `None` default as implicitly widening the annotation, so `path: str = None` would silently be read as `path: str | None = None`. It was convenient and it was a mistake: - the same annotation meant two different types depending on a value written elsewhere on the line; - genuine errors were swallowed — `def retry(count: int = None)` where `None` was a slip for `0` type-checked cleanly; - you could not distinguish "this parameter is a `str`, and its default happens to be `None` by accident" from "`None` is a real, intended value here". The typing specification stopped blessing the behaviour, and checkers have defaulted to rejecting implicit Optional for years. Most still keep an opt-in legacy setting, whose only honest use is making a large migration tractable one package at a time. Treat that setting as a temporary scaffold, never as a house style. ### The runtime is entirely indifferent Nothing at runtime compares a default against an annotation. The function object stores the two independently: the default lives with the code object's argument defaults, and the annotation is recorded separately. `load()` runs happily with `path` bound to `None`, and the failure surfaces later and elsewhere — typically as `AttributeError: 'NoneType' object has no attribute 'encode'` several frames deep, at a line that looks innocent because its parameter was declared non-nullable. ### The fixes, in the order you should consider them 1. **`None` is a real value here.** Write the union: `def load(path: str | None = None)`. Now the checker forces every use of `path` inside the body to handle the `None` branch, which is exactly the code you were going to have to write anyway. 2. **`None` was standing in for a real default.** Write the real one: `encoding: str = "utf-8"`. This is the most common outcome and it deletes a branch instead of typing it. 3. **The parameter is not really optional.** Drop the default and let the caller pass it. ### `None` as "the caller said nothing" Very often `= None` is not a domain value at all but a marker for "not supplied", and the first line of the body replaces it with something real. That is fine, and `X | None = None` types it honestly. What that spelling *cannot* do is distinguish "omitted" from "explicitly passed `None`" — both arrive as `None`. When that difference matters, the usual answer is a module-level sentinel object used as the default, with the parameter annotated as the union of the real type and the sentinel's type; the cost is that the sentinel now leaks into the signature, so only pay it when the distinction is genuine. ### What turning the rule on looks like on real code Consider a search-index rebuilder whose document loader carries `encoding: str = None` and threads that parameter down into a decode call. Nothing about the runtime changes when you start enforcing the rule; the 27-minute test suite stays green from beginning to end, because the code is the same code. What changes is that the checker can now see `None` flowing into a call that requires a `str`, which is precisely where the encoding mismatch had been hiding: the loader fell back to a platform-dependent decode whenever the argument was omitted. The repair is mechanical — annotate `str | None`, then resolve the fallback in one place near the top of the function rather than at each use. ### The trap in the other direction Adding the union does not add a default. `def load(path: str | None)` is still a required parameter; the caller must pass something, and that something may be `None`. Required-ness and nullability are independent axes, and a signature says both, separately. ### What an interviewer is listening for That you name the annotation/default contradiction rather than "the checker is being strict"; that you know the behaviour used to be permitted and was deliberately withdrawn; that your first instinct is to ask whether `None` is a real value here at all, rather than to reach for the union reflexively.
- If the code runs either way, what actually breaks?The contract other people and the checker read. Declaring `str` tells every reader that `None` is impossible, so a checker happily allows `path.encode()` on a value that is `None` at runtime, and nobody writes the guard. The failure then appears far from the signature, usually as an AttributeError on NoneType several frames deep, at a line whose own annotations look perfectly safe.
- How do you tell "the caller omitted this" apart from "the caller passed None deliberately"?`X | None = None` cannot: both arrive as `None`. The standard answer is a module-level sentinel object used as the default, with the parameter annotated as the union of the real type and the sentinel's type, so the body can test identity against the sentinel. It costs you a leaked name in the signature, so use it only where the distinction genuinely changes behaviour.
- Can you keep the old implicit behaviour?Checkers still expose it as an opt-in setting, but it is off by default and the typing specification no longer blesses it. Its only honest use is making a large migration tractable one package at a time while you add the real unions. Treat it as temporary scaffolding, never as a house style.
It is a signpost that contradicts the road it stands on: the annotation promises a string at every call, and the default drives None straight past it.
saying these in an interview costs you the question
- Thinks a None default automatically makes the type Optional
- Calls the wrong annotation harmless because runtime ignores it
- Silences the checker by annotating the parameter Any
- Removes the annotation entirely instead of fixing it
- Reaches for the union before asking if None is a real value