The Things I've Watched Break Production Code
I've spent more years than I care to count watching perfectly reasonable people write Python that works fine on their machine and then quietly corrupts a production database at 2 AM. The mistakes aren't usually dramatic. They're the kind of thing that slips in during a routine change because nobody bothered to read the small print. There's a reason people look for a Step By Step Guide For Python Common Mistakes To Avoid — it's because the beginner tutorials don't cover what actually goes wrong when you're shipping real software. They show you list comprehensions and move on. They don't tell you what happens when two threads hit that same list at the same time.
Mutable Default Arguments
This is the mistake that shows up first in my code reviews and it never stops surprising me. In Python, default arguments are evaluated once at function definition time, not every time the function is called. So if you write something like this: def add_item(item, container=[]):
container.append(item)
return container The empty list isn't created fresh on each call. It's created once, when the function is defined, and then that same list object gets reused over and over. The second time you call add_item("apple"), the container already contains whatever the first call added to it. This isn't a Python quirk. This is how function definitions actually work in the language. You can verify it by checking the id() of the default argument across multiple calls.
I had a job once where a data processing pipeline was silently dropping records because an engineer used a mutable default dict to cache results between calls. The cache never cleared. After about six hours of processing, memory usage had climbed to four gigabytes and nobody could figure out why. The fix was one line — pass None as the default and create the dict inside the function body. But the bug had been sitting there for three months.
List Comprehensions With Side Effects
People use list comprehensions because they're concise. The problem is they tend to hide side effects. When you write a regular for loop, the indentation makes it visually obvious that something is happening inside the block. With a list comprehension, the logic lives on one line and the side effect becomes invisible until the data starts looking wrong. Here's what I see too often: results = [process_item(x) for x in items] process_item also writes to a database
The list comprehension doesn't care about the database writes. It only cares about the return values. If process_item raises an exception halfway through, half the items have been written and half haven't. You get partial data corruption and no error message that points you at the right place. A regular for loop with explicit try/except around each iteration gives you better control and makes the error handling obvious at a glance.
Understanding What Actually Gets Passed Around
Python doesn't pass by reference the way C++ does, and it doesn't pass by value the way Java does. It passes by object reference, which means variables are names bound to objects in memory. When you assign one variable to another, you're not copying the object. You're creating a new name that points to the same object. This matters a lot when you're working with dictionaries and nested data structures. A shallow copy of a dict copies the top-level keys but the values still point to the same nested objects. So modifying a nested list inside a copied dict also modifies the original. I ran into this last year with a configuration system where I was deep-copying settings between environments and the nested lists were still sharing references. The workaround was using copy.deepcopy from the standard library, but the real fix was restructuring the config to use immutable types like tuples instead of lists where mutability wasn't needed.
Step By Step Guide For Python Common Mistakes To Avoid
The most useful approach isn't to memorize a checklist. It's to understand the patterns that cause problems and build habits that prevent them. Here's what actually works in practice, based on the mistakes I've personally encountered and debugged across dozens of projects. Mypy and ruff will catch a significant portion of the bugs that end up as support tickets. Type hints alone don't prevent runtime errors, but combined with a linter they'll flag things like calling a method that doesn't exist on a type, or passing the wrong type to a function. I recommend running mypy in strict mode on your CI pipeline. It adds about thirty seconds to your build and has saved me from at least two production incidents per year. The pattern to use instead is straightforward. If a function needs a mutable default, use None and create the object inside the function. This applies to lists, dicts, sets, and any other mutable type. It's a habit you build in the first week and then never think about again.
def add_item(item, container=None):
if container is None:
container = []
container.append(item)
return container
Step three: Avoid side effects in comprehensions
Write your loops as explicit for loops when they do more than just transform data. If a comprehension is doing database writes, file I/O, API calls, or state mutations, factor that logic out into a named function and call it from a regular loop. The code is slightly longer but the intent is clear and the error handling is visible. Use shallow copies when you need a new container but don't care about nested objects being shared. Use deep copies when nested mutability is a problem. The rule of thumb is: if your data structure contains only immutable types (strings, numbers, tuples), a shallow copy is fine. If it contains lists, dicts, or custom mutable objects inside nested structures, use deepcopy. There's a performance cost to deepcopy — roughly three to five times slower than a shallow copy for large structures — so don't reach for it unless you actually need it. The mistake most people make here is testing the happy path and calling it done. You need to test what happens when inputs are empty, when they're unexpectedly large, when they contain unexpected types, and when the function is called repeatedly with the same arguments. Pytest's parametrize decorator makes this easy.
@pytest.mark.parametrize("items,expected",[
([], []),
([1, 2, 3], [1, 2, 3]),
([None, "", 0], [None, "", 0]),
])
def test_process_items(items, expected):
assert process_items(items) == expected This runs three test cases with one fixture. You'll catch the empty list case and the falsy value case in the same pass.
Step six: Read the traceback before asking for help
Most Python errors have tracebacks that tell you exactly what went wrong and where. The mistake is skimming past them and posting on a forum with "my code doesn't work." Look at the last line of the traceback — it names the exception. Look at the lines above it — they show the call stack. You'll usually find the answer in the first two lines. I recently spent forty-five minutes debugging an issue that turned out to be a KeyError in a dict lookup. The traceback said so explicitly on the last line. I'd been staring at the wrong part of the code for an hour before I actually read it.
What This Approach Doesn't Fix
These steps catch the common mistakes. They don't prevent architectural problems, race conditions in concurrent code, or issues that arise from incorrect domain logic. A well-typed function that implements the wrong algorithm is still wrong. Linters won't tell you that your pagination logic skips page three when the offset calculation is off by one. For concurrency issues specifically, the standard threading library in Python has limitations due to the GIL that make it unsuitable for CPU-bound parallel work. If you're doing heavy computation across multiple cores, you need multiprocessing or asyncio depending on whether your bottleneck is CPU or I/O. Mixing threading with shared mutable state without proper locking is a different category of bug entirely, and these steps won't shield you from it.
A Note On Debugging Real Problems
When something breaks in production, the first thing I do is add structured logging around the suspected area. Not print statements. Structured logs with correlation IDs that let you trace a single request across multiple functions. I once spent two days chasing a bug that was only reproducible under load. The root cause was a connection pool exhausting itself because a context manager wasn't being used correctly in an async function. The fix was adding async with statements around the pool acquisition, but the investigation took longer than it should have because the error didn't surface until the pool was fully depleted. If you want a downloadable reference, the Python documentation's "Common Pitfalls" section is the closest thing to an official guide. It's terse and covers the basics but doesn't go into the kind of real-world scenarios where these mistakes actually show up. The best resource is your own error history — keep a personal document of the bugs you've encountered and how you fixed them. That becomes more valuable than any generic list over time.
Get the Full Details
