Python Foundations
Variables, Types, and How Python Really Handles Values
Variables, Types, and How Python Really Handles Values
Most bugs that confuse Python beginners — and plenty that confuse experienced engineers — come from one misunderstanding: thinking a variable is a box that holds a value. In Python, a variable is a name that refers to an object. Get this mental model right and a whole category of "why did that change?!" bugs disappears.
Names Point at Objects
a = [1, 2, 3]
b = a # b now refers to the SAME list object
b.append(4)
print(a) # [1, 2, 3, 4] ← a "changed" too
Nothing was copied. a and b are two labels on one list. You can prove it with is, which checks identity (same object), not equality (same value):
print(a is b) # True
print(a == [1, 2, 3, 4]) # True — equal value
print(a is [1, 2, 3, 4]) # False — different object
If you actually want a separate list, copy it: b = a.copy() or b = list(a). For nested structures (a list of dicts, say) you need copy.deepcopy(a), because a shallow copy still shares the inner objects.
Mutable vs Immutable
This is where the model pays off:
| Immutable (can't change in place) | Mutable (can change in place) |
|---|---|
int, float, bool, str, tuple, frozenset, None | list, dict, set, most custom objects |
With immutable values, "changing" a variable really means pointing the name at a new object:
x = 10
y = x
x += 1 # creates a new int 11; x now points to it
print(y) # 10 — y still points at the old object
So aliasing only bites with mutable objects. This is exactly why test fixtures that return a shared dict can leak state between tests: test 1 mutates the dict, test 2 sees the mutation.
The Core Built-in Types
count = 42 # int — arbitrary precision, no overflow
price = 19.99 # float — binary floating point
name = "checkout" # str — immutable text
passed = True # bool — a subclass of int (True == 1)
nothing = None # the "no value" singleton
Two gotchas worth knowing early:
Floats are approximate. 0.1 + 0.2 == 0.3 is False. In assertions on computed money or measurements, compare with a tolerance (math.isclose(a, b), or pytest.approx), or use decimal.Decimal for currency.
Check for None with is. Write if result is None:, not if result == None:. None is a singleton, and is can't be fooled by a class that overrides ==.
Dynamic Typing, Strong Typing
Python is dynamically typed: a name can point at an int now and a string later. It's also strongly typed: it won't silently convert between unrelated types.
total = "Items: " + 3 # TypeError — no silent conversion
total = "Items: " + str(3) # fine
That TypeError is a feature. JavaScript would happily produce "Items: 3" in some cases and nonsense in others; Python makes you say what you mean.
Truthiness
Every object has a truth value. Empty things are falsy: 0, 0.0, "", [], {}, set(), None. Everything else is truthy. That gives you idiomatic checks:
if not failures:
print("All tests passed")
But be careful when 0 is a valid value: if not retry_count: treats "zero retries" the same as "not set". When the distinction matters, write if retry_count is None:.
Checking Types at Runtime
type(x) tells you the exact type; isinstance(x, (int, float)) is what you usually want, because it respects subclasses. You'll rarely need either in normal code — type hints (Lesson 13) catch most type mistakes before the code runs.
💬 Discussion
Have you ever had a test that passed alone but failed when run with others? Could shared mutable state have been the cause?
After `a = {"env": "qa"}; b = a; b["env"] = "prod"`, what is `a["env"]`?
Next Lesson
Strings and f-Strings — the Data You Handle Most