Skip to content

Using TypeDicts in FastAPI for PATCH endpoints

This article goes through a quick example of how to use TypedDict in FastAPI for the body of a PATCH endpoint.

Consider a FastAPI application with the following endpoints

  • GET /movies: Get all movies
  • GET /movies/{movie_id}: Get a movie by ID

Here's what the output of the latter endpoint might look like

http -p=b GET :8000/movies/1
{
    "comment": null,
    "id": 1,
    "rating": null,
    "title": "Pulp Fiction",
    "year": 1994
}

and let's say we want to add the following endpoint:

  • PATCH /movies/{movie_id}: Update a movie's rating or comment

The HTTP verb PATCH makes the most sense for this operation, as it's used to apply partial modifications to a resource. The request body should contain only the fields that need to be updated.

Let's look at different implementations and point out the issues with each.

Almost there: Pydantic model with extra="forbid"

The first solution that might come to mind is adding a Pydantic model with the fields that can be updated, and setting extra="forbid" to prevent any other fields from being passed.

from pydantic import BaseModel, ConfigDict


class MovieUpdate(BaseModel):
    model_config = ConfigDict(extra="forbid")

    rating: Rating
    comment: Comment


@app.patch("/movies/{movie_id}")
def update_movie(movie_id: int, update: schemas.MovieUpdate) -> schemas.MovieRead:
    if movie := db.get(doc_id=movie_id):
        movie.update(update.model_dump())
        db.update(movie, doc_ids=[movie_id])
        return schemas.MovieRead.model_validate({"id": movie.doc_id, **movie})

    raise HTTPException(
        status_code=http.HTTPStatus.NOT_FOUND,
        detail="Movie not found",
    )

This approach will correctly disallow updating fields that are not in the MovieUpdate model, such as title and year, but fields are required so we cannot omit them from the request body.

We could make the fields optional by setting None as the default value:

class MovieUpdate(BaseModel):
    model_config = ConfigDict(extra="forbid")

    rating: Rating | None = None
    comment: Comment | None = None

This is not ideal because it allows None as a valid value for the fields, which is not what we want.

The solution: TypedDict

The TypedDict class was introduced in Python 3.8. Pydantic has support for it, and it's perfect for this use case.

from typing import TypedDict

from pydantic import ConfigDict, with_config


@with_config(ConfigDict(extra="forbid"))  # (1)
class MovieUpdate(TypedDict, total=False):  # (2)
    rating: Rating
    comment: Comment


@app.patch("/movies/{movie_id}")
def update_movie(movie_id: int, update: schemas.MovieUpdate) -> schemas.MovieRead:
    if movie := db.get(doc_id=movie_id):
        movie.update(update)
        db.update(movie, doc_ids=[movie_id])  # (3)
        return schemas.MovieRead.model_validate({"id": movie.doc_id, **movie})

    raise HTTPException(
        status_code=http.HTTPStatus.NOT_FOUND,
        detail="Movie not found",
    )
  1. Use Pydantic's @with_config decorator to disallow extra fields.
  2. Use total=False to make all fields optional.
  3. Since movie is a dictionary, we can use it directly to update the fields.

This gives us all the desired behavior:

  1. Fields are optional

    http -p=b PATCH :8000/movies/1 rating:=3
    {
        "comment": null,
        "id": 1,
        "rating": 3,
        "title": "Pulp Fiction",
        "year": 1994
    }
    
  2. Extra fields are not allowed

    http -p=b PATCH :8000/movies/1 year:=2024
    {
        "detail": [
            {
                "input": 2024,
                "loc": [
                    "body",
                    "year"
                ],
                "msg": "Extra inputs are not permitted",
                "type": "extra_forbidden"
            }
        ]
    }
    
  3. Fields are not nullable

    http -p=b PATCH :8000/movies/1 rating:=null
    {
        "detail": [
            {
                "input": null,
                "loc": [
                    "body",
                    "rating"
                ],
                "msg": "Input should be a valid integer",
                "type": "int_type"
            }
        ]
    }
    

Further reading


Appendix: PEP 728

Update: September, 2025

PEP 728 has been accepted 🎉.

PEP 728 will allow us to declare the TypedDict with closed=True to disallow extra fields, or extra_items=... to allow only fields of a certain type. It will be available in Python 3.15, scheduled for release in October 2026, but typing-extensions already backported it in version 4.10.0.

When Pydantic supports it, the implementation would become simply:

class MovieUpdate(TypedDict, total=False, closed=True):  # (1)
    rating: Rating
    comment: Comment
  1. Use total=False to make all fields optional, and use closed=True to disallow extra fields.

Appendix: Full code

my_app/app.py
# /// script
# dependencies = [
#    "fastapi[standard]",
#    "mypy",
#    "pydantic",
#    "tinydb",
#    "uvicorn",
# ]
# requires-python = ">=3.13"
# ///

import http
import pathlib
from typing import Annotated, TypedDict

from fastapi import FastAPI, HTTPException
from pydantic import BaseModel, ConfigDict, Field, with_config
from tinydb import TinyDB

Comment = Annotated[
    str,
    Field(
        min_length=1,
        max_length=100,
        description="Comment up to 100 characters",
    ),
]

Rating = Annotated[
    int,
    Field(
        ge=1,
        le=5,
        description="Rating from 1 to 5",
    ),
]


class Movie(BaseModel):
    title: str
    year: int
    rating: Rating | None = None
    comment: Comment | None = None


class MovieRead(Movie):
    id: int


class MovieAdd(Movie): ...


class MovieList(BaseModel):
    movies: list[MovieRead]


@with_config(ConfigDict(extra="forbid"))
class MovieUpdate(TypedDict, total=False):
    rating: Rating
    comment: Comment


app = FastAPI()

tinydb_path = pathlib.Path("./db.json")
db = TinyDB(tinydb_path)


@app.get("/movies")
def list_movies():
    return MovieList(
        movies=[
            MovieRead.model_validate({"id": movie.doc_id, **movie})
            for movie in db.all()
        ]
    )


@app.get("/movies/{movie_id}")
def read_movie(movie_id: int):
    if movie := db.get(doc_id=movie_id):
        return MovieRead.model_validate({"id": movie.doc_id, **movie})

    raise HTTPException(
        status_code=http.HTTPStatus.NOT_FOUND,
        detail="Movie not found",
    )


@app.patch("/movies/{movie_id}")
def update_movie(movie_id: int, update: MovieUpdate) -> MovieRead:
    if movie := db.get(doc_id=movie_id):
        movie.update(update)
        db.update(movie, doc_ids=[movie_id])
        return MovieRead.model_validate({"id": movie.doc_id, **movie})

    raise HTTPException(
        status_code=http.HTTPStatus.NOT_FOUND,
        detail="Movie not found",
    )


@app.post("/movies")
def add_movie(movie: MovieAdd) -> MovieRead:
    movie_id = db.insert(movie.model_dump())
    new_movie = db.get(doc_id=movie_id)
    return MovieRead.model_validate({"id": movie_id, **new_movie})


@app.delete("/movies/{movie_id}", status_code=http.HTTPStatus.NO_CONTENT)
def delete_movie(movie_id: int) -> None:
    db.remove(doc_ids=[movie_id])


if __name__ == "__main__":
    import csv

    import uvicorn

    tinydb_path.unlink(missing_ok=True)
    db = TinyDB(tinydb_path)

    with pathlib.Path("./movies.csv").open() as f:
        reader = csv.DictReader(f)
        db.insert_multiple(reader)

    uvicorn.run(app, host="0.0.0.0", port=8000)