Skip to content
>_ StartupJobList_

Interview questions · Worked answers

Python interview questions and worked answers

Prepare to explain how Python handles objects, iterators, resource cleanup and concurrent work. Work through concrete failure cases, test the reasoning, then use the answered follow-ups to explore the trade-offs.

Backend Python developers · Covers object semantics, iteration, cleanup, concurrency, typing, testing and profiling. CPython-specific and version-dependent behavior is identified where it matters.

When does changing one list change another?

Assignment creates another name for the same object. A shallow copy creates a new outer list or dictionary, but its elements still refer to the same nested objects. If the new result must be independently editable, copy the data at the boundary where ownership changes, or build new records explicitly.

A function receives a list of records, makes a “copy” for a filtered result, and then annotates one nested record. Explain when the original changes and show a safe approach.

Python variables bind names to objects; they do not contain the objects. copy.copy() copies one compound container, while copy.deepcopy() recursively copies its reachable contents, using a memo to handle cycles. Deep copying can duplicate too much or interact badly with custom objects, so it is not a universal fix. A mutable default argument has the same shared-object issue: Python evaluates the default once when the function is defined, not once per call.

python · Worked example
rows = [[]] * 3
rows[0].append("x")
# rows is [['x'], ['x'], ['x']]

This creates three references to one inner list: Use a fresh inner list for each row: rows = [[] for _ in range(3)]. Similarly, prefer def add_tag(tags=None): ...; tags = [] if tags is None else tags over def add_tag(tags=[]) when each call needs its own list. A shallow copy of [{"tags": []}] still shares the nested tags list; copying only the outer list will not isolate that mutation.

Illustrative, not executed.

Follow-up: When is a shallow copy enough?

When only the outer collection is mutated and the contained values are immutable or intentionally shared. For nested mutable data, explicitly decide which layer is owned, then copy or reconstruct that layer. If the object graph contains file handles, caches, or custom classes, define the ownership contract instead of blindly calling deepcopy().

Read the source: Python copy operations · default argument values · programming FAQ on shared defaults

↑ Back to questions

What can you safely assume about an iterator?

An iterator is a forward-only stream unless its type documents extra behavior. Consuming it advances its state; it is not automatically rewindable or reusable. If the function needs two passes, it must either accept a repeatable container, materialize the items within a justified memory bound, or perform both calculations in one pass.

A function accepts an iterable of events. It counts matching events and then returns the matching events. A caller passes a generator. Why can the second operation return nothing, and what would you change?

An iterator supplies __next__() and signals exhaustion by raising StopIteration. A generator suspends at each yield and resumes from that point. Generator expressions and functions can keep memory use low by producing values on demand, but laziness also means errors and side effects occur during iteration, not at construction. An iterator may represent an unbounded stream, so blindly calling list() may never finish or may exhaust memory.

python · Worked example
def matching_events(events):
    count = sum(event.kind == "error" for event in events)
    matches = [event for event in events if event.kind == "error"]
    return count, matches

For a generator, the first pass exhausts it and matches is empty. A one-pass version can accumulate both outputs. If the caller needs every event and the stream is known to be small, converting once to a list is reasonable. If it may be large, use a single pass or change the API to receive a factory that creates a fresh iterator.

Illustrative, not executed.

Follow-up: Can itertools.tee() make a generator reusable?

It creates multiple iterators by buffering values as consumers get out of step. That can be useful when the lag is bounded, but it may retain a large amount of data; it does not make an infinite stream free to duplicate. Prefer one-pass logic when practical.

Read the source: Iterator protocol and functional iteration · iterator and generator data model · itertools.tee

↑ Back to questions

Who closes a resource when iteration stops early?

Put resource lifetime under an explicit context manager. A with block calls the manager’s exit method when control leaves the block, including when an exception occurs. Merely yielding a resource-backed generator does not make early consumer termination a clear ownership boundary; the consumer should close it or the API should keep iteration inside the resource’s context.

A generator reads a large file and yields parsed records. A caller stops after the first matching record. What guarantees cleanup, and what does not?

A context manager’s __exit__() runs after a successful __enter__(), whether the suite finishes normally or raises. Its return value controls whether an exception is suppressed. A generator can use try/finally, but the finally runs when the generator is closed or otherwise finalized, so relying on garbage collection timing is a weaker contract than explicit closure. For multiple dynamically acquired resources, contextlib.ExitStack registers cleanup as resources are acquired.

python · Worked example
with open(path, encoding="utf-8") as source:
    for line in source:
        record = parse(line)
        if is_match(record):
            break

A safer shape keeps the file open only while the consumer is in the loop: A generator that opens the file internally and yields records can leave ownership unclear if a caller breaks early and retains the generator. Make the generator itself a context manager, document and enforce close(), or let the caller own the with open(...) block. Do not catch every exception in a cleanup method and return true: that silently suppresses failures.

Illustrative, not executed.

Follow-up: What if some resources are optional or discovered in a loop?

Use ExitStack and enter each resource as it becomes known; the stack unwinds them in reverse order if acquisition or processing fails.

Read the source: The with statement semantics · contextlib.ExitStack · generator closing methods

↑ Back to questions

What should an asyncio coroutine do when cancellation arrives?

Put cleanup in finally. If code catches asyncio.CancelledError to add cleanup or logging, it should generally re-raise the exception. Cancellation is cooperative: Task.cancel() requests cancellation, and the exception is raised at a later opportunity, usually an await. Swallowing it can interfere with TaskGroup and asyncio.timeout(). A finally block ensures cleanup is attempted, not that an awaited cleanup always completes: another cancellation can interrupt it, and an exception from cleanup can replace the exception already in flight (with the earlier exception retained as context). Give cleanup a bounded, idempotent policy and decide explicitly how to report cleanup failure.

A request handler awaits a remote operation while holding a temporary resource. The caller times out and cancels the task. How should the coroutine clean up without defeating cancellation?

Cancellation is not an asynchronous kill signal that can safely interrupt any instruction. The task gets a chance to run cleanup. CancelledError directly subclasses BaseException, so except Exception generally does not swallow it. Structured-concurrency helpers use cancellation internally to shut down related work and report errors; suppressing cancellation without understanding task state breaks that coordination.

asyncio.TaskGroup and asyncio.timeout() require Python 3.11 or later. On an older supported runtime, choose APIs available there and keep cancellation propagation explicit.

python · Worked example
async def fetch_with_cleanup(resource):
    try:
        return await resource.fetch()
    finally:
        await resource.close()

A failure pattern is except asyncio.CancelledError: return None: the task now appears to have completed normally, while its caller or task group expects cancellation to propagate. If you must catch it, perform bounded cleanup and raise it again. Even with finally, cancellation during an awaited close() can interrupt that close; a cleanup exception can also become the exception the caller sees. Make cleanup idempotent, bound how long it may wait, and preserve/log the original failure according to the application’s policy.

Illustrative, not executed.

Follow-up: Does cancel() prove the task is cancelled?

No. The coroutine can delay or suppress the request. The caller can await the task and handle CancelledError; task groups and timeout scopes provide structured ways to manage child tasks and deadlines.

Read the source: Asyncio task cancellation and CancelledError · TaskGroup · async context managers

↑ Back to questions

Does adding threads make Python CPU work run on more cores?

On standard GIL-enabled CPython, threads are useful for overlapping I/O, but usually do not run pure-Python bytecode in parallel across cores. For CPU-bound work, measure a process pool or move the computation to an implementation that releases the GIL. asyncio is useful for many I/O operations in one event loop when the libraries are awaitable; it does not make CPU work parallel by itself.

A service has many independent network waits and one CPU-heavy pure-Python transformation. Which concurrency model would you try first, and what version details matter?

This statement is about CPython’s usual GIL-enabled build, not every Python implementation or every extension. Starting with Python 3.13, CPython offers an optional free-threaded build. It is not the default, and importing a C extension that is not marked as free-threading-safe may enable the GIL. The free-threaded mode has compatibility and performance tradeoffs; check the actual interpreter and dependency set. Even with a GIL, do not treat compound shared-state operations as a synchronization guarantee. Use locks, queues, or message passing when correctness depends on ordering.

Python · Worked example
import asyncio

async def fetch_all(urls, fetch):
    return await asyncio.gather(*(fetch(url) for url in urls))

Illustrates overlapping awaitable I/O only; it does not make CPU-bound Python work parallel. No benchmark is claimed.

Illustrative, not executed.

Follow-up: How would you verify which interpreter mode is active?

Record the exact Python build and runtime configuration. On free-threaded CPython, the docs describe checking sysconfig.get_config_var("Py_GIL_DISABLED") for build support and sys._is_gil_enabled() for the active GIL state. Also test whether imported extensions keep the GIL enabled. Do not infer free-threading from the minor version alone.

Read the source: Threading and the GIL · free-threaded Python support and extension behavior · asyncio overview

↑ Back to questions

When is a protocol better than checking a concrete class?

State the operations the function needs, not the concrete class that happens to provide them. If it only iterates, accept an iterable of records. If it needs a custom capability, define a small protocol or abstract base class. A protocol lets static type checkers recognize structural compatibility without requiring inheritance.

A function streams records from a source. The current implementation accepts only list[Record], but callers may use a tuple, file reader, or generator. How would you express the contract?

Python’s runtime data model defines behavior through methods such as __iter__ and __next__; the for statement uses that protocol. typing.Protocol describes a structural interface to static type checkers. Type annotations do not validate input at runtime, so external or untrusted data still needs explicit parsing and validation. @runtime_checkable protocols check for attribute presence, not full signatures or semantic correctness.

python · Worked example
from collections.abc import Iterable

def count_valid(records: Iterable[Record]) -> int:
    return sum(record.is_valid() for record in records)

A concrete list[Record] annotation rejects useful iterators to a type checker even though the function only needs iteration. At the other extreme, accepting object and probing arbitrary attributes hides the true contract. Use the narrowest behavioral interface the function actually requires. If records come from JSON, convert and validate them at the boundary; Iterable[Record] does not make a JSON dictionary into a Record.

Illustrative, not executed.

Follow-up: Should I use isinstance(value, SomeProtocol) at runtime?

Only when a shallow presence check is genuinely useful. A runtime-checkable protocol does not verify method signatures, return types, or behavior, so it is not a substitute for validation or a focused test.

Read the source: Python data model and iterator protocol · typing protocols · annotations in the language reference

↑ Back to questions

What do type annotations guarantee at runtime?

The annotation will not validate decoded JSON. Parse untrusted data into a known shape at the input boundary, reject values that do not meet the endpoint’s contract, and then pass a validated value into typed internal functions. A static checker can check code paths, but it does not inspect a request at runtime.

A Python endpoint accepts JSON with a values field, then totals it. The team annotates the parsed value as list[int]. What happens if the payload contains strings or booleans, and where should the contract be enforced?

json.loads() returns ordinary Python values such as dictionaries, lists, strings, numbers, booleans, and None. An annotation documents what a function expects; Python does not automatically compare each runtime value with that annotation. TypedDict can describe dictionary keys to a static checker, but a decoded dictionary still needs validation. In Python, bool is a subclass of int, so a boundary that accepts JSON integers but rejects JSON booleans should check the exact type or use a schema validator with that policy.

python · Worked example
def total_values(payload):
    if type(payload) is not dict or "values" not in payload:
        raise ValueError("values is required")
    values = payload["values"]
    if type(values) is not list or any(type(value) is not int for value in values):
        raise ValueError("values must be a list of integers, excluding booleans")
    return sum(values)

assert total_values({"values": [3, 4]}) == 7
for invalid in (["3", "4"], [True], None):
    try:
        total_values({"values": invalid})
    except ValueError:
        pass
    else:
        raise AssertionError("invalid values accepted")

The validator checks the container and every element before calling sum. Exact int checks reject booleans, which an isinstance(value, int) check would accept. Missing values, strings and an invalid container produce a controlled ValueError. This contract permits an empty list, whose total is zero; a product requiring at least one value would need an explicit nonempty check.

Executed with Python 3.9.6: integer total and rejection of strings, booleans and an invalid container passed.

Follow-up: Does validation make type hints unnecessary?

No. Validation protects the boundary and gives controlled errors; annotations make the trusted internal contract visible and allow static tools to catch mistakes in code that calls it. Use both for different jobs.

Read the source: Typing support and runtime behavior · TypedDict behavior · JSON decoding · Python numeric types and bool

↑ Back to questions

How do you test a function that calls an external dependency?

Separate the transformation from the I/O boundary where practical. Test the transformation with ordinary inputs and edge cases. For the orchestration layer, replace the dependency at the name the code looks up, then assert the meaningful call and result. Keep at least one integration test for the real adapter if the project can run it safely.

A function reads a record from a client, transforms it, and saves the result. How would you test behavior without making a real network call?

Unit tests should make behavior and failures observable, not mirror every implementation line. A fake can be simpler than a mock when it models a small contract. When using unittest.mock.patch, patch the symbol in the module under test’s namespace: from client import fetch binds a name there, so patching the original client.fetch later may not replace that bound reference. Isolate fixtures so one test cannot contaminate another.

Python · Worked example
from unittest.mock import patch

with patch("service.fetch", return_value={"id": 7}):
    result = service.load(7)

Patch the name looked up by service.load. This shape is illustrative and was not executed.

Illustrative, not executed.

Follow-up: Should every test mock the database or network?

No. Use mocks for narrow boundary behavior and errors; use a controlled integration test to verify the adapter and configuration together. A pile of mocks can pass while the real wiring is broken.

Read the source: unittest test cases and fixtures · unittest.mock and where to patch

↑ Back to questions

How do you decide what to optimize?

Define the slow user-visible operation, measure a representative workload, and profile it to locate the expensive work. Then make one change and repeat the same measurement. A shorter expression is not evidence of faster behavior.

A data-processing endpoint is slow. A teammate wants to replace a loop with a compact comprehension and claims it will be faster. What evidence do you gather first?

Profilers help attribute time and call counts, but profiling overhead changes execution. cProfile is useful for locating time in Python call paths. timeit is designed for measuring small snippets more fairly, with repeated runs. A microbenchmark can miss I/O, allocation, cache, input-size, and concurrency effects from the real endpoint. Track both latency and resource use when relevant.

shell · Worked example
python -m cProfile -s cumulative app.py

An example command for locating Python call-path costs. It is not a benchmark and was not run for this guide.

Illustrative, not executed.

Follow-up: Which tool should I use?

Use a profiler to locate where an application spends time, then timeit or a benchmark harness for a narrow alternative. Do not use profiler output as a precise benchmark between Python code and C extensions because instrumentation overhead differs.

Read the source: Python profiler guidance · timeit · performance measurement notes in timeit

↑ Back to questions

How do you make a Python project reproducible on another machine?

Make the supported Python version and dependency set explicit, isolate project packages in a virtual environment, and document a repeatable install and test command. Keep the environment directory out of source control; commit the project’s dependency metadata or lock file according to its packaging tool.

A script works on one laptop but fails on a teammate’s machine because package versions differ. What would you check and change?

venv creates an environment with its own site-packages, isolated from the base installation by default. It is disposable and should be recreated, not copied or committed. A virtual environment does not by itself pin dependency versions or guarantee cross-platform equivalence; the project still needs a declared dependency policy and tested setup instructions.

shell · Worked example
python -m venv .venv
. .venv/bin/activate
python -m pip install -r requirements.txt
python -m pytest

A possible POSIX workflow when the project uses requirements.txt and pytest. Activation and file names depend on the project; no environment was created for this guide.

Illustrative, not executed.

Follow-up: Does activation make the environment reproducible?

No. Activation changes which interpreter and tools the shell finds; it does not freeze dependency versions. Reproducibility comes from explicit interpreter constraints, dependency declarations or lock files, and a tested install procedure.

Read the source: venv creation and isolation · Python tutorial on virtual environments and packages · module installation overview

↑ Back to questions

Salary context

Advertised salaries for these skills

These are Hacker News job posts that mention these technologies, not employee earnings or a measure of what a skill adds to pay. The amounts summarize annualized advertised minimums in approximate USD. Groups can overlap and differ in roles, locations and dates.

Python posts

377 posts · 377 usable advertised salary amounts

2024-06-04 to 2026-10-14

$150,000

Median advertised minimum, approximate USD/year

Middle 50%: $110,000–$170,000

Explore Python salaries → Browse matching job posts →
Currency and calculation basis

Approximate USD using ECB daily reference rates as of 2026-10-09. Original currencies and offer ranges remain on the source listings. These figures cover posts tagged Python, rather than every job in the broader technology family.

↑ Back to questions

Keep exploring