Philipp Lehmann @PhilippTheServer

Designing a Consistent FastAPI Response Envelope

Ad-hoc return values leave every client guessing whether a payload is wrapped.

The problem

A FastAPI application that grew endpoint by endpoint, each written by whoever needed it that week, tends to end up with three or four different response shapes in active use:

@router.get("/items/{item_id}")
def get_item(item_id: int):
    return {"id": item_id, "name": "widget"}


@router.get("/items")
def list_items():
    return {"status": "ok", "data": [{"id": 1, "name": "widget"}]}


@router.post("/orders")
def create_order(payload: OrderCreate):
    return {"success": True, "order": {"id": 1}}

None of these is wrong on its own — each returns valid JSON that answers its own request correctly. The problem only appears once a client has to consume more than one of them: is the payload at the top level, or under data, or under a feature-specific key like order? Is a boolean success flag present, and if so is it called status or success, and does its absence mean failure or just mean this particular endpoint never had the field? A frontend or another service ends up writing a different unwrapping function per endpoint, and every new endpoint is a coin flip about which shape it will introduce.

Errors compound this. A validation failure returns FastAPI’s default shape ({"detail": [...]}), a manually raised HTTPException returns {"detail": "..."}, and a hand-rolled error dict from inside a route returns whatever that route’s author decided that day. A client cannot write one piece of code that reliably extracts “did this succeed, and if not why” across the whole API.

Working through it

Deciding what the envelope actually needs to say

Before writing any code, the envelope has to answer a small, fixed set of questions for every response, success or failure: did it succeed, what is the payload if it did, and what is the error if it did not. Anything beyond that — pagination metadata, request IDs — is worth adding only once a concrete need for it exists, because every field in a shared envelope is a field every client has to learn to ignore when it does not apply.

class Envelope(BaseModel, Generic[T]):
    success: bool
    data: T | None = None
    error: str | None = None

Generics keep the OpenAPI schema honest

A tempting shortcut is data: Any, which works but throws away the one thing FastAPI would otherwise give clients for free: a precise schema per endpoint in /docs and in the generated OpenAPI document. Pydantic’s Generic[T] support means Envelope[Item] and Envelope[list[Item]] are each their own concrete schema once used as a response_model, so a client’s code generator sees exactly what data contains for that specific endpoint, not an unhelpful "data": {}.

@router.get("/items/{item_id}", response_model=Envelope[Item])
def get_item(item_id: int) -> Envelope[Item]:
    item = find_item(item_id)
    return Envelope(success=True, data=item)

Repeating Envelope(success=True, data=...) in every route is its own kind of drift

Wrapping the return value by hand in every endpoint works, but it is exactly the kind of repeated boilerplate that someone eventually forgets — one route returns the bare object instead of the envelope, and it passes review because it still looks like reasonable code. A custom APIRoute class fixes this at the framework level rather than relying on every route author remembering: FastAPI lets you override how a route’s endpoint function result becomes an HTTP response, so the wrapping happens once, centrally, for every route attached to a router that uses it.

class EnvelopeRoute(APIRoute):
    def get_route_handler(self):
        original_handler = super().get_route_handler()

        async def custom_handler(request: Request) -> Response:
            response = await original_handler(request)
            body = json.loads(response.body)
            wrapped = {"success": True, "data": body, "error": None}
            return JSONResponse(content=wrapped, status_code=response.status_code)

        return custom_handler

This moves the decision “does this response get wrapped” from “did the author remember” to “which router class is this endpoint attached to” — a property visible at the top of the file, not something that has to be checked function by function.

The trade-off this creates, stated plainly

A blanket envelope costs something real: every response body now carries an extra layer of nesting that every client has to unwrap, even for the simplest possible endpoint, and it makes the API slightly more verbose on the wire for no benefit on endpoints that were already unambiguous. It also means the HTTP status code and the envelope’s success field can, if you are not careful with the exception handling, disagree — a 200 with "success": false is confusing in a different way than the problem you started with. Deciding that a non-2xx status code always implies success: false in the envelope, and enforcing that pairing in one place (an exception handler, not each route), is what keeps the two signals from drifting apart from each other.

The solution

A complete FastAPI application with the generic envelope and the custom route class, plus tests that assert the exact response shape for both a plain object and a list endpoint.

app/
  main.py
  envelope.py
  schemas.py
tests/
  test_envelope.py
requirements.txt
# app/envelope.py
from __future__ import annotations

import json
from typing import Generic, TypeVar

from fastapi import Request, Response
from fastapi.responses import JSONResponse
from fastapi.routing import APIRoute
from pydantic import BaseModel

T = TypeVar("T")


class Envelope(BaseModel, Generic[T]):
    success: bool
    data: T | None = None
    error: str | None = None


class EnvelopeRoute(APIRoute):
    def get_route_handler(self):
        original_handler = super().get_route_handler()

        async def custom_handler(request: Request) -> Response:
            response = await original_handler(request)
            body = json.loads(response.body)
            wrapped = {"success": True, "data": body, "error": None}
            return JSONResponse(content=wrapped, status_code=response.status_code)

        return custom_handler
# app/schemas.py
from pydantic import BaseModel


class Item(BaseModel):
    id: int
    name: str
    price: float
# app/main.py
from fastapi import APIRouter, FastAPI

from app.envelope import EnvelopeRoute
from app.schemas import Item

app = FastAPI(title="Envelope example")
router = APIRouter(route_class=EnvelopeRoute)

_items = {
    1: Item(id=1, name="widget", price=9.99),
    2: Item(id=2, name="gadget", price=19.99),
}


@router.get("/items/{item_id}", response_model=Item)
def get_item(item_id: int) -> Item:
    return _items[item_id]


@router.get("/items", response_model=list[Item])
def list_items() -> list[Item]:
    return list(_items.values())


app.include_router(router)
# requirements.txt
fastapi==0.115.0
uvicorn[standard]==0.30.6
pydantic==2.9.2
httpx==0.27.2
pytest==8.3.3
# tests/test_envelope.py
from fastapi.testclient import TestClient

from app.main import app

client = TestClient(app)


def test_single_item_is_wrapped():
    response = client.get("/items/1")
    body = response.json()
    assert body["success"] is True
    assert body["error"] is None
    assert body["data"] == {"id": 1, "name": "widget", "price": 9.99}


def test_list_is_wrapped_as_a_single_data_array():
    response = client.get("/items")
    body = response.json()
    assert body["success"] is True
    assert isinstance(body["data"], list)
    assert len(body["data"]) == 2

Verifying it

python -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
pytest tests/ -v
uvicorn app.main:app --reload
curl -s localhost:8000/items/1 | python -m json.tool

Correct output from the curl:

{
    "success": true,
    "data": {
        "id": 1,
        "name": "widget",
        "price": 9.99
    },
    "error": null
}

Both test cases pass, and /docs shows Envelope_Item_ and Envelope_list_Item__ as distinct, precisely-typed schemas for the two endpoints — the generic keeps the automatic documentation as specific as it would be without any wrapping at all.

Conclusion

A shared response shape is only worth it once more than one client consumes the API. For a single frontend maintained by the same team as the backend, a hand-rolled shape per endpoint costs less than the ceremony of a generic envelope; the payoff shows up when multiple, independently-maintained consumers all need to parse responses the same way.

Centralise the wrapping mechanically, do not rely on every endpoint author repeating it by hand. A custom route class (or equivalent middleware) makes “is this wrapped” a property of the router, checkable at a glance, rather than a convention that degrades one forgotten return statement at a time.

Pin the relationship between HTTP status and the envelope’s own success field, and enforce it in one place. Letting individual routes decide both independently is how you end up with a 200 that says "success": false, or a 422 that says "success": true — each individually plausible, together a contract nobody can rely on.