Variables & Types
Variables and dynamic typing
Section titled “Variables and dynamic typing”In Python you assign a value to a name — no type declaration needed. The variable’s type is determined by the object it currently references, not by a declaration.
x = 10 # x references an intx = "hello" # x now references a str — perfectly validx = [1, 2, 3] # x now references a listThis is called dynamic typing. The type travels with the object, not the variable name.
Names bind to objects
Section titled “Names bind to objects”A Python name is not a box that holds a value — it is a label tied to an object.
= never copies; it only makes a name point at an object that already exists (or was just created).
If two names point at the same mutable object, a mutation through one name is visible through the other:
a = [1, 2]b = a # b is bound to the SAME object as a, not a copyb.append(3)print(a) # [1, 2, 3] -- mutating through b also changes what a seesprint(a is b) # True -- same object, two names
c = a.copy() # a new, independent object with equal contentsc.append(99)print(a) # [1, 2, 3] -- unaffected by mutating cprint(a is c) # Falseid(obj) returns a number that is unique to an object for its lifetime (in CPython, its memory address).
Two names refer to the same object exactly when id(a) == id(b) — which is exactly what a is b checks for you.
This is the single most common bug shape in code that “worked in testing”: a function receives a list, mutates it in place assuming it owns a private copy, and the caller’s list changes too.
Identity vs equality: is and ==
Section titled “Identity vs equality: is and ==”== asks “do these two objects have the same value?” (it calls __eq__).
is asks “are these two names bound to the exact same object?” (it compares id()).
For small integers and some literals, CPython’s implementation details make is look like it also checks value — right up until it doesn’t:
small1 = 100small2 = 100print(small1 is small2) # True -- CPython caches small ints -5..256
big1 = 257big2 = 257print(big1 is big2) # True -- literal 257 folded to one constant in this file
big3 = int("257")big4 = int("257")print(big3 is big4) # False -- built at runtime, two separate objectsbig1 is big2 prints True here because both 257 literals live in the same compiled code and the compiler deduplicated the constant — a detail of this file, not a guarantee of the language. The moment the same value is produced by a computation (int("257"), a sum, a value read from a file), it is a fresh object and is returns False. Never use is to compare values — use ==. Reserve is for identity checks that are meant to be identity checks: x is None, x is True, x is some_specific_object.
Built-in scalar types
Section titled “Built-in scalar types”Python ships with four scalar types you will use in every program:
| Type | Literal example | Notes |
|---|---|---|
int | 42, -7, 0 | Arbitrary precision — never overflows |
float | 3.14, -0.5 | IEEE 754 double precision |
str | "hello", 'world' | Immutable Unicode sequence |
bool | True, False | Subclass of int; True == 1, False == 0 |
There is also None — a singleton that represents the absence of a value.
None has its own type, NoneType, and there is exactly one None object in the entire Python process.
Always test for it with is None, never == None.
Mutability
Section titled “Mutability”Whether an object can change in place after creation — without becoming a different object — is called mutability. This is independent of variable names: it is a property of the object itself.
| Type | Mutable? | Note |
|---|---|---|
int, float, bool, complex | No | Any “change” creates a new object |
str | No | Every string method returns a new string |
tuple | No | The tuple’s slots can’t be reassigned — but an element that is itself mutable (a list inside a tuple) can still change |
frozenset | No | The immutable sibling of set |
list | Yes | .append, .sort, slice assignment all mutate in place |
dict | Yes | Keys and values can be added, changed, removed |
set | Yes | Elements can be added or removed |
Aliasing (two names, one object) only causes surprises for mutable types. Aliasing an int or a str is harmless — there is no operation that changes a string or an int in place, so every name bound to it will only ever see the value it was created with.
Checking types at runtime
Section titled “Checking types at runtime”type() returns the exact type of an object.
isinstance() tests whether an object is an instance of a type or any of its subclasses.
age = 30print(type(age)) # <class 'int'>print(isinstance(age, int)) # Trueprint(isinstance(True, int)) # True — bool is a subclass of intType conversions
Section titled “Type conversions”Python does not silently coerce types. You convert explicitly using the type name as a function:
n = int("42") # str -> intf = float("3.14") # str -> floats = str(100) # int -> strb = bool(0) # int -> bool -> FalseIf the conversion is impossible, Python raises a ValueError:
int("hello") # ValueError: invalid literal for int()Truthiness
Section titled “Truthiness”Every Python object has a boolean interpretation used in if and while conditions.
The following values are falsy — everything else is truthy:
False,None- Zero:
0,0.0,0j - Empty containers:
"",[],{},(),set()
name = ""if name: print("has name")else: print("name is empty") # this branch runsThat list is exactly the trap: 0, "", and [] are valid, present values that happen to be falsy — they are not the same thing as “no value was given.” if x: cannot tell “the caller passed 0” apart from “the caller passed nothing.” Whenever 0, "", or [] are meaningful inputs, check identity against None instead of truthiness:
def set_volume(vol=None): if vol: # BUG: 0 (mute) is falsy return vol return 50
print(set_volume(0)) # 50 -- wrong: mute (0) silently became the default
def set_volume_fixed(vol=None): if vol is not None: # checks "was something given", not "is it truthy" return vol return 50
print(set_volume_fixed(0)) # 0 -- correctFloating-point precision
Section titled “Floating-point precision”float is IEEE 754 double precision — a fixed number of binary bits, which cannot represent most decimal fractions exactly. This is not a Python bug; every language with binary floats has it.
import mathfrom decimal import Decimal
print(0.1 + 0.2) # 0.30000000000000004print(0.1 + 0.2 == 0.3) # Falseprint(math.isclose(0.1 + 0.2, 0.3)) # Trueprint(Decimal("0.1") + Decimal("0.2")) # 0.3Never compare floats with == unless you constructed both sides from the exact same literal/computation. Use math.isclose(a, b) for general comparisons, or switch to decimal.Decimal (constructed from strings, not floats) when you need exact decimal arithmetic — money is the canonical example.
Shadowing a builtin
Section titled “Shadowing a builtin”list, str, dict, type, id, input, sum, and every other builtin are just names in the built-in scope — nothing stops you from rebinding one:
items = [1, 2, 3]list = items # shadows the builtin `list` type in this scopeprint(list([4, 5])) # TypeError: 'list' object is not callableOnce list is rebound, the name no longer refers to the builtin type anywhere later in that scope — calling list(...) now tries to call the object stored in list (a plain list, not callable) instead of constructing a new list. The fix is simply never using a builtin’s name for a variable; del list also removes the shadow, but renaming the variable is the real fix.
bool is a subclass of int
Section titled “bool is a subclass of int”True and False are not a separate primitive type bolted onto Python — bool is a subclass of int, and True/False behave as 1/0 in every numeric context:
print(True + True) # 2print(isinstance(True, int)) # Trueprint(True == 1) # TrueThis is why sum([True, True, False]) is a perfectly normal (if surprising the first time) way to count how many items in a list of booleans are True — it evaluates to 2.
Full runnable demo
Section titled “Full runnable demo”age: int = 30price: float = 9.99greeting: str = "Hello"active: bool = Truenothing = None
print(type(age))print(type(price))print(type(greeting))print(type(active))print(type(nothing))
x = int("42")y = float("3.14")z = str(100)print(x, y, z)
falsy_values = [0, "", None, False, [], 0.0]for v in falsy_values: print(f"bool({v!r}) = {bool(v)}")Loading Python runtime (first run only)…
The second playground below is a self-contained tour of everything on this page that can silently go wrong: aliasing, is vs ==, truthiness, float precision, shadowing, and bool arithmetic.
import mathfrom decimal import Decimal
# 1. Names bind to objects; assignment never copies.a = [1, 2]b = a # b is bound to the SAME list object, not a copyb.append(3)print("a:", a)print("a is b:", a is b)c = a.copy() # an independent object with an equal valuec.append(99)print("a after c mutated:", a)print("a == c:", a == c, "| a is c:", a is c)
# 2. is vs ==: small-int cache and same-file constant folding make "is"# LOOK like a value comparison... until the value is built at runtime.small1 = 100small2 = 100print("100 is 100 (cached -5..256):", small1 is small2)
big1 = 257big2 = 257print("257 is 257 (literals, same file):", big1 is big2)
big3 = int("257")big4 = int("257")print("int('257') is int('257'):", big3 is big4)
# 3. Truthiness: 0 / "" / [] are valid values, not "missing".def set_volume(vol=None): if vol: # BUG: a real 0 (mute) is falsy, so it falls through to the default return vol return 50 # default volume
print("set_volume(0), buggy:", set_volume(0))
def set_volume_fixed(vol=None): if vol is not None: return vol return 50
print("set_volume(0), fixed:", set_volume_fixed(0))
# 4. Float precision.print("0.1 + 0.2 ==", 0.1 + 0.2)print("0.1 + 0.2 == 0.3:", 0.1 + 0.2 == 0.3)print("isclose:", math.isclose(0.1 + 0.2, 0.3))print("Decimal:", Decimal("0.1") + Decimal("0.2"))
# 5. Shadowing a builtin.items = [1, 2, 3]list = items # shadows the builtin list type in this scopeprint("list (shadowed name):", list)try: list([4, 5])except TypeError as e: print(f"list([4, 5]) -> TypeError: {e}")del list # remove the shadow; the builtin is visible againprint("list([4, 5]) after del:", list([4, 5]))
# 6. bool is a subclass of int.one = 1print("True + True ==", True + True)print("isinstance(True, int):", isinstance(True, int))print("True == 1:", True == 1, "| True is one:", True is one)Loading Python runtime (first run only)…
Mental Model
Section titled “Mental Model”Think of a name as a label tied to an object with a piece of string, not a box that stores a value. a = [1, 2] ties the label a to a list object. b = a ties a second label b to that same object — no new object is created. b.append(3) writes into the object; both labels still see the change, because they point at the same thing. c = a.copy() creates a brand-new object with equal contents and ties c to it — from then on the two objects change independently.
flowchart LR
subgraph Names
aL["a"]
bL["b"]
cL["c"]
end
obj1(["list object #1: [1, 2, 3]"])
obj2(["list object #2: [1, 2, 99]"])
aL --> obj1
bL --> obj1
cL --> obj2 Trade-offs
Section titled “Trade-offs”| What you get | Benefit | Cost |
|---|---|---|
| Dynamic typing | No type declarations; fast prototyping; flexible containers | Type errors surface at runtime instead of before the program runs |
| Assignment binds, never copies | O(1) regardless of object size — passing large data around is free | Aliasing bugs: mutating through one name mutates every name bound to that object |
is identity check | O(1), never calls __eq__ | Looks like it works for small ints and same-file literals, then silently breaks for larger or runtime-built values |
Truthy/falsy coercion in if | Concise conditionals (if items:) | Valid values (0, "", []) collapse into the same branch as “missing” |
Arbitrary-precision int | No silent integer overflow, ever | float still has only 53 bits of precision — 0.1 + 0.2 != 0.3 |
Common Misconceptions
Section titled “Common Misconceptions”- “
=copies the value, like it does in some other languages.” It does not —=binds a name to an object. For mutable objects, two names can end up referring to the very same one. - “
isand==are interchangeable for numbers and strings.” They often agree by implementation accident (the small-int cache, literal folding) until a value falls outside that range or is built at runtime. - “
0means the same thing asNone.”0,"", and[]are legitimate values that happen to be falsy;Noneis the actual “no value” sentinel. - “
0.1 + 0.2 == 0.3because that’s basic arithmetic.” Binary floating point cannot represent0.1or0.2exactly; usemath.iscloseorDecimalwhen exactness matters. - “Reassigning a builtin name like
listis harmless if I don’t use it again nearby.” It shadows the builtin for the rest of that scope — any later call tolist(...)in the same scope breaks.
Reading AI-generated code
Section titled “Reading AI-generated code”- Does the code compare to
Nonewith==instead ofis?if value == None:works by accident (NoneType.__eq__happens to agree) butis Noneis the correct, idiomatic check. - Does a variable reuse a builtin’s name (
list,str,type,id,dict,input,sum, …)? If so, check whether that builtin is called anywhere later in the same scope. - Does
if value:need to accept0,"", or[]as a real, valid input? If so it should beif value is not None:. - Does the code compare two
intorstrvalues withisinstead of==? Only identity checks (is None,is True,is some_sentinel) should useis. - Does the code compare floats with
==directly, instead ofmath.iscloseorDecimal? - Does a function receive a mutable argument (a
listordict) and mutate it in place without the caller clearly expecting that?
🐛 Spot the bug
def process(data): list = get_items(data) # shadows the builtin list if list == None: # should be "is None" return [] return listShow the answer
Two bugs stacked in four lines. First, list = get_items(data) shadows the builtin list type inside this function — if process ever needs to call list(...) later, it will fail with TypeError: 'list' object is not callable (or silently do the wrong thing if get_items can return something callable). Second, if list == None: relies on __eq__ agreeing with identity, which works for None today but is not the idiomatic or guaranteed-safe check; the correct form is if list is None:. Fix: rename the variable (items = get_items(data)) and use is None.