Python Interview Questions That Trip Up Even Senior Developers
You've been writing Python for years but can you explain why [[]] * 3 shares the same inner list, why your multithreaded code is slower than single-threaded, or what += actually does to a list? These are the CPython interview questions that separate script-writers from senior developers.
Python Interview Questions That Trip Up Even Senior Developers
You know Python.
You've been writing it for years.
You pip install your way out of every problem, you've built REST APIs, you've done the whole Pandas DataFrame thing, and at this point Python feels like breathing.
So when the interviewer goes:
"Are you comfortable with Python internals?"
You nod. Obviously.
Then they write this on the whiteboard:
def add_item(item, items=[]):
items.append(item)
return items
print(add_item(1))
print(add_item(2))
"What does the second print output?"
You say [2].
Interview's over.
Not because the question is hard. Because you just revealed that you've been writing Python for years without understanding one of the most fundamental gotchas in the language.
Senior Python interviews don't test if you can write a list comprehension. They test if you know what CPython is actually doing. How memory works. Why your multithreaded code is somehow slower than single-threaded. Why your production service is leaking 200MB a day.
This is that guide.
1. The Mutable Default Argument Trap
Back to the intro question.
print(add_item(1)) # [1]
print(add_item(2)) # [1, 2]
Yeah. [1, 2]. Not [2].
Most people think default arguments get evaluated every time you call the function.
They don't.
Default arguments are evaluated ONCE. When the def statement executes. When the module loads.
That [] in the function signature? It's created once. One list object. In memory. Sitting there. And every single call to add_item without passing a second argument uses that exact same list.
You're appending to the same list every time.
def add_item(item, items=None):
if items is None:
items = []
items.append(item)
return items
That's the fix. None as default, create fresh list inside.
If you haven't been burned by this in production yet, you haven't been writing Python long enough.
2. Late Binding Closures
"What does this print?"
multipliers = [lambda x: i * x for i in range(4)]
for m in multipliers:
print(m(2))
You want to say 0, 2, 4, 6.
It prints 6, 6, 6, 6.
Every. Single. One.
Python closures are late-binding. The lambda doesn't capture the value of i at the time it's created. It captures a reference to i. And by the time you actually call any of these lambdas, the loop is done. i is 3. Every lambda evaluates 3 * 2.
Six six six six.
Fix:
multipliers = [lambda x, i=i: i * x for i in range(4)]
Force early binding with a default argument. Which, as we just learned in trap #1, gets evaluated at definition time.
See how trap #1 and trap #2 are connected? That's the kind of thing interviewers love. When you link two concepts together like that, they know you actually get it.
3. The GIL
If you don't understand the GIL, you don't understand Python concurrency. Full stop.
"I have CPU-heavy work. I'll just use threading to split it across 4 cores."
No you won't.
CPython has a Global Interpreter Lock. It's a mutex that prevents multiple threads from executing Python bytecodes at the same time. Doesn't matter if you have 16 cores. One thread runs Python at a time.
One process. One GIL. One thread executing Python. That's it.
If you spin up 4 threads to do heavy math, it will be slower than 1 thread. Not the same speed. Slower. Because now you have 4 threads fighting over the GIL plus context-switching overhead.
So what's threading actually good for?
I/O. Network requests, database queries, file reads. When a thread is waiting for an HTTP response, it releases the GIL. Another thread can run. That's useful.
For actual CPU parallelism? multiprocessing. Separate OS processes. Separate interpreters. Separate GILs. Actual parallelism.
Or if you're on Python 3.13+, there's the experimental free-threaded mode that removes the GIL entirely. But that's bleeding edge and most production code isn't there yet.
4. is vs ==
a = 256
b = 256
print(a is b) # True
c = 257
d = 257
print(c is d) # False
What.
== checks value equality. Do these two things have the same value?
is checks identity. Are these two things the same object in memory?
CPython pre-allocates integers from -5 to 256 at startup. It's called integer interning. Every time you use 42 or 256 or -3, Python just hands you a pointer to the same pre-existing object. Saves memory.
257? Not interned. Gets a fresh object every time. Two different objects, same value. == says True, is says False.
(If you run this in a .py file instead of the REPL, the compiler might actually intern them both anyway as an optimization. But the point is: never use is to compare values. Use ==.)
The interviewers don't care if you memorized the number 256. They care if you know the difference between identity and equality.
5. copy vs deepcopy
import copy
original = [[1, 2, 3], [4, 5, 6]]
shallow = copy.copy(original)
deep = copy.deepcopy(original)
original[0][0] = 999
print(shallow) # [[999, 2, 3], [4, 5, 6]]
print(deep) # [[1, 2, 3], [4, 5, 6]]
copy.copy() creates a new outer list. But the inner lists? Still the same objects. Same references. You mutate original[0] and shallow[0] changes too because they're pointing at the same inner list.
copy.deepcopy() recursively copies everything. New outer list, new inner lists, completely independent.
And here's where people really mess up:
a = [1, 2, 3]
b = a
That's not a copy at all. b IS a. Same object. b.append(4) and a is now [1, 2, 3, 4] too.
c = a[:] # shallow copy
d = list(a) # also shallow copy
These create new lists but if the elements are mutable objects, you still share references to them.
The number of production bugs caused by people thinking b = a copies a list is... honestly depressing.
6. Modifying a List While Iterating
l = [1, 2, 3, 4]
for i in l:
l.remove(i)
print(l)
"Empty list, right? We removed every element."
Output: [2, 4]
Python doesn't throw a ConcurrentModificationException like Java. It just silently does the wrong thing.
The iterator keeps an internal index. Starts at 0. Reads element at index 0 (which is 1), removes it. List shifts left: [2, 3, 4]. Iterator increments to index 1. Index 1 is now 3 (not 2). It completely skipped 2.
Same thing happens again. Removes 3, list becomes [2, 4], iterator goes to index 2, index 2 doesn't exist, loop ends.
[2, 4].
This is one of those bugs that makes you question reality at 2 AM.
Fix: iterate over a copy.
for i in l.copy():
l.remove(i)
Or just don't mutate while iterating. Build a new list:
l = [x for x in l if x not in items_to_remove]
7. Generators vs Lists (Memory)
# This creates a list. All 10 million integers. In memory. Right now.
big_list = [x for x in range(10_000_000)]
# This creates a generator. Produces values one at a time. Almost no memory.
big_gen = (x for x in range(10_000_000))
The difference? Square brackets vs parentheses.
big_list eats ~400MB of RAM.
big_gen eats basically nothing. It computes each value on demand.
If you're processing a massive file line by line and you load the entire thing into a list first, you deserve whatever OOM error you get.
# Bad - loads entire file into memory
lines = open("huge_file.txt").readlines()
for line in lines:
process(line)
# Good - streams line by line
for line in open("huge_file.txt"):
process(line)
Also the generator is exhaustible. Once you iterate through it, it's done. Empty. Can't restart it. Can't index into it. Can't get its length without consuming it.
gen = (x for x in range(5))
print(list(gen)) # [0, 1, 2, 3, 4]
print(list(gen)) # [] <-- gone
That's not a bug, that's by design. But it trips people up constantly.
8. Decorators Are Just Functions
A lot of developers use decorators every day and can't explain what they actually are.
@my_decorator
def say_hello():
print("hello")
This is literally just:
def say_hello():
print("hello")
say_hello = my_decorator(say_hello)
That's it. @ is syntactic sugar for "pass this function to that function and replace it with whatever comes back."
A decorator takes a function, wraps it, returns a new function.
def timer(func):
def wrapper(*args, **kwargs):
start = time.time()
result = func(*args, **kwargs)
print(f"{func.__name__} took {time.time() - start:.2f}s")
return result
return wrapper
When the interviewer asks "write a decorator" and you freeze up, it's because you've been treating @ as magic. It's not magic. It's a function that takes a function and returns a function.
And use functools.wraps:
from functools import wraps
def timer(func):
@wraps(func)
def wrapper(*args, **kwargs):
# ...
return wrapper
Without @wraps, your decorated function loses its original name, docstring, and other metadata. say_hello.__name__ returns "wrapper" instead of "say_hello". Debugging becomes a nightmare.
9. __new__ vs __init__
Everyone knows __init__. It initializes the object.
Almost nobody knows __new__.
__new__ actually creates the instance. __init__ just fills in the attributes after the object already exists.
"Implement a Singleton in Python."
class Singleton:
_instance = None
def __new__(cls, *args, **kwargs):
if not cls._instance:
cls._instance = super().__new__(cls)
return cls._instance
def __init__(self, value):
self.value = value
a = Singleton(1)
b = Singleton(2)
print(a is b) # True - same object
print(a.value) # 2 - __init__ ran again on the same instance
Notice the gotcha: __init__ still runs every time even though __new__ returns the existing instance. So a.value becomes 2. Most people don't catch this.
If you can't explain the difference between __new__ and __init__, you don't understand how Python creates objects. And if you can't write a metaclass, you definitely don't understand it.
10. __slots__
Normal Python objects store their attributes in a __dict__ — a dictionary that can grow dynamically.
class User:
def __init__(self, name, age):
self.name = name
self.age = age
u = User("rushi", 25)
u.random_attr = "sure, why not" # totally fine, dict can grow
If you're creating millions of these objects, each one carries a __dict__. That's a lot of memory overhead.
class User:
__slots__ = ('name', 'age')
def __init__(self, name, age):
self.name = name
self.age = age
u = User("rushi", 25)
u.random_attr = "nope" # AttributeError
__slots__ tells Python: "these are the ONLY attributes this class will ever have." No __dict__ needed. Fixed memory layout. Way less memory per instance and slightly faster attribute access.
Tradeoffs:
- Can't add arbitrary attributes
- Can't use
__dict__on instances - Multiple inheritance with
__slots__gets messy - You lose flexibility for performance
Interviewers ask this to see if you've ever actually dealt with memory optimization in Python. If you haven't, you'll have no idea what __slots__ is.
11. Dictionary Ordering
"Are Python dictionaries ordered?"
This is a trick question based on which Python version you're talking about.
Python 3.6: dicts are ordered as an implementation detail of CPython. Not guaranteed by the language spec.
Python 3.7+: insertion order is officially part of the language spec.
Python 3.5 and earlier: completely unordered.
d = {"b": 2, "a": 1, "c": 3}
print(list(d.keys())) # ['b', 'a', 'c'] in Python 3.7+
If someone tells you "use OrderedDict for ordered dictionaries" in 2026, they're living in the past. Regular dict preserves insertion order now.
OrderedDict still exists and has some differences though — like == comparison between two OrderedDicts considers order, while regular dict comparison doesn't.
from collections import OrderedDict
dict1 = {"a": 1, "b": 2}
dict2 = {"b": 2, "a": 1}
print(dict1 == dict2) # True — regular dict ignores order for equality
od1 = OrderedDict({"a": 1, "b": 2})
od2 = OrderedDict({"b": 2, "a": 1})
print(od1 == od2) # False — order matters
12. Python's Memory Model — Reference Counting and GC
"Does Python have memory leaks?"
"No, it has garbage collection."
Wrong.
Python's primary memory management is reference counting. Every object has a counter. Something points to it, counter goes up. Reference goes away, counter goes down. Hits zero, memory freed immediately.
But.
class Node:
def __init__(self):
self.next = None
a = Node()
b = Node()
a.next = b
b.next = a # circular reference
del a
del b
# Reference counts are both 1 (they point to each other)
# Reference counting alone can't free them
That's a reference cycle. Both objects have a refcount of 1 even though nothing in your program can reach them anymore.
Python has a generational garbage collector that periodically scans for these cycles and cleans them up. But:
- It runs periodically, not instantly
- Objects with
__del__methods in cycles used to be uncollectable (fixed in Python 3.4 but still a gotcha people bring up) - If you're caching things in a global dict and forgetting to evict — that's a real memory leak, GC can't help you, those references are reachable
For long-running services (web servers, workers, daemons), this matters. A lot.
weakref module lets you create references that don't increase the refcount. Useful for caches.
import weakref
class BigObject:
pass
obj = BigObject()
weak = weakref.ref(obj)
print(weak()) # <BigObject object>
del obj
print(weak()) # None — it's gone, weak ref didn't prevent GC
13. asyncio — Single Threaded, Remember
People hear "async" and think "parallel."
No.
Python's asyncio is single-threaded cooperative multitasking. One thread. One event loop.
import asyncio
async def fetch_data():
await asyncio.sleep(1) # simulates I/O
return "data"
async def main():
result = await fetch_data()
print(result)
asyncio.run(main())
await is not "run this on another thread." It's "pause this coroutine, let the event loop run something else, come back when this I/O is done."
If you do CPU-heavy work inside an async function without await, you block the entire event loop. Everything else waits.
async def bad():
# This blocks the entire event loop for 10 seconds
# No other coroutine can run during this
result = heavy_cpu_computation()
return result
For concurrent I/O, async is great:
async def main():
# These run concurrently, not sequentially
results = await asyncio.gather(
fetch_url("https://api1.com"),
fetch_url("https://api2.com"),
fetch_url("https://api3.com"),
)
Three HTTP requests at the same time. One thread. That's the point.
But the moment you put time.sleep(5) instead of await asyncio.sleep(5), you've blocked everything. time.sleep doesn't know about the event loop. It just freezes the thread.
14. String Interning and Immutability
a = "hello"
b = "hello"
print(a is b) # True
c = "hello world!"
d = "hello world!"
print(c is d) # ...maybe? depends.
Python interns some strings. Short strings that look like identifiers (letters, digits, underscores) tend to get interned. Strings with spaces or special characters? Maybe, maybe not. It's an implementation detail, not a guarantee.
But the bigger point: strings in Python are immutable.
s = "hello"
s[0] = "H" # TypeError
Every time you "modify" a string, you're creating a new string object.
s = ""
for i in range(10000):
s += str(i) # creates a new string object EVERY iteration
That's O(n²) behavior because each concatenation copies all previous characters into a new string.
parts = []
for i in range(10000):
parts.append(str(i))
s = "".join(parts) # one allocation, O(n)
If you're building strings in a loop with +=, you've probably shipped slow code without knowing it. join() is the answer. Always.
15. The Walrus Operator and When to Actually Use It
Python 3.8 added := and half the community lost their minds.
# Before
line = input()
while line != "quit":
process(line)
line = input()
# After
while (line := input()) != "quit":
process(line)
# Useful in comprehensions
results = [y for x in data if (y := expensive(x)) > threshold]
Without the walrus, you'd either compute expensive(x) twice or write a regular loop.
Where it gets annoying:
# Please don't do this
if (n := len(a)) > 10 and (m := len(b)) > 5 and (k := n + m) > 20:
print(f"{k}")
Just because you can jam assignments into expressions doesn't mean you should. Python's whole vibe is readability. Don't sacrifice that for cleverness.
Interviewers ask about this to see if you know modern Python. If you're still writing Python 3.6 patterns in 2026, that's a signal.
16. What the hell is *args, **kwargs?
Everyone uses them. Not everyone can explain the unpacking rules.
def func(a, b, *args, **kwargs):
print(a) # 1
print(b) # 2
print(args) # (3, 4, 5)
print(kwargs) # {'x': 10, 'y': 20}
func(1, 2, 3, 4, 5, x=10, y=20)
*args — collects extra positional arguments into a tuple.
**kwargs — collects extra keyword arguments into a dict.
But the ordering rules:
def func(positional, /, normal, *, keyword_only):
pass
- Before
/— positional only (Python 3.8+) - After
*— keyword only - Between — either
def func(a, b, /, c, d, *, e, f):
pass
func(1, 2, 3, d=4, e=5, f=6) # works
func(a=1, b=2, c=3, d=4, e=5, f=6) # TypeError — a and b are positional only
The / thing trips people up because most devs have never seen it. But len, range, and most builtins use positional-only parameters internally — you just never noticed because you never tried len(obj=[1,2,3]).
17. The Question That Separates Everyone
Interviewer gives you this:
x = [1, 2, 3]
y = x
x = x + [4]
print(x)
print(y)
vs
x = [1, 2, 3]
y = x
x += [4]
print(x)
print(y)
"Same output?"
No.
First one:
[1, 2, 3, 4]
[1, 2, 3]
x + [4] creates a NEW list and assigns it to x. y still points to the old list.
Second one:
[1, 2, 3, 4]
[1, 2, 3, 4]
x += [4] calls x.__iadd__([4]) which mutates the list IN PLACE. y points to the same object, so it sees the change.
+ creates new object. += mutates in place (for mutable types).
For immutable types like int and str, += does create a new object because it can't mutate. But for lists? In-place.
This is the kind of question where knowing the answer is the difference between "I write Python" and "I understand Python's object model."
The mistakes that will tank your interview
❌ "Python is multithreaded, just use threading."
The GIL says otherwise.
❌ "volatile exists in Python like Java."
It doesn't. Python doesn't have volatile. The GIL provides some guarantees but it's not the same thing at all.
❌ "is and == are the same thing."
They're not even close.
❌ "Lists are copied with =."
No. That creates another reference to the same object.
❌ "async makes my code run in parallel."
Single thread. Event loop. Concurrent I/O, not parallel execution.
❌ "Python can't have memory leaks because of garbage collection."
It absolutely can.
❌ "Decorators are some advanced metaprogramming thing."
They're literally just func = decorator(func).
Final thought
Python looks simple. That's the trap.
a = [[]] * 3
a[0].append(1)
print(a)
Output: [[1], [1], [1]]
Not [[1], [], []].
Because [[]] * 3 creates three references to THE SAME inner list. Not three separate lists. One list, three aliases.
This is Python. The language that looks like pseudocode until you actually have to debug it in production at 3 AM and nothing makes sense and you're questioning your entire career.
The developers who get senior roles aren't the ones who've memorized every stdlib function. They're the ones who can look at a piece of Python code and tell you what CPython is actually doing with it in memory.
Stop reading Python tutorials. Start reading CPython source code.
Or at minimum, stop assuming = copies things. It doesn't. It never did. And that's probably causing a bug in your code right now.