FastAPI Interview Questions · 2026

FastAPI Interview Questions (2026): Most Asked, With Answers

A backend candidate at a Series B logistics startup got asked to explain, live, why swapping def for async def on one endpoint made a slow database call block the entire server instead of speeding anything up. He'd shipped three FastAPI services in production and had never once had to think about how the framework decides between running his function directly on the event loop or shipping it out to a threadpool. FastAPI was built by Sebastián Ramírez on top of Starlette for the web parts and Pydantic for data validation, and the project's own docs describe it as one of the fastest Python frameworks available, with independent TechEmpower benchmarks placing it near the top of Python options, behind only Starlette and Uvicorn themselves since FastAPI is a layer on top of both (FastAPI documentation). Microsoft, Uber, Netflix, and Cisco all use it in production, which is part of why it now shows up constantly in backend and full-stack loops even at companies that started on Flask or Django.

Here's the pattern in how these interviews actually go: the first fifteen minutes are almost always about type hints, Pydantic models, and path operations, the stuff that shows up in the first tutorial page anyone reads. The next fifteen are where it gets real, dependency injection with Depends(), the async/def split and why picking the wrong one silently degrades throughput, and whether you understand that FastAPI's automatic docs and validation are a side effect of the same type annotations you'd write anyway, not a separate feature you bolt on. A shocking number of candidates who've shipped real FastAPI services still can't explain why a blocking database call inside an async def route stalls every other request on that worker.

This page covers around 50 FastAPI interview questions across eight areas: core fundamentals and request handling, Pydantic validation, the dependency injection system, async and concurrency, routing and application structure, security with OAuth2 and JWT, testing and error handling, and the harder performance and deployment questions that separate a senior answer from a tutorial-level one. Code examples are real Python, not pseudocode.

50Questions
Starlette + PydanticBuilt On
HTTPX-basedTest Client
OpenAPI + JSON SchemaDocs Standard

Fundamentals: what FastAPI actually is and how requests get validated

Every loop opens here, even for someone with two years of production FastAPI on the resume. The questions look basic, but a vague answer signals you've been copying tutorial patterns without reading what they do.

Easy questions

13

FastAPI is a Python web framework for building APIs, built directly on Starlette for the ASGI web layer, routing, and middleware, and on Pydantic for data validation, serialization, and the automatic generation of JSON Schema. That combination is what lets FastAPI infer request validation, response serialization, and interactive API docs straight from your function's type hints, instead of requiring separate schema definitions the way older frameworks do.

The practical effect: a plain Python function with type-annotated parameters becomes a fully validated, documented API endpoint with almost no extra code. That's the single biggest reason teams adopt it over building the same thing by hand on top of Flask.

Both, and the docs are explicit that it means both things on purpose. Runtime speed comes from being an ASGI framework running on Uvicorn, which lets it handle async I/O without blocking a worker thread per request, and independent TechEmpower benchmarks put it among the fastest Python frameworks tested, just below Starlette and Uvicorn themselves since it sits directly on top of them.

Developer speed comes from the type-hint-driven validation and auto-generated docs, you write one function signature and get request parsing, validation errors, and an interactive Swagger UI page for free. Candidates who only mention one half of this are giving half the answer.

It reads the type annotations on the function's parameters at import time and matches them against where each value comes from, path, query string, header, cookie, or request body, using a set of conventions rather than requiring you to say explicitly.

python
@app.get("/items/{item_id}")
async def read_item(item_id: int, q: str | None = None):
  return {"item_id": item_id, "q": q}

item_id is a path parameter because it appears in the route string, and its int annotation means FastAPI converts and validates the URL segment into an integer, returning a 422 with a clear error if the conversion fails. q isn't in the path, so it becomes an optional query parameter. No manual parsing, no separate validation layer.

A path parameter is part of the URL itself and is always required, declared inline in the route string like {item_id}. A query parameter is anything in the function signature that isn't in the path and isn't a Pydantic model, it comes from the ?key=value portion of the URL and can have a default to make it optional.

A request body is a Pydantic model passed as a parameter, FastAPI reads it from the JSON payload of the request rather than the URL at all. You can mix all three in a single endpoint and FastAPI resolves each parameter's source correctly based on its type.

FastAPI returns a 422 Unprocessable Entity response with a JSON body listing every field that failed validation, which model or parameter it belongs to, and why, before your function body ever runs. You don't write any of that error handling yourself, it comes from Pydantic's validation running against the type hints you already declared.

This is a common trip-up point for candidates coming from Flask, where request validation is something you build by hand with if-statements or a separate library like Marshmallow. In FastAPI it's the default behavior of declaring a typed parameter at all.

A BaseModel subclass declares a data shape using type-annotated class attributes, and Pydantic generates validation, parsing, and serialization logic from those annotations automatically. When you type-hint a path operation's parameter as a Pydantic model, FastAPI reads the raw request body as JSON, hands it to that model for validation, and either gives you a fully validated instance in your function or short-circuits with a 422 before your code runs at all.

python
from pydantic import BaseModel

class Item(BaseModel):
  name: str
  price: float
  is_offer: bool | None = None

@app.post("/items/")
async def create_item(item: Item):
  return item

A field with no default is required, the request fails validation with a clear message if it's missing entirely. A field with a default value, including None, becomes optional, and Pydantic fills it in automatically when the client omits it from the payload.

python
class Item(BaseModel):
  name: str      # required
  description: str | None = None # optional, defaults to None
  tax: float = 0.0   # optional, defaults to 0.0

A common mistake is annotating a field as str | None without a default value, that field is still required, it just permits null as one of the acceptable values, which trips people up the first time they see a 422 for a field they thought was optional.

Calling a helper function directly works fine for simple logic, but it doesn't get you FastAPI's automatic parameter resolution, OpenAPI documentation, or per-request caching. A dependency declared with Depends() can itself declare parameters, query params, headers, sub-dependencies, and FastAPI resolves that whole chain automatically and documents it in the generated API docs.

python
from typing import Annotated
from fastapi import Depends

async def common_parameters(q: str | None = None, skip: int = 0, limit: int = 100):
  return {"q": q, "skip": skip, "limit": limit}

@app.get("/items/")
async def read_items(commons: Annotated[dict, Depends(common_parameters)]):
  return commons

Plain function calls don't show up in the OpenAPI schema and can't declare their own required query parameters that FastAPI validates before your endpoint even runs.

Yes. Path operations accept a dependencies list separate from their normal parameters specifically for this, dependencies that need to run, an API key check, a rate limit counter, but whose return value the endpoint itself never touches.

python
async def verify_api_key(x_api_key: str = Header(...)):
  if x_api_key != EXPECTED_KEY:
    raise HTTPException(status_code=403)

@app.get("/admin/", dependencies=[Depends(verify_api_key)])
async def admin_panel():
  return {"status": "ok"}

Declaring it in dependencies=[...] instead of as a normal function parameter keeps the endpoint signature clean when nobody actually needs the returned value.

APIRouter lets you define a group of related path operations in their own file, then wire that group into the main app with app.include_router(), rather than every route in a growing app living in one module. It supports the same decorators, dependencies, and configuration as the main FastAPI app object (FastAPI documentation, Bigger Applications).

python
# routers/users.py
router = APIRouter(prefix="/users", tags=["users"])

@router.get("/")
async def list_users():
  return [{"id": 1}]

# main.py
from routers import users
app.include_router(users.router)

If a database gets breached, storing hashed passwords means the attacker gets non-reversible hashes instead of usable plaintext credentials, and can't directly log in as any user or reuse those same credentials against other services where the user reused a password. The official docs use a modern password hashing library with an algorithm like Argon2, purpose-built to be slow, which resists brute-force guessing far better than a fast general-purpose hash.

python
from pwdlib import PasswordHash

password_hash = PasswordHash.recommended()
hashed = password_hash.hash(plain_password)
password_hash.verify(plain_password, hashed) # True/False

FastAPI testing goes through Starlette's TestClient, which is built on HTTPX, and it deliberately hides the async machinery from you so plain pytest works without any async test runner setup. Test functions should be normal def, not async def, and client calls are made directly, not awaited, even though the app underneath is fully async (FastAPI documentation, Testing).

python
from fastapi.testclient import TestClient

client = TestClient(app)

def test_read_main():
  response = client.get("/")
  assert response.status_code == 200
  assert response.json() == {"msg": "Hello World"}

A candidate who reaches straight for pytest-asyncio and async def test functions for basic endpoint tests is usually overcomplicating something the framework already made simple.

HTTPException is how you deliberately stop a path operation and return a specific status code and error body, instead of the exception propagating as an unhandled 500. FastAPI catches it internally through a registered exception handler and converts it directly into the JSON error response the client receives.

python
@app.get("/items/{item_id}")
async def read_item(item_id: int):
  if item_id not in items_db:
    raise HTTPException(status_code=404, detail="Item not found")
  return items_db[item_id]

Medium questions

27

ASGI, Asynchronous Server Gateway Interface, is the successor to WSGI that supports async request handling, WebSockets, and long-lived connections, none of which WSGI was designed for since it assumes one thread handles one request from start to finish. WSGI apps, Flask and Django in their classic form, can't natively await anything inside a request without a separate worker model bolted on.

python
# WSGI (Flask classic): one blocking call per worker thread
def view(request):
  result = requests.get("https://api.example.com/data") # blocks the whole worker
  return result.json()

# ASGI (FastAPI): the event loop can serve other requests while this awaits
@app.get("/data")
async def view():
  async with httpx.AsyncClient() as client:
    result = await client.get("https://api.example.com/data")
  return result.json()

FastAPI runs on Uvicorn, an ASGI server, which is part of why it can hold far more concurrent I/O-bound connections per worker than a synchronous WSGI app can.

response_model tells FastAPI to validate and filter the outgoing response through a specific Pydantic model, independent of whatever object type the function actually returns internally. It's the mechanism that lets you return a full database row from your endpoint while guaranteeing the client only ever sees the fields you've explicitly declared as public.

python
class UserOut(BaseModel):
  id: int
  email: str
  # password_hash intentionally excluded

@app.post("/users/", response_model=UserOut)
async def create_user(user: UserIn):
  db_user = save_to_db(user) # db_user also has password_hash
  return db_user # FastAPI strips it, only UserOut fields go out

Without it, forgetting to manually strip a sensitive field before returning it is an easy way to leak data through an API response.

You reach for a custom validator when a field needs a rule beyond its basic type, a string that must match a format, a number that must fall in a range, a value that depends on another field on the same model. Pydantic v2 exposes this through the field_validator decorator.

python
from pydantic import BaseModel, field_validator

class SignupRequest(BaseModel):
  username: str
  password: str

  @field_validator("username")
  @classmethod
  def username_alphanumeric(cls, v: str) -> str:
    if not v.isalnum():
      raise ValueError("username must be alphanumeric")
    return v

Raising ValueError inside the validator is what triggers FastAPI's 422 response with your custom message attached, no separate exception handling needed on your end.

Pydantic validates recursively. If a model field is annotated as another BaseModel, the incoming dictionary or JSON object for that field gets validated against that nested model's own rules, and validation errors report the full path to the failing nested field, not just the top-level model name.

python
class Address(BaseModel):
  city: str
  zip_code: str

class Customer(BaseModel):
  name: str
  address: Address

# a bad payload's error looks like:
# {"loc": ["body", "address", "zip_code"], "msg": "field required",...}

This is what lets a deeply nested request body still produce a specific, actionable error message instead of a generic failure at the top level.

By default, Pydantic doesn't just reject a value of the wrong type, it attempts a reasonable conversion first. A query string like "42" annotated as int gets coerced into the integer 42 rather than rejected outright, since URL query parameters always arrive as strings and strict type equality would make almost every query parameter fail.

python
@app.get("/items/")
async def read_items(skip: int = 0, limit: int = 10):
  # /items/?skip=5&limit=20 arrives as strings "5" and "20"
  # Pydantic coerces both to int before your function runs
  return {"skip": skip, "limit": limit}

Strict mode exists for cases where coercion is actively undesirable, a boolean field that should reject the string "true" and only accept an actual JSON boolean, but the coercive default is what makes query and path parameters usable at all.

Yes, by default, within a single request, FastAPI caches the result of each dependency the first time it's resolved and reuses that cached result for every other path operation or sub-dependency in that same request that asks for the identical dependency callable. A database session dependency referenced by three different sub-dependencies in one request only actually runs once.

python
async def get_db():
  db = SessionLocal()
  try:
    yield db
  finally:
    db.close()

# even if get_db is depended on by both get_current_user and get_items,
# it executes exactly once per request, both consumers share the same session

You can opt out per-dependency with Depends(get_db, use_cache=False) if you genuinely need a fresh call, but that's rare in practice.

A sub-dependency is a dependency that itself takes another dependency as one of its own parameters, forming a resolution tree FastAPI walks automatically. A common real chain: a route depends on get_current_user, which itself depends on get_token, which reads and validates the Authorization header.

python
async def get_token(authorization: str = Header(...)):
  return authorization.replace("Bearer ", "")

async def get_current_user(token: Annotated[str, Depends(get_token)]):
  user = decode_and_lookup(token)
  if not user:
    raise HTTPException(status_code=401)
  return user

@app.get("/profile")
async def profile(user: Annotated[dict, Depends(get_current_user)]):
  return user

Each endpoint that needs authentication only declares Depends(get_current_user), the token extraction detail is hidden a layer down and shared across every endpoint that uses it.

A dependency written with yield instead of return lets you run cleanup code after the request finishes, closing a database connection, releasing a lock, regardless of whether the path operation succeeded or raised an exception. Code after the yield runs in a finally-like way once the response has been sent.

python
async def get_db():
  db = SessionLocal()
  try:
    yield db # this value is injected into the endpoint
  finally:
    db.close() # runs after the response, even if an exception happened

Plain return dependencies have no equivalent teardown hook, they hand back a value and that's the end of their involvement in the request.

Declare the dependency once at the APIRouter or app level instead of per path operation. Both accept a dependencies argument that applies to every route defined under them, without needing to touch each individual function signature.

python
router = APIRouter(
  prefix="/admin",
  dependencies=[Depends(verify_api_key)],
)

@router.get("/stats")
async def stats():
  return {"users": 1204}
# verify_api_key runs before this, and every other route under /admin,
# without being declared again

Use async def when the function awaits something genuinely asynchronous, a call using an async-capable library like httpx.AsyncClient or an async database driver, and there's nothing else useful for that code to do while waiting. Use plain def when the function calls blocking, synchronous code, most traditional database libraries and many third-party SDKs still fall into this category.

The official guidance is direct about what to do if you're not sure: use normal def. FastAPI still runs it efficiently either way, just through a different mechanism, and picking the wrong one on purpose is worse than picking plain def by default when in doubt (FastAPI documentation, Concurrency and async / await).

Freely, and this is one of the more convenient parts of FastAPI's design. An async def path operation can depend on a plain def dependency and vice versa, FastAPI handles running each one correctly, synchronous dependencies go through the threadpool, asynchronous ones run directly on the event loop, regardless of what the calling function or route is.

python
def sync_dependency():     # runs in threadpool
  return get_config_from_disk()

async def async_dependency():  # runs on event loop
  return await fetch_remote_flag()

@app.get("/status")
async def status(
  config: Annotated[dict, Depends(sync_dependency)],
  flag: Annotated[bool, Depends(async_dependency)],
):
  return {"config": config, "flag": flag}

BackgroundTasks runs a function after the response has already been sent to the client, in the same process, without making the client wait. It's declared as a parameter and you call .add_task() with the function and its arguments, and it works whether the task function itself is sync or async.

python
def write_log(email: str, message: str):
  with open("log.txt", "a") as f:
    f.write(f"{email}: {message}n")

@app.post("/notify/{email}")
async def notify(email: str, background_tasks: BackgroundTasks):
  background_tasks.add_task(write_log, email, message="sent")
  return {"message": "queued"}

The docs are explicit that this is not a replacement for a real task queue like Celery, once a task is heavy on compute, needs to survive a server restart, or needs to run across multiple processes or machines, BackgroundTasks running in the same process with no persistence stops being appropriate (FastAPI documentation, Background Tasks).

FastAPI itself is a framework, not a server, it defines how requests get routed, validated, and turned into responses, but something still has to actually accept TCP connections, speak HTTP, and hand parsed requests to your application following the ASGI protocol. Uvicorn is that server, and it's the one included by default when you install fastapi[standard].

Running multiple Uvicorn worker processes, or using Gunicorn as a process manager in front of several Uvicorn workers, is how a single FastAPI app scales across multiple CPU cores in production, since each worker is still fundamentally single-threaded for its own event loop.

Yes. include_router() accepts a prefix, tags, dependencies, and responses that get merged with whatever the router already declared, which lets you reuse the same router under different mount points with different requirements.

python
app.include_router(
  users.router,
  prefix="/api/v1",
  dependencies=[Depends(require_admin)],
)

This is genuinely useful for versioning, mounting the same route logic under both /api/v1 and /api/latest without duplicating the endpoint functions.

Middleware wraps every single request and response globally, before FastAPI even routes to a specific path operation, and it works with the raw Request and Response objects directly rather than through injected parameters. A dependency only runs for the specific endpoints that declare it, and it works through FastAPI's parameter resolution instead of raw request objects.

python
@app.middleware("http")
async def add_timing_header(request: Request, call_next):
  start = time.perf_counter()
  response = await call_next(request)
  response.headers["X-Process-Time"] = str(time.perf_counter() - start)
  return response

Reach for middleware for genuinely global concerns, CORS, request timing, logging every request regardless of route. Reach for a dependency when the logic is specific to some subset of endpoints, since middleware has no concept of "only these routes" the way a router-level dependency does (FastAPI documentation, Middleware).

Split routes into one APIRouter per resource, users, items, orders, each in its own module under a routers/ package, with shared dependencies like database sessions and current-user resolution living in a separate dependencies.py. The main app file becomes almost entirely include_router() calls and app-level configuration.

This mirrors what the official docs recommend for bigger applications, and it is not really about a specific folder layout. It is about the principle: keep path operation functions thin, push shared logic into dependencies, and keep the entry point file readable at a glance rather than a growing list of every route in the app.

It takes the token extracted by an OAuth2PasswordBearer dependency as its own input, decodes and verifies the JWT's signature and expiration, pulls the username or user ID out of the token's claims, looks that user up, and either returns the user object or raises a 401 if any step fails. Every protected endpoint just declares Depends(get_current_user) and gets the resolved user injected, with all of the token logic hidden behind that one dependency.

python
async def get_current_user(token: Annotated[str, Depends(oauth2_scheme)]):
  try:
    payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
    username = payload.get("sub")
  except InvalidTokenError:
    raise HTTPException(status_code=401, detail="Could not validate credentials")
  user = get_user(username)
  if user is None:
    raise HTTPException(status_code=401)
  return user

Authentication answers who the request is coming from, that's what get_current_user and the token verification handle. Authorization answers what that identified user is allowed to do, and it usually lives in a separate dependency layered on top, one that checks the resolved user's role or permissions rather than re-verifying their identity.

python
async def require_admin(user: Annotated[User, Depends(get_current_user)]):
  if user.role != "admin":
    raise HTTPException(status_code=403, detail="Admin access required")
  return user

@app.delete("/users/{user_id}")
async def delete_user(user_id: int, admin: Annotated[User, Depends(require_admin)]):
 ...

Keeping these as separate dependencies, one for identity, one for permission, means you can reuse get_current_user everywhere and only layer require_admin onto the specific routes that actually need it.

FastAPI apps expose app.dependency_overrides, a dictionary that maps the original dependency callable to a replacement, and every route that would have used the real one gets the override instead for the duration of the test. No changes to the application code are needed.

python
def override_get_db():
  db = TestingSessionLocal()
  try:
    yield db
  finally:
    db.close()

app.dependency_overrides[get_db] = override_get_db

def test_create_item():
  response = client.post("/items/", json={"name": "test"})
  assert response.status_code == 200

Clearing app.dependency_overrides between test modules matters, a forgotten override can quietly leak into unrelated tests and make failures confusing to trace.

Define your own exception class, then register a handler with @app.exception_handler() that catches it anywhere in the app and converts it to whatever response shape you want, without needing to wrap every route in its own try/except block.

python
class OutOfStockError(Exception):
  def __init__(self, item: str):
    self.item = item

@app.exception_handler(OutOfStockError)
async def out_of_stock_handler(request: Request, exc: OutOfStockError):
  return JSONResponse(
    status_code=409,
    content={"detail": f"{exc.item} is out of stock"},
  )

@app.get("/order/{item}")
async def order(item: str):
  if not in_stock(item):
    raise OutOfStockError(item)
  return {"ordered": item}

This keeps domain-specific error logic in one place instead of scattered across every endpoint that could hit that error condition.

422 Unprocessable Entity, not 400 Bad Request. The distinction is deliberate: 400 generally means the request is malformed in a way the server can't even parse, invalid JSON syntax, while 422 means the request was syntactically valid and understood, but the data inside it failed semantic validation against the expected schema, a string where an integer was required, a missing field.

Candidates who assume every client error should be a flat 400 usually haven't looked closely at what FastAPI actually returns by default, or haven't had to explain the distinction to an API consumer who's building against the schema.

Override the authentication dependency itself, the same way you'd override a database dependency, so tests inject a fake, already-authenticated user directly instead of exercising the full token issuance and verification flow for every single test.

python
def fake_current_user():
  return User(id=1, username="testuser", role="admin")

app.dependency_overrides[get_current_user] = fake_current_user

def test_admin_endpoint():
  response = client.get("/admin/stats")
  assert response.status_code == 200

The real login and token flow still deserves its own dedicated integration test, but every other test that merely requires "some authenticated user" shouldn't have to re-run that whole path.

A single Uvicorn process runs one event loop on one CPU core, so it can handle a large number of concurrent I/O-bound requests efficiently, but it can't use more than one core for CPU-bound work no matter how much traffic arrives. Running multiple worker processes, each with its own event loop, is how a FastAPI app actually uses multiple cores in production.

A common, defensible starting point is roughly (2 x CPU cores) + 1 workers under Gunicorn managing Uvicorn workers, then tuning based on actual load testing rather than trusting that number blindly for every workload. Memory-bound apps or ones with heavy per-request state might need fewer workers than that formula suggests.

A lifespan handler runs setup code once when the app starts and teardown code once when it shuts down, the modern replacement for the older separate @app.on_event('startup') and @app.on_event('shutdown') decorators. It's the right place for things that should exist exactly once for the app's whole lifetime, a database connection pool, a loaded ML model, a cache client.

python
from contextlib import asynccontextmanager

@asynccontextmanager
async def lifespan(app: FastAPI):
  app.state.db_pool = await create_pool() # startup
  yield
  await app.state.db_pool.close()     # shutdown

app = FastAPI(lifespan=lifespan)

Creating a fresh connection pool per request instead of once at startup is a common performance mistake, especially from developers coming from a request-per-process framework where that pattern is less costly.

The most common approach is a URL prefix, /api/v1/ versus /api/v2/, using separate APIRouter instances or even entirely separate response models per version, mounted under different prefixes with include_router(). Header-based versioning exists too, but it's harder for API consumers to discover through the auto-generated docs and rarely worth the added complexity for most teams.

A pragmatic middle ground some teams use is keeping shared business logic in common service functions, and only duplicating the thin routing and response-shaping layer per version, so a v1 to v2 migration doesn't mean maintaining two entirely separate implementations of the same underlying logic.

FastAPI builds an OpenAPI schema automatically from your path operations, Pydantic models, and type hints, then serves interactive Swagger UI and ReDoc pages directly from that schema with zero extra code required. The docs are generated from the same source of truth the validation runs against, so they can't drift out of sync with your request and response models the way hand-written API documentation often does.

What they won't catch is business logic that isn't expressed in the type system itself, an endpoint that silently ignores a field, or a response that sometimes includes an extra key under conditions the type hints don't capture. The schema accurately describes the shape of the contract, it doesn't guarantee the runtime behavior matches every edge case of that contract perfectly.

A client posts a username and password to a login endpoint using form data, not JSON, which FastAPI reads through OAuth2PasswordRequestForm. The server verifies the credentials against a stored, hashed password, and if they match, issues a signed token, typically a JWT, back to the client. Every subsequent request includes that token in an Authorization header, and a dependency built on OAuth2PasswordBearer extracts and validates it before the protected route runs (FastAPI documentation, OAuth2 with Password and JWT).

python
@app.post("/token")
async def login(form_data: Annotated[OAuth2PasswordRequestForm, Depends()]):
  user = authenticate_user(form_data.username, form_data.password)
  if not user:
    raise HTTPException(status_code=401, detail="Incorrect username or password")
  token = create_access_token({"sub": user.username})
  return {"access_token": token, "token_type": "bearer"}

Hard questions

10

Pydantic v2 rewrote the validation core in Rust, which gives a substantial performance improvement over the pure-Python v1 validator, and it changed several APIs in the process. @validator became @field_validator, .dict() became .model_dump(), .json() became .model_dump_json(), and Config classes moved toward model_config = ConfigDict(...) instead of a nested class.

Modern FastAPI depends on Pydantic v2, so a project stuck on FastAPI installed alongside a legacy Pydantic v1 dependency, an older internal library that hasn't migrated, is a real source of confusing bugs. Knowing this split exists is a decent signal that a candidate has actually maintained a FastAPI codebase across a version bump rather than only ever starting fresh.

It blocks the entire event loop, not just that one request. FastAPI's event loop is single-threaded per worker process, so a synchronous, blocking call inside an async def function, a classic psycopg2 query, a synchronous requests.get, freezes every other concurrent request that worker was supposed to be handling until that call returns.

python
import time

@app.get("/slow")
async def slow_endpoint():
  time.sleep(5) # blocks the whole event loop for 5 seconds
  return {"done": True}
# every other request on this worker stalls for those 5 seconds too

This is the exact bug from the opening of this page. The fix is either making the call genuinely async, or declaring the route with plain def so FastAPI runs it in a separate thread pool instead of on the event loop directly.

Because threadpool dispatch has real overhead, scheduling the function onto a worker thread and awaiting its result costs more than running a fast, non-blocking coroutine directly on the event loop. For a route doing genuine async I/O, forcing it through def and the threadpool actually makes it slower and burns a limited thread pool slot that other blocking work might need.

The right mental model is that async def is for I/O-bound code you can genuinely await, and def is a safety net for code that either blocks or that you haven't verified doesn't block. Neither one is universally "the safe choice", they're suited to different situations.

They share module-level and global state, since both requests run on the same event loop in the same process, but they don't share local variables inside the function itself, each invocation gets its own local scope. This is where a lot of real bugs come from, a mutable global counter or cache dictionary accessed without care across concurrent requests can produce race-condition-style bugs even though Python's GIL means no two lines of Python bytecode execute at the exact same instant.

python
request_count = 0

@app.get("/ping")
async def ping():
  global request_count
  request_count += 1 # not actually atomic across concurrent async requests
  return {"count": request_count}

Two coroutines can interleave between reading and writing that global at an await point, which is a subtler failure mode than classic multithreading races but a real one. Anything shared across requests, a cache, a connection pool, a counter, needs to be built with that in mind.

Middleware forms a stack, and the last one added with app.add_middleware() becomes the outermost layer. On the way in, requests pass through middleware in reverse registration order, last added runs first, and on the way out, responses pass back through in the opposite direction.

python
app.add_middleware(MiddlewareA)
app.add_middleware(MiddlewareB)
# request path: MiddlewareB -> MiddlewareA -> route
# response path: route -> MiddlewareA -> MiddlewareB

Getting this backwards is a common source of confusion when someone adds an authentication middleware and a logging middleware and can't figure out why the logging middleware sees requests that authentication should have already rejected.

Yes, an APIRouter can call .include_router() on another router just like the main app can, building a tree of nested routers. This is useful when a resource genuinely needs to be exposed under more than one path structure, an internal admin API and a public API both wanting to reuse the exact same set of endpoints under different prefixes and dependency sets.

python
public_router = APIRouter(prefix="/public")
public_router.include_router(items.router)

admin_router = APIRouter(prefix="/admin", dependencies=[Depends(require_admin)])
admin_router.include_router(items.router)

app.include_router(public_router)
app.include_router(admin_router)

A signed JWT is readable by anyone who has it, the payload is just base64-encoded JSON, but it can't be modified without invalidating the signature, since the signature is computed over the exact payload using a secret key only the server holds. That protects against tampering, a client altering their own claimed role or user ID, not against the token being read.

That's why you never put sensitive data like a plaintext password inside a JWT payload, anyone with the token can decode and read it trivially. What the signature buys you is confidence that whatever claims are in there are exactly what the server originally issued, which is what makes a stateless auth check possible without a database lookup on every request.

Expiration limits the damage window if a token gets stolen, a leaked token that's only valid for 30 minutes is a much smaller problem than one valid indefinitely. The catch that trips people up: a stateless JWT, verified only by its signature and expiry, remains valid until it actually expires, even if the user changes their password in the meantime, because there's no server-side lookup happening on every request to invalidate it early.

If you genuinely need immediate revocation, changed password, logged out, banned user, you need either a server-side denylist of revoked token IDs checked on each request, which reintroduces a database lookup and gives up some of the stateless benefit, or short enough expiration windows combined with refresh tokens that you can revoke server-side. There's no way to make a pure, stateless JWT revocable instantly without one of those tradeoffs.

CPU-bound work doesn't benefit from async at all, there's no I/O wait to yield during, the CPU is simply busy the whole time, and running it directly inside an async def route blocks the single event loop for that entire duration exactly like a blocking I/O call would. Making the function async doesn't parallelize CPU work, Python's GIL means true CPU parallelism needs separate processes, not just separate coroutines.

The usual fix is offloading it to a process pool, concurrent.futures.ProcessPoolExecutor run via loop.run_in_executor(), or handing it to a proper task queue like Celery if it's heavy enough to justify a separate worker infrastructure entirely, the same category BackgroundTasks itself explicitly says it isn't meant for.

This is a general Python pitfall, not FastAPI-specific, but it shows up constantly in FastAPI code because dependencies and path operations are ordinary Python functions underneath the decorators. A mutable default argument, a list or dict, gets created exactly once when the function is defined, not once per call, so mutating it inside the function body leaks state across every single request that hits that endpoint.

python
@app.post("/items/")
async def add_item(item: str, items: list = []): # created once, shared forever
  items.append(item)
  return items
# every request appends to the SAME list object, growing across requests

# correct version:
async def add_item(item: str, items: Annotated[list | None, None] = None):
  items = items or []
  items.append(item)
  return items

Under a high-traffic FastAPI app this turns into a slow, confusing memory leak that's easy to miss in a quick local test with a handful of requests.

How to prepare for a FastAPI interview

Skip re-reading the tutorial for a fourth time. Build one small thing that forces you into the parts most candidates skip: a protected endpoint using the full OAuth2-password-plus-JWT flow, a dependency chain at least two levels deep with one dependency using yield for cleanup, and one endpoint that deliberately mixes a blocking call with async def so you can watch it stall a second, unrelated request while it runs. Reading about the async/def split is not the same as watching your own server hang because you got it backwards.

Across backend mock interviews run through LastRoundAI, the async-versus-def question and the "what does dependency caching mean during a single request" question are the two that most reliably separate candidates who've maintained a FastAPI service under real traffic from candidates who built one clean demo project and stopped there. Neither is an obscure trivia question, both come from the same handful of tutorial pages every FastAPI developer has open at some point, they just require actually reading past the first code block.

Turn on strict type checking with mypy or pyright against your Pydantic models before the interview, not during it. Fixing the errors it surfaces by hand teaches you more about how FastAPI's validation actually flows than reading the documentation a second time will.

Get the reps in before the real thing

Explaining a dependency chain on paper is not the same as defending it out loud once an interviewer asks what happens if you remove the yield. LastRoundAI's mock interview mode runs live coding rounds with real-time follow-up questions in your browser, and the free plan includes 15 credits a month that reset monthly rather than piling up unused. Starter is $19/mo if a handful of sessions isn't enough runway.

Once your answers hold up under a follow-up, the slower part of the job hunt is usually just getting in front of enough backend and full-stack roles that actually test FastAPI instead of listing it as a nice-to-have. Auto-Apply queues tailored applications for your review, 10 a month on the free plan, up to 400 a month on the Ultimate plan, and nothing goes out until you approve it.

Questions about either product go to contact@lastroundai.com. That's the only inbox we check.

How this list was built

Worth being straight about where these questions come from, because plenty of pages in this category are not. The set was compiled from a research pass across official documentation, vendor release notes, published engineering writing and public discussion of hiring processes, then cross-checked against the current version of each technology so nothing here describes behaviour that has since changed.

What that means in practice: these are the questions the material supports as reasonable and current for this role, not a transcript of any one company's loop. We have not sat in on your interview and we are not going to claim we have. Treat the list as well-sourced preparation rather than a leaked question bank, and expect your panel to phrase things their own way.

If you spot something out of date, tell us at contact@lastroundai.com and we will fix it.

Frequently asked questions

Is FastAPI still worth learning in 2026?

For interview purposes the question is really whether the teams you are targeting use it, which is worth checking against their actual job postings rather than general popularity rankings. Where it is in use it tends to be deeply embedded and slow to replace.

Should I memorise FastAPI syntax for the interview?

Rarely worth it. Most interviewers care that you know what to reach for and why, and will not fail you for forgetting an exact flag. Being confidently wrong about behaviour costs far more than admitting you would check the documentation.

What is the most common mistake in FastAPI interviews?

Answering the question that was asked and stopping there. The strongest candidates add the trade-off or the failure mode without being prompted, which is what signals real use rather than revision.

How long does it take to prepare for a FastAPI interview?

If you already work with FastAPI day to day, a focused week on the areas you avoid in practice is usually enough. Coming in cold, expect three to four weeks. The gap is rarely knowledge; it is being able to explain something you normally just use.

What FastAPI topics come up most often?

Interviewers concentrate on the parts that cause production incidents rather than the parts that are pleasant to learn. Expect the fundamentals to be assumed and the follow-up questions to sit one layer below what a tutorial covers.

Leave a Reply

Your email address will not be published. Required fields are marked *