Python interviews focus on the language features that separate working knowledge from fluency. Expect questions on when to use each built-in data structure, mutable default arguments, generators and laziness, decorators and functools.wraps, the Global Interpreter Lock and how it shapes your concurrency choice, and how reference counting and the cycle collector manage memory. Employers also probe testing and code quality practice. The questions below cover the language and the judgement around using it well.
Behavioural Questions
1. Tell me about a Python project you built. What problem did it solve and what would you do differently?
Note: Pick the project with the most interesting constraint, not the one with the most lines. Interviewers are listening for judgement, not scope.
Give it in four beats:
- The problem in business terms. "A nightly reconciliation that took four hours and blocked the finance team" lands far better than "an ETL pipeline".
- Your actual contribution. Be precise about what you wrote versus what existed. Inflating this is trivially exposed by one follow-up question.
- The hard part. Strong candidates: memory blowing up on a large dataset, a third-party API that rate-limited you, making a long job restartable after a failure, or a dependency conflict that forced a packaging decision.
- What you would change. Say something real — that you would have written the tests first, chosen a queue instead of threads, or used generators rather than loading everything into a list.
2. Describe a time you had to debug something difficult in Python. How did you approach it?
Describe a method, because the interviewer is checking whether you debug systematically or by guessing.
- Reproduce it reliably first. Most of the effort in a hard bug goes here — a failure that only happens with production data volume, or only under concurrency, or only on the third run.
- Narrow it down. Bisect the code path, or use
git bisectif it used to work. Add assertions where you believe the state is still correct and move them later until one fails. - The tools you reached for.
pdborbreakpoint()for stepping,loggingat DEBUG rather than scattered prints,tracebackfor the full chain,cProfileif it was slow, andtracemallocif memory was the issue. - The root cause. Good stories: a mutable default argument shared across calls, a shallow copy where a deep copy was needed, an exception silently swallowed by a bare
except, or a floating-point comparison.
Note: Finish with the regression test you added. A bug fixed without a test is a bug scheduled to return.
3. How do you decide between writing your own code and using a Python library?
Frame it as a cost decision. Python's ecosystem makes it easy to add dependencies and easy to end up with forty of them you cannot upgrade.
Use a library when the problem is well-specified and dangerous to get subtly wrong: dates and timezones, cryptography, HTTP with retries and connection pooling, numerical work, or parsing anything with a real grammar. Reimplementing requests or cryptography is not a good use of your week.
Write it yourself when you need a small fraction of what the library offers, or when it drags in a large dependency tree for thirty lines of behaviour.
What to check first:
- Is it maintained — last release date, open issue count, and whether it supports your Python version?
- How many transitive dependencies does it pull in?
- What is the licence, and does it matter for your product?
- How hard would it be to remove in a year?
Note: The standard library is underrated. Candidates regularly reach for a package for something itertools, collections, or pathlib already does.
4. How do you approach code quality and testing in Python projects?
Give a layered answer, and avoid quoting a coverage percentage as if it were the goal.
Automated tooling first, because conventions that are not enforced decay:
- Formatting — black or ruff format, so style is never a review topic.
- Linting — ruff or flake8 for the errors a human reader misses.
- Type checking — mypy or pyright. Type hints on function boundaries catch a genuine class of bug and double as documentation.
- All of it in CI, so it cannot be skipped.
Testing, in order of value per line:
- Unit tests on business logic — fast, no I/O, and where most real bugs live.
- Integration tests on the seams — the database layer, the external API client.
- A few end-to-end tests on the flows that must never break.
Note: Mention pytest fixtures for setup, parametrize for table-driven cases, and mocking only at the boundary. Over-mocking produces tests that pass while the code is broken, which is worse than having no tests.
5. How do you keep up with Python and decide whether to adopt a new version or tool?
Answer with a filter rather than a reading list.
How you learn: the release notes for each version — Python's are unusually readable — plus PEPs for anything that changes semantics, and building something small with a new feature rather than only reading about it.
How you filter a new version:
- Does it fix a problem you actually have? Structural pattern matching, better error messages, and per-version speed improvements are all real, but none of them justify an upgrade on their own.
- Do your dependencies support it? This is usually the binding constraint, especially for anything with C extensions.
- Can you run the test suite against it in CI before committing?
A sensible policy to state: stay one minor version behind the latest in production, upgrade on a schedule rather than on impulse, and never let the runtime reach end-of-life, because that turns into a security problem rather than a convenience one.
Note: Naming a tool you evaluated and rejected is more convincing than listing ten you like.
Technical Questions
1. What is the difference between a list, a tuple, a set and a dictionary in Python?
Four built-in collections with different guarantees.
- list — ordered, mutable, allows duplicates. Indexing is O(1); membership testing with
inis O(n) because it scans. - tuple — ordered, immutable, allows duplicates. Because it is immutable it is hashable, so a tuple can be a dictionary key or a set member. Slightly smaller and faster than a list.
- set — unordered, mutable, no duplicates. Backed by a hash table, so membership testing is O(1). This is the reason to use one.
- dict — key-value pairs, mutable, keys unique and hashable. O(1) lookup. Insertion order has been guaranteed since Python 3.7.
How to choose: a list for an ordered sequence you will modify; a tuple for a fixed record or a return value of several items; a set when you need uniqueness or fast membership tests; a dict when you need to look something up by key.
Note: The performance point is the one that matters in practice. Replacing `if x in my_list` with a set inside a loop turns an O(n²) algorithm into O(n), and that single change fixes a surprising number of slow scripts.
2. What is the difference between shallow copy and deep copy, and what is the mutable default argument trap?
Python assigns by reference, so b = a gives two names for one object.
- Shallow copy —
copy.copy(x),list(x), orx[:]. A new outer container holding the same inner objects. Mutating a nested list still affects both. - Deep copy —
copy.deepcopy(x). Recursively copies everything, and handles circular references.
The mutable default argument trap is the classic Python gotcha:
def add(item, bucket=[]): # WRONG
bucket.append(item)
return bucket
add(1) # [1]
add(2) # [1, 2] <- the same list!The default is evaluated once, when the function is defined, not on each call. So every call without an explicit argument shares one list.
The fix:
def add(item, bucket=None):
if bucket is None:
bucket = []
bucket.append(item)
return bucketNote: The same applies to dictionaries, sets, and any object constructed in the signature — including datetime.now(), which freezes at import time.
3. What are decorators in Python and how would you write one?
A decorator is a function that takes a function and returns a replacement, letting you add behaviour without editing the original. The @ syntax is sugar: @log above def f means f = log(f).
import functools, time
def timed(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
start = time.perf_counter()
try:
return func(*args, **kwargs)
finally:
print(f"{func.__name__} took {time.perf_counter()-start:.3f}s")
return wrapper
@timed
def slow(): ...The details that matter:
functools.wrapsis not optional. Without it the wrapper replaces the original's__name__,__doc__, and signature, which breaks introspection, documentation tools, and some frameworks.*args, **kwargsso the decorator works on any signature.- A decorator that takes arguments needs one more layer — a function returning a decorator returning a wrapper.
Where they are genuinely used: route registration in Flask and FastAPI, @property, @staticmethod, @functools.lru_cache for memoisation, retries, authentication checks, and logging.
4. What are generators and iterators, and when would you use yield instead of returning a list?
An iterator is any object with __iter__ and __next__, producing values one at a time until it raises StopIteration. A generator is the easy way to write one: a function containing yield. Calling it does not run the body — it returns a generator object, and each next() runs until the next yield, then suspends with its local state intact.
def read_large_file(path):
with open(path) as f:
for line in f:
yield line.strip()Why use one instead of building a list:
- Memory. A list of ten million rows needs all ten million in RAM. A generator holds one at a time — this is the whole argument.
- Laziness. Work is done only as consumed, so an early
breakcosts nothing. - Composition. Generators chain into pipelines where each stage streams into the next.
- Infinite sequences become expressible at all.
The trade-offs: you can only iterate once, and you cannot index or take len(). If you need random access or multiple passes, build the list.
Note: A generator expression is the same thing in one line — sum(x*x for x in data) never materialises the intermediate sequence, unlike the list-comprehension version.
5. What is the Global Interpreter Lock, and how do you achieve concurrency in Python?
The GIL is a mutex in CPython allowing only one thread to execute Python bytecode at a time. It exists because CPython's memory management uses reference counting, which is not thread-safe. The consequence is that threads do not give you parallel CPU execution.
Critically, the GIL is released during I/O. That is what makes the choice straightforward:
- I/O-bound work — network calls, disk, database queries. Use
threadingorasyncio. While one thread waits on a socket, others run, so you get real concurrency.asyncioscales further because it does not need a thread per task. - CPU-bound work — number crunching, image processing, parsing. Use
multiprocessing, which runs separate interpreter processes each with their own GIL. The cost is that data must be pickled between processes. - Numerical work — NumPy, Pandas, and similar libraries release the GIL inside their C routines, so they already parallelise without you doing anything.
Note: Mention that PEP 703 introduces an optional free-threaded build in Python 3.13, making the GIL removable. It is experimental, but knowing it exists shows you follow the language rather than repeating a decade-old summary.
6. Explain *args and **kwargs, and how Python passes arguments to functions.
*args collects extra positional arguments into a tuple; **kwargs collects extra keyword arguments into a dict. The names are convention — the * and ** are what matter.
def f(a, b=2, *args, key=None, **kwargs):
...They work in the other direction too — f(*my_list, **my_dict) unpacks a sequence and a mapping into the call.
How Python passes arguments is the deeper half of this question. It is neither pass-by-value nor pass-by-reference but pass-by-object-reference: the function receives a reference to the same object.
- If the object is mutable and you mutate it, the caller sees the change:
lst.append(1)affects the original. - If you rebind the parameter, the caller sees nothing:
lst = [9]only changes the local name. - Immutable objects — int, str, tuple — cannot be mutated at all, so they always behave as if copied.
Note: A bare * in a signature forces everything after it to be keyword-only, as in def f(a, *, verbose=False). It is a good habit for boolean flags, since f(x, True) at the call site tells the reader nothing.
7. How does exception handling work in Python, and what is wrong with a bare except?
The full structure has four clauses:
try:
result = risky()
except ValueError as e:
log.warning("bad input: %s", e)
raise
except (IOError, OSError):
...
else:
# runs only if no exception was raised
commit(result)
finally:
# always runs, exception or not
cleanup()Why except: with no exception class is a bug:
- It catches
KeyboardInterruptandSystemExit, so Ctrl-C stops working and the process becomes hard to shut down. - It catches genuine programming errors — a typo producing a
NameError, aTypeErrorfrom a bad refactor — and hides them. The code appears to work while doing nothing. - It gives no information about what you expected to fail.
If you truly need to catch everything, use except Exception:, which excludes the system-exiting ones, log the traceback, and re-raise unless you have a specific reason not to.
Note: Catch the narrowest exception that can actually occur, and keep the try block as short as possible so it cannot accidentally swallow a failure from an unrelated line. raise ... from e preserves the original cause when wrapping.
8. What is the difference between a list comprehension, a generator expression, and map or filter?
All four build a new sequence from an existing one; they differ in what they return and when the work happens.
- List comprehension —
[x*2 for x in data if x > 0]. Builds the whole list immediately. Readable, and the right default for anything of modest size. - Generator expression — the same with parentheses,
(x*2 for x in data). Returns a generator; nothing is computed until consumed and nothing is stored. Use it for large or infinite inputs, and whenever the result is fed straight intosum,any,max, or a loop. mapandfilter— return lazy iterators in Python 3.map(str.upper, names)is clean when you are applying an existing named function, butmap(lambda x: x*2, data)is both slower and harder to read than the comprehension.
The idiomatic guidance: comprehensions are preferred in Python; reach for map only when passing a function that already exists. Dict and set comprehensions follow the same pattern with braces.
Note: Nested comprehensions past two levels become unreadable. A plain loop is better code than a comprehension nobody can parse.
9. What is the difference between __str__ and __repr__, and what are dunder methods for?
Dunder (double underscore) methods let your classes participate in Python's built-in syntax. Defining __len__ makes len(obj) work; __eq__ makes == work; __iter__ makes the object usable in a for loop; __enter__ and __exit__ make it a context manager.
__repr__ is for developers. It should be unambiguous and, ideally, valid Python that would recreate the object. It is what you see in the REPL, in a debugger, and inside a printed list.
__str__ is for users. It should be readable. It is what print() and str() use.
class Point:
def __init__(self, x, y):
self.x, self.y = x, y
def __repr__(self):
return f"Point(x={self.x}, y={self.y})"
def __str__(self):
return f"({self.x}, {self.y})"The practical rule: always define __repr__; define __str__ only when a friendlier form is genuinely useful. If __str__ is missing, Python falls back to __repr__ — but not the other way round, which is why a class with only __str__ still shows as an unhelpful memory address inside a list.
Note: @dataclass generates a sensible __repr__, __eq__, and __init__ for you, and is usually the right answer for a plain data-holding class.
10. How does memory management and garbage collection work in Python?
CPython uses two mechanisms together.
Reference counting is the primary one. Every object tracks how many references point at it; when the count hits zero the memory is freed immediately. This is why an object's __del__ often runs the instant a variable goes out of scope, and why memory is reclaimed promptly.
A generational cycle collector handles what reference counting cannot: reference cycles. If a.child = b and b.parent = a, both counts stay above zero even when nothing else refers to them. The collector periodically walks objects, finds unreachable cycles, and frees them. It uses three generations on the observation that most objects die young, so generation zero is scanned frequently and older generations rarely.
What this means in practice:
- Memory leaks in Python are usually not leaks in the C sense — they are references you forgot you were holding. A module-level cache that only grows, a list that accumulates, or a closure capturing a large object.
- Use
weakrefwhen you need to reference an object without keeping it alive — the standard fix for parent-child cycles and caches. - Diagnose with
tracemallocto compare allocation snapshots, orgc.get_objects()andobjgraphto find what is holding a reference.
Note: Freed memory is not always returned to the operating system — CPython keeps arenas for reuse, so RSS can stay high after a spike.





