Object Model • Memory • Concurrency Corners • Architecture • Leadership • 2026

Python Interview Questions for 10+ Years Experience (Senior)

Python interviews for 10+ years of experience skip basics like what a list is and go after what only experience teaches: how attribute lookup and memory really work underneath, where threads, processes and asyncio fail in quiet ways, why memory and latency behave in ways the obvious answer misses, and how you make and defend decisions across a large codebase and a team. It is written for Python engineers with around eight to fifteen years behind them, interviewing for senior, staff or lead roles. Each question shows what the interviewer is checking, a shape for your answer and a sample to adapt. Say your own version out loud, with a story from your own work.

Search all questions by round, difficulty and level, or save the ones you want to practice.

Object Model 4 questions

Hard Technical round Senior Practice question

1. How does @property actually work underneath? Explain the descriptor protocol and the order Python follows when it looks up an attribute.

What the interviewer is really testing:
Whether you know the object model well enough to explain properties, method binding and cached attributes from one rule, instead of treating them as magic.
Answer frame:

Descriptor: an object stored on the class that defines __get__, and optionally __set__ or __delete__.

Data vs non-data: a data descriptor beats the instance __dict__; a non-data descriptor loses to it.

Lookup order: data descriptor on the type, then the instance __dict__, then a non-data descriptor or plain class attribute, then __getattr__.

Sample spoken answer:

“property is just a class that implements the descriptor protocol. When I read obj.x, Python first looks for x on the type, walking the MRO. If it finds a data descriptor, meaning something that defines __set__ or __delete__, that wins and its __get__ runs. Otherwise it checks the instance's own __dict__. If x isn't there, it falls back to the class attribute, calling __get__ if it's a non-data descriptor. Only if all that fails does __getattr__ run. property defines __set__ even with no setter, which is why assigning to the instance can't shadow it. Plain functions are non-data descriptors, and that's how methods bind: __get__ returns a bound method. It also explains cached_property: it's non-data, so after the first call it stores the value in the instance dict, and that entry wins from then on.”

Code:
class Positive:
    def __set_name__(self, owner, name):
        self.attr = "_" + name

    def __get__(self, obj, objtype=None):
        if obj is None:
            return self
        return getattr(obj, self.attr)

    def __set__(self, obj, value):
        if value <= 0:
            raise ValueError("must be positive")
        setattr(obj, self.attr, value)

class Order:
    qty = Positive()

    def __init__(self, qty):
        self.qty = qty  # goes through Positive.__set__
Red flag to avoid:

Calling property special syntax the interpreter handles, or not knowing why an instance attribute can't override it.

They may ask next:
  • What does __set_name__ give a descriptor, and when is it called?
  • Why does cached_property fail on a class whose __slots__ leave out __dict__?
Say it in 60 seconds
Hard Technical round Mid-level, Senior Practice question

2. You write a proxy class that forwards everything with __getattr__. Calling proxy.__len__() works, but len(proxy) raises TypeError. Why?

What the interviewer is really testing:
Whether you know that Python looks up special methods on the type, not the instance, and what that means for proxies, wrappers and mocks.
Answer frame:

Explicit call: proxy.__len__ is a normal attribute lookup, so it falls through to __getattr__ and gets forwarded.

Implicit call: len(), operators and with look the special method up on the object's type directly, skipping the instance, __getattr__ and even __getattribute__.

Fix: define the special methods you need on the proxy class, delegating each one explicitly.

Sample spoken answer:

“The two calls take different paths. proxy.__len__() is an ordinary attribute lookup: Python doesn't find __len__ on the proxy, so it calls __getattr__, which forwards to the wrapped object, and it works. But len(proxy) doesn't do an attribute lookup on the instance at all. For implicit calls like len, +, ==, iteration or a with block, Python looks the special method up on the type directly. It skips the instance dict, skips __getattr__, and even skips __getattribute__. The proxy's type has no __len__, so it raises TypeError. That's a deliberate design choice, partly for speed. The fix is to define the special methods I need on the proxy class, each one delegating to the wrapped object. It's also why mocking libraries ship a separate mock class that supports magic methods.”

Code:
class Proxy:
    def __init__(self, target):
        self._target = target

    def __getattr__(self, name):
        return getattr(self._target, name)

    def __len__(self):  # implicit calls need this on the class
        return len(self._target)

    def __iter__(self):
        return iter(self._target)
Red flag to avoid:

Saying len(x) is just shorthand for x.__len__() with no difference in how the method is found.

They may ask next:
  • Why does assigning obj.__len__ = lambda: 3 on an instance not change what len(obj) returns?
  • What's the risk of a __getattr__ that forwards everything, including names starting with underscores?
Say it in 60 seconds
Hard Technical round Senior Practice question

3. When would you reach for a metaclass, and what would you try first? Where do __init_subclass__ and class decorators fit?

What the interviewer is really testing:
Whether you know the power tools of class creation and have the judgement to pick the smallest one that works.
Answer frame:

Metaclass: controls how a class object is created, and is inherited by every subclass.

Lighter tools: __init_subclass__ for registries and checks, a class decorator for one class, a descriptor for one attribute.

Real need: customising the class namespace or behaviour on the class object itself.

Sample spoken answer:

“A metaclass controls how a class itself gets built, so it can change the namespace, the bases, or what happens whenever a subclass is defined. It's powerful but it spreads: every subclass inherits it, and mixing two libraries that each bring their own gives a metaclass conflict. So I try simpler tools first. For registering subclasses or checking that they define certain attributes, __init_subclass__ on a base class runs every time a subclass is created, and that covers most plugin registries. For changing one class, a class decorator is explicit and isn't inherited. For behaviour on one attribute, a descriptor with __set_name__. I'd only write a metaclass when I truly need to control class creation, like customising the namespace with __prepare__, or giving the class object itself behaviour, the way enum-style libraries make a class iterable.”

Code:
class Handler:
    registry = {}

    def __init_subclass__(cls, *, event, **kwargs):
        super().__init_subclass__(**kwargs)
        Handler.registry[event] = cls

class SignupHandler(Handler, event="signup"):
    def run(self, payload):
        ...
Red flag to avoid:

Writing a metaclass just to register plugins, or being unable to say how type relates to classes.

They may ask next:
  • What is a metaclass conflict, and how would you resolve one?
  • What does __prepare__ let a metaclass do that nothing else can?
Say it in 60 seconds
Medium Technical round Mid-level, Senior Practice question

4. What should __eq__ or __add__ return when the other operand is a type it doesn't understand, and what goes wrong if it returns False or raises instead?

What the interviewer is really testing:
Whether you know how Python dispatches binary operators between two types and write classes that cooperate with others.
Answer frame:

NotImplemented: return the singleton so Python tries the other operand's reflected method.

Fallback: if both give up, equality compares identity and arithmetic raises TypeError.

Wrong choices: returning False blocks the other side; raising breaks mixed comparisons.

Sample spoken answer:

“It should return the NotImplemented singleton. That tells Python to ask the other side: for a == b it then tries b.__eq__(a), and for a + b it tries b.__radd__(a). Only if both give up does Python fall back: equality compares identity, and arithmetic raises TypeError. If my __eq__ returns False for unknown types, I stop the other class from ever deciding, which breaks comparisons with proxies, test doubles and wrapper types. If it raises, then a simple in check on a list with mixed types can blow up. There's a subtle rule too: if the right operand's type is a subclass of the left's and provides its own reflected method, Python tries the right side first, so subclasses can take over. And NotImplemented isn't NotImplementedError; the error is for abstract methods, and returning it by mistake is a classic bug.”

Code:
class Money:
    def __init__(self, amount, currency):
        self.amount, self.currency = amount, currency

    def __eq__(self, other):
        if not isinstance(other, Money):
            return NotImplemented
        return (self.amount, self.currency) == (other.amount, other.currency)

    def __hash__(self):
        return hash((self.amount, self.currency))
Red flag to avoid:

Raising TypeError or returning False from __eq__ for any unknown type.

They may ask next:
  • Why does defining __eq__ without __hash__ make instances unhashable?
  • How does functools.total_ordering rely on NotImplemented?
Say it in 60 seconds

Memory 3 questions

Hard Technical round Senior Practice question

5. An in-process cache keyed by objects keeps them alive forever. How do weak references help, and why is __del__ a poor place for cleanup?

What the interviewer is really testing:
Whether you understand object lifetime well enough to avoid leaks from caches and to choose deterministic cleanup.
Answer frame:

Weak containers: WeakValueDictionary and WeakKeyDictionary drop entries when the object dies.

Limits: some built-in types can't be weakly referenced; slotted classes need __weakref__.

Cleanup: __del__ timing isn't guaranteed; use a context manager, with weakref.finalize as a safety net.

Sample spoken answer:

“A normal dict holds strong references, so nothing in my cache can ever be freed, and the cache is a leak. A WeakValueDictionary or WeakKeyDictionary holds references that don't keep the object alive: when the last strong reference goes, the entry disappears by itself. That suits caches of objects owned elsewhere, like parsed documents tied to a session. One catch: ints, strings, tuples and plain lists and dicts can't be weakly referenced, and slotted classes need __weakref__. As for __del__, it runs when the object is collected. In CPython that's often immediate, but inside a reference cycle it waits for the cycle collector, on other implementations the timing is loose, and at shutdown the globals it needs may already be gone. Errors inside it are just printed. So I clean up with a context manager, and use weakref.finalize as a backstop.”

Red flag to avoid:

Relying on __del__ to close files, sockets or database connections.

They may ask next:
  • Why might a WeakValueDictionary lose an entry sooner than you expect?
  • When would you use weakref.ref with a callback instead of a weak dictionary?
Say it in 60 seconds
Hard Technical round Senior Practice question

6. You delete a huge list of small objects and even call gc.collect(), but the process's memory barely drops. Why, and what do you do about it?

What the interviewer is really testing:
Whether you can tell freed-but-held memory from a real leak, and know the allocator behaviour behind it.
Answer frame:

Allocator: small objects come from Python's own allocator, which can only release a whole arena once it's completely empty.

Fragmentation: a few survivors scattered across arenas keep them all held.

Response: confirm with tracemalloc, stream instead of building, or run the peak in a child process.

Sample spoken answer:

“Freeing an object hands its memory back to Python's allocator, not necessarily to the operating system. Small objects come from pymalloc, which carves memory into arenas and pools, and an arena can only be given back when every object in it is free. If a few long-lived objects are scattered across many arenas, those arenas stay, and the process size stays high even though most of that memory is free for reuse. The C allocator underneath has similar fragmentation for bigger blocks. Also, gc.collect() only deals with reference cycles; it doesn't compact anything. So this isn't a leak: Python will reuse the space. If peak size is the real issue, I stream the data instead of building the whole list, or use compact shapes like arrays. If a job needs lots of memory once, I run it in a child process, which gives everything back when it exits.”

Red flag to avoid:

Calling it a leak and adding gc.collect() calls everywhere.

They may ask next:
  • How would you use tracemalloc snapshots to prove whether live Python objects are actually growing?
  • Why can a process's memory keep rising slowly even though nothing is leaking?
Say it in 60 seconds
Hard Technical round Senior Practice question

7. You load a large read-only lookup table in the parent, then fork worker processes to share it. Hours later every worker holds its own copy. Why, when the workers never write to it?

What the interviewer is really testing:
Whether you know that reading a Python object still writes to it, and how that defeats copy-on-write after fork.
Answer frame:

Copy-on-write: after fork, parent and child share memory pages until either one writes to a page.

Reads are writes: touching an object changes its reference count, and the cycle collector writes to object headers when it scans, so shared pages get copied one by one.

Fixes: gc.freeze() before forking, flat buffers such as arrays or memory-mapped files, or a shared store the workers query.

Sample spoken answer:

“After fork, memory pages are shared copy-on-write: they stay shared until someone writes to them. The catch is that in Python, reading an object writes to it. Every time a worker looks something up, the reference counts of the objects it touches go up and down, and those counts live inside each object, on the same pages as the data. The cycle collector also writes to the headers of container objects when it scans them. Each write copies a whole page into that worker, so after hours of traffic the shared table has quietly become a private copy in every process. What helps: calling gc.freeze() in the parent just before forking, so the collector leaves those objects alone, though reference counts still move; keeping the table in flat buffers, like arrays or a memory-mapped file, where there are no per-item Python objects to touch; or moving it into a shared store.”

Code:
import gc
import os

table = load_lookup_table()  # built once, in the parent
gc.freeze()                  # the collector stops scanning these objects

for _ in range(4):
    if os.fork() == 0:
        serve(table)
        os._exit(0)
Red flag to avoid:

Saying read-only data always stays shared after fork because the workers never assign to it.

They may ask next:
  • How would you measure how much memory each worker really shares with the parent?
  • What changes if the workers are started with spawn instead of fork?
Say it in 60 seconds

Concurrency 3 questions

Hard Technical round Senior Practice question

8. A worker pool starts hanging at random after someone adds a background thread to the parent process. Why can fork do that, and what does spawn change?

What the interviewer is really testing:
Whether you know what fork really copies and can connect an intermittent hang to inherited lock state.
Answer frame:

What fork copies: all memory, but only the thread that called fork.

The hang: a lock held by another thread at fork time stays locked forever in the child.

Spawn and forkserver: a clean interpreter, at the cost of start time and picklable arguments.

Standard: set the start method explicitly and don't start threads before forking.

Sample spoken answer:

“fork copies the whole process memory but only the thread that called it. If another thread was holding a lock at that moment, say the logging module's lock or one inside a C library, the child inherits that lock in the locked state and no thread will ever release it. The first time the child touches it, it hangs forever. That's why it looks random: it depends on timing. The spawn method starts a fresh interpreter and re-imports the main module, so there's no inherited lock state, but workers start slower, everything sent to them must be picklable, and top-level code in the main module has to sit under the __main__ guard. forkserver sits in between: a clean server process forks the workers. The default differs by platform, so I set the start method explicitly with multiprocessing.get_context and never create threads before forking.”

Red flag to avoid:

Blaming the GIL for the hang, or not knowing that fork copies only one thread.

They may ask next:
  • How would you find out which lock a hung child process is waiting on?
  • What would you put in place so nobody can reintroduce threads before the fork later?
Say it in 60 seconds
Hard Technical round Senior Practice question

9. CPython now has an optional build without the GIL. What would you check before running a large, threaded service on it?

What the interviewer is really testing:
Whether you can judge a runtime change on evidence: who benefits, what dependencies must support, and which hidden races surface.
Answer frame:

Benefit: only CPU-heavy work spread across threads gains; single-threaded code runs somewhat slower.

Extensions: every compiled dependency must support the build, or the GIL can come back on import.

Hidden races: check-then-set and lazy initialisation the GIL used to mask.

Rollout: one service behind a switch, measured against the normal build.

Sample spoken answer:

“First, whether it helps at all. It only pays off if the service does CPU-heavy work across threads. Single-threaded code runs somewhat slower on that build, and IO-bound services already get what they need from threads or asyncio. Second, the compiled dependencies. Each extension has to be built for it and marked as safe; if one isn't, the interpreter can switch the GIL back on when it's imported, and I'd lose the benefit with only a warning to show for it. Third, my own code. Races the GIL used to hide, often by luck, become far more likely: check-then-set on shared dicts, lazy initialisation without a lock, counters shared between threads. So I'd run the test suite under thread stress, audit shared mutable state, and roll it out on one service behind a switch, comparing throughput and latency with the normal build before going wider.”

Red flag to avoid:

Assuming the new build makes every Python program faster, or that existing code needs no review.

They may ask next:
  • How would you confirm at runtime whether the GIL is actually off in a running process?
  • Which kinds of workloads would you still run in separate processes, and why?
Say it in 60 seconds
Hard Technical round Senior Practice question

10. One thread in a service does heavy CPU work in pure Python and another serves network requests. Request latency is terrible, though the network thread barely uses any CPU. What's happening?

What the interviewer is really testing:
Whether you know how threads hand over the GIL, and why a CPU-bound thread hurts an IO-bound one far more than its share of CPU suggests.
Answer frame:

Handover: a thread that wants the GIL waits for the switch interval, then asks the running thread to let go.

Convoy: the network thread gives up the GIL on every blocking call and must win it back each time, so one request pays that wait many times.

Fix: move the CPU work to a process or into C code that releases the GIL; tune the switch interval only with measurements.

Sample spoken answer:

“It's the GIL handover. The network thread releases the GIL every time it blocks on a socket, which is fine, but when data arrives it needs the GIL back to run Python code, and the CPU thread is holding it. A waiting thread doesn't get it straight away: it waits for the switch interval, then asks the running thread to drop it at its next check. One request can do many small reads and writes, and it pays that wait on each one, so latency multiplies even though the network thread uses almost no CPU. People call this the convoy effect. The real fix is to take the CPU work off the GIL: run it in a separate process, or move it into C code that releases the GIL while it computes. sys.setswitchinterval can shorten the wait, but it adds switching overhead for everything, so I'd only touch it with measurements.”

Red flag to avoid:

Saying threads share the CPU fairly, so a thread that barely uses CPU can't be slowed down.

They may ask next:
  • How would you show that this is really happening, rather than guessing?
  • What would happen if the same CPU work ran inside an asyncio event loop instead?
Say it in 60 seconds

Asyncio 2 questions

Hard Technical round Mid-level, Senior Practice question

11. You wrap a blocking call in asyncio.to_thread under a timeout. The timeout fires, but the work carries on, and over time the thread pool fills up. Why?

What the interviewer is really testing:
Whether you know that cancelling the awaiting coroutine can't stop code already running in a thread, and how to bound blocking work properly.
Answer frame:

What gets cancelled: only the await; the function keeps running in its thread until it returns.

Pool exhaustion: abandoned calls still hold worker threads, so new calls queue behind them and time out too.

Fix: give the blocking call its own timeout, use a separate bounded pool, or put work you may need to kill in a process.

Sample spoken answer:

“Cancellation in asyncio only works at an await. When the timeout fires, the coroutine awaiting the thread's result gets cancelled, but the function itself is running in a worker thread, and Python has no safe way to stop a running thread from outside. So the call carries on until it returns, holding its worker the whole time. If the thing it's calling hangs, say a database or network call with no timeout of its own, those abandoned calls pile up, the pool runs out of workers, and new calls sit in the queue and time out without ever starting. It can also slow down or hang shutdown, because the loop waits for the default pool to finish. The fixes: give the blocking call a real timeout at its own level, like a socket or driver timeout; run it in a separate, bounded pool so it can't starve everything else; and if the work truly might need killing, put it in a process.”

Red flag to avoid:

Assuming a timeout around to_thread stops the blocking function and frees its thread.

They may ask next:
  • Why is there no safe way to kill a running thread in Python?
  • How would you notice the pool was saturated before users did?
Say it in 60 seconds
Hard Technical round Senior Practice question

12. A graceful shutdown of your asyncio service hangs until the container kills it. How can code that catches CancelledError, or a broad except, cause that?

What the interviewer is really testing:
Whether you understand that asyncio cancellation is cooperative and how one careless except block breaks shutdown and timeouts.
Answer frame:

Mechanism: cancellation throws CancelledError into the task at its current await.

The bug: code that swallows it keeps running, so whoever awaits it waits forever.

Rules: re-raise after cleanup, clean up in finally, and bound the shutdown wait.

Sample spoken answer:

“Cancellation in asyncio works by throwing CancelledError into the task at the await where it's paused. If code catches it and carries on, the task simply doesn't stop, and whatever awaits it, like the shutdown routine, waits forever. The usual culprit is a retry loop with a broad except. CancelledError derives from BaseException, so except Exception doesn't catch it, but a bare except or except BaseException does, and so does code that catches it on purpose to log something and forgets to re-raise. Timeouts are built on cancellation too, so the same bug makes timeouts misbehave. My rules: if you catch it, clean up and re-raise; put cleanup in finally. For shutdown, I cancel the tasks, wait with a time limit, then log which tasks are still pending by name, so the stuck one is obvious.”

Red flag to avoid:

Recommending except BaseException: pass around a loop to keep a worker alive.

They may ask next:
  • Why doesn't asyncio.shield fully protect an operation during shutdown?
  • How do you make sure the container's stop signal actually starts this shutdown path?
Say it in 60 seconds

Runtime Edge Cases 3 questions

Hard Technical round Senior Practice question

13. A retry helper keeps every caught exception in a list to report at the end, and memory climbs on long runs. Why can a stored exception hold so much?

What the interviewer is really testing:
Whether you know that an exception carries its traceback, and that the traceback keeps every frame and its local variables alive.
Answer frame:

Traceback chain: the exception's __traceback__ points to the frames it passed through, and each frame holds its local variables.

Implicit delete: Python removes the as name at the end of the except block, to break the cycle between the frame and the exception.

Fix: store what the report needs, like the type, message and formatted traceback, not the live exception.

Sample spoken answer:

“An exception object isn't small. Its __traceback__ links to the frames it passed through, and every frame keeps all its local variables alive. So if one of those frames had a big response body or a large list in a local, keeping the exception keeps that too, and chained exceptions through __cause__ and __context__ bring their own tracebacks. That's also why Python deletes the name after except SomeError as e. Otherwise the current frame would point to the exception, the exception to its traceback, and the traceback back to the frame: a reference cycle only the cycle collector could clean up. In a retry helper I'd store just what the report needs: the exception type, the message and the formatted traceback as a string from the traceback module. If I truly need the object, calling with_traceback(None) on it drops the frames.”

Code:
import traceback

failures = []

try:
    call_service()
except ConnectionError:
    failures.append(traceback.format_exc())  # a string, no frames kept
Red flag to avoid:

Saying an exception is just a type and a message, so keeping thousands of them costs nothing.

They may ask next:
  • What does raise ... from None change about what gets stored and printed?
  • How would you prove with tracemalloc that stored exceptions are what holds the memory?
Say it in 60 seconds
Hard Technical round Senior Practice question

14. isinstance(obj, Config) returns False even though the object was built from that very class. How can one module end up loaded twice, and how do you prove it?

What the interviewer is really testing:
Whether you know that the import system caches modules by name, not by file, and can debug identity bugs that come from that.
Answer frame:

Cache by name: sys.modules is keyed by the module's name, so one file imported under two names runs twice and makes two different classes.

Usual causes: a file run as a script and also imported by name, or a path setting that makes a package reachable under two names.

Prove it: compare id() and __module__ of both classes, and search sys.modules for entries with the same __file__.

Fix: one import root, entry points run with -m, and no path hacks.

Sample spoken answer:

“Python caches modules in sys.modules by name, not by file. If the same file gets imported under two different names, it runs twice and creates two separate class objects that just look alike, so an object made by one fails isinstance against the other. Module-level registries and singletons get duplicated too, which is often the first odd symptom. Two causes cover most cases. A file run as a script becomes __main__, and when something else imports it by its real name, it loads a second time. Or the path includes both a project folder and a folder inside it, so the same code is reachable as app.config and plain config. To prove it, I print id() and __module__ for both classes, and look through sys.modules for two entries pointing to the same file. The fix is one import root, entry points started with -m, and removing any code that edits sys.path.”

Red flag to avoid:

Assuming Python never loads the same file twice, so the bug must be in isinstance.

They may ask next:
  • Why can't the __main__ module safely hold a registry that other modules import?
  • How do tests that add folders to the import path make this bug more likely?
Say it in 60 seconds
Hard Technical round Senior Practice question

15. A generator opens a database cursor inside try/finally. The caller breaks out of the loop early. When does that finally actually run?

What the interviewer is really testing:
Whether you know how generators are closed and why relying on garbage collection for resource cleanup is fragile.
Answer frame:

Close: close() throws GeneratorExit at the paused yield, so the finally runs.

No close: it waits for garbage collection, which is prompt in CPython only by accident.

Fix: make the caller own the resource, or wrap the generator in contextlib.closing.

Sample spoken answer:

“The finally runs when the generator is closed. Calling close() throws GeneratorExit into the generator at the paused yield, the finally runs, and the generator ends. If nobody calls close, it happens when the generator object is garbage collected. In CPython that's usually the moment the last reference goes, so it looks immediate and people rely on it by accident. But if the generator is stored somewhere, stuck in a reference cycle, or running on another Python implementation, the cursor can stay open much longer. And if the finally tries to yield again during GeneratorExit, Python raises a RuntimeError. So for resources I don't trust garbage collection. Either the caller owns the resource with a with block around the loop, or I wrap the generator in contextlib.closing. Async generators are worse, because cleanup needs an await, so there I use contextlib.aclosing.”

Code:
from contextlib import closing

with closing(read_rows(conn)) as rows:
    for row in rows:
        if row.is_last:
            break
# the generator's finally has run by this line
Red flag to avoid:

Saying the finally runs as soon as the loop exits, in every case and every implementation.

They may ask next:
  • What do send and throw let a caller do with a generator?
  • How does yield from pass close() through to an inner generator?
Say it in 60 seconds

Performance 1 question

Medium Technical round Mid-level, Senior Practice question

16. Your Python CLI tool takes several seconds just to start. How do you find what is slow at import time, and fix it without breaking anything?

What the interviewer is really testing:
Whether you know how imports cost time, how to measure them, and the risk that lazy imports bring.
Answer frame:

Measure: python -X importtime shows the cost of every import, nested ones included.

Causes: heavy libraries imported at the top, and real work done at import time.

Fix: import heavy modules where they're used, move work into functions, keep a test that imports everything.

Sample spoken answer:

“I'd start with python -X importtime, which prints how long each import took, including nested ones, to stderr. Sorting that usually points to one or two heavy modules, often a big numeric library or a cloud SDK pulled in at the top of a module that most commands never use. Then there's work done at import: reading config files, compiling large regexes, opening connections, building big lookup tables. Fixes, in order: move heavy imports inside the functions that need them, move import-time work into functions that run on demand, and split the CLI so each subcommand loads only its own code. I'm careful with lazy imports, because they move an ImportError from startup into the middle of a run, so I keep a test that imports every command module. Then I track start time in CI so it doesn't creep back.”

Red flag to avoid:

Guessing at the cause and rewriting code without measuring the imports first.

They may ask next:
  • How do import-time side effects make a codebase harder to test?
  • What does Python cache between runs to speed up imports, and when can that cache get in the way?
Say it in 60 seconds

Architecture 4 questions

Hard System design round Senior Practice question

17. The test suite of a large Python monorepo now takes over an hour in CI, and every team waits on it. How would you redesign how tests run so most changes get feedback in minutes?

What the interviewer is really testing:
Whether you can fix a shared engineering bottleneck for the whole codebase: measure it, run less, run in parallel, and keep trust in the results.
Answer frame:

Measure: find the slowest tests and fixtures, and the flaky tests that force re-runs.

Run less: use the import graph between packages to run only the tests a change can affect, with the full suite on main.

Run in parallel: shard across workers, which first means tests share no global state, fixed ports or database rows.

Keep trust: quarantine flaky tests with an owner, and treat anything the selective run missed as a bug in the selection.

Sample spoken answer:

“First I'd measure. pytest's durations report and the CI timings usually show a small set of slow tests, and often fixtures that rebuild a database or load big files for every test when once per session would do. Flaky tests matter too, because each retry costs a whole run. Then two structural changes. Run less: with clear package boundaries I can build the import graph and run only the tests for packages a change touches plus whatever depends on them, while the full suite still runs on main. Run in parallel: shard tests across workers and machines. That only works when tests don't share global state, fixed ports or the same database rows, so part of the job is giving each worker its own database. Last, trust: flaky tests go into quarantine with a named owner, and anything the selective run misses gets caught on main and treated as a selection bug.”

Red flag to avoid:

Just buying bigger CI machines, or deleting slow tests without knowing what they cover.

They may ask next:
  • How do you stop the selective run from quietly skipping a test that should have run?
  • What would you do about tests that need a real external service?
Say it in 60 seconds
Medium Technical round Senior Practice question

18. How would you bring type checking to a large, untyped Python codebase shared by many teams, without stopping feature work?

What the interviewer is really testing:
Whether you can roll out a standard across a big codebase gradually, ratcheting it tighter without blocking the teams who ship on it.
Answer frame:

Baseline: run the checker in CI leniently, freeze existing errors, fail only on new ones.

Boundaries first: type the public interfaces of shared libraries, because every caller benefits at once.

Tighten: stricter settings module by module as teams finish; new code fully typed.

Debt: track Any and ignore comments as visible numbers with owners.

Sample spoken answer:

“I'd go gradually, never in one big change. First, run the checker in CI across the whole codebase in a lenient mode, and record today's errors as a baseline, so only new errors fail the build. Then I'd start at the boundaries: the public functions of shared libraries and core domain code, because every team calling them gets better checking straight away. Structural types help there, since they describe what a function needs without touching old class hierarchies. As each team finishes a module, it switches to stricter settings, and new code has to be fully typed from day one. For untyped third-party libraries I add stubs or wrap them in a small typed layer. I report how many modules are strict as a visible number, and treat Any and ignore comments as debt with an owner, so the rollout keeps moving after the first push.”

Red flag to avoid:

Proposing to type the whole codebase in one pass, or making the checker block every merge on day one.

They may ask next:
  • How do you show teams that typing is worth their time, with evidence rather than opinion?
  • How do you stop ignore comments from piling up once the rollout is done?
Say it in 60 seconds
Hard System design round Senior Practice question

19. You're setting the dependency rules for dozens of Python services. How do you protect them from supply-chain attacks, like a same-named or look-alike package?

What the interviewer is really testing:
Whether you understand how Python packages get installed and where an attacker can get in, and can set rules a whole organization follows.
Answer frame:

One source: install only through an internal index that proxies the public one, never with the public index added alongside it.

Pinned and hashed: lock files with exact versions and hashes, so CI and production install exactly what was reviewed.

Install-time code: prefer wheels, because building from a source package runs its build code on your machine.

New dependencies: a light review for maintenance and typo-squatted names, plus scans of every lock file.

Sample spoken answer:

“The first risk is dependency confusion. If our installer looks at our private index and the public one side by side, someone can publish a public package with the same name as an internal one and a higher version, and the installer may pick theirs. So every install goes through one internal index that proxies the public one, and we reserve our internal names publicly too. Second, lock files with exact versions and hashes, installed in hash-checking mode, so a swapped file or an unreviewed upgrade fails the build instead of shipping. Third, install-time code: installing from a source package can run its build script, so I prefer wheels and build the few exceptions in an isolated job. Fourth, a new dependency gets a quick review: is it maintained, is the name exactly right, do we need it at all. And every lock file is scanned for known vulnerabilities, with small automated update pull requests so fixes actually land.”

Red flag to avoid:

Letting each team install whatever resolves on the day from any index, with no lock files or hashes.

They may ask next:
  • What should a shared library pin, compared with what a service should pin?
  • How do you get an urgent security fix into every service within a day?
Say it in 60 seconds
Hard Situational round Senior Practice question

20. A library that dozens of your services depend on has been abandoned by its maintainer, and a security issue has just been reported in it. What do you do?

What the interviewer is really testing:
Whether you can handle an urgent risk and a long-term ownership decision at the same time, across many teams.
Answer frame:

Contain now: work out whether you're exposed, and patch or mitigate within days.

Know the footprint: which services use it, which parts of it, and through which other packages.

Decide the future: adopt or fork it, vendor a small piece, or replace it, based on how much you use and how hard a swap is.

Prevent: check maintenance health when a dependency is added, not after it dies.

Sample spoken answer:

“I'd run two tracks. The urgent one: read the report and work out whether our use of the library actually reaches the vulnerable code. If it does, we patch it ourselves in a fork published to our internal index, move every service onto that build, and ship within days, with a mitigation at the edge if we need more time. The second track is the real decision. I'd map which services use it, which features, and whether it comes in indirectly through other packages. If we only use a small piece, we vendor or rewrite that piece. If it's central and well built, we adopt it properly, give engineers time to maintain it, and offer our fixes back or ask to become maintainers. If there's a healthy alternative, we plan a migration with a codemod and move team by team. And I'd add a maintenance check to how we approve new dependencies.”

Red flag to avoid:

Waiting for someone upstream to fix it, or ordering every team to rip it out this week.

They may ask next:
  • How would you decide between maintaining a fork and replacing the library?
  • How do you fund maintenance time when no team's roadmap includes it?
Say it in 60 seconds

Leadership 3 questions

Medium Behavioral round Senior Practice question

21. Tell me about a migration that touched code owned by many teams, like moving off Python 2 or an in-house framework. How did you get the long tail finished?

What the interviewer is really testing:
Whether you can drive a change you don't own end to end: make progress visible, make each team's part small, and personally handle the last stubborn pieces.
Answer frame:

Make it measurable: a check per package in CI, and a dashboard of what's done.

Make it cheap: codemods and a short playbook, so each team's share is small.

Ratchet: once a package passes, CI stops it from slipping back.

Long tail: take on orphaned code yourself, and delete what nobody uses.

Sample spoken answer:

“At a previous company I led moving a large codebase from Python 2 to 3, across about fifteen teams. The hard part wasn't syntax, it was the line between bytes and text, which only broke at runtime. I set up CI to run every package's tests on both versions and published a dashboard of which packages passed. We wrote codemods for the mechanical changes and a short playbook for the bytes and text boundaries, so each team's share was a few focused days. Once a package passed on Python 3, CI stopped it from going back. That got us most of the way in two quarters. The long tail was code with no clear owner, so I took several of those packages myself, paired with the last two teams, and we deleted a surprising amount of code nobody used. The lesson: migrations stall in the last stretch, so plan people for it from day one.”

Red flag to avoid:

A big-bang switch over a weekend, or a mandate with a deadline and no help for the teams doing the work.

They may ask next:
  • How did you get teams to give it time next to their own roadmap?
  • What did you do with a service whose owners had all left?
Say it in 60 seconds
Hard Behavioral round Senior Practice question

22. Tell me about a big technical bet in a Python system that you had to roll back. What did you misjudge, and what do you do differently now?

What the interviewer is really testing:
Whether you own a failure honestly, understand its technical cause, and changed how you make decisions afterwards.
Answer frame:

The bet: what you changed and what you expected.

What went wrong: the real technical cause, not bad luck.

Your part: the judgement you got wrong.

Change: how you run bets now.

Sample spoken answer:

“A few years ago I led splitting our Python monolith into about eight services, expecting teams to ship independently. Within months things were worse. One page load now fanned out into a dozen network calls, so latency went up, and a shared models library drifted to different versions across services, which caused subtle data mismatches between them. Deploys got more coordinated, not less, because the services were still tightly coupled; we'd just moved the coupling onto the network. We merged five of them back into one codebase. What I misjudged was the reason to split: we cut along technical layers instead of team ownership and real scaling needs, and we never tested whether the boundaries held inside one process first. Now I enforce module boundaries inside the monolith before any split, and a piece only becomes a service when it has its own owners or scaling needs.”

Red flag to avoid:

Blaming the framework or the team, with nothing you would do differently yourself.

They may ask next:
  • How did you explain merging services back to the people who had backed the split?
  • What signals now tell you a module is ready to become its own service?
Say it in 60 seconds
Medium Culture fit round Senior Practice question

23. You're hiring senior Python engineers. What would your interview loop test, and how do you tell a senior answer from a mid-level one?

What the interviewer is really testing:
Whether you know what senior work actually is and can build a fair process that tests for it.
Answer frame:

Real work: a practical coding task and a debugging task on realistic code, not puzzles.

Judgement: a design conversation about a system they built, pushing on trade-offs.

Influence: review, mentoring and disagreeing with a plan.

Fairness: a written rubric before the first interview.

Sample spoken answer:

“I'd test what the job actually needs. One practical coding session on realistic code, like extending a small service with tests, instead of puzzles. One debugging session: a failing test or a slow endpoint, where I watch how they narrow it down. One design conversation about a system they've built, where I push on the trade-offs. And one conversation about influence: code review, mentoring, disagreeing with a plan. The difference I look for: a mid-level engineer gives a correct answer, while a senior asks about the constraints, names the trade-off and says what they'd measure. For Python specifically, a senior knows where the language surprises people, like blocking calls in async code or memory that doesn't come back, and has a story about each. I write the rubric before the first interview, so the whole panel scores the same things.”

Red flag to avoid:

Judging seniority by trivia recall or by how fast someone solves a puzzle.

They may ask next:
  • How do you keep a take-home task fair to people with little free time?
  • What would make you say no to a candidate who solved every coding problem?
Say it in 60 seconds
Were you asked something else? Share it A person checks every question before it goes on the site. No name is shown.
For the call itself

You practiced these. On the real call, ClapAssist helps with the rest.

ClapAssist is an AI interview assistant for Mac and Windows. It listens to the interview on your computer and shows you what to say, in short lines you can read while you talk. Your live interview audio and screen are never stored. Your resume and notes are saved to your account so the app fills them in on any computer. It stays out of screen share on every plan, including Free; only you can see it.

Download with 10 free minutes
Mac and Windows · Stays out of screen share · No card