Core semantics & gotchas
Mid / SeniorIdentity vs equality, name binding, closures, mutable defaults, copying, truthiness, and the small syntax features (walrus, match/case) whose semantics trip people up.
Created 23 October 2026
8 concepts · detail hidden
01`is` vs `==`, and small-int / string interning
#c-is-vs-and-small-int-string-interningischecks identity — are these two names literally pointing at the exact same object in memory (the same thingid(obj)would report equal for)?==checks equality — does calling the object’s__eq__method say these two objects represent the same value, even if they’re two separate objects in memory. For simple immutable values these often coincide by accident, which is exactly the trap: CPython, purely as a memory optimization, keeps a cache of small integers (-5to256inclusive) created once at interpreter startup and reused everywhere — every time your code seems to “create” the integer5, it’s actually reusing the same pre-existing object, soa = 5; b = 5; a is bhappens to beTrue. Certain string literals get similarly interned (deduplicated into a single shared object) in some situations, particularly simple identifier-like strings. This is purely a CPython implementation detail, not a language guarantee — outside the small-int cache (e.g.,a = 1000; b = 1000), whethera is bisTrueorFalseis unspecified and can differ between Python versions or even between runs, so code that relies on it is fragile. The practical lesson: always use==for value comparison; reserveisspecifically for identity checks where identity is what you actually mean — most idiomatically, comparing against the singletonsNone,True, andFalse(if x is None:rather thanif x == None:), since there’s only ever oneNoneobject in a running interpreter.The bug the small-int cache explains
You should never rely on the cache for correctness, but it’s worth knowing because it explains a genuinely confusing class of bug: code that accidentally uses
isfor integer comparison appears to “work” when tested with small numbers (cached, soishappens to beTrue) and then silently breaks once real production data contains integers outside the cached range.def is_expected(status_code, expected): return status_code is expected # BUG: should be == is_expected(int("200"), 200) # -> True (200 is in the -5..256 cache) is_expected(int("404"), 404) # -> False (two distinct int objects with the same value)The tests pass for
200and fail for404with no change in logic, which is disorienting unless you know the cache exists. (CPython 3.8+ emits aSyntaxWarning: "is" with a literalfor the most obvious form,x is 404, but not when both sides are variables.)02The late-binding closure problem
#c-the-late-binding-closure-problemfuncs = [lambda: i for i in range(5)] print([f() for f in funcs]) # all print 4, not 0,1,2,3,4A closure is a function that “remembers” variables from the scope it was defined in, even after that outer scope has finished running — here, each
lambdais a closure over the loop variablei. The surprising part: Python closures capture variables by reference to the name, not by snapshotting the value at the moment the function was created — so all five lambdas share the samei, and by the time any of them actually runs (after the list comprehension finishes),iholds its final value from the loop,4. This is called late binding: the lookup ofihappens at call time, not at definition time. It surprises people because in many other languages (and in some other parts of Python itself — see the mutable-default-argument case below, which is the opposite, early-binding problem), you’d expect each iteration to “freeze” its own value. The standard fix is to force early binding by capturing the current value as a default argument, which is evaluated once immediately when the lambda is defined:lambda i=i: i— the parameter name shadows the outeriand gets its value copied in right away, once per lambda, rather than being looked up later.functools.partial(lambda i: i, i)achieves the same early-capture effect a different way.Early vs late binding — one pattern behind several gotchas
Most things in Python are late-bound: global and closure variable lookups, attribute access, and method resolution all happen fresh at the moment of access/call, against whatever the runtime state is then. That is why the closure above sees the final
i, and why monkey-patching a method on a class affects instances that already exist. The notable exception is default argument values, which are early-bound — evaluated once, whendef/lambdaruns, and stored on the function object (f.__defaults__). Seen as one unified rule rather than unrelated trivia, the two classic gotchas fall out of it:Gotcha Binding Surprise Loop-variable closures late every closure sees the variable’s final value Mutable default arguments early one default object is shared across all calls And the standard fixes are each other’s mirror image: the
lambda i=i: itrick uses early binding (a default) to escape late binding, while thelst=Noneidiom moves list creation into the body to get late, per-call evaluation instead of early binding.03Mutable default arguments — the bug and why Python works this way
#c-mutable-default-arguments-the-bug-and-why-python-works-this-def append_to(item, lst=[]): lst.append(item) return lstThe bug: calling
append_to(1)thenappend_to(2)gives you[1, 2]on the second call, not a fresh[2]— because the default value[]is a single mutable object (alist, which can be changed in place) that is created exactly once, at the moment thedefstatement itself runs (function definition time), not fresh on every call. Every call that doesn’t explicitly supplylstreuses that same shared list object, and since the function mutates it in place (.append), changes persist and accumulate across calls. This is genuinely consistent with how Python evaluates default argument expressions in general — they’re computed once, when the function is defined, and stored as part of the function object — it just happens to be surprising specifically when the default is mutable, because with an immutable default (likelst=None, or a number, or a string) there’s nothing to mutate, so the “shared object” fact is invisible. Understanding this as “defaults are early-bound, evaluated once” rather than memorizing “never use[]as a default” is the real test — the fix (lst=None, thenif lst is None: lst = []inside the function body) works by moving the list creation to be re-executed fresh on every call, inside the function body, rather than once at definition time.04`copy.copy` vs `copy.deepcopy` — where a shallow copy silently bites
#c-copycopy-vs-copydeepcopy-where-a-shallow-copy-silently-bitesA shallow copy (
copy.copy) only duplicates the outermost container itself; any nested mutable objects inside it remain shared references between the original and the copy. It silently bites you the moment you mutate a nested mutable object through either the original or the copy — that mutation is visible through both, since they’re still, underneath, referencing the exact same inner object.05`if x:` vs `if x is not None:` for numpy arrays and pandas Series — the ambiguous-truth-value error
#c-if-x-vs-if-x-is-not-none-for-numpy-arrays-and-pandas-series-if x:callsbool(x), which for a numpy array or pandas Series with more than one element is genuinely ambiguous — numpy/pandas deliberately refuse to guess whether you meant “any element truthy,” “all elements truthy,” or something else entirely, and raiseValueErrorrather than silently picking one interpretation.if x is not None:instead performs a plain identity check against theNonesingleton, entirely sidesteppingbool()and this ambiguity altogether.06Why relying on guaranteed dict insertion order for program logic is a footgun
#c-why-relying-on-guaranteed-dict-insertion-order-for-program-lInsertion-order preservation is a genuine, real language guarantee since 3.7, but relying on it for actual program logic (rather than merely display/iteration) risks quietly, silently breaking correctness the moment any code path re-inserts, deletes-then-reinserts, or otherwise reorders keys in a way the original design didn’t anticipate — dict ordering reflects insertion history, not any kind of semantic priority or sorted order, and treating it as the latter is a genuinely easy, real mistake.
07What the walrus operator (`:=`) is actually for
#c-what-the-walrus-operator-is-actually-forThe walrus operator (
:=, PEP 572, Python 3.8+) lets you assign a value to a name as part of a larger expression, rather than requiring a separate statement — genuinely useful specifically for avoiding computing the same value twice (e.g.,if (n := len(data)) > 10:reusesnafterward without a separate, redundantlen(data)call), not merely a cosmetic line-count reduction.08Structural pattern matching (`match`/`case`) vs an `if/elif` chain — where it helps and where it's overused
#c-structural-pattern-matching-matchcase-vs-an-ifelif-chain-whematch/case(added in Python 3.10) superficially resembles a switch statement from other languages, but it’s doing something meaningfully more powerful: structural pattern matching, meaning eachcasecan simultaneously check the shape of the value (is it a list of exactly two elements? a dict with a specific key? an instance of a particular class?) and destructure/bind parts of it to new names, all in one concise pattern, rather than needing a chain of separateisinstancechecks, length checks, and manual indexing/unpacking the way an equivalentif/elifchain would require.from dataclasses import dataclass @dataclass class Point: x: int y: int def handle(command: object) -> str: match command: case "quit" | "exit": # OR pattern — either literal matches return "stopping" case ["move", x, y]: # sequence pattern — exact length, binds x/y return f"moving to {x},{y}" case ["move", *rest]: # star pattern — any length, captures the rest return f"moving with extra args {rest}" case {"action": "greet", "name": str(name)}: # mapping pattern — only checks the keys given return f"hello {name}" case Point(x=0, y=0): # class pattern — isinstance + attribute check return "at the origin" case Point(x=x, y=y) if x == y: # class pattern + guard clause return f"on the diagonal at {x}" case Point(): # class pattern, no attribute constraints return "some other point" case int() | float() as n if n < 0: # type check, OR, capture-as-name, and a guard return f"negative number {n}" case _: # wildcard — matches anything, catches the rest return "unrecognized"Each pattern kind does something an
if/elifchain can’t express as directly:- Literal / OR patterns (
"quit" | "exit") — match several exact values in one branch, like acaselabel in other languages. - Sequence patterns (
["move", x, y]) — check the length and unpack in one step;["move", *rest]matches any length and captures the remainder, like extended iterable unpacking. - Mapping patterns (
{"action": "greet", "name": str(name)}) — check for the given keys (extra keys in the dict are ignored unless you add**rest) and bind matched values, withstr(name)additionally requiring that value to be astr. - Class patterns (
Point(x=0, y=0)) — combine anisinstancecheck with attribute matching/destructuring in one go; this relies on the class defining__match_args__(or using keyword patterns likex=x) so positional patterns know which attributes map to which position. - Capture patterns (
x,y,as n) — bind part of the matched value to a new name, available in that branch’s body and in a trailingifguard. - Guard clauses (
if x == y) — an extra boolean condition checked only after the pattern itself already matched; if the guard is false, matching falls through to the nextcaserather than failing the whole statement. - Wildcard (
_) — matches anything without binding it, conventionally last, playing the role ofelse.
It’s genuinely better specifically when you’re branching on the structure of data — e.g., parsing a small domain-specific command language, handling different shapes of parsed JSON/AST nodes, or matching against several different possible “shapes” of a class hierarchy — situations where an equivalent
if/elifchain would be full of repetitive, error-prone manual destructuring. It’s overused (or at least not clearly better) when you’re just doing simple value equality checks against a flat set of constants (if x == "a": ... elif x == "b": ...) — there, a plainif/elifchain or even a dict-based dispatch is just as clear and doesn’t need pattern matching’s extra machinery, and reaching formatchthere mostly just signals “I wanted to use the new syntax” rather than a genuine structural-matching need.- Literal / OR patterns (