FastAPI – Designing the REST API

September 20, 202514 min readUpdated 8/23/2026

The previous lessons cover the mechanics. This one is about the decisions — what the URLs should be, which verb does what, what a response should and should not contain, and how to return a page of results without the pager lying. These are the choices a client lives with, and they are the expensive ones to change later.

Resources, not procedures

The first instinct is usually to name endpoints after the code behind them. That produces a remote procedure call with a URL:

POST /getPropertyById
POST /createNewBooking
POST /updatePropertyPrice
POST /doCancelBooking

The alternative is to name the thing and let the verb say what is being done to it:

GET    /properties/{id}
POST   /bookings
PATCH  /properties/{id}
POST   /bookings/{id}/cancel

The practical gains are concrete rather than aesthetic. Caches and proxies understand that GET is safe to repeat and cache; they cannot know that about POST /getPropertyById. Anyone who has used another REST API can guess your URLs. And the set stays small — four verbs against a noun rather than a new endpoint per operation.

Plural nouns for collections, consistently. /properties/{id} reads as "the properties collection, this one", and mixing /property/{id} with /bookings guarantees someone gets it wrong at three in the morning.

PATCH, not PUT

PUT replaces a resource; PATCH modifies part of it. The difference matters more than it sounds:

@router.patch("/{public_id}", response_model=PropertyResponse)
def update_property(
    public_id: UUID, payload: PropertyUpdateRequest, user: CurrentUser, db: DbSession
) -> PropertyResponse:
    """PATCH, not PUT: only the fields present in the body are changed."""
    return PropertyResponse.model_validate(PropertyService(db).update(public_id, user, payload))

With PUT, a client that wants to change a price must send the whole listing back — title, description, amenities, images, everything. If it fetched that object thirty seconds ago and somebody edited the description in between, the write silently reverts it. That is a lost update, and it happens without anybody doing anything wrong.

PATCH avoids it by sending only what changed, which is why the update model has every field optional:

class PropertyUpdateRequest(ApiModel):
    """Every field optional — this is a PATCH, and `None` means "leave it alone".

    That is also why status is not here: publishing is a transition with rules, not a field
    assignment. It gets its own endpoint.
    """

There is a wrinkle worth knowing: with everything optional, None means both "leave it alone" and "set it to null". StayHub reads it as the first, which makes explicitly nulling a field impossible through PATCH. When you need both, model_fields_set tells you which keys were actually present in the request as opposed to defaulted.

Some changes are not field assignments

This is the design point people miss, and the one that keeps an API honest.

A listing has a status. It is tempting to let clients PATCH it. StayHub does not, and instead gives the transition its own endpoint:

@router.post("/{public_id}/publish", response_model=PropertyResponse)
def publish_property(public_id: UUID, user: CurrentUser, db: DbSession) -> PropertyResponse:
    """Go live. Checks the listing is complete, then indexes it into Elasticsearch."""
    return PropertyResponse.model_validate(PropertyService(db).publish(public_id, user))


@router.post("/{public_id}/unpublish", response_model=PropertyResponse)
def unpublish_property(public_id: UUID, user: CurrentUser, db: DbSession) -> PropertyResponse:
    """Back to draft and out of search. Existing bookings are untouched."""
    return PropertyResponse.model_validate(PropertyService(db).unpublish(public_id, user))

Publishing is not "set a column to PUBLISHED". It checks the listing is complete, then writes it into the search index. Unpublishing removes it from search and deliberately leaves existing bookings alone. Neither is expressible as a field assignment, and exposing the field would let a client skip all of it.

The signal to watch for: if changing a field has preconditions or side effects, it is an action, not a field. Cancelling a booking is the same shape — there is a deadline, and the dates have to be released — so it is POST /bookings/{id}/cancel and the status is read-only in the response.

Purists dislike the verb in the URL. The alternative is a client that has to know the business rules, which is worse. A sub-resource action on a noun is a widely used and perfectly defensible compromise.

response_model is a security boundary

It looks like a formatting convenience. It is the thing standing between your database columns and the internet.

@router.get("/{public_id}", response_model=PropertyResponse)

FastAPI filters the return value through that model. A field not on the model does not appear, even if the object you returned has it. So the default changes direction: a new column is private until somebody deliberately exposes it, rather than public the moment it is added.

StayHub's listing response omits the street address, and its nested host omits the email:

class PropertyHostResponse(ApiModel):
    """The slice of a host a guest is allowed to see. Note the absence of `email`."""

    public_id: UUID
    first_name: str
    avatar_url: str | None = None
    host_bio: str | None = None

Returning the ORM object directly would publish the host's email, their password hash, their role and their soft-delete flag. Not because anyone decided to — because nobody decided not to.

Pagination that does not lie

A list endpoint that returns a bare list has nowhere to put the total, so the client cannot render "page 3 of 12" or even know whether to show a Next button. By the time you notice, the endpoint is public and changing its shape is a breaking change.

An envelope solves it, and a generic one solves it once:

class Page(ApiModel, Generic[T]):
    items: list[T]
    total: int
    page: int
    page_size: int

    @property
    def pages(self) -> int:
        return (self.total + self.page_size - 1) // self.page_size if self.page_size else 0

    @classmethod
    def of(cls, items: list[T], total: int, params: PageParams) -> "Page[T]":
        return cls(items=items, total=total, page=params.page, page_size=params.page_size)

Page[AdminUserRow] produces a distinct schema in the OpenAPI document, so a generated client gets a real type rather than any. That is the payoff for making it generic rather than writing a bespoke envelope per endpoint.

{"items":[{"publicId":"c6f21f71-...","email":"guest@stayhub.test","firstName":"Sam",
  "lastName":"Okafor","role":"CUSTOMER","isHost":false,
  "createdAt":"2026-08-21T06:17:32.571844Z"}],
 "total":4,"page":1,"pageSize":2}

Two ways a pager goes wrong

The count and the query must carry the same filters. It is easy to build the filtered page and then count the whole table — the rows look right, the pager is silently wrong, and nothing errors:

    """Browse accounts.

    ⚠️ The COUNT and the SELECT must carry the SAME filters. It is easy to build the filtered page
    and then count the whole table — the rows look right, the pager is silently wrong, and nothing
    errors. Both are built from `conditions` below for exactly that reason.
    """

Building one list of conditions and using it twice makes the mistake hard to make:

    total = db.execute(select(func.count(User.id)).where(*conditions)).scalar_one()

LIMIT/OFFSET without ORDER BY is undefined. Postgres makes no promise about row order without one, so the same page-2 request can return rows already seen on page 1:

    rows = db.execute(
        select(User)
        .where(*conditions)
        .order_by(User.id.desc())
        .limit(page.page_size)
        .offset(page.offset)
    ).scalars().all()

Order by something unique. Sorting by created_at alone is not enough when two rows share a timestamp — their relative order is still undefined, and they can swap between requests. Add the primary key as a tiebreaker.

One more thing OFFSET does badly: it gets slower as the offset grows, because the database still produces and discards every skipped row. It is fine for an admin console and wrong for an infinite scroll over millions of rows, where keyset pagination — "give me the next 20 after this id" — is the right tool.

The whole endpoint, assembled

Put the pieces together and a paginated, filtered list endpoint is about twenty lines — most of it the conditions:

@router.get("/users", response_model=Page[AdminUserRow])
def list_users(
    _: AdminUser,
    db: DbSession,
    page: PageQuery,
    q: str | None = Query(default=None, max_length=200, description="Matches email or name"),
    is_host: bool | None = Query(default=None, alias="isHost"),
) -> Page[AdminUserRow]:
    return Page.of([AdminUserRow.model_validate(u) for u in rows], total, page)

The signature carries the contract — staff only, a bounded page, two optional filters — and the body carries the query. Adding a third filter is one parameter and one condition, and the pagination, the bounds and the envelope all come along unchanged.

Note the response row is its own model rather than the full user:

class AdminUserRow(ApiModel):
    public_id: UUID
    email: str
    first_name: str
    last_name: str
    role: str
    is_host: bool
    created_at: datetime

A list row and a detail view want different amounts of data. Reusing the detail model for lists means every row carries fields nobody renders, which on a page of a hundred is real bytes for nothing — and any nested relationship it declares is a query per row unless you eager-load it.

Filtering

Filters are query parameters, optional, and each one appends a condition:

    conditions = [User.deleted.is_(False)]
    if q:
        pattern = f"%{q}%"
        conditions.append(
            User.email.ilike(pattern)
            | User.first_name.ilike(pattern)
            | User.last_name.ilike(pattern)
        )
    if is_host is not None:
        conditions.append(User.is_host.is_(is_host))

Two details. if is_host is not None, not if is_host — otherwise ?isHost=false is indistinguishable from not sending it, and filtering for non-hosts becomes impossible.

And ilike here is SQLAlchemy building a parameterised LIKE, not string interpolation. An f-string in a where clause is how SQL injection gets in. The pattern's % characters are part of the bound value, not part of the statement:

    def test_a_quote_in_the_query_is_a_value_not_syntax(self, client):
        """Parameterised, not interpolated. If this 500s, a string is being built into SQL."""
        r = client.get("/api/v1/admin/users?q=%27%20OR%201%3D1%20--")
        assert r.status_code == 200
        assert r.json()["total"] == 0

Sorting

Accept a small closed set of sorts, never a raw column name. This is StayHub's:

    sort: str = Query(default="relevance", pattern="^(relevance|price_asc|price_desc|rating)$"),

The temptation is ?sortBy=price_per_night&order=desc, mapped straight onto a column. Two problems. It exposes your schema as your API, so a column rename is a breaking change. And unless every value is validated against an allow-list, it is an injection point — order_by takes a string.

A closed set also lets a sort mean something the database does not: relevance is a search score, not a column, and rating can mean "rating, then number of reviews as a tiebreaker" without the client knowing.

Caching, which is mostly free

Two headers do most of the work, and REST's verb discipline is what makes them usable at all — a proxy can cache GET /properties/{id} precisely because GET is defined as safe.

@router.get("/amenities", response_model=list[AmenityResponse])
def list_amenities(db: DbSession) -> list[AmenityResponse]:
    """The amenity vocabulary, for the listing form and the search filter panel.

    A read on the write service, deliberately: it is a tiny static lookup, and routing it through
    Hasura would mean the listing form needs a GraphQL client just for this.
    """

That endpoint returns a vocabulary that changes perhaps twice a year and is fetched on every page load of the listing form. A Cache-Control: public, max-age=3600 on it removes those requests entirely:

@router.get("/amenities", response_model=list[AmenityResponse])
def list_amenities(db: DbSession, response: Response):
    response.headers["Cache-Control"] = "public, max-age=3600"
    ...

ETag is the other half, for data that changes unpredictably: return a hash of the representation, and a client that sends it back as If-None-Match gets a 304 Not Modified with no body. You still do the work of producing the answer, but you do not send it — worth it when responses are large.

The rule that matters: never cache anything user-specific as public. A shared proxy caching one guest's /bookings/mine and serving it to another is a data breach delivered by an optimisation. Use Cache-Control: private for anything behind a token, or no caching at all.

How deep to nest

Nesting expresses ownership, and one level of it is almost always enough:

GET  /properties/{id}/bookings        one level — fine
GET  /hosts/{id}/properties/{pid}/bookings/{bid}/payments    four — do not

By the time a URL has four ids, three of them are redundant — a payment id already identifies its booking, its property and its host. Nest one level to scope a collection, then use the top-level resource for anything with an id of its own:

GET  /bookings/availability/{property_id}     scoped by property
GET  /bookings/{public_id}                    identified on its own

StayHub goes further and uses a filter rather than nesting for a host's view of their own bookings: GET /bookings/hosting rather than /hosts/{id}/bookings. The host is the authenticated caller, so putting their id in the URL adds nothing and invites somebody to try another one.

Version from the first day

app.include_router(api_router, prefix=settings.api_v1_prefix)

One line, and it buys the ability to run /api/v2 beside /api/v1 when a breaking change becomes unavoidable. Retrofitting a version prefix once clients exist means every one of them changes on the same day.

What counts as breaking is narrower than people assume. Adding an optional field, a new endpoint, or a new enum value a client can ignore is safe. Removing or renaming a field, changing a type, making an optional field required, or changing a status code is not. The asymmetry is worth internalising, because it means most evolution needs no new version at all.

Repeat-safety

Networks retry. A client that times out and retries a POST /bookings may create two bookings, and it will not know it did.

GET, PUT and DELETE are idempotent by definition; POST is not. StayHub gets away with it because the exclusion constraint makes a duplicate booking for the same dates impossible — the retry gets a 409 rather than a second row. That is worth noticing as a pattern: a uniqueness constraint on something the client already knows is the cheapest idempotency you can get.

When the operation has no natural unique key, the general answer is an Idempotency-Key header: the client generates one per logical operation, the server stores it with the result, and a repeat returns the stored result instead of acting again. Stripe's API is the reference implementation, and it is worth copying if you take payments.

When a collection is not a list endpoint

StayHub has both GET /properties/mine and GET /search, and they look like the same kind of thing. They are not, and the split is worth understanding because most applications eventually need it.

"""`GET /search` — the one read that does not come from Hasura (decision D2).

Everything else the frontends read is a GraphQL query. This is not, because the whole point of
maintaining an Elasticsearch index is to answer that question without touching Postgres, and
Hasura reads Postgres.
"""

A list endpoint answers "the rows matching these exact filters", from the system of record, exactly. A search endpoint answers "the things most relevant to this", from an index, approximately — with ranking, fuzzy matching and a relevance score.

They differ in ways that show up in the API. Search results are ranked, so sort=relevance is a real option that a database list cannot offer. Search is eventually consistent, because the index is updated after the write. And search has a hard ceiling: the default from + size window is capped at 10,000 results, which is why deep pagination is not free there:

    # Elasticsearch's default `from + size` window is capped at 10,000 results. Past that you need
    # `search_after`; a demo never gets there, but the cap is why deep pagination is not free.
    page: int = Field(default=1, ge=1, le=100)

Trying to serve both from one endpoint means one of the two is done badly. Keep them separate and let each be honest about what it is.

Bulk operations

Sooner or later a client wants to change twenty things at once, and twenty round trips is the wrong answer. The design question is what happens when three of them fail.

The shape that causes least pain accepts a list and returns a result per item, with a 207-style body rather than a single status:

{"results": [
  {"id": "a1b2...", "ok": true},
  {"id": "c3d4...", "ok": false, "error": {"message": "Listing not found.", "fieldErrors": {}}},
  {"id": "e5f6...", "ok": true}
]}

The alternative — all-or-nothing in one transaction — is simpler and right when the items are genuinely one unit of work. It is wrong when they are independent, because one bad id then rejects nineteen good changes and the client has no way to know which one was the problem.

Whichever you choose, bound the list. A bulk endpoint with no maximum is a way to ask the server to do unbounded work in one request, and the timeout it eventually produces looks like an outage rather than a rejected request.

Documenting it for somebody else

The OpenAPI document is generated, so the question is not whether to write documentation but how much to shape what is already there.

Three things repay the effort. Tags, so endpoints group by resource rather than appearing in one flat list. Docstrings, whose first line becomes the summary and whose body renders as markdown — which is where a rule like "creates nothing" or a ⚠️ about what a parameter does not do belongs. And the error responses a caller must branch on, since FastAPI can only infer the success case.

Application-level metadata is worth five minutes once:

app = FastAPI(
    title=settings.app_name,
    version="1.0.0",
    description=(
        "StayHub — the WRITE side of an Airbnb-style app.\n\n"
        "Every create, update and delete lives here. Reads come from Hasura GraphQL "
        "(http://localhost:8081) with ONE deliberate exception: `GET /api/v1/search`, which "
        "queries Elasticsearch. Both accept the same JWT this API issues."
    ),
    lifespan=lifespan,
    docs_url="/docs",
)

That description is doing real work. It tells a new consumer which API answers which question — the single thing about this system that is not guessable from the endpoint list, and the thing they would otherwise have to be told in a conversation.

One decision to make deliberately: whether /docs is public. FastAPI(docs_url=None, redoc_url=None) turns it off, which is right for an internal API where the route list is not something you want to publish. Behind authentication is the middle option, and it needs a custom route since the built-in one has no dependency hook.

Small decisions worth making once

  • 404 rather than 403 for somebody else's resource. A 403 confirms the id exists, which on a guessable identifier is a slow enumeration. StayHub's admin API is the exception — a documented surface whose existence is not a secret.
  • Return the updated object from a PATCH or an action, not 204 No Content. It saves the client a follow-up GET and removes a window where its copy is stale.
  • Soft-delete returns 200 with a message, not 204, when something remains true afterwards worth saying — StayHub's returns {"message": "Listing removed."}.
  • Dates as ISO 8601 strings, always UTC. Let the client localise. A naive datetime in an API is a bug that only appears in another timezone.
  • Money as a decimal string, not a float. "797.40", not 797.4.
  • Reads that need a rule are still reads. StayHub's /properties/mine returns drafts, so it needs "only your own rows" — that makes it an endpoint rather than something a generic query layer can serve.

Next: one error shape for the whole API — because everything above assumes failures are as well designed as successes, and by default they are not.