Python Common Pitfalls
A curated tour of the mistakes that trip up even experienced Python developers — each one rooted in a real language rule that, once understood, stops being surprising.
This is a capstone topic: it doesn't introduce new language features so much as it collects the specific, recurring mistakes that Python's own design choices make easy to fall into — mutable defaults, late-binding closures, is vs ==, and more. Every pitfall here traces back to a rule taught in depth elsewhere on this site (linked by name below); the goal here is recognition — seeing the pattern quickly in real code — not re-teaching each underlying mechanism from scratch.
Why These Pitfalls Keep Appearing
Most of the mistakes below aren't random — they cluster around a small number of underlying Python behaviors that are genuinely easy to misjudge on first exposure: names bind to objects rather than copying them, default argument values are evaluated once at function-definition time rather than fresh per call, closures capture variables by reference to their enclosing scope rather than by value at definition time, and exceptions form a broad hierarchy that's easy to over-catch. Recognizing the underlying rule behind each pitfall is what turns "I got bitten by this once" into "I saw this coming."
Mutable Default Arguments
A default argument value is evaluated exactly once, when the def statement runs, and that same object is reused on every call that doesn't supply its own value. If the default is mutable (a list, dict, or set) and the function mutates it, every call that relies on the default shares and accumulates state across calls — see the dedicated Default Arguments topic for the full mechanism.
def add_item(item, cart=[]): # cart is created ONCE, at def time
cart.append(item)
return cart
add_item("apple") # ["apple"]
add_item("banana") # ["apple", "banana"] -- surprise: same list!
# fix: use None as a sentinel, create fresh inside the function
def add_item(item, cart=None):
if cart is None:
cart = []
cart.append(item)
return cartLate-Binding Closures in Loops
A closure captures a *variable*, not the value it held at the moment the closure was created — so a closure created inside a loop, referencing the loop variable, sees whatever that variable holds when the closure is finally *called*, which by then is usually the loop's final value for every closure created. See the related Closures topic for the underlying scoping rule.
funcs = [lambda: i for i in range(3)]
print([f() for f in funcs]) # [2, 2, 2] -- not [0, 1, 2]!
# fix: bind the current value via a default argument
funcs = [lambda i=i: i for i in range(3)]
print([f() for f in funcs]) # [0, 1, 2]is vs ==
is compares object identity (are these literally the same object in memory); == compares equality via __eq__, which can be customized. Using is where == is meant produces bugs that vary by object type and, in CPython specifically, by an implementation detail: small integers and some strings are cached/interned, so is can appear to "work" by accident on toy examples and then fail on realistic data. See the related Identity vs Equality topic.
a = 1000
b = 1000
print(a == b) # True: equal value
print(a is b) # False in general -- two distinct int objects
# (CPython implementation detail: small ints like
# -5..256 ARE cached, so "a = 5; b = 5; a is b" can
# be True by accident -- never rely on this)
print(a is not None) # correct, idiomatic use of is: singleton checkShallow vs Deep Copy
copy.copy() (a shallow copy) creates a new outer container but reuses references to the same inner objects; copy.deepcopy() recursively copies inner objects too. Assignment (b = a) copies neither — it makes a second name for the same object. Mutating a nested mutable object after a shallow copy is visible through both the original and the copy. See the related Copying Objects topic.
import copy
original = {"tags": ["a", "b"]}
shallow = copy.copy(original)
shallow["tags"].append("c")
print(original["tags"]) # ['a', 'b', 'c'] -- the inner list is shared!
deep = copy.deepcopy(original)
deep["tags"].append("d")
print(original["tags"]) # unaffected -- deepcopy recursively copied itMutable Class Attributes
An attribute assigned directly in the class body (not inside __init__) is a single object shared by every instance of the class, because it lives on the class, not on each instance. If it's mutable and one instance mutates it, every instance sees the change — a variant of the same "shared mutable default" trap as mutable default arguments.
class ShoppingCart:
items = [] # class attribute -- shared by ALL instances!
def add(self, item):
self.items.append(item)
cart_a = ShoppingCart()
cart_b = ShoppingCart()
cart_a.add("apple")
print(cart_b.items) # ['apple'] -- leaked into a supposedly separate cart
# fix: initialize per-instance state in __init__
class ShoppingCart:
def __init__(self):
self.items = []Modifying a Collection While Iterating
Adding or removing elements from a list (or dict, or set) while iterating over it directly can skip elements, revisit elements, or raise RuntimeError, because the iterator tracks position/state that mutation invalidates out from under it.
nums = [1, 2, 3, 4, 5]
for n in nums:
if n % 2 == 0:
nums.remove(n) # mutating the list being iterated!
print(nums) # [1, 3, 5] happens to look right here, but this pattern
# is unreliable in general -- indices shift under the iterator
# fix: iterate over a copy, or build a new list
nums = [1, 2, 3, 4, 5]
nums = [n for n in nums if n % 2 != 0] # clear, correct, no mutation-during-iterationCatching Overly Broad Exceptions
except Exception: (or worse, bare except:) catches every error, including ones you didn't anticipate and shouldn't silently swallow — a typo causing AttributeError, a KeyboardInterrupt a user sent on purpose (bare except: catches this too, since it also catches BaseException subclasses). Catching broad and doing nothing (or just pass) hides real bugs behind an apparently working program. See the related Exception Handling topic.
try:
process(record)
except Exception:
pass # swallows EVERY error, including bugs in process() itself
# fix: catch only what you expect and can handle, and always
# think about whether to log/re-raise the rest
try:
process(record)
except ValueError as exc:
logger.warning("skipping invalid record: %s", exc)Shadowing Built-ins
Naming a variable list, dict, str, id, type, or sum rebinds that name in the local/module scope, hiding the built-in of the same name for the rest of that scope — Python allows this silently, since built-ins are just names in a special implicit scope, not reserved keywords.
def summarize(list): # shadows the built-in list constructor
list = sorted(list) # fine here, but...
return list(list) # TypeError: 'list' object is not callable
# -- the built-in list() is no longer reachable
def summarize(items): # fix: don't reuse built-in names
items = sorted(items)
return list(items)Circular Imports
If module A imports module B, and module B (directly or transitively) imports module A, Python can raise ImportError for a name that genuinely exists, because the first module is still mid-execution (partially initialized) when the second tries to import from it. This usually signals a design issue — two modules too tightly coupled — more than a syntax mistake. See the related Modules & Packages topic.
# models.py
from services import notify_admin # services.py imports models too!
class Order:
...
# services.py
from models import Order # ImportError if models.py hasn't finished
# executing yet when this line runs
# fix: restructure (move the shared piece to a third module),
# or import inside the function that needs it (a narrower workaround)Import Side Effects
The top-level statements in a module run once, the first time it's imported — so a module that opens a file, starts a network connection, or does other work at import time (outside of if __name__ == "__main__": or a function) performs that work as a side effect of simply being imported, which can surprise a caller who only wanted to use one function from it.
# config.py -- surprising: importing this module hits the network
import requests
SETTINGS = requests.get("https://config.example.com/settings").json()
# fix: defer the work behind a function, called explicitly when needed
import requests
def load_settings() -> dict:
return requests.get("https://config.example.com/settings").json()Floating-Point Equality
Floats are stored in binary IEEE-754 representation, and many decimal fractions (like 0.1) have no exact binary representation — so arithmetic that's exact in decimal can be off by a tiny amount in binary floating point, making == comparisons between computed floats unreliable. See the related Floating-Point Numbers topic.
print(0.1 + 0.2 == 0.3) # False!
print(0.1 + 0.2) # 0.30000000000000004
import math
print(math.isclose(0.1 + 0.2, 0.3)) # True -- compares within a toleranceTimezone Mistakes
A naive datetime (no attached timezone info) and an aware datetime (with tzinfo set) look similar but behave differently: comparing or subtracting a naive and an aware datetime raises TypeError, and treating a naive datetime as if it were in a specific timezone (often silently assumed to be local time, or silently assumed to be UTC) is a very common source of off-by-hours bugs, especially across daylight saving transitions or when a server and a user are in different timezones.
from datetime import datetime, timezone
naive = datetime.now() # no timezone info at all
aware = datetime.now(timezone.utc) # explicitly UTC
# naive - aware -> TypeError: can't subtract offset-naive and offset-aware datetimes
# fix: be explicit and consistent -- store/compare in UTC,
# convert to local time only for display
aware_now = datetime.now(timezone.utc)Async Pitfalls: Blocking Calls and Forgetting await
Two distinct async mistakes both stem from async/await being cooperative, not automatic: (1) calling a blocking, synchronous operation (a plain time.sleep, blocking file/network I/O) inside an async def freezes the entire event loop — every other coroutine waiting on that loop stalls too, since nothing preempts it. (2) Calling a coroutine function without await doesn't run it — it just creates a coroutine *object* that silently does nothing unless awaited or scheduled, which Python (in CPython at least) warns about at runtime ("coroutine was never awaited") but doesn't prevent.
import asyncio
import time
async def bad_delay():
time.sleep(2) # BLOCKS the whole event loop -- nothing else runs!
async def good_delay():
await asyncio.sleep(2) # cooperative: yields control back to the event loop
async def fetch_data():
return "data"
async def main():
result = fetch_data() # forgot await! result is a coroutine object,
print(result) # <coroutine object fetch_data at 0x...>
# RuntimeWarning: coroutine 'fetch_data' was never awaited
result = await fetch_data() # fixed: actually runs and returns "data"
print(result)Resource Leaks
Opening a file, socket, or database connection without ensuring it's closed — especially if an exception is raised between open and close — leaks the underlying OS resource. CPython's reference counting often closes such objects promptly when they become unreachable (a CPython implementation detail, not a language guarantee), which can mask the bug in casual testing while it still fails under load, on other implementations, or when an exception skips the close entirely. with (the context management protocol) guarantees cleanup regardless of how the block exits. See the related Context Managers topic.
f = open("data.txt")
data = f.read()
process(data) # if this raises, f.close() below never runs!
f.close()
# fix: with guarantees __exit__ (and thus close()) runs even on exception
with open("data.txt") as f:
data = f.read()
process(data)Untrusted Pickle and Unsafe eval/exec
Two related security pitfalls both boil down to the same rule: never execute or deserialize data from a source you don't fully trust. pickle.load() on untrusted bytes can execute arbitrary code during deserialization — the pickle format is not a safe way to accept data from outside your own system, only a convenience for trusted, internal use (e.g. caching your own objects). Likewise, eval()/exec() on untrusted input runs arbitrary Python code with your program's full privileges — there's essentially no way to "sandbox" them safely against a determined attacker.
import pickle
# NEVER do this with data from an untrusted source (a network request,
# an uploaded file, user input) -- pickle.load can execute arbitrary code
data = pickle.load(untrusted_file)
# safer for cross-system/untrusted data: a format with no code-execution
# capability, like JSON
import json
data = json.load(untrusted_file)
# similarly, avoid eval() on untrusted strings
user_input = "os.system('rm -rf /')"
eval(user_input) # NEVER -- runs arbitrary code
# if you need to evaluate a literal (not arbitrary code) safely:
import ast
value = ast.literal_eval("[1, 2, 3]") # only parses literals, no executionKey Takeaways
Default argument values are evaluated once, at def time — never use a mutable object as a default; use None and create fresh inside the function.
Closures capture variables, not values — bind loop variables explicitly (a default-argument trick, or a factory function) if a closure must remember the value at creation time.
Use is only for identity/singleton checks (x is None); use == for value equality, and never rely on CPython's small-int/string caching.
A class attribute assigned in the class body is shared by every instance — initialize per-instance mutable state inside __init__.
with (context managers) guarantees cleanup even on exception; a bare open/close pair does not.
Never deserialize pickle data, or eval()/exec() code, from a source you don't fully trust.
def f(items=[]): items.append(1) return items def f(items=None): if items is None: items = [] items.append(1) return items
The mutable default list is created once at def time and reused across every call, silently accumulating state; a None sentinel creates a fresh list per call.
try: risky() except: pass try: risky() except ValueError as exc: logger.warning("handled: %s", exc)
A bare except: catches everything, including KeyboardInterrupt and typos that raise unrelated errors, and pass silently discards all of it -- catch only the specific exceptions you expect and can meaningfully handle.
nums = [1, 2, 3, 4] for n in nums: if n % 2 == 0: nums.remove(n) nums = [1, 2, 3, 4] nums = [n for n in nums if n % 2 != 0]
Mutating a list while iterating over it directly shifts indices under the iterator, causing skipped or revisited elements; building a new filtered list avoids the hazard entirely.
data = pickle.load(response_from_the_internet) data = json.load(response_from_the_internet) pickle.load on untrusted bytes can execute arbitrary code during deserialization; a data-only format like JSON has no code-execution capability and is safe for untrusted input.
async def main(): fetch_data() # forgot await async def main(): await fetch_data()
Calling a coroutine function without await only creates a coroutine object -- it never actually runs; forgetting await is a silent no-op with a runtime warning, not an error that stops execution.
Why does a mutable default argument accumulate state across calls instead of resetting each time?
The default value is evaluated exactly once, when the def statement executes, and that same object is reused by every subsequent call that doesn't override it -- if it's mutable and mutated, the mutation persists across calls.
Why does a lambda created inside a loop, referencing the loop variable, tend to return the loop's final value for every instance?
A closure captures the variable itself, not the value it held when the closure was created; by the time the closures are called, the loop has finished and the variable holds its final value, which every closure sees.
Why is a is b an unreliable way to check whether two integers are equal in value?
is checks object identity, not value equality; even though CPython caches small integers (an implementation detail, not a language guarantee) so is can appear to work for small values, larger integers are typically distinct objects even when equal in value, so is gives the wrong answer in general.
What's the difference between what copy.copy() and copy.deepcopy() do to nested mutable objects?
copy.copy() makes a new outer container but keeps references to the same inner objects, so mutating a nested object is visible through both the original and the copy; copy.deepcopy() recursively copies inner objects too, so the two become fully independent.
Why can except Exception: pass hide real bugs rather than just handling expected errors?
It silently swallows every exception, including ones caused by genuine bugs (typos causing AttributeError, logic errors) that the programmer never anticipated -- the program appears to keep running but the underlying problem goes unnoticed.
Why is loading pickled data from an untrusted source dangerous, specifically?
Unpickling executes code as part of reconstructing certain objects, so a maliciously crafted pickle stream can run arbitrary code with the program's full privileges during deserialization -- it's not a data-only format the way JSON is.
What's the difference between a coroutine that raises an error and one where await was simply forgotten?
A raised error stops execution and is visible immediately; forgetting await just creates a coroutine object that never runs at all -- it's a silent no-op, flagged only by a runtime warning ("coroutine was never awaited"), not an exception.
What does this print, and why?
It prints {'debug': True}. overrides is a class attribute, defined in the class body rather than in __init__, so it's a single dict object shared by every instance -- mutating it through a's self.overrides is visible through b too.
Write a function make_multipliers() that returns a list of three functions, where calling the i-th function returns i * x for a given x, correctly avoiding the late-binding closure pitfall.
def make_multipliers(): return [lambda x, i=i: i * x for i in range(3)] fns = make_multipliers() print([f(10) for f in fns]) # [0, 10, 20]
Using i=i as a default argument captures the current value of i at each lambda's creation time (default values are evaluated once, at definition), rather than letting the lambda look up i in the enclosing scope later when the loop has already finished.
What does this print, and what's the correct way to make this comparison reliable?
It prints False -- repeated binary floating-point addition accumulates small representation error, so total ends up as something like 0.9999999999999999, not exactly 1.0. Use math.isclose(total, 1.0) for a tolerance-based comparison instead of exact equality.
class Config:
overrides = {}
def set(self, key, value):
self.overrides[key] = value
a = Config()
b = Config()
a.set("debug", True)
print(b.overrides)total = 0.0
for _ in range(10):
total += 0.1
print(total == 1.0)