Dinesh/ Blog
python

Python Variables & Types — Mental Model of Memory

Trace Python references, identity, mutation, copying, hashability and defaults with executable object-model examples.

22 min readChecking device speech…
Lesson preparation & details

Level: beginner

Python and software engineering · Lesson 5 of 15 ↗
Loading this browser’s progress…

By the end, you should be able to

  • Assignment binds names; copying is explicit and shallow copies share nested objects

Sources checked: . A source check does not change the publication or update date.

Editorial review: · What review means

In this article · 21 sections

Review boundary and corrected contracts

Assignment binds names; copying is explicit and shallow copies share nested objects. Tuple immutability does not imply hashability of all tuples. Integer identity beyond documented singleton behavior is not portable. The intentional tuple-assignment error is caught as a teaching assertion.

The examples below teach the stated input contracts; fragments and deliberately incorrect counterexamples are exercises, not production implementations. Complexity counts elementary operations unless a bit-cost or allocation caveat is stated. Practice times are suggestions, not measured performance or hiring guarantees.

Learning objective: Predict what a piece of Python code does to memory — references, mutation, identity vs equality — before running it, including expected exceptions and implementation-dependent results.

Why this session exists

Three recurring sources of Python bugs are: a mutable default argument that grows across calls, an assignment that "unexpectedly" changes another variable, and an is check that returns True when they expected False (or vice versa). All three come from missing the same mental model — variables in Python are labels on objects, not boxes containing values. This model explains these failures without relying on allocation accidents.

You will be able to

  • Draw the memory diagram for these assignments: x = 5, x = [1,2,3], y = x, x.append(4).

  • Explain the difference between is and ==, and predict which one to use in practice and reviews.

  • Recognize the common built-in types you will use, and know which are mutable vs immutable — and distinguish shallow from deep immutability.

  • Reason about type annotations well enough to add def add(a: int, b: int) -> int: and understand why it matters.

  • Diagnose the mutable-default-argument bug on sight, and know at least two fixes.

Prerequisites


Pre-read · skim before the session (30 min total)

Further reading: Python Language Reference · Data model

Further reading: Immutable vs Mutable Data Types in Python

Further reading: PEP 8 — Style Guide

How to use the readings. Read the data-model section on objects, values and types; draw an alias diagram before and after mutation. Use the secondary mutability reading for practice, and PEP 8 for naming and None-comparison conventions.

Optional video pointers carried forward from the original lesson. These URLs and inherited titles are navigation aids only; video identity, duration and contents were not reverified in this review. The complete mechanisms and worked answers are in this lesson and the official sources below.


(a) Intuition · 5 min

Variables are name tags, not boxes

Real: Imagine a hotel. Guests are people (objects). Room numbers are names (variables). When you write x = [1, 2, 3], you don't put the list inside a box called x. You put the list somewhere in memory, print a name tag x, and stick it on the list. When you write y = x, you print another tag y and stick it on the same list. Now the list has two name tags. Mutating the list via y.append(4) is like feeding one of the two-name-tagged guests dinner — the guest changes; both name tags still point to it.

 
    Formally: Python values are objects and names bind to objects. A namespace maps names to objects; its implementation need not be a dictionary (function locals may use optimized storage). Heap addresses and object allocation are implementation details.
    x = y does NOT copy. It rebinds. If you truly want a copy, you must ask: y = list(x), y = x.copy(), or y = copy.deepcopy(x) for nested structures.
  

The three ideas that unlock Python's memory model

If these don't feel solid, everything Python does will feel like magic

  • Objects are values. Every object has an identity (id()), a type (type()), and a value. Identity stays fixed for an object’s lifetime; its type determines supported operations. A mutable object’s value can change. Some compatible user-defined instances allow __class__ reassignment, so do not generalize this to an absolute runtime ban on changing type.

  • Names are labels. x = obj binds name x to the object in the current namespace, not to the integer returned by id(). No copying happens.

  • Mutability is a property of the OBJECT, not the name. list, dict, set are mutable. int, str, tuple, frozenset are immutable. Simple name assignment rebinds a name; item and attribute assignment can instead mutate a container or invoke a setter.

A common Python state-sharing bug

def add_item(item, cart=[]):     # intentional shared-default counterexample
    cart.append(item)
    return cart
 
print(add_item("apple"))        # ['apple']
print(add_item("banana"))       # ['apple', 'banana']

The default list is created when def executes, not when a call omits cart. Two fixes serve different contracts: use None and create a list inside to mutate a supplied cart deliberately, or accept an immutable default and return a new list without mutating the caller.

def add_item_fresh(item, cart=None):
    if cart is None:
        cart = []
    cart.append(item)
    return cart
 
def add_item_copy(item, cart=()):
    return [*cart, item]
 
assert add_item_fresh("a") == ["a"]
assert add_item_fresh("b") == ["b"]
original = ["a"]
assert add_item_copy("b", original) == ["a", "b"]
assert original == ["a"]

A quick history so you know why the world looks like this

Python’s first public release was in 1991. PEP 8 (created in 2001) documents style conventions; the interpreter does not enforce its line-length or naming recommendations. Python 3.0 (2008) made str Unicode text and print a function. PEP 484 (2015, Python 3.5) standardized type hints without making Python enforce annotated argument types. Python 3.13 (2024) added an experimental free-threaded CPython build; it did not remove the GIL from every installation. These are language and tooling milestones, not evidence that every team uses the same style or types.

(b) Tracing names, objects, and mutation · 15 min

What x = [1, 2, 3]; y = x; y.append(4) looks like in memory

Python’s object model becomes easier to predict once names and values are drawn separately. Follow both arrows after the assignment, then ask whether appending through y changes an arrow or the list itself.

What x = [1, 2, 3]; y = x; y.append(4) looks like in memory
Figure text equivalent — labels and relationships
  • Node “'x'” (square).
  • Node “'y'” (square).
  • Node “one list object [1, 2, 3, 4]” (square).
  • Group “Names in the current scope” contains “'x'”, “'y'”.
  • Group “Object storage (conceptual)” contains “one list object [1, 2, 3, 4]”.
  • “'x'” leads to “one list object [1, 2, 3, 4]”. Line: normal; ends: arrow point.
  • “'y'” leads to “one list object [1, 2, 3, 4]”. Line: normal; ends: arrow point.

Two names, one object. y.append(4) mutates the object; both names still point to it, so both "see" the change. That's the whole trick.

The two kinds of "changes" in Python

Name: Rebinding

Tagline: x = 5 then x = 6

  • Creates or reuses object 6

  • Points name x to it

  • Old object may be reclaimed if unreachable; CPython’s cached 5 normally remains reachable

  • The name has been REBOUND to a new object

  • Works on any name; irrelevant to mutability

Name: Mutation

Tagline: lst = [1,2] then lst.append(3)

  • The object itself changes state

  • All names pointing at it see the change

  • Requires a mutable type (list, dict, set)

  • int, str, tuple can't do this — no methods to mutate

  • Aliasing explains why a mutation is visible through several names

The built-in types you'll use daily

Common built-in types and their mutation contracts

Name: int, float, bool

Desc: Numbers. Immutable. bool is a subclass of int — that's why True + 1 == 2. Use 1_000_000 for readable big numbers.

Name: str

Desc: Text. Unicode by default in Python 3. Immutable — transformations return a string result without changing the original; implementations may reuse an existing object. Use f-strings: f"hello {name}".

Name: list

Desc: Ordered, mutable sequence. [1, 2, 3]. .append(), .pop(), .sort(). Your default ‘collection of things’.

Name: tuple

Desc: Ordered, IMMUTABLE sequence. (1, 2, 3). Use for fixed-length records and function return values. A tuple is a valid dict key only if every element is hashable.

Name: dict

Desc: Ordered (since 3.7) key→value map. {'name': 'ada'}. Expected O(1) lookup under ordinary hash distribution and bounded hash/equality cost; collisions can make lookup O(n). The workhorse of Python.

Name: set

Desc: Unordered collection of unique hashable items. {1, 2, 3}. Expected O(1) membership with ordinary hashes and bounded hash/equality cost, not a worst-case guarantee. Use for dedup and ‘in this group?’ checks.

Name: None

Desc: The sole instance of NoneType. Represents ‘no value’. Compare with is None, never == None.

Name: bytes / bytearray

Desc: Raw byte sequences. b'hello' is immutable bytes; bytearray is mutable. Unlike Unicode str, they hold byte values for files, networks and binary protocols.

is vs == — the check that trips up everyone

== compares VALUES

Desc: Asks for equality using the types’ comparison protocol. User-defined equality can return a non-bool result or NotImplemented; it is not necessarily a recursive content comparison.

is compares IDENTITY

Desc: ‘Are these two names pointing at the exact same object in memory?’ Uses id() comparison.

Use is for singletons

Desc: x is None is the usual missing-value test. Use if flag: for truth testing; flag is True asks specifically for the bool singleton, not general truthiness.

Use == for value comparison

Desc: x == 5, name == 'ada', a == b for lists. Use is when object identity itself is the contract, including alias tests and a private object() sentinel.

Literal identity is not a value contract

Desc: CPython can issue SyntaxWarning for identity tests against non-singleton literals. Small-integer caching and constant reuse are implementation details, not a Python 3.12 introduction or a promised Python 3.14 behavior change. Never predict integer identity from its value alone.

Type hints — optional, but seniors use them

Type hints (PEP 484) describe intended types for tools such as mypy and Pyright. Python does not enforce annotated argument or return types by itself. Annotations are available for runtime introspection and can be evaluated or interpreted by libraries, so ‘no runtime effects’ is too broad.

def greet(name: str, times: int = 1) -> str:
    return (f"hello, {name}! " * times).strip()
 
items: list[str] = []
counts: dict[str, int] = {}
maybe_age: int | None = None

Why bother with types?

A type checker can flag a call to append on a value known to be str before execution. A traceback still helps locate runtime failures; neither hints nor a checker prove correctness, especially around Any or untyped dependencies. Add precise annotations at function boundaries and run the checker deliberately.

Myth: "Assignment copies the value. After y = x, I have two independent things — changing one can't affect the other."

Truth: Assignment never copies. y = x creates a second name pointing at the same object. If that object is mutable, mutating it through either name is visible through both.

Why: Because it's true for the types beginners meet first. x = 5; y = x; y = 6 leaves x as 5 — so "copying" looks correct. But that worked because you rebound y to a different object, not because anything was copied. Ints are immutable, so the two behaviours are indistinguishable until you hit a list.

Test: predict the alias and rebinding results, then run:

x = [1, 2, 3]
y = x
y.append(4)
print(x)        # [1, 2, 3, 4]
print(x is y)   # True
 
a = 5
b = a
b += 1
print(a)        # 5
print(a is b)   # False: different integer values

Question: Why can some tuples be dictionary keys while lists cannot?

A hash table first uses hash(key) to narrow the candidates, then equality to distinguish keys with colliding hashes. Expected constant-time lookup is a performance model, not the reason every mutable object must be prohibited. The actual contract is that a key’s hash remains stable during its lifetime and equal keys have equal hashes.

Lists compare by mutable contents and deliberately have no hash. A tuple’s hash depends on its elements, so it is hashable only when every element is hashable. Immutability of the outer tuple alone is insufficient. Conversely, an ordinary user-defined object can have mutable attributes yet retain identity-based equality and hashing inherited from object.

class Box:
    pass
 
box = Box()
lookup = {box: "still reachable"}
box.value = 99
assert lookup[box] == "still reachable"
assert { (1, 2): "pair" }[(1, 2)] == "pair"
try:
    hash((1, [2]))
except TypeError:
    pass
else:
    raise AssertionError("a tuple containing a list must be unhashable")

Do not implement a hash from mutable fields while equality uses those fields: changing a key can then make a stored entry unreachable through an ordinary lookup. Copying or freezing only the outer container does not repair that contract.

Name: Name tags on objects

Picture: Never picture a variable as a box holding a value. Picture a warehouse of objects, each with a stable identity during its lifetime, and sticky name tags you can peel off and re-stick freely.Assignment moves a tag. Mutation changes an object. Those are the only two operations, and confusing them causes nearly every Python surprise.

  • Simple-name assignment changes a binding; evaluating its right-hand expression can still have effects. An id value may be reused after an object’s lifetime ends.

  • x.append(...), x[0] = ..., x.sort() change the object itself — visible through every tag on it.

  • == asks "do these two objects look the same?". is asks "are these the same object?" — same warehouse slot.

  • Immutable objects (int, str, tuple) make the distinction invisible, which is exactly why the wrong model survives so long.

Trigger: Fire this model the moment you see: a function that "unexpectedly" changed its caller's list · a default argument that remembers old calls · two variables that change together · SettingWithCopyWarning in pandas · a class attribute shared across all instances.

Decision: You need to hand a caller's list to code that will modify it. Copy defensively, or mutate in place?

Name: Mutate in place

Gain: can avoid a full copy and make intended changes visible to the caller. Auxiliary memory depends on the operation; in-place sorting or growing a list may allocate.

Cost: action at a distance — the caller's object changes under them; requires ownership and synchronization in concurrent code; a classic source of Heisenbugs

Pick: the mutation IS the contract and the name says so (list.sort(), random.shuffle()), or the data is too big to copy

Name: Copy first

Gain: a shallow copy protects the outer container from direct changes. It does not isolate nested objects, remove other side effects, or provide thread safety; the contract still needs review.

Cost: an outer list copy takes O(n) time and space for n references; and .copy() is shallow — nested mutables are still shared, which quietly reintroduces the original bug

Pick: the function's job is to compute, not to modify — when the documented contract promises not to mutate caller-owned input

Verdict: Default to copying, and make mutation loud when you choose it. Python's own stdlib encodes this: sorted(x) returns a new list, x.sort() mutates and returns None — that None exists specifically to stop you writing y = x.sort() and believing you got a copy.And when you do copy, know your depth: x.copy() for flat data, copy.deepcopy(x) for supported nested structures when its copying contract fits. Shallow-copying a list of dicts protects the outer list and nothing else.


(c) Hands-on · 25 min

Copy this into a file, run it, then predict the output of each print BEFORE running it — write your prediction in a comment. Then run and check.

"""s005_memory_model.py — the exercises that install the mental model."""
from __future__ import annotations
import copy
 
# =========================
# Part 1 · Names and objects
# =========================
print("--- Part 1 ---")
 
x = [1, 2, 3]
y = x
y.append(4)
print("x:", x)                  # PREDICT: ?
print("y:", y)                  # PREDICT: ?
print("x is y:", x is y)        # PREDICT: ?
print("id(x) == id(y):", id(x) == id(y))
 
# Rebinding vs mutation
a = [1, 2, 3]
b = a
b = b + [4]                     # NEW list — rebind
print("a:", a)                  # PREDICT: ?
print("b:", b)                  # PREDICT: ?
print("a is b:", a is b)        # PREDICT: ?
 
# =========================
# Part 2 · Immutable ‘change’
# =========================
print("\n--- Part 2 ---")
 
s = "hello"
t = s
s = s + " world"                # rebind — strings are immutable
print("s:", s)                  # PREDICT: ?
print("t:", t)                  # PREDICT: ?
 
n = 5
m = n
n += 1                          # rebind — ints are immutable
print("n:", n, "m:", m)         # PREDICT: ?
 
# =========================
# Part 3 · The mutable-default trap
# =========================
print("\n--- Part 3 ---")
 
def bad_append(item, target=[]):
    target.append(item)
    return target
 
print(bad_append("a"))          # PREDICT: ?
print(bad_append("b"))          # PREDICT: ?
print(bad_append("c", []))      # PREDICT: ?
 
def good_append(item, target=None):
    target = [] if target is None else target
    target.append(item)
    return target
 
print(good_append("a"))
print(good_append("b"))
print(good_append("c", []))
 
# =========================
# Part 4 · Copy vs deep copy
# =========================
print("\n--- Part 4 ---")
 
grid = [[0, 0, 0], [0, 0, 0]]
shallow = grid.copy()           # or list(grid)
deep = copy.deepcopy(grid)
grid[0][0] = 99
print("grid:", grid)
print("shallow:", shallow)      # PREDICT: shares inner lists?
print("deep:", deep)            # PREDICT: fully isolated?
 
# =========================
# Part 5 · is vs ==
# =========================
print("\n--- Part 5 ---")
 
small_a = 100
small_b = 100
print("small ints — a is b:", small_a is small_b)  # PREDICT: ?
 
big_a = 100_000
big_b = 100_000
print("big ints — a is b:", big_a is big_b)        # PREDICT: ?
 
# CPython caches small ints in [-5, 256]. Above that, identity is not guaranteed.
# NEVER rely on it — use == for value and is for intentional identity checks.
 
# =========================
# Part 6 · Types you should know
# =========================
print("\n--- Part 6 ---")
 
samples: dict[str, object] = {
    "int": 42,
    "float": 3.14,
    "bool": True,
    "str": "hi",
    "list": [1, 2],
    "tuple": (1, 2),
    "dict": {"a": 1},
    "set": {1, 2, 3},
    "none": None,
    "bytes": b"raw",
}
for name, val in samples.items():
    mutable = val.__class__.__name__ in {"list", "dict", "set", "bytearray"}
    print(f"  {name:<6} type={type(val).__name__:<10} mutable={mutable}")

What each block teaches

Anatomy of the exercises

Name: Part 1 · reference sharing

Desc: y = x doesn't copy — both names point to the same list. x is y is True. This illustrates shared references directly.

Name: Part 1 · rebinding

Desc: b = b + [4] builds a NEW list and rebinds b to it. a still points to the original. Contrast with .append() which mutates in place.

Name: Part 2 · immutable rebind

Desc: Strings and ints are immutable. s = s + " world" yields a different value and rebinds s; t retains the original. The int update n += 1 also rebinds n.

Name: Part 3 · mutable-default trap

Desc: The default list is created once per execution of def. Use a None sentinel and construct inside when fresh per-call state is intended.

Name: Part 4 · shallow vs deep copy

Desc: list.copy() copies the outer list; inner lists are still shared. copy.deepcopy() recursively copies. Know both.

Name: Part 5 · CPython integer identity is not portable

Desc: CPython caches ints in [-5, 256] as singletons. That's an implementation detail. Never rely on is for value comparison.

Name: Part 6 · type awareness

Desc: These common types demonstrate distinct contracts. The sample’s class-name check is illustrative, not a general mutability detector for arbitrary classes.

What to observe

In Part 1 mutation, x and y both show [1, 2, 3, 4]. Rebinding in Part 1b leaves them different. In Part 3, the ‘bad’ function accumulates across calls. The returned list is the same default object on both calls. In Part 4, shallow[0][0] is also 99 because the inner list is shared. deep[0][0] is 0. In Part 5, small_a is small_b (cached), but big_a is big_b may be False. This is why is is not ==.

Goal: Prove to yourself that tuples of mutable objects are ‘kinda mutable’

Hint: Tuples themselves are immutable — you can't reassign an element. But if an element is a list, you CAN mutate the list. What does t[0].append(99) do here?

Run in the REPL:

t = ([1, 2], [3, 4])
print(t)
t[0].append(99)         # allowed?
print(t)
try:
    t[0] = [7, 8]       # tuple item assignment is forbidden
except TypeError:
    pass
else:
    raise AssertionError("tuple assignment unexpectedly succeeded")

The first mutation succeeds; the second raises TypeError: 'tuple' object does not support item assignment. The tuple's contents (which references) are frozen; the objects those references point to are not. This explains why a tuple of ints is hashable and a tuple of lists is not. Dict keys require stable hash/equality behavior, not universal deep immutability.


(d) Production reality · 15 min

Three concrete failure mechanisms

These are illustrative scenarios, not verified incidents at Flask, TensorFlow or Instagram.

A default set crosses request boundaries. An intentionally bad process(items, seen=set()) suppresses items seen on any earlier call using that default, not just the first request. A fresh per-call set isolates requests; an explicitly shared set should be an intentional caller-owned dependency.

def process_bad(items, seen=set()):
    handled = []
    for item in items:
        if item not in seen:
            seen.add(item)
            handled.append(item)
    return handled
 
def process_fresh(items, seen=None):
    if seen is None:
        seen = set()
    handled = []
    for item in items:
        if item not in seen:
            seen.add(item)
            handled.append(item)
    return handled
 
assert process_bad([1, 2]) == [1, 2]
assert process_bad([2, 3]) == [3]
assert process_fresh([2, 3]) == [2, 3]

Equality is overridable; identity is not. A type can reject equality or return a non-scalar result. The missing-value check should not call that comparison protocol:

class NoEquality:
    def __eq__(self, other):
        raise TypeError("equality is not supported here")
 
value = NoEquality()
assert (value is None) is False
try:
    value == None             # intentional negative control
except TypeError:
    pass
else:
    raise AssertionError("the overloaded comparison must run")

A reachable cache is retained, not automatically collected. A module-level list keeps appended objects reachable. Set an explicit retention policy; eviction does not guarantee an immediate RSS decrease or release of objects referenced elsewhere.

from collections import deque
 
recent = deque(maxlen=3)
for item in range(10):
    recent.append(item)
assert list(recent) == [7, 8, 9]

A deque bounds retained entry count, not bytes per entry. A cache holding large values needs a size policy appropriate to those values.

Where this shows up in the rest of the plan

Root: The memory model shapes every Python session after this one

Label: S007 · Functions

Desc: Argument passing, closures, and the mutable-default trap in full.

Label: S008 · Data structures

Desc: Choose by ordering, lookup, duplication and mutation requirements; mutability is one factor.

Label: S009 · Classes & objects

Desc: self is a reference too. Class attributes vs instance attributes trip up the same way.

Label: S011 · Errors & debugging

Desc: pdb + id() + type() are the diagnostic tools for the model you learned today.

Label: Later topic · Dataframe view/copy semantics

Desc: Array/dataframe view-versus-copy behavior needs its own library/version contract. Python aliasing is useful background, not a complete explanation of pandas assignment semantics.

Label: S065 · Docker

Desc: Long-running Python processes can retain growing module-scope state unless ownership and eviction are explicit.


(e) Recall + stretch · 10 min

Self-check (answer follows each question).

Recall — questions with model answers

Q: What does y = x do in Python — copy or reference?

A: It binds or rebinds y to the SAME object x refers to. No copy.

Q: Which of these is mutable: int, str, tuple, list, dict, set, frozenset, bytes?

A: Mutable: list, dict, set (and bytearray). Immutable: int, float, bool, str, tuple, frozenset, bytes, None.

Q: Why is def add(item, cart=[]) a bug, and what's the fix?

A: The default [] is created once at function-definition time and shared by every call — so items accumulate across calls. Fix: def add(item, cart=None): then if cart is None: cart = [].

Q: When should you use is instead of ==?

A: Use is for identity: x is None, a private sentinel, or an intentional alias check. Use == for value equality and if flag for truth testing. True, False and Ellipsis are also singletons; identity testing is not restricted to three objects.

Q: What's the difference between list.copy() and copy.deepcopy(list)?

A: list.copy() is shallow — it copies the outer list but not the inner objects. If the list contains other lists, both copies share the same inner lists. copy.deepcopy() recursively copies supported components using a memo to handle cycles and shared references. Immutable objects may be reused; types can customize copying and some resources cannot be copied.

Q: Why can a tuple be a dict key but a list cannot?

A: Keys need a stable hash and equal keys must share a hash. Lists are unhashable; tuples are hashable exactly when their elements are. Mutable user-defined objects can still be identity-hashable, so hashability is not equivalent to immutability.

Q: Write a type-annotated function count_unique(items) that takes any iterable of strings and returns an int.

A:

from collections.abc import Iterable
 
def count_unique(items: Iterable[str]) -> int:
    return len(set(items))

Q: Explain in one sentence what a namespace is in Python.

A: A namespace maps names to objects; a dictionary is one implementation, not a requirement for every scope. Ordinary function name resolution distinguishes local, enclosing-function, module-global and built-in scopes. Binding rules are determined before execution; an uninitialized local does not fall through to a global.

Q: Why does a = 100; b = 100; a is b return True, but a = 10**9; b = 10**9; a is b may return False?

A: CPython caches small integers in the range [-5, 256] as singleton objects. Any name bound to 100 points to the cached instance. Above that range, the interpreter may create new objects, so identity is not guaranteed. This is a CPython optimization — never rely on it in code.

Q: Stretch — you're building a config object that must be immutable at runtime, allow attribute access (config.db_url), and support type hints. What Python feature fits best?

Stretch: true

A: A frozen dataclass provides annotated fields, attribute access and generated methods that reject ordinary assignment/deletion. It is shallow and emulates immutability rather than enforcing an unbreakable freeze: a list field can still mutate. Use immutable field values for an immutable configuration. A typed NamedTuple similarly freezes slots, not nested objects; neither feature performs general runtime type validation.

Explain-out-loud test

If you can't teach these three without notes, redo the session:

  1. What is a variable in Python? (one sentence, no analogies)
  2. What's the mutable-default-argument bug? (name it AND the fix)
  3. When do you use is vs ==? (one rule)

Model answers: (1) A variable name binds to an object; rebinding changes the binding and mutation changes the referenced object. (2) A mutable default is evaluated once and shared by omitted-argument calls; use a None sentinel plus fresh construction, or an immutable default plus a fresh returned copy. (3) Use is for identity, including None and alias checks, and == for value equality.


What comes next

Hub: The 6-Month Learning Plan


Part of a 130-session evergreen learning series. Session structure: intuition → visual → hands-on → production war stories → recall. Duration: 90 minutes.

Corrective source notes

Versioned Python 3.12 contracts were checked on 2026-10-09; later-version history is labeled separately. Local examples are bounded teaching programs, not service or performance certification. The review packet retains exact source quotations, executed code, negative controls and coverage limits.

Pause / Recall / Apply

Can you explain it without the page?

Close the example. Reconstruct the core idea, then change one assumption. Mark complete when you’re ready; you can always undo it.

Stored in this browser only. No account, no sync. Clearing browser data removes your record.