pyguides

Build CRUD REST APIs with FastAPI

CRUD is the bread and butter of most REST APIs: create a resource, read it back, update it, and delete it. FastAPI maps cleanly onto those four operations, which is why it is so easy to build CRUD endpoints with FastAPI: each HTTP verb (POST, GET, PUT, PATCH, DELETE) is a first-class decorator, and Pydantic models double as both validation and OpenAPI documentation.

This guide picks up where the FastAPI quickstart leaves off. By the end you will have a single-file, in-memory items API with proper status codes, request and response model separation, and a small test suite. You will also know the gotchas that trip up developers new to the framework.

TL;DR

The short version of how to build CRUD endpoints with FastAPI:

  • Map POST, GET, PUT, PATCH, and DELETE to one path operation decorator each.
  • Split Pydantic schemas by intent: ItemBase, ItemCreate, ItemUpdate, and ItemOut.
  • Set status_code on the decorator, never on the function, and use response_model to filter responses.
  • Use model_dump(exclude_unset=True) so PATCH does not overwrite existing values with None.
  • Test with FastAPI’s built-in TestClient, which is built on httpx and needs no live server.

Why FastAPI fits CRUD work

Three properties of FastAPI make it a natural fit when you build CRUD endpoints:

  • Path operation decorators (@app.get, @app.post, @app.put, @app.patch, @app.delete) map one-to-one onto HTTP verbs. No router boilerplate required for a small API.
  • Pydantic models are the request body, the response body, and the OpenAPI schema, all at once. You define ItemCreate once, and FastAPI reuses it for validation, serialization, and the auto-generated /docs page.
  • Status codes and HTTPException are first-class citizens. A 404 is one line, not a custom error middleware.

Project setup

You should already have FastAPI installed from the quickstart. Save the code below to a file called main.py and run it with uvicorn main:app --reload:

uvicorn main:app --reload

The --reload flag restarts the server on file changes, which is what you want while iterating. The full source for this guide is short enough to live in a single file.

The data model with Pydantic v2

The under-taught pattern that makes a CRUD API maintainable is splitting schemas by intent. You do not want one Item class doing the job of four:

from pydantic import BaseModel, Field

class ItemBase(BaseModel):
    name: str = Field(min_length=1, max_length=100)
    description: str | None = None
    price: float = Field(gt=0)

class ItemCreate(ItemBase):
    """Fields a client must send to create an item."""
    pass

class ItemUpdate(BaseModel):
    """Fields a client may send on PATCH. All optional."""
    name: str | None = Field(default=None, min_length=1, max_length=100)
    description: str | None = None
    price: float | None = Field(default=None, gt=0)

class ItemOut(ItemBase):
    """What the server returns. Includes server-assigned fields."""
    id: int

ItemBase holds the shared fields. ItemCreate inherits them and is what POST accepts. ItemUpdate is the PATCH body, where every field is optional. ItemOut adds the id and is what the server returns on every response.

A few notes on the Pydantic v2 syntax you are seeing:

  • Field(min_length=..., max_length=..., gt=...) is the per-field validation API. Anything that fails gets a 422 with a structured error body. The full set of constraints lives in the Pydantic models documentation.
  • model_dump() (v2) replaces the v1 .dict() method. It returns a plain dict you can store or merge.
  • If you copy an old Stack Overflow answer that uses class Config:, that is the v1 syntax. In v2 you use model_config = ConfigDict(...) instead.

The in-memory store

For this guide the database is a dict plus a counter. Keeping state in memory keeps the focus on FastAPI’s request handling. The SQLAlchemy basics guide shows the persistent version.

items_db: dict[int, dict] = {}
next_id = 1

That is it. Two module-level variables. The counter hands out monotonically increasing ids, and the dict gives O(1) lookups.

How do you create a resource with POST?

POST creates a new resource. Convention says the response status should be 201 Created, and the response body should be the created resource (including its new id):

from fastapi import FastAPI, HTTPException, status

app = FastAPI(title="Items API", version="0.1.0")

@app.post("/items/", response_model=ItemOut, status_code=status.HTTP_201_CREATED)
async def create_item(payload: ItemCreate) -> ItemOut:
    global next_id
    item = ItemOut(id=next_id, **payload.model_dump())
    items_db[next_id] = item.model_dump()
    next_id += 1
    return item

Two details worth calling out:

  • status_code=status.HTTP_201_CREATED is a parameter of the @app.post(...) decorator, not of the function. The FastAPI guide on response status codes emphasizes this distinction.
  • response_model=ItemOut filters the response through the schema. If your stored dict ever had extra fields (a password_hash, say), they would not leak into the JSON body. The return type annotation alone does not do this.

How do you read resources with GET?

Two reads are worth supporting: a paginated list and a single resource by id:

@app.get("/items/", response_model=list[ItemOut])
async def list_items(skip: int = 0, limit: int = 10) -> list[ItemOut]:
    return list(items_db.values())[skip : skip + limit]

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

Three things to notice:

  • skip and limit are query parameters, not path parameters. FastAPI infers this from the fact that they are typed function arguments, not in the URL path.
  • item_id: int automatically rejects non-integer values with a 422 response. You do not have to validate the type yourself.
  • A missing resource raises HTTPException(status_code=404, detail="Item not found"). The detail becomes the JSON body of the error response.

How do you update resources with PUT and PATCH?

PUT replaces the resource entirely, and PATCH applies a partial update. They have different semantics, and FastAPI lets you model both with a single endpoint and a different payload type:

@app.put("/items/{item_id}", response_model=ItemOut)
async def replace_item(item_id: int, payload: ItemCreate) -> ItemOut:
    if item_id not in items_db:
        raise HTTPException(status_code=404, detail="Item not found")
    items_db[item_id] = ItemOut(id=item_id, **payload.model_dump()).model_dump()
    return items_db[item_id]

@app.patch("/items/{item_id}", response_model=ItemOut)
async def update_item(item_id: int, payload: ItemUpdate) -> ItemOut:
    if item_id not in items_db:
        raise HTTPException(status_code=404, detail="Item not found")
    stored = items_db[item_id].copy()
    stored.update(payload.model_dump(exclude_unset=True))
    items_db[item_id] = stored
    return stored

The PATCH endpoint uses payload.model_dump(exclude_unset=True). That is the trick. Without it, model_dump() returns every field, with None for the ones the client did not send, and you would clobber existing values with nulls. With exclude_unset=True, you get back only the fields the client actually included in the request body.

How do you delete a resource with DELETE?

DELETE removes the resource. The standard response is 204 No Content, which means you must not return a body:

@app.delete("/items/{item_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_item(item_id: int) -> None:
    if item_id not in items_db:
        raise HTTPException(status_code=404, detail="Item not found")
    del items_db[item_id]
    return None

The function has no meaningful return value, and the status code is 204. If you accidentally return {"deleted": True} here, the response will be a 204 with a body, which violates the HTTP spec and confuses clients.

How do you test FastAPI CRUD routes?

FastAPI ships a TestClient built on httpx, so you can write tests without spinning up a real server. Put the tests in a file called test_main.py:

from fastapi.testclient import TestClient
from main import app

client = TestClient(app)

def test_create_and_get_item():
    r = client.post("/items/", json={"name": "widget", "price": 9.99})
    assert r.status_code == 201
    item_id = r.json()["id"]

    r = client.get(f"/items/{item_id}")
    assert r.status_code == 200
    assert r.json()["name"] == "widget"

def test_get_missing_item_returns_404():
    r = client.get("/items/9999")
    assert r.status_code == 404

Run with pytest test_main.py. For a fuller tour of pytest, the pytest basics guide covers fixtures, parametrize, and the rest of what you will need as the suite grows.

What are the common gotchas?

A few mistakes that catch almost everyone the first time:

  • status_code goes on the decorator (@app.post(..., status_code=201)), not on the function signature. The official docs spell this out because the distinction trips people up.
  • response_model is the only thing that filters the response. Annotating the return type does not strip extra fields. If you have a password_hash on the stored dict and forget response_model=ItemOut, it will appear in the JSON.
  • PATCH requires exclude_unset=True on model_dump(). Without it, omitted fields overwrite existing values with None.
  • 204 No Content cannot have a body. Returning {"deleted": True} from a delete handler with status_code=204 is wrong.
  • 422 is automatic. You do not need to catch ValidationError and re-raise. FastAPI already returns a 422 with a per-field error list.
  • Sync versus async: for in-memory CRUD, plain def reads more naturally. async def is only required when you are awaiting something (an async database driver, an httpx.AsyncClient call, and so on). Do not annotate everything async by reflex.

Where to go next

You now have a working CRUD API, but the data disappears every time the server restarts. To build CRUD endpoints that persist, the natural next steps are:

  • Swap the dict for a real database. The SQLAlchemy basics guide walks through an ORM, and SQLModel is a Pydantic-friendly alternative if you want to keep the same model classes on both sides.
  • Add authentication. The FastAPI auth tutorial covers JWTs and dependency injection.
  • Split the API into multiple files. FastAPI’s APIRouter lets you group routes by resource, which matters once you have more than one or two collections.

For deeper coverage of the underlying Pydantic features used here, the Pydantic guide is worth a read. With the foundation in this guide, you can now build CRUD endpoints with FastAPI for a real service: the path operations, the request and response models, the status codes, the error handling, and the test client. The rest is iteration.

Frequently asked questions

A few questions developers ask most often when they build CRUD endpoints with FastAPI:

What’s the difference between PUT and PATCH in FastAPI CRUD?

PUT replaces the entire resource, so every required field must be in the request body. PATCH applies a partial update, so every field is optional and you use model_dump(exclude_unset=True) to read only the fields the client actually sent. FastAPI just maps both verbs to a path operation decorator; the semantics come from your payload schema.

Why use response_model instead of a return type annotation?

A return type annotation like -> ItemOut is a static-type hint only. response_model=ItemOut is what actually filters the response payload and registers the schema in OpenAPI. Without it, extra fields on your stored dict will leak into the JSON body.

Should I use async def or def for FastAPI handlers?

For in-memory CRUD, plain def reads more cleanly. async def matters when you are awaiting something inside the handler, like an async database driver or an httpx.AsyncClient call. FastAPI runs sync handlers in a threadpool, so blocking work does not stall the event loop.

How do you build CRUD endpoints with FastAPI and a real database?

Replace the in-memory dict with a real database. The SQLAlchemy basics guide shows the equivalent setup with an ORM, and SQLModel is worth a look if you want to keep the same model classes on both sides.

See also