Python Variables and References


  • Description: Name binding semantics, multiple assignment, unpacking, LEGB scope, global/nonlocal, the mutable-default trap, augmented assignment, and copy/deepcopy
  • My Notion Note ID: K2A-D1-2
  • Created: 2022-03-05
  • Updated: 2026-05-11
  • License: Reuse is very welcome. Please credit Yu Zhang and link back to the original on yuzhang.io

Table of Contents


1. Names Bind to Objects

  • Assignment binds a name to an object, no storage allocation at the name's site, no value copy
  • Closer to copying a pointer than to C++ auto b = a; (which would copy for std::vector)
a = [1, 2, 3]
b = a            # b binds to the SAME list
b.append(4)
print(a)         # [1, 2, 3, 4]
print(a is b)    # True

To get a real copy:

  • b = a.copy() or b = a[:], shallow
  • b = copy.deepcopy(a), recursive

2. Mutable vs Immutable

Mutable Immutable
list, dict, set, bytearray, most custom classes int, float, bool, str, tuple, frozenset, bytes
  • Rebinding an immutable name does NOT mutate, creates a new object and rebinds
  • Mutable objects mutate in place under the same syntax
s = "hello"
s += " world"    # new str object; old "hello" is unreferenced
n = 5
n += 1           # new int object

xs = [1, 2]
xs += [3]        # mutates xs (calls __iadd__); same object
  • Asymmetry only matters when the object is shared (function args, closures, defaults)

3. Multiple and Chained Assignment

x, y, z = 1, 2, 3       # tuple unpacking on RHS
a = b = c = 0           # chained: all three bound to the SAME 0
a, b = b, a             # swap (no temp; RHS evaluated first)
  • Chained assignment binds ONE object to multiple names, matters for mutables
matrix = [[0] * 3] * 3   # all three rows are the SAME list!
matrix[0][0] = 1
print(matrix)            # [[1,0,0], [1,0,0], [1,0,0]]

matrix = [[0] * 3 for _ in range(3)]   # correct: three distinct lists

4. Tuple Unpacking

a, b = (1, 2)
a, b, c = "xyz"              # any iterable
a, *rest = [1, 2, 3, 4]      # rest = [2, 3, 4]
first, *middle, last = [1, 2, 3, 4, 5]   # middle = [2, 3, 4]
*head, last = "abcd"         # head = ['a','b','c'], last = 'd'

Common use:

for i, value in enumerate(items): ...
for key, value in d.items(): ...
  • Star-unpacking is also used at call sites to forward arguments

5. LEGB Scope Rule

Name resolution order:

  1. Local, current function
  2. Enclosing, outer functions (for nested defs)
  3. Global, module-level
  4. Built-in, len, print, etc.
x = "global"
def outer():
    x = "enclosing"
    def inner():
        print(x)              # found in Enclosing → "enclosing"
    inner()
outer()
  • No block scope: names from if/for are visible for the rest of the enclosing function (opposite of C++):
def f(flag):
    if flag:
        msg = "yes"
    return msg   # UnboundLocalError if flag is False

6. global and nonlocal

  • Assigning to a name inside a function creates a local binding by default
  • To rebind an outer name, declare it
  • Reading an outer name doesn't need a declaration, only rebinding does
counter = 0
def bump():
    global counter
    counter += 1

def make_counter():
    n = 0
    def bump():
        nonlocal n        # rebind in enclosing function
        n += 1
        return n
    return bump
  • global → module scope
  • nonlocal (3.0+) → nearest enclosing function scope

7. Mutable Default Argument Trap

  • Defaults are evaluated once at def time and shared across calls
  • A mutable default accumulates state
def append_to(item, target=[]):     # BUG
    target.append(item)
    return target

append_to(1)   # [1]
append_to(2)   # [1, 2], the same list!
append_to(3)   # [1, 2, 3]

Fix, use None sentinel:

def append_to(item, target=None):
    if target is None:
        target = []
    target.append(item)
    return target
  • Applies to any mutable default ([], {}, set(), custom mutable objects)
  • Immutable defaults (int, str, tuple) are safe, rebinding doesn't mutate

8. Augmented Assignment

  • +=, -=, *=, /=, //=, %=, **=, &=, |=, ^=, <<=, >>=, @=
  • Each calls the in-place dunder (__iadd__, etc.) when defined; falls back to the binary op
xs = [1, 2]
ys = xs
xs += [3]        # in-place: xs and ys both [1, 2, 3]
xs = xs + [4]    # new list: ys still [1, 2, 3], xs is [1, 2, 3, 4]
  • Equivalent in effect for immutable types
  • Different for mutables, __iadd__ mutates and may also rebind LHS

9. del and Reference Counting

a = [1, 2, 3]
b = a
del a           # b still holds the list; alive
del b           # refcount → 0; freed
  • del name unbinds a name
  • Object is freed when refcount hits zero (CPython), or by the cyclic GC for reference cycles
  • No delete operator like C++, deterministic cleanup uses with (context managers)

10. copy and deepcopy

  • copy.copy(x), shallow (top-level container is new, nested objects shared)
  • copy.deepcopy(x), recursive
import copy

a = [[1, 2], [3, 4]]
b = copy.copy(a)
b[0].append(99)
print(a)        # [[1, 2, 99], [3, 4]] , inner list shared

c = copy.deepcopy(a)
c[0].append(7)
print(a)        # unchanged

Quick shallow copies:

list_copy = my_list[:]            # or my_list.copy()
dict_copy = dict(my_dict)         # or my_dict.copy()
set_copy = set(my_set)            # or my_set.copy()
  • For value-type semantics across a whole class, implement __copy__ and __deepcopy__