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, andDELETEto one path operation decorator each. - Split Pydantic schemas by intent:
ItemBase,ItemCreate,ItemUpdate, andItemOut. - Set
status_codeon the decorator, never on the function, and useresponse_modelto filter responses. - Use
model_dump(exclude_unset=True)so PATCH does not overwrite existing values withNone. - Test with FastAPI’s built-in
TestClient, which is built onhttpxand 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
ItemCreateonce, and FastAPI reuses it for validation, serialization, and the auto-generated/docspage. - Status codes and
HTTPExceptionare 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 usemodel_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_CREATEDis a parameter of the@app.post(...)decorator, not of the function. The FastAPI guide on response status codes emphasizes this distinction.response_model=ItemOutfilters the response through the schema. If your stored dict ever had extra fields (apassword_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:
skipandlimitare query parameters, not path parameters. FastAPI infers this from the fact that they are typed function arguments, not in the URL path.item_id: intautomatically 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"). Thedetailbecomes 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_codegoes 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_modelis the only thing that filters the response. Annotating the return type does not strip extra fields. If you have apassword_hashon the stored dict and forgetresponse_model=ItemOut, it will appear in the JSON.PATCHrequiresexclude_unset=Trueonmodel_dump(). Without it, omitted fields overwrite existing values withNone.204 No Contentcannot have a body. Returning{"deleted": True}from a delete handler withstatus_code=204is wrong.422is automatic. You do not need to catchValidationErrorand re-raise. FastAPI already returns a 422 with a per-field error list.- Sync versus async: for in-memory CRUD, plain
defreads more naturally.async defis only required when you are awaiting something (an async database driver, anhttpx.AsyncClientcall, and so on). Do not annotate everythingasyncby 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
APIRouterlets 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.