Variables & Dynamic Typing

Names, not boxes — understanding how Python binds names to objects, dynamic typing, scope, and the gotchas that catch everyone

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.

python
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
The Mental Model

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():

python
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:

python
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.

C / Java — Static Typing
// Type is fixed at declaration
int x = 42;
x = "hello";  // ERROR!
Python — Dynamic Typing
# Name can point to any type
x = 42
x = "hello"   # Fine!
Strong vs Weak Typing

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.

python
# 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

python
# 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__)
Subtle Difference

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)

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)

python
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
python
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

python
# 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

python
# 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!
Chain Assignment with Mutables

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:

python
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.)
LetterScopeExample
LLocalVariables inside the current function
EEnclosingVariables in outer (enclosing) functions
GGlobalVariables at module level
BBuilt-inprint, len, True, None, etc.

global and nonlocal

python
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
The UnboundLocalError Trap

If you assign to a name anywhere in a function, Python treats it as local throughout that function — even before the assignment:

Bug — UnboundLocalError
x = 10
def broken():
    print(x)   # UnboundLocalError!
    x = 20     # This makes x local
Fix — Use global or Restructure
x = 10
def fixed():
    global x
    print(x)   # 10
    x = 20

Identity vs Equality: is vs ==

python
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

python
# 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!)
The Rule

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:

python
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]
What Happened

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.

OperationWhat It DoesCaller Sees Change?
lst.append(x)Mutates the objectYes
lst[0] = xMutates the objectYes
lst = [...]Rebinds local nameNo
lst += [x]Mutates (calls __iadd__)Yes
lst = lst + [x]Rebinds to new listNo

The Mutable Default Argument Trap

This trips up almost everyone:

Wrong — Shared Default
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] — !!
Right — None Sentinel
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!
Why This Happens

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

python
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
Avoid — Exact Type Check
if type(x) == int:
    # Fails for subclasses of int
    # (like bool, which IS an int)
    print("integer")
Better — isinstance
if isinstance(x, int):
    # Works for int and all subclasses
    print("integer")
# Note: isinstance(True, int) is True
Duck Typing

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:

python
# 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
Hints Don't Enforce

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.

python
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":

python
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
Where to Define Constants

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

ConceptKey Takeaway
VariablesNames bound to objects, not containers that hold values
AssignmentCreates a reference, never copies — use .copy() when needed
Dynamic typingTypes live on objects, not variables — names can be rebound to any type
Strong typingNo implicit conversions — "3" + 4 is a TypeError
Namingsnake_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 Mutatinglst = [...] rebinds locally; lst.append() mutates for everyone
Mutable defaultsUse None sentinel — never [] or {} as default arguments
Type checkingPrefer isinstance() over type() ==; prefer duck typing over both
Type hintsOptional annotations for tools — Python ignores them at runtime
ConstantsConvention only (UPPER_SNAKE) — Python has no enforcement