Dlaczego FastAPI i Pydantic tak dobrze grają razem
Ręczna walidacja w Flask/Django kontra podejście deklaratywne
W klasycznym podejściu do budowy API, na przykład w Flasku czy czystym Django, walidacja danych jest zwykle rozproszona i ręczna. Programista:
- parsuje
request.datalubrequest.POST, - sprawdza, czy pola są obecne,
- konwertuje typy (np.
int(request.form["age"])), - zwraca błędy jako własnoręcznie zbudowany JSON.
Po kilku endpointach pojawia się chaos: te same zasady walidacji trzeba kopiować, a aktualizacja reguł w jednym miejscu wymaga przeszukania całego kodu. Dokumentacja OpenAPI, jeśli w ogóle istnieje, zazwyczaj jest pisana osobno w YAML/JSON albo generowana tylko częściowo. Typowy efekt? Backend i frontend zaczynają mieć różne wersje rozumienia tego samego API.
FastAPI i Pydantic odwracają tę logikę. Zamiast pisać walidację „po fakcie”, opisujesz dane deklaratywnie: co endpoint przyjmuje i co zwraca, a framework sam dba o spójność. Reguły walidacji, konwersja typów i dokumentacja wynikają z jednego źródła prawdy – modeli i typów.
Adnotacje typów Pythona jako kontrakt API
Od Pythona 3.x adnotacje typów stały się pierwszoplanowym narzędziem. FastAPI wykorzystuje je bardzo dosłownie: sygnatura funkcji endpointu jest kontraktem API. Jeśli napiszesz:
from fastapi import FastAPI
app = FastAPI()
@app.get("/items/{item_id}")
def read_item(item_id: int, q: str | None = None):
return {"item_id": item_id, "q": q}
to:
item_id: intstaje się parametrem ścieżki typu integer w OpenAPI,q: str | Nonestaje się opcjonalnym query paramem typu string,- FastAPI automatycznie wymusi konwersję
item_iddoint, a przy błędzie zwróci logiczną odpowiedź HTTP.
Ten sam mechanizm działa z modelami Pydantic: kiedy w sygnaturze użyjesz klasy dziedziczącej po BaseModel, FastAPI wykorzysta ją do:
- parsowania JSON z request body,
- weryfikacji typów,
- generowania schematu JSON Schema i włączenia go do OpenAPI.
Podział ról: co robi FastAPI, a co robi Pydantic
Łatwo wrzucić wszystko do jednego worka i powiedzieć „FastAPI działa z Pydantic, robi magię”. Lepiej spojrzeć na role obu bibliotek bardziej technicznie:
- Pydantic:
- definiowanie modeli danych jako klas Pythona,
- wbudowana walidacja typów (proste i złożone),
- konwersja danych wejściowych do typów zadeklarowanych w modelu,
- generowanie JSON Schema dla modeli.
- FastAPI:
- analiza sygnatur funkcji endpointów (typy argumentów, typy zwrotu),
- mapowanie request body/query/path/header/cookie na parametry funkcji,
- sklejanie schematów z Pydantic w kompletny dokument OpenAPI,
- udostępnianie Swagger UI i ReDoc,
- opakowanie błędów walidacji w odpowiedź HTTP (np. status 422).
Taki podział pozwala korzystać z Pydantic także poza FastAPI (np. w skryptach, workerach, testach), a jednocześnie utrzymać spójność kontraktów danych w całym projekcie.
Ścieżka: od typu w sygnaturze do dokumentacji OpenAPI
Dobrze jest raz zobaczyć cały „łańcuch” przepływu informacji:
- Definiujesz model Pydantic:
from pydantic import BaseModel class Item(BaseModel): name: str price: float is_offer: bool | None = None - Używasz go jako typu w endpointzie:
@app.post("/items") def create_item(item: Item) -> Item: return item - FastAPI:
- przyjmuje JSON w body,
- przekazuje go do Pydantic, który waliduje i konwertuje dane na
Item, - na podstawie
Itemgeneruje schemat JSON Schema, - umieszcza schemat w dokumencie OpenAPI (w sekcji
components.schemas), - dodaje informację o tym, że endpoint
/itemsprzyjmuje i zwraca ten schemat.
Efekt końcowy możesz od razu obejrzeć w /docs (Swagger UI) i /redoc – bez pisania osobnej specyfikacji.
Szybki fundament: model danych Pydantic od zera
Model jako klasa dziedzicząca po BaseModel
Punktem startu jest zawsze klasa dziedzicząca po BaseModel. Konstrukcja jest intuicyjna: przypomina zwykłą klasę Pythona, ale z silnym typowaniem i logiką walidacji „pod spodem”:
from pydantic import BaseModel
class User(BaseModel):
username: str
age: int
is_active: bool = True
Instancjonowanie takiego modelu wygląda jak praca z normalnymi obiektami:
u = User(username="jan", age=30)
print(u.username) # "jan"
print(u.is_active) # True (domyślna wartość)
Różnica pojawia się, gdy spróbujesz przekazać niepoprawne typy:
User(username="jan", age="trzydzieści")
# Pydantic podniesie ValidationErrorTen model możesz potem wykorzystać w FastAPI zarówno jako wzorzec danych wejściowych, jak i wyjściowych, reużywając logikę walidacji.
Pola wymagane, domyślne i opcjonalne
Pydantic wyciąga wnioski z typów i domyślnych wartości:
- pole bez wartości domyślnej jest wymagane,
- pole z wartością domyślną jest opcjonalne – jeśli nie zostanie podane, użyta zostanie wartość domyślna,
- pole typu
Optionallub| Nonemoże przyjmowaćNone.
from typing import Optional
class Profile(BaseModel):
full_name: str # wymagane
bio: Optional[str] = None # opcjonalne, może być None
newsletter: bool = False # opcjonalne, domyślnie False
Jeśli spróbujesz utworzyć Profile bez full_name, Pydantic zasygnalizuje brak wymaganego pola. To zachowanie przełoży się bezpośrednio na OpenAPI: w schemacie JSON pola wymagane pojawią się w sekcji required.
Walidacja typów prostych w praktyce
Typy podstawowe – str, int, float, bool, datetime – obsługiwane są „z pudełka”. Przykład:
from datetime import datetime
from pydantic import BaseModel
class Event(BaseModel):
name: str
start_at: datetime
duration_minutes: int
public: bool
Pydantic potrafi przyjąć kilka formatów dat (np. ISO 8601), przekonwertować je na datetime, a przy niepowodzeniu zwrócić czytelny błąd. Przy bool przeprowadzi konwersję m.in. z "true", "false", 1, 0 – szczególnie przydatne przy danych z formularzy.
Automatyczna konwersja typów i kiedy pojawia się błąd
Pydantic jest dość „przyjacielski” – próbuje konwertować dane, jeśli to możliwe. Kilka typowych scenariuszy:
"123"->int– konwersja OK,"12.5"->float– konwersja OK,1->bool– konwersja OK,"abc"->int– walidacja nie przejdzie,"2023-05-01T10:00:00"->datetime– konwersja OK.
To „miękkie” podejście zwykle pomaga, ale warto wiedzieć, że możesz zaostrzyć reguły, stosując typy takie jak conint, confloat czy własne walidatory – o tym później. Dla FastAPI ma to ten plus, że większość „dziwnych” danych od klienta odpadnie jeszcze zanim dotrze do logiki biznesowej.
Wstrzykiwanie modeli Pydantic do endpointów FastAPI
Request body jako model Pydantic
Gdy endpoint ma przyjmować JSON w ciele żądania, po prostu używasz modelu Pydantic jako typu parametru funkcji. FastAPI rozpoznaje, że to request body:
from fastapi import FastAPI
from pydantic import BaseModel
app = FastAPI()
class Item(BaseModel):
name: str
description: str | None = None
price: float
tax: float | None = None
@app.post("/items")
def create_item(item: Item):
# item jest już zwalidowanym obiektem Pythona
return {"message": "Created", "item": item}
Nie ma tu ręcznego request.json(). FastAPI:
- czyta ciało żądania,
- próbuje zdekodować JSON,
- tworzy obiekt
Item(używając Pydantic), - przy błędzie walidacji zwraca status 422 z opisem błędu.
Łączenie body, query params i path params
W praktyce endpoint często łączy różne źródła danych: parametry ścieżki do identyfikacji zasobu, query params jako filtry/paginacja i body jako właściwe dane do zapisu. Przykład:
from fastapi import Path, Query
class UpdateItem(BaseModel):
name: str | None = None
price: float | None = None
@app.put("/shops/{shop_id}/items/{item_id}")
def update_item(
shop_id: int = Path(..., ge=1),
item_id: int = Path(..., ge=1),
item: UpdateItem | None = None,
expand: bool = Query(False)
):
return {
"shop_id": shop_id,
"item_id": item_id,
"expand": expand,
"item": item,
}
W tym przykładzie w jednym strzale:
- shop_id i item_id są parametrami ścieżki z dodatkowymi ograniczeniami (min 1),
- expand to query param typu
boolz domyślną wartością, - item to body reprezentujące częściową aktualizację zasobu.
Dokumentacja OpenAPI pokaże wszystkie te elementy w odpowiednich miejscach: path params, query params, request body ze schematem z modelu UpdateItem.
Parametry proste vs modele – różnica w interpretacji
FastAPI przyjmuje prostą zasadę: parametry prostych typów traktuje jako query/path/header/cookie, a modele Pydantic jako body (o ile nie wskażesz inaczej). Stąd różnica:
@app.get("/search")
def search(q: str | None = None, limit: int = 10):
... # q i limit to query params
@app.post("/users")
def create_user(user: UserCreate):
... # user to request body
Jeśli chcesz jednak, by proste pole znalazło się w body (np. body jako zwykły string), możesz użyć Body z FastAPI, ale w prawdziwych projektach zwykle wygrywa jawny model Pydantic – jest czytelniejszy i lepiej się dokumentuje.
Wpływ typowania na dokumentację OpenAPI
Każdy element sygnatury funkcji trafia potem do OpenAPI:
- parametry typu
int,str,boolstają się parametrami w sekcjiparameters, - modele Pydantic stają się schematami w
components.schemas, - typ zwrotny funkcji (
-> ItemOut) staje się schematem odpowiedzi, - domyślne wartości, zakresy i opisy (dodane np. przez
Query,Path,Field) są odwzorowywane w specyfikacji.
Dlatego konsekwentne typowanie endpointów to nie tylko „ładny kod” – to fundament aktualnej, generowanej automatycznie dokumentacji dla twojego zespołu i zewnętrznych integratorów.
Response_model, filtrowanie danych i kontrola odpowiedzi
Walidacja odpowiedzi za pomocą response_model
Kontrola schematu odpowiedzi i ukrywanie pól
response_model to nie tylko walidacja, ale też filtr. Z serwera możesz zwrócić bogaty obiekt, a do klienta wysłać tylko to, co jawnie zadeklarujesz w modelu odpowiedzi:
from pydantic import BaseModel
from fastapi import FastAPI
app = FastAPI()
class UserInDB(BaseModel):
id: int
email: str
password_hash: str
class UserPublic(BaseModel):
id: int
email: str
fake_db = {
1: UserInDB(id=1, email="user@example.com", password_hash="...")
}
@app.get("/users/{user_id}", response_model=UserPublic)
def get_user(user_id: int):
user = fake_db[user_id]
# zwracamy "za dużo", ale FastAPI & Pydantic odfiltrują pola spoza UserPublic
return user
W odpowiedzi pola password_hash nie będzie, bo nie należy do schematu UserPublic. Po stronie kodu możesz więc pracować z pełnym modelem, a na zewnątrz publikować bezpieczną „maskę” danych.
response_model_include, response_model_exclude i widoki danych
Dla prostych scenariuszy wygodniej czasem sterować polami na poziomie widoku endpointu niż definiować osobny model. FastAPI eksponuje do tego parametry response_model_include i response_model_exclude:
class Product(BaseModel):
id: int
name: str
description: str | None = None
price: float
internal_code: str
@app.get(
"/products/{product_id}",
response_model=Product,
response_model_exclude={"internal_code"},
)
def get_product(product_id: int):
product = Product(
id=product_id,
name="Keyboard",
description="Mechanical keyboard",
price=100.0,
internal_code="SKU-123",
)
return product
Do klienta trafi wszystko poza internal_code. Gdy potrzebna jest jeszcze inna wersja widoku (np. bardzo skrócona lista), można skorzystać z response_model_include:
@app.get(
"/products/{product_id}/summary",
response_model=Product,
response_model_include={"id", "name", "price"},
)
def get_product_summary(product_id: int):
# ta sama logika, inny widok danych
...
W specyfikacji OpenAPI nadal wyląduje pełny model Product, ale klient dostanie faktycznie zawężony JSON – to filtr po stronie serwera, nie zmiana kontraktu schematu.
Ścisła walidacja odpowiedzi i debug niezgodności
Domyślnie FastAPI także konwertuje odpowiedzi do zadanego modelu, co bywa pomocne, ale potrafi też zamaskować błąd po twojej stronie. Jeśli chcesz wymusić ostrzejszą kontrolę, możesz włączyć walidację zwrotną:
app = FastAPI()
@app.get("/numbers", response_model=list[int])
def numbers():
# przypadkowo zwrócony string
return ["1", "2", "3"]
Pydantic spróbuje przekonwertować stringi na liczby i prawdopodobnie mu się to uda. Jeśli jednak ma to być twardy błąd, w Pydantic v2 możesz użyć konfiguracji model_config z strict=True lub typów ścisłych (StrictInt, StrictStr). Wtedy rozbieżność między modelem a realną odpowiedzią wyjdzie na jaw natychmiast, zamiast po paru tygodniach w produkcji.
Modele odpowiedzi a kody statusu i wiele wariantów
Zdarza się, że jeden endpoint może zwrócić różne formy danych w zależności od sytuacji. FastAPI pozwala przypisać model odpowiedzi dla głównego scenariusza, a w dokumentacji pokazać bardziej szczegółowy obraz przy użyciu responses:
from fastapi import HTTPException
class ErrorDetail(BaseModel):
detail: str
class Order(BaseModel):
id: int
total: float
@app.get(
"/orders/{order_id}",
response_model=Order,
responses={404: {"model": ErrorDetail}},
)
def get_order(order_id: int):
order = None # znalezienie w bazie pominięte
if not order:
raise HTTPException(status_code=404, detail="Order not found")
return order
W OpenAPI główna odpowiedź 200 używa schematu Order, a odpowiedź 404 – schematu ErrorDetail. Klient od razu widzi, jakiej struktury spodziewać się w typowych i błędnych przypadkach.

Zaawansowane typy Pydantic w kontraktach FastAPI
Listy, zagnieżdżone modele i struktury słownikowe
Gdy aplikacja rośnie, same typy proste przestają wystarczać. Pydantic wspiera kolekcje i zagnieżdżone modele, a FastAPI przekłada je na odpowiednio złożone schematy OpenAPI.
from typing import List, Dict
class Tag(BaseModel):
name: str
class Article(BaseModel):
title: str
tags: List[Tag] = []
metadata: Dict[str, str] = {}
W OpenAPI pole tags stanie się tablicą obiektów typu Tag, a metadata – obiektem słownikowym o wartościach typu string. Klient ma więc jasność, że tags nie jest np. jednym stringiem rozdzielanym przecinkami, tylko prawdziwą listą obiektów.
Enumy – ograniczony zbiór wartości
Dla pól, które mogą przyjmować tylko kilka konkretnych wartości (status, rola użytkownika, typ zamówienia), bardzo wygodnym typem jest Enum. Połączenie Pydantic + Enum daje czytelny kod i przejrzystą dokumentację.
from enum import Enum
class OrderStatus(str, Enum):
pending = "pending"
paid = "paid"
cancelled = "cancelled"
class Order(BaseModel):
id: int
status: OrderStatus
W specyfikacji pojawi się pole status z typem string i wyliczeniem ["pending", "paid", "cancelled"]. Frontend od razu wie, jakie wartości może wysłać, a Pydantic na backendzie nie przepuści nic spoza tej listy.
Ograniczenia numeryczne i tekstowe (conint, constr, confloat)
Kolejny krok to ograniczenie zakresów i długości. Pydantic udostępnia „fabryki typów” do tworzenia takich pól:
from pydantic import BaseModel, conint, constr, confloat
class ProductCreate(BaseModel):
name: constr(min_length=3, max_length=100)
quantity: conint(ge=1, le=1000)
price: confloat(gt=0, le=10000)
Te ograniczenia pojawią się zarówno w walidacji, jak i w OpenAPI jako:
minLengthimaxLengthdlaname,minimum/maximumorazexclusiveMinimum/exclusiveMaximumdla liczb.
Dzięki temu front może np. zbudować walidację formularza wyłącznie z kontraktu, bez dodatkowych ustaleń w wiadomościach na komunikatorze.
Dane złożone: Union, Annotated i typy specjalne
Czasem pole może przyjmować kilka różnych kształtów. Prosty przykład to dane kontaktowe: email albo numer telefonu. Do tego służy Union (w Pythonie 3.10+ zapis |):
from typing import Union
from pydantic import EmailStr
class Contact(BaseModel):
value: Union[EmailStr, constr(regex=r"^+?[0-9]{7,15}$")]
Taki model jest walidowany sekwencyjnie: Pydantic próbuje dopasować wartość do pierwszego typu, potem do drugiego itd. W dokumentacji będzie to pole o typie string, ale z dodatkowymi opisami i przykładowymi formatami. Jeśli chcesz doprecyzować opis, w Pydantic v2 możesz użyć Annotated:
from typing import Annotated
from pydantic import Field
Price = Annotated[
confloat(gt=0),
Field(description="Cena w walucie bazowej, wyższa niż 0"),
]
class Item(BaseModel):
price: Price
Walidatory, Field i precyzyjne komunikaty błędów
Field: domyślne wartości, opisy i dodatkowe ograniczenia
Field to odpowiednik Query czy Path, ale dla modeli Pydantic. Pozwala dodać metadane i ograniczenia bez tworzenia customowych typów:
from pydantic import BaseModel, Field
class UserCreate(BaseModel):
email: str = Field(..., description="Adres e-mail użytkownika")
password: str = Field(
...,
min_length=8,
max_length=128,
description="Hasło, co najmniej 8 znaków",
example="S3cureP@ss",
)
Opis i przykład pojawią się w OpenAPI, a min_length/max_length trafią do schematu JSON. W panelu Swagger UI użytkownik od razu zobaczy, jakich danych się oczekuje, a walidacja po stronie serwera i tak dopilnuje reszty.
Walidatory pól – logika biznesowa bliżej danych
Niektóre reguły trudno wyrazić samymi typami. Wtedy z pomocą przychodzą walidatory. W Pydantic v1 używa się dekoratora @validator, w v2 – @field_validator. Przykład na Pydantic v2:
from pydantic import BaseModel, field_validator, EmailStr
class Registration(BaseModel):
email: EmailStr
password: str
password_repeat: str
@field_validator("password_repeat")
@classmethod
def passwords_match(cls, v, values):
if "password" in values.data and v != values.data["password"]:
raise ValueError("Hasła muszą być identyczne")
return v
Informacja o błędzie trafi do odpowiedzi 422 z dokładną ścieżką pola (password_repeat) i komunikatem. Klient nie musi zgadywać, co poszło nie tak – ma wszystko w jednym, spójnym formacie.
Walidacja całego modelu i zależności między polami
Są też reguły dotyczące kombinacji pól: albo jedno, albo drugie, ale nie oba naraz. Albo przynajmniej jedno z nich. Takie przypadki obsłuży walidator modelu (@model_validator w Pydantic v2):
from pydantic import BaseModel, model_validator
class SearchQuery(BaseModel):
text: str | None = None
tags: list[str] | None = None
@model_validator(mode="after")
def at_least_one_criterion(self):
if not self.text and not self.tags:
raise ValueError("Podaj co najmniej tekst wyszukiwania lub tagi")
return self
Taka reguła nie da się łatwo odwzorować w „czystym” JSON Schema, ale wciąż pojawi się w odpowiedziach błędów walidacji jako komunikat przy całym modelu. Dla klienta to jasny sygnał, jak budować poprawne zapytania.
Konfiguracja modeli i wpływ na OpenAPI
Aliasowanie pól i konwencje nazewnicze
Backend często używa innych nazw niż frontend. Pydantic pozwala rozdzielić nazwę atrybutu w Pythonie od nazwy pola w JSON. W Pydantic v2 robi się to przez Field(alias=...) i odpowiednią konfigurację modelu:
from pydantic import BaseModel, Field
from pydantic import ConfigDict # v2
class User(BaseModel):
model_config = ConfigDict(populate_by_name=True)
user_id: int = Field(alias="id")
full_name: str = Field(alias="fullName")
W Pythonie pracujesz z user.user_id i user.full_name, ale JSON wejściowy i wyjściowy używa id i fullName. OpenAPI pokaże aliasy, bo to one są „prawdziwym” kontraktem dla świata zewnętrznego.
Model_config, tryby strict i serializacja
model_config pozwala sterować wieloma aspektami walidacji i serializacji. Kilka typowych opcji:
strict=True– wyłącza „miękką” konwersję typów,extra="forbid"– zakazuje nadmiarowych pól w wejściu,use_enum_values=True– serializuje enumy jako wartości, nie nazwy.
class StrictModel(BaseModel):
model_config = ConfigDict(
strict=True,
extra="forbid",
)
value: int
Przy takim ustawieniu JSON z polem "value": "1" zostanie odrzucony, mimo że normalnie Pydantic skonwertowałby string na int. Dla systemów z bardzo ostrym kontraktem typów to spora zaleta.
OpenAPI, przykłady i rozbudowane opisy schematów
Przykłady na poziomie pól i całych modeli
Kontrakt API staje się dużo czytelniejszy, gdy pola i modele mają przykładowe dane. Pydantic pozwala dodać example do poszczególnych pól oraz json_schema_extra dla całego modelu.
class Address(BaseModel):
street: str = Field(..., example="ul. Kowalska 1")
city: str = Field(..., example="Warszawa")
postal_code: str = Field(..., example="00-001")
model_config = ConfigDict(
json_schema_extra={
"example": {
"street": "ul. Kwiatowa 5",
"city": "Kraków",
"postal_code": "30-001",
}
}
)
Swagger UI pokaże zarówno przykłady przy polach, jak i jeden spójny przykład całego obiektu. Dzięki temu ktoś konsumujący API nie musi zgadywać, jak wygląda poprawny adres – wystarczy, że kliknie w rozwijane „Example value”.
Integracja modeli Pydantic z FastAPI w praktyce
Same modele to dopiero połowa układanki. Cała magia dzieje się wtedy, gdy podłączysz je do endpointów FastAPI – wtedy walidacja, serializacja i dokumentacja OpenAPI zaczynają współpracować z routerami, statusami HTTP i autoryzacją.
Request body – wejście zawsze w tym samym kształcie
Pierwszy, najbardziej oczywisty przypadek to użycie modeli jako „korpusu” żądania. FastAPI rozpoznaje, że skoro parametr jest typem BaseModel, to trzeba go sparsować z JSON-a i zwalidować:
from fastapi import FastAPI
from pydantic import BaseModel, EmailStr
app = FastAPI()
class UserCreate(BaseModel):
email: EmailStr
password: str
class UserOut(BaseModel):
id: int
email: EmailStr
@app.post("/users", response_model=UserOut, status_code=201)
def create_user(payload: UserCreate):
# tutaj normalna logika: zapis do bazy itd.
return UserOut(id=1, email=payload.email)
payload jest już gotowym obiektem Pythona z poprawnymi typami. Jeśli przyjdzie niepoprawny JSON, odpowiedź 422 zostanie wygenerowana automatycznie, z dokładnym opisem błędów. Jednocześnie OpenAPI dostanie pełny opis modelu wejściowego i wyjściowego.
response_model – kontrakt na wyjściu
Dobrą praktyką jest podawanie osobnego modelu na wyjściu, nawet jeśli struktury są podobne. Można wtedy spokojnie ukryć pola techniczne (np. password_hash) i pilnować, żeby klient nigdy ich nie zobaczył.
class UserInternal(BaseModel):
id: int
email: EmailStr
password_hash: str
class UserPublic(BaseModel):
id: int
email: EmailStr
@app.get("/users/{user_id}", response_model=UserPublic)
def get_user(user_id: int):
user = UserInternal(
id=user_id,
email="alice@example.com",
password_hash="...",
)
return user
FastAPI weźmie zwrócony UserInternal, przepuści go przez UserPublic i dopiero taki obiekt trafi do klienta. OpenAPI zna tylko UserPublic, więc w dokumentacji nie ma śladu po polach, które chcesz zachować w backendzie.
Tryb odczytu, tryb zapisu i rozdzielenie modeli
W większych projektach szybko pojawia się zestaw trzech modeli na tę samą encję: model wejściowy (create/update), model „pełny” wewnątrz aplikacji oraz model publiczny. Schemat może wyglądać tak:
class ArticleBase(BaseModel):
title: str
content: str
class ArticleCreate(ArticleBase):
pass
class ArticleRead(ArticleBase):
id: int
author: str
created_at: datetime
Endpointy POST/PUT używają ArticleCreate jako body, a GET-y zwracają ArticleRead. Dla klienta API to jasny sygnał: co mogę podać, a co dostanę w odpowiedzi.

Parametry zapytań, ścieżki i nagłówki z typami Pydantic
FastAPI potrafi wykorzystać te same mechanizmy walidacji także poza body – w query params, path params i nagłówkach. Wystarczy dodać odpowiednie funkcje pomocnicze.
Query i Path – walidacja bez dodatkowego kodu
Zamiast ręcznie sprawdzać, czy limit jest dodatni, a sort ma dozwoloną wartość, można oprzeć się wyłącznie na typach i ograniczeniach Pydantic:
from fastapi import Query, Path
from pydantic import conint
from enum import Enum
class SortOrder(str, Enum):
asc = "asc"
desc = "desc"
@app.get("/products")
def list_products(
limit: conint(gt=0, le=100) = Query(10, description="Liczba rekordów na stronę"),
offset: conint(ge=0) = Query(0),
sort: SortOrder = Query(SortOrder.asc),
):
return {"limit": limit, "offset": offset, "sort": sort}
OpenAPI pokaże od razu: limit z zakresem, offset jako liczba całkowita ≥ 0 oraz sort jako enum z dwoma możliwymi wartościami. Formularz w Swagger UI ustawi odpowiednie pola, a niepoprawne dane zostaną odrzucone z komunikatem 422.
Modele w parametrach zapytań – wspólne filtry
Jeśli wiele endpointów ma podobne filtry, dobrze jest je ująć w modelu Pydantic i wstrzykiwać jako zależność. Tak powstaje jeden wspólny schemat OpenAPI dla filtrów, używany w różnych miejscach.
from fastapi import Depends
from pydantic import BaseModel
class ProductFilters(BaseModel):
min_price: float | None = Field(None, ge=0)
max_price: float | None = Field(None, ge=0)
in_stock: bool | None = None
def get_filters(
min_price: float | None = Query(None, ge=0),
max_price: float | None = Query(None, ge=0),
in_stock: bool | None = Query(None),
) -> ProductFilters:
return ProductFilters(
min_price=min_price,
max_price=max_price,
in_stock=in_stock,
)
@app.get("/products/search")
def search_products(filters: ProductFilters = Depends(get_filters)):
return {"filters": filters}
W OpenAPI widać wszystkie parametry zapytania, a dodatkowo w kodzie aplikacji masz jeden, spójny obiekt filters, który łatwo przekazać np. do warstwy bazy danych.
Walidacja nagłówków i cookies
Podobnie działają nagłówki czy ciasteczka. Można nadać im typy Pydantic, a FastAPI zajmie się resztą:
from fastapi import Header, HTTPException, status
@app.get("/secure-endpoint")
def secure_endpoint(x_api_key: str = Header(...)):
if x_api_key != "sekretny-klucz":
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
return {"message": "OK"}
Typ, wymaganie oraz opis nagłówka trafią do OpenAPI. Klient od razu widzi, że musi wysłać X-API-Key, w jakim formacie i czy jest obowiązkowy.
Typy odpowiedzi, błędy i zagnieżdżone schematy
Listy, słowniki i odpowiedzi stronicowane
W realnych projektach rzadko zwraca się pojedynczy obiekt. Częściej jest to lista obiektów z dodatkowymi metadanymi stronicowania. Pydantic elegancko opisuje takie przypadki:
from typing import List
class Product(BaseModel):
id: int
name: str
class PageMeta(BaseModel):
total: int
limit: int
offset: int
class ProductPage(BaseModel):
meta: PageMeta
items: List[Product]
@app.get("/products", response_model=ProductPage)
def list_products_route():
return ProductPage(
meta=PageMeta(total=100, limit=10, offset=0),
items=[Product(id=1, name="Monitor")],
)
OpenAPI wygeneruje pełne, zagnieżdżone schematy, które Swagger UI potrafi ładnie zwizualizować. Po jednej wizycie w panelu klient rozumie strukturę odpowiedzi, nawet jeśli jest wielopoziomowa.
Union w odpowiedziach – różne kształty dla różnych przypadków
Bywa, że odpowiedź przyjmuje różne formy, np. zależnie od typu zasobu. Da się to odzwierciedlić Unionem, choć trzeba pamiętać, że część narzędzi klienckich różnie interpretuje takie schematy.
class Cat(BaseModel):
type: Literal["cat"]
meows_per_minute: int
class Dog(BaseModel):
type: Literal["dog"]
barks_per_minute: int
Pet = Cat | Dog
@app.get("/pet/{pet_id}", response_model=Pet)
def get_pet(pet_id: int):
return Cat(type="cat", meows_per_minute=20)
W OpenAPI pojawi się tu mechanizm oneOf z referencjami do Cat i Dog. Generator klienta może na tej podstawie tworzyć typy z rozróżnieniem na type="cat" lub type="dog", co bardzo ułatwia pracę np. w TypeScripcie.
Standardowe formaty błędów i własne modele błędów
FastAPI ma wbudowany model błędu dla 422, ale inne kody możesz opisać własnymi strukturami. Dzięki temu klient ma pewność, że np. 404 czy 409 będzie miało zawsze ten sam kształt.
from fastapi import HTTPException
from fastapi.responses import JSONResponse
class ErrorResponse(BaseModel):
code: str
message: str
@app.get(
"/orders/{order_id}",
responses={
404: {
"description": "Zamówienie nie istnieje",
"model": ErrorResponse,
}
},
)
def get_order(order_id: int):
if order_id != 1:
return JSONResponse(
status_code=404,
content=ErrorResponse(
code="ORDER_NOT_FOUND",
message="Zamówienie nie zostało znalezione",
).model_dump(),
)
return {"id": 1}
Opis dla 404 wraz z modelem ErrorResponse trafi do specyfikacji OpenAPI. Generator klienta może wtedy mieć np. osobny typ błędu na 404, z polami code i message.
Bezpieczeństwo, tokeny i modele autoryzacji
Token JWT i odseparowanie części publicznej od prywatnej
Tokeny często zawierają zarówno część techniczną (np. data ważności), jak i dane użytkownika. Wygodnie jest mieć na nie osobne modele – inny dla tego, co przechodzi przez API, inny dla „wnętrza” tokena.
from datetime import datetime
class Token(BaseModel):
access_token: str
token_type: str = "bearer"
class TokenPayload(BaseModel):
sub: str
exp: datetime
Endpoint logowania zwraca Token jako response_model, specyfikacja OpenAPI dokładnie opisuje kształt odpowiedzi, a logika związana z dekodowaniem JWT używa TokenPayload w zupełnie innym miejscu aplikacji.
Zależności bezpieczeństwa z modelami Pydantic
Zależności Depends często zwracają obiekty opisane modelami Pydantic. Dzięki temu reszta kodu używa silnie typowanego użytkownika, a nie słownika bez struktury.
from fastapi import Depends, HTTPException, status
class CurrentUser(BaseModel):
id: int
email: EmailStr
is_admin: bool = False
def get_current_user() -> CurrentUser:
# zwykle: odczyt tokena, sprawdzenie w bazie itd.
return CurrentUser(id=1, email="alice@example.com", is_admin=True)
@app.get("/me", response_model=CurrentUser)
def read_me(user: CurrentUser = Depends(get_current_user)):
return user
Zarówno wewnątrz aplikacji, jak i w OpenAPI ten sam model opisuje dane aktualnego użytkownika. Kod zależności jest luźno połączony z routerami, a jednocześnie masz pełny kontrakt na poziomie specyfikacji.
Migrations w API: wersjonowanie modeli i zachowanie kompatybilności
Dodawanie pól opcjonalnych bez psucia klientów
W pewnym momencie dojdzie nowe pole do istniejącego modelu. Klasyczny przykład: do użytkownika dochodzi phone_number. Jeśli zrobisz je opcjonalne i ustawisz sensowną wartość domyślną, dotychczasowi klienci się nie „wyłożą”.
class UserV1(BaseModel):
id: int
email: EmailStr
class UserV2(UserV1):
phone_number: str | None = Field(
None,
description="Numer telefonu w formacie międzynarodowym",
example="+48123456789",
)
OpenAPI zacznie prezentować nową wersję z dodatkowym polem. Klient, który je zignoruje, nadal zadziała poprawnie, a nowi klienci mogą zacząć je uzupełniać.
Endpointy wersjonowane i równoległe schematy
Kiedy zmiana jest niekompatybilna (np. zmiana typu pola lub usunięcie kluczowego atrybutu), rozsądniej jest współistnienie kilku wersji modeli i endpointów, przynajmniej przez jakiś czas.
class ProfileV1(BaseModel):
username: str
full_name: str
class ProfileV2(BaseModel):
handle: str
first_name: str
last_name: str
@app.get("/v1/profile", response_model=ProfileV1)
def profile_v1():
return ProfileV1(username="alice", full_name="Alice Doe")
@app.get("/v2/profile", response_model=ProfileV2)
def profile_v2():
return ProfileV2(handle="alice", first_name="Alice", last_name="Doe")
Specyfikacja OpenAPI pokaże obie ścieżki, każdy z innym kształtem odpowiedzi. Klienci mogą przechodzić na nową wersję stopniowo, mając jasny wgląd w to, co się zmieniło.
Modelowanie zagnieżdżonych struktur i relacji
Relacje jeden-do-wielu w odpowiedziach
Zagnieżdżone modele są w Pydantic naturalne. Można nimi opisać relacje w stylu „użytkownik + jego zamówienia” bez dodatkowego wysiłku:
class Order(BaseModel):
id: int
total: float
class UserWithOrders(BaseModel):
id: int
email: EmailStr
orders: list[Order] = []
@app.get("/users/{user_id}/details", response_model=UserWithOrders)
def user_details(user_id: int):
return UserWithOrders(
id=user_id,
email="alice@example.com",
orders=[Order(id=1, total=99.99)],
)
Swagger UI rozwinie taką strukturę drzewiasto, a generatory klienta stworzą odpowiednie klasy/typy zagnieżdżone. Po stronie frontendu wystarczy trzymać się kontraktu – reszta jest oczywista.
Unikanie cykli i referencji zwrotnych
Przy relacjach dwukierunkowych łatwo o pętlę: użytkownik ma listę postów, post ma pełnego użytkownika itd. Z punktu widzenia API lepiej zwykle zwrócić pełny obiekt tylko z jednej strony relacji, a z drugiej jedynie identyfikator.
Najczęściej zadawane pytania (FAQ)
Po co używać FastAPI z Pydantic, skoro mam już Django albo Flask?
FastAPI z Pydantic odciąża cię od ręcznej walidacji i ręcznego klejenia odpowiedzi z błędami. Zamiast pisać w każdym widoku/parze endpointów: „sprawdź, czy pole istnieje, skonwertuj typ, zwróć błąd”, opisujesz dane raz – typami i modelami – a resztą zajmuje się framework. Jedno źródło prawdy zamiast walidacji rozsianej po całym projekcie.
Dodatkowo zyskujesz automatyczną dokumentację OpenAPI, Swagger UI i ReDoc. Frontend widzi od razu aktualny kontrakt API – bez osobnego pliku YAML/JSON utrzymywanego ręcznie. Przy większym projekcie różnica w utrzymaniu jest kolosalna.
Jak działa walidacja danych w FastAPI z użyciem Pydantic krok po kroku?
Gdy wywołujesz endpoint, FastAPI sprawdza sygnaturę funkcji i widzi, jakie typy przyjmują parametry: proste (int, str, bool) albo modele Pydantic. Na tej podstawie wie, czy dane mają być w ścieżce, query, nagłówkach czy w body. Następnie zdekodowane dane trafiają do Pydantic, który próbuje je skonwertować do zadanych typów i sprawdza wymagane pola.
Jeśli coś się nie zgadza (np. „abc” zamiast liczby), Pydantic rzuca ValidationError, a FastAPI zamienia to automatycznie na odpowiedź HTTP 422 z czytelnym JSON-em opisującym, które pola są błędne i dlaczego. Twoja logika biznesowa dostaje już tylko poprawne, silnie typowane obiekty.
Czym różni się rola FastAPI od roli Pydantic w walidacji?
Pydantic odpowiada za modele danych: definiujesz klasy dziedziczące po BaseModel, typujesz pola, ustawiasz wartości domyślne. On zajmuje się konwersją wejścia (np. stringów z JSON-a) na konkretne typy Pythona, pilnuje wymaganych pól i generuje JSON Schema na potrzeby dokumentacji.
FastAPI natomiast „orchestruje” cały proces: analizuje typy w sygnaturze endpointu, przypisuje parametry do odpowiednich części żądania (body, path, query), woła Pydantic do walidacji i z gotowych schematów składa kompletny dokument OpenAPI. Dodatkowo podaje ci to w Swagger UI i ReDoc oraz pakuje błędy walidacji w poprawne odpowiedzi HTTP.
Jak zdefiniować model Pydantic i użyć go jako request body w FastAPI?
Bazą jest klasa dziedzicząca po BaseModel. Wskazujesz pola z typami i ewentualnymi wartościami domyślnymi. Przykład:
class Item(BaseModel):
name: str
price: float
is_offer: bool | None = None
Następnie używasz tego modelu jako typu parametru w endpointzie, np. def create_item(item: Item). FastAPI zinterpretuje ten parametr jako JSON w body, przekaże go do Pydantic, a ty dostaniesz gotowy obiekt Item. Nie musisz ręcznie robić request.json() ani pisać if-ów sprawdzających pola.
Jak FastAPI generuje dokumentację OpenAPI na podstawie Pydantic?
Najpierw Pydantic tworzy JSON Schema dla każdego modelu, który wykorzystujesz w endpointach. Ten schemat opisuje typy pól, wymagane atrybuty, wartości domyślne itp. FastAPI zbiera te schematy i umieszcza je w sekcji components.schemas specyfikacji OpenAPI.
Następnie łączy je z definicjami poszczególnych ścieżek: przy endpointzie POST/GET/PUT itd. dopisuje, że body wejściowe lub odpowiedź bazuje na konkretnym schemacie. Dzięki temu /docs (Swagger UI) i /redoc pokazują dokładnie to, co wynika z twoich modeli i typów, bez dodatkowej ręcznej konfiguracji.
Jak działa automatyczna konwersja typów w Pydantic i kiedy dostanę błąd?
Pydantic jest dość elastyczny: spróbuje zamienić dane na zadany typ, jeśli konwersja ma sens. Przykładowo string "123" stanie się int, "12.5" zamieni się na float, a wartości typu "true", "false", 1, 0 potrafi przemapować na bool. Dzięki temu dane z formularzy lub z luźniejszych API nadal mogą przejść poprawnie.
Błąd pojawia się wtedy, gdy konwersja jest niemożliwa lub łamie reguły, które ustalisz (np. "abc" dla int, dziwny format daty, brak wymaganego pola). W FastAPI taki błąd zostanie przechwycony i zobaczysz odpowiedź 422 z listą problematycznych pól i komunikatami. To pozwala szybko wychwycić błędne requesty po stronie klienta.
Jak w FastAPI łączyć modele Pydantic z parametrami ścieżki i query?
Najprostszy sposób to użycie modelu Pydantic dla body oraz zwykłych typów dla parametrów ścieżki i query. Przykładowo: def update_item(item_id: int, q: str | None = None, item: Item). Wtedy item_id stanie się parametrem ścieżki, q – opcjonalnym query paramem, a item – JSON-em w body walidowanym przez Pydantic.
FastAPI sam rozpozna źródło danych na podstawie tego, czy dany parametr ma domyślną wartość, gdzie jest użyty w ścieżce, i czy jest modelem Pydantic. Ty widzisz po prostu funkcję z jasno opisanymi argumentami, a resztą „hydrauliki” zajmuje się framework.
Najważniejsze wnioski
- FastAPI i Pydantic zastępują ręczną, rozproszoną walidację jednym deklaratywnym źródłem prawdy – opisujesz strukturę danych w modelach i adnotacjach typów, a framework sam pilnuje spójności.
- Sygnatura funkcji endpointu staje się kontraktem API: typy parametrów (path, query, body) oraz typ zwracany są automatycznie wymuszane, konwertowane i odzwierciedlane w specyfikacji OpenAPI.
- Pydantic odpowiada za definicję modeli, walidację i konwersję typów oraz generowanie JSON Schema, podczas gdy FastAPI „skleja” to w pełny dokument OpenAPI i obsługuje odpowiedzi HTTP, w tym błędy 422.
- Modele Pydantic można używać wszędzie (API, skrypty, workery, testy), dzięki czemu kontrakt danych jest wspólny dla całego projektu, a zmiana typu czy pola w jednym miejscu propaguje się do reszty systemu.
- Na podstawie modeli Pydantic FastAPI automatycznie tworzy dokumentację interaktywną (Swagger UI, ReDoc), bez pisania osobnych plików YAML/JSON – zmieniasz kod, dokumentacja aktualizuje się sama.
- Sposób definiowania pól w modelu (typ + wartość domyślna lub jej brak) jednoznacznie określa, co jest wymagane, co opcjonalne i co może być None, a ta logika od razu trafia do schematów OpenAPI.
- Proste typy (str, int, float, bool, datetime) są walidowane „z pudełka”, więc już podstawowy model Pydantic daje solidny fundament pod bezpieczne API, w którym złe dane są wychwytywane zanim dotrą do logiki biznesowej.






