PythonCommon Mistakes

Common Mistakes

Python is forgiving syntax, but that forgiveness hides a handful of traps that catch beginners and experienced developers alike. None of these are exotic — they show up in real codebases, in real code reviews, and in real production incidents. This page rounds up the mistakes that come up again and again, why they happen, and how to avoid them.

Quick reference

Mistake

Why it happens

Fix

Mutable default arguments

The default value is created once, when the function is defined, not on every call.

Use None as the default and create the mutable object inside the function.

Modifying a list while iterating it

Removing or inserting items shifts indices under the iterator, so items get skipped.

Iterate over a copy (for x in lst[:]) or build a new list with a comprehension.

Comparing floats with ==

Floating-point numbers cannot represent most decimals exactly.

Use math.isclose() instead of == for float comparisons.

Using is instead of ==

is checks identity, not equality — it can appear to work by accident for small ints or interned strings.

Use == to compare values; reserve is for None, True, False.

Forgetting self

Instance methods are called on an instance, and Python passes it implicitly as the first argument.

Always declare instance methods as def method(self, ...).

Late-binding closures in loops

Functions created in a loop share the loop variable — they look it up when called, not when created.

Capture the value with a default argument or functools.partial.

Circular imports

Two modules import each other at module load time, so one of them sees a half-initialised module.

Restructure the shared code into a third module, or move the import inside the function that needs it.

Bare except: clauses

It silently swallows every exception, including ones you never meant to catch.

Catch Exception (or a specific exception type) instead of using a bare except:.

Mutable default arguments

Default argument values are evaluated once, when the def statement runs — not each time the function is called. If the default is a mutable object like a list or dict, every call that relies on the default shares the same object.

broken

Python
def add_item(item, items=[]):
    items.append(item)
    return items

print(add_item('a'))  # ['a']
print(add_item('b'))  # ['a', 'b']  <- surprise! same list every call

fixed

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

print(add_item('a'))  # ['a']
print(add_item('b'))  # ['b']
Modifying a list while iterating over it

When you loop over a list with for x in lst, Python tracks the current index internally. Removing an element shifts every element after it down by one, so the next index the loop visits skips an item.

broken — skips elements

Python
nums = [1, 2, 3, 4, 5, 6]
for n in nums:
    if n % 2 == 0:
        nums.remove(n)

print(nums)  # [1, 3, 5]  <- looks right by luck, but try [2, 4, 6, 8] and see

fixed — iterate a copy, or build a new list

Python
nums = [1, 2, 3, 4, 5, 6]

# Option 1: iterate over a copy
for n in nums[:]:
    if n % 2 == 0:
        nums.remove(n)

# Option 2: comprehension (preferred)
nums = [1, 2, 3, 4, 5, 6]
nums = [n for n in nums if n % 2 != 0]
Comparing floats with `==`

Floating-point numbers are stored in binary, and most decimal fractions cannot be represented exactly. This means arithmetic that looks correct on paper can fail an equality check.

broken

Python
print(0.1 + 0.2 == 0.3)  # False
print(0.1 + 0.2)         # 0.30000000000000004

fixed

Python
import math

print(math.isclose(0.1 + 0.2, 0.3))  # True
Using `is` instead of `==`

is checks whether two names point to the exact same object in memory; == checks whether two objects are equal in value. CPython caches small integers and some string literals, so is can appear to work — until it does not.

broken — works by accident, then breaks

Python
a = 1000
b = 1000
print(a is b)   # False on most CPython builds — not cached
print(a == b)   # True — this is what you actually want

x = 5
y = 5
print(x is y)   # True — small ints ARE cached, but this is an implementation detail
print(x == y)   # True — reliable regardless of implementation
Don't rely on interning
Small-int and string caching is a CPython implementation detail, not a language guarantee. Code that relies on `is` for value comparison can break on a different Python build or version. Use `==` for values; use `is` only for singleton checks like `x is None`.
Forgetting `self`

When Python calls instance.method(arg), it desugars to ClassName.method(instance, arg) — the instance is always passed as the first parameter. Omit it in the signature and Python will either raise a TypeError about too many arguments, or (if you also forget to declare the method inside the class body correctly) fail in a more confusing way.

broken

Python
class Counter:
    def increment():   # missing self
        pass

c = Counter()
c.increment()
# TypeError: increment() takes 0 positional arguments but 1 was given

fixed

Python
class Counter:
    def increment(self):
        self.count = getattr(self, 'count', 0) + 1

c = Counter()
c.increment()
Late-binding closures in loops

A function defined inside a loop does not "freeze" the loop variable's current value — it looks the variable up when the function is called, by which point the loop has finished and the variable holds its final value. This is the classic "lambda in a loop" bug.

broken — every lambda prints the same value

Python
funcs = []
for i in range(3):
    funcs.append(lambda: i)

print([f() for f in funcs])  # [2, 2, 2]  <- not [0, 1, 2]!

fixed — capture the value with a default argument

Python
funcs = []
for i in range(3):
    funcs.append(lambda i=i: i)  # i=i captures the current value now

print([f() for f in funcs])  # [0, 1, 2]

alternative fix — functools.partial

Python
from functools import partial

def identity(i):
    return i

funcs = [partial(identity, i) for i in range(3)]
print([f() for f in funcs])  # [0, 1, 2]
Circular imports

A circular import happens when module A imports module B, and module B (directly or indirectly) imports module A. The symptom is usually an ImportError or AttributeError complaining that a name "partially initialized module" has no attribute — because whichever module runs second sees an incomplete version of the other.

the shape of the problem

Python
# models.py
from services import save_user   # imports services

# services.py
from models import User          # imports models -> circular!

The most durable fix is to restructure: pull the shared pieces (like User) into a third module that both models.py and services.py depend on, so neither imports the other. A quick, pragmatic workaround is to move the import inside the function that needs it, so it only runs after both modules have finished loading:

quick workaround — local import

Python
# services.py
def save_user(user):
    from models import User  # imported lazily, when the function runs
    ...
Bare `except:` clauses

A bare except: catches everything — not just the exceptions you expected, but typos that raise NameError, KeyboardInterrupt when the user presses Ctrl+C, and even SystemExit. This makes bugs invisible and programs impossible to stop cleanly.

broken

Python
try:
    result = compute_soemthing()  # typo — should be compute_something
except:
    result = None
# The NameError from the typo is silently swallowed. Good luck debugging this.

fixed

Python
try:
    result = compute_something()
except Exception as exc:
    logging.exception('computation failed: %s', exc)
    result = None
Rule of thumb
Catch the narrowest exception type that makes sense (`ValueError`, `KeyError`, a custom exception). If you truly need a catch-all, catch `Exception`, not a bare `except:` — that way `KeyboardInterrupt` and `SystemExit` still propagate as expected.