Команда 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)

Veritaris
13.01.2026 12:51dataclass жертвует защитой неизменяемости 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
CrazyElf
Не, ну так то использовать
.getбез необходимости - это стрелять себе в ногу, так что пример несколько притянут за уши )