The Core Idea: Names, Not Boxes
In languages like C or Java, a variable is a container that holds a value. In Python, a variable is a name tag that points to an object. This distinction matters.
a = [1, 2, 3] b = a # b now points to the SAME list object b.append(4) print(a) # [1, 2, 3, 4] — a is affected! print(a is b) # True — same object in memory
Think of variables as sticky notes attached to objects, not as boxes that contain values. Assignment (=) sticks a name onto an object. Multiple names can point to the same object.
You can verify which object a name points to with id():
a = [1, 2, 3] b = a print(id(a)) # 140234567890 (some memory address) print(id(b)) # 140234567890 (same address!) b = [1, 2, 3] # b now points to a NEW list print(id(b)) # 140234567999 (different address)
Dynamic Typing
Python figures out the type at runtime. The object carries the type, not the variable name:
x = 42 # x points to an int x = "hello" # now x points to a str — totally fine x = [1, 2] # now a list x = True # now a bool
You never write int x = 42. There are no type declarations — names are just labels.
// Type is fixed at declaration int x = 42; x = "hello"; // ERROR!
# Name can point to any type x = 42 x = "hello" # Fine!
Python is dynamically typed (types resolved at runtime) but also strongly typed (no implicit conversions). "3" + 4 raises a TypeError — Python won't silently coerce types.
How Assignment Works
Assignment in Python always does the same thing: bind a name to an object.
# Step 1: Python creates the object 42 in memory # Step 2: The name 'x' is bound to that object x = 42 # Step 1: Python creates the string "hello" # Step 2: The name 'x' is rebound to the new object # Step 3: If nothing else points to 42, it gets garbage collected x = "hello"
Augmented Assignment
# For immutable types (int, str, tuple): creates new object x = 10 x += 5 # Same as x = x + 5 — new int object 15 # For mutable types (list, dict, set): modifies in place lst = [1, 2] lst += [3] # Extends the SAME list object (calls __iadd__)
lst += [3] modifies the list in place (other references see the change). lst = lst + [3] creates a new list (other references don't see the change). This matters when aliases exist.
Naming Rules and Conventions
Rules (Enforced by Python)
# Valid names my_var = 1 # Letters, digits, underscores _private = 2 # Can start with underscore café = 3 # Unicode letters allowed (but avoid for clarity) # Invalid names # 2fast = 1 # Can't start with a digit # my-var = 1 # No hyphens # class = 1 # Can't use keywords
Conventions (PEP 8 Style)
my_variable = 42 # snake_case for variables and functions MAX_RETRIES = 3 # UPPER_SNAKE for constants class MyClass: # PascalCase for classes pass _internal = "private" # Leading underscore: "private by convention" __mangled = "name mangled" # Double leading underscore: name mangling in classes _ = "throwaway" # Single underscore: throwaway variable
False None True and as assert async await break class continue def del elif else except finally for from global if import in is lambda nonlocal not or pass raise return try while with yield
You can check with import keyword; print(keyword.kwlist)
Multiple Assignment
Unpacking
# Assign multiple values at once x, y, z = 1, 2, 3 # Swap values (no temp variable needed!) x, y = y, x # Unpack from any iterable name, age = ["Alice", 30] first, *rest = [1, 2, 3, 4] # first=1, rest=[2, 3, 4] first, *_, last = [1, 2, 3, 4] # first=1, last=4, _ discarded
Same Value, Multiple Names
# Chain assignment a = b = c = 0 # All point to the same int object (safe for immutables) # But be careful with mutables! a = b = [] # Both point to the SAME list a.append(1) print(b) # [1] — surprise!
a = b = [] creates one list with two names. If you want independent lists, use a = [] and b = [] as separate statements.
Scope: Where Names Live
Python uses the LEGB rule to resolve names:
x = "global" # G — Global scope def outer(): x = "enclosing" # E — Enclosing function scope def inner(): x = "local" # L — Local scope print(x) # "local" inner() # B — Built-in scope (print, len, etc.)
| Letter | Scope | Example |
|---|---|---|
| L | Local | Variables inside the current function |
| E | Enclosing | Variables in outer (enclosing) functions |
| G | Global | Variables at module level |
| B | Built-in | print, len, True, None, etc. |
global and nonlocal
count = 0 def increment(): global count # Without this, assignment creates a LOCAL 'count' count += 1 def outer(): x = 10 def inner(): nonlocal x # Refers to the enclosing scope's x x += 1 inner() print(x) # 11
If you assign to a name anywhere in a function, Python treats it as local throughout that function — even before the assignment:
x = 10 def broken(): print(x) # UnboundLocalError! x = 20 # This makes x local
x = 10 def fixed(): global x print(x) # 10 x = 20
Identity vs Equality: is vs ==
a = [1, 2, 3] b = [1, 2, 3] a == b # True — same VALUE a is b # False — different OBJECTS in memory c = a a is c # True — same object
The Small Integer Cache
# CPython caches integers -5 to 256 x = 256 y = 256 x is y # True (cached) x = 257 y = 257 x is y # False (or True — depends on context, don't rely on it!)
Use == for value comparison. Reserve is for only two cases: checking against None (if x is None) and checking object identity when you explicitly need it.
Rebinding vs Mutating
This distinction is critical for understanding function behavior:
def modify(lst): lst.append(4) # MUTATES the original — caller sees this lst = [99, 100] # REBINDS local name — caller does NOT see this nums = [1, 2, 3] modify(nums) print(nums) # [1, 2, 3, 4]
lst.append(4) modifies the object that both nums and lst point to. But lst = [99, 100] makes the local name lst point to a brand new list — the caller's nums still points to the original.
| Operation | What It Does | Caller Sees Change? |
|---|---|---|
lst.append(x) | Mutates the object | Yes |
lst[0] = x | Mutates the object | Yes |
lst = [...] | Rebinds local name | No |
lst += [x] | Mutates (calls __iadd__) | Yes |
lst = lst + [x] | Rebinds to new list | No |
The Mutable Default Argument Trap
This trips up almost everyone:
def add_item(item, items=[]): items.append(item) return items print(add_item(1)) # [1] print(add_item(2)) # [1, 2] — ! print(add_item(3)) # [1, 2, 3] — !!
def add_item(item, items=None): if items is None: items = [] items.append(item) return items print(add_item(1)) # [1] print(add_item(2)) # [2] — fresh!
Default arguments are evaluated once when the function is defined, not each time it's called. That [] is created once and reused across every call. The None sentinel pattern is idiomatic Python.
- Safe (immutable):
None,True,False,0,"",(),frozenset() - Dangerous (mutable):
[],{},set(), any custom mutable object
Checking Types at Runtime
x = 42 type(x) # <class 'int'> type(x) == int # True — exact type match isinstance(x, int) # True — preferred (handles inheritance) isinstance(x, (int, float)) # True — check multiple types
if type(x) == int: # Fails for subclasses of int # (like bool, which IS an int) print("integer")
if isinstance(x, int): # Works for int and all subclasses print("integer") # Note: isinstance(True, int) is True
Pythonic code often avoids type checks entirely. Instead of checking what something is, check what it can do. Use try/except or check for specific methods — "if it walks like a duck and quacks like a duck..."
Type Hints (Optional Annotations)
Python 3.5+ supports type hints — they don't affect runtime behavior but help tools and readers:
# Variable annotations name: str = "Alice" age: int = 30 scores: list[int] = [95, 87, 92] # Function annotations def greet(name: str, times: int = 1) -> str: return f"Hello, {name}!" * times
Python completely ignores type hints at runtime. You can write x: int = "hello" and Python won't complain. Use tools like mypy or pyright to get static type checking.
from typing import Optional, Union x: int = 42 y: float = 3.14 z: str = "hello" flag: bool = True items: list[str] = ["a", "b"] mapping: dict[str, int] = {"a": 1} pair: tuple[int, str] = (1, "hello") maybe: Optional[str] = None # str or None either: Union[int, str] = 42 # int or str either: int | str = 42 # Python 3.10+ syntax
Constants (By Convention)
Python has no true constants. By convention, UPPER_SNAKE_CASE signals "don't change this":
MAX_RETRIES = 3 BASE_URL = "https://api.example.com" PI = 3.14159 # Nothing stops you from doing this, but don't: PI = 4 # Python won't complain, but your teammates will
Put constants at the top of the module, after imports. For shared constants, create a constants.py module and import from it.
Summary: Key Takeaways
| Concept | Key Takeaway |
|---|---|
| Variables | Names bound to objects, not containers that hold values |
| Assignment | Creates a reference, never copies — use .copy() when needed |
| Dynamic typing | Types live on objects, not variables — names can be rebound to any type |
| Strong typing | No implicit conversions — "3" + 4 is a TypeError |
| Naming | snake_case for variables, UPPER_SNAKE for constants, PascalCase for classes |
| Scope (LEGB) | Local → Enclosing → Global → Built-in |
is vs == | == for values, is only for None checks |
| Rebinding vs Mutating | lst = [...] rebinds locally; lst.append() mutates for everyone |
| Mutable defaults | Use None sentinel — never [] or {} as default arguments |
| Type checking | Prefer isinstance() over type() ==; prefer duck typing over both |
| Type hints | Optional annotations for tools — Python ignores them at runtime |
| Constants | Convention only (UPPER_SNAKE) — Python has no enforcement |