Pydantic: BaseModel, валидаторы, Settings

Pydantic это библиотека для data validation на основе type hints. Используется в FastAPI для валидации request/response, в settings management, для парсинга API-ответов, JSON config. В этом уроке - BaseModel, custom validators, Settings из env переменных, и почему Pydantic стал стандартом в Python backend.

Установка

pip install pydantic
pip install pydantic-settings   # для Settings (отдельный пакет)

В Python 2026 используется Pydantic v2 - переписан с компилируемым ядром для скорости (10-50x быстрее v1). API частично отличается от v1.

BaseModel - основа

from pydantic import BaseModel

class User(BaseModel):
    name: str
    age: int
    email: str

u = User(name="Alice", age=30, email="a@b.c")
print(u.name)         # Alice
print(u.model_dump()) # {'name': 'Alice', 'age': 30, 'email': 'a@b.c'}

# Из JSON
u = User.model_validate_json('{"name": "Bob", "age": 25, "email": "b@b.c"}')

# В JSON
json_str = u.model_dump_json()

В отличие от dataclass, Pydantic валидирует при создании. Если передать невалидный тип, бросится ValidationError.

Type coercion

Pydantic пытается конвертировать совместимые типы:

u = User(name="Alice", age="30", email="a@b.c")   # str "30" → int 30
print(type(u.age))   # int

# Невалидное
u = User(name="Alice", age="abc", email="a@b.c")
# ValidationError: Input should be a valid integer

Это удобно для парсинга JSON где числа могут приходить строками (например, query params).

В strict mode конверсии отключены - точное совпадение типа требуется. Регулируется на уровне модели или поля.

Optional поля и defaults

from pydantic import BaseModel

class User(BaseModel):
    name: str                          # required
    age: int = 18                      # default
    email: str | None = None           # nullable
    tags: list[str] = []               # mutable default - Pydantic создаёт копию

Pydantic правильно handles mutable defaults - не создаёт shared mutable. С dataclass нужен field(default_factory=list), с Pydantic просто пишешь [].

Validation rules через типы

from typing import Annotated
from pydantic import BaseModel, Field, EmailStr, conint

class User(BaseModel):
    name: Annotated[str, Field(min_length=2, max_length=50)]
    age: Annotated[int, Field(ge=0, le=150)]
    email: EmailStr
    score: Annotated[float, Field(ge=0.0, le=1.0)]

u = User(name="A", age=30, email="invalid", score=0.5)
# ValidationError:
# - name: ensure this value has at least 2 characters
# - email: value is not a valid email address

Field() для constraints. EmailStr, HttpUrl, IPvAnyAddress - готовые типы с встроенной валидацией. Для них нужен pip install email-validator или pydantic[email].

Custom validators

from pydantic import BaseModel, field_validator, model_validator

class User(BaseModel):
    name: str
    age: int
    password: str
    confirm_password: str

    @field_validator("name")
    @classmethod
    def name_no_digits(cls, v: str) -> str:
        if any(c.isdigit() for c in v):
            raise ValueError("Name cannot contain digits")
        return v.strip()

    @field_validator("password")
    @classmethod
    def password_strength(cls, v: str) -> str:
        if len(v) < 8:
            raise ValueError("Password must be at least 8 characters")
        return v

    @model_validator(mode="after")
    def passwords_match(self) -> "User":
        if self.password != self.confirm_password:
            raise ValueError("Passwords do not match")
        return self
  • field_validator - валидация одного поля
  • model_validator - валидация модели целиком (полезно для cross-field логики)
  • mode="after" - после индивидуальных field validators
  • mode="before" - до парсинга, получает raw input

Nested models

class Address(BaseModel):
    street: str
    city: str
    country: str

class User(BaseModel):
    name: str
    address: Address
    backup_addresses: list[Address] = []

u = User(name="Alice", address={"street": "X", "city": "Y", "country": "Z"})
print(u.address.city)

Pydantic автоматически валидирует nested структуры. Можно передавать dict, Pydantic сам создаст вложенные модели.

Discriminated unions

Для variants одного объекта:

from typing import Literal
from pydantic import BaseModel, Field

class Cat(BaseModel):
    pet_type: Literal["cat"]
    meow_volume: int

class Dog(BaseModel):
    pet_type: Literal["dog"]
    bark_loudness: int

class Owner(BaseModel):
    name: str
    pet: Cat | Dog = Field(discriminator="pet_type")

owner = Owner(name="Alice", pet={"pet_type": "cat", "meow_volume": 5})
print(type(owner.pet))   # Cat

discriminator определяет какой тип используется по значению поля. Это эффективнее чем try-each-type подход.

Pydantic Settings

Конфигурация из env переменных (руками их читают через os.environ - см. урок про os и pathlib):

from pydantic_settings import BaseSettings

class Settings(BaseSettings):
    app_name: str = "My App"
    database_url: str
    debug: bool = False
    api_keys: list[str] = []

    class Config:
        env_file = ".env"
        env_prefix = "MYAPP_"
        case_sensitive = False

settings = Settings()
# Читает env: MYAPP_APP_NAME, MYAPP_DATABASE_URL, MYAPP_DEBUG, MYAPP_API_KEYS

С .env файлом:

MYAPP_DATABASE_URL=postgresql://localhost/mydb
MYAPP_DEBUG=true
MYAPP_API_KEYS=["key1", "key2"]

Стандартный паттерн в production:

  1. Defaults в коде
  2. Override через env vars (12-factor app)
  3. Локальная разработка через .env файл (в .gitignore)

SecretStr - защита секретов

from pydantic import SecretStr

class Settings(BaseSettings):
    api_key: SecretStr

s = Settings(api_key="my-secret-key")
print(s.api_key)                # SecretStr('**********') - safe для логов
print(s.api_key.get_secret_value())   # "my-secret-key" - реальное значение

SecretStr скрывает значение в repr/str - защита от случайной утечки в логах.

Сериализация

class User(BaseModel):
    name: str
    age: int
    password: str

u = User(name="Alice", age=30, password="secret")

# В dict
u.model_dump()
# {'name': 'Alice', 'age': 30, 'password': 'secret'}

# В JSON string
u.model_dump_json()

# Исключить поля
u.model_dump(exclude={"password"})
# {'name': 'Alice', 'age': 30}

# Только конкретные
u.model_dump(include={"name"})
# {'name': 'Alice'}

# Только set поля (не defaults)
u2 = User(name="Bob", age=25, password="x")
u2.model_dump(exclude_unset=True)
# зависит от того, что было передано в init

Alias - другое имя в JSON

from pydantic import BaseModel, Field

class User(BaseModel):
    full_name: str = Field(alias="fullName")

u = User.model_validate({"fullName": "Alice Smith"})
print(u.full_name)   # 'Alice Smith'

# При сериализации тоже camelCase
u.model_dump(by_alias=True)
# {'fullName': 'Alice Smith'}

Полезно для API с camelCase JSON и Python snake_case кодом.

Config model behavior

from pydantic import BaseModel, ConfigDict

class StrictUser(BaseModel):
    model_config = ConfigDict(
        strict=True,                # без type coercion
        extra="forbid",             # ошибка на дополнительные поля
        frozen=True,                # immutable после создания
        populate_by_name=True,      # accept as snake_case и alias
        str_strip_whitespace=True,  # trim strings
        validate_assignment=True,   # валидация при obj.field = x
    )
    name: str

extra="forbid" критично для security - предотвращает unexpected fields в request bodies. strict=True для жёсткой типизации без surprises.

Pydantic для парсинга API-ответов

import httpx
from pydantic import BaseModel

class GithubUser(BaseModel):
    login: str
    id: int
    name: str | None = None
    followers: int

async def fetch_user(username: str) -> GithubUser:
    async with httpx.AsyncClient() as client:
        r = await client.get(f"https://api.github.com/users/{username}")
        r.raise_for_status()
        return GithubUser.model_validate(r.json())

user = await fetch_user("alice")
print(user.followers)   # типизированный доступ

Внешний API возвращает JSON, Pydantic валидирует и даёт типизированный объект. Если API возвращает что-то неожиданное - явная ошибка, не silent dict access.

Pydantic vs dataclass

FeaturedataclassPydantic
Generates initДаДа
Type checkingТолько через mypyRuntime валидация
DefaultsЧерез field() для mutableПрямо
Custom validatorsЧерез post_init@field_validator, @model_validator
JSON serializationЧерез external (asdict + json)model_dump_json()
PerformanceЧуть быстрее (без validation)Быстрее в v2 (compiled core)
Use caseПростые data containersAPI/config validation

Для backend часто Pydantic из-за валидации. Для internal data containers - dataclass.

ORM/dict mode

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

    model_config = ConfigDict(from_attributes=True)

# Из ORM-like объекта
class SqlAlchemyUser:
    name = "Alice"
    age = 30

user = User.model_validate(SqlAlchemyUser())

from_attributes=True позволяет создавать Pydantic из объектов с атрибутами (SQLAlchemy ORM, дataclass, любых). Полезно для conversion между БД и API layers.

Распространённые ошибки

1. Mutable default

class Bad(BaseModel):
    tags: list = []   # OK в Pydantic - сам обрабатывает

В dataclass это бы было ошибкой, в Pydantic - валидно. Каждая инстанция получает свой список.

2. Использование dict вместо BaseModel

# Плохо в FastAPI
@app.post("/users")
def create(data: dict):
    return data   # нет валидации

Используй Pydantic models для всех body. Это и валидация, и documentation, и type safety.

3. extra="allow" в API

class User(BaseModel):
    name: str
    model_config = ConfigDict(extra="allow")   # ОПАСНО

# Принимает любые поля - utечка данных, surprising behavior

Для API лучше extra="forbid" (или default "ignore") - explicit лучше implicit.

4. SecretStr в обычных полях

class User(BaseModel):
    name: SecretStr   # ОВЕРКИЛЛ

SecretStr для секретов (passwords, API keys). Для обычных полей - обычный str. SecretStr усложняет работу с полем (нужен get_secret_value()).

5. Игнорирование ValidationError

try:
    user = User(...)
except Exception:   # слишком широко
    pass

Catch конкретный ValidationError, обработай каждое поле через exc.errors() для нормального error response.

Hooks через model_validator

Pre-processing data перед валидацией:

from pydantic import model_validator

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

    @model_validator(mode="before")
    @classmethod
    def normalize(cls, data: dict) -> dict:
        if isinstance(data, dict):
            if "email" in data:
                data["email"] = data["email"].lower().strip()
            if "name" in data:
                data["name"] = data["name"].strip()
        return data

mode="before" обрабатывает raw data до валидации - удобно для normalization (trim, lowercase, etc).

Сравнение с Go и PHP

В Go используют encoding/json со struct tags:

type User struct {
    Name  string `json:"name" validate:"required,min=2"`
    Age   int    `json:"age" validate:"min=0,max=150"`
    Email string `json:"email" validate:"email"`
}

// Парсинг
var user User
json.Unmarshal(data, &user)
// Валидация через библиотеку (например, go-playground/validator)
validate.Struct(user)

В PHP с Symfony Validator или JsonSerializable:

class User {
    #[Assert\NotBlank]
    #[Assert\Length(min: 2)]
    public string $name;

    #[Assert\Range(min: 0, max: 150)]
    public int $age;
}

Pydantic объединяет parsing + validation в одном API через type hints. Это elegantly: один класс описывает и схему, и правила. Декларативный подход.

Pydantic в asyncio контексте

Pydantic v2 thread-safe и async-friendly. Можно использовать в async коде без проблем:

async def process(data: dict) -> User:
    user = User.model_validate(data)   # синхронная operation
    await save_to_db(user)
    return user

Сама валидация синхронная (CPU-bound), но это OK - она быстрая. В async handler никаких проблем.

Мини-задание

  1. Базовая модель с валидацией:
from pydantic import BaseModel, Field, field_validator

class Product(BaseModel):
    name: str = Field(min_length=1, max_length=100)
    price: float = Field(ge=0)
    in_stock: bool = True

    @field_validator("name")
    @classmethod
    def name_capitalize(cls, v: str) -> str:
        return v.strip().capitalize()

p = Product(name="apple", price=99.99)
print(p.name)   # Apple
print(p.model_dump_json(indent=2))
  1. Nested и custom valid:
from pydantic import BaseModel, EmailStr

class Address(BaseModel):
    city: str
    zip_code: str = Field(pattern=r"^\d{6}
quot;) class User(BaseModel): name: str email: EmailStr addresses: list[Address] = [] user = User( name="Alice", email="alice@example.com", addresses=[ {"city": "Moscow", "zip_code": "101000"}, {"city": "London", "zip_code": "200000"}, ], ) print(user.model_dump())
  1. Settings из env:
from pydantic_settings import BaseSettings
import os

os.environ["APP_DATABASE_URL"] = "postgresql://localhost/test"
os.environ["APP_DEBUG"] = "true"

class Settings(BaseSettings):
    database_url: str
    debug: bool = False
    max_workers: int = 4

    class Config:
        env_prefix = "APP_"

settings = Settings()
print(settings.database_url)   # postgresql://localhost/test
print(settings.debug)           # True
print(settings.max_workers)     # 4 (default)

Что дальше

Освоили Pydantic. В следующем уроке - SQLAlchemy: ORM для работы с реляционными БД. Это слой персистентности нашего backend.

Зарегистрируйтесь бесплатно, чтобы пройти квиз, решить задание с автопроверкой и вести прогресс.