Pydantic-Modelle und Validatoren

Track KI · M0 Python produktiv, Baustein 04 · ca. 55 Min. (plus Projektaufgabe lokal)

Was du aus Teil a brauchst

Aus Teil 1: Type Hints lesen und schreiben brauchst du die Schreibweisen list[str], dict[str, float], tuple[...] und X | None sowie die Tatsache, dass der Interpreter Hints nicht prüft. Pydantic liest genau diese Hints aus (mit get_type_hints) und baut daraus eine Prüfung zur Laufzeit.

Worum es geht

Daten, denen du nicht trauen darfst (JSON aus einer API, eine Nutzereingabe, die Antwort eines Modells), brauchen eine Prüfung zur Laufzeit. Pydantic prüft, ob die Daten wirklich so aussehen, wie dein Modell es verlangt, und wirft sonst eine genaue Fehlermeldung. In M1 wird daraus “unstrukturierter Text wird zu einem validierten Objekt” (Quelle).

Du kennst das schon: zod. Hier lernst du dieselbe Aufteilung in Python, mit einem Unterschied, der Fehler verursacht, wenn du ihn nicht kennst: Pydantic wandelt Eingaben standardmäßig locker um (aus "42" wird 42), zod nicht.

Was im Browser läuft: Echtes Pydantic (Version 2) läuft in Pyodide. Im Browser ist es Version 2.10.6 (in der Testumgebung ausgeführt).

Von JS/TS her gedacht

Idee TypeScript / zod Python / Pydantic
Schema definieren z.object({ betrag: z.number() }) class Rechnung(BaseModel): betrag: float
Typ aus dem Schema z.infer<typeof Rechnung> nicht nötig: die Klasse ist der Typ
Validieren, wirft Fehler Rechnung.parse(x) wirft ZodError Rechnung.model_validate(x) wirft ValidationError
Validieren ohne Exception Rechnung.safeParse(x) try / except ValidationError
Länge, Bereich z.string().min(3), z.number().max(5) Field(min_length=3), Field(le=5)
Optional z.string().optional() str \| None = None
Eigene Regel .refine(...) @field_validator, @model_validator
"42" für ein Zahlenfeld Fehler (strikt), außer z.coerce.number() wird zu 42 (lax mode), strikt nur mit strict=True
Unbekannte Felder werden entfernt, .strict() wirft werden ignoriert, extra="forbid" wirft
Fehlerliste err.issues mit path und message e.errors() mit loc, type und msg

Die zod-Zeilen sind aus dem Gedächtnis gegenübergestellt und nicht ausgeführt (zod ist hier nicht installiert). Die Pydantic-Zeilen führst du gleich selbst aus.

const Rechnung = z.object({ betrag: z.number(), waehrung: z.string().length(3) });
type Rechnung = z.infer<typeof Rechnung>;
const r = Rechnung.parse(daten);   // wirft ZodError bei falschen Daten

Konzept

Schritt 1: Ein Pydantic-Modell validiert beim Erzeugen

Ein Pydantic-Modell ist eine Klasse mit Type Hints, die von BaseModel erbt. Beim Erzeugen prüft und wandelt sie die Daten um (Quelle: “validiert sich selbst beim Erzeugen”):

"12.5" ist zu 12.5 (float) geworden. model_validate(dict) ist der Weg für Daten von draußen (JSON, die schon als dict vorliegen). Rechnung(betrag=..., waehrung=...) geht auch, model_validate_json(text) nimmt direkt einen JSON-String. model_dump() macht aus dem Objekt wieder ein dict.

Bei falschen Daten wirft Pydantic alle Fehler auf einmal, nicht nur den ersten. Jeder Fehler hat eine Stelle (loc), einen Code (type) und einen Text (msg):

Verlässlich sind loc und type. Die Texte (msg) können sich zwischen Pydantic-Versionen leicht ändern, vergleiche darum in Code nie auf msg.

Schritt 2: Field, Optional, Defaults, verschachtelte Modelle

Field(...) hängt Regeln an ein Feld: gt (größer), ge (größer gleich), lt, le, min_length, max_length.

Zwei Dinge sind hier anders als in TypeScript:

  1. str | None allein macht ein Feld nicht optional. Es erlaubt nur den Wert None. Fehlt der Schlüssel, ist das ein Fehler (missing, siehe oben bei notiz). Optional im Sinne von “darf fehlen” wird es erst mit einem Default: str | None = None.
  2. Ein veränderlicher Default wie [] ist in Pydantic sicher. Erinnere dich an die Default-Falle bei Funktionen (ein Default wird nur einmal erzeugt). Pydantic kopiert den Default für jede Instanz:

Modelle lassen sich verschachteln, auch in Listen. Der Fehlerpfad loc zeigt dann den ganzen Weg: Feldname, Listenindex, Feldname.

('positionen', 1, 'menge') liest du von links nach rechts: im Feld positionen, das zweite Element (Index 1, Zählung ab 0), dort das Feld menge. Das ist die Entsprechung zu path bei zod. Der erste Auftrag-Wert "7" war dagegen in Ordnung und wurde zu 7.

Union: int | str heißt “int oder str”. Pydantic nimmt im Standard (smart mode) zuerst eine exakt passende Variante und wandelt nur um, wenn keine exakt passt:

Schritt 3: Eigene Regeln mit Validatoren

Was Field nicht ausdrücken kann, schreibst du als Validator. Ein @field_validator prüft ein Feld und gibt den (ggf. bereinigten) Wert zurück. Das Beispiel der Quelle:

Der Validator wirft ValueError, Pydantic macht daraus einen ValidationError mit type value_error und der Stelle des Feldes. Und was du mit return zurückgibst, wird der neue Wert: "eur" wird zu "EUR".

Regeln, die mehrere Felder zugleich betreffen, gehören in einen @model_validator. Er läuft nach der Prüfung aller Felder (mit mode="after") und gibt das Modell (self) zurück. Der Fehlerpfad loc ist dann leer, weil kein einzelnes Feld schuld ist. (Der model_validator steht nicht in der Quelle, er ist allgemeines Pydantic-Wissen.)

Konfiguration mit pydantic-settings. Dasselbe Prinzip gilt für Einstellungen: Eine Klasse Settings(BaseSettings) liest Werte aus Umgebungsvariablen und validiert sie einmal beim Start, statt sie hart zu codieren (Quelle). Das Paket pydantic-settings ist ein eigenes Paket und wird hier nicht im Browser ausgeführt. Zeigen und lernen kannst du es lokal im Lernlabor, sobald du das Paket dort als Abhängigkeit aufgenommen hast (es ist im Lernlabor noch nicht installiert).

# Nur zum Lesen, nicht im Browser ausgeführt
from pydantic_settings import BaseSettings

class Settings(BaseSettings):
    modell_name: str               # aus der Umgebungsvariable MODELL_NAME
    max_versuche: int = 3

settings = Settings()              # wirft ValidationError, wenn MODELL_NAME fehlt

Falle

Falle 1: Lax mode. "42" wird zu 42, "1.0" wird zu 1, 1.5 für ein int-Feld ist dagegen ein Fehler. Das ist bequem für JSON und Umgebungsvariablen (dort ist alles ein String), aber anders als in zod. Wer es streng will: model_config = ConfigDict(strict=True).

Falle 2: Die Umwandlung geht nur in eine Richtung. Eine Zahl für ein str-Feld wird nicht zu einem String. Anders als in JS, wo "Nr. " + 42 einfach läuft, meldet Pydantic dort string_type. Das siehst du in Übung 3.

Falle 3: Validator ohne return. Ein @field_validator, der den Wert nicht zurückgibt, setzt das Feld auf None, und zwar ohne Fehlermeldung. Das passiert schnell, wenn du den Wert bereinigst (v = v.strip()) und das return vergisst. Übung 2 lässt dich genau das reparieren.

Falle 4: str | None ohne Default ist ein Pflichtfeld (siehe Schritt 2).

Übungen

Übung 1: Pydantic-Modell bauen (mittel)

Schreibe ein Modell Termin für einen Besprechungstermin mit diesen Regeln:

  • thema: String, Pflicht, mindestens 2 Zeichen
  • dauer_min: ganze Zahl von 15 bis 240 (einschließlich), Pflicht
  • teilnehmer: Liste von Strings, standardmäßig leer
  • raum: String oder None, darf fehlen (dann None)

Der Check füttert dein Modell mit gültigen und ungültigen Daten, auch mit "45" als Dauer.

Grenzen für Zahlen und Länge kommen aus Field(...). Für “darf fehlen” reicht ein Typ mit None allein nicht, das hast du in Schritt 2 gesehen. Und zur Liste: Schritt 2 zeigt, ob ein veränderlicher Default in Pydantic gefährlich ist.

from pydantic import BaseModel, Field

class Termin(BaseModel):
    thema: str = Field(min_length=2)
    dauer_min: int = Field(ge=15, le=240)
    teilnehmer: list[str] = []
    raum: str | None = None

Termin

Übung 2: Validator reparieren (mittel)

Das Modell soll Gutschein-Codes bereinigen: Leerzeichen am Rand entfernen, in Großbuchstaben umwandeln, danach muss der Code genau 8 Zeichen haben. " abcd1234 " soll also zu "ABCD1234" werden, "abcd123" soll abgelehnt werden. Der Code läuft ohne Absturz, macht aber zwei Dinge falsch. Finde und repariere beide Fehler.

Probiere das Modell selbst mit " abcd1234 " aus und schau, was in .code steht. Zwei Fragen: Wann wird gemessen, vor oder nach dem Bereinigen? Und was bestimmt den Wert, der am Ende im Feld landet?

from pydantic import BaseModel, Field, field_validator

class Gutschein(BaseModel):
    code: str
    wert: float = Field(gt=0)

    @field_validator("code")
    @classmethod
    def code_pruefen(cls, v: str) -> str:
        v = v.strip().upper()
        if len(v) != 8:
            raise ValueError("Code braucht genau 8 Zeichen")
        return v

Gutschein

Übung 3: Fehlermeldungen lesen (mittel)

Diese Modelle und diese Eingabe sind gegeben:

class Adresse(BaseModel):
    strasse: str
    plz: int

class Kunde(BaseModel):
    name: str
    adresse: Adresse
    tags: list[str]

daten = {
    "adresse": {"strasse": "Gazi Bulvari 7", "plz": "35x"},
    "tags": ["neu", 42],
}

Kunde.model_validate(daten) wirft einen ValidationError. Trage die loc-Werte aller Fehler ein, als Liste von Tupeln, in der Reihenfolge, in der Pydantic sie meldet (Reihenfolge der Felder im Modell). Zum Beispiel hat der Fehler aus Schritt 2 die Stelle ("positionen", 1, "menge").

Gehe die Felder von Kunde der Reihe nach durch und frage bei jedem: Fehlt es, oder passt der Wert nicht zum Typ? Bei verschachtelten Modellen und Listen gehört der ganze Weg zur Stelle, Listenindex ab 0.

antwort = [("name",), ("adresse", "plz"), ("tags", 1)]
antwort

Projektaufgabe: Marktlokation (Abschluss-Check des Bausteins)

Die Quelle stellt zwei Aufgaben. Sie laufen lokal im Lernlabor, nicht im Browser. Das Rohmaterial liegt in lernlabor/uebung/.

Aufgabe A (Baustein 03): In lernlabor/uebung/altes_skript.py liegt ein bewusst untypisiertes Skript. Versieh parse_zeile, filtere_gueltige und zaehle_pro_sparte vollständig mit Type Hints, inklusive Rückgabetyp. Die Felder stehen in data_contract_marktlokation.yaml.

Aufgabe B (Baustein 04): Baue in lernlabor/src/lernlabor/model.py ein Pydantic-Modell Marktlokation nach dem Data Contract aus data_contract_marktlokation.yaml. Mindestens ein eigener Validator (zum Beispiel für marktlokations_id, das Muster steht im Contract) und mindestens ein Field mit Regel. Prüfe das Modell gegen die gueltig- und ungueltig-Zeilen aus beispieldaten.json.

Ein Hilfsskript prüft Aufgabe B für dich und zeigt, ob jede ungültige Zeile am richtigen Feld scheitert (ohne Schlüssel, ohne Netz):

cd lernlabor && uv run python uebung/ki/ki_02_marktlokation_pruefen.py

Solange es Marktlokation noch nicht gibt, macht das Skript nur einen Trockenlauf und zeigt dir, was es erwartet.

Fertig, wenn:

  • Alle drei Funktionen in altes_skript.py haben Hints für jeden Parameter und den Rückgabewert, und die Hints nennen Elementtypen (list[dict[str, str]] statt nur list).
  • Das Hilfsskript meldet für beide gueltig-Zeilen “ok” und für alle drei ungueltig-Zeilen den erwarteten Fehlerort.
  • Mindestens ein @field_validator gibt den Wert zurück, und ein Feld hat ein optionales None mit Default.
  • Optional, Ziel von M0 laut Quelle: Ein Typchecker (pyright oder mypy) meldet für beide Dateien keine Fehler. Das Werkzeug ist im Lernlabor noch nicht eingerichtet, darum ist dieses Ziel optional.

Selbstcheck:

Merksatz

Ein Pydantic-Modell ist die Grenze zwischen Daten von draußen und Daten, mit denen der Rest des Codes sicher arbeiten kann: Type Hints beschreiben, Pydantic prüft (Quelle).

Prüfstein

Dein Service bekommt per HTTP ein JSON mit {"name": ..., "alter": ...}, reicht es an eine interne Funktion begruesse(name: str, alter: int) -> str weiter und speichert das Ergebnis. Wo schreibst du in diesem Ablauf Type Hints, wo ein Pydantic-Modell, und was passiert beim Aufruf mit "alter": "zwanzig" mit und ohne das Modell?

Zurück: Teil 1: Type Hints lesen und schreiben erklärt die Hints, auf denen Pydantic aufbaut.


Quelle: quellen/kursbuch-lerninhalte.md, Modul M0, Baustein “04 Pydantic-Modelle” (Zeilen 138 bis 190 der Datei, zusammen mit Baustein 03). Über die Quelle hinaus (allgemeines Fachwissen, mit echtem Python 3.13 und Pydantic 2.12 lokal sowie Pydantic 2.10.6 in Pyodide geprüft): lax und strict mode, Fehlerstruktur loc/type/msg, model_validator, Union im smart mode, kopierte Defaults, Vergleichstabelle mit zod. Die zod-Zeilen sind nicht ausgeführt. Die Datenquelle der Projektaufgabe ist erfunden (lernlabor/uebung/README.md). Fehlermeldungstexte (msg) von Pydantic sind versionsabhängig (bitte prüfen, falls eine neue Version verwendet wird).