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.
6. Tell me about a time you automated a manual process with Python. How did you measure the impact?
Interviewers use this question to see whether you can spot wasted effort, build something reliable, and prove that it mattered. Use the STAR structure (Situation, Task, Action, Result) and keep the technology in service of the outcome.
- Situation — describe the manual process concretely: who did it, how often, and how long it took. For example, an operations team spending four hours every Monday merging Excel exports from three systems into one MIS report.
- Task — what you were asked to do or chose to take on, and the constraints: no new servers, data sitting in a shared drive, a non-technical owner who had to run it.
- Action — the design choices that show maturity:
pandasfor the merge, validation that stops the run when a column is missing,logginginstead of print statements, a scheduled job, and a short README so someone else could run it. Mention how you handled edge cases such as duplicate rows or a changed file format. - Result — quantify it. ‘Four hours became six minutes, and the Monday reconciliation errors stopped’ is far stronger than ‘it made things faster’.
Points that impress: you spoke to the people doing the work before writing code, you ran the old and new process side by side until the outputs matched, and you kept a manual fallback for the first few weeks. That shows judgement, not just scripting skill.
Note: Do not claim the automation was perfect from day one. Mentioning one bug you found in the first week, and how you fixed and tested it, makes the story far more credible.
7. Describe a time you reviewed a colleague's Python code and found serious problems. How did you give the feedback?
This question tests technical judgement and emotional intelligence together. The interviewer wants to know that you can raise real problems without damaging the working relationship.
- Set the scene briefly — for example, a pull request adding a payment reconciliation script written by a newer teammate under deadline pressure.
- Name the actual issues — be specific: SQL built with f-strings (an injection risk), a bare
except:that silently swallowed failures, no tests for the rounding logic, and a mutable default argument. Specifics show you know what matters. - Explain how you prioritised — separate blocking issues (security, correctness, data loss) from style nits. Leave the nits to the linter, such as
rufforblack, rather than to human comments. - Describe the delivery — comments phrased as questions or suggestions (‘What happens here if the API times out?’), each blocking comment paired with the reason and a proposed fix, and a quick call or pairing session instead of forty written comments.
- Share the result — the fixes went in, the colleague added parameterised queries and tests, and perhaps you added a checklist or pre-commit hook so the team catches the same issues automatically next time.
What to avoid: stories where you rewrote their code yourself, or where you were right and they were simply wrong. Show that you assumed good intent and that the goal was better code, not winning the argument.
Note: If you can, mention something you learned from the other person during the review. Good reviewers treat it as a two-way conversation.
8. Describe a time you upgraded the Python version or major dependencies on a production project. How did you plan it?
Upgrades are routine but risky, so this question checks whether you plan, test and roll out carefully rather than bumping versions and hoping.
- Context and motivation — for example, moving a service from Python 3.8, which had reached end of life, to 3.12 for security support and a noticeable speed gain, along with Django or pandas major versions.
- Inventory — list direct and transitive dependencies, check which ones support the target version, and read changelogs for breaking changes. Removed modules such as
distutilsor deprecated pandas behaviour are typical traps. - Make problems visible early — run the test suite with warnings turned into errors (
python -W error::DeprecationWarning), and add the new version to the CI matrix so both versions are tested in parallel. - Upgrade in small steps — one major library at a time, each in its own pull request with a regenerated lock file, instead of one giant change that is impossible to debug.
- Roll out safely — deploy to staging, compare logs and performance, then release to a small share of traffic or one server first, with a documented rollback (the previous image or lock file).
- Result — quantify it: zero downtime, 20 percent lower latency, three latent bugs found by the stricter warnings.
Points that impress: you raised test coverage around fragile areas before upgrading, you communicated the plan to dependent teams, and you set up a tool such as Dependabot or Renovate so future upgrades are small and frequent.
Note: Mention what you would do differently. A common honest answer is that you would have upgraded more often, because a five-year jump is much harder than five one-year jumps.
9. Tell me about a time a Python job failed in production because of unexpected data. What did you change afterwards?
Real-world data is messy, so every experienced Python developer has a story like this. The interviewer is looking for calm incident handling, a clear root cause, and lasting prevention rather than a one-off patch.
- The failure — describe it concretely. For example, a nightly ETL job crashed because a vendor started sending files with a byte-order mark, an empty amount column, or dates in a new format, so the morning sales dashboard was blank.
- Immediate response — you told the affected users first, applied a safe fix, re-ran the job and confirmed the numbers matched the source. Keep this short; it shows ownership.
- Root cause — why the code was fragile: it assumed a fixed schema, trusted input types, and a broad
try/excepthid an earlier warning sign. - Prevention — this is the most important part:
- Validate input at the boundary with a schema, using Pydantic or pandera, and fail loudly with a clear message.
- Decide deliberately between rejecting the whole file and quarantining bad rows, and write that decision down.
- Add the bad file as a test fixture so the bug can never return silently.
- Add alerting on failures and on unusual row counts, not just on crashes.
- Result — the next format change was caught by validation before it reached the dashboard, and the vendor was contacted the same day.
Tone matters: keep it blameless. Say the process allowed the problem, not that a colleague or the vendor was careless.
Note: A strong closing line is what you now do by default on every new pipeline, such as validating the schema before any transformation runs.
10. Tell me about a time you had to deliver a Python feature under a tight deadline. What did you compromise on and what did you protect?
The interviewer wants evidence that you can prioritise under pressure without shipping something dangerous. The best answers make the trade-offs explicit.
- Situation — for example, a client demo or a regulatory date moved forward by two weeks, and a reporting API had to be ready in half the planned time.
- What you negotiated away — the things that can safely wait:
- Generality, such as supporting one export format instead of four.
- Performance tuning beyond what current data volumes needed.
- Nice-to-have refactoring and UI polish.
- What you refused to compromise — the things that are expensive or impossible to fix later:
- Correctness of calculations, backed by tests on the critical path.
- Security basics, such as input validation, parameterised queries and no secrets in code.
- Data integrity and a working rollback.
- How you communicated — you told the manager or product owner what would and would not be included, in writing, before the deadline rather than on the day.
- Follow-up — you logged the shortcuts as tickets with owners and closed them in the following sprint, so the technical debt was deliberate and visible rather than forgotten.
Result: the feature shipped on time, there were no production incidents, and the deferred items were delivered within a month.
Note: Interviewers are wary of candidates who say they never compromise, and equally of those who cut tests first. Showing a clear line between deferrable work and non-negotiable quality is what makes this answer strong.
Technical Questions
11. 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.
12. 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.
13. 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.
14. 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.
15. 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.
16. 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.
17. 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.
18. 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.
19. 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.
20. 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.
21. How does the Python data model let a custom class behave like a built-in container or sequence?
Python’s built-in operations are implemented by calling special (dunder) methods on the object. If your class implements the right methods, the language treats it like a native type. This is often called implementing a protocol.
__len__— makeslen(obj)work, and also gives the object truthiness if__bool__is not defined.__getitem__— makesobj[i]and slicing work. If__iter__is missing, Python falls back to calling__getitem__with 0, 1, 2 and so on, so iteration andinwork as well.__contains__— a faster, explicit implementation ofx in obj.__iter__— returns an iterator; used byforloops, unpacking andlist(obj).__setitem__and__delitem__— make the container mutable.
class Playlist:
def __init__(self, songs):
self._songs = list(songs)
def __len__(self):
return len(self._songs)
def __getitem__(self, index):
return self._songs[index] # int or slice
p = Playlist(['Kesariya', 'Tum Hi Ho', 'Ilahi'])
len(p) # 3
p[-1], p[0:2] # indexing and slicing
'Ilahi' in p # True, via the __getitem__ fallback
sorted(p) # iteration works tooGoing further: inheriting from collections.abc.Sequence or MutableMapping tells you which methods are required and gives you the rest for free, such as index, count and __reversed__. This is better than subclassing list or dict directly, whose C implementations often bypass your overridden methods.
Note: This is what ‘Pythonic’ really means in an interview: making your objects work with len, in, for and slicing instead of inventing methods like get_size() or has_item().
22. How do __eq__ and __hash__ work together, and what happens if you override only one of them?
Sets and dictionaries find items by first computing hash(key) to choose a bucket, then using == to confirm the match. That gives one rule every class must respect: objects that compare equal must have the same hash. The reverse is not required, because different objects may share a hash.
- Default behaviour — a plain class compares by identity, and its hash is derived from
id(). Consistent, but two objects with the same data are not equal. - Override
__eq__only — Python sets__hash__toNone, so instances become unhashable. Putting one in a set raisesTypeError: unhashable type. This is deliberate protection against breaking the rule above. - Override
__hash__only — legal but pointless, since equality is still identity-based. - Hash on mutable fields — dangerous. If a field changes after the object is added to a set, it sits in the wrong bucket and can no longer be found or removed.
class Point:
def __init__(self, x, y):
self.x, self.y = x, y
def __eq__(self, other):
if not isinstance(other, Point):
return NotImplemented
return (self.x, self.y) == (other.x, other.y)
def __hash__(self):
return hash((self.x, self.y))Return NotImplemented rather than False for unknown types, so Python can try the other operand’s __eq__.
The easy route: @dataclass(frozen=True) generates a consistent __eq__ and __hash__ and prevents mutation, which is exactly what a value object used as a dictionary key needs.
Note: This explains why lists are unhashable and tuples are hashable: hashability really means ‘safe to use as a key because it will not change’.
23. What is a context manager, and how do you write one using a class and using contextlib?
A context manager guarantees that setup and cleanup code runs around a block, even if the block raises an exception. The with statement is the syntax that uses it. Files, locks, database transactions and temporary settings are all classic examples.
Class-based — implement two methods:
import time
class Timer:
def __enter__(self):
self.start = time.perf_counter()
return self # bound to the name after 'as'
def __exit__(self, exc_type, exc, tb):
self.elapsed = time.perf_counter() - self.start
return False # do not suppress exceptions
with Timer() as t:
run_report()
print(t.elapsed)__exit__ receives the exception type, value and traceback, or three None values if the block succeeded. Returning True swallows the exception, which you should do only on purpose.
Generator-based — shorter for simple cases:
from contextlib import contextmanager
@contextmanager
def temp_cwd(path):
old = os.getcwd()
os.chdir(path)
try:
yield path # the body of the with block runs here
finally:
os.chdir(old)The try/finally is essential. Without it, an exception in the block skips the cleanup.
Useful contextlib helpers:
suppress(FileNotFoundError)— ignore a specific exception cleanly.ExitStack— manage a variable number of context managers, such as opening N files.closing(obj)— callclose()on objects that are not context managers.asynccontextmanager— the same idea forasync with.
Note: A good rule is that any resource with an explicit release step deserves a context manager. It turns ‘remember to clean up’ into something the language guarantees.
24. What is a closure in Python, what does nonlocal do, and why do lambdas created in a loop all return the same value?
A closure is an inner function that remembers variables from the enclosing function’s scope, even after the outer function has returned. Python stores those variables in cells attached to the function’s __closure__ attribute.
def make_multiplier(n):
def multiply(x):
return x * n # n is captured from the enclosing scope
return multiply
triple = make_multiplier(3)
triple(10) # 30Name lookup follows LEGB: Local, Enclosing, Global, Built-in. Reading an enclosing variable works automatically, but assigning to a name inside a function makes it local. That is what nonlocal fixes:
def counter():
count = 0
def increment():
nonlocal count # rebind the enclosing variable
count += 1
return count
return incrementWithout nonlocal, count += 1 raises UnboundLocalError. The keyword global does the same for module-level names, but it is usually a design smell.
The late-binding trap: closures capture the variable, not its value at creation time.
funcs = [lambda: i for i in range(3)]
[f() for f in funcs] # [2, 2, 2], not [0, 1, 2]
# Fix: bind the current value as a default argument
funcs = [lambda i=i: i for i in range(3)]
[f() for f in funcs] # [0, 1, 2]All three lambdas look up i when they are called, and by then the loop has finished with i equal to 2. functools.partial is a cleaner alternative to the default-argument trick.
Note: Closures are the mechanism behind decorators and callback factories, so interviewers often ask this straight after a decorator question.
25. How does asyncio work internally, and what are the event loop, coroutines, tasks and await?
asyncio provides cooperative concurrency in a single thread. It is ideal for programs that spend most of their time waiting on the network, such as API clients, scrapers and web servers.
- Coroutine — calling an
async deffunction does not run it; it returns a coroutine object that must be awaited or scheduled. - await — pauses the current coroutine until the awaited operation completes, handing control back to the event loop. Only awaitable objects can be awaited: coroutines, tasks and futures.
- Event loop — the scheduler. It runs ready coroutines, and when they await I/O it registers the socket with the operating system (through
epoll,kqueueand similar) and resumes the coroutine when data arrives. - Task — a coroutine wrapped so the loop runs it concurrently, created with
asyncio.create_task(),asyncio.gather()or aTaskGroup(Python 3.11 and later).
import asyncio
async def fetch(i):
await asyncio.sleep(1) # stands in for a network call
return i * 2
async def main():
async with asyncio.TaskGroup() as tg:
tasks = [tg.create_task(fetch(i)) for i in range(10)]
print([t.result() for t in tasks]) # about 1 second, not 10
asyncio.run(main())Key consequences:
- There is no parallelism. One slow synchronous call, such as
time.sleep()orrequests.get(), freezes every task. - A task only switches at an
await, so code between awaits runs atomically, which makes many race conditions easier to reason about than with threads. - You need async-aware libraries:
httpxoraiohttpinstead ofrequests,asyncpginstead of a blocking driver. - Forgetting
awaitproduces a ‘coroutine was never awaited’ warning and the code silently does nothing.
Note: TaskGroup is preferred over bare gather in new code, because if one task fails it cancels the others and raises every error properly.
26. How do you run blocking or CPU-heavy code inside an asyncio application without freezing the event loop?
The event loop runs on one thread, so any call that blocks, whether waiting on a synchronous library or crunching numbers, stops every other coroutine. The fix is to move that work off the loop’s thread and await its result.
Blocking I/O: use a thread.
import asyncio
async def handler(path):
# Python 3.9 and later: runs the function in the default thread pool
text = await asyncio.to_thread(read_large_file, path)
return textThreads work well for blocking I/O such as legacy database drivers, requests or file access, because the GIL is released while waiting.
CPU-bound work: use a process pool. Threads will not help with pure Python computation because of the GIL.
from concurrent.futures import ProcessPoolExecutor
pool = ProcessPoolExecutor(max_workers=4)
async def score(data):
loop = asyncio.get_running_loop()
return await loop.run_in_executor(pool, heavy_model_score, data)The function and its arguments must be picklable, and the pool should be created once and reused, because starting processes is expensive.
Other good practices:
- Prefer native async libraries when they exist, such as
httpx,asyncpgandaiofiles, so no thread is needed at all. - Replace
time.sleep()withawait asyncio.sleep(). - Run with
PYTHONASYNCIODEBUG=1orasyncio.run(main(), debug=True), which logs any callback that blocks the loop for more than 100 ms. - For very heavy or long jobs, hand the work to a task queue such as Celery or RQ rather than running it inside a web process at all.
Note: A single forgotten synchronous call is the most common reason an async service is slower than the synchronous one it replaced. Interviewers like candidates who know how to detect it.
27. What is the difference between instance methods, class methods and static methods, and when should you use each?
All three are defined inside a class, but they receive different first arguments and so have access to different things.
- Instance method — the default. Receives the instance as
self, so it can read and change that object’s state. Used for almost all behaviour. - Class method (
@classmethod) — receives the class ascls. It can access class attributes and create new instances, and it respects subclasses becauseclsis whatever class it was called on. - Static method (
@staticmethod) — receives nothing automatically. It is a plain function namespaced inside the class because it is logically related.
class Invoice:
GST_RATE = 0.18
def __init__(self, amount):
self.amount = amount
def total(self): # instance method
return round(self.amount * (1 + self.GST_RATE), 2)
@classmethod
def from_paise(cls, paise): # alternative constructor
return cls(paise / 100)
@staticmethod
def is_valid_gstin(code): # no state needed
return len(code) == 15 and code[:2].isdigit()
inv = Invoice.from_paise(125000)
inv.total() # 1475.0When to use each:
- Class methods are ideal for alternative constructors such as
from_json,from_roworfrom_env. The standard library does this withdict.fromkeys()anddatetime.fromtimestamp(). Because they usecls, a subclass callingfrom_paisegets a subclass instance. - Static methods suit small helpers tied to the class’s domain. If the helper is useful elsewhere, a module-level function is often more Pythonic.
Note: Under the hood these decorators are descriptors that change how the function binds when accessed, which is a good follow-up point if the interviewer asks how they work.
28. How does method resolution order work with multiple inheritance, and what does super() actually call?
When a class inherits from several parents, Python needs a single, predictable order in which to search for attributes. That order is the method resolution order (MRO), computed with the C3 linearisation algorithm. You can inspect it with ClassName.__mro__ or ClassName.mro().
C3 guarantees two things: a child always comes before its parents, and the left-to-right order of the bases in the class statement is preserved. If no order satisfies both, Python refuses to create the class with a TypeError.
class Base:
def save(self):
print('Base.save')
class AuditMixin(Base):
def save(self):
print('audit')
super().save()
class CacheMixin(Base):
def save(self):
print('clear cache')
super().save()
class Order(AuditMixin, CacheMixin):
pass
Order.__mro__ # Order, AuditMixin, CacheMixin, Base, object
Order().save() # audit, clear cache, Base.saveThe key insight: super() does not mean ‘my parent class’. It means ‘the next class in the MRO of the instance’s actual type’. Inside AuditMixin, super().save() calls CacheMixin.save, a class AuditMixin knows nothing about. That is how the diamond is resolved and why Base.save runs exactly once.
Cooperative multiple inheritance works only if every class plays along:
- Every override calls
super(), so the chain is not broken. - Methods accept arbitrary keyword arguments (the double-star
kwargsparameter) and pass them on, because you cannot know which class comes next. - A common base at the root ends the chain without calling
super()further.
This is exactly how Django class-based views combine mixins such as LoginRequiredMixin, and why mixins must be listed to the left of the main view class.
Note: Calling Base.save(self) directly instead of using super() is the classic bug in diamonds: it skips classes in the MRO or runs the base twice.
29. How do dataclasses compare with namedtuple, TypedDict and Pydantic models for representing structured data?
All four give you named fields instead of anonymous tuples or dictionaries, but they make different trade-offs around mutability, validation and runtime cost.
| Tool | What it is | Runtime validation | Best for |
|---|---|---|---|
@dataclass | A normal class with generated __init__, __repr__ and __eq__ | None; type hints are not enforced | Internal domain objects |
namedtuple / typing.NamedTuple | An immutable tuple subclass with named fields | None | Lightweight, read-only records such as rows or coordinates |
TypedDict | A plain dict with key types described for static checkers | None; exists only for mypy or pyright | Typing JSON-like dictionaries you cannot change |
Pydantic BaseModel | A class that parses, coerces and validates input | Yes, with clear errors | Data crossing a boundary: API requests, config, files |
from dataclasses import dataclass, field
@dataclass(frozen=True, slots=True)
class Order:
order_id: str
amount: float
tags: list[str] = field(default_factory=list)Dataclass details worth knowing:
field(default_factory=list)avoids the shared mutable default problem; a bare[]default raises an error.frozen=Truemakes instances immutable and hashable.slots=True(Python 3.10 and later) reduces memory;order=Trueadds comparison methods.__post_init__is the hook for derived fields or simple checks.
How to choose: validate untrusted data at the edges of your system with Pydantic, then pass dataclasses around internally where the data is already trusted. Use NamedTuple when tuple behaviour, such as unpacking and immutability, is genuinely useful, and TypedDict when the data must stay a dictionary.
Note: A common interview trap: a dataclass with amount: int will happily accept a string. Only Pydantic, or your own checks, enforce types at runtime.
30. What does __slots__ do in a Python class, and when is it worth using?
By default every instance stores its attributes in a per-object dictionary, __dict__. That is flexible, since you can add any attribute at any time, but a dictionary costs memory for every single object. Declaring __slots__ replaces the dictionary with a fixed set of compact storage slots.
class Tick:
__slots__ = ('symbol', 'price', 'ts')
def __init__(self, symbol, price, ts):
self.symbol = symbol
self.price = price
self.ts = ts
t = Tick('INFY', 1520.5, 1700000000)
t.volume = 10 # AttributeError: no slot named 'volume'Benefits:
- Memory — often 40 to 60 percent less per instance. This matters when you create millions of small objects, such as market ticks, graph nodes or parsed log records.
- Slightly faster attribute access, because slots are implemented as descriptors with fixed offsets.
- Typo protection — assigning a misspelled attribute raises an error instead of silently creating a new one.
Costs and gotchas:
- You cannot add attributes dynamically, and instances have no
__dict__; some libraries rely on it. - Weak references are not supported unless you add
'__weakref__'to the slots. - A subclass that does not declare its own
__slots__gets a__dict__again, losing the saving. - Multiple inheritance from two classes that both have non-empty slots raises a layout conflict.
- Class attributes cannot be used as defaults for slotted names; set defaults in
__init__.
Modern shortcut: @dataclass(slots=True) in Python 3.10 and later generates the slots for you.
Note: Treat __slots__ as an optimisation, not a default. Measure with tracemalloc or sys.getsizeof first, and reach for it only when object count is genuinely large.
31. What are descriptors in Python, and how do property and regular methods rely on them?
A descriptor is an object, defined as a class attribute, that controls what happens when that attribute is accessed on an instance. It implements one or more of __get__, __set__ and __delete__. Descriptors are the machinery behind much of Python’s object model.
- Data descriptor — defines
__set__or__delete__. It takes priority over the instance’s__dict__. - Non-data descriptor — defines only
__get__. An entry in the instance’s__dict__with the same name overrides it.
A reusable validator:
class Positive:
def __set_name__(self, owner, name):
self.name = '_' + name
def __get__(self, obj, objtype=None):
if obj is None:
return self
return getattr(obj, self.name)
def __set__(self, obj, value):
if value <= 0:
raise ValueError(f'{self.name[1:]} must be positive')
setattr(obj, self.name, value)
class Product:
price = Positive()
stock = Positive()
def __init__(self, price, stock):
self.price = price # goes through Positive.__set__
self.stock = stock__set_name__ (Python 3.6 and later) tells the descriptor which attribute name it was assigned to, so one class can guard many fields.
Where you already use descriptors:
- Methods — functions are non-data descriptors. Accessing
obj.methodcallsfunction.__get__(obj, type), which returns a bound method withselffilled in. property— a data descriptor that calls your getter and setter functions.classmethodandstaticmethod— descriptors that bind to the class or to nothing.functools.cached_property— a non-data descriptor that stores the result in the instance dictionary, which then shadows it on later accesses.- ORM fields — Django model fields and SQLAlchemy columns use descriptors for lazy loading of related objects.
Note: Use property for one attribute on one class; write a descriptor when the same access logic must be reused across many attributes or classes.
32. What is a metaclass in Python, and when would you need one instead of a class decorator or __init_subclass__?
In Python, classes are objects too, and the object that creates a class is its metaclass. The default metaclass is type. When Python executes a class statement, it gathers the body into a namespace and calls type(name, bases, namespace). A custom metaclass lets you intercept that creation step.
class RegistryMeta(type):
registry = {}
def __new__(mcls, name, bases, namespace):
cls = super().__new__(mcls, name, bases, namespace)
if bases: # skip the abstract base itself
mcls.registry[name.lower()] = cls
return cls
class Exporter(metaclass=RegistryMeta):
pass
class CsvExporter(Exporter):
pass
RegistryMeta.registry # {'csvexporter': CsvExporter}Real-world uses:
- ORMs — Django’s
ModelBasereads field declarations and builds the_metaoptions and database mapping. - Abstract base classes —
ABCMetatracks abstract methods and blocks instantiation. - Enums —
EnumMetaturns class attributes into enum members. - Controlling the class namespace itself through
__prepare__, or customising behaviour on the class object, such aslen(MyEnum).
Simpler alternatives, usually preferred:
__init_subclass__(Python 3.6 and later) — a hook on the base class that runs whenever a subclass is defined. It handles registration and validation of subclasses, which covers most metaclass use cases.- Class decorators — modify or register one class after it is created, explicitly and visibly.
__set_name__on descriptors — lets fields learn their names without a metaclass.
class Exporter:
registry = {}
def __init_subclass__(cls):
super().__init_subclass__()
Exporter.registry[cls.__name__.lower()] = clsDrawbacks of metaclasses: they are hard to read, and combining two classes with unrelated metaclasses raises a metaclass conflict error.
Note: A good interview answer quotes Tim Peters: if you wonder whether you need a metaclass, you probably do not. Then show that you know __init_subclass__ solves the same problem more simply.
33. How do type hints work in Python, and how do you use Optional, generics, TypeVar and a checker such as mypy?
Type hints are annotations that describe expected types. Python itself does not enforce them at runtime; they are read by static checkers such as mypy and pyright, by IDEs for autocompletion, and by libraries such as Pydantic and FastAPI that choose to use them.
from typing import Optional, TypeVar, Callable, Literal
from collections.abc import Iterable
T = TypeVar('T')
def first(items: Iterable[T]) -> Optional[T]:
for item in items:
return item
return None
def retry(fn: Callable[[], T], attempts: int = 3) -> T: ...
Mode = Literal['read', 'write']
def open_store(mode: Mode) -> None: ...Core building blocks:
- Built-in generics —
list[int],dict[str, float]andtuple[int, ...]work directly from Python 3.9. Optional[X]— means X orNone. Python 3.10 added an equivalent union shorthand written with the bitwise-or operator, andUnioncovers several types.TypeVarand generics — link input and output types, sofirstreturnsstrfor a list of strings. Python 3.12 adds the shorterdef first[T](...)syntax.Callable,Literal,TypedDict,Protocol— describe functions, fixed values, dictionary shapes and structural interfaces.Any— opts out of checking; keep it rare.
Working with mypy in practice:
- Adopt it gradually. Unannotated functions are not checked by default, so start with new modules and tighten with
--strictover time. - Run it in CI and pre-commit alongside the test suite.
- Use
reveal_type(x)to see what the checker infers, andtyping.castor a targeted ignore comment only when you know better than the checker. - Guard imports needed only for hints with
if TYPE_CHECKING:to avoid circular imports.
Note: The value is not the annotations themselves but the bugs caught before runtime, such as passing None where a string is expected, which is the single most common error mypy finds.
34. What are abstract base classes in Python, and how do they differ from duck typing and typing.Protocol?
Python supports three styles of describing ‘an object that can do X’. They trade flexibility for explicitness.
1. Duck typing — no declaration at all. If an object has a read() method, you call it. This is the most Pythonic default, but mistakes surface only at runtime, often far from their cause.
2. Abstract base classes (ABCs) — nominal typing. A class must explicitly inherit from the ABC, and Python refuses to instantiate it until every abstract method is implemented.
from abc import ABC, abstractmethod
class PaymentGateway(ABC):
@abstractmethod
def charge(self, amount: int) -> str: ...
class Razorpay(PaymentGateway):
pass
Razorpay() # TypeError: can't instantiate abstract classABCs can also provide shared concrete methods, and collections.abc offers ready-made ones such as Mapping and Iterable that fill in mixin methods for you.
3. Protocols (typing.Protocol, Python 3.8 and later) — structural typing, often called static duck typing. Any class with matching methods satisfies the protocol without inheriting from it, and mypy verifies it.
from typing import Protocol
class SupportsCharge(Protocol):
def charge(self, amount: int) -> str: ...
def checkout(gateway: SupportsCharge) -> None:
gateway.charge(49900) # any class with charge() is acceptedHow to choose:
- ABC — when you own the hierarchy, want instantiation-time errors, and want to share implementation, as in a plugin framework.
- Protocol — when you want to accept third-party classes you cannot modify, or keep modules decoupled. Adding
@runtime_checkableallowsisinstancechecks, but those only test that the methods exist, not their signatures. - Plain duck typing — for small scripts where the extra declarations add no value.
Note: Protocols are increasingly preferred in modern codebases because they describe what a function needs rather than forcing callers into your inheritance tree.
35. How do you manage dependencies and virtual environments in Python, and what role does pyproject.toml play?
The goal is isolation, so each project has its own packages, and reproducibility, so every machine and every deploy installs exactly the same versions.
Virtual environments:
python -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
python -m pip install -e '.[dev]'- Never install project packages into the system Python; it can break operating system tools and other projects.
- Use
python -m pipso pip always matches the interpreter you think you are using. - Do not commit
.venv; commit the files that recreate it.
pyproject.toml is the standard configuration file for a Python project:
[build-system](PEP 518) — which build backend to use, such as setuptools, hatchling or poetry-core.[project](PEP 621) — name, version, required Python, and dependencies with version ranges.[tool.*]— configuration for ruff, black, pytest, mypy and coverage in one place.
Ranges versus locks — the key distinction:
- Declared dependencies use ranges, such as
requests>=2.31, which is correct for libraries. - Applications also need a lock file pinning every direct and transitive package to an exact version, ideally with hashes. Tools include
pip-tools(requirements.txtcompiled fromrequirements.in), Poetry (poetry.lock) anduv(uv.lock).
Good practice: separate dev dependencies such as pytest and mypy from runtime ones, pin the Python version as well (.python-version or the Docker base image), update dependencies regularly through automated pull requests, and run pip-audit to flag known vulnerabilities.
Note: uv has become popular because it replaces pip, venv and pip-tools with one very fast tool, and mentioning it shows that you follow the ecosystem.
36. How does the Python import system work, and how do you fix a circular import?
When Python executes import payments, it follows a clear sequence:
- Check the cache —
sys.modules. If the module is already there, even partially loaded, it is returned immediately. This is why a module’s top-level code runs only once per process. - Find it — search the entries in
sys.pathin order: the script’s directory,PYTHONPATH, the standard library, thensite-packages. The first match wins, which is why a local file namedrandom.pyoremail.pycan shadow the standard library. - Load and execute — create a new module object, insert it into
sys.modules, then run the file’s top-level code to fill its namespace. Compiled bytecode is cached in__pycache__.
A package is a directory of modules, normally with an __init__.py that runs when the package is first imported.
Why circular imports fail:
# orders.py
from customers import Customer
class Order: ...
# customers.py
from orders import Order # orders is only half-loaded here
class Customer: ...Importing orders starts running it, which imports customers, which asks for Order from the partially initialised orders module. Order is not defined yet, so Python raises ImportError: cannot import name 'Order' from partially initialized module.
Fixes, best first:
- Restructure — move the shared pieces into a third module that both import. A cycle usually signals that responsibilities are tangled.
- Import the module, not the name —
import ordersand useorders.Orderinside functions, so the attribute is looked up later. - Import only for type checking — put the import under
if TYPE_CHECKING:and use string or postponed annotations. - Import inside the function that needs it, as a last resort.
Note: Tools such as import-linter can enforce layering rules in CI so cycles are caught before they become runtime errors.
37. How do pytest fixtures work, including scopes, yield-based teardown, conftest.py and parametrize?
A fixture is a function that provides a test with something it needs: a database connection, a temporary directory or a sample object. Tests request fixtures simply by naming them as parameters, and pytest injects them.
import pytest
@pytest.fixture
def db():
conn = sqlite3.connect(':memory:')
conn.execute('CREATE TABLE users (name TEXT)')
yield conn # the test runs at this point
conn.close() # teardown, runs even if the test fails
def test_insert(db):
db.execute("INSERT INTO users VALUES ('Asha')")
assert db.execute('SELECT COUNT(*) FROM users').fetchone()[0] == 1Key features:
- Yield fixtures — code before
yieldis setup, code after is teardown. Cleaner than setUp and tearDown methods. - Scope —
function(the default, fresh per test),class,module,packageorsession. Expensive resources, such as a Docker database, use a wide scope; mutable state should stay function-scoped so tests remain independent. - Composition — fixtures can depend on other fixtures, forming a dependency graph pytest resolves for you.
conftest.py— fixtures defined here are available to every test in that directory and below, with no imports needed.- Built-in fixtures —
tmp_path,monkeypatch,capsysandcaplogcover common needs. autouse=True— applies a fixture to every test in scope; use sparingly, because it hides dependencies.
Parametrize runs one test over many inputs, each reported separately:
@pytest.mark.parametrize('raw, expected', [
('42', 42),
(' 7 ', 7),
('-3', -3),
])
def test_parse_int(raw, expected):
assert parse_int(raw) == expected
def test_rejects_text():
with pytest.raises(ValueError, match='not a number'):
parse_int('abc')Note: Fixtures can themselves be parametrised with params=[...], which is a neat way to run a whole test module against, for example, both SQLite and PostgreSQL.
38. How do you mock external dependencies in Python tests, and why must you patch where an object is looked up?
Unit tests should not call real payment gateways, email servers or third-party APIs: they would be slow, flaky and sometimes expensive. unittest.mock lets you replace those dependencies with controllable fakes.
# app/weather.py
import requests
def city_temp(city):
resp = requests.get(API_URL, params={'q': city}, timeout=5)
resp.raise_for_status()
return resp.json()['temp']
# tests/test_weather.py
from unittest.mock import patch
@patch('app.weather.requests.get')
def test_city_temp(mock_get):
mock_get.return_value.json.return_value = {'temp': 31}
assert city_temp('Delhi') == 31
mock_get.assert_called_once()The golden rule: patch where the name is looked up, not where it is defined. patch replaces an attribute on a module object. If app/weather.py did from requests import get, it holds its own reference to get, so patching requests.get changes nothing it uses. You must patch app.weather.get instead.
Useful tools:
return_value— what the mock returns;side_effect— raise an exception, such asrequests.Timeout, or return a sequence of values across calls.autospec=Trueorcreate_autospec— the mock copies the real signature, so calling it with wrong arguments fails instead of passing silently.- Assertions:
assert_called_once_with(...)andcall_args. - pytest’s
monkeypatchfixture — simple patching of attributes, environment variables and dictionaries, undone automatically. - HTTP-specific libraries such as
responsesorrespxintercept requests at the transport layer and are often clearer than raw mocks.
Avoid over-mocking. If a test mocks five internal functions, it tests the implementation, not the behaviour, and breaks on every refactor. Mock at the boundaries of your system, and design code so dependencies can be passed in, which often removes the need for patching altogether.
Note: Also test the failure paths, such as timeouts and 500 responses, through side_effect. Those are exactly the cases real services produce and happy-path tests never cover.
39. How do you design custom exceptions in Python, and what does exception chaining with raise from do?
Custom exceptions let callers handle failures in your domain precisely, without catching unrelated errors. Good design starts with a small hierarchy.
class PaymentError(Exception):
'''Base class for every payment failure in this package.'''
class CardDeclined(PaymentError):
def __init__(self, reason, order_id):
super().__init__(f'Card declined for {order_id}: {reason}')
self.reason = reason
self.order_id = order_id
class GatewayUnavailable(PaymentError):
pass- Inherit from
Exception, never fromBaseException, which is reserved for exits and interrupts. - One base class per package lets callers write
except PaymentErrorto catch everything from your code, or a subclass to be specific. - Store useful context as attributes and call
super().__init__with a readable message. - Name them after what went wrong, ending in
Errorby convention.
Exception chaining preserves the original cause when you translate a low-level error into a domain error:
try:
resp = client.post(url, json=payload, timeout=10)
except requests.Timeout as exc:
raise GatewayUnavailable('Payment gateway timed out') from excraise X from excsetsX.__cause__, and the traceback shows both errors joined by ‘The above exception was the direct cause of the following exception’. Debugging stays easy while callers see a clean, meaningful type.- Raising inside an
exceptblock withoutfromsets__context__implicitly, and the traceback says ‘During handling of the above exception, another exception occurred’, which suggests a bug in the handler. raise X from Nonesuppresses the original when it is irrelevant noise.
Newer features: Python 3.11 added ExceptionGroup with except* for handling several concurrent failures, as produced by asyncio TaskGroups, and exc.add_note() for attaching extra context to an existing exception.
Note: Translate exceptions at module boundaries. Callers of your payment module should never need to import requests just to catch its errors.
40. What is the difference between == and is in Python, and why does is sometimes appear to work for numbers and strings?
The two operators answer different questions:
==asks are these values equal? It calls__eq__, so each class decides what equality means.isasks are these the same object in memory? It compares identity, asid(a) == id(b)would, and cannot be overridden.
a = [1, 2, 3]
b = [1, 2, 3]
a == b # True, same contents
a is b # False, two separate list objects
c = a
c is a # True, two names for one objectWhy is seems to work for small numbers: CPython pre-creates and caches the integers from -5 to 256, and it interns many short strings that look like identifiers. So two variables holding 100 usually point to the same cached object and is returns True. For 1000 computed at runtime, or a string built by concatenation, you get separate objects and is returns False.
x = int('256'); y = int('256')
x is y # True, cached
x = int('1000'); y = int('1000')
x is y # False, same value but different objectsThis is an implementation detail of CPython, not a language guarantee, and it can change between versions. Since Python 3.8, using is with a literal produces a SyntaxWarning for exactly this reason.
When to use is: only for singletons and sentinels, where identity is the point.
if value is None:is the idiomatic check, and it cannot be fooled by a custom__eq__.- Unique sentinel objects, such as
_MISSING = object(), to distinguish ‘not passed’ fromNone.
A subtle bonus point: float('nan') == float('nan') is False, yet nan in [nan] is True for the same object, because container membership checks identity before equality.
Note: A bug where if status is 'active' works locally but fails in production is almost always this: strings that happened to be interned in one code path and not in another.
41. Which classes in the collections module do you use most, and what problems do they solve?
The collections module provides specialised containers that replace a lot of hand-written boilerplate and often perform better than a plain list or dictionary.
Counter— counts hashable items. It supportsmost_common(n)and arithmetic between counters.from collections import Counter
words = Counter(text.split())
words.most_common(3) # [('the', 42), ('data', 17), ...]deque— a double-ended queue with O(1) appends and pops at both ends.list.pop(0)andlist.insert(0, x)are O(n), so a deque is the right choice for queues and breadth-first search. Withmaxlenit becomes a fixed-size sliding window.from collections import deque
recent = deque(maxlen=5) # keeps only the last 5 readings
for reading in sensor_stream:
recent.append(reading)
moving_avg = sum(recent) / len(recent)namedtuple— tuples with named fields, such asPoint(x=1, y=2): readable, immutable and memory-light.typing.NamedTupleis the typed class-based version.defaultdict— supplies a default for missing keys, which makes grouping trivial:groups[key].append(item)withdefaultdict(list).OrderedDict— plain dictionaries keep insertion order since Python 3.7, butOrderedDictstill offersmove_to_end()andpopitem(last=False), which make a simple LRU cache easy. Its equality check is also order-sensitive.ChainMap— searches several dictionaries as one view without copying them. Ideal for layered configuration: command-line arguments, then environment variables, then defaults.
Why interviewers ask: reaching for Counter or deque instead of writing loops shows fluency, and knowing that list.pop(0) is O(n) shows awareness of performance.
Note: The collections.abc submodule is different: it holds abstract base classes such as Mapping and Iterable, used for type checks and for building your own containers.
42. What does the functools module offer beyond wraps, such as lru_cache, partial, reduce and singledispatch?
functools contains tools for working with functions as objects. The ones worth knowing for interviews:
lru_cacheandcache— memoise a function’s results, keyed by its arguments.from functools import lru_cache
@lru_cache(maxsize=1024)
def shipping_rate(pincode, weight_kg):
return expensive_rate_lookup(pincode, weight_kg)
shipping_rate.cache_info() # hits, misses, current size
shipping_rate.cache_clear()- Arguments must be hashable; passing a list raises
TypeError. @cache(Python 3.9 and later) is an unbounded version; use it only when the set of inputs is small.- Only cache pure functions. Caching something that depends on time or database state returns stale results.
- Decorating a method puts
selfin the cache key, which keeps every instance alive and can leak memory. Prefercached_propertyor a module-level function.
- Arguments must be hashable; passing a list raises
partial— fixes some arguments of a function, producing a new callable:to_int = partial(int, base=2). Useful for callbacks and for avoiding the lambda late-binding trap.reduce— folds a sequence into one value:reduce(operator.mul, nums, 1). Often a plain loop orsumormath.prodis clearer.singledispatch— function overloading by the type of the first argument:from functools import singledispatch
@singledispatch
def to_json(obj):
raise TypeError(f'Cannot serialise {type(obj)}')
@to_json.register
def _(obj: datetime):
return obj.isoformat()singledispatchmethoddoes the same inside classes.cached_property— computes an attribute once per instance, then stores it.total_ordering— define__eq__and one comparison such as__lt__, and the other comparison methods are generated.
Note: lru_cache is the quickest win in many coding rounds: adding it to a naive recursive Fibonacci or grid-path function turns exponential time into linear time.
43. How do you profile a slow Python program and decide what to optimise?
The first rule is to measure before changing anything. Intuition about where time goes is wrong surprisingly often, and optimising the wrong function wastes effort and adds complexity.
Step 1: find the hot spots.
cProfile— the built-in deterministic profiler, giving call counts and cumulative time per function:python -m cProfile -s cumtime report.py
python -m cProfile -o out.prof report.py # then view with snakevizline_profiler— time spent per line inside a function you have already identified.py-spy— a sampling profiler that attaches to a running process, including production, with negligible overhead and no code changes. It can produce flame graphs.tracemallocormemray— when the problem is memory rather than speed.timeit— for comparing two small alternatives reliably.
Step 2: fix in order of impact.
- Algorithm and data structures — replacing list membership inside a loop with a set, or a nested loop with a dictionary lookup, beats any micro-optimisation.
- Avoid repeated work — move invariant calculations out of loops, cache results with
lru_cache, batch database queries and API calls. - Use built-ins and C-backed libraries —
sum,sorted, comprehensions and especially vectorised NumPy or pandas operations instead of Python-level loops over rows. - Fix I/O — often the real bottleneck: use connection pooling, concurrent requests with asyncio or threads, and bulk inserts.
- Parallelism —
multiprocessingfor CPU-bound work. - Heavier tools — Numba, Cython, PyPy, or rewriting a hot loop in Rust or C, only when the above is exhausted.
Step 3: verify. Re-measure with the same realistic input, keep a benchmark to catch regressions, and confirm results are unchanged with tests.
Note: Keep Amdahl’s law in mind: if a function takes 5 percent of total runtime, making it infinitely fast saves only 5 percent. The profiler tells you where the other 95 percent is.
44. How should you work with files, paths and text encodings in Python to avoid common bugs?
Three habits prevent most file-handling bugs: use pathlib, always specify an encoding, and always use a with block.
pathlib over os.path — paths become objects with readable operations that work on every operating system:
import csv
from pathlib import Path
base = Path(__file__).resolve().parent
reports = base / 'data' / 'reports' # joins with the right separator
reports.mkdir(parents=True, exist_ok=True)
for csv_file in sorted(reports.glob('*.csv')):
print(csv_file.stem, csv_file.suffix, csv_file.stat().st_size)
with csv_file.open(encoding='utf-8-sig', newline='') as f:
for row in csv.DictReader(f):
process(row)- Build paths relative to
__file__, not to the current working directory, which changes depending on where the script is launched. read_text()andwrite_text()are handy for small files.
Text versus bytes:
stris Unicode text;bytesis raw data. Convert at the boundaries with.encode('utf-8')and.decode('utf-8').- Text mode (
'r','w') decodes and handles newlines; binary mode ('rb','wb') is for images, PDFs and pickles.
Always pass encoding=. The default depends on the platform, often cp1252 on Windows, so code that works on a Linux server raises UnicodeDecodeError on a colleague’s laptop when it meets Hindi text or a rupee symbol. Useful choices:
'utf-8'— the sensible default.'utf-8-sig'— strips the byte-order mark that Excel adds to CSV exports, which otherwise corrupts the first column name.errors='replace'— for messy legacy files where losing a character is acceptable.
Large files: iterate line by line with for line in f rather than calling read(), so memory use stays constant. With the csv module, open files with newline='' to avoid blank rows on Windows.
Note: Write files atomically when others may read them: write to a temporary file in the same directory, then call Path.replace() so readers never see a half-written file.
45. How does the Python logging module work, and why is it better than using print statements?
print writes to standard output with no level, no timestamp, no source and no way to switch it off. The logging module separates what you record from where and how it is shown, which is what production code needs.
The main pieces:
- Loggers — obtained with
logging.getLogger(__name__). Names form a dotted hierarchy (app.payments.razorpay), and records propagate up to parent loggers. - Levels —
DEBUG,INFO,WARNING,ERROR,CRITICAL. You can turn on DEBUG for one noisy module without flooding the rest. - Handlers — decide the destination: console, rotating file, syslog or an external service.
- Formatters — decide the layout: timestamp, level, logger name, message, or JSON for log aggregators.
import logging
logger = logging.getLogger(__name__)
def settle(order_id, amount):
logger.info('Settling order %s for %s', order_id, amount)
try:
gateway.settle(order_id, amount)
except GatewayError:
logger.exception('Settlement failed for %s', order_id)
raise
# configure once, in the application entry point
if __name__ == '__main__':
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s %(levelname)s %(name)s %(message)s',
)Best practices:
- Libraries should never configure logging; they only create loggers. Configuration, through
basicConfigordictConfig, belongs in the application. - Pass arguments separately, as in
logger.info('x=%s', x), instead of f-strings. The string is formatted only if the level is enabled, and aggregators can group identical messages. - Use
logger.exception()insideexceptblocks; it logs at ERROR level and includes the full traceback. - Add context such as request IDs or user IDs through
extra, filters or a structured logging library likestructlog. - Never log passwords, tokens, card numbers or full personal data such as Aadhaar or PAN numbers.
Note: In containers, log to standard output and let the platform collect it, rather than writing rotating files inside the container.
46. What is structural pattern matching with match and case, and how is it different from a switch statement?
Python 3.10 introduced match and case. It looks like a switch statement from C or Java, but it is far more powerful: it matches the shape of data and binds parts of it to names in the same step.
def handle(event):
match event:
case {'type': 'click', 'x': x, 'y': y}:
return f'click at {x},{y}'
case {'type': 'key', 'key': str(key)} if key.isupper():
return f'shortcut {key}'
case ['move', dx, dy]:
return f'move by {dx},{dy}'
case Point(x=0, y=0):
return 'origin'
case None:
return 'no event'
case _:
return 'unknown'Pattern types:
- Literal patterns — numbers, strings,
None,True. - Capture patterns — a bare name such as
xmatches anything and binds it. - Wildcard —
_matches anything without binding, like a default branch. - Sequence patterns —
[first, *rest]destructures lists and tuples by length. - Mapping patterns — match dictionaries by required keys; extra keys are allowed.
- Class patterns —
Point(x=0, y=0)checks the type and attributes; dataclasses support positional patterns automatically. - Guards — an
ifafter the pattern adds an extra condition. - OR patterns — several alternatives can share one case, and
asbinds the matched value to a name.
How it differs from switch: there is no fall-through, cases are tried top to bottom and the first match wins, and it destructures nested data rather than only comparing values.
The classic trap: case RED: does not compare against a constant called RED; it captures anything into a new variable named RED. Constants must be dotted names, such as case Color.RED:.
When to use it: parsing commands, handling JSON messages or ASTs with varying shapes, and replacing long chains of isinstance checks. For simple value lookups, a dictionary is still clearer.
Note: Mentioning the dotted-name trap in an interview is a quick way to show you have used the feature in practice, not only read about it.
47. If Python has a GIL, why do you still need locks in multithreaded code, and how do Lock and Queue help?
The GIL ensures that only one thread executes Python bytecode at a time, which protects the interpreter’s own internals, such as reference counts. It does not make your code thread-safe, because a thread can be switched out between bytecode instructions.
import threading
balance = 0
def deposit():
global balance
for _ in range(100_000):
balance += 1 # read, add, write: three separate steps
threads = [threading.Thread(target=deposit) for _ in range(4)]
for t in threads: t.start()
for t in threads: t.join()
print(balance) # can be less than 400000balance += 1 compiles to load, add and store. If two threads both load 10 before either stores, both write 11 and one deposit is lost. That is a race condition, and it appears intermittently, which makes it hard to debug.
Fix with a Lock:
lock = threading.Lock()
def deposit():
global balance
for _ in range(100_000):
with lock: # only one thread inside at a time
balance += 1Other synchronisation tools:
RLock— a reentrant lock the same thread can acquire again, useful when locked methods call each other.queue.Queue— a thread-safe FIFO. The producer-consumer pattern with a queue is often the cleanest design because threads share messages instead of mutable state, so no manual locking is needed.Event,SemaphoreandCondition— for signalling and for limiting concurrent access, for example at most five simultaneous API calls.
Avoiding deadlocks: keep critical sections small, always acquire multiple locks in the same order, use timeouts where appropriate, and never call unknown code while holding a lock.
Looking ahead: the free-threaded CPython build (PEP 703, experimental from Python 3.13) removes the GIL entirely, so correct locking becomes even more important.
Note: Single operations such as list.append or dict assignment happen to be atomic in CPython, but relying on that is fragile. Use a lock or a queue whenever shared state is modified.
48. What is the difference between json and pickle for serialisation, and why is unpickling untrusted data dangerous?
Both turn Python objects into bytes or text that can be stored or sent, but they are designed for very different jobs.
| json | pickle | |
|---|---|---|
| Format | Human-readable text | Python-specific binary |
| Interoperability | Any language | Python only, and sensitive to library versions |
| Types supported | dict, list, str, int, float, bool, None | Almost any Python object, including custom classes |
| Safe to load untrusted input | Yes | No |
Why unpickling is dangerous: a pickle is not just data; it is a small program for the pickle virtual machine. An object can define __reduce__ to tell pickle to call any function with any arguments during loading:
class Exploit:
def __reduce__(self):
return (os.system, ('touch /tmp/pwned',))
# pickle.loads() on these bytes runs the shell commandSo pickle.loads() on data from a user, a network request or a downloaded file means arbitrary code execution. The official documentation warns about this explicitly. The same risk applies to anything built on pickle, such as joblib model files, pandas read_pickle and older PyTorch checkpoints.
Practical guidance:
- Use JSON for APIs, configuration, caches shared between services and anything that crosses a trust boundary.
- Handle types JSON lacks with a
default=function, for example convertingdatetimeto ISO strings andDecimalto strings. - For performance or schemas, consider MessagePack, Protocol Buffers or Avro; for ML weights, the
safetensorsformat. - Use pickle only for data your own process wrote and fully controls, such as
multiprocessinginternals or local caches. If pickles must travel, sign them withhmacand verify before loading.
Note: Interviewers often ask this in the context of ML deployment: loading a pickled model from an untrusted source is a real supply-chain attack vector.
49. Why does 0.1 + 0.2 not equal 0.3 in Python, and when should you use Decimal or Fraction instead of float?
Python’s float is an IEEE 754 double-precision binary number. Just as 1/3 cannot be written exactly in decimal, 0.1 cannot be written exactly in binary. Each value is stored as the nearest representable number, and small errors appear when you combine them:
0.1 + 0.2 # 0.30000000000000004
0.1 + 0.2 == 0.3 # False
round(2.675, 2) # 2.67, because 2.675 is really 2.67499999...
round(2.5), round(3.5) # 2 and 4: round half to even, known as banker's roundingThis is not a Python bug; every language using hardware floats behaves the same way.
Comparing floats: never use == on computed results. Use math.isclose(a, b, rel_tol=1e-9), or a tolerance that suits your domain.
Decimal — for money and anything with exact decimal rules:
from decimal import Decimal, ROUND_HALF_UP
price = Decimal('19.99') # construct from a string, not a float
gst = (price * Decimal('0.18')).quantize(Decimal('0.01'), rounding=ROUND_HALF_UP)
total = price + gst # Decimal('23.59')- Always build Decimals from strings;
Decimal(0.1)faithfully copies the float’s binary error. quantize()with an explicit rounding mode matches accounting and tax rules.- It is slower than float, which rarely matters for business logic.
- An alternative for money is storing integer paise and formatting only for display; databases use
DECIMALorNUMERICcolumns for the same reason.
Fraction — exact rational arithmetic: Fraction(1, 3) + Fraction(1, 6) == Fraction(1, 2) is exactly True. Useful for probabilities and exact ratios, but numerators and denominators can grow large.
Floats remain right for scientific, statistical and ML work, where tiny relative error is acceptable and speed matters. Python int, by contrast, has arbitrary precision and never overflows.
Note: Using float for currency is a classic interview red flag. Saying ‘Decimal from strings, or integer paise’ is the answer interviewers want to hear.
50. What happens when you run a Python script, and how do bytecode, .pyc files, CPython and PyPy fit in?
Python is often called interpreted, but there is a compilation step before anything runs.
- Parse — the source is tokenised and parsed into an abstract syntax tree. Syntax errors are raised here, before any code executes.
- Compile to bytecode — the AST is compiled into bytecode: compact instructions for Python’s virtual machine, stored in code objects. You can see them with the
dismodule. - Execute — the interpreter’s evaluation loop runs the bytecode on a stack-based virtual machine, calling C functions for built-ins and objects.
import dis
def add(a, b):
return a + b
dis.dis(add)
# LOAD_FAST a, LOAD_FAST b, BINARY_OP (+), RETURN_VALUE.pyc files: when a module is imported, its bytecode is cached in __pycache__/module.cpython-312.pyc. Next time, if the source has not changed (checked by modification time or hash), Python skips parsing and compiling, so start-up is faster. The script you run directly is compiled but not cached. A .pyc does not make code run faster once loaded, and it is not a secure way to hide source, because it decompiles easily.
Implementations:
- CPython — the reference implementation, written in C, and what nearly everyone uses. It has the GIL, reference-counting memory management and the best compatibility with C extensions such as NumPy. Version 3.11 added a specialising adaptive interpreter that made typical code substantially faster, and 3.13 introduced an experimental JIT compiler and an optional free-threaded build.
- PyPy — an alternative implementation with a tracing JIT compiler. Long-running pure Python loops can run several times faster, but C-extension compatibility and start-up time are weaker.
- Others — MicroPython for microcontrollers, and Cython, which compiles Python-like code into C extension modules.
Note: This explains practical advice such as keeping hot loops inside built-ins: every Python-level bytecode instruction has overhead, while sum() or a NumPy operation runs the whole loop in C.