FastAPI i Pydantic: walidacja danych, modele wejścia i wyjścia

Piotr Dul

17-10-2026

(aktualizacja: 17-10-2026)

Jak w FastAPI używać Pydantic w praktyce: osobny model wejścia i wyjścia, ukrywanie hasła w odpowiedzi, ograniczenia pól i częściowa aktualizacja PATCH. Z przetestowanym kodem.

FastAPI i Pydantic: walidacja danych, modele wejścia i wyjścia
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_length i max_length pilnują długości tekstu.
  • Literal pozwala 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 /docs zobaczysz listę dozwolonych opcji. Zamiast Literal możesz użyć klasy Enum.
  • default_factory=list tworzy 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, ale default_factory jasno 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.

Konsultacja i warsztaty devmentor.pl

Masz pytania?