Advanced Python Mistakes to Avoid

Learn the most common advanced Python mistakes: mutable defaults, late-binding closures, misuse of metaclasses, and other pitfalls that trip up experienced developers.

9 min read

Advanced Python mistakes are the ones that survive code review because the code looks correct. The program runs without crashing, but it produces wrong results, leaks memory, or behaves inconsistently. These mistakes come from misunderstanding Python's evaluation model rather than from typos or syntax errors.

The articles throughout this section covered many of the individual topics: the object model, descriptors, metaclasses, and memory management. This article collects the mistakes that experienced developers make when combining these features, along with the fixes that prevent them.

Each mistake reveals something fundamental about Python. Understanding why the mistake happens teaches you more than memorizing the fix.

Mutable default arguments

This is the most famous Python gotcha, and it still catches experienced developers. Default argument values are evaluated once at function definition time, not each time the function is called.

pythonpython
def append_to(item, target=[]):
    target.append(item)
    return target
 
print(append_to(1))
print(append_to(2))
print(append_to(3))

The same list object is shared across every call to the function. Each invocation appends to the accumulated list from all previous calls that relied on the default argument value.

texttext
[1]
[1, 2]
[1, 2, 3]

The fix is to use None as the sentinel default and create a new mutable object inside the function body only when needed.

pythonpython
def append_to(item, target=None):
    if target is None:
        target = []
    target.append(item)
    return target
 
print(append_to(1))
print(append_to(2))

Each call now gets its own fresh list. The None sentinel pattern is the standard solution and works for lists, dicts, sets, and any other mutable type.

texttext
[1]
[2]

Late-binding closures in loops

Closures capture variables by reference, not by value. When you create closures inside a loop, every closure references the same loop variable, which holds its final value after the loop completes, a detail the article on Python namespace and scope internals explains through cell objects.

pythonpython
funcs = []
for i in range(3):
    funcs.append(lambda: i)
 
print([f() for f in funcs])

All three closures return 2 because that is the value of i after the loop finishes. Each closure captured the variable, not its value at creation time.

texttext
[2, 2, 2]

The fix captures the current value at each iteration by binding it as a default argument value to the lambda parameter. Default arguments are evaluated at definition time rather than at call time, creating distinct closures for each iteration.

pythonpython
funcs = []
for i in range(3):
    funcs.append(lambda i=i: i)
 
print([f() for f in funcs])

Default arguments are evaluated at definition time. The i=i expression captures the current value of i as the default for the parameter, producing three distinct closures.

texttext
[0, 1, 2]

Metaclass overuse

Metaclasses are powerful, but most problems that seem to need a metaclass can be solved with simpler tools. The init subclass hook handles subclass registration, class decorators handle post-creation modification, and inheritance and mixins handle shared behaviour.

Before writing a metaclass, ask whether a class decorator would work, and use it if the answer is yes. If you only need to react to new subclasses, use init subclass instead. The article on Python metaclasses covers the legitimate use cases.

The metaclass is the right tool only when you need to intercept the class creation process itself. This means validating or modifying the namespace before the class object exists, or controlling the type of the resulting class.

Shadowing built-in names

Using built-in names as variable names works at first but breaks when you later need the built-in. The names list, dict, set, str, id, type, and filter are commonly shadowed.

pythonpython
list = [1, 2, 3]
 
try:
    more = list((4, 5))
except TypeError as e:
    print(e)

The variable list shadows the built-in list type. Subsequent calls to the list constructor fail because the name now refers to a list instance.

texttext
'list' object is not callable

The fix is to use descriptive variable names: instead of list, use items, values, or results, and instead of id, use identifier or object_id. Never shadow the name type, which is needed for both introspection and metaclass work.

Circular imports

When two modules import from each other, Python can produce confusing AttributeError messages. Module A imports from B, B starts importing from A, but A has not finished loading yet, so the name B wants is not yet defined.

The best fix is to restructure the code to break the cycle. Move shared dependencies to a third module that both A and B import. If restructuring is not possible, move the import inside the function that needs it rather than at module level.

pythonpython
def process():
    from module_b import helper
    return helper()

The deferred import delays the resolution until the function is actually called, by which time both modules have finished loading. This is a workaround, not a solution, so prefer restructuring the modules to eliminate the cycle.

Rune AI

Rune AI

Key Insights

  • Mutable default arguments are evaluated once and shared across calls; use None and create fresh objects.
  • Closures capture variables by reference; use default arguments to capture values in loops.
  • Metaclasses should be the last tool you reach for, not the first.
  • Never shadow built-in names like list, dict, or id with your own variables.
  • Import loops cause hard-to-debug AttributeErrors; restructure modules to break cycles.
RunePowered by Rune AI

Frequently Asked Questions

Why do mutable default arguments cause bugs in Python?

Default argument values are evaluated once at function definition time, not each time the function is called. When the default value is a mutable object like a list or dictionary, every call that uses the default shares the same object. Mutations from one call persist into the next. The fix is to use None as the default and create a new mutable object inside the function body when needed.

What is the late-binding closure problem?

Closures capture variables by reference, not by value. When a closure is created inside a loop, all closures reference the same variable, which holds its final value after the loop ends. This means calling any of the closures returns the same result. The fix is to capture the value at each iteration by passing it as a default argument or using a factory function.

Conclusion

Advanced Python mistakes are subtle because the code often looks correct and may even pass simple tests. Mutable defaults, late-binding closures, and metaclass overuse are the most common. The antidote is understanding Python's evaluation model: when things run, how names are resolved, and which objects are shared. Each mistake teaches something fundamental about how Python works.