When is None a bad sentinel for an omitted Python argument, and what do you use instead?
answer
- The placeholder must not be valid data
- Three states, not two
- A unique private object as default
- Compare with is, never ==
- Give it a readable __repr__
basics
~20 sNone fails as a sentinel when None is itself a legal value for the parameter: the function can no longer tell an omitted argument from an explicit None. Use a private module-level object such as _MISSING = object(), tested with is.
solid answer
~50 s`None` is the right placeholder only while it means nothing to the caller. Once `None` is data — `timeout=None` meaning "block forever", `session=None` meaning "run without one", a nullable field being written through — the parameter has three states (omitted, explicitly `None`, a real value) and a two-state placeholder cannot express them. The idiom is a unique module-level object: `_MISSING = object()` as the default, tested with `if x is _MISSING`. It is safe as a default under Python's def-time evaluation rule precisely because it has no mutable state, and identity comparison cannot be spoofed by a user-defined `__eq__`. Give it a small class with a `__repr__` so it prints readably in `help()` and tracebacks, and keep it private unless callers must pass it. The alternative — accepting `**kwargs` and testing membership — is exact but erases the parameter from the signature.
code
python · 11 lines_MISSING = object()
DEFAULT_TIMEOUT = 30.0
def sync(records, timeout=_MISSING):
if timeout is _MISSING:
timeout = DEFAULT_TIMEOUT
return len(records), timeout
print(sync([]))
print(sync([], timeout=None))
print(sync([], timeout=5.0))go deeper
Know the normal case first: default optional parameters to None and test with is None. Recognise that this question is about the rarer case where None is a value the caller may legitimately send.
Be able to write the sentinel: a module-level object() as the default, compared with is, and explain why it is safe as a default when a list would not be — it has no mutable state.
Demonstrate the three-state analysis on a real parameter, and the details that show experience: a __repr__ for tracebacks and help output, keeping the sentinel private, and the **kwargs alternative with its introspection cost.
Own the contract question: whether a parameter needs a third state at all, whether the sentinel becomes public API you can never change, and how the team spells it consistently instead of five modules inventing five sentinels.
### Where `None` runs out The `None` default works because `None` is immutable, identity-testable and universally understood as "nothing here". It stops working the moment `None` becomes a **legal value for that parameter**, because then a function cannot distinguish two different caller intents: ```python def sync(records, timeout=None): if timeout is None: timeout = DEFAULT_TIMEOUT # was the caller silent, or did they say "no timeout"? ``` If `timeout=None` is supposed to mean *block forever*, the omitted case and the explicit case collapse into one branch. The parameter now has three states — omitted, explicitly `None`, an actual value — and a two-state placeholder cannot represent three states. The same shape shows up whenever `None` is meaningful: a nullable field being written to a store, `parent=None` for a root node, `session=None` meaning "run without a session". ### The sentinel object The idiom is a unique, private, module-level object compared by identity: ```python _MISSING = object() def sync(records, timeout=_MISSING): if timeout is _MISSING: timeout = DEFAULT_TIMEOUT ... ``` `object()` is the smallest thing in the language guaranteed to be unequal (and non-identical) to anything a caller could construct or import by accident. It is immutable in the sense that matters — it has no state to share — so using it as a default value is safe under the def-time evaluation rule that makes `[]` dangerous. Test it with `is`, never `==`: identity is the whole point, and `==` invites a user-defined `__eq__` to answer for you. Three refinements separate a considered design from a copied snippet: **Give it a `__repr__`.** A bare `object()` renders as `<object object at 0x104e2b160>` in `help()` output, in a traceback and in generated documentation. A one-class sentinel reads properly: ```python class _MissingType: def __repr__(self): return "<missing>" MISSING = _MissingType() ``` **Decide whether it is public.** If callers ever need to pass the sentinel explicitly — to say "restore the default" on a partial-update API — it is part of your public surface and needs a name without a leading underscore, documentation, and a stability promise. If not, keep it private; an accidentally-exported sentinel becomes a thing other modules import and compare against, and you can never change it. The standard library shows both instincts: `dataclasses.MISSING` and `inspect.Parameter.empty` are public because their APIs require callers to see them. **Never make the sentinel mutable.** A sentinel that is an empty list or dict re-creates the exact bug you were avoiding: shared state on the function object, plus a value a caller could equal by accident. ### The alternative: `**kwargs` membership You can also detect omission precisely by not declaring the parameter at all: ```python def sync(records, **options): timeout = options["timeout"] if "timeout" in options else DEFAULT_TIMEOUT ``` This is exact and needs no sentinel, but it erases the parameter from the signature: no introspection, no editor completion, no error on a misspelled keyword, and a typo becomes a silently ignored option. Use it for genuine pass-through layers; prefer the sentinel for a named parameter you want documented. ### A scenario worth having lived through An inventory sync between two systems had `def sync(records, session=None)`, where `None` meant "open your own session". A later change wanted "run without a session at all" for a dry-run mode, and the quickest-looking fix was to move a shared session into the signature — `session=make_session()` — which opened a connection at import time that no code path ever closed; the socket outlived every request and the dry-run flag still could not be expressed. The correct fix was a sentinel: `session=_MISSING` means "make me one", `session=None` means "no session", and any session object means "use this one". On an 11-person team the value of that change is not cleverness but legibility — three intents, three visibly distinct values, and no resource created by the act of importing a module. ### What to say Lead with the diagnosis: `None` fails when `None` is data. Then the mechanism: a private module-level `object()` (or a tiny class with a `__repr__`), compared with `is`, safe as a default precisely because it has no mutable state. Then the design judgement: whether the sentinel is public, and the `**kwargs` alternative with its cost. Noting that the standard library has API-specific sentinels but no general-purpose factory — you write your own — shows you know the current state of the language rather than assuming a helper exists.
- Is `...` (Ellipsis) an acceptable sentinel for this?It works — it is a unique immutable builtin singleton and identity-testable — and it prints readably. The risk is that it is *shared*: other libraries and your own callers may use `...` with a meaning of their own, and typing constructs use it too, so an explicitly passed `...` can be ambiguous. A private object you own cannot collide.
- How else can a function detect that a keyword was never passed?Accept `**options` and test `"timeout" in options`. That is exact and needs no sentinel, but the parameter disappears from the signature: no introspection, no completion, and a misspelled keyword is silently ignored instead of raising TypeError. Use it for genuine pass-through wrappers; prefer a sentinel for a parameter you want documented.
- Should the sentinel be part of the public API?Only if callers must pass it — for example a partial-update API where explicitly sending the sentinel means "reset this field to its default". Then it needs a public name, documentation and stability. Otherwise keep it underscore-private: once other modules import and compare against it, you can never change or remove it.
A sentinel is a blank ballot that no voter could ever cast: the moment a real voter could hand you the same mark, you can no longer tell abstention from a choice.
saying these in an interview costs you the question
- Uses a mutable object such as [] or {} as the sentinel
- Compares the sentinel with == instead of is
- Picks a magic string like "unset" a caller could pass
- Claims None always works because callers should not pass None
- Thinks a sentinel default is re-evaluated on each call
- Exports the sentinel publicly with no caller that needs it