Skip to content

Response Models Interview Questions & Answers

13 questions Updated 2026-06-20 Share:

FastAPI response model interview questions — response_model, filtering fields, exclude_none, exclude_unset, JSONResponse, custom response classes and status codes.

13 of 13

response_model tells FastAPI which Pydantic model to use for validating and filtering the handler's return value before serialising it to JSON.

class UserIn(BaseModel):
    name: str
    password: str          # sensitive — never leak this

class UserOut(BaseModel):
    id: int
    name: str              # no password field

@app.post("/users", response_model=UserOut)
async def create_user(user: UserIn) -> UserOut:
    db_user = await db.create(user)
    return db_user         # password field is stripped by response_model

FastAPI:

  1. Validates the return value against UserOut.
  2. Strips fields not in UserOut (e.g., password).
  3. Documents UserOut as the response schema in OpenAPI.

Rule of thumb: always set response_model on any endpoint that returns ORM objects or models with sensitive fields.

Both tell FastAPI the response shape, but response_model= overrides the return annotation for serialisation and filtering:

# return annotation only — used for schema AND serialisation
@app.get("/users/{id}")
async def get_user(id: int) -> UserOut:
    return await db.get(User, id)

# response_model wins — annotation is for type checkers only
@app.get("/users/{id}", response_model=UserOut)
async def get_user(id: int) -> User:   # mypy sees User; FastAPI uses UserOut
    return await db.get(User, id)

Use the return annotation when they're the same type (clean, Pythonic). Use response_model= when the serialised type differs from what the handler actually returns (ORM model → Pydantic output DTO).

Rule of thumb: prefer the return annotation; add response_model= only when you need to filter or transform the handler's return value.

Set response_model_exclude_none=True in the route decorator:

class Report(BaseModel):
    total: int
    average: float | None = None
    notes: str | None = None

@app.get("/report", response_model=Report, response_model_exclude_none=True)
async def get_report():
    return {"total": 100}
    # average and notes are None → omitted: {"total": 100}

Rule of thumb: use exclude_none=True for sparse responses where None means "not applicable" — keeps the JSON payload small and clean.

response_model_exclude_unset=True strips fields that were not explicitly set in the return value — it distinguishes between "not set" and "set to None".

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

@app.patch("/items/{id}", response_model=Item, response_model_exclude_unset=True)
async def patch_item(id: int, patch: Item):
    existing = await db.get(id)
    updated_data = patch.model_dump(exclude_unset=True)
    existing.update(updated_data)
    return existing

This is critical for PATCH semantics: you only return (and store) fields the client actually sent, not defaults.

Rule of thumb: use exclude_unset=True on PATCH endpoints — it lets clients send partial updates without overwriting fields they didn't mention.

Use response_model_exclude={"field1", "field2"} in the route decorator:

class User(BaseModel):
    id: int
    name: str
    password_hash: str
    internal_notes: str | None = None

@app.get(
    "/users/{id}",
    response_model=User,
    response_model_exclude={"password_hash", "internal_notes"},
)
async def get_user(id: int):
    return await db.get(id)

This avoids creating a separate UserOut model for simple exclusion cases. For complex filtering (renames, computed fields), a dedicated output model is cleaner.

Rule of thumb: use response_model_exclude for 1-2 fields; create a dedicated output model when you exclude three or more fields or need renames/aliases.

Return JSONResponse when you need to:

  • Set a non-default status code dynamically.
  • Set custom response headers.
  • Bypass response_model filtering (e.g., a pre-serialised payload).
from fastapi.responses import JSONResponse

@app.get("/items/{id}")
async def get_item(id: int):
    item = await db.get(id)
    if not item:
        return JSONResponse(
            status_code=404,
            content={"detail": "Not found"},
            headers={"X-Request-ID": "abc123"},
        )
    return item   # normal path uses response_model

JSONResponse bypasses Pydantic serialisation — pass JSON-safe Python primitives or use jsonable_encoder() first.

Rule of thumb: return dicts/models 95% of the time; reach for JSONResponse only when you need direct control over status code or headers.

Class Use case
JSONResponse (default) JSON API responses
HTMLResponse Rendered HTML pages
PlainTextResponse Plain text, logs
RedirectResponse 301/302/307 redirects
FileResponse Send a file with proper headers
StreamingResponse Large responses, real-time data
ORJSONResponse Faster JSON via orjson
UJSONResponse Faster JSON via ujson
from fastapi.responses import FileResponse, StreamingResponse

@app.get("/download")
async def download():
    return FileResponse("report.pdf", filename="report.pdf")

@app.get("/stream")
async def stream():
    async def generate():
        for chunk in large_data():
            yield chunk
    return StreamingResponse(generate(), media_type="text/plain")

Rule of thumb: set default_response_class=ORJSONResponse on the FastAPI() instance to get faster serialisation across all endpoints with no other changes.

orjson is a Rust-backed JSON library that is 5-10× faster than the stdlib json module. Install it with pip install orjson, then:

from fastapi import FastAPI
from fastapi.responses import ORJSONResponse

# Make it the default for all routes
app = FastAPI(default_response_class=ORJSONResponse)

@app.get("/items")
async def list_items():
    return [{"id": 1}, {"id": 2}]

Or per-route:

@app.get("/items", response_class=ORJSONResponse)
async def list_items():
    ...

orjson natively handles datetime, UUID, bytes and NumPy arrays without jsonable_encoder.

Rule of thumb: switch to ORJSONResponse globally for any throughput-sensitive API — it's a one-line change with measurable latency improvement.

Return a StreamingResponse with an async (or sync) generator:

from fastapi.responses import StreamingResponse
import asyncio

async def generate_csv():
    yield "id,name\n"
    async for row in db.stream_all_users():
        yield f"{row.id},{row.name}\n"

@app.get("/export/users.csv")
async def export_users():
    return StreamingResponse(
        generate_csv(),
        media_type="text/csv",
        headers={"Content-Disposition": "attachment; filename=users.csv"},
    )

The client receives bytes as they arrive; the server never holds the full dataset in memory.

Rule of thumb: use StreamingResponse for exports, large file downloads, and server-sent events — never load a 1M-row dataset into a list before sending.

Return a RedirectResponse:

from fastapi.responses import RedirectResponse
from fastapi import status

@app.get("/old-path")
async def old_path():
    return RedirectResponse(
        url="/new-path",
        status_code=status.HTTP_301_MOVED_PERMANENTLY,
    )

# Temporary redirect (default 307)
@app.get("/login")
async def login_redirect():
    return RedirectResponse(url="/auth/login")

Default status code for RedirectResponse is 307 Temporary Redirect, which preserves the HTTP method. Use 301 for permanent SEO-friendly redirects, 302 for temporary, 303 to force GET on redirect.

Rule of thumb: use 307 to preserve POST method on redirect; use 303 to convert a POST response to a GET (Post/Redirect/Get pattern).

Use response_model_include={"field1", "field2"}:

class User(BaseModel):
    id: int
    name: str
    email: str
    phone: str | None = None
    internal_id: str

@app.get(
    "/users/{id}/public",
    response_model=User,
    response_model_include={"id", "name"},   # only these two fields
)
async def get_user_public(id: int):
    return await db.get(id)
# response: {"id": 42, "name": "Alice"}

Rule of thumb: prefer response_model_exclude for "strip secrets" use cases and a dedicated output model for complex projections — response_model_include with many field names becomes hard to maintain.

Inject the Response object into the handler and set headers on it:

from fastapi import Response

@app.get("/items")
async def list_items(response: Response):
    items = await db.all()
    response.headers["X-Total-Count"] = str(len(items))
    response.headers["Cache-Control"] = "max-age=60"
    return items   # normal JSON response with extra headers

You can also set headers via JSONResponse(headers={...}) or middleware. Injecting Response is the cleanest option when the route already returns a plain dict/model.

Rule of thumb: inject Response for per-request headers (pagination counts, rate-limit info); use middleware for headers that apply to all responses (CORS, CSP).

Inject BackgroundTasks and add tasks before returning:

from fastapi import BackgroundTasks

def notify_admin(item_id: int):
    send_email("admin@example.com", f"New item {item_id} created")

@app.post("/items", status_code=201)
async def create_item(item: Item, background_tasks: BackgroundTasks):
    new_item = await db.create(item)
    background_tasks.add_task(notify_admin, new_item.id)
    return new_item   # response is sent immediately; email sent after

FastAPI sends the HTTP response, then runs the background task(s) sequentially. The client gets its 201 Created without waiting for the email.

Rule of thumb: use BackgroundTasks for fast, non-retryable work; use a task queue (Celery, ARQ) for anything that must succeed or be retried.

More ways to practice

The self-quiz is live. Get notified when mock interviews and new question packs drop.

or
Join our WhatsApp Channel