Python Variables & Types — Mental Model of Memory
Trace Python references, identity, mutation, copying, hashability and defaults with executable object-model examples.
Original publication date unrecordedUpdated
Lesson preparation & details
Level: beginner
By the end, you should be able to
- Assignment binds names; copying is explicit and shallow copies share nested objects
Bring with you
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
isand==, 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
- S001 — Dev Environment — a working
uv+ Python 3.12. - S003 — The Command Line — for the REPL and script runs.
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.
- Python Names and Values
- Python 3.12 Type Hints — deep dive
- Python Variables - Python Tutorial for Beginners with Examples | Mosh
(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 = objbinds 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,setare mutable.int,str,tuple,frozensetare 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.
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 = NoneWhy 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 valuesQuestion: 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:
- What is a variable in Python? (one sentence, no analogies)
- What's the mutable-default-argument bug? (name it AND the fix)
- When do you use
isvs==? (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
- Prev: S004 · Reading Docs & Effective Googling
- Next: S006 · Control Flow — if/else, loops, comprehensions
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
- Python 3.12 data model — identity, equality and hash contracts.
- Copy, typing and dataclasses — shallow/deep copy, non-enforced annotations and frozen-instance limits.
- Control-flow tutorial — definition-time defaults and parameter contracts.
- Python history, PEP 8, Python 3.0, PEP 484 and Python 3.13 — the qualified historical milestones above.
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.