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 loopSynchronous 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 SLOWERAsync 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: adminThe 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): ... # betterIn 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 vIt 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 bookingTwo. 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.