Common Python Pitfalls

Even experienced Python developers get bitten by these gotchas. This guide covers the most common pitfalls, explains why they happen, and shows you how to fix them.

Mutable Default Arguments

This is the single most common Python gotcha. When you use a mutable object (like a list or dictionary) as a default argument, that object is shared across all calls to the function.

# BUG: The default list is shared between all calls!
def add_item(item, items=[]):
    items.append(item)
    return items

print(add_item("a"))  # ['a']
print(add_item("b"))  # ['a', 'b']  — Wait, what?!

The default value [] is created once when the function is defined, not each time it is called. Every call that uses the default shares the same list object.

The fix: Use None as the default and create a new list inside the function.

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

Python Playground
Output
Click "Run" to execute your code

Late Binding Closures in Loops

When you create closures (functions that reference variables from an enclosing scope) inside a loop, all closures share the same variable. By the time the closures execute, the loop variable has its final value.

# BUG: All functions return 4 (the last value of i)
functions = []
for i in range(5):
    functions.append(lambda: i)

print([f() for f in functions])  # [4, 4, 4, 4, 4]

The fix: Capture the variable's current value using a default argument.

functions = []
for i in range(5):
    functions.append(lambda i=i: i)  # i=i captures the current value

print([f() for f in functions])  # [0, 1, 2, 3, 4]

Python Playground
Output
Click "Run" to execute your code

Modifying a List While Iterating

Changing a list while you are looping over it leads to skipped elements and confusing behavior.

# BUG: Skips elements!
numbers = [1, 2, 3, 4, 5, 6]
for num in numbers:
    if num % 2 == 0:
        numbers.remove(num)
# Result: [1, 3, 5, 6]  — 6 was skipped!

The fix: Iterate over a copy, or use a list comprehension to create a new list.

# Fix 1: Iterate over a copy
for num in numbers[:]:  # [:] creates a shallow copy
    if num % 2 == 0:
        numbers.remove(num)

# Fix 2: List comprehension (preferred)
numbers = [num for num in numbers if num % 2 != 0]

Python Playground
Output
Click "Run" to execute your code

is vs == (Identity vs Equality)

== checks whether two objects have the same value. is checks whether they are the same object in memory. These are not the same thing.

a = [1, 2, 3]
b = [1, 2, 3]

a == b   # True  — same value
a is b   # False — different objects!

a = b
a is b   # True  — now they point to the same object

The rule: Use == for value comparison. Only use is for checking None, True, and False:

# Good
if x is None:
    ...

if x is not None:
    ...

# Bad — don't use is for value comparison
if x is 42:     # Don't do this!
    ...

Python Playground
Output
Click "Run" to execute your code

Integer Caching Surprises

CPython caches small integers (typically -5 to 256) for performance. This means is comparisons on small numbers may accidentally work, giving you a false sense of security.

a = 256
b = 256
a is b   # True — cached!

a = 257
b = 257
a is b   # False — not cached (in most contexts)

This behavior is an implementation detail that varies across Python versions and implementations. Never use is for number comparisons.

Python Playground
Output
Click "Run" to execute your code

Circular Imports

Circular imports happen when module A imports module B, and module B imports module A. Python handles this by partially loading modules, which can lead to ImportError or AttributeError.

# module_a.py
from module_b import helper_b

def helper_a():
    return "A"

# module_b.py
from module_a import helper_a  # ImportError or AttributeError!

def helper_b():
    return "B"

Fixes:

  • Restructure your code to remove the circular dependency (best solution)
  • Move imports inside functions so they happen at call time, not import time
  • Use a third module to hold shared code
# Fix: Import inside the function
# module_b.py
def helper_b():
    from module_a import helper_a  # Imported when called, not when loaded
    return "B"

Bare except: Catching Too Much

A bare except: (or except Exception:) catches more than you think, including KeyboardInterrupt and SystemExit, which prevents users from stopping your program with Ctrl+C.

# BAD: Catches EVERYTHING, even Ctrl+C
try:
    do_something()
except:
    pass  # Silently swallows all errors

# BAD: Still too broad
try:
    do_something()
except Exception:
    pass  # At least doesn't catch KeyboardInterrupt

# GOOD: Catch specific exceptions
try:
    value = int(user_input)
except ValueError:
    print("Please enter a valid number")

Python Playground
Output
Click "Run" to execute your code

Forgetting self in Methods

When defining a method in a class, the first parameter must be self (for instance methods). Forgetting it leads to confusing errors.

# BUG: Missing self
class Calculator:
    def add(a, b):      # Oops! Missing self
        return a + b

calc = Calculator()
calc.add(2, 3)  # TypeError: add() takes 2 positional arguments but 3 were given

The error message is confusing because Python automatically passes the instance as the first argument. So calc.add(2, 3) actually calls add(calc, 2, 3).

# FIX: Include self
class Calculator:
    def add(self, a, b):
        return a + b

Python Playground
Output
Click "Run" to execute your code

Variable Scope Gotchas (UnboundLocalError)

If you assign to a variable inside a function, Python treats it as a local variable for the entire function -- even before the assignment. This can cause UnboundLocalError.

x = 10

def print_x():
    print(x)     # Works fine — reads the global x

def modify_x():
    print(x)     # UnboundLocalError! Python sees the assignment below
    x = 20       # This makes x local for the ENTIRE function

# Fix: Use the global keyword (or better, avoid globals)
def modify_x():
    global x
    print(x)
    x = 20

Python Playground
Output
Click "Run" to execute your code

OOP Pitfalls

Object-oriented programming in Python has its own set of traps. These are especially dangerous because they often produce code that works initially but breaks in subtle ways as the codebase grows.

The Fragile Base Class Problem

When you inherit from a class, you create a tight coupling. Changes to the base class can silently break subclasses. This is the fragile base class problem.

class BaseList:
    def __init__(self):
        self.items = []

    def add(self, item):
        self.items.append(item)

    def add_many(self, items):
        for item in items:
            self.add(item)  # Calls self.add()

class CountingList(BaseList):
    def __init__(self):
        super().__init__()
        self.add_count = 0

    def add(self, item):
        self.add_count += 1
        super().add(item)

# Seems fine, but...
cl = CountingList()
cl.add_many([1, 2, 3])
# add_count is 3 — correct! But only because BaseList.add_many calls self.add()
# If BaseList changes to use self.items.append() directly, our count breaks.

Python Playground
Output
Click "Run" to execute your code

Diamond Inheritance Confusion

Multiple inheritance with a shared ancestor (the diamond pattern) creates confusion about which methods get called and in what order.

class A:
    def __init__(self):
        print("A.__init__")
        self.value = "A"

class B(A):
    def __init__(self):
        print("B.__init__")
        super().__init__()
        self.value = "B"

class C(A):
    def __init__(self):
        print("C.__init__")
        super().__init__()
        self.value = "C"

class D(B, C):
    def __init__(self):
        print("D.__init__")
        super().__init__()

# What is D().value? The answer depends on the MRO.

Python Playground
Output
Click "Run" to execute your code

Overusing Inheritance

A common mistake is reaching for inheritance when composition would be simpler and more flexible.

# Overuse of inheritance
class DatabaseConnection:
    ...

class UserRepository(DatabaseConnection):  # Is UserRepository a database connection? No!
    ...

# Better: composition
class UserRepository:
    def __init__(self, db: DatabaseConnection):
        self.db = db

Ask yourself: "Is my subclass truly a kind of the parent class?" If not, use composition.

super() Gotchas with Multiple Inheritance

super() does not call the parent class -- it calls the next class in the MRO. This is a critical distinction with multiple inheritance.

class A:
    def method(self):
        print("A.method")

class B(A):
    def method(self):
        print("B.method")
        super().method()  # Calls C.method, NOT A.method!

class C(A):
    def method(self):
        print("C.method")
        super().method()  # Calls A.method

class D(B, C):
    def method(self):
        print("D.method")
        super().method()

# D.method -> B.method -> C.method -> A.method

Python Playground
Output
Click "Run" to execute your code

Forgetting super().__init__() in Diamond Hierarchies

When using multiple inheritance, every __init__ in the chain must call super().__init__(). If even one class forgets, part of the hierarchy goes uninitialized.

class A:
    def __init__(self):
        self.a_value = "initialized"

class B(A):
    def __init__(self):
        super().__init__()  # MUST call super()
        self.b_value = "initialized"

class C(A):
    def __init__(self):
        # BUG: Forgot super().__init__()!
        self.c_value = "initialized"

class D(B, C):
    def __init__(self):
        super().__init__()

# D() -> B.__init__ -> C.__init__ (no super!) -> A.__init__ never called!

Python Playground
Output
Click "Run" to execute your code

Quick Reference: Pitfall Checklist

Pitfall Symptom Fix
Mutable default args Data bleeds between calls Use None as default
Late binding closures All closures return same value Capture with i=i
Modify list while iterating Skipped or missing elements Iterate over copy or use comprehension
is vs == False negatives on value comparison Use == for values, is for None
Integer caching is works for small numbers only Always use == for numbers
Bare except: Swallows KeyboardInterrupt Catch specific exceptions
Forgetting self Confusing TypeError messages Always include self as first param
UnboundLocalError Variable seems to vanish Don't mix reading/assigning globals in functions
Fragile base class Subclass breaks when parent changes Prefer composition
Diamond inheritance Wrong method called, missing init Understand MRO, always call super().__init__()

What's Next?

Learn how to handle errors gracefully in Error Handling Best Practices.