Skip to lesson
Python for JS Developers

Gotchas & PitfallsLesson 2 of 2

Scope, Closures & Traps

4 min

Python scope rules follow LEGB: Local, Enclosing, Global, Built-in. You can READ outer variables freely, but REASSIGNING them requires explicit permission with the global or nonlocal keyword. Without it, Python treats the name as a new local variable, and reading it before assignment raises UnboundLocalError.

This catches JS developers constantly. In JS, count++ inside a function modifies the outer count. In Python, count += 1 without 'global count' fails. The fix is straightforward, but you need to know it exists. Note that mutating an outer object is fine without any keyword: items.append(x) works, because you are not rebinding the name.

Here is the subtle part: if you ASSIGN to a variable anywhere inside a function, Python treats that variable as local to the ENTIRE function, even on lines before the assignment. Reading it before the assignment line raises UnboundLocalError, even if a global with the same name exists. Python decides scope when it compiles the function, not while it runs.

Also remember: Python has no block scope. Variables created inside if, for, while, with, or try blocks live on after the block ends. Only functions, classes, modules, and comprehensions create new scopes. This is the opposite of JS's let and const, which are block-scoped.

The mutable default argument trap is Python's most infamous gotcha. Default argument values are evaluated ONCE, when the function is defined, not each time it is called. So def add_item(item, items=[]) shares the SAME list across all calls. The fix: use None as the default and create a new list inside the function.

Quick reference

JavaScriptPythonNote
let x = 0; fn() { x++ }global x neededMust declare intent to rebind
Closure modifies outernonlocal x neededExplicit for closures too
function(x, arr=[]) {}def fn(x, arr=None):Never use [] as default!
Block scope (let/const)Function scope onlyNo block-level scope

Side by side

JavaScript
// JavaScript scope
let count = 0;
function increment() {
  count++;  // modifies outer directly
}

function makeCounter() {
  let n = 0;
  return () => ++n;  // closure modifies outer
}

// Default params - fresh each call
function addItem(item, list = []) {
  list.push(item);
  return list;
}
addItem("a"); // ["a"]
addItem("b"); // ["b"] - fresh []
Python
# Python scope
count = 0
def increment():
    global count      # REQUIRED!
    count += 1

def make_counter():
    n = 0
    def inc():
        nonlocal n    # REQUIRED for closures!
        n += 1
        return n
    return inc

# TRAP: Mutable default argument
def add_item(item, items=[]):  # BUG!
    items.append(item)
    return items
add_item("a")  # ["a"]
add_item("b")  # ["a", "b"] SHARED!

# Fix:
def add_item(item, items=None):
    if items is None:
        items = []
    items.append(item)
    return items

Exercise

Real Python, running in your browser

Fix the 3 bugs related to scope and mutable defaults:

Starter code
total = 0

def add_to_total(n):
    total += n
    return total

def make_multiplier(factor):
    def multiply(x):
        factor = factor * 1  # "normalize"
        return x * factor
    return multiply

def append_log(msg, log=[]):
    log.append(msg)
    return log

Ctrl + Enter runCtrl + Shift + Enter checkTab indentEsc then Tab to leave the editorCtrl + / comment