FastAPI – Interview Questions

October 23, 202513 min readUpdated 8/23/2026

The questions a FastAPI role actually asks, answered against the seventeen lessons before this one. Short answers with the reasoning attached — because in an interview the reasoning is what is being assessed, and the answer is just the way in.

Fundamentals

What does FastAPI actually do for you?

It reads your type hints and uses them for three jobs at once: validating and converting the request, serialising the response, and generating an OpenAPI document with interactive documentation. One annotation, three uses. Everything else — routing, dependency injection, middleware — is built on Starlette underneath.

Why is it fast?

Two reasons, neither of which is the one people give. Starlette is a thin ASGI layer, and pydantic v2's validation core is compiled Rust. It is not fast because it is async — async removes waiting, not work.

The follow-up worth volunteering: framework overhead is almost never why an API is slow. One unindexed query or one N+1 in a list endpoint costs more than the framework will spend all day.

What is ASGI, and why does it matter?

WSGI is a function that takes a request and returns a response — one request occupies one worker for its whole life, and the protocol has no vocabulary for anything arriving later. ASGI is a coroutine over (scope, receive, send), so responses are streams of events. That is what makes WebSockets, server-sent events and streaming ordinary rather than special.

async def or def

What is the difference?

A def route is dispatched to a threadpool, so blocking in it is safe. An async def route runs on the event loop, so blocking in it stalls every other request in the process.

So which should I use?

If the handler does not await anything, write def. An async def that never awaits is strictly worse than the same function written def — same duration, and it blocks everything else while doing it.

What is the classic mistake?

@app.get("/listings")
async def list_listings(db: Session = Depends(get_db)):
    return db.execute(select(Property).limit(20)).scalars().all()   # blocks the loop

Synchronous SQLAlchemy inside async def. It works perfectly in development — one request at a time, a 2 ms query — and degrades under concurrency in a way that looks like a database problem.

Is async always faster?

No, and this is worth having a number for. Eight independent aggregate queries against a local Postgres, measured:

8 serial      median   7.9ms
8 concurrent  median  19.8ms      <- 2.5x SLOWER

Async charges a fixed overhead and pays back in proportion to waiting. Break-even is roughly 1–2 ms of wait per call — below it you pay for nothing, above it the returns are large. A local database is below the line; an S3 upload or a third-party API is far above it.

How big is the threadpool?

Forty threads by default. Forty concurrent def requests occupy all of them and the forty-first waits for a thread rather than for the database. It becomes the bottleneck when handlers are slow for non-database reasons.

Dependency injection

What does Depends do?

Calls a function before your route and passes the result. The dependency's own parameters become part of the route's signature — so a pagination dependency adds ?page= and ?pageSize= to every route that uses it, including in the documentation.

What does yield add?

Teardown. Everything before yield is setup, the value is injected, everything after runs when the response is done. It is how a database session is guaranteed to close even when the route raises — hence try/finally.

Are dependencies re-run if two things need them?

No — cached per request, keyed on the callable and its arguments. That is not an optimisation, it is what makes the pattern usable: without it a route depending on both the user and the session would get two sessions in two transactions.

How do you test code that uses them?

    app.dependency_overrides[get_db] = lambda: db
    app.dependency_overrides[get_current_user] = lambda: admin

The trap: the dict is keyed on the function object. Import it from a different module than the app did and the override silently does nothing — no error, the key just never matches. And always clear it, because the dict lives on the module-level app and leaks between test files.

Requests and responses

How does FastAPI decide where a parameter comes from?

In order: a name matching a {placeholder} is a path parameter; a pydantic model is the body; a known type like Request or BackgroundTasks is that thing; anything with Depends is a dependency; everything else is a query parameter.

The last one is a catch-all, which is why misspelling a path placeholder silently turns your path parameter into a required query parameter — a 422 about something you never wrote.

What is wrong with this?

@router.get("/{public_id}")
def get_property(public_id: UUID): ...


@router.get("/mine")
def my_listings(host: HostUser): ...

Routes match in declaration order, first match wins, so /mine is read as a property whose public_id is "mine" — and the UUID conversion fails with a 422 on an endpoint that exists and works. Literal paths must be declared before parameterised ones.

Why Annotated rather than a default value?

def search(q: str | None = Query(default=None, max_length=200)): ...     # older
def search(q: Annotated[str | None, Query(max_length=200)] = None): ... # better

In the first, the function's actual default is a Query object, so calling it directly from a test gives you a Query instead of None. With Annotated the default is a real None and the function behaves like an ordinary Python function — and the type can be given a name and reused.

What is response_model for?

Filtering. It is the difference between a new column being private by default and public the moment somebody adds it. If you return the ORM object directly, you publish the password hash the day the column appears.

Data

Why separate pydantic schemas from ORM models?

Because response_model is a security boundary. A field not on the model does not appear in the response, so a new column is private until somebody exposes it — rather than public the moment it is added. Return the ORM object directly and you publish the password hash the day you add it.

Where does a transaction begin and end?

The service opens it implicitly and commits it. Repositories flush() and never commit(), because only the caller knows the boundary — "create a booking AND its payment, or neither" spans two repositories.

What is the N+1 problem?

Relationships load lazily, so reading booking.property in a loop over twenty bookings is twenty-one queries. Fix it with joinedload for many-to-one and selectinload for one-to-many — the second avoids multiplying parent rows by children.

How do you stop two users booking the same dates?

Not in application code. Two requests can both pass an availability check before either INSERT lands. The guarantee is a database constraint:

EXCLUDE USING gist (property_id WITH =, daterange(check_in, check_out, '[)') WITH &&)
  WHERE (status IN ('PENDING', 'CONFIRMED', 'COMPLETED'))

The application still checks first, for a readable message. A check without a constraint is a race; a constraint without a check is a 500. Catching the IntegrityError and translating it into a 409 is what joins the two.

Why expire_on_commit=False?

Specifically a FastAPI concern. With the default, objects are marked stale after commit(), so reading an attribute triggers a refresh — and response serialisation happens after the route returns, sometimes after the session has closed. The symptom is DetachedInstanceError on an innocent-looking attribute access.

Errors, middleware and background work

Why not raise HTTPException from a service?

Because that service then only works inside a web request. A domain exception is portable and lets the web layer decide what a failure means over HTTP. Routes may raise HTTPException; services should not.

What is the trap with @app.exception_handler(Exception)?

It does not run where the other handlers do. Specific handlers live in ExceptionMiddleware, the innermost layer; a handler for bare Exception becomes ServerErrorMiddleware's, the outermost. So its response has skipped every user middleware — including CORS. The browser then reports a CORS failure and never exposes the body, so a frontend never sees the error message. Catch it inside your own middleware instead.

Which order does middleware run in?

add_middleware inserts at the front, so the last one registered is the outermost — the reverse of how the calls read. CORS belongs outside anything that converts exceptions into responses.

Middleware or a dependency?

Middleware for cross-cutting concerns that touch the response or must cover everything: request ids, timing, access logs, CORS. Dependencies for anything route-specific or that injects a value: auth, pagination, sessions. Authentication as middleware is a common mistake — it cannot inject the user and does not appear in the OpenAPI schema.

What does BackgroundTasks promise?

That the work runs after the response is sent. Nothing else. No retry, no persistence, no back-pressure, no visibility. Use it for work that can silently never happen; use a real queue for anything else.

What is the trap with it?

A yield dependency's teardown runs before background tasks, so the request's session is closed by then. Passing an ORM object half-works: a loaded column still reads, the first unvisited relationship raises DetachedInstanceError. So the version that passes the object passes a test that checks the total and breaks when somebody adds a related field — after the 200 has gone out, where nobody is looking. Pass ids; open a session in the task.

Security

Why bcrypt rather than SHA-256?

Because it is slow. A fast hash means a leaked database is brute-forced at billions of guesses a second. bcrypt also salts automatically, so identical passwords produce different hashes.

Anything surprising about it?

It truncates at 72 bytes, silently. Two long passwords sharing their first 72 bytes are the same password to bcrypt, and a passphrase in a non-Latin script hits the limit far sooner than it looks.

What can safely go in a JWT?

The claims are signed, not encrypted — anyone holding the token can read them. An id and a role, yes; anything you would not write on a postcard, no.

How do you revoke one?

A token is a snapshot, so you either keep it short-lived with a revocable refresh token, or check something on each request. StayHub re-reads the user from the database every request — one indexed lookup that turns a demotion or deletion into immediate effect. Trusting the role in the token means a demoted user keeps their old permissions until it expires.

Why does jwt.decode need algorithms=[...]?

Because a JWT's header names its own algorithm, and a library that trusts it will verify a token claiming "alg": "none", or verify an RS256 token using the public key as an HMAC secret. Naming what you accept closes both.

Why return 404 for someone else's resource?

A 403 confirms the id exists, which on a guessable identifier is a slow enumeration of the table. The exception is a documented surface like an admin API, where existence is not a secret and a 403 is less confusing.

What is wrong with trusting UploadFile.content_type?

It is whatever the client typed in the multipart part. curl -F 'file=@shell.php;type=image/png' sets it to anything. Sniff the leading bytes and require them to match the declared type — and never build a path from file.filename, because "../../app/main.py" is a valid string.

Production

How many workers?

Start at 2 × cores, then check the arithmetic: containers × workers × (pool_size + max_overflow) must stay under the database's max_connections. That product is what turns a successful scale-up into FATAL: sorry, too many clients already.

The most common container mistake?

Missing --host 0.0.0.0. Uvicorn binds the container's own loopback, the bind succeeds, the app logs that it is running, docker ps shows the port published, and every request is refused with nothing anywhere saying why.

Why PYTHONUNBUFFERED=1?

Python buffers stdout when it is not a TTY, which is exactly the case in a container. Without it logs arrive in delayed chunks and a crashing process loses whatever was still buffered — so you get an empty log for the one event you needed.

When do migrations run?

As a deploy step, before the new version serves traffic — not in the entrypoint, where several replicas race. And they must be backwards-compatible, because during a rollout old and new code both run against one database. Add a column nullable, then require it in a second deploy.

How do you deploy without dropping requests?

Three things. A readiness probe that means ready, so traffic does not arrive before the pool is warm. Exec-form CMD, so SIGTERM reaches uvicorn and in-flight requests finish. And a brief pause before shutdown, so the load balancer has finished deregistering.

Things people get wrong on a whiteboard

Write a paginated list endpoint.

The three things being checked are usually the three people miss:

    total = db.execute(select(func.count(User.id)).where(*conditions)).scalar_one()
...
    rows = db.execute(
        select(User)
        .where(*conditions)
        .order_by(User.id.desc())
        .limit(page.page_size)
        .offset(page.offset)
    ).scalars().all()
...
    return Page.of([AdminUserRow.model_validate(u) for u in rows], total, page)

Filtering the rows but counting the whole table gives a silently wrong pager. No ORDER BY means page two can repeat rows from page one, because Postgres promises nothing without it. And an unbounded pageSize is pagination that protects nothing.

What is wrong with this validator?

@field_validator("guests")
@classmethod
def check_capacity(cls, v: int, info) -> int:
    listing = db.get(Property, info.data["property_id"])   # ⚠️
    if v > listing.max_guests:
        raise ValueError("Too many guests.")
    return v

It puts a database query inside a schema. A model answers "is this well formed?"; it cannot know what is true right now, and it should not have a session. Capacity is a service-layer rule — and the schema layer reaching for a database is how a circular import arrives a week later.

Spot the bug.

@app.post("/bookings")
def create(payload: BookingCreateRequest, db: DbSession, background: BackgroundTasks):
    background.add_task(send_confirmation, payload)
    booking = BookingService(db).create(user, payload)
    return booking

Two. The task is queued before the work — Starlette runs whatever is on the list, so a failed booking still emails "your dates are held". And the endpoint returns the ORM object with no response_model, publishing every column on it.

The open-ended ones

How would you structure a FastAPI project?

Routes own HTTP, services own the rules, repositories own persistence and never commit, schemas are what is sent and models are what is stored. Then the honest half: not on day one. Six layers for six endpoints is worse than one file. The signals to move are the same logic appearing twice, or a rule you cannot test without a client.

What would you do differently at scale?

Object storage instead of local disk. A real queue instead of BackgroundTasks for anything that must happen. PgBouncer once the connection arithmetic stops working. Keyset pagination instead of OFFSET on large tables. Redis for anything currently in process memory, since workers share nothing.

How do you debug a slow endpoint?

Duration percentiles per route first — p95 and p99, never the mean, which is dominated by the fast majority. Then look for N+1 and missing indexes, because it is nearly always the database. Only then reach for caching or more processes. Four containers running the same N+1 query serve four times the traffic at exactly the same speed per request.

What do you check when it works locally and not in production?

Bind address, hostnames that only exist on your machine, a missing environment variable, CORS origins, whether migrations ran, connection pool exhaustion, proxy timeouts, and wheels built for the wrong architecture. In that order, and starting with the logs.

Questions worth asking back

An interview is two-directional, and these tend to be more informative than they sound.

"How do you test the API layer?" The answer separates teams that test services in isolation from teams that also test the assembled application. The second catches a class of bug — middleware ordering, response filtering, exception handler registration — that the first structurally cannot.

"What happens when a deploy goes wrong?" Whether rollback is one command, and whether anybody has run it. A pipeline that only goes forward is a pipeline nobody trusts.

"Where do background jobs run?" If the answer is BackgroundTasks for things that must happen, you have found either a gap or a conversation.

"How would I find out why one request was slow?" The presence of a correlation id, structured logs and per-route percentiles is a good proxy for how debuggable the whole system is.

"What is the oldest thing here nobody wants to touch?" Every codebase has one. How it is described tells you more about the team than any answer about architecture.

What to say when you do not know

The strongest answers in this list are the ones with a measurement or a scar attached — the async numbers, the CORS bug, the background-task trap. That is not a coincidence: interviewers are trying to distinguish people who have read about a framework from people who have shipped one.

So when you do not know, say so and then say how you would find out. "I would check whether the teardown runs before or after the task — that is measurable in about five minutes" is a better answer than a confident guess, and it is true regardless of which way the answer goes.

That is the end of the track. The eighteen lessons build one application: a REST API with typed validation, a real data layer, one error shape, authentication, uploads, background work, tests that catch configuration bugs, structured logs, and a container that runs it. Every example came from an application that runs and has a hundred passing tests — which is the only reason any of it can be trusted.