Python interview questions from beginner to advanced - core concepts, real-world debugging scenarios and coding exercises with worked answers and runnable code.
“What does this print?” followed by a five-line snippet with a mutable default argument. That question has ended more Python interviews than any algorithm puzzle, because it tests whether you understand how the language actually works rather than whether you’ve memorised syntax. This bank collects the questions that come up again and again, grouped by level, and split three ways: concepts (explain how it works), scenarios (something is broken in production, what do you do) and coding (write it, with runnable answers).
Beginner: the data model, built-in types, functions and the gotchas every Python developer hits in their first months.
Intermediate: decorators, generators, context managers, closures, exceptions, and day-to-day debugging and packaging.
Advanced: the GIL and free threading, memory management, asyncio internals, metaclasses and descriptors, and production performance problems.
1What’s the difference between mutable and immutable types, and why does it matter?Beginner
Immutable: int, float, bool, str, tuple, frozenset, bytes. “Changing” one creates a new object
Mutable: list, dict, set, bytearray and most user-defined objects. They change in place
Only hashable (in practice, immutable) objects can be dict keys or set members. A tuple of lists isn’t hashable
Mutability is behind the classic bugs: shared default arguments, aliasing (b = a doesn’t copy a list), and modifying a list while iterating over it
1
2
3
4
5
6
s="hello"t=s.upper()# new string; s is unchanged
a=[1,2]b=a# same list, two names
b.append(3)print(a)# [1, 2, 3]
2What’s the difference between is and ==?Beginner
== compares values (calls __eq__). is compares identity (same object in memory, id(a) == id(b))
Use is only for singletons: None, True, False, and sentinel objects
CPython caches small integers (-5 to 256) and interns some strings, so a is b can be True for equal ints or strings by accident. Never rely on it. Python 3.8+ emits a SyntaxWarning for x is 5
1
2
3
a=[1,2]b=[1,2]print(a==b,aisb)# True False
3When would you use a list, tuple, set or dict?Beginner
Type
Ordered
Mutable
Duplicates
Lookup
Use for
list
Yes
Yes
Yes
O(n) by value
Sequences you append to or reorder
tuple
Yes
No
Yes
O(n)
Fixed records, dict keys, return values
set
No
Yes
No
O(1) average
Membership tests, de-duplication, set algebra
dict
Insertion order (3.7+)
Yes
Unique keys
O(1) average by key
Mapping keys to values
A common performance fix in interviews: replace if x in big_list inside a loop with a set
4How does Python pass arguments to functions: by value or by reference?Beginner
Neither, exactly. Python passes references to objects (“call by sharing”). The parameter name is bound to the same object the caller passed
Mutating a mutable argument (lst.append(1)) is visible to the caller
Rebinding the parameter (lst = [9]) only changes the local name, not the caller’s variable
1
2
3
4
5
6
7
8
9
10
defmutate(lst):lst.append(4)# caller sees this
defrebind(lst):lst=[9]# caller doesn't see this
nums=[1,2,3]mutate(nums)rebind(nums)print(nums)# [1, 2, 3, 4]
5What are *args and **kwargs, and what are keyword-only and positional-only parameters?Beginner
*args collects extra positional arguments into a tuple; **kwargs collects extra keyword arguments into a dict
Parameters after * (or *args) are keyword-only; parameters before / are positional-only (3.8+)
The same syntax unpacks at the call site: f(*items, **options)
1
2
3
4
5
defconnect(host,/,port=5432,*,timeout=5,**extra):returnhost,port,timeout,extraconnect("db",5433,timeout=10,sslmode="require")# connect(host="db") -> TypeError: host is positional-only
6What does if __name__ == "__main__": do?Beginner
Every module has a __name__. It’s "__main__" when the file is run directly, and the module’s import name when it’s imported
Code under the guard runs only when executed as a script, so the module can also be imported (by tests or other code) without side effects
1A helper that adds items to a list keeps returning items from previous calls. What’s going on?Scenario · Beginner
Default argument values are evaluated once, when the function is defined, not on every call. A mutable default is shared by every call
Fix: use None as the default and create a new object inside the function
Linters flag this (Ruff B006, Pylint dangerous-default-value). Turn those rules on
1
2
3
4
5
6
7
8
9
10
11
12
defadd_tag(tag,tags=[]):# bug: one shared list
tags.append(tag)returntagsadd_tag("a")print(add_tag("b"))# ['a', 'b']
defadd_tag_fixed(tag,tags=None):iftagsisNone:tags=[]tags.append(tag)returntags
2pip install requests succeeded, but import requests fails with ModuleNotFoundError. How do you troubleshoot?Scenario · Beginner
The package went into a different interpreter from the one running your code
Check which Python runs the script and which pip installed it: python -c "import sys; print(sys.executable)" and python -m pip --version
Always install with python -m pip install ... so pip matches the interpreter, and activate the right virtual environment
In an IDE or Jupyter, check the selected interpreter or kernel (see what ipykernel is)
Other causes: a local file named requests.py shadowing the package, or running from the wrong directory. There’s a full walkthrough in the No module named post
3A loop that removes invalid entries from a list leaves some of them behind. Why?Scenario · Beginner
Removing items while iterating shifts the remaining elements left, so the iterator skips the element after each removal
Fix: build a new list with a comprehension, or iterate over a copy
1
2
3
4
5
6
7
8
9
nums=[1,2,2,3]forninnums:ifn==2:nums.remove(n)print(nums)# [1, 2, 3]: one 2 survived
nums=[1,2,2,3]nums=[nforninnumsifn!=2]print(nums)# [1, 3]
4An invoice total comes out as 0.30000000000000004 instead of 0.3. What do you do?Scenario · Beginner
Floats are binary (IEEE 754) and can’t represent most decimal fractions exactly, so 0.1 + 0.2 != 0.3
For money, use decimal.Decimal built from strings, or store integer minor units (cents/paise)
For comparisons of measured values, use math.isclose() rather than ==
1Count how often each word appears in a text, ignoring case and punctuation, and return the top three.Beginner
re.findall handles tokenising; collections.Counter does the counting and ranking
Mention the manual dict version too. Interviewers sometimes want to see you can do it without Counter
1
2
3
4
5
6
7
8
9
importrefromcollectionsimportCounterdeftop_words(text,n=3):words=re.findall(r"[a-z']+",text.lower())returnCounter(words).most_common(n)print(top_words("The cat and the hat. The cat sat!"))# [('the', 3), ('cat', 2), ('and', 1)]
2Check whether a string is a palindrome, ignoring case, spaces and punctuation.Beginner
1
2
3
4
5
6
defis_palindrome(s):cleaned=[c.lower()forcinsifc.isalnum()]returncleaned==cleaned[::-1]print(is_palindrome("A man, a plan, a canal: Panama"))# True
print(is_palindrome("cloudutsuk"))# False
Follow-up: do it in O(1) extra space with two pointers moving inwards and skipping non-alphanumerics
3Remove duplicates from a list while keeping the original order.Beginner
set() loses order. dict.fromkeys() keeps insertion order (guaranteed since 3.7) and is O(n)
For unhashable items (like dicts), track a key you can hash
1What is a decorator? Write one that logs how long a function takes.Intermediate
A decorator is a callable that takes a function and returns a replacement, applied with @decorator syntax, which is shorthand for func = decorator(func)
Use functools.wraps so the wrapper keeps the original’s name, docstring and signature for debugging and introspection
importfunctoolsimporttimedeftimed(func):@functools.wraps(func)defwrapper(*args,**kwargs):start=time.perf_counter()try:returnfunc(*args,**kwargs)finally:print(f"{func.__name__} took {time.perf_counter()-start:.3f}s")returnwrapper@timeddefslow_add(a,b):time.sleep(0.2)returna+bslow_add(1,2)# slow_add took 0.200s
2What are generators, and why use them instead of returning a list?Intermediate
A function with yield returns a generator: an iterator that produces values lazily, pausing between them
Memory stays constant regardless of the number of items, and the consumer can stop early
Generator expressions ((x * 2 for x in data)) are the lazy version of list comprehensions
yield from delegates to a sub-generator. A generator can be consumed only once
3How do context managers work, and how do you write one?Intermediate
with calls __enter__ on entry and __exit__ on exit, even when an exception is raised, which makes it the standard way to release resources (files, locks, connections, transactions)
__exit__ receives the exception details and can suppress the exception by returning True (rarely a good idea)
contextlib.contextmanager turns a generator into a context manager
5What’s the difference between __str__ and __repr__, and when would you use a dataclass?Intermediate
__repr__ is for developers: unambiguous, ideally valid Python to recreate the object. Used in the REPL, logs and containers
__str__ is for end users (print, f-strings). It falls back to __repr__ if it isn’t defined
@dataclass generates __init__, __repr__ and __eq__ (and optionally ordering, hashing, frozen=True immutability and slots=True) for classes that mainly hold data
3A job calls 500 REST endpoints one after another and takes 10 minutes. How would you speed it up?Scenario · Intermediate
The work is I/O-bound: the process spends almost all its time waiting on the network, so concurrency helps even with the GIL (blocking I/O releases it)
Simplest: concurrent.futures.ThreadPoolExecutor with a bounded number of workers around the existing requests code
Higher scale: asyncio with an async client (httpx, aiohttp) and a Semaphore to cap concurrency
Reuse connections (a Session or client), respect rate limits, add timeouts and retries with backoff
Don’t use multiprocessing here. It adds overhead without helping I/O-bound work
Declare direct dependencies in pyproject.toml, and lock the full resolved tree with hashes: uv lock, pip-tools (pip-compile), Poetry or PDM
Pin the Python version too (requires-python, .python-version, the CI image tag)
Install from the lock file in CI and in Docker images, and update dependencies deliberately (with Dependabot or Renovate), not by accident
5A background worker occasionally “loses” jobs, with no errors in the logs. Code review finds except Exception: pass. What’s wrong, and how do you fix it?Scenario · Intermediate
The handler swallows every error silently, so failures look like success. Bugs, timeouts and bad data all disappear
Catch the specific exceptions you can handle, log everything else with the traceback (logger.exception(...)), and let unexpected errors propagate or send the job to a dead-letter queue
Add metrics for failures and retries so silent drops become visible
Intermediate: Coding
1Implement an LRU cache with get and put in O(1).Intermediate
OrderedDict keeps insertion order and supports move_to_end and popitem(last=False) in O(1)
In real code, functools.lru_cache(maxsize=...) covers memoising functions, but interviewers usually want the data structure
Complexity: O(n · k log k) for n words of length k. A 26-letter count tuple as the key makes it O(n · k)
Advanced
Advanced: Concepts
1What is the GIL, what does it affect, and what’s changing with free-threaded Python?Advanced
The Global Interpreter Lock in CPython lets only one thread execute Python bytecode at a time, which keeps reference counting and interpreter internals simple and fast
It limits CPU-bound threads (no parallel speed-up) but not I/O-bound ones, because blocking I/O and many C extensions (NumPy, hashing, compression) release it
Workarounds: multiprocessing / ProcessPoolExecutor, native extensions that release the GIL, or vectorised libraries
PEP 703 introduced an optional free-threaded build (python3.13t) in 3.13 as experimental. In 3.14 it became officially supported (PEP 779), but it’s still a separate build, not the default, and extension modules must support it
The GIL never made your code thread-safe: compound operations like counter += 1 can still interleave. Use locks
2How does Python manage memory?Advanced
CPython uses reference counting: an object is freed as soon as its count reaches zero
A cyclic garbage collector (the gc module) finds reference cycles that counting can’t free, using generational collection
Small objects come from the pymalloc allocator, so freed memory often isn’t returned to the OS straight away. Process RSS can stay high after a spike
Tools: sys.getsizeof, tracemalloc, gc.get_objects(), weakref (references that don’t keep objects alive, useful for caches and observers), and __slots__ to remove the per-instance __dict__ for millions of small objects
3How does asyncio work under the hood?Advanced
An event loop runs on one thread and schedules coroutines. await is a suspension point where a coroutine yields control while waiting for I/O
asyncio.create_task wraps a coroutine in a Task so it runs concurrently. asyncio.TaskGroup (3.11+) gives structured concurrency: if one task fails, the others are cancelled and errors come back as an ExceptionGroup
It’s cooperative: any blocking call (time.sleep, requests.get, heavy CPU work) freezes every task on the loop
Offload blocking work with asyncio.to_thread() or loop.run_in_executor()
4When would you use a metaclass, a class decorator or __init_subclass__?Advanced
A metaclass controls class creation itself (type is the default). It’s powerful, but hard to combine and hard to read. ORMs and frameworks use metaclasses
__init_subclass__ (3.6+) runs whenever a class is subclassed. It covers most “register every subclass” or “validate subclasses” needs without a metaclass
A class decorator modifies a class after creation (@dataclass is one)
Rule of thumb: decorator first, then __init_subclass__, and a metaclass only when you truly need to change how the class object is built
5What are descriptors, and how do property and methods use them?Advanced
A descriptor is an object with __get__, __set__ or __delete__, stored as a class attribute. It intercepts attribute access on instances
property, staticmethod, classmethod and plain functions (which become bound methods) are all descriptors
Data descriptors (with __set__) take precedence over the instance __dict__; non-data descriptors don’t
There’s a worked example in the advanced coding section below
6What is the MRO, and how does super() work with multiple inheritance?Advanced
The method resolution order is the order Python searches classes for an attribute, computed with C3 linearisation. Inspect it with Cls.__mro__
super() means “the next class in the MRO of the instance’s class”, not “my parent”. That’s what makes cooperative multiple inheritance work, as long as every class calls super() and accepts **kwargs
7How do type hints work at runtime, and when would you use a Protocol?Advanced
Hints aren’t enforced at runtime. Static checkers (mypy, Pyright) and tools such as Pydantic use them
typing.Protocol defines structural types: any class with the right methods matches, no inheritance needed. That’s duck typing a type checker can verify
Generics (list[int], PEP 695 def first[T](xs: list[T]) -> T in 3.12+), TypedDict for dict shapes, and Literal / Final for constants
Python 3.14 evaluates annotations lazily by default (PEP 649/749), which removes most of the need for from __future__ import annotations and string forward references
Advanced: Scenarios
1A long-running API service’s memory grows steadily until the container is OOM-killed. How do you find the leak?Scenario · Advanced
Confirm it’s growth, not just a high plateau. Graph RSS over time against request count
Usual suspects: unbounded caches or dicts keyed by request data, lru_cache on methods (it holds a reference to self), global lists that only grow, objects held by closures or callbacks, cycles involving objects with __del__, and C-extension leaks
Compare tracemalloc snapshots taken some minutes apart to see which lines allocate the growing memory. Use objgraph or gc.get_referrers to see what keeps objects alive
Short-term mitigation: worker recycling (Gunicorn --max-requests with jitter) while you fix the root cause
1
2
3
4
5
6
7
importtracemalloctracemalloc.start(25)before=tracemalloc.take_snapshot()# ... let the service handle traffic ...
after=tracemalloc.take_snapshot()forstatinafter.compare_to(before,"lineno")[:10]:print(stat)
2A CPU-heavy image-processing job uses 16 threads on a 16-core machine, but it’s no faster than one thread. Why, and what would you do?Scenario · Advanced
On standard CPython, the GIL lets only one thread run Python bytecode at a time, so CPU-bound threads take turns
Use ProcessPoolExecutor or multiprocessing to spread work across cores (watch pickling overhead and memory per process)
Push the heavy loops into libraries that release the GIL (Pillow, NumPy, OpenCV), or into native code (Cython, Rust via PyO3)
The free-threaded build (3.14t) can run threads in parallel if all your dependencies support it. Benchmark it, because single-thread overhead exists
3An asyncio web service freezes for a few seconds every so often, and all requests stall together. What do you check?Scenario · Advanced
Something is blocking the event loop: a synchronous HTTP or database client, time.sleep, file I/O, or a CPU-heavy function in a coroutine
Turn on asyncio debug mode (PYTHONASYNCIODEBUG=1, or asyncio.run(main(), debug=True)), which logs callbacks slower than 100 ms
Profile with py-spy dump during a stall to see what the loop thread is doing
Fix: switch to async clients, wrap unavoidable blocking calls in asyncio.to_thread(), move CPU work to a process pool, and add timeouts everywhere
4A CLI tool takes three seconds to start, even for --help. How do you speed it up?Scenario · Advanced
Measure first: python -X importtime -c "import mytool" 2> import.log shows the cumulative time each import takes
Move heavy imports (pandas, cloud SDKs, ML libraries) inside the functions that need them, so --help never loads them
Python 3.15 adds explicit lazy imports (PEP 810): lazy import pandas defers loading until the name is first used, and there’s a global switch (-X lazy_imports) for experiments
Also check for expensive work at import time (network calls, config loading) and for slow entry-point discovery
5A colleague wants to cache user session objects by pickling them into Redis. What concerns do you raise?Scenario · Advanced
Security: unpickling untrusted data can execute arbitrary code. If anyone can write to that Redis key, it’s a remote-code-execution path
Compatibility: pickles tie the data to class definitions and Python versions, so deployments that rename classes break old sessions
Prefer JSON (or MessagePack) with explicit schemas (Pydantic, dataclasses), and sign or encrypt data if it crosses trust boundaries
Advanced: Coding
1Implement a thread-safe token-bucket rate limiter.Advanced
Tokens refill continuously at rate per second up to capacity. Each request spends a token or is rejected
Use time.monotonic() (not time.time(), which can jump) and a lock around the read-modify-write
importthreadingimporttimeclassTokenBucket:def__init__(self,rate,capacity):self.rate=rateself.capacity=capacityself.tokens=capacityself.updated=time.monotonic()self.lock=threading.Lock()defallow(self,cost=1):withself.lock:now=time.monotonic()self.tokens=min(self.capacity,self.tokens+(now-self.updated)*self.rate)self.updated=nowifself.tokens>=cost:self.tokens-=costreturnTruereturnFalsebucket=TokenBucket(rate=5,capacity=10)print(sum(bucket.allow()for_inrange(20)))# about 10: the burst capacity
Follow-ups: an async version with asyncio.Lock, a distributed version with Redis and a Lua script, and returning the wait time instead of False
2Fetch 50 URLs concurrently with asyncio, limited to 10 requests in flight, and collect the results in order.Advanced
asyncio.Semaphore caps concurrency. TaskGroup gives structured concurrency and cancels the rest if one task fails
The fake fetch keeps the example runnable; swap in httpx.AsyncClient for real calls
classPositive:def__set_name__(self,owner,name):self.name=nameself.private="_"+namedef__get__(self,obj,objtype=None):ifobjisNone:returnselfreturngetattr(obj,self.private)def__set__(self,obj,value):ifnotisinstance(value,(int,float))orvalue<=0:raiseValueError(f"{self.name} must be a positive number, got {value!r}")setattr(obj,self.private,value)classOrder:quantity=Positive()price=Positive()def__init__(self,quantity,price):self.quantity=quantityself.price=priceorder=Order(3,9.99)order.quantity=0# ValueError: quantity must be a positive number, got 0
__set_name__ (3.6+) tells the descriptor its attribute name, so it doesn’t have to be passed in twice
Follow-up: how does property compare? It’s a descriptor too, but you’d write a getter and setter per attribute instead of reusing one class
Tips for the interview
A few things I’d keep in mind on the candidate’s side of the table:
Talk through trade-offs, not just answers. “I’d use a thread pool here because it’s I/O-bound, but for CPU work I’d switch to processes” shows understanding that a correct snippet alone doesn’t.
Know the standard library.collections, itertools, functools, pathlib, dataclasses, contextlib and concurrent.futures solve half of the coding questions in a few lines.
Write readable code first. Clear names, small functions and a test case or two beat a clever one-liner.
Say what you’d do in production. Timeouts, logging, tests, typing and dependency pinning come up in senior interviews even when the question is about something else.
Be honest about versions. If you’re not sure whether a feature arrived in 3.11 or 3.12, say so. Nobody expects you to remember every changelog.
Conclusion
Most Python interviews come back to the same few ideas: how names and objects work (mutability, references, scope), how iteration and laziness work (iterators, generators), how resources are managed (context managers, memory), and how concurrency actually behaves (the GIL, threads vs processes vs asyncio). Get those solid, and the trick questions stop being tricky. A good next step: take three questions from the level above yours, write the code without looking at the answers, and run it. Getting the output wrong teaches more than reading the answer.