FastAPI 1 - Introductie & Pydantic

Auteur

Fabrice Devaux

Publicatiedatum

24 februari 2026

Inleiding

FastAPI is een modern Python webframework voor het bouwen van API’s, ontworpen met een sterke focus op snelheid (Fast): enerzijds de snelheid van applicaties gemaakt met FastAPI, anderzijds de snelheid waarmee ontwikkeld kan worden.

Het werd rond 2018 ontwikkeld en bouwt voort op bestaande, robuuste bouwstenen uit het Python-ecosysteem zoals Starlette (voor de weblaag en async-afhandeling) en Pydantic (voor data-validatie en serialisatie). Starlette is voor FastAPI ongeveer wat Werkzeug voor Flask is.

De kernfilosofie van FastAPI ligt in het verlengde van Pydantic: dat type hints niet alleen documentatie zijn, maar uitvoerbare specificaties: dezelfde modellen die je schrijft voor input en output zorgen automatisch voor validatie, foutmeldingen én interactieve API-documentatie (OpenAPI/Swagger).

Type hints is waarschijnlijk een gekend concept. Wil je dit toch nog even opfrissen, dan het de FastAPI documentatie een eigen handige en beknopte uitleg!

Een totaal andere aanpak dan Connexion en de Spec-First beweging dus, waarbij eerst de OpenAPI specificatie wordt uitgewerkt en daaruit (deels) automatisch code wordt geproduceerd.

Dankzij native ondersteuning voor async/await is FastAPI bijzonder performant en geschikt voor moderne web- en microservice-architecturen. In grote lijnen werkt FastAPI door Python-functies te koppelen aan HTTP-endpoints, waarbij parameters, request bodies en responses expliciet worden beschreven met types en Pydantic-modellen — wat leidt tot duidelijke, veilige en onderhoudbare API’s met verrassend weinig code.

Extra: ASGI? Async?

In Python kan code asynchroon worden uitgevoerd in zogenaamde coroutines. Dit is een alternatief voor threading. FastAPI (en Startlette) ondersteunen async via de ASGI specificatie - de async tegenhanger van WSGI.

We komen hier later op terug maar om te beginnen moet je enkel weten dat je met FastAPI zowel async als normale functies kan uitvoeren en hier verder niets speciaals voor hoeft te doen.

Laat je niet misleiden door het 0.X versienummer van FastAPI. Dit framework is de laatste jaren ontploft in populariteit en is voor velen de de-facto standaard geworden voor nieuwe API projecten.

Voor deze les volgen we grotendeels de structuur en voorbeelden van de tutorial uit de geweldige FastAPI documentatie.

Eerste stappen met FastAPI

Hello World

Open een nieuwe project-folder, bvb fastapi-tutorial in VSCode of PyCharm.

git init
uv init
uv add "fastapi[standard]"

(De [standard] optie is nodig om o.a. ook het fastapi CLI commando te installeren.)

main.py
from fastapi import FastAPI

1app = FastAPI()

2@app.get("/")
def root():
3    return {"message": "Hello World"}
1
Net als bij Flask of Connexion stelt een (singleton) FastAPI object met de naam app de volledige applicatie voor.
2
Net als bij Flask worden methodes van dat object als decorator gebruikt om API routes vast te leggen. FastAPI gebruikt te term path i.p.v. route.
3
Opnieuw zoals bij Flask en Connexion bepaalt de return value van die functie (bij FastAPI spreken we over een path operation function) de HTTP respons. Wanneer dit een dict (of list) is stuurt FastAPI automatisch JSON terug. De standaard HTTP code is 200.
uv run fastapi dev

FastAPI genereert vanaf deze code automatisch een zo volledig mogelijke OpenAPI specificatie (JSON), http://127.0.0.1:8000/openapi.json, als ook de interactieve Swagger UI documentatie: http://127.0.0.1:8000/docs.

De gelijkenis met Flask is zo groot dat we er even hetzelfde voorbeeld in Flask bijhalen:

main.py
from flask import Flask

app = Flask(__name__)

@app.route("/")
def root():
    return {"message": "Hello World"}

Zoals eerder aangehaald ondersteunt FastAPI ook zogenaamde async code. In de FastAPI documentatie worden meestal async voorbeelden gegeven. Ter illustratie zie je hieronder de async versie van hetzelfde voorbeeld. Tijdens deze less blijven we met ‘gewone’ functies werken.

main.py
from fastapi import FastAPI

app = FastAPI()

@app.get("/")
async def root():
    return {"message": "Hello World"}

Path Parameters

Syntax

main.py
from fastapi import FastAPI

app = FastAPI()

1@app.get("/items/{item_id}")
def read_item(item_id):
    return {"item_id": item_id}
1
Bij FastAPI is de syntax voor een path-variable gelijkaardig aan die van f-strings.
main.py
from flask import Flask

app = Flask(__name__)

@app.route("/items/<item_id>")
def read_item():
    return {"item_id": item_id}

Data Validatie en Conversie

Door eenvoudig een type-hint toe te voegen aan het functie argument neemt FastAPI die automatisch over in de OpenAPI specificatie en voert tevens run-time validatie uit op deze parameter. De (str) parameter uit het URL pad wordt ook werkelijk omgezet naar een int!

main.py
from fastapi import FastAPI

app = FastAPI()

@app.get("/items/{item_id}")
def read_item(item_id: int):
    print(f"Type of item_id is {type(item_id)}")
    return {"item_id": item_id}
$ curl -i 'http://127.0.0.1:8000/items/abc'
HTTP/1.1 422 Unprocessable Content
date: Sun, 21 Dec 2025 15:50:17 GMT
server: uvicorn
content-length: 152
content-type: application/json

{"detail":[{"type":"int_parsing","loc":["path","item_id"],"msg":"Input should be a valid integer, unable to parse string as an integer","input":"abc"}]}
Tip

Het op deze manier automatisch converteren van data naar het juiste type is eigenlijk type coercion (vanuit het Engels to coerce = dwingen) i.p.v. conversie.

Toegelaten Waarden

Door een eigen Enum als type voor de parameter te gebruiken zorg je dat slecht een beperkt aantal waarden worden toegelaten. Opnieuw vertaalt FastAPI dit naar de juiste specificatie in de OAS.

main.py
from enum import StrEnum
from fastapi import FastAPI

class ModelName(StrEnum):
    alexnet = "alexnet"
    resnet = "resnet"
    lenet = "lenet"

app = FastAPI()

@app.get("/models/{model_name}")
def get_model(model_name: ModelName):
    if model_name is ModelName.alexnet:
        return {"model_name": model_name, "message": "Deep Learning FTW!"}

    if model_name.value == "lenet":
        return {"model_name": model_name, "message": "LeCNN all the images"}

    return {"model_name": model_name, "message": "Have some residuals"}

Merk op dat in de API respons model_name, een ModelName object, automatisch wordt omgezet naar een str (aangezien JSON uiteraard geen ModelName type kent).

Tip

Als alternatief voor een Enum kan je de toegelaten waarden ook als Literal strings definiëren.

from typing import Literal

ModelName = Literal["alexnet", "resnet", "lenet"]
Belangrijk

Merk op dat we in al deze voorbeelden steeds alles slechts één maal moeten definiëren. De URL paden, HTTP methods, parameters en parameter types, …

Dit is een van de grote voordelen van FastAPI (t.o.v. bvb. Connexion).

Query Parameters

Herhaling: Wat zijn query parameters?

URL Query Parameters zijn stukjes extra informatie die je aan een URL toevoegt na een vraagteken (?). Ze bestaan uit key-value paren.

Voorbeeld: /search?term=python&page=2

Ze worden meestal gebruikt om filters, zoektermen, sortering of paginering mee te geven aan de server. Je kunt meerdere parameters combineren met een &.

Syntax

Bij een FastAPI path functie worden parameters die niet in het URL path voorkomen automatisch als query parameters geïnterpreteerd.

main.py
from fastapi import FastAPI

app = FastAPI()

fake_items_db = [{"item_name": "Foo"}, {"item_name": "Bar"}, {"item_name": "Baz"}]

@app.get("/items/")
def read_item(skip: int = 0, limit: int = 10):
    return fake_items_db[skip : skip + limit]

Net als bij path parameters zijn dit initieel strings (want het URL path zelf is een string) maar zet FastAPI elke parameter automatisch om (en valideert) op basis van de type hint.

De default waarden uit de functie definitie worden overgenomen in de OAS.

Optionele Parameters

Een parameter wordt automatisch optioneel als er een default waarde gedefinieerd is. Veelal wordt een default waarde van None gekozen. De type hint dient dan mee aangepast te worden om ook None toe te laten (want None heeft een eigen type).

main.py
from fastapi import FastAPI

app = FastAPI()

@app.get("/items/{item_id}")
def read_item(item_id: str, q: str | None = None):
    if q:
        return {"item_id": item_id, "q": q}
    return {"item_id": item_id}

De str | None type hint notatie bestaat nog maar sinds Python 3.10. Daar voor moest typing.Optional gebruikt worden.

  • q: str | None = None
  • q: Optional[str] = None

Die oudere syntax is echter erg verwarrend, want het is de aanwezigheid van een default waarde die de parameter optioneel maakt, en helemaal niet het feit dat de waarde ook None kan zijn.

Sinds Python 3.10 Kan Optional[str] ook geschreven worden als str | None (string of None). De FastAPI documentatie verkiest deze nieuwere notatie. Andere verkiezen de oude notatie omdat Optional duidelijker aangeeft dat het om een optionele parameter gaat.

In onderstaand voorbeeld is de parameter foo helemaal niet optioneel (maar kan wel None zijn), terwijl bar dat wel is:

main.py
from typing import Optional
from fastapi import FastAPI

app = FastAPI()

@app.get("/")
def test(foo: Optional[str], bar: str = "bar"):
    return {"foo": foo, "bar": bar}

Type Conversion

Net als bij path parameters zorgt FastAPI automatisch voor type conversie (indien mogelijk).

Bijvoorbeeld voor een extra bool parameter:

main.py
from fastapi import FastAPI

app = FastAPI()

@app.get("/items/{item_id}")
def read_item(item_id: str, q: str | None = None, short: bool = False):
    item = {"item_id": item_id}
    if q:
        item.update({"q": q})
    if not short:
        item.update(
            {"description": "This is an amazing item that has a long description"}
        )
    return item

Hier zullen ?short=true, ?short=True, ?short=1, ?short=on of ?short=yes als resultaat een variabele met de (bool) waarde True geven. Terwijl bij bvb. ?short=bad een foutmelding terug wordt gestuurd.

Oefeningen

Oefening 1

Maak een endpoint GET /posts/{post_id}

  • post_id moet een integer zijn
  • Het endpoint stuurt de post_id terug, bvb: json { "post_id": 123 }
  • Bekijk en test in Swagger UI. Test ook met “incorrecte” post_id input.
Oplossing
main.py
from fastapi import FastAPI

app = FastAPI()

@app.get("/posts/{post_id}")
def read_post(post_id: int):
    return {"post_id": post_id}

Oefening 2

Maak een endpoint GET /posts

  • Ondersteunt volgende query parameters:

    • skip: int = 0
    • limit: int = 5
  • Gebruik een vaste lijst van posts, bv.:

    posts = [
        {"id": 1, "title": "Hello"},
        {"id": 2, "title": "FastAPI"},
        {"id": 3, "title": "Is"},
        {"id": 4, "title": "Really"},
        {"id": 5, "title": "Nice"},
        {"id": 6, "title": "!"}
    ]
  • Stuurt enkel de slice posts[skip : skip + limit] terug

  • Bekijk en test in Swagger UI. Test bvb /posts?skip=1&limit=3

Oplossing
main.py
from fastapi import FastAPI

app = FastAPI()

posts = [
    {"id": 1, "title": "Hello"},
    {"id": 2, "title": "FastAPI"},
    {"id": 3, "title": "Is"},
    {"id": 4, "title": "Really"},
    {"id": 5, "title": "Nice"},
    {"id": 6, "title": "!"},
]

@app.get("/posts")
def list_posts(skip: int = 0, limit: int = 5):
    return posts[skip : skip + limit]

Oefening 3

Maak een endpoint GET /status/{status}

  • status mag enkel één van deze waarden zijn:

    • "draft"
    • "published"
    • "archived"
  • Stuurt de status terug, bvb.:

    {
     "status": "published"
    }
  • Bekijk en test in Swagger UI. Bekijk ook eens hoe de parameter gedefinieerd is in de OAS.

Gebruik voor het type van status een Enum of Literal.

Oplossing
main.py
from enum import StrEnum
from fastapi import FastAPI

class PostStatus(StrEnum):
    draft = "draft"
    published = "published"
    archived = "archived"

# from typing import Literal
# PostStatus = Literal["draft", "published", "archived"]

app = FastAPI()

@app.get("/status/{status}")
def get_status(status: PostStatus):
    return {"status": status}

Pydantic

Voordat we verder kunnen met FastAPI moeten we eerst Pydantic introduceren.

Pydantic is een data validation library die in de laatste jaren is ontploft in populariteit en inmiddels de standaard is geworden op dit vlak. Dit mede door als onderliggende bouwsteen te fungeren voor frameworks als FastAPI.

Pydantic vs. pydantic

Pydantic is ondertussen ook een bedrijf dat naast het onderhouden van de pydantic library ook actief is rond LLM Agents en OpenTelemetry.

Als we het hebben over Pydantic bedoelen we vanaf nu specifiek de pydantic library.

Van Python classes naar Pydantic Models

Een standaard Python class

Om een model te definiëren voor bepaalde data kan op zich een gewone class gebruikt worden:

main.py
class User:
    def __init__(self, uid, name, fullname):
        self.uid = uid
        self.name = name
        self.fullname = fullname

    def __repr__(self):
        return f"uid={self.uid}, name={self.name}, fullname={self.fullname}"

user = User(123, "fdevaux", "Fabrice Devaux")
bad_user = User("abc", "with spaces", 999)
print(user, bad_user)

Deze aanpak heeft als grootste voordeel eenvoudig te zijn en geen extra modules of packages te vereisen.

We krijgen echter geen enkele validatie van de data en hebben heel wat extra werk om het model bruikbaar te maken, bvb. het implementeren van een __repr__, __eq__, … methods.

Python Dataclasses

Met dataclasses uit de Python standard library wordt al heel wat handwerk opgevangen. Zo worden methodes als __init__, __repr__, __eq__, en meer, automatisch gegenereerd. Dankzij type hints krijgen we ook statische validatie, bvb. in een IDE als VS Code or PyCharm, of via tools als mypy.

Er is echter nog geen dynamisch (of run-time) validatie. Onderstaande code kan gewoon worden uitgevoerd ook al voldoet het bad_user object helemaal niet aan het opgegeven model.

main.py
from dataclasses import dataclass

@dataclass
class User:
    uid: int
    name: str
    fullname: str

user = User(123, "fdevaux", "Fabrice Devaux")
user2 = User(123, "fdevaux", "Fabrice Devaux")
bad_user = User("abc", "with spaces", 999)
print(user, bad_user)
print(user == user2, user == bad_user)
$ mypy main.py
main.py:13: error: Argument 1 to "User" has incompatible type "str"; expected "int"  [arg-type]
main.py:13: error: Argument 3 to "User" has incompatible type "int"; expected "str"  [arg-type]
Found 2 errors in 1 file (checked 1 source file)

De opties om de data objecten te serializen, bvb. naar JSON, zijn ook beperkt. Een optie is om gebruik te maken van dataclasses.as_dict en json.dumps. We komen dan echter snel in de problemen bij datatypes die niet rechtstreeks door json.dumps omgezet kunnen worden:

main.py
import json
from dataclasses import asdict, dataclass
from datetime import datetime

@dataclass
class User:
    uid: int
    name: str
    fullname: str
    created: datetime

user = User(123, "fdevaux", "Fabrice Devaux", datetime.now())
print(json.dumps(asdict(user)))
# TypeError: Object of type datetime is not JSON serializable

Doe-het-zelf Oplossingen

Voor zowel validatie als serializeren kunnen we natuurlijk zelf code gaan toevoegen. Maar dat is behoorlijk omslachtig en repetitief.

main.py
import json
from dataclasses import asdict, dataclass
from datetime import datetime

@dataclass
class User:
    uid: int
    name: str
    fullname: str
    created: datetime

    def __post_init__(self):
        if not isinstance(self.uid, int):
            raise TypeError(f"uid must be an int, got {type(self.uid).__name__}")

def datetime_serializer(val: datetime):
    """Convert non-serializable objects to JSON-compatible format"""
    if isinstance(val, datetime):
        return val.isoformat()
    raise TypeError(f"Object of type {type(val).__name__} is not JSON serializable")

user = User(123, "fdevaux", "Fabrice Devaux", datetime.now())
print(json.dumps(asdict(user), default=datetime_serializer))

bad_user = User("abc", "with spaces", 999, datetime.now())

Pydantic Models

FastAPI is rechtstreeks afhankelijk van Pydantic dus deze laatste hoeven we niet apart te installeren.

De syntax om een model te definiëren in Pydantic lijkt heel erg op een dataclass maar i.p.v. de @dataclass decorator te gebruiken wordt pydantic.BaseModel gesubclassed.

main.py
from datetime import datetime
from pydantic import BaseModel

class User(BaseModel):
    uid: int
    name: str
    fullname: str
    created: datetime

now = created = datetime.now()
user = User(uid=123, name="fdevaux", fullname="Fabrice Devaux", created=now)
print(user)

user2 = User(uid=123, name="fdevaux", fullname="Fabrice Devaux", created=now)
print(user == user2)

bad_user = User(uid="abc", name="with spaces", fullname=999, created=now)

In de output van dit voorbeeld merken we direct op dat Pydantic:

  • Automatisch voor standaard methods als __repr__ en __eq__ zorgt.
  • At runtime validatie uitvoert en een Exception geeft met duidelijke details van de validatiefouten
uid=123 name='fdevaux' fullname='Fabrice Devaux' created=datetime.datetime(2025, 12, 24, 8, 41, 52, 773203)

True

Traceback (most recent call last):
  File "/Users/dfabrice/dev/github/fastapi-tutorial/main.py", line 21, in <module>
    bad_user = User(uid="abc", name="with spaces", fullname=999, created=now)
  File "/Users/dfabrice/dev/github/fastapi-tutorial/.venv/lib/python3.13/site-packages/pydantic/main.py", line 250, in __init__
    validated_self = self.__pydantic_validator__.validate_python(data, self_instance=self)
pydantic_core._pydantic_core.ValidationError: 2 validation errors for User
uid
  Input should be a valid integer, unable to parse string as an integer [type=int_parsing, input_value='abc', input_type=str]
    For further information visit https://errors.pydantic.dev/2.12/v/int_parsing
fullname
  Input should be a valid string [type=string_type, input_value=999, input_type=int]
    For further information visit https://errors.pydantic.dev/2.12/v/string_type
Belangrijk

Merk de gelijkenis op tussen deze Pydantic ValidationError Exception and de HTTP 422 fout die FastAPI in de eerste voorbeelden terugstuurt. Bvb.

{
  "detail": [
    {
      "type": "int_parsing",
      "loc": [
        "path",
        "item_id"
      ],
      "msg": "Input should be a valid integer, unable to parse string as an integer",
      "input": "abc"
    }
  ]
}

FastAPI gebruikt dus Pydantic voor validate zelfs als we nog geen expliciete Pydantic modellen definiëren! Het enige dat we tot nu toe gedaan hebben in de FastAPI voorbeelden is standaard type hints toevoegen aan functie argumenten.

Modelobjecten Initialiseren

In het vorig voorbeeld wordt “rechtstreeks” een User object gemaakt. Merk op dat Pydantic standaard het gebruik van keyword-argumenten verplicht.

Pydantic voorziet tevens classmethods om objecten te maken:

  • Vanaf een Python dict
user = User.model_validate(
    {
        "uid": 123,
        "name": "fdevaux",
        "fullname": "Fabrice Devaux",
        "created": datetime(2025, 12, 24, 9, 0, 51, 40569),
    }
)
  • Vanaf een JSON string
user = User.model_validate_json(
    '{"uid":123,"name":"fdevaux","fullname":"Fabrice Devaux",'
    '"created":"2025-12-24T09:00:51.040569"}'
)

Serialisatie

Pydantic zorgt automatisch voor de juiste serialisatie, inclusief voor types die niet rechtstreeks door Python’s standaard json module worden ondersteund.

user = User(uid=123, name="fdevaux", fullname="Fabrice Devaux", created=now)
print(f"Object: {type(user)}, {user}")

user_dict = user.model_dump()
print(f"Object: {type(user_dict)}, {user_dict}")

user_json = user.model_dump_json()
print(f"Object: {type(user_json)}, {user_json}")
Object: <class '__main__.User'>, uid=123 name='fdevaux' fullname='Fabrice Devaux' created=datetime.datetime(2025, 12, 24, 9, 0, 51, 40569)

Object: <class 'dict'>, {'uid': 123, 'name': 'fdevaux', 'fullname': 'Fabrice Devaux', 'created': datetime.datetime(2025, 12, 24, 9, 0, 51, 40569)}

Object: <class 'str'>, {"uid":123,"name":"fdevaux","fullname":"Fabrice Devaux","created":"2025-12-24T09:00:51.040569"}

Fields

Naast Model is Field een kernconcept en term binnen het Pydantic ecosysteem.

Net als bij dataclasses.field kunnen met Field extra specificaties aan een field van een Pydantic Model worden toegevoegd.

Constraints

Met Field kunnen extra constraints (beperkingen) worden opgelegd aan een Model field.

Zo kunnen we bijvoorbeeld vastleggen dat een uid een positieve int moet zijn en name minstens 3 karakters moet zijn en enkel bestaan uit lowercase letters.

main.py
from datetime import datetime
from pydantic import BaseModel, Field

class User(BaseModel):
    uid: int = Field(gt=0)
    name: str = Field(min_length=3, pattern=r"^[a-z]+$")
    fullname: str
    created: datetime

user = User(uid=-123, name="f#", fullname="Fabrice Devaux", created=datetime.now())
pydantic_core._pydantic_core.ValidationError: 2 validation errors for User
uid
  Input should be greater than 0 [type=greater_than, input_value=-123, input_type=int]
    For further information visit https://errors.pydantic.dev/2.12/v/greater_than
name
  String should match pattern '^[a-z]+$' [type=string_pattern_mismatch, input_value='fa#', input_type=str]
    For further information visit https://errors.pydantic.dev/2.12/v/string_pattern_mismatch

Annotated Pattern

Pydantic ondersteunt een tweede manier om met Field extra informatie te linken aan Model field, door gebruik te maken van typing.Annotated.

Dit heeft als voordeel dat de = Field(...) notatie verward kan worden met het definiëren van een default waarde, wat niet het geval is. Bovendien kan met Annotated een custom type gedefinieerd worden en hergebruikt in verschillende modellen.

Volgende code is equivalent met het laatste voorbeeld:

main.py
from datetime import datetime
from typing import Annotated
from pydantic import BaseModel, Field

UserName = Annotated[str, Field(min_length=3, pattern=r"^[a-z]+$")]

class User(BaseModel):
    uid: Annotated[int, Field(gt=0)]
    name: UserName
    fullname: str
    created: datetime

user = User(uid=-123, name="f#", fullname="Fabrice Devaux", created=datetime.now())

Default_factory

default_factory wordt op dezelfde manier als bij dataclasses en laat toe een functie aan te roepen om een default waarde te generen. Dit gebeurt telkens opnieuw op het moment dat een object wordt aangemaakt.

In het volgende voorbeeld is friends een lijst van strings. Als default_factory kunnen we dan gewoon list gebruiken.

Als de gewenste functie een argument nodig heeft, zoals bvb. datetime.now(tz=UTC) als default waarde voor created, kan dit op een aantal verschillende manier aangepakt worden. Via een eigen helper-functie zonder argumenten, via een lambda functie, of met functools.partial.

main.py
from datetime import UTC, datetime
from functools import partial
from typing import Annotated
from pydantic import BaseModel, Field

def now() -> datetime:
    return datetime.now(tz=UTC)

class User(BaseModel):
    uid: Annotated[int, Field(gt=0)]
    name: Annotated[str, Field(min_length=3, pattern=r"^[a-z]+$")]
    fullname: str
    friends: Annotated[list[str], Field(default_factory=list)]
    created_1: Annotated[datetime, Field(default_factory=now)]
    created_2: Annotated[datetime, Field(default_factory=lambda: datetime.now(tz=UTC))]
    created_3: Annotated[datetime, Field(default_factory=partial(datetime.now, tz=UTC))]

user = User(uid=123, name="fdevaux", fullname="Fabrice Devaux")
print(user)

Alias

In het User model hebben we het veld met de user ID uid genoemd. id is immers een standaard Python keyword dat we best niet hergebruiken in de code. Maar misschien willen we wel id gebruiken bij het (de)serializeren van data (bvb. in een API request bij FastAPI).

Met het alias argument in Field kunnen we een alias definiëren. Op die manier kan de input data id bevatten maar behouden de User objecten hun uid attribuut.

main.py
from datetime import UTC, datetime
from functools import partial
from typing import Annotated
from pydantic import BaseModel, Field

class User(BaseModel):
    uid: Annotated[int, Field(gt=0, alias="id")]
    name: Annotated[str, Field(min_length=3, pattern=r"^[a-z]+$")]
    fullname: str
    created: Annotated[datetime, Field(default_factory=partial(datetime.now, tz=UTC))]

user = User.model_validate({"id": 123, "name": "fdevaux", "fullname": "Fabrice Devaux"})
print(user)
print(user.model_dump_json(by_alias=True))

Pydantic Types

Naast ondersteuning van alle Python standard types en specificaties via Field biedt Pydantic ook nog een verzameling handige eigen types:

Stel dat het User model moet worden uitgebreid met een “email adres” veld. In plaats van zelf een custom str type met extra beperkingen (via Field) te maken kan pydantic.EmailStr gebruikt worden.

main.py
from pydantic import BaseModel, EmailStr

class User(BaseModel):
    name: str
    email: EmailStr

user = User.model_validate({"name": "Fabrice", "email": "not_email"})

Model_config

Met het model_config attribuut van een Pydantic model class (BaseModel subclass) kunnen verschillende zaken globaal geconfigureerd worden voor die class (en eventuele subclasses).

model_config moet een instance zijn van pydantic.ConfigDict. Van de vele opties bekijken we er een paar die vaak gebruikt worden.

Strict

Met strict (zie ook Strict Mode) wordt bepaald of Pydantic type-coercion (conversie) uitvoert.

main.py
from pydantic import BaseModel, ConfigDict

class User(BaseModel):
    uid: int
    name: str

class StrictUser(BaseModel):
    model_config = ConfigDict(strict=True)
    uid: int
    name: str

user1 = User.model_validate({"uid": "123", "name": "fdevaux"})
print(user1)

user2 = StrictUser.model_validate({"uid": "123", "name": "fdevaux"})
Tip

strict kan ook ook worden toegepast op individuele velden (attributen) van een model class, d.m.v. Field(strict=True).

Extra

extra bepaalt wat er gebeurd met “extra” velden in de input die niet gespecificeerd zijn in de model class.

main.py
from pydantic import BaseModel, ConfigDict

class IgnoreUser(BaseModel):
    model_config = ConfigDict(extra="ignore")  # = default

    name: str

class AllowUser(BaseModel):
    model_config = ConfigDict(extra="allow")

    name: str

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

    name: str

print(IgnoreUser.model_validate({"name": "fdevaux", "email": "f@gmail.com"}))
print(AllowUser.model_validate({"name": "fdevaux", "email": "f@gmail.com"}))
print(ForbidUser.model_validate({"name": "fdevaux", "email": "f@gmail.com"}))

Validate_assignment

Het is belangrijk om in te zien dat Pydantic zijn validatie uitvoert op het moment dan een model object wordt aangemaakt (bvb. via BaseModel.model_validate) maar niet wanneer later een object attribuut rechtstreeks wordt gewijzigd.

Met validate_assignment=True zal dit wel gebeuren.

main.py
from typing import Annotated
from pydantic import BaseModel, ConfigDict, Field

class User(BaseModel):
    name: Annotated[str, Field(min_length=3)]

class ValidateUser(BaseModel):
    model_config = ConfigDict(validate_assignment=True)

    name: Annotated[str, Field(min_length=3)]

user = User(name="fdevaux")
validate_user = ValidateUser(name="fdevaux")

user.name = "fd"
print(user)
validate_user.name = "fd"

Frozen

Met frozen=True maak je object instances van de model class immutable. M.a.w. eenmaal aangemaakt kunnen de attribuut-waarden niet meer gewijzigd worden. Een beetje zoals een tuple. Dit is in veel gevallen aan te raden. De code is voorspelbaarder en eenvoudiger over te redeneren

Tegelijk worden objecten ook hashable (als alle attributen ook hashable zijn), waardoor deze bvb. kunnen gebruikt worden als keys in een dict.

main.py
from pydantic import BaseModel, ConfigDict

class User(BaseModel):
    model_config = ConfigDict(frozen=True)

    uid: int
    name: str

user = User(uid=123, name="fdevaux")
user_dict = {user: True}
user.name = "not_allowed"

Alias_generator

Als laatste optie bekijken we alias_generator. Met de Field.alias optie zagen we al hoe een alias wordt ingesteld voor een specifiek veld (bvb id voor uid).

Met alias_generator zal een functie worden aangeroepen om voor elk veld een alias te bepalen. Pydantic voorziet zelf een aantal handige functies, zoals bvb. to_camel, waarbij elk veld een camelCase alias zal krijgen. Dit wordt vaak gebruikt bij REST APIs.

Vaak gecombineerd met serialize_by_alias=True, waardoor de alias ook effectief gebruikt wordt bij het serializen (zonder telkens model_dump_json(by_alias=True) te moeten gebruiken).

main.py
from datetime import UTC, datetime
from typing import Annotated
from pydantic import BaseModel, ConfigDict, Field
from pydantic.alias_generators import to_camel

class User(BaseModel):
    model_config = ConfigDict(alias_generator=to_camel, serialize_by_alias=True)

    uid: Annotated[int, Field(gt=0, alias="id")]
    name: Annotated[str, Field(min_length=3, pattern=r"^[a-z]+$")]
    full_name: str
    creation_date: datetime

user = User.model_validate(
    {
        "id": 123,
        "name": "fdevaux",
        "fullName": "Fabrice Devaux",
        "creationDate": datetime.now(tz=UTC),
    }
)
print(user)
print(user.model_dump_json())

Custom Validators

We zagen dat Pydantic al heel wat validatie automatisch uitvoert, door het gebruik van standard type hints, Annotated, Field constraints en model_config opties.

Daar bovenop is het tevens mogelijk zelf nog extra validatiecode toe te voegen via custom validators.

Field_validator

Met de field_validator decorator wordt een class_method automatisch aangeroepen tijdens de validatie van een bepaalde veld (attribuut). De method krijgt als argument het betreffende veld en moet als return value de gewenste waarde (veelal ongewijzigd) teruggeven. Een validatieprobleem wordt aangegeven door een ValueError exception te raisen.

Zo een custom validator kan voor of na Pydantic’s eigen validatie gebeuren.

main.py
from typing import Annotated
from pydantic import BaseModel, Field, ValidationError, field_validator

class User(BaseModel):
    uid: Annotated[int, Field(gt=0)]
    name: Annotated[str, Field(min_length=3)]

    @field_validator("uid", mode="after")
    @classmethod
    def is_even(cls, value: int) -> int:
        if value % 2 == 1:
            raise ValueError("Is not an even number")
        return value

    @field_validator("name", mode="before")
    @classmethod
    def contains_a(cls, value: str) -> str:
        if "a" not in value:
            raise ValueError("Does not contain the letter 'a'")
        return value

print("Test 1:")
try:
    user = User.model_validate({"uid": 123, "name": "fdevaux"})
except ValidationError as err:
    print(err)

print("Test 2:")
try:
    user = User.model_validate({"uid": -123, "name": "fdevaux"})
except ValidationError as err:
    print(err)

print("Test 3:")
try:
    user = User.model_validate({"uid": 2, "name": "missing"})
except ValidationError as err:
    print(err)

print("Test 4:")
try:
    user = User.model_validate({"uid": 2, "name": "m"})
except ValidationError as err:
    print(err)

Model_validator

Een model_validator is ook een decorator en werkt op een gelijkaardige manier. Deze decoreert echter een gewone method (dus geen classmethod) en krijg als argument bijgevolg het volledige object (self).

Met een model_validator kan je zaken valideren die betrekking hebben op de waarden van verschillende velden samen.

main.py
from pydantic import BaseModel, Field, ValidationError, model_validator
from typing_extensions import Self

class User(BaseModel):
    name: str
    password: str
    password_repeat: str

    @model_validator(mode="after")
    def password_match(self) -> Self:
        if self.password != self.password_repeat:
            raise ValueError("Passwords do not match")
        return self

try:
    user = User.model_validate(
        {"name": "fdevaux", "password": "secret", "password_repeat": "secrt"}
    )
except ValidationError as err:
    print(err)

Oefeningen

Probeer zo mogelijk enkel gebruik te maken van de Pydantic documentatie website.

Oefening 1

Maak een User Pydantic model met volgende eigenschappen:

  • uid: positief geheel getal
  • name: minstens 3 karakters, enkel lowercase letters
  • fullname: verplicht
  • created: huidige UTC-tijd als default
  • friends: lijst van strings, standaard lege lijst

Test met een geldig object en een dat faalt op meerdere velden.

Oplossing
main.py
from datetime import UTC, datetime
from functools import partial
from typing import Annotated
from pydantic import BaseModel, Field, PositiveInt, ValidationError

class User(BaseModel):
    uid: PositiveInt
    name: Annotated[str, Field(min_length=3, pattern=r"^[a-z]+$")]
    fullname: str
    friends: Annotated[list[str], Field(default_factory=list)]
    created: Annotated[datetime, Field(default_factory=partial(datetime.now, tz=UTC))]

user = User(uid=123, name="fdevaux", fullname="Fabrice Devaux")
print(user)
print(user.model_dump_json())

try:
    User(uid=-1, name="F#aa", fullname="X")
except ValidationError as err:
    print(err)

Oefening 2

Maak een User model met volgende velden:

Intern (Python):

  • uid
  • full_name
  • created_at

Extern (API / JSON):

  • id
  • fullName
  • createdAt

Valideer correcte test data als dict. Print een JSON model-dump om te controleren.

Oplossing
main.py
from datetime import UTC, datetime
from typing import Annotated
from pydantic import BaseModel, ConfigDict, Field
from pydantic.alias_generators import to_camel

class User(BaseModel):
    model_config = ConfigDict(alias_generator=to_camel)

    uid: Annotated[int, Field(gt=0, alias="id")]
    full_name: str
    created_at: datetime

user = User.model_validate(
    {
        "id": 123,
        "fullName": "Fabrice Devaux",
        "createdAt": datetime.now(tz=UTC),
    }
)

print(user)
print(user.model_dump_json(by_alias=True))

Oefening 3

Maak een UserRegistration Pydantic model met velden:

  • username: minstens 3 karakters
  • email:
  • password: minstens 8 karakters, 1 cijfer, 1 speciaal karakter (!@#$%^&*)
  • password_repeat: identiek aan password

Test met een geldig model, een ongeldig zwak password en een niet-identiek password.

Oplossing
main.py
import re
from pydantic import (
    BaseModel,
    EmailStr,
    Field,
    ValidationError,
    field_validator,
    model_validator,
)
from typing_extensions import Self

SPECIAL_CHARS = r"!@#$%^&*"

class UserRegistration(BaseModel):
    username: str = Field(min_length=3)
    email: EmailStr
    password: str = Field(min_length=8)
    password_repeat: str

    @field_validator("password")
    @classmethod
    def password_strength(cls, value: str) -> str:
        if not re.search(r"\d", value):
            raise ValueError("Password must contain at least one digit")
        if not re.search(f"[{SPECIAL_CHARS}]", value):
            raise ValueError(
                f"Password must contain at least one special character ({SPECIAL_CHARS})"
            )
        return value

    @model_validator(mode="after")
    def passwords_match(self) -> Self:
        if self.password != self.password_repeat:
            raise ValueError("Passwords do not match")
        return self

print("Valid:")
user = UserRegistration(
    username="fabrice",
    email="fabrice.devaux@syntra.com",
    password="Secret1!",
    password_repeat="Secret1!",
)
print(user)

print("\nInvalid, weak password:")
try:
    UserRegistration(
        username="alice",
        email="alice@example.com",
        password="password",
        password_repeat="password",
    )
except ValidationError as err:
    print(err)

print("\nInvalid (passwords mismatch):")
try:
    UserRegistration(
        username="bob",
        email="bob@example.com",
        password="Secret1!",
        password_repeat="Secret2!",
    )
except ValidationError as err:
    print(err)

Pydantic in FastAPI

Om deze les af te sluiten bekijken we al een eerste voorbeeld waarbij FastAPI gebruik maakt van Pydantic, namelijk het modelleren van een (JSON) request body.

Basis

Een request body is data die de client naar de API stuurt. Een response body is data die de API naar de client stuurt.

Om in FastAPI het schema van een request body te definiëren maken we een Pydantic model en gebruiken we dit als type hint in de path operation functie - net zoals query parameters.

main.py
from fastapi import FastAPI
from pydantic import BaseModel

class Item(BaseModel):
    name: str
    description: str | None = None
    price: float
    tax: float | None = None

app = FastAPI()

@app.post("/items/")
def create_item(item: Item):
    return item

Enkel en alleen met die informatie zal FastAPI:

  • De eigenlijke body van een request inlezen als JSON.
  • Zo nodig data elementen naar het juiste Python type omzetten.
  • De data valideren
    • Indien ongeldig, een aangepaste HTTP foutmelding terugsturen
  • De data als Item object doorgeven aan de functie.
  • Een overeenkomstig JSON Schema voor het model genereren.
  • Het JSON Schema overnemen in het OpenAPI schema en gebruiken in de (Swagger) UI documentatie.

Door met Python classes en types te werken (i.p.v. met een eenvoudige dict, bvb. via json.loads()) is het bovendien een stuk handiger om code te schrijven in de meeste editors/IDE’s, d.m.v. auto-completion, type checking, enz.

PyCharm Pydantic plugin

Als je PyCharm gebruikt wordt de pydantic-pycharm-plugin aangeraden.

Ter herinnering, query parameters worden op dezelfde manier in de functie parameters gespecificeerd. Het verschil zit in het type van de parameters. Base types (bvb. int) worden gezien als query parameters, Pydantic models worden gezien als request body. Later zien we hoe de rol van een parameter expliciet duidelijk kan worden gemaakt.

@app.get("/items/")
def read_item(skip: int = 0, limit: int = 10):
    return fake_items_db[skip : skip + limit]

Complexer Voorbeeld

Pydantic modellen kunnen genest en gecombineerd worden. FastAPI zal het resultaat omzetten naar een volwaardige OpenAPI spec en overeenkomstig valideren.

Path en/of query parameters kunnen ook gecombineerd worden met een request body. FastAPI weet automatisch wat elke functieargument voorstelt.

Onderstaand voorbeeld demonstreert ook een aantal andere Pydantic types en opties en hoe FastAPI hiermee omgaat.

main.py
from datetime import datetime
from typing import Annotated
from fastapi import FastAPI
from pydantic import UUID4, BaseModel, ConfigDict, Field, PositiveInt
from pydantic.alias_generators import to_camel

class Item(BaseModel):
    name: str
    description: str | None = None
    price: float
    tax: float | None = None

class LineItem(BaseModel):
    item: Item
1    quantity: PositiveInt

class Order(BaseModel):
2    model_config = ConfigDict(alias_generator=to_camel)

    order_date: datetime
3    customer_uuid: Annotated[
        UUID4, Field(examples=["d4a93ebc-65b2-48e0-a804-310347129f07"])
    ]
    line_items: list[LineItem]

app = FastAPI()

@app.post("/orders/{order_type}")
def create_item(order_type: str, order: Order):
    print(f"Received order of type {order_type}: {order}")
    return order
1
pydantic.PositiveInt is een handige alias voor Annotated[int, Gt(0)]
2
Met alias_generator=to_camel in de model_config zal de OpenAPI spec camelCase (i.p.v. de snake_case uit de model-attributen) gebruiken.
3
pydantic.UUID4 zal valideren dat de waarde effectief een UUID is. Door via pydantic.Field een examples in te stellen kunnen we beïnvloeden wat in een OpenAPI spec als voorbeeld zal staan.

I.p.v. voor elk individueel veld (Field) een voorbeeldwaarde te definiëren kan je ook een (of meerdere) voorbeelden in een keer voor een volledige model meegeven.

class Order(BaseModel):
    model_config = ConfigDict(
        alias_generator=to_camel,
        json_schema_extra={
            "examples": [
                {
                    "orderDate": "2025-12-31T12:41:19.236Z",
                    "customerUuid": "d4a93ebc-65b2-48e0-a804-310347129f07",
                    "lineItems": [
                        {
                            "item": {
                                "name": "Apple",
                                "description": "A juicy red apple.",
                                "price": 3.5,
                                "tax": 21.0,
                            },
                            "quantity": 2,
                        }
                    ],
                }
            ]
        },
    )

    order_date: datetime
    customer_uuid: UUID4
    line_items: list[LineItem]