W FastAPI Pydantic służy do opisywania danych, które API przyjmuje i zwraca: model w argumencie endpointu waliduje dane wejściowe, a `response_model` filtruje odpowiedź. Dobrą praktyką jest osobny model wejścia i osobny model wyjścia, dzięki czemu na przykład hasło nigdy nie wycieknie w odpowiedzi.
W pierwszym artykule o FastAPI użyliśmy prostego modelu Task. Tu pokażemy, jak z modeli Pydantic korzystać w sposób, który nie wywróci się przy pierwszym prawdziwym projekcie: z ograniczeniami pól, osobnymi modelami wejścia i wyjścia oraz częściową aktualizacją danych. Czym sam Pydantic jest i jak działa, opisaliśmy w artykule Czym jest Pydantic w Pythonie, więc tutaj skupiamy się na FastAPI.
Jak FastAPI używa Pydantic do walidacji danych?
Gdy w argumencie endpointu podasz model Pydantic, FastAPI traktuje go jako treść zapytania (body). Zrobi trzy rzeczy: odczyta JSON, sprawdzi go względem modelu i przekaże Ci gotowy obiekt, jeśli wszystko się zgadza. Jeśli nie, zwróci błąd 422 z opisem, które pole jest niepoprawne, a Twój kod w ogóle się nie uruchomi.
To oznacza, że w funkcji endpointu możesz zakładać, że dane są poprawne. Nie piszesz ręcznych sprawdzeń typu „czy pole istnieje” i „czy to jest tekst”.
Jak ograniczyć wartości pól?
Do ograniczeń służy Field oraz typ Literal:
from typing import Literal
from pydantic import BaseModel, Field
class UserCreate(BaseModel):
username: str = Field(min_length=3, max_length=30)
password: str = Field(min_length=8)
role: Literal["student", "mentor"] = "student"
tags: list[str] = Field(default_factory=list)
min_lengthimax_lengthpilnują długości tekstu.Literalpozwala przyjąć tylko wskazane wartości. Dla roli „admin” FastAPI odpowie błędem z komunikatem w stylu „Input should be 'student' or 'mentor'”, a w dokumentacji/docszobaczysz listę dozwolonych opcji. ZamiastLiteralmożesz użyć klasyEnum.default_factory=listtworzy nową pustą listę dla każdego obiektu. Pydantic kopiuje wartości domyślne, więc nie wpadniesz w pułapkę znaną ze zwykłych funkcji, aledefault_factoryjasno pokazuje intencję.
Dlaczego warto mieć osobny model wejścia i wyjścia?
Weź endpoint tworzący użytkownika. Dane wejściowe zawierają hasło, ale odpowiedź nie powinna go zwracać. Rozwiązaniem są dwa modele:
from fastapi import FastAPI
app = FastAPI()
class UserOut(BaseModel):
id: int
username: str
role: Literal["student", "mentor"]
users: dict[int, dict] = {}
@app.post("/users", response_model=UserOut, status_code=201)
def create_user(user: UserCreate):
user_id = len(users) + 1
users[user_id] = {"id": user_id, **user.model_dump()}
return users[user_id]
Parametr response_model=UserOut mówi FastAPI, żeby przed wysłaniem odpowiedzi przefiltrować dane przez ten model. Nawet jeśli w słowniku users jest hasło, w odpowiedzi go nie będzie. Wyślij POST /users z JSON-em {"username": "ania", "password": "tajne1234"}, a dostaniesz tylko id, username i role.
W prawdziwej aplikacji nigdy nie zapisujesz hasła jawnym tekstem, tylko jego hash. Tutaj upraszczamy, żeby pokazać sam mechanizm.
Filtr działa też w dokumentacji: pod /docs pole odpowiedzi opisane jest według UserOut, więc osoba korzystająca z API widzi dokładnie to, co dostanie.
Jak zrobić częściową aktualizację (PATCH)?
Przy aktualizacji użytkownik często zmienia jedno pole, a nie wszystkie. Potrzebujesz modelu, w którym każde pole jest opcjonalne:
from fastapi import HTTPException
class UserUpdate(BaseModel):
username: str | None = Field(default=None, min_length=3, max_length=30)
role: Literal["student", "mentor"] | None = None
@app.patch("/users/{user_id}", response_model=UserOut)
def update_user(user_id: int, changes: UserUpdate):
if user_id not in users:
raise HTTPException(status_code=404, detail="Nie ma takiego użytkownika")
users[user_id].update(changes.model_dump(exclude_unset=True))
return users[user_id]
Kluczowe jest exclude_unset=True. Bez niego model_dump() zwróciłby także pola, których klient nie przysłał, z wartością None, i nadpisałbyś nimi istniejące dane. Z exclude_unset=True w słowniku zostają tylko pola faktycznie wysłane w zapytaniu. Dzięki temu PATCH /users/1 z {"role": "mentor"} zmienia wyłącznie rolę.
Zauważ też różnicę między „pole nie zostało przysłane” a „pole przysłano jako null”. exclude_unset je rozróżnia, i właśnie dlatego jest lepszym wyborem niż sprawdzanie, czy wartość jest None.
Jakie błędy walidacji zobaczy użytkownik API?
Przy zbyt krótkim haśle lub złej roli FastAPI zwraca status 422 i listę błędów. Każdy z nich zawiera lokalizację (np. body, password), typ błędu i czytelny komunikat. Nie musisz niczego dopisywać, a osoba korzystająca z API od razu wie, co poprawić. Na początek domyślne zachowanie jest wystarczające, a jak je zmienić, pokazujemy w następnej sekcji.
Jak obsługiwać błędy w FastAPI?
Walidacja to tylko jeden rodzaj błędów. Poza nią zdarzają się brakujące zasoby, brak uprawnień i nieoczekiwane awarie. FastAPI daje na to kilka narzędzi.
Jak zwrócić błąd HTTP z endpointu?
Do tego służy HTTPException, której użyliśmy już dla brakującego użytkownika. Możesz też dodać własne nagłówki:
@app.get("/forbidden")
def forbidden():
raise HTTPException(
status_code=403,
detail="Brak dostępu",
headers={"X-Reason": "role"},
)
Klient dostanie status 403, JSON {"detail": "Brak dostępu"} i nagłówek X-Reason.
Jak oddzielić logikę od kodów HTTP?
Gdy ten sam błąd zgłasza wiele miejsc w kodzie, wygodniej utworzyć własny wyjątek i jeden handler:
from fastapi import Request
from fastapi.responses import JSONResponse
class UserNotFound(Exception):
def __init__(self, user_id: int):
self.user_id = user_id
@app.exception_handler(UserNotFound)
def user_not_found_handler(request: Request, exc: UserNotFound):
return JSONResponse(
status_code=404,
content={
"error": "user_not_found",
"message": f"Nie ma użytkownika {exc.user_id}",
},
)
Teraz w dowolnej funkcji piszesz raise UserNotFound(user_id), a FastAPI zamienia to na odpowiedź 404 w jednym, spójnym formacie. Funkcje biznesowe nie muszą nic wiedzieć o HTTP.
Jak zmienić format błędów walidacji 422?
Domyślna lista błędów jest rozbudowana. Jeśli chcesz prostszy format, nadpisz handler dla RequestValidationError:
from fastapi.exceptions import RequestValidationError
@app.exception_handler(RequestValidationError)
def validation_handler(request: Request, exc: RequestValidationError):
errors = [
{"field": ".".join(str(part) for part in e["loc"][1:]), "message": e["msg"]}
for e in exc.errors()
]
return JSONResponse(
status_code=422,
content={"error": "validation_error", "details": errors},
)
Zapytanie GET /users/abc zwróci wtedy {"error": "validation_error", "details": [{"field": "user_id", "message": "Input should be a valid integer, ..."}]}. Fragment loc[1:] pomija pierwszy element lokalizacji (body, path albo query), żeby zostawić tylko nazwę pola.
Co się dzieje przy nieobsłużonym błędzie?
Jeśli w endpoincie wystąpi wyjątek, którego nie obsłużysz (na przykład dzielenie przez zero), klient dostanie samo „Internal Server Error” ze statusem 500, bez szczegółów. To celowe: nie chcesz pokazywać użytkownikom API nazw plików ani fragmentów kodu. Szczegółów szukasz w logach serwera. Na produkcji warto dodać własne logowanie, żeby takie błędy nie przepadły.
Jaki błąd mentor poprawia najczęściej?
Uwaga mentora: początkujący często łapią każdy wyjątek ogólnym except Exception i zwracają 200 z komunikatem o błędzie. Klient nie ma wtedy jak odróżnić sukcesu od porażki. Zwracaj właściwy kod statusu: 404, 403, 422, a 500 zostaw dla prawdziwych awarii.
Co dalej po modelach?
Znasz już modele wejścia i wyjścia, ograniczenia pól i PATCH. Dalej możesz dodać zapis do prawdziwej bazy, przesyłanie plików razem z danymi (to osobny temat, bo wymaga Form i File) oraz konfigurację CORS. W kolejnym artykule pokażemy, jak połączyć FastAPI z modelem językowym i obsłużyć wiele równoległych zapytań.
Jeśli chcesz pisać backend w Pythonie z mentorem, który ocenia Twój kod, sprawdź kurs Python developer.


