TL;DR — Python's syntax is small enough to learn in a weekend; what takes longer is its object model, because that is where the bugs live. A name is not a box holding a value, it is a label attached to an object, and several labels can point at the same object. That single fact explains the mutable-default-argument bug (the default list is created once, at
deftime, and every call shares it), whyisand==disagree, and why a function that "worked in testing" starts leaking state between users in production. Add two more facts — every object has a truth value, so[0.0]is truthy while[]is falsy, and floats are binary approximations, so0.1 + 0.2 != 0.3— and you have covered the four ways Python most often returns a wrong answer instead of an error. In the runnable example below, all four produce plausible output and none of them raise. That is what makes them worth an article.
1. Simple explanation
Most languages teach you to think of a variable as a box. You put a value in the box, and the box has a name.
Python does not work that way. In Python, the object exists somewhere in memory, and a name is a label you stick on it. Assignment does not copy anything into a box; it moves a label.
x = [1, 2, 3] # create a list object, stick the label 'x' on it
y = x # stick a SECOND label on the SAME object
y.append(4) # modify the object
print(x) # [1, 2, 3, 4] -- 'x' sees it too, because it is one object
Nothing was copied. There is one list with two labels. If you expected x to be untouched, you were thinking in boxes.
This matters because Python objects come in two kinds. Mutable objects — lists, dicts, sets — can be changed in place, so a change made through one label is visible through every other label. Immutable objects — ints, strings, tuples — cannot be changed at all, so x = x + 1 does not modify the number x points at; it creates a new number and re-points the label. Immutable objects feel like boxes, which is exactly why the box mental model survives long enough to hurt you later.
Analogy — a shared document, not a photocopy. When you email someone a PDF, they get a copy; scribbling on theirs leaves yours alone. When you share a link to a live document, you both have a pointer to one thing, and their edits appear in your window. Python assignment is the link, not the photocopy. Every "why did my list change?" bug is someone who thought they had sent a PDF.
The other three fundamentals in this article are smaller but equally load-bearing: when Python evaluates default arguments (once, at definition), what counts as true (anything not empty or zero), and what a float actually is (a binary fraction, not the decimal you typed).
2. Diagram
NAMES AND OBJECTS -- assignment moves labels, it does not copy
x = [1, 2, 3] x ──────┐
▼
y = x ┌──────────────┐
│ list object │
y.append(4) │ [1, 2, 3, 4] │
└──────────────┘
print(x) -> [1,2,3,4] y ──────┘
ONE object, TWO labels
DEFAULT ARGUMENTS -- evaluated at `def` time, not at call time
def f(text, history=[]): ← the [] is built ONCE, here,
history.append(text) when the module is imported
return history and stored on the function object
f("hello") ──┐
f("world") ──┼──► the SAME list ['hello', 'world']
f("again") ──┘ grows across every call, forever
def f(text, history=None): ← None is immutable, nothing to share
history = [] if history is None else history
↑ a NEW list, per call
TRUTHINESS -- "empty" is falsy, and it is not the same as "missing"
value bool() is None what it usually means
─────────────────────────────────────────────────────────
[] False False searched, found nothing
None False True never searched / it failed
[0.0] True False found ONE result, score 0.0
0 False False a real, measured zero
"" False False a real, empty answer
`if not results:` collapses rows 1, 2, 4 and 5 into one branch.
FLOATS -- what you type is not what is stored
you type 0.1
binary can 0.1000000000000000055511151231257827021181583404541015625
store only └─────────── the nearest representable double ──────────┘
0.1 + 0.2 = 0.30000000000000004 ≠ 0.3
└─ error accumulates; == is a coin flip
3. How it works
Names, objects, and rebinding
Every value in Python is an object with three properties: an identity (its address, returned by id()), a type, and a value. A name is an entry in a namespace dictionary mapping the string "x" to a reference to that object.
Assignment (x = ...) rebinds the name. Mutation (x.append(...), x[0] = ...) changes the object. These are entirely different operations, and the distinction is the single most useful thing to internalise:
def rebind(lst):
lst = [99] # rebinds the LOCAL name; caller sees nothing
def mutate(lst):
lst.append(99) # changes the shared object; caller sees it
There is no "pass by value" or "pass by reference" in Python; arguments are bound to the same objects the caller passed. Whether the caller observes a change depends entirely on whether you mutated or rebound.
Why default arguments are evaluated once
A def statement is executable code. When Python runs it, it builds a function object and evaluates the default expressions right then, storing the results in func.__defaults__. It never re-evaluates them.
For an immutable default (0, None, "x") this is invisible — you cannot modify the shared object, so nobody notices. For a mutable default it means every call that omits the argument shares one list, accumulating state for the lifetime of the process. You can inspect it directly:
def f(x, history=[]):
history.append(x)
return history
f.__defaults__ # ([],) -- and it grows as you call f
The fix is always the same: use None as the sentinel and build the real default inside the body. The one nuance is that None becomes unavailable as a legitimate argument value; if a caller genuinely needs to pass "nothing" as distinct from "not specified," use a module-level sentinel object instead.
Truthiness
if x: does not test whether x exists. It calls bool(x), which consults __bool__ if defined, else __len__, else defaults to True. So:
- Empty containers (
[],{},set(),"") are falsy because their length is zero. 0,0.0, andDecimal(0)are falsy.Noneis falsy.- Everything else is truthy, including
[0.0],[False],"0", and"False".
The production bug this causes is collapsing distinct states. if not results: fires for "the search returned zero documents," for "the search failed and returned None," and for "results was never assigned." Those need different handling — one is a legitimate empty answer to show the user, one is an error to retry or log. Test explicitly: if results is None: for the failure, elif len(results) == 0: for the empty result.
Floats
Python floats are IEEE-754 double-precision binary. Decimal 0.1 has no exact binary representation, exactly as 1/3 has no exact decimal representation. What gets stored is the nearest representable double, and arithmetic on approximations accumulates error.
This is not a Python quirk — it is the same in C, Java, and JavaScript. It matters here because similarity scores, thresholds, and probabilities are floats, and score == 0.5 or total == 1.0 will fail unpredictably. Use math.isclose(a, b), or compare against a tolerance. For money, use decimal.Decimal and never floats at all.
Errors: try/except and what to catch
The last fundamental is exception handling, and the rule is narrow: catch the specific exception you know how to handle, at the level where you can actually do something about it.
try:
resp = client.get(url, timeout=5)
except TimeoutError:
resp = None # you know what to do: degrade
except Exception: swallows programming errors — typos, AttributeError, bad arguments — and turns a loud crash into a silent wrong answer, which is the theme of this entire article. except: bare is worse still; it catches KeyboardInterrupt and SystemExit, so your process ignores Ctrl-C. If you must catch broadly at a service boundary, log the traceback and re-raise or return an explicit error state.
4. The math
Floating point is the one piece of this article with real arithmetic behind it, and understanding it takes about five minutes.
A double-precision float stores a number as:
$$ v = (-1)^{s} \times 1.m \times 2^{e} $$
with 1 sign bit, 11 exponent bits, and 52 mantissa bits — 64 bits total. The mantissa is a binary fraction: each bit after the point is worth $2^{-1}, 2^{-2}, 2^{-3}, \dots$
To store decimal 0.1, you need $0.1 = \sum_i b_i 2^{-i}$ for some choice of bits. Working it out gives the repeating pattern:
$$ 0.1_{10} = 0.0\overline{0011}_2 $$
It repeats forever, exactly as $1/3 = 0.333\ldots$ repeats in decimal. With only 52 mantissa bits available, the sum is truncated, and the stored value is:
$$ 0.1 \to 0.1000000000000000055511151231257827\ldots $$
The error is about $5.55 \times 10^{-18}$. Add 0.1 + 0.2, and each operand carries its own representation error; the sum lands on a representable double that is not the one nearest to 0.3:
$$ 0.1 + 0.2 = 0.30000000000000004440892098500626 $$
which differs from stored 0.3 in the last bit. Hence == returns False.
The practical consequence is a rule about relative versus absolute tolerance. The gap between adjacent representable doubles — the unit in the last place — scales with magnitude. Near 1.0 it is about $2.2 \times 10^{-16}$; near 1,000,000 it is about $1.2 \times 10^{-10}$. So a fixed absolute tolerance of 1e-9 is far too loose for values near zero and far too tight for large ones. math.isclose defaults to a relative tolerance of 1e-9, which is the right default:
$$ \text{isclose}(a,b) \iff |a-b| \le \max(\text{rel} \cdot \max(|a|,|b|),\ \text{abs}) $$
Set abs_tol explicitly when either value may legitimately be zero, because relative tolerance around zero degenerates.
One more consequence worth knowing: float addition is not associative. (0.1 + 0.2) + 0.3 and 0.1 + (0.2 + 0.3) give different results. This is why summing a long list of small numbers loses accuracy, and why math.fsum (which tracks the error term) exists.
5. Real code
Four behaviours, none of which raises an exception, all of which return something plausible and wrong.
"""Four Python behaviours that produce silent wrong answers, not exceptions."""
# ---- 1. the mutable default argument ------------------------------------
def add_message_BROKEN(text, history=[]): # evaluated ONCE, at def time
history.append(text)
return history
def add_message(text, history=None):
history = [] if history is None else history # a fresh list per call
history.append(text)
return history
print("1. MUTABLE DEFAULT ARGUMENT -- the default is created once, at def time")
a = add_message_BROKEN("hello")
b = add_message_BROKEN("world") # a different 'conversation'
print(f" broken: first call {a}")
print(f" second call {b} <- the first message leaked in")
print(f" and a is b: {a is b} -- they are the SAME list object")
c, d = add_message("hello"), add_message("world")
print(f" fixed : {c} and {d}")
# ---- 2. truthiness of empty containers ----------------------------------
print("\n2. TRUTHINESS -- empty and missing are both falsy, and they differ")
for value, label in [([], "empty list"), (None, "missing"), ([0.0], "one zero score"),
(0, "zero"), ("", "empty string")]:
print(f" {label:<16} bool()={bool(value)!s:<6} is None={value is None}")
print(" -> `if not results:` treats 'retrieved nothing' and 'retrieval failed'")
print(" as the SAME case. They need different handling.")
# ---- 3. `is` vs `==` ------------------------------------------------------
print("\n3. `is` vs `==` -- identity, not equality, and it is not predictable")
lit_a, lit_b = 257, 257 # literals in ONE compiled module
run_a, run_b = int("257"), int("257") # built at runtime, so no interning
small_a, small_b = int("256"), int("256")
print(f" 257 is 257 (literals) -> {lit_a is lit_b}")
print(f" int('257') is int('257') -> {run_a is run_b} <- SAME VALUE, different answer")
print(f" int('256') is int('256') -> {small_a is small_b} <- and 256 IS cached")
print(f" int('257') == int('257') -> {run_a == run_b} always what you meant")
print(" Note the first line: the textbook '257 is 257 -> False' demo does NOT")
print(" reproduce inside one module, because CPython interns constants per code")
print(" object. That is the real lesson -- identity is an implementation detail.")
print(" -> use `is` ONLY for None, True, False. Never for values.")
# ---- 4. floating point ----------------------------------------------------
print("\n4. FLOATS -- 0.1 + 0.2 is not 0.3, and this reaches similarity scores")
s = 0.1 + 0.2
print(f" 0.1 + 0.2 = {s!r}")
print(f" == 0.3 -> {s == 0.3}")
print(f" isclose -> {abs(s - 0.3) < 1e-9}")
print(" -> never compare scores or thresholds with ==; use a tolerance.")
assert a is b and a == ["hello", "world"] # the shared-state bug
assert c == ["hello"] and d == ["world"] # the fix
assert bool([]) is False and bool([0.0]) is True # a list holding 0.0 is TRUTHY
# Small ints are cached; larger ones built at runtime are distinct objects.
assert small_a is small_b
assert run_a is not run_b and run_a == run_b
# ...and literals in one module are interned, so the classic demo misleads.
assert lit_a is lit_b
assert (0.1 + 0.2) != 0.3
print("\nall assertions passed")
Output:
1. MUTABLE DEFAULT ARGUMENT -- the default is created once, at def time
broken: first call ['hello', 'world']
second call ['hello', 'world'] <- the first message leaked in
and a is b: True -- they are the SAME list object
fixed : ['hello'] and ['world']
2. TRUTHINESS -- empty and missing are both falsy, and they differ
empty list bool()=False is None=False
missing bool()=False is None=True
one zero score bool()=True is None=False
zero bool()=False is None=False
empty string bool()=False is None=False
-> `if not results:` treats 'retrieved nothing' and 'retrieval failed'
as the SAME case. They need different handling.
3. `is` vs `==` -- identity, not equality, and it is not predictable
257 is 257 (literals) -> True
int('257') is int('257') -> False <- SAME VALUE, different answer
int('256') is int('256') -> True <- and 256 IS cached
int('257') == int('257') -> True always what you meant
Note the first line: the textbook '257 is 257 -> False' demo does NOT
reproduce inside one module, because CPython interns constants per code
object. That is the real lesson -- identity is an implementation detail.
-> use `is` ONLY for None, True, False. Never for values.
4. FLOATS -- 0.1 + 0.2 is not 0.3, and this reaches similarity scores
0.1 + 0.2 = 0.30000000000000004
== 0.3 -> False
isclose -> True
-> never compare scores or thresholds with ==; use a tolerance.
all assertions passed
Three things in that output are worth pausing on.
a is b is True. The two calls did not merely produce equal lists — they produced the same object. In a web service, "the same object" means user B sees user A's data.
[0.0] is truthy but 0 is falsy. A list containing a zero score is not empty, so it passes if results:. But if you unwrap it first and test the score itself, it fails. The same data, two different branches, depending on where you put the check.
The is demo does not do what the textbooks claim. Almost every tutorial shows 257 is 257 → False to illustrate that only small integers are cached. Run it inside a single module and you get True, because CPython interns constants within a code object. The lesson is stronger than the one usually taught: identity for values is an implementation detail that changes between contexts and between interpreter versions, so it is never something to build logic on.
6. Real-world example
A retrieval service handling concurrent requests, with three of these four bugs in eleven lines:
def search(query, seen=[]): # BUG 1: shared across all users
hits = vector_store.query(query)
if not hits: # BUG 2: conflates empty and failed
return "I don't know."
top = hits[0]
if top.score == THRESHOLD: # BUG 3: float equality
log.info("exactly at threshold")
seen.append(query)
return top.text
In staging with one developer clicking through, this behaves perfectly. In production:
Bug 1 makes seen grow without bound for the process lifetime, so it is a memory leak, and because it is shared across requests it is also a privacy leak — every user's queries in one list. This is the kind of finding that turns a performance ticket into an incident report.
Bug 2 means that when the vector store times out and the client returns None, the user is told "I don't know" — a confident, wrong, cached-looking answer — instead of the service retrying or surfacing an error. The dashboards show a healthy 200 response. Nobody finds out until someone reads the transcripts.
Bug 3 simply never fires. The log line was added to debug threshold tuning and silently produces nothing, so the engineer concludes scores never land at the threshold.
The fixed version separates every case the broken one collapsed:
def search(query, seen=None):
seen = [] if seen is None else seen
hits = vector_store.query(query) # raises or returns a list
if hits is None:
raise RetrievalError("vector store returned None")
if len(hits) == 0:
return "I don't know." # a real, empty answer
top = hits[0]
if math.isclose(top.score, THRESHOLD, rel_tol=1e-9):
log.info("at threshold: %.6f", top.score)
seen.append(query)
return top.text
Note that the threshold comparison here is a logging concern. For the actual routing decision you would use >=, not equality — see Vector Search for Retrieval for why the threshold choice matters more than the comparison operator.
7. Interview questions companies actually ask
"What does def f(x, items=[]) do, and why is it a problem?" The default list is created once when the def statement executes, and stored on the function object. Every call that omits items mutates the same list. Fix with items=None and build inside. Strong answer adds: it is only a problem for mutable defaults, and you can see the accumulated state in f.__defaults__.
"Difference between is and ==?" is compares identity (same object), == compares value (via __eq__). Use is only for None, True, False, and sentinel objects. Strong answer: small-int caching and string interning make is appear to work for values, which is exactly what makes it dangerous — it is an implementation detail, not a guarantee.
"Why does 0.1 + 0.2 != 0.3?" Binary floating point cannot represent decimal 0.1 exactly, so both operands are approximations and the sum lands on a different representable double than stored 0.3. Use math.isclose, or Decimal for money. Strong answer notes float addition is not associative, so summation order changes results.
"Is Python pass-by-value or pass-by-reference?" Neither, precisely. Arguments bind names to the caller's objects. Mutating the object is visible to the caller; rebinding the name is not. The usual name is "pass by object reference" or "call by sharing."
"What is falsy in Python?" None, False, numeric zero, empty containers, and any object whose __bool__ returns False or whose __len__ returns 0. The follow-up they actually want: why if not x: is a bug when x could legitimately be 0 or [].
"When would you use a bare except:?" Essentially never. It catches KeyboardInterrupt and SystemExit. If you need a broad catch at a service boundary, use except Exception:, log the traceback, and re-raise or return an explicit error.
8. When to use / tradeoffs
Use None as the default sentinel — always, for mutable defaults. There is no case where a mutable default is correct that would not be clearer as an explicit module-level constant. The only cost is one extra line in the body.
Use truthiness for genuine "is there anything here" checks, where empty and missing really are the same case — cleaning up an optional list of tags, for instance. Use explicit is None and len(x) == 0 whenever the two states need different handling, which in any service that can fail is most of the time. The tradeoff is verbosity against correctness, and verbosity wins in error paths.
Use math.isclose for float comparison, with abs_tol set explicitly when values can be zero. Use decimal.Decimal for currency and anything where a user will check your arithmetic by hand; it is roughly 50–100× slower than float, which is irrelevant for invoices and prohibitive inside a training loop.
Use == for values, is for singletons. No tradeoff here — is on values is simply wrong even when it happens to work.
Catch narrow exceptions close to where you can act. Broad catches are appropriate exactly once per service, at the top-level request boundary, where the job is to log and return a 500 rather than to recover.
The meta-tradeoff worth naming: every one of these fixes makes the code slightly longer and slightly less elegant. That is the correct trade. All four bugs share the property that they produce plausible output, which means testing does not catch them and code review usually does not either — the broken versions read perfectly well. Defensive explicitness is cheap insurance against a class of bug that is otherwise found only by reading production transcripts.
9. Summary + related articles
Python's syntax is not where the difficulty is. The object model is: names label objects, several names can label one object, and mutation through any label is visible through all of them. From that follow the mutable-default bug and the pass-semantics confusion. Two further facts — truthiness collapses distinct states, and floats are binary approximations — round out the four failure modes that return wrong answers instead of raising.
What ties them together is silence. None of them crashes. Each returns something a reviewer would accept. That is why they are worth learning explicitly rather than absorbing by experience, because experience here means a production incident.
Related articles
- Python Data Structures — which container to reach for, and the O(n) membership test that quietly dominates runtime
- Python Advanced — generators, decorators, context managers, and the dataclass fix for mutable defaults
- Python Best Practices — type hints, tests, and logging: the habits that catch these bugs before production
- Async Programming — where shared mutable state stops being a style question and becomes a concurrency bug
- Linear Algebra — the next foundation, and where float precision starts to matter numerically
- Vector Search for Retrieval — similarity scores are floats, and thresholds on them are the example in §6
Resources
- Python Language Reference, "Execution model" — the authoritative description of names, binding, and scope: https://docs.python.org/3/reference/executionmodel.html
- Python Tutorial, "Floating Point Arithmetic: Issues and Limitations" — the standard library's own explanation, short and worth reading in full: https://docs.python.org/3/tutorial/floatingpoint.html
- Goldberg, D. (1991). What Every Computer Scientist Should Know About Floating-Point Arithmetic. ACM Computing Surveys 23(1) — the canonical reference: https://dl.acm.org/doi/10.1145/103162.103163
math.iscloseand PEP 485, which introduced it and explains the relative/absolute tolerance design: https://peps.python.org/pep-0485/- Ramalho, L. — Fluent Python (2nd ed.), Ch. 6 "Object References, Mutability, and Recycling" — the best long-form treatment of the object model
- Hettinger, R. — Transforming Code into Beautiful, Idiomatic Python (PyCon 2013) — still the most efficient hour for moving from working Python to idiomatic Python
- The Python
dismodule — disassemble a function to see for yourself when defaults are evaluated: https://docs.python.org/3/library/dis.html