Skip to main content

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 None object is at the CPython level (singleton, NoneType)
  • Why every Python function returns a value - even with no return statement
  • How to distinguish is None from == None and why it matters
  • The design principle behind list.sort() returning None
  • How to use None as 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:

StyleMeaning to reader
return NoneNone is a meaningful signal: "not found", "no result"
returnBare early exit: "I'm done, the return value doesn't matter"
No returnProcedure: 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 value
  • Result.err(message) - wraps an error message
  • .unwrap() - returns value or raises ValueError
  • .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]:
db = {1: {"name": "Alice", "email": "[email protected]"},
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(1).map(extract_email).unwrap_or("unknown")) # [email protected]
print(get_user(2).map(extract_email).unwrap_or("unknown")) # unknown
print(get_user(99).map(extract_email).unwrap_or("unknown")) # unknown

Quick Reference

PatternCodeNotes
Check for Noneif x is None:Always use is, never ==
Check non-Noneif x is not None:Explicit and safe
Optional paramdef f(x=None)Standard pattern
Custom sentinel_MISSING = object()When None is a valid value
Safe defaultx if x is not None else defaultPrecise
Concise defaultx or defaultReplaces all falsy values, not just None
Nullable return type-> str | NonePython 3.10+, or Optional[str]
Procedure return-> NoneSide-effect-only function
In-place (None)lst.sort()Do not assign result
Pure sortsorted(lst)Assign result

Key Takeaways

  • None is a singleton - exactly one None exists; x is None is the correct identity check
  • Every function returns - the compiler inserts LOAD_CONST None; RETURN_VALUE at every path with no explicit return
  • is None not == None - == invokes __eq__ which can lie; is uses identity and cannot be overridden
  • if not x is not a None check - it catches 0, "", [], False too; use if 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
  • -> None in type hints is a contract - tells callers "do not use my return value"
© 2026 EngineersOfAI. All rights reserved.