
Python's None is the singleton instance of NoneType, and you should test for it with is None, never == None or a bare truthiness check. In production the hard part isn't the syntax. It's that one value ends up standing for "missing," "unknown," "not provided," "explicitly cleared," and "failed," and those cases cross JSON, SQL, ORMs, and LLM outputs with different semantics at each boundary.
This guide covers the patterns we use to keep None from leaking through service layers: identity checks, sentinels, strict typing, Pydantic PATCH semantics, SQL three-valued logic, and the lint rules that enforce all of it.
Table of Contents
- ▹What None Actually Is
- ▹The Truthiness Trap
- ▹Sentinels: Distinguishing "Not Provided" from None
- ▹Typing None Strictly
- ▹Boundary Architecture: Where None Is Allowed to Live
- ▹PATCH Semantics with Pydantic
- ▹SQL NULL Is Not Python None
- ▹None, NaN, and pd.NA in Data Pipelines
- ▹None in LLM and Agent Pipelines
- ▹Return None, Raise, or Return a Result?
- ▹Enforcing It: Linters, Type Checkers, Tests
- ▹Performance: Measure, Don't Guess
- ▹Production Implementation with ByteForth
- ▹Frequently Asked Questions
What None Actually Is
None is a language-level singleton. There is exactly one instance per interpreter, which is why identity comparison is both correct and the fastest way to check for it.
>>> type(None)
<class 'NoneType'>
>>> None is None
True
>>> bool(None)
False
>>> def no_return(): pass
>>> no_return() is None
True
Three facts drive most production bugs:
- ▹Any function without an explicit
returnreturnsNone. A forgotten return in a branch silently producesNone. - ▹
Noneis falsy. So are0,"",[],{}, and0.0. All of those can be legitimate values. - ▹
Nonehas almost no behavior. Any attribute access or arithmetic on it raisesAttributeErrororTypeError, usually far from where theNonewas introduced.
The official Python data model documentation is the authoritative reference on the singleton's semantics.
The Truthiness Trap
This is the most common None bug we find in audits.
# BUG: discount of 0 is a valid value, but it gets replaced
def apply_discount(price: float, discount: float | None = None) -> float:
discount = discount or 0.10 # 0.0 -> 0.10
return price * (1 - discount)
# CORRECT: identity check
def apply_discount(price: float, discount: float | None = None) -> float:
if discount is None:
discount = 0.10
return price * (1 - discount)
The same trap exists in if not items: when None means "not loaded yet" and [] means "loaded, nothing found." Those states need different handling. Cache layers hit this constantly: if None means "cache miss" and the underlying value can also be None, you will hammer your database on every request for a missing record, which is a classic cache-penetration problem. Store a negative-cache marker instead.
_MISSING = object()
def get_user(cache, db, user_id: int):
hit = cache.get(user_id, _MISSING)
if hit is not _MISSING:
return hit # may legitimately be None (negative cache)
row = db.fetch_user(user_id)
cache.set(user_id, row, ttl=60 if row is None else 3600)
return row
Sentinels: Distinguishing "Not Provided" from None
When None is a valid input meaning "clear this field," you cannot also use it as the default meaning "leave this alone." Use a typed sentinel.
from typing import Final
class _Unset:
__slots__ = ()
def __repr__(self) -> str:
return "UNSET"
def __bool__(self) -> bool:
return False
UNSET: Final = _Un