Variables, Objects & Dynamic Typing¶
What this lesson gives you
The most important mental model in Python: a variable is a label on an object, not a box that holds a value.
Estimated time: 22 min read · Part: Values, Variables & Expressions
Learning objectives
- Explain the reference model: names bind to objects, assignment copies references not values.
- Use
id()andisto inspect object identity and distinguish it from equality. - Predict what happens to shared mutable objects when one reference modifies them.
- Distinguish dynamic typing (types on objects) from static typing (types on names).
- Understand Python's strong typing — no silent coercion — and recognise the naming conventions of PEP 8.
In Python, everything is an object — a number, a string, a function, even a type itself. A variable is simply a name bound to an object stored in memory. This is different from languages like C, where a variable is a fixed-size box allocated at a memory address, and assignment copies the value into the box. In Python the name and the object are separate things, and the name can be re-pointed at any time without affecting the underlying object or any other names that happen to reference it.
This single distinction — name vs. object, reference vs. copy — is the source of the most common class of Python surprises for people who learned in other languages. Understanding it deeply, not just intellectually, prevents a whole category of bugs before you write them. The next several code examples build the complete picture.
Analogy
A variable is a sticky note, not a bucket. x = 42 writes "x" on a note and sticks it to the object 42. y = x writes a second note, "y", and sticks it to the same object. Re-assigning x = "hello" just peels x's note off and sticks it on a different object — it doesn't disturb y or the original 42. The object 42 still exists and y still points to it. This single image prevents the most confusing mutable-data bugs you'll hit — carry it forward to every lesson.
x = 42 # name 'x' binds to the int object 42
x = "hello" # x re-binds to a str object; 42 is now unreferenced
y = x # y binds to the SAME object x points at
print(id(x)) # memory address of the object
print(id(y)) # same address — one object, two names
print(id(x) == id(y)) # True
print(x is y) # True — 'is' checks identity (same object)
# Rebind x — y is unaffected:
x = "world"
print(y) # still "hello" — the object didn't change
140234891234560 140234891234560 True True hello
The aliasing trap: mutable objects¶
The sticky-note model matters most with mutable objects — objects whose contents can change in place. Lists, dictionaries, and sets are mutable. When two names point at the same mutable object, a mutation through one name is visible through the other. This is called aliasing and is the canonical Python gotcha.
a = [1, 2, 3]
b = a # b points at THE SAME list object
b.append(4) # mutates the shared object
print(a) # [1, 2, 3, 4] — a sees the change
print(a is b) # True
# To get an INDEPENDENT copy, copy explicitly:
c = a.copy() # or: list(a), or a[:]
c.append(99)
print(a) # [1, 2, 3, 4] — unchanged
print(c) # [1, 2, 3, 4, 99]
print(a is c) # False — different objects now
[1, 2, 3, 4] True [1, 2, 3, 4] [1, 2, 3, 4, 99] False
Shallow vs deep copy
For lists of lists (nested mutable objects), a.copy() and a[:] give a shallow copy — the outer list is new, but the inner lists are still shared. Modifying an inner list in the copy still affects the original. For a fully independent copy of any nested structure, use import copy; copy.deepcopy(a). Knowing when shallow is enough (flat lists) vs when you need deep (nested structures) is a production skill.
import copy
original = [[1, 2], [3, 4]]
shallow = original.copy() # outer list is new; inner lists are shared
deep = copy.deepcopy(original)
original[0].append(99)
print(original) # [[1, 2, 99], [3, 4]]
print(shallow) # [[1, 2, 99], [3, 4]] — inner list shared, sees change
print(deep) # [[1, 2], [3, 4]] — fully independent
[[1, 2, 99], [3, 4]] [[1, 2, 99], [3, 4]] [[1, 2], [3, 4]]
Dynamic typing, strong typing, and type()¶
Python is dynamically typed: a name has no fixed type — the object it points to carries the type, and a name can be re-bound to a differently-typed object at any time without error. A variable named value might hold an int in one execution path and a str in another, and Python's runtime only sees the type when the relevant instruction executes.
But Python is also strongly typed: it won't silently coerce incompatible types behind your back. Adding a string to an integer raises a TypeError instead of guessing what you meant. This distinguishes Python from weakly-typed languages like JavaScript ("5" + 5 gives "55") or Perl. Strong typing means you get clear errors when types are wrong rather than silent nonsense results that are hard to trace.
value = 42
print(type(value)) # <class 'int'>
value = "hello" # re-bind to a str — perfectly valid
print(type(value)) # <class 'str'>
# Strong typing: operations must make sense
try:
result = "age: " + 7 # TypeError — can't add str and int
except TypeError as e:
print(e) # can only concatenate str (not "int") to str
result = "age: " + str(7) # fix: explicit conversion
print(result) # age: 7
# isinstance() for runtime type checks:
print(isinstance(value, str)) # True
print(isinstance(value, (str, int))) # True — accepts a tuple of types
Identity vs equality, and None¶
Two operators look similar but mean very different things. == checks whether two objects have the same value (it calls the __eq__ method). is checks whether two names point to the same object in memory (identity). For most values, you want ==. The canonical exception is None: always write if x is None:, never if x == None:. This is a style convention (PEP 8) and a correctness safeguard — a custom class can define __eq__ to return True when compared to None, but is is not fooled.
a = [1, 2, 3]
b = [1, 2, 3] # same value, different object
print(a == b) # True — same value
print(a is b) # False — different objects
# None is a singleton — exactly one object exists
x = None
print(x is None) # True — correct idiom
print(x == None) # True — works, but not idiomatic; avoids __eq__ override
# Small integer caching — CPython caches -5 to 256
p = 256; q = 256
print(p is q) # True (cached object)
r = 257; s = 257
print(r is s) # False or True (implementation-dependent!)
# ⚠ Never use 'is' to compare integers — this is a known interview trap
True False True True True False
PYTHON REFERENCE MODEL
═══════════════════════════════════════════════════
After: a = [1,2,3]; b = a; c = a.copy()
Name table Object heap
────────── ────────────────────────────────
a ──────────────► [1, 2, 3] (id: 0x7f1a)
b ──────────────► [1, 2, 3] (id: 0x7f1a) ← same object!
c ──────────────► [1, 2, 3] (id: 0x8c3b) ← different object
a.append(4): heap changes to [1, 2, 3, 4]
a sees: [1, 2, 3, 4] ✓
b sees: [1, 2, 3, 4] ← aliased, sees change!
c sees: [1, 2, 3] ✓ (independent copy)
Naming conventions, del, and reference counting¶
Python names follow PEP 8 conventions. The standard is snake_case for variables and functions, UPPER_SNAKE_CASE for module-level constants, and PascalCase for class names. Leading underscores carry convention meanings: _private means "treat as internal," and __dunder__ (double underscore) names are Python's protocol methods (__init__, __len__, etc.).
The del statement removes a name binding from its namespace — it doesn't necessarily destroy the object. Objects are destroyed (garbage collected) when their reference count drops to zero. CPython uses reference counting as its primary garbage collection mechanism, supplemented by a cycle collector for circular references. In practice you rarely call del explicitly — Python's GC handles memory automatically — but understanding it explains why objects sometimes linger longer than expected (e.g., a closed file that still has a reference in a list).
| Convention | Use for | Example |
|---|---|---|
snake_case | Variables, functions, methods, modules | days_until_renewal, fetch_deals() |
UPPER_SNAKE | Module-level constants | MAX_RETRIES = 3, BASE_URL |
PascalCase | Class names | DealProcessor, CustomerRecord |
_single_leading | Internal / "private" by convention | _cache, _validate() |
__double_leading | Name-mangled class attributes | __secret → _ClassName__secret |
__dunder__ | Python protocol methods | __init__, __repr__, __len__ |
| Operation | What it does to names and objects |
|---|---|
x = obj | Binds name x to obj; increments obj's refcount. |
y = x | Binds name y to same object as x; refcount +1 again. |
x = other | Rebinds x; decrements old object's refcount. If 0 → object destroyed. |
del x | Removes name x from namespace; decrements refcount. Object survives if other names hold it. |
a = a.copy() | Creates a new object; a now points to the copy, not the original. |
WHY names bind to objects, not values This model makes Python extremely flexible: the same name can hold different types at different times, functions can be passed as values, and objects can be built dynamically. It also makes garbage collection straightforward — count the references, destroy when zero. The cost is aliasing surprises with mutable objects, which is why functional programmers prefer immutable data structures (tuples, frozensets) for shared data.
HOW to avoid aliasing bugs in practice Three reliable techniques: (1) Return a new object instead of mutating in place — functional style. (2) When you must share mutable state, make it explicit with a class and controlled mutation methods. (3) Use .copy() or copy.deepcopy() when you need independence. The is operator and id() are your debugging tools when something unexpected mutates.
WHERE aliasing trips people up in consulting work Pandas DataFrames exhibit exactly this pattern: df2 = df[df['col'] > 5] may return a view (aliased) or a copy depending on the operation, causing the infamous SettingWithCopyWarning. The fix (.copy()) is the same. DataFrame aliasing bugs are among the most common in data analysis scripts passed between teams.
Knowledge check
InterviewAfter a = [1, 2]; b = a; b.append(3), what is a, and why?
[1, 2]—bis an independent copy, soais untouched.[1, 2, 3]—b = abindsbto the same list object, so mutating throughbis visible througha.- It raises an error because you can't append to an aliased list.
[3]— append replaces the contents.
Answer
[1, 2, 3] — b = a binds b to the same list object, so mutating through b is visible through a.
Assignment copies the reference, not the object. a and b are two names for one list, so b.append(3) mutates the shared object and a sees [1, 2, 3] . To get an independent copy: b = a.copy() .
Knowledge check
Concept checkWhat does is test and when should you use it instead of ==?
istests value equality — it's a synonym for==.istests object identity (same memory address). Use it only forNone,True,False, and enum members — singletons where identity and equality coincide by design. Never use it to compare integers or strings: small-value caching makes it work by accident but break on larger values.isis faster and should always be preferred over==.ischecks type;==checks value.
Answer
is tests object identity (same memory address). Use it only for None, True, False, and enum members — singletons where identity and equality coincide by design. Never use it to compare integers or strings: small-value caching makes it work by accident but break on larger values.
is compares id() values — it asks "are these the exact same object?" The only reliable use is for singletons: None, True, False, and enum values. CPython caches small integers and interned strings, making is appear to work for those values, but it's implementation-defined behaviour that breaks on larger numbers — a notorious interview trap.
Knowledge check
AppliedYou're passing a list to a function that occasionally modifies it. How do you call it such that your original list is never altered, regardless of what the function does?
- You can't prevent it — functions always have access to the caller's objects.
- Pass a copy:
func(my_list.copy())for shallow, orfunc(copy.deepcopy(my_list))for nested structures. The function receives a new object it can mutate freely without touching yours. - Use
delon the list after the call. - Convert the list to a tuple before passing — tuples are immutable.
Answer
Pass a copy: func(my_list.copy()) for shallow, or func(copy.deepcopy(my_list)) for nested structures. The function receives a new object it can mutate freely without touching yours.
Passing a copy breaks the alias. For a flat list, .copy() or list(original) suffices. For nested structures, copy.deepcopy is needed. Converting to a tuple prevents mutation but changes the type the function receives, which may break it. The cleaner long-term solution is to design functions that don't mutate their arguments (return new values instead).
Exercise
Write a function safe_update(record: dict, updates: dict) -> dict that applies the updates to a copy of the record and returns the new dict, leaving the original unchanged. Then write a second function unsafe_update(record: dict, updates: dict) -> dict that mutates the original. Demonstrate both versions with a test that proves the original is unchanged after safe_update and changed after unsafe_update.
Show solution
def safe_update(record: dict, updates: dict) -> dict:
"""Return a new dict with updates applied; original is never touched."""
return {**record, **updates} # dict unpacking creates a new object
def unsafe_update(record: dict, updates: dict) -> dict:
"""Mutate record in place and return it — dangerous pattern."""
record.update(updates)
return record
# Test safe:
original = {"name": "Acme", "tier": "gold"}
result = safe_update(original, {"tier": "platinum", "mrr": 5000})
print("original after safe_update:", original)
# {'name': 'Acme', 'tier': 'gold'} — unchanged
print("result:", result)
# {'name': 'Acme', 'tier': 'platinum', 'mrr': 5000}
# Test unsafe:
original2 = {"name": "Globex", "tier": "silver"}
result2 = unsafe_update(original2, {"tier": "gold"})
print("original after unsafe_update:", original2)
# {'name': 'Globex', 'tier': 'gold'} — mutated!
print("same object:", original2 is result2)
# True
Key takeaways
- Everything is an object; a variable is a name bound to an object — a sticky note, not a box.
- Assignment copies the reference, not the object — the source of aliasing surprises with mutable data.
ischecks identity (same object);==checks value. Useis None/is not None, never== None.- Python is dynamically typed (no declarations) but strongly typed (no silent coercion).
- For independent copies:
.copy()(shallow) orcopy.deepcopy()(nested structures). - Use clear
snake_casenames — they're documentation and they read well aloud.