Skip to content

Core semantics & gotchas

Mid / Senior

Identity 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

  1. 01`is` vs `==`, and small-int / string interning

    is checks identity — are these two names literally pointing at the exact same object in memory (the same thing id(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 (-5 to 256 inclusive) created once at interpreter startup and reused everywhere — every time your code seems to “create” the integer 5, it’s actually reusing the same pre-existing object, so a = 5; b = 5; a is b happens to be True. 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), whether a is b is True or False is 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; reserve is specifically for identity checks where identity is what you actually mean — most idiomatically, comparing against the singletons None, True, and False (if x is None: rather than if x == None:), since there’s only ever one None object 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 is for integer comparison appears to “work” when tested with small numbers (cached, so is happens to be True) 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 200 and fail for 404 with no change in logic, which is disorienting unless you know the cache exists. (CPython 3.8+ emits a SyntaxWarning: "is" with a literal for the most obvious form, x is 404, but not when both sides are variables.)

    #c-is-vs-and-small-int-string-interning
  2. 02The late-binding closure problem
    funcs = [lambda: i for i in range(5)]
    print([f() for f in funcs])  # all print 4, not 0,1,2,3,4
    

    A closure is a function that “remembers” variables from the scope it was defined in, even after that outer scope has finished running — here, each lambda is a closure over the loop variable i. 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 same i, and by the time any of them actually runs (after the list comprehension finishes), i holds its final value from the loop, 4. This is called late binding: the lookup of i happens 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 outer i and 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, when def/lambda runs, 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: i trick uses early binding (a default) to escape late binding, while the lst=None idiom moves list creation into the body to get late, per-call evaluation instead of early binding.

    #c-the-late-binding-closure-problem
  3. 03Mutable default arguments — the bug and why Python works this way
    def append_to(item, lst=[]):
        lst.append(item)
        return lst
    

    The bug: calling append_to(1) then append_to(2) gives you [1, 2] on the second call, not a fresh [2] — because the default value [] is a single mutable object (a list, which can be changed in place) that is created exactly once, at the moment the def statement itself runs (function definition time), not fresh on every call. Every call that doesn’t explicitly supply lst reuses 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 (like lst=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, then if 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.

    #c-mutable-default-arguments-the-bug-and-why-python-works-this-
  4. 04`copy.copy` vs `copy.deepcopy` — where a shallow copy silently bites

    A 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.

    #c-copycopy-vs-copydeepcopy-where-a-shallow-copy-silently-bites
  5. 05`if x:` vs `if x is not None:` for numpy arrays and pandas Series — the ambiguous-truth-value error

    if x: calls bool(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 raise ValueError rather than silently picking one interpretation. if x is not None: instead performs a plain identity check against the None singleton, entirely sidestepping bool() and this ambiguity altogether.

    #c-if-x-vs-if-x-is-not-none-for-numpy-arrays-and-pandas-series-
  6. 06Why relying on guaranteed dict insertion order for program logic is a footgun

    Insertion-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.

    #c-why-relying-on-guaranteed-dict-insertion-order-for-program-l
  7. 07What the walrus operator (`:=`) is actually for

    The 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: reuses n afterward without a separate, redundant len(data) call), not merely a cosmetic line-count reduction.

    #c-what-the-walrus-operator-is-actually-for
  8. 08Structural pattern matching (`match`/`case`) vs an `if/elif` chain — where it helps and where it's overused

    match/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 each case can 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 separate isinstance checks, length checks, and manual indexing/unpacking the way an equivalent if/elif chain 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/elif chain can’t express as directly:

    • Literal / OR patterns ("quit" | "exit") — match several exact values in one branch, like a case label 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, with str(name) additionally requiring that value to be a str.
    • Class patterns (Point(x=0, y=0)) — combine an isinstance check with attribute matching/destructuring in one go; this relies on the class defining __match_args__ (or using keyword patterns like x=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 trailing if guard.
    • 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 next case rather than failing the whole statement.
    • Wildcard (_) — matches anything without binding it, conventionally last, playing the role of else.

    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/elif chain 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 plain if/elif chain or even a dict-based dispatch is just as clear and doesn’t need pattern matching’s extra machinery, and reaching for match there mostly just signals “I wanted to use the new syntax” rather than a genuine structural-matching need.

    #c-structural-pattern-matching-matchcase-vs-an-ifelif-chain-whe