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"
            }
        ]
    }
    

Appendix: Full code

pyproject.toml
[project]
name = "fastapi-typeddict-patch"
version = "0.1.0"
dependencies = [
   "fastapi[standard]",
   "pydantic",
   "tinydb",
]
requires-python = ">=3.12"

[dependency-groups]
dev = [
    "mypy>=1.14.0",
    "ruff>=0.8.4",
]
my_app/app.py
import http

from fastapi import FastAPI, HTTPException
from tinydb import TinyDB

from my_app import schemas

app = FastAPI()
db = TinyDB("./db.json")


@app.get("/movies/")
def list_movies():
    return schemas.MovieList(
        movies=[
            schemas.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 schemas.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: schemas.MovieUpdate) -> schemas.MovieRead:
    if movie := db.get(doc_id=movie_id):
        movie.update(update)
        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",
    )
my_app/schemas.py
from typing import Annotated, TypedDict

from pydantic import BaseModel, ConfigDict, Field, with_config

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 MovieList(BaseModel):
    movies: list[MovieRead]


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