Mid-level 4 min · March 06, 2026
Top 50 Python Interview Questions

Python Mutable Default Arguments — Data-Leak Gotcha

Intermittent personal data leaks? Python's mutable default args cause shared state at definition - avoid this gotcha that FAANG interviews test..

N
Naren Founder & Principal Engineer

20+ years shipping production code across the stack, with years spent interviewing engineers. Notes here come from systems that actually shipped.

Follow
Production
production tested
May 24, 2026
last updated
1,554
articles · all by Naren
 ● Production Incident 🔎 Debug Guide ⚙ Triage Commands
Quick Answer
  • Python interviewers test reasoning, not rote syntax: mutability, GIL, decorators, and generators appear in every senior round
  • Mutable default args cause shared-state bugs — use None and create fresh defaults inside the function
  • The GIL serialises CPU-bound threads — use multiprocessing for parallelism, threading for I/O
  • Decorators wrap functions for cross-cutting concerns; @functools.wraps preserves metadata
  • Generators yield lazily — use them for streaming data or infinite sequences to save memory
  • Context managers (with statement) acquire/release resources cleanly; implement with __enter__/__exit__ or @contextmanager
✦ Definition~90s read
What is Top 50 Python Interview Questions?

Python's mutable default arguments are a classic gotcha where a function's default parameter value—typically a list, dict, or set—is evaluated once at definition time, not each call. This means every invocation shares the same mutable object, so mutations persist across calls, effectively creating a data leak.

Think of a Python interview like a driving test.

The core problem is that Python binds default arguments to the function object itself, stored in __defaults__, and mutating them modifies that shared state. This violates the principle of least surprise and can cause subtle, hard-to-debug bugs in production code, especially in long-running services or when the function is called from multiple contexts.

The fix is trivial: use None as the default and instantiate the mutable inside the function body. This gotcha is a favorite interview question because it tests understanding of Python's object model, scoping rules, and the distinction between definition-time and runtime evaluation.

It also ties directly into memory management—since the default object's identity remains constant—and concurrency, where shared mutable state across calls can race under the GIL. Beyond the gotcha itself, the article explores how this pattern relates to generators (which lazily evaluate and can also leak state via closures), decorators (which wrap functions and may inadvertently capture mutable defaults), and context managers (which manage resources but can suffer similar state-sharing issues if not implemented carefully).

Understanding this single concept sharpens your mental model of Python's execution semantics and helps you write safer, more predictable code.

Plain-English First

Think of a Python interview like a driving test. The examiner doesn't just want to see you turn the wheel — they want to know you understand WHY you check mirrors, signal, and brake in that order. These 50 questions are the 'manoeuvres' every Python examiner tests. Know the reasoning behind each one and you'll pass confidently, not by luck.

Python interviews trip up even experienced developers — not because the language is hard, but because interviewers aren't just testing syntax. They're testing whether you understand memory management, mutability traps, the CPython internals that explain quirky behaviour, and whether you can apply the right tool for the right job under pressure. A candidate who can recite list comprehension syntax but can't explain WHY it's faster than a for-loop won't get the role.

This article exists because most interview prep resources give you a list of questions with shallow one-line answers. That's fine for a quiz, but it won't help you when the interviewer follows up with 'why does that happen?' or 'what would you use instead in a production system?' Every answer here explains the mechanism, not just the result — so you can handle follow-ups confidently.

By the end of this article you'll be able to answer all 50 questions with depth, spot the common traps interviewers deliberately set, write clean Python that demonstrates seniority, and walk into any intermediate-to-senior Python interview with genuine confidence rather than crossed fingers.

Why Mutable Default Arguments Are a Data Leak

In Python, default arguments are evaluated once at function definition time, not each time the function is called. This means if you use a mutable object like a list or dict as a default, that single object is shared across all calls. The core mechanic: the default value is bound to the function object itself, stored in __defaults__, and reused every invocation.

When you mutate that default argument inside the function — appending to a list, adding to a dict — the change persists across calls. This is not a bug; it's how Python's object model works. The function signature is compiled once, and the default objects live as long as the function exists. This behavior is deterministic and reproducible, but it violates the assumption most developers make that each call gets a fresh default.

Use this pattern intentionally for caching, memoization, or accumulating state across calls — but never for a parameter that should start empty each time. In real systems, this gotcha silently corrupts data: a list that should be empty for each request accumulates entries from previous requests, leading to hard-to-debug state leaks. The fix is to use None as the default and create a fresh mutable inside the function body.

Common Misconception
The default is not re-evaluated on each call. It's a single object created at def time, shared by all invocations.
Production Insight
A web handler using a mutable default list to accumulate query parameters will mix data across concurrent requests, causing one user to see another user's data.
The symptom: intermittent, non-reproducible data corruption that disappears when you add debug prints (because timing changes).
Rule: never use a mutable default for a parameter that should be fresh per call — use None and instantiate inside the function.
Key Takeaway
Default arguments are evaluated once at function definition, not at call time.
Mutating a default argument mutates the shared object, leaking state across calls.
Use None as the default for mutable parameters and create a new instance inside the function body.
Python Mutable Default Arguments Data Leak THECODEFORGE.IO Python Mutable Default Arguments Data Leak How mutable defaults cause shared state and memory leaks Mutable Default Argument List/dict created once at function definition Shared Object Identity All calls reference same mutable object Mutation Across Calls Changes persist and accumulate unexpectedly Memory Leak via Accumulation Unbounded growth from repeated mutations Fix: Immutable Default Use None and create new mutable inside ⚠ Default argument evaluated once at definition time Always use None for mutable defaults, then initialize inside THECODEFORGE.IO
thecodeforge.io
Python Mutable Default Arguments Data Leak
Top Python Interview Questions

Memory Management: Mutability and Identity

One of the most frequent 'gotcha' questions in Python interviews concerns the difference between mutable and immutable objects. In Python, everything is an object. Immutable objects (like strings, ints, and tuples) cannot be changed after creation; any 'modification' actually creates a new object in memory. Mutable objects (like lists and dicts) can be changed in place. This distinction is critical when passing arguments to functions, as Python uses 'Pass-by-Object-Reference.'

io/thecodeforge/core/mutability_demo.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
# io.thecodeforge: Demonstrating the Mutability Trap
def append_to_list(val, my_list=[]):  # DANGEROUS: Default args are evaluated once
    my_list.append(val)
    return my_list

# Production-grade approach
def safe_append(val, my_list=None):
    if my_list is None:
        my_list = []
    my_list.append(val)
    return my_list

print(f"Unsafe 1: {append_to_list(1)}")
print(f"Unsafe 2: {append_to_list(2)}") # Unexpectedly contains [1, 2]
Output
Unsafe 1: [1]
Unsafe 2: [1, 2]
Forge Tip:
Type this code yourself rather than copy-pasting. The muscle memory of writing it will help it stick. In interviews, always mention that default arguments in Python are evaluated at definition time, not execution time.
Production Insight
Mutability defaults cause silent data corruption that surfaces weeks later.
Only way to debug is to print(id(mylist)) across calls — painful.
Rule: never use mutable literals as defaults; use None and instantiate inside.
Key Takeaway
Default args are evaluated once at function definition.
Mutable defaults are shared across all calls.
Always use None as sentinel and create fresh mutable inside.

Concurrency and the GIL (Global Interpreter Lock)

For senior roles, you must explain why Python threads don't speed up CPU-bound tasks. The Global Interpreter Lock (GIL) is a mutex that protects access to Python objects, preventing multiple native threads from executing Python bytecodes at once. While this makes memory management simpler, it means for true parallelism in CPU-intensive work, you must use the multiprocessing module to bypass the GIL by using separate memory spaces.

io/thecodeforge/concurrency/gil_workaround.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
# io.thecodeforge: Parallelism via Multiprocessing
from multiprocessing import Pool
import os

def cpu_intensive_task(n):
    # Simulate heavy computation
    return sum(i * i for i in range(n))

if __name__ == "__main__":
    numbers = [10**6, 10**6, 10**6, 10**6]
    with Pool(processes=os.cpu_count()) as pool:
        results = pool.map(cpu_intensive_task, numbers)
    print(f"Task completed across {os.cpu_count()} cores.")
Output
Task completed across 8 cores.
Strategy Note:
If the interviewer asks about I/O-bound tasks (like web scraping), emphasize that threading is still useful because the GIL is released during I/O operations.
Production Insight
Using threading for CPU work causes performance worse than single-threaded.
The GIL context-switching overhead kills throughput.
Rule: threading for I/O, multiprocessing for CPU, asyncio for both when I/O dominates.
Key Takeaway
GIL lets one thread run Python bytecode at a time.
Threading adequate for I/O — GIL released during blocking calls.
Multiprocessing gives true parallelism via separate processes.

Generators and Iterators: Lazy Evaluation in Action

Generators are functions that use yield to produce a sequence of values lazily. They pause execution at each yield, preserving state until the next value is requested via next(). This makes them memory-efficient for large datasets and infinite sequences. Iterators are objects that implement __iter__() and __next__(). Python's for loop works by calling iter() on the target and then next() repeatedly until StopIteration is raised. Generators are a special case of iterators generated by functions with yield.

io/thecodeforge/iterators/range_simulator.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
# io.thecodeforge: Custom iterator vs generator for debouncing events
class EventTimestampIterator:
    def __init__(self, timestamps):
        self._timestamps = timestamps
        self._index = 0

    def __iter__(self):
        return self

    def __next__(self):
        if self._index >= len(self._timestamps):
            raise StopIteration
        ts = self._timestamps[self._index]
        self._index += 1
        return ts

# Generator equivalent – more concise
def debounced_events(timestamps, interval_ms=500):
    previous = None
    for ts in timestamps:
        if previous is None or (ts - previous) >= interval_ms:
            previous = ts
            yield ts

# Both produce the same output but generator uses less boilerplate
Mental Model: Generators as Paused Functions
  • yield freezes the function frame and returns a value to the caller.
  • The generator object stores the frame and local state on the heap.
  • Calling next() un-freezes and continues until the next yield or end.
  • This is exactly how async/await works in Python — it's syntactic sugar over generators.
Production Insight
Reading a 10GB file line by line with a generator works; loading it all into a list crashes.
Always prefer generators for streams, logs, and database cursors.
Rule: if you iterate once, use a generator. If you need random access, use a list.
Key Takeaway
Generators produce items on demand — memory footprint is O(1).
They maintain state across yield points without extra class code.
Use them for any sequence that could grow unbounded.

Decorators: Wrapping Functions for Cross-Cutting Concerns

A decorator is a function that takes another function as argument, extends its behaviour, and returns a new function. Python's @decorator syntax is syntactic sugar for func = decorator(func). Common use cases: logging, authentication, caching, timing, validation. Critical detail: unless you use @functools.wraps, the decorated function loses its original __name__ and __doc__, which breaks introspection and tooling.

io/thecodeforge/decorators/timing_decorator.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
# io.thecodeforge: Production-grade timing decorator with @wraps
import functools
import time

def measure_time(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        start = time.perf_counter()
        result = func(*args, **kwargs)
        elapsed = time.perf_counter() - start
        print(f"{func.__name__} took {elapsed:.4f}s")
        return result
    return wrapper

@measure_time
def fetch_user_data(user_id):
    time.sleep(0.2)  # simulate DB call
    return {"id": user_id, "name": "Alice"}

print(fetch_user_data(1))
print(f"Function name preserved: {fetch_user_data.__name__}")
Output
fetch_user_data took 0.2001s
{'id': 1, 'name': 'Alice'}
Function name preserved: fetch_user_data
Common Mistake: Missing @wraps
Without @functools.wraps, fetch_user_data.__name__ becomes 'wrapper'. This breaks tools like pytest, logging, and debuggers that rely on function identities.
Production Insight
Decorators without @wraps cause silent failures in middleware routing and test fixtures.
Always apply @wraps to the inner function — it copies __name__, __doc__, __module__.
Rule: any decorator you write for production must include @functools.wraps.
Key Takeaway
Decorators add pre/post processing without modifying the original function.
@functools.wraps preserves function identity — never skip it.
Use decorators for cross-cutting concerns only; don't overuse them for business logic.

Context Managers and Resource Management

Context managers handle acquiring and releasing resources automatically using the with statement. Built-in examples: file I/O, database connections, locks. You can create custom context managers by defining __enter__ and __exit__ methods on a class, or by decorating a generator function with @contextlib.contextmanager. The __exit__ method receives exception type, value, and traceback — you can choose to suppress exceptions or log them.

io/thecodeforge/context_managers/db_session.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# io.thecodeforge: Context manager for database sessions
from contextlib import contextmanager

@contextmanager
def db_session(connection_string):
    conn = create_connection(connection_string)  # not shown
    try:
        yield conn
        conn.commit()  # commit if no exception
    except Exception:
        conn.rollback()
        raise
    finally:
        conn.close()

# Usage — resource is automatically closed
with db_session("postgresql://localhost/mydb") as conn:
    conn.execute("INSERT INTO users ...")
Interview Hook:
When the interviewer asks 'How would you implement a context manager?', start with the class-based approach (__enter__/__exit__), then mention the @contextmanager shortcut for simple cases. This shows depth.
Production Insight
A missing try/finally around explicit close() calls leaks database connections.
Context managers eliminate that class of bug entirely.
Rule: any function that acquires a resource must use with statement or be wrapped in one.
Key Takeaway
Context managers guarantee cleanup even when exceptions occur.
__exit__ can suppress exceptions by returning True — only do that intentionally.
Use @contextmanager for simple cases; class-based for complex state.

Pass by Object Reference: Why Your Function Mutates the Caller's Data

Junior devs love to argue pass-by-value vs pass-by-reference. The truth is messier: Python uses pass-by-object-reference. The variable name you pass is a reference to an object. If you assign a new value to that name inside the function, the caller doesn't see it. But if you mutate the object the name points to, the caller sees the change. This is why list.append() modifies the original list, but list = new_list does not. Understanding this prevents production bugs where a function unexpectedly corrupts a shared cache or database session state. Always copy mutable objects before modifying them unless you explicitly want side effects.

mutation_trap.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// io.thecodeforge

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

# Production trap: default list is shared across calls
print(add_item('a'))  # ['a']
print(add_item('b'))  # ['a', 'b'] -- not ['b']!

# Safe version: use immutable sentinel
from typing import List, Optional

def add_item_safe(item: str, target_list: Optional[List[str]] = None) -> List[str]:
    if target_list is None:
        target_list = []
    target_list.append(item)
    return target_list
Output
['a']
['a', 'b']
['a']
['b']
Production Trap:
Never use mutable default arguments like def foo(items=[]). Every call without that argument shares the same list object. Use None and create a new list inside the function.
Key Takeaway
In Python, your function receives the object reference, not a copy of the value. Mutate the object, and the caller sees it. Reassign the name, and the caller doesn’t.

The `__slots__` Directive: Slash Memory Usage Without Changing Logic

Every Python object carries a __dict__ that stores its attributes in a hash map. For thousands of small objects, this overhead kills memory and cache locality. __slots__ tells the interpreter to allocate a fixed-size array for attributes instead of a dictionary. Each slot attribute becomes a descriptor, so attribute access is faster. Use it for data classes, ORM models, or any place you create many instances. The downside: you cannot add new attributes dynamically, and you must declare every attribute in the class hierarchy. Measure before optimizing, but when you have a million objects, __slots__ can cut memory from 50 bytes per instance to under 20.

slots_memory.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// io.thecodeforge

class Point:
    __slots__ = ('x', 'y')

    def __init__(self, x: float, y: float):
        self.x = x
        self.y = y

# Try to add a new attribute at runtime
p = Point(1.0, 2.0)
# p.z = 3.0  # AttributeError: 'Point' object has no attribute 'z'

# Memory comparison (rough)
import sys

class DynamicPoint:
    def __init__(self, x, y):
        self.x = x
        self.y = y

d = DynamicPoint(1, 2)
p = Point(1, 2)

print(f"Dynamic: {sys.getsizeof(d)} bytes + dict overhead")
print(f"Slotted: {sys.getsizeof(p)} bytes, no dict")
Output
Dynamic: 56 bytes + dict overhead
Slotted: 48 bytes, no dict
When to Use:
Only apply __slots__ when profiling shows memory pressure from many instances. It breaks inheritance if a parent doesn't also define __slots__. Test your hierarchy.
Key Takeaway
__slots__ removes per-instance dictionaries, cutting memory by up to 50% for object-heavy code, at the cost of dynamic attribute creation.
● Production incidentPOST-MORTEMseverity: high

The Shared-State Disaster: Mutable Default Arguments

Symptom
Users get data from previous requests appended to their payload — personal data leaks occur intermittently.
Assumption
The developer assumed that the default argument my_list=[] creates a fresh list on every call, as it would in most languages.
Root cause
Python evaluates default arguments once at function definition time, not at call time. All calls share the same list object.
Fix
Replace mutable defaults with None and create a new list inside the function if None is passed.
Key lesson
  • Never use mutable objects (list, dict, set) as default argument values.
  • Use None as the default and instantiate the mutable inside the function body.
  • Always check default arguments during code review — this bug is nearly invisible in log files.
Production debug guideFour common Python production failures and the exact commands to diagnose them4 entries
Symptom · 01
Memory usage grows without bound; process OOM-killed
Fix
Run tracemalloc.start() and snapshot; diff snapshots to find leak — or inspect with gc.get_objects()
Symptom · 02
Application hangs on a CPU-bound task
Fix
Attach with py-spy or strace to see which thread is spinning; check if you accidentally used threading instead of multiprocessing
Symptom · 03
Function returns stale data (mutable default bug)
Fix
Inspect the function's __defaults__ attribute; compare object IDs with id() to confirm shared state
Symptom · 04
ImportError after deployment: module not found
Fix
Run python -c 'import sys; print(sys.path)' and verify the module is in one of those paths; check for missing __init__.py
★ Quick Debug Cheat Sheet: Python Production IssuesFive high-signal commands for the most common Python interview topics that break in production
Recursion depth exceeded in production
Immediate action
Increase recursion limit temporarily for diagnosis
Commands
import sys; sys.setrecursionlimit(5000)
sys.getrecursionlimit()
Fix now
Rewrite the algorithm iteratively or adjust the recursion limit carefully — but never rely on high limits in production.
GIL causing latency spikes in I/O-heavy service+
Immediate action
Swap threads for asyncio or multiprocessing
Commands
import asyncio; asyncio.run(async_main())
from multiprocessing import Pool; with Pool() as p: p.map(func, data)
Fix now
Use asyncio for high-concurrency I/O; use multiprocessing for CPU-bound parallel work.
Decorated function loses its name and docstring+
Immediate action
Check if @functools.wraps is applied
Commands
print(my_func.__name__) # returns 'wrapper' without wraps
import functools; @functools.wraps(func) def wrapper(...)
Fix now
Always apply @functools.wraps to the inner function of any decorator you write.
Generator ran out of items (StopIteration)+
Immediate action
Wrap usage in a for loop or next(gen, default)
Commands
next(gen, 'fallback')
list(gen) # careful: exhausts the generator
Fix now
Use for item in gen: loop which handles StopIteration automatically; never manually call next() without a default.
Context manager resource not released (file handle leak)+
Immediate action
Check if object implements __enter__/__exit__ or use contextlib.closing
Commands
type(obj).__exit__ # verify method exists
from contextlib import closing; with closing(thing):
Fix now
Always use the with statement for resources; if the object doesn't support it, wrap with contextlib.closing().
FeatureListTuple
MutabilityMutable (Can change)Immutable (Cannot change)
Memory UsageHigher (Over-allocation for growth)Lower (Fixed size)
PerformanceSlower iterationFaster iteration
Use CaseHomogeneous data, dynamic sizingHeterogeneous data, fixed records/keys

Key takeaways

1
You now understand that Python's 'Pass-by-Object-Reference' behaves differently for mutable vs. immutable types.
2
You've seen how to bypass the GIL using the multiprocessing module for CPU-bound performance.
3
Generators and context managers reduce memory footprint and ensure resource cleanup.
4
Decorators with @functools.wraps preserve function identity for production tooling.
5
The 'Forge' standard requires safe handling of default arguments to ensure production stability.
6
Practice daily
the forge only works when it's hot 🔥

Common mistakes to avoid

5 patterns
×

Using mutable default arguments in functions

Symptom
Function results unexpectedly accumulate data across calls; test suite fails when run in import order.
Fix
Replace default value [] or {} with None, then create the mutable inside the function body if None.
×

Confusing 'is' vs '==' equality

Symptom
Comparing two integer variables with 'is' works for small ints (caching) but fails unexpectedly for large ints or strings.
Fix
Use '==' for value equality, 'is' only for testing identity (e.g., x is None).
×

Failing to use context managers for file I/O or database connections

Symptom
ResourceWarning: unclosed file or database connection pool exhaustion in production.
Fix
Always use 'with open() as f:' or 'with db_session() as conn:' to guarantee cleanup.
×

Assuming threading speeds up CPU-bound tasks

Symptom
CPU utilisation remains low while threads contend for the GIL; throughput worse than single-threaded.
Fix
Use multiprocessing.Pool for CPU-bound work; reserve threading for I/O-bound tasks.
×

Forgetting @functools.wraps on decorators

Symptom
Decorated function appears with name 'wrapper' in logs and debugger; __doc__ lost.
Fix
Import functools, apply @functools.wraps to the inner function before returning it.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01SENIOR
Explain the difference between `__str__` and `__repr__` in Python.
Q02SENIOR
How does Python's garbage collection handle reference cycles?
Q03JUNIOR
What is the difference between `@staticmethod` and `@classmethod`?
Q04SENIOR
Explain how Python resolves attribute lookups in classes (MRO).
Q05SENIOR
What is the purpose of `__slots__` and when should you use it?
Q01 of 05SENIOR

Explain the difference between `__str__` and `__repr__` in Python.

ANSWER
__repr__ aims to be unambiguous — ideally returning a string that could be used to recreate the object (e.g., Fraction(3, 4)). __str__ aims to be readable for end users. If only __repr__ is defined, __str__ falls back to it. Senior engineers always define __repr__ first, then __str__ when a pretty-print is needed.
FAQ · 5 QUESTIONS

Frequently Asked Questions

01
What is the difference between a Deep Copy and a Shallow Copy in Python?
02
How does Python's Garbage Collection work?
03
What are Python Decorators and when should you use them?
04
Explain the difference between `yield` and `return` in a generator.
05
How do you handle large files that don't fit into memory in Python?
N
Naren Founder & Principal Engineer

20+ years shipping production code across the stack, with years spent interviewing engineers. Notes here come from systems that actually shipped.

Follow
Verified
production tested
May 24, 2026
last updated
1,554
articles · all by Naren
🔥

That's Python Interview. Mark it forged?

4 min read · try the examples if you haven't

Previous
Spring Boot Interview Questions
1 / 4 · Python Interview
Next
Python OOP Interview Questions