Команда Python for Devs подготовила перевод статьи о том, почему словари Python могут незаметно подводить в продакшне и какие альтернативы помогают ловить ошибки раньше. В тексте разбираются dict, NamedTuple, dataclass и Pydantic — от быстрого прототипирования до строгой валидации данных.


Представьте, что вы обрабатываете записи клиентов. Пайплайн отрабатывает без ошибок, но клиенты так и не получают приветственные письма. Покопавшись в коде, вы обнаруживаете, что проблема — всего лишь опечатка в ключе словаря.

def load_customer(row):
    return {"customer_id": row[0], "name": row[1], "emial": row[2]}  # Typo


def send_welcome_email(customer):
    email = customer.get("email")  # Returns None silently
    if email:
        print(f"Sending email to {email}")
    # No email sent, no error raised


customer = load_customer(["C001", "Alice", "alice@example.com"])
send_welcome_email(customer)  # Nothing happens

Поскольку .get() возвращает None, если ключ отсутствует, баг остаётся незамеченным.

Это как раз тот тип проблем, которые хочется ловить как можно раньше. В этой статье мы посмотрим, как типизированные контейнеры данных — NamedTuple, dataclass и Pydantic — помогают выявлять такие ошибки во время выполнения.

Полный исходный код и Jupyter-ноутбук для этого туториала доступны на GitHub.

Что такое типизированные контейнеры данных?

Python предлагает несколько способов структурировать данные, и каждый следующий даёт больше безопасности, чем предыдущий:

  • dict: никакой защиты. Ошибки всплывают только при обращении к отсутствующему ключу.

  • NamedTuple: базовая безопасность. Ловит опечатки при написании кода в IDE и во время выполнения.

  • dataclass: поддержка статического анализа. Инструменты вроде mypy находят ошибки ещё до запуска программы.

  • Pydantic: максимальная защита. Валидирует данные в момент создания экземпляра.

Давайте посмотрим, как каждый из этих инструментов работает с одними и теми же данными клиента.

Использование словарей

Словари создаются быстро, но не дают никакой защиты:

customer = {
    "customer_id": "C001",
    "name": "Alice Smith",
    "email": "alice@example.com",
    "age": 28,
    "is_premium": True,
}

print(customer["name"])
Alice Smith

Опечатки в ключах

Опечатка в имени ключа приводит к KeyError во время выполнения:

customer["emial"]  # Typo: should be "email"
KeyError: 'emial'

Сообщение об ошибке говорит, что пошло не так, но не где. Когда словари проходят через несколько функций, поиск источника опечатки может занять немало времени на отладку:

def load_customer(row):
    return {"customer_id": row[0], "name": row[1], "emial": row[2]}  # Typo here


def validate_customer(customer):
    return customer  # Passes through unchanged


def send_email(customer):
    return customer["email"]  # KeyError raised here


customer = load_customer(["C001", "Alice", "alice@example.com"])
validated = validate_customer(customer)
send_email(validated)  # Error points here, but bug is in load_customer
KeyError                                  Traceback (most recent call last)
     13 customer = load_customer(["C001", "Alice", "alice@example.com"])
     14 validated = validate_customer(customer)
---> 15 send_email(validated)  # Error points here, but bug is in load_customer

Cell In[6], line 10, in send_email(customer)
      9 def send_email(customer):
---> 10     return customer["email"]

KeyError: 'email'

Трассировка стека показывает место, где был выброшен KeyError, а не то, где было написано "emial". В этом примере баг и его проявление разделены 13 строками, но в продакшн-коде они вполне могут находиться в разных файлах.

Использование .get() делает ситуацию ещё хуже, потому что он молча возвращает None:

email = customer.get("email")  # Returns None - key is "emial" not "email"
print(f"Sending email to: {email}")
Sending email to: None

Такой «тихий» сбой опасен: система уведомлений может пропустить тысячи клиентов или, что ещё хуже, код может записать None в колонку базы данных, повредив весь пайплайн данных.

Путаница типов

Опечатки приводят к падениям, но неверные типы могут незаметно портить данные. Поскольку у словарей нет схемы, ничто не мешает присвоить полю значение неподходящего типа:

customer = {
    "customer_id": "C001",
    "name": 123,  # Should be a string
    "age": "twenty-eight",  # Should be an integer
}

total_age = customer["age"] + 5
TypeError: can only concatenate str (not "int") to str

Сообщение об ошибке вводит в заблуждение: оно говорит о «конкатенации строк», но реальная проблема в том, что age вообще не должно было быть строкой.

Использование NamedTuple

NamedTuple — это лёгкий способ задать фиксированную структуру с именованными полями и аннотациями типов, по сути словарь со схемой:

from typing import NamedTuple


class Customer(NamedTuple):
    customer_id: str
    name: str
    email: str
    age: int
    is_premium: bool


customer = Customer(
    customer_id="C001",
    name="Alice Smith",
    email="alice@example.com",
    age=28,
    is_premium=True,
)

print(customer.name)
Alice Smith

Автодополнение в IDE ловит опечатки

IDE не умеют подсказывать ключи словаря, поэтому при вводе customer[" вы не увидите никаких вариантов. С NamedTuple всё иначе: при вводе customer. IDE показывает все доступные поля — customer_id, name, email, age, is_premium.

Даже если не пользоваться автодополнением и печатать вручную, опечатки подсвечиваются сразу волнистой линией:

customer.emial
         ~~~~~

При запуске кода будет выброшена ошибка:

customer.emial
AttributeError: 'Customer' object has no attribute 'emial'

Сообщение об ошибке указывает конкретный объект и отсутствующий атрибут, так что сразу понятно, что именно нужно исправить.

Неизменяемость предотвращает случайные изменения

NamedTuple являются неизменяемыми: после создания значения в них нельзя изменить:

customer.name = "Bob"  # Raises an error
AttributeError: can't set attribute

Это защищает от багов, когда данные случайно модифицируются в процессе обработки.

Ограничения: отсутствие проверки типов во время выполнения

Аннотации типов в NamedTuple не проверяются во время выполнения, поэтому передать значения неправильных типов всё равно возможно:

# Wrong types are accepted without error
customer = Customer(
    customer_id="C001",
    name=123,  # Should be str, but int is accepted
    email="alice@example.com",
    age="twenty-eight",  # Should be int, but str is accepted
    is_premium=True,
)

print(f"Name: {customer.name}, Age: {customer.age}")
Name: 123, Age: twenty-eight

Код выполняется без ошибок, но данные имеют неверные типы. Баг проявится позже, когда вы попытаетесь использовать эти данные.

Использование dataclass

dataclass уменьшает объём шаблонного кода при написании классов, которые в основном служат для хранения данных. Вместо ручной реализации init и других методов вы просто объявляете поля.

Он даёт ту же поддержку со стороны IDE, что и NamedTuple, а также добавляет три важных возможности:

  • Изменяемые объекты — значения полей можно менять после создания экземпляра.

  • Изменяемые значения по умолчанию — безопасные значения по умолчанию для списков и словарей через field(default_factory=list).

  • Логика после инициализации — возможность выполнять валидацию или вычислять производные поля в __post_init__.

from dataclasses import dataclass


@dataclass
class Customer:
    customer_id: str
    name: str
    email: str
    age: int
    is_premium: bool = False  # Default value


customer = Customer(
    customer_id="C001",
    name="Alice Smith",
    email="alice@example.com",
    age=28,
)

print(f"{customer.name}, Premium: {customer.is_premium}")
Alice Smith, Premium: False

Изменяемость позволяет обновлять данные

dataclass жертвует защитой неизменяемости NamedTuple ради гибкости. После создания экземпляра вы можете менять значения полей:

customer.name = "Alice Johnson"  # Changed after marriage
customer.is_premium = True  # Upgraded their account

print(f"{customer.name}, Premium: {customer.is_premium}")
Alice Johnson, Premium: True

Для дополнительной защиты можно использовать @dataclass(slots=True), чтобы запретить случайное добавление новых атрибутов:

@dataclass(slots=True)
class Customer:
    customer_id: str
    name: str
    email: str
    age: int
    is_premium: bool = False


customer = Customer(
    customer_id="C001",
    name="Alice",
    email="alice@example.com",
    age=28,
)

customer.nmae = "Bob"  # Typo
AttributeError: 'Customer' object has no attribute 'nmae'

Изменяемые значения по умолчанию и default_factory

Изменяемые значения по умолчанию, такие как списки, ведут себя не так, как можно ожидать. Может показаться, что каждый экземпляр получает свой собственный пустой список, но на самом деле Python создаёт [] один раз, и затем все экземпляры используют один и тот же объект:

from typing import NamedTuple


class Order(NamedTuple):
    order_id: str
    items: list = []


order1 = Order("001")
order2 = Order("002")

order1.items.append("apple")
print(f"Order 1: {order1.items}")
print(f"Order 2: {order2.items}")  # Also has "apple"!
Order 1: ['apple']
Order 2: ['apple']

Во втором заказе появляется "apple", хотя мы добавляли его только в первый. Изменение списка у одного заказа затрагивает все остальные.

dataclass защищает от такой ошибки, запрещая изменяемые значения по умолчанию:

@dataclass
class Order:
    items: list = []
ValueError: mutable default <class 'list'> for field items is not allowed: use default_factory

Вместо этого dataclass предлагает использовать field(default_factory=...). Функция-фабрика вызывается при создании экземпляра, а не при определении класса, поэтому каждый объект получает свой собственный список:

from dataclasses import dataclass, field


@dataclass
class Order:
    order_id: str
    items: list = field(default_factory=list)  # Each instance gets its own list


order1 = Order("001")
order2 = Order("002")

order1.items.append("apple")
print(f"Order 1: {order1.items}")
print(f"Order 2: {order2.items}")  # Not affected by order1

В отличие от примера с NamedTuple, второй заказ остаётся пустым, потому что у него свой собственный список.

Валидация после инициализации с помощью __post_init__

Без валидации некорректные данные проходят дальше молча:

@dataclass
class Customer:
    customer_id: str
    name: str
    email: str
    age: int
    is_premium: bool = False


customer = Customer(
    customer_id="C001",
    name="",  # Empty name
    email="invalid",
    age=-100,
)
print(f"Created: {customer}")  # No error - bad data is in your system
Created: Customer(customer_id='C001', name='', email='invalid', age=-100, is_premium=False)

dataclass предоставляет метод __post_init__, который позволяет отловить такие проблемы в момент создания объекта и провалидировать поля до того, как они будут использованы:

@dataclass
class Customer:
    customer_id: str
    name: str
    email: str
    age: int
    is_premium: bool = False

    def __post_init__(self):
        if self.age < 0:
            raise ValueError(f"Age cannot be negative: {self.age}")
        if "@" not in self.email:
            raise ValueError(f"Invalid email: {self.email}")


customer = Customer(
    customer_id="C001",
    name="Alice",
    email="invalid-email",
    age=28,
)
ValueError: Invalid email: invalid-email

Сообщение об ошибке точно указывает, что именно не так, поэтому баг легко исправить.

Ограничения: только ручная валидация

__post_init__ требует, чтобы вы прописывали все правила валидации вручную. Если забыть проверить какое-то поле, некорректные данные всё равно могут просочиться дальше.

В этом примере __post_init__ валидирует только формат email, поэтому неверные типы для name и age остаются незамеченными:

@dataclass
class Customer:
    customer_id: str
    name: str
    email: str
    age: int
    is_premium: bool = False

    def __post_init__(self):
        if "@" not in self.email:
            raise ValueError(f"Invalid email: {self.email}")


customer = Customer(
    customer_id="C001",
    name=123,  # No validation for name type
    email="alice@example.com",
    age="twenty-eight",  # No validation for age type
)

print(f"Name: {customer.name}, Age: {customer.age}")
Name: 123, Age: twenty-eight

Одни лишь аннотации типов не обеспечивают проверку во время выполнения. Для автоматической валидации нужен инструмент, который действительно проверяет типы при создании объектов.

Для подробного разбора использования dataclass и Pydantic в продакшн-сценариях см. Production-Ready Data Science.

Использование Pydantic

Pydantic — это библиотека для валидации данных, которая проверяет аннотации типов во время выполнения. В отличие от NamedTuple и dataclass, она действительно убеждается, что переданные значения соответствуют объявленным типам в момент создания объекта. Установить её можно так:

pip install pydantic

Чтобы создать модель Pydantic, нужно унаследоваться от BaseModel и объявить поля с аннотациями типов:

from pydantic import BaseModel


class Customer(BaseModel):
    customer_id: str
    name: str
    email: str
    age: int
    is_premium: bool = False


customer = Customer(
    customer_id="C001",
    name="Alice Smith",
    email="alice@example.com",
    age=28,
)

print(f"{customer.name}, Age: {customer.age}")

О том, как использовать Pydantic для принудительного получения структурированных ответов от AI-моделей, см. в нашем туториале по PydanticAI.

Проверка во время выполнения

Помните, как dataclass без возражений принимал name=123? Pydantic автоматически ловит такие случаи и выбрасывает ValidationError:

from pydantic import BaseModel, ValidationError


class Customer(BaseModel):
    customer_id: str
    name: str
    email: str
    age: int
    is_premium: bool = False


try:
    customer = Customer(
        customer_id="C001",
        name=123,
        email="alice@example.com",
        age="thirty",
    )
except ValidationError as e:
    print(e)
2 validation errors for Customer
name
  Input should be a valid string [type=string_type, input_value=123, input_type=int]
age
  Input should be a valid integer, unable to parse string as an integer [type=int_parsing, input_value='thirty', input_type=str]

Сообщение об ошибке показывает:

  • Какие поля не прошли валидацию (name, age);

  • Что ожидалось (корректная строка, корректное целое число);

  • Что было получено (123 как int, 'thirty' как str).

Вся необходимая информация для исправления бага собрана в одном месте — без долгого копания в трассировках стека.

Приведение типов

В отличие от dataclass, который сохраняет значения «как есть», Pydantic автоматически приводит совместимые типы к тем, что указаны в аннотациях:

customer = Customer(
    customer_id="C001",
    name="Alice Smith",
    email="alice@example.com",
    age="28",  # String "28" is converted to int 28
    is_premium="true",  # String "true" is converted to bool True
)

print(f"Age: {customer.age} (type: {type(customer.age).__name__})")
print(f"Premium: {customer.is_premium} (type: {type(customer.is_premium).__name__})")
Age: 28 (type: int)
Premium: True (type: bool)

Это особенно полезно при чтении данных из CSV-файлов или API, где все значения часто приходят в виде строк.

Валидация ограничений

Помимо типов, часто нужны бизнес-правила: возраст должен быть положительным, имя не может быть пустым, идентификатор клиента должен соответствовать определённому шаблону.

В dataclass поля объявляются в одном месте, а валидируются в __post_init__. По мере добавления ограничений логика валидации разрастается:

@dataclass
class Customer:
    customer_id: str
    name: str
    email: str
    age: int
    is_premium: bool = False

    def __post_init__(self):
        if not self.customer_id:
            raise ValueError("Customer ID cannot be empty")
        if not self.name or len(self.name) < 1:
            raise ValueError("Name cannot be empty")
        if "@" not in self.email:
            raise ValueError(f"Invalid email: {self.email}")
        if self.age < 0 or self.age > 150:
            raise ValueError(f"Age must be between 0 and 150: {self.age}")

Pydantic позволяет задавать ограничения прямо в Field(), удерживая правила рядом с данными, которые они валидируют:

from pydantic import BaseModel, Field, ValidationError


class Customer(BaseModel):
    customer_id: str
    name: str = Field(min_length=1)
    email: str
    age: int = Field(ge=0, le=150)  # Age must be between 0 and 150
    is_premium: bool = False


try:
    customer = Customer(
        customer_id="C001",
        name="",  # Empty name
        email="alice@example.com",
        age=-5,  # Negative age
    )
except ValidationError as e:
    print(e)
2 validation errors for Customer
name
  String should have at least 1 character [type=string_too_short, input_value='', input_type=str]
age
  Input should be greater than or equal to 0 [type=greater_than_equal, input_value=-5, input_type=int]

Валидация вложенных структур

Структуры данных редко бывают плоскими. У клиента есть адрес, у заказа — список товаров. Если ошибка возникает внутри вложенного объекта, важно точно понимать, где именно.

Pydantic валидирует каждый уровень и сообщает полный путь до ошибки:

from pydantic import BaseModel, Field, ValidationError


class Address(BaseModel):
    street: str
    city: str
    zip_code: str = Field(pattern=r"^\d{5}$")  # Must be 5 digits


class Customer(BaseModel):
    customer_id: str
    name: str
    address: Address


try:
    customer = Customer(
        customer_id="C001",
        name="Alice Smith",
        address={
            "street": "123 Main St",
            "city": "New York",
            "zip_code": "invalid",  # Invalid zip code
        },
    )
except ValidationError as e:
    print(e)
1 validation error for Customer
address.zip_code
  String should match pattern '^\d{5}$' [type=string_pattern_mismatch, input_value='invalid', input_type=str]

Сообщение об ошибке указывает address.zip_code, точно показывая место проблемы во вложенной структуре.

О том, как извлекать структурированные данные из документов с помощью Pydantic, см. в нашем гайде по извлечению данных с LlamaIndex.

Итоговые выводы

Подытожим, что даёт каждый инструмент:

  • dict: быстро создаётся. Нет структуры и валидации.

  • NamedTuple: фиксированная структура с автодополнением в IDE. Неизменяемый.

  • dataclass: изменяемые поля, безопасные значения по умолчанию, кастомная логика через __post_init__.

  • Pydantic: проверка типов во время выполнения, автоматическое приведение типов, встроенные ограничения.

Лично я использую dict для быстрого прототипирования:

stats = {"rmse": 0.234, "mae": 0.189, "r2": 0.91}

А затем перехожу на Pydantic, когда код доходит до продакшна. Например, конфигурация обучения должна сразу отклонять некорректные значения, такие как отрицательный learning rate:

from pydantic import BaseModel, Field

class TrainingConfig(BaseModel):
    epochs: int = Field(ge=1)
    batch_size: int = Field(ge=1)
    learning_rate: float = Field(gt=0)

config = TrainingConfig(epochs=10, batch_size=32, learning_rate=0.001)

Выбирайте уровень защиты, который соответствует вашим задачам. Эксперименту в ноутбуке не нужен Pydantic, а вот продакшн-API — нужен.

Русскоязычное сообщество про Python

Друзья! Эту статью подготовила команда Python for Devs — канала, где каждый день выходят самые свежие и полезные материалы о Python и его экосистеме. Подписывайтесь, чтобы ничего не пропустить!

Комментарии (3)


  1. CrazyElf
    13.01.2026 12:51

    Не, ну так то использовать .get без необходимости - это стрелять себе в ногу, так что пример несколько притянут за уши )


  1. klyusba
    13.01.2026 12:51

    Не хватает TypedDict в обзоре


  1. Veritaris
    13.01.2026 12:51

    dataclass жертвует защитой неизменяемости NamedTuple ради гибкости. После создания экземпляра вы можете менять значения полей:

    Неправда. Не жертвует, а просто по-умолчанию разрешает изменять поля объекта
    И ведь автор рассказал про параметр slots декоратора @dataclasses.dataclass. Но либо забыл, либо не читал документацию дальше, где как раз рассказывается про frozen, который как раз эмулирует запрет измений поля объекта, а также другие параметры - kw_only и др. (init, repr, eq, order, unsafe_hash, match_args, weakref_slot), пусть и не все их них прям полезны, см. https://docs.python.org/3/library/dataclasses.html#dataclasses.dataclass