Skip to content

Using Pydantic's MISSING sentinel in FastAPI for PATCH endpoints

Starting with Pydantic 2.12.0, the MISSING sentinel can be used to annotate non-required, non-nullable fields, which is very useful for PATCH endpoints.

This article is a follow-up to Using TypeDicts in FastAPI for PATCH endpoints.

Let's say we want to add the following endpoint:

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

The request body will contain only the fields that user wants to update, and fail if the user provides the wrong data types, or if an unexpected field is included.

Solution: MISSING sentinel and extra="forbid"

The MISSING sentinel can be used to annotate non-required, non-nullable fields, and the extra="forbid" configuration can be used to disallow extra fields:

from pydantic import BaseModel, ConfigDict
from pydantic_core import MISSING


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

    rating: Rating | MISSING = MISSING
    comment: Comment | MISSING = MISSING


@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 will correctly disallow updating fields that are not in the MovieUpdate model, such as title and year, while the rating and comment fields are optional and can be omitted from the request body:

  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

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

import http
import pathlib
from typing import Annotated

from fastapi import FastAPI, HTTPException
from pydantic import BaseModel, ConfigDict, Field
from pydantic_core import MISSING
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]


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

    rating: Rating | MISSING = MISSING
    comment: Comment | MISSING = MISSING


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.model_dump())
        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)