I still remember the cold, stale air of the office at 3:14 AM during my time at that logistics startup. I wasn’t debugging a complex distributed systems problem or a race condition in the database; I was staring at a single, seemingly innocent Python function that was slowly poisoning every subsequent request in the queue. It turns out that learning how to avoid a shared mutable default isn’t something you pick up from a high-level architectural seminar, but from watching your production metrics turn into a slow-motion car crash because someone used a list as a default argument.
I’m not here to lecture you on Python’s internals or recite documentation that you could easily Google yourself. Instead, I want to show you the pattern of the mistake, why it’s so easy to miss during a standard code review, and the exact, boring way to fix it so you can actually sleep through the night. I’ll skip the academic fluff and give you the concrete implementation details that I’ve had to write down for myself after every single time this has bitten me in production.
Table of Contents
Tracking Side Effects in Function Calls

The problem isn’t just that the value changes; it’s that the change is invisible until it isn’t. When you pass a mutable object—like a list or a dictionary—as a default argument, you aren’t just setting a starting value. You are actually attaching a specific instance to the function definition itself at the moment the module is loaded. Because that object lives as long as the function does, every subsequent call that relies on that default is interacting with the exact same memory address. This is where the concept of object identity in Python becomes a nightmare for debugging; you think you’re working with a fresh container, but you’re actually poking at a global state that has been accumulating garbage from every previous execution.
I’ve spent far too many hours tracing logs only to realize that a function was behaving differently not because of the inputs, but because of its own history. These side effects in function calls turn your logic into a moving target. If Function A modifies a list that was passed as a default, Function B—which might be totally unrelated—will inherit those modifications. It makes unit testing a headache because your tests might pass in isolation but fail when run in a specific sequence. It’s a subtle form of technical debt that usually stays hidden until the system is under enough load to make the state drift noticeable.
The Invisible Trap of Object Identity in Python

The reason this behavior feels so insidious is that Python doesn’t treat a default argument as a template for a new object; it treats it as a piece of data that lives on the function itself. When you define a function, that default object is instantiated exactly once, at the moment of definition. This is where the concept of object identity in Python becomes your biggest enemy. If you pass a list or a dictionary as that default, you aren’t passing a fresh copy every time the function is invoked. You are passing a pointer to the exact same memory address that was created when the module was first loaded.
I remember a specific incident during a migration where a service started behaving erratically, seemingly “remembering” data from previous requests. It turned out a developer had used a dictionary as a default parameter to collect metadata. Because that dictionary persisted across every single call, data from User A was leaking into User B’s session. It wasn’t a logic error in the function body; it was a fundamental misunderstanding of mutable vs immutable objects. Once you realize that the function is essentially carrying around a hidden, long-lived backpack of state, you stop seeing these bugs as “glitches” and start seeing them as the inevitable result of how Python manages its internal references.
My rules for not sabotaging the next person's deployment
- Use `None` as your placeholder. It’s the simplest, most boring, and most effective way to ensure a new object is actually created every time the function runs.
- If you find yourself needing a complex default, initialize it inside the function body. It feels like extra lines of code, but it’s better than a three-hour debugging session trying to figure out why a dictionary is growing across different requests.
- Treat every mutable object—lists, dicts, even sets—as a potential landmine. If it can be changed in place, it shouldn’t be sitting in a function signature.
- When writing unit tests, don’t just test the “happy path.” Specifically write tests that call the function multiple times in a row to see if state is leaking from one call to the next.
- If you’re working in a large codebase, use type hinting with `Optional`. It won’t stop the bug, but it makes the intention clear to anyone else reading your code—and to your future self when you’re looking at this at 2 AM.
What I'm actually walking away with
Stop using mutable objects like lists or dicts as default arguments; if you need a container, use `None` and initialize it inside the function body.
Remember that Python evaluates default arguments exactly once—at definition time, not at execution time—which is why that “empty” list isn’t actually empty after the first call.
When you’re debugging a weird state issue in production, don’t just look at the logic; check if a function is silently carrying baggage from a previous invocation via a shared object.
The Long View

At the end of the day, avoiding shared mutable defaults isn’t about memorizing a specific Python quirk; it’s about respecting the fact that state is a liability. We’ve seen how object identity can turn a simple function call into a cross-thread contamination event, and how tracking those side effects is often harder than writing the logic itself. The fix is almost always boring—use `None` as your placeholder, initialize your collections inside the function body, and keep your function signatures predictable and stateless. It’s a small amount of extra typing that buys you a massive amount of sleep when you’re on call.
I tend to view code not as a collection of clever tricks, but as a series of decisions we make for our future selves. Every time I catch myself reaching for a mutable default, I remind myself that I am essentially setting a trap for the person who has to debug this at 2 AM—and more often than not, that person is me. Write your code with the assumption that someone will eventually try to use it in a way you didn’t intend. If you design for that level of skepticism, you won’t just write better Python; you’ll build systems that actually stay upright when things get messy.
Frequently Asked Questions
If I use `None` as the default, does that actually add significant overhead in a high-throughput loop?
In a high-throughput loop, the overhead of checking `if x is None` is negligible compared to the cost of the actual logic inside the loop. We’re talking nanoseconds. I’ve seen much bigger bottlenecks in database connection pooling and JSON serialization. Don’t optimize for the micro-cost of a `None` check; optimize for the sanity of your team. Use `None`. It makes the intent clear and the behavior predictable, which is worth way more than a few CPU cycles.
Is there a way to use type hinting that makes this mistake obvious during a code review before it hits production?
You can’t really catch this with standard type hints because, technically, a list is a list. The type checker sees exactly what you asked for. However, if you use `mypy` with strict settings or a tool like `beartype` for runtime checks, you can get closer. Personally, I just use `Sequence` or `Mapping` from `typing` for function arguments. It signals that the function shouldn’t be mutating the input, which makes the intent—and the mistake—much louder during a review.
How do I handle this when I'm working with third-party libraries that seem to use mutable defaults internally?
This is where it gets messy. When the bug isn’t in your code, but buried in a dependency, you can’t exactly go in and rewrite their function signatures. My rule of thumb is to treat those functions as radioactive. Don’t pass your own mutable objects into them, and if the library is returning a shared object, wrap it in a `copy.deepcopy()` immediately. It’s a small performance hit, but it’s cheaper than a 2 AM debugging session.
At what point does the "None check" inside the function body become more of a maintenance headache than the original problem?
It becomes a headache the moment you start writing logic to handle the “implications” of the None rather than the data itself. If your function body is suddenly 40% boilerplate checking `if arg is None` just to decide which default object to instantiate, you’ve traded one bug for a different kind of noise. I prefer keeping the function’s core logic clean. If the check makes the function harder to reason about, it’s time to move that logic upstream.
