None and Implicit Return - The Invisible Return Value
Reading time: ~14 minutes | Level: Foundation → Engineering
Here is a bug that ships to production constantly:
def process(items):
items.sort() # sorts in place, returns None
result = process([3, 1, 2])
print(result) # None
print(result[0]) # TypeError: 'NoneType' object is not subscriptable
The programmer sorted a list and assigned the result. But list.sort() returns None. The next line crashes.
Why does sort() return None? What does Python return when you write no return statement? Understanding this prevents an entire class of bugs.
What You Will Learn
- What Python's
Noneobject is at the CPython level (singleton, NoneType) - Why every Python function returns a value - even with no
returnstatement - How to distinguish
is Nonefrom== Noneand why it matters - The design principle behind
list.sort()returningNone - How to use
Noneas a sentinel - and when to choose a different sentinel - How
Optional[T]type hints model nullable return values - The most common None-related production bugs and their fixes
Prerequisites
- Python function basics:
def, parameters,return(Lesson 01) - Python's object model: everything is an object
- Basic type hints are helpful but not required
None Is a Singleton Object
None is not a keyword in the way if and def are. It is a built-in constant - a real Python object, the sole instance of NoneType.
print(type(None)) # <class 'NoneType'>
print(id(None)) # 4371234560 (a fixed memory address)
x = None
y = None
print(x is y) # True - same object
print(id(x) == id(y) == id(None)) # True
There is exactly one None object in the entire Python interpreter. Every variable assigned None holds a reference to the same object.
CPython heap:
┌──────────────────────────────────────────────┐
│ │
│ address 0x4371234560 │
│ ┌────────────────────────────┐ │
│ │ PyObject │ │
│ │ ob_refcnt = 823 │ (high refcount - never freed)
│ │ ob_type → NoneType │ │
│ └────────────────────────────┘ │
│ ▲ ▲ ▲ │
│ │ │ │ │
│ x y result │
│ │
└──────────────────────────────────────────────┘
:::note CPython Optimization
CPython keeps None's reference count very high and never frees it. The None singleton lives for the entire interpreter lifetime.
:::
Implicit Return: Every Function Returns Something
In Python, every function returns a value. If no return statement is reached, the function implicitly returns None.
def greet(name):
print(f"Hello, {name}")
# no return statement
result = greet("Alice")
# prints: Hello, Alice
print(result) # None
print(type(result)) # <class 'NoneType'>
What the Bytecode Shows
import dis
def greet(name):
print(f"Hello, {name}")
dis.dis(greet)
Output:
2 0 LOAD_GLOBAL 0 (print)
2 LOAD_FAST 0 (name)
4 FORMAT_VALUE 0
6 BUILD_STRING 2
8 CALL_FUNCTION 1
10 POP_TOP
3 12 LOAD_CONST 0 (None) ← compiler inserts this
14 RETURN_VALUE ← always present
The Python compiler automatically appends LOAD_CONST None + RETURN_VALUE at the end of every code path that lacks an explicit return. Every function ends with RETURN_VALUE.
Early Returns Also Return None
def find_first(items, target):
for item in items:
if item == target:
return item # returns the found item
# falls through: implicit return None
result = find_first([1, 2, 3], 99)
print(result) # None - not found
This is a valid, idiomatic Python pattern.
return, return None, and No Return
All three are semantically identical:
def a():
return None
def b():
return
def c():
pass
print(a(), b(), c()) # None None None
The convention for which to use:
| Style | Meaning to reader |
|---|---|
return None | None is a meaningful signal: "not found", "no result" |
return | Bare early exit: "I'm done, the return value doesn't matter" |
| No return | Procedure: called for side effects only |
:::tip Style Guide
For functions called purely for side effects (like print or list.sort), omit the return or use bare return. If None signals "not found," use explicit return None.
:::
is None vs == None
Always use is None. Never use == None.
x = None
print(x is None) # True - identity check
print(x == None) # True - equality check (works, but wrong style)
They look the same until you hit a class that overrides __eq__:
class Weird:
def __eq__(self, other):
return True # claims equality with everything
w = Weird()
print(w == None) # True ← false positive
print(w is None) # False ← correct
Since None is a singleton, identity (is) is always correct and cannot be spoofed.
:::danger Never Write == None
Any class can override __eq__ to return True for None comparisons. is None uses identity - it cannot be overridden and is always correct.
:::
For non-None:
# Correct
if x is not None:
process(x)
# Wrong - can yield false negatives
if x != None:
process(x)
None in Boolean Context
None is falsy. But falsy is not the same as None:
# All falsy:
bool(None) # False
bool(0) # False
bool("") # False
bool([]) # False
bool(False) # False
Using if not x: as a None check catches all of these - which is almost always wrong:
def process(count=None):
if not count:
print("no count") # BUG: fires for count=0 too!
def process_fixed(count=None):
if count is None:
print("no count given")
elif count == 0:
print("count is zero")
process(0) # "no count" - wrong
process_fixed(0) # "count is zero" - correct
:::warning Falsy ≠ None
if not x: catches None, 0, "", [], False, and more. Use if x is None: when you specifically mean None.
:::
The Command-Query Separation Principle
list.sort() returns None deliberately. This is the Command-Query Separation (CQS) principle:
- Commands mutate state and return
None- signaling "use the mutated object, not my return value" - Queries compute and return a value without mutating state
# Command: in-place sort → None
lst = [3, 1, 2]
result = lst.sort()
print(result) # None
print(lst) # [1, 2, 3] ← lst was mutated
# Query: returns new sorted list → list
lst = [3, 1, 2]
sorted_lst = sorted(lst)
print(sorted_lst) # [1, 2, 3]
print(lst) # [3, 1, 2] ← unchanged
| Returns None (command) | Returns value (query) |
|---|---|
list.sort() | sorted(list) |
list.append(x) | list + [x] |
dict.update(d) | {**d1, **d2} |
set.add(x) | set | {x} |
:::tip Cannot Chain Commands
my_list.sort().reverse() raises AttributeError: 'NoneType' object has no attribute 'reverse'. Commands return None to prevent chaining. Use my_list.sort(); my_list.reverse() instead.
:::
None as a Sentinel Value
A sentinel signals "no value" or "not found."
Basic Pattern
def find_user(user_id: int):
db = {1: {"name": "Alice"}, 2: {"name": "Bob"}}
return db.get(user_id) # returns None if not found
user = find_user(99)
if user is None:
print("User not found")
else:
print(user["name"])
When None Is a Valid Value
cache = {}
def get_cached(key):
return cache.get(key) # None for missing AND for cached None - ambiguous!
cache["x"] = None
print(get_cached("x")) # None (cached)
print(get_cached("y")) # None (missing) - indistinguishable!
Fix: use a distinct sentinel object:
_MISSING = object() # unique - no other object equals it
def get_cached(key):
result = cache.get(key, _MISSING)
if result is _MISSING:
return compute_and_cache(key)
return result # may be None - that's the cached value
cache["x"] = None
print(get_cached("x")) # None (the cached value)
print(get_cached("y")) # computes y
:::note Sentinel Pattern
_MISSING = object() at module level. Each object() call creates a unique instance. _MISSING is _MISSING is always True; _MISSING == anything_else is always False.
:::
Optional[T] Type Hints
from typing import Optional
# Old style (Python 3.5+)
def find_user(user_id: int) -> Optional[dict]:
...
# New style (Python 3.10+)
def find_user(user_id: int) -> dict | None:
...
# Procedure: returns nothing useful
def log_event(event: str) -> None:
print(f"[LOG] {event}")
-> None tells callers "do not use this return value." -> dict | None tells callers "check for None before using the result."
Common None Bugs
Bug 1: Assigning In-Place Method Result
# BUG
data = [3, 1, 2].sort() # sort() returns None
data[0] # TypeError: 'NoneType' object is not subscriptable
# FIX
data = [3, 1, 2]
data.sort()
# or
data = sorted([3, 1, 2])
Bug 2: Forgetting to Return
# BUG
def double(x):
result = x * 2
# forgot return!
print(double(5)) # None
# FIX
def double(x):
return x * 2
Bug 3: None Propagation
# BUG
user = get_user(user_id)
email = user.email # AttributeError if user is None
# FIX
user = get_user(user_id)
if user is None:
raise ValueError(f"User {user_id} not found")
email = user.email
Bug 4: None in Arithmetic
# BUG
count = get_count() # might be None
total = count + 1 # TypeError
# FIX (precise)
count = get_count()
total = (count if count is not None else 0) + 1
# FIX (concise, but replaces 0 too - know when this is OK)
total = (get_count() or 0) + 1
Interview Questions
Q1: What does a Python function return when it has no return statement?
Answer: Every Python function returns a value. When no return statement is reached, the CPython compiler automatically inserts LOAD_CONST None and RETURN_VALUE bytecode instructions at the end of every code path. The function implicitly returns None. Python has no "void" functions - all functions return something. This is by design and consistent across all Python implementations.
Q2: Why use is None instead of == None?
Answer: None is a singleton - exactly one None object exists per interpreter. is None checks identity (same memory address), which is always correct and cannot be overridden. == None invokes __eq__, which any class can override to return True even when the object is not None. PEP 8 explicitly requires is None and is not None for None comparisons. Additionally, is is slightly faster since it skips method dispatch.
Q3: Why does list.sort() return None?
Answer: list.sort() follows the Command-Query Separation (CQS) principle. Commands (operations that mutate state) return None to make it obvious the caller should use the mutated object, not the return value. If sort() returned self, programmers might write sorted_list = my_list.sort() and assume the original is unchanged - a dangerous false assumption. Returning None forces the programmer to interact with my_list directly, making the mutation explicit. The alternative sorted() is the query - it returns a new list and leaves the original unchanged.
Q4: What is the difference between return, return None, and no return statement?
Answer: All three result in the function returning None - they are semantically identical. The style convention: use return None when None carries semantic meaning ("not found," "no result"); use bare return for early exits from procedures where the return value is irrelevant; omit the return entirely for functions called purely for side effects. Type checkers treat all three identically when the annotated return type is -> None.
Q5: When should you use a custom sentinel instead of None?
Answer: Use _MISSING = object() when None is itself a valid data value and you need to distinguish "not provided/not found" from "explicitly set to None." Classic cases: caches where None can be a cached result; optional parameters where None is a meaningful argument; APIs needing three states (has value, explicitly None, not set). Pattern: _MISSING = object() at module level, check with if value is _MISSING:. This works because object() creates a unique instance that compares unequal to everything except itself.
Q6: What is the danger of if not x: as a None check?
Answer: not x returns True for any falsy value: None, 0, 0.0, "", [], {}, set(), False, and any object whose __bool__ returns False. Using if not x: when you intend if x is None: will incorrectly trigger for legitimate falsy values like an empty list, the integer zero, or an empty string. This can cause subtle correctness bugs - the function "works" for typical inputs but fails silently for edge cases. Always use if x is None: when you specifically mean "this variable was not set."
Practice Challenges
Beginner: Find the None Bug
def get_top_scores(scores, n=3):
scores.sort(reverse=True)
top = scores[:n]
print(f"Top {n}: {top}")
results = get_top_scores([45, 88, 72, 91, 60])
print(f"Winner: {results[0]}")
Solution
# Bug: get_top_scores has no return statement.
# results is None, so results[0] raises TypeError.
# Fix: return the top scores
def get_top_scores(scores, n=3):
sorted_scores = sorted(scores, reverse=True) # non-mutating
top = sorted_scores[:n]
print(f"Top {n}: {top}")
return top # ← was missing
results = get_top_scores([45, 88, 72, 91, 60])
# Top 3: [91, 88, 72]
print(f"Winner: {results[0]}") # Winner: 91
Intermediate: Safe Nested Config Lookup
Problem: Write get_config(key, default=None) supporting dot-notation keys ("db.host"). Must correctly return None when the value is explicitly None vs. when the key doesn't exist.
config = {
"db": {"host": "localhost", "port": 5432, "password": None},
"debug": False,
"retries": 0,
}
# Expected outputs:
get_config("db.host") # "localhost"
get_config("db.password") # None (key exists, value is None)
get_config("db.user") # None (key missing, default)
get_config("db.user", "root") # "root"
get_config("debug") # False
get_config("retries") # 0
Solution
_MISSING = object()
def get_config(key: str, default=None, _config: dict = config):
parts = key.split(".")
current = _config
for part in parts:
if not isinstance(current, dict):
return default
current = current.get(part, _MISSING)
if current is _MISSING:
return default
return current # could be None - that's a valid config value
print(get_config("db.host")) # localhost
print(get_config("db.password")) # None (exists)
print(get_config("db.user")) # None (missing, default)
print(get_config("db.user", "root")) # root
print(get_config("debug")) # False
print(get_config("retries")) # 0
Advanced: Result Type
Problem: Implement a Result[T] type replacing nullable returns with an explicit success/failure container:
Result.ok(value)- wraps a success valueResult.err(message)- wraps an error message.unwrap()- returns value or raisesValueError.unwrap_or(default)- returns value or default.map(func)- applies func if ok, propagates error if not- Chainable:
get_user(id).map(lambda u: u["email"]).unwrap_or("unknown")
Solution
from __future__ import annotations
from typing import TypeVar, Generic, Callable, Optional
T = TypeVar("T")
U = TypeVar("U")
class Result(Generic[T]):
def __init__(self, value, error: Optional[str], ok: bool):
self._value = value
self._error = error
self._ok = ok
@classmethod
def ok(cls, value: T) -> Result[T]:
return cls(value, None, True)
@classmethod
def err(cls, message: str) -> Result:
return cls(None, message, False)
def is_ok(self) -> bool:
return self._ok
def unwrap(self) -> T:
if not self._ok:
raise ValueError(f"unwrap called on Err: {self._error}")
return self._value
def unwrap_or(self, default: T) -> T:
return self._value if self._ok else default
def map(self, func: Callable[[T], U]) -> Result[U]:
if self._ok:
try:
return Result.ok(func(self._value))
except Exception as e:
return Result.err(str(e))
return Result.err(self._error)
def __repr__(self):
return f"Ok({self._value!r})" if self._ok else f"Err({self._error!r})"
def get_user(uid: int) -> Result[dict]:
2: {"name": "Bob", "email": None}}
return Result.ok(db[uid]) if uid in db else Result.err(f"User {uid} not found")
def extract_email(user: dict) -> str:
if user["email"] is None:
raise ValueError("User has no email")
return user["email"]
print(get_user(2).map(extract_email).unwrap_or("unknown")) # unknown
print(get_user(99).map(extract_email).unwrap_or("unknown")) # unknown
Quick Reference
| Pattern | Code | Notes |
|---|---|---|
| Check for None | if x is None: | Always use is, never == |
| Check non-None | if x is not None: | Explicit and safe |
| Optional param | def f(x=None) | Standard pattern |
| Custom sentinel | _MISSING = object() | When None is a valid value |
| Safe default | x if x is not None else default | Precise |
| Concise default | x or default | Replaces all falsy values, not just None |
| Nullable return type | -> str | None | Python 3.10+, or Optional[str] |
| Procedure return | -> None | Side-effect-only function |
| In-place (None) | lst.sort() | Do not assign result |
| Pure sort | sorted(lst) | Assign result |
Key Takeaways
- None is a singleton - exactly one
Noneexists;x is Noneis the correct identity check - Every function returns - the compiler inserts
LOAD_CONST None; RETURN_VALUEat every path with no explicit return is Nonenot== None-==invokes__eq__which can lie;isuses identity and cannot be overriddenif not xis not a None check - it catches0,"",[],Falsetoo; useif x is None:for precision- Commands return None by design -
list.sort(),list.append(),dict.update()follow CQS; never assign their return values - Use custom sentinels when None is valid data -
_MISSING = object()gives a value that collides with nothing -> Nonein type hints is a contract - tells callers "do not use my return value"
