FastAPI Interview Questions: Debugging Async Endpoints, Dependencies, and Tests

Practice FastAPI interview questions by fixing a blocking endpoint, isolating dependency overrides, and testing validation, errors, and resource cleanup.

Author: PracHub

Published: 9/9/2026

FastAPI Interview Questions: Debugging Async Endpoints, Dependencies, and Tests

September 9, 2026

Quick Overview

Debug an original job-status service with executed checks for worker-thread offloading, HTTP contracts, dependency overrides, and lifespan cleanup.

Software EngineerFree

FastAPI interview questions are easier to answer when you can trace a request through the framework. An async def endpoint can still call blocking code, a fake dependency can survive into another test, and a missing record can become a server error if the response contract is wrong.

Use PracHub's FastAPI CRUD implementation exercise as a follow-up. First, work through this original job-status service and explain which check would reject each broken behavior.

Evidence boundary: Framework behavior below comes from current official documentation. The service, debugging questions, test probes, and results are original PracHub practice. They are not a particular employer's interview sequence or leaked assignment.

Trace a FastAPI request from the async endpoint through worker execution and a typed response

Why does this async endpoint still block?

Imagine a job-status endpoint backed by an existing synchronous repository. The first implementation calls repo.get(job_id) directly inside async def. If that method waits on blocking I/O, the event-loop thread waits with it. The coroutine declaration does not transform an ordinary function call into asynchronous work.

Official framework behavior: FastAPI dispatches normal def path operations and dependencies through a thread pool. An ordinary helper that you call yourself does not receive that automatic treatment simply because it is a function. FastAPI concurrency documentation

There are several reasonable repairs. Use an asynchronous client and await its operation, make an entirely synchronous route a normal def, or explicitly offload the synchronous operation while keeping the surrounding route asynchronous. Choose according to the actual library and resource constraints.

Our exercise uses explicit offloading. The repository returns an in-memory result so the example remains self-contained; it stands in for a synchronous I/O boundary. We test which thread executes it, rather than pretending a tiny dictionary lookup is a meaningful latency benchmark.

Thread offloading is not a general solution for CPU-heavy Python work. It also does not make a non-thread-safe client safe to share. Before moving a real database session or SDK call across threads, check that resource's ownership and concurrency rules.

Establish the job-status contract first

A successful request for job 1 returns {"job_id": 1, "status": "ready"}. A positive but unknown ID returns 404 with a defined detail. Zero and non-integer IDs fail request validation. Internal repository fields must not appear in the success response.

The broken version has two independent defects: it calls the synchronous repository directly, and it returns an error dictionary through a success response model. The second defect is not repaired by moving work into a thread.

Here is the complete original app. The broken switch retains both paths so the same tests can reject the initial behavior and accept the repair. In a real patch, remove the obsolete path after the regression is established.

from contextlib import asynccontextmanager
from typing import Annotated
from fastapi import Depends, FastAPI, HTTPException, Path, Request
from pydantic import BaseModel
from starlette.concurrency import run_in_threadpool


class Repository:
    def __init__(self):
        self.closed = False

    def get(self, job_id):
        return {"job_id": 1, "status": "ready"} if job_id == 1 else None

    def close(self):
        self.closed = True


class Job(BaseModel):
    job_id: int
    status: str


async def get_repository(request: Request):
    request.app.state.trace.append("dependency-enter")
    try:
        yield request.app.state.repository
    finally:
        request.app.state.trace.append("dependency-exit")


def create_app(broken=False):
    @asynccontextmanager
    async def lifespan(app):
        app.state.repository = Repository()
        app.state.trace = ["startup"]
        try:
            yield
        finally:
            app.state.repository.close()
            app.state.trace.append("shutdown")

    app = FastAPI(lifespan=lifespan)

    @app.get("/jobs/{job_id}", response_model=Job)
    async def read_job(
        job_id: Annotated[int, Path(gt=0)],
        repo: Annotated[Repository, Depends(get_repository)],
    ):
        if broken:
            row = repo.get(job_id)
        else:
            row = await run_in_threadpool(repo.get, job_id)
        if row is None:
            if broken:
                return {"error": "job not found"}
            raise HTTPException(status_code=404, detail="job not found")
        return row

    return app

The Job model describes the public success shape. Official behavior: FastAPI uses response models to validate and filter returned data. A return value that cannot satisfy the declared output is a server-side implementation problem, not invalid input supplied by the caller. Response model documentation

For an unknown job, the broken branch returns an object without the required success fields. In our executed test that produces HTTP 500 when server exceptions are converted to responses. The repaired branch raises HTTPException with 404 and detail="job not found".

Official error handling: HTTPException is raised, not returned as a normal value. It interrupts request handling and produces the configured HTTP error response. FastAPI error handling

Prove the synchronous boundary moved off the loop

A status-only test does not detect the execution defect. Both versions can return the correct job when the repository is fast. Instead, replace the repository with a probe that records the event-loop thread and rejects execution on that same thread.

async def scenario():
    loop_thread = threading.get_ident()

    class Probe:
        def get(self, job_id):
            assert threading.get_ident() != loop_thread
            return {"job_id": job_id, "status": "ready"}

This is the central assertion from the full test, not a standalone program. The harness supplies Probe through the original dependency key, starts the app's lifespan, and sends a request through HTTPX's ASGI transport in the same event loop.

The broken path fails that assertion. The repaired path runs the repository call in a worker and passes. This establishes the execution boundary for this request; it does not establish maximum throughput, freedom from pool saturation, or safe cancellation of every underlying operation.

If an interviewer asks whether the service is now “fast,” explain what remains unmeasured. You would need representative I/O, concurrency, pool limits, timeouts, and workload characteristics. A deterministic regression check and a load test answer different questions.

Who owns the repository, and who only borrows it?

The application lifespan creates one repository and closes it on shutdown. The request dependency yields that repository and records entry and exit. It deliberately does not close the application-owned object after every request.

This distinction prevents a subtle repair from creating another bug. A cleanup block is not automatically correct: closing a shared client in request teardown can break the next request. Conversely, never closing a resource created for each request can leak resources.

Official lifecycle detail: Current FastAPI documentation distinguishes yield dependencies with request scope from function scope. Request scope is the default for a yield dependency and ends after the response is sent; function scope ends before sending the response. Cleanup timing should be described with its scope, especially for streaming. Dependencies with yield

Our test observes dependency exit after a completed ordinary request and repository closure after leaving the client context. It does not measure first-byte timing or establish streaming behavior. If the endpoint later streams from a resource, revisit how long that resource must remain available.

Application startup and shutdown own the repository while each request borrows it through a dependency

Replace dependencies without leaking test state

Official test mechanism: app.dependency_overrides maps the original dependency callable to its replacement callable. The key is the function object registered in Depends, not the string name of the function and not the repository class merely because it has a matching type. Testing dependency overrides

In our test harness, use a context manager that restores the previous mapping even if a test assertion or request raises:

@contextmanager
def override(app, replacement):
    previous = app.dependency_overrides.copy()
    app.dependency_overrides[get_repository] = replacement
    try:
        yield
    finally:
        app.dependency_overrides.clear()
        app.dependency_overrides.update(previous)

The copied mapping matters when another fixture already installed an override. Blindly clearing everything would remove that fixture's state. This helper assumes tests do not concurrently mutate the same application object; create separate app instances when isolation requires it.

To expose leakage, make the fake return status fake, deliberately raise inside the override context, and then request job 1 again. The second request must return ready, and the override dictionary must match its earlier state.

Without restoration, the later request can pass for the wrong reason because it still uses a fake. A suite that only tests each endpoint once may miss this. The useful check crosses the cleanup boundary instead of merely checking that the fake was called.

Start lifespan in the test that depends on it

Official testing guidance: Use TestClient as a context manager when the test needs application lifespan to run. Creating a client object alone is not the same lifecycle assertion. Testing lifespan events

Our synchronous tests use with TestClient(app) as client. Inside the block, the repository exists and is open. After the block, its closed flag is true and the trace ends with shutdown. The async thread probe explicitly enters app.router.lifespan_context(app) before using ASGI transport.

The request trace for a successful call is startup, dependency-enter, dependency-exit; application shutdown happens afterward. These observations make the resource boundary visible. They do not prove a real connection pool was drained or every background task was stopped, because this exercise has neither.

When a test says that app.state.repository is missing, inspect initialization before mocking around the error. A missing startup step can be a harness defect, while a missing production initialization path is an application defect. Replacing both with a global fake hides the distinction.

Verify errors and output shape independently

Use a small request matrix so each assertion has a purpose:

Request or conditionExpected check
/jobs/1200 and exactly the two public fields.
/jobs/99404 and the agreed detail message.
/jobs/0422 with the error located at the path ID.
/jobs/nope422 for an invalid integer path value.
Fake returns an internal fieldThat field is absent from the response.
Override context raisesNext request uses the original dependency.

Avoid asserting the entire default validation payload unless that full structure is part of your compatibility contract. Error wording and metadata can change with library versions. Here the test checks the status and the location of the invalid field.

Likewise, do not catch every exception and return 404. A missing record, a failed network call, and an output-validation bug need different handling. Catching broadly can turn an operational incident into a misleading “not found” response and erase useful debugging evidence.

Executed results: The fixed application passed seven tests. The broken path passed five and failed two: the missing-job response and the off-loop execution assertion. The run used Python 3.12, FastAPI 0.141.1, Starlette 1.6.0, Pydantic 2.13.5, HTTPX 0.28.1, and pytest 9.1.1.

The environment emitted deprecation warnings concerning HTTPX's use in Starlette TestClient and an AnyIO portal alias. They are retained in the verification log rather than hidden. These are the tested versions for this article, not a promise that every future dependency combination behaves identically.

Five questions for another implementation

These records provide related practice across companies. The original job-status service above does not reproduce their assignments.

PracHub questionPractice focus
Implement FastAPI CRUD endpoints from skeletonsKeep input, output, and status contracts consistent.
Build a FastAPI summarization serviceSeparate endpoint behavior from service dependencies.
Design input validation and error handlingDistinguish invalid input from operational failure.
Implement a simple service with testsWrite checks that reject a plausible faulty version.
Debug and Harden a Ticketing Backend APIConnect observable HTTP behavior to a focused repair.

Try the FastAPI CRUD exercise with a fresh app instance. Explain where synchronous work executes, who owns each resource, and what evidence shows your test cleanup actually ran.

Sources and Further Reading

Sources checked September 9, 2026. Test results describe the original local exercise, not a production deployment.


Comments (0)