Валидация и очистка грязных данных на Python: рабочие приёмы
CSV с датами в пяти форматах, дублями и NaN там, где ждёте ноль. Разбираем, чем чистить и валидировать данные на Python: голый pandas, Pydantic и pandera — с ценой каждого подхода.

Вам приходит выгрузка на 200 тысяч строк: телефоны в формате +7, 8 и 7 (900), даты как 2024-03-01 и 01.03.2024, поле «сумма» с пробелами-разделителями тысяч и запятой вместо точки, а в колонке «email» у каждой сотой строки — пустая ячейка или —. Загрузить это в базу как есть нельзя: одна строка с мусором рушит расчёт агрегатов. Ниже — три подхода к очистке и валидации на Python и цена каждого. Код проверялся на Python 3.12, pandas 2.2, Pydantic 2.x и pandera 0.20.
Угол статьи узкий: как поймать и починить грязь в табличных данных перед загрузкой. Схемы БД, ETL-оркестрация и работа с потоковыми данными — отдельные темы, здесь их нет.
Что считать грязью
Прежде чем чистить, договоритесь, что именно ломается. На практике мусор в выгрузках сводится к нескольким классам:
- Форматы одного типа. Даты, телефоны, суммы записаны по-разному в пределах одной колонки.
- Пропуски-самозванцы. Пустая строка,
-,N/A,нет данных— всё это NaN, но pandas по умолчанию распознаёт не все варианты. - Дубли. Полные и частичные — когда совпадает ключ, но различаются второстепенные поля.
- Нарушение бизнес-правил. Отрицательная цена, дата в будущем, email без
@. - Скрытые типы. Колонка чисел, где одна ячейка — текст, из-за чего pandas делает весь столбец
object.
Очевидное решение — «прогоню через df.dropna() и приведу типы» — ломается на первом же классе: dropna не тронет ячейку с —, а astype(float) упадёт на пробеле-разделителе тысяч. Нужны более точные инструменты.
Подход 1. Голый pandas: быстро и грубо
Если данные разовые и правил немного, хватает pandas. Ключ — задать список «пустых» значений при чтении и чистить колонки по одной.
import pandas as pd
# Расширяем список того, что считать пропуском
NA_VALUES = ["", "-", "—", "N/A", "нет данных", "null"]
df = pd.read_csv(
"orders.csv",
na_values=NA_VALUES,
keep_default_na=True, # оставить стандартные NaN, NA и т.п.
dtype={"phone": "string"}, # не дать pandas превратить телефон в число
)
# Сумма: убираем пробелы-разделители тысяч, меняем запятую на точку
df["amount"] = (
df["amount"]
.astype("string")
.str.replace(r"\s+", "", regex=True)
.str.replace(",", ".", regex=False)
)
# errors="coerce" превращает нечисловое в NaN, а не роняет весь вызов
df["amount"] = pd.to_numeric(df["amount"], errors="coerce")
# Даты в разных форматах: format="mixed" разбирает каждую строку отдельно
df["order_date"] = pd.to_datetime(
df["order_date"], format="mixed", dayfirst=True, errors="coerce"
)
# Телефон: оставляем только цифры, нормализуем к 7XXXXXXXXXX
digits = df["phone"].str.replace(r"\D", "", regex=True)
df["phone"] = digits.str.replace(r"^8", "7", regex=True)
# Дубли по ключу заказа, оставляем последнюю запись
df = df.drop_duplicates(subset="order_id", keep="last")
После errors="coerce" всё, что не разобралось, стало NaN. Отчёт по браку — одна строка: df["amount"].isna().sum(). Дальше решаете, дропать эти строки или отправлять на ручной разбор.
Цена. Кода мало, зависимостей ноль. Но правила размазаны по вызовам, повторно их не переиспользовать, а «валидация» сводится к «получилось разобрать или нет». Бизнес-правила («сумма > 0») придётся проверять руками отдельными масками.
Правило errors="coerce" — обоюдоострое: оно не роняет обработку, но молча превращает мусор в NaN. Всегда считайте, сколько значений оно съело, иначе потеряете часть данных, не заметив.
Про format="mixed"
Опция format="mixed" в pd.to_datetime появилась в pandas 2.0 и разбирает каждую строку своим форматом. Она медленнее фиксированного format и на неоднозначных датах вроде 03.04.2024 опирается на dayfirst. Если формат в файле один — задайте его явно строкой, это и быстрее, и предсказуемее.
Подход 2. Pydantic: валидация построчно с типами
Когда данные идут в приложение и каждую запись надо не только почистить, но и жёстко проверить, удобнее описать модель. Pydantic 2.x валидирует объект, приводит типы и даёт понятную ошибку с указанием поля.
from datetime import date
from pydantic import BaseModel, field_validator, EmailStr
class Order(BaseModel):
order_id: int
amount: float
order_date: date
email: EmailStr # проверка формата email из коробки
@field_validator("amount")
@classmethod
def amount_positive(cls, value: float) -> float:
if value <= 0:
raise ValueError("сумма должна быть больше нуля")
return value
# Прогоняем словари построчно, собираем брак отдельно
valid_rows = []
errors = []
for i, row in enumerate(df.to_dict(orient="records")):
try:
valid_rows.append(Order(**row))
except Exception as exc:
errors.append((i, str(exc)))
Плюс модели — валидные и невалидные строки разъезжаются автоматически, а сообщение об ошибке точно указывает поле и причину. EmailStr требует установленного пакета email-validator — без него импорт упадёт, это отдельная зависимость.
Цена. Построчный цикл на сотнях тысяч строк заметно медленнее векторных операций pandas. Pydantic хорош на данных, которые и так приходят объектами (JSON из API), но заставлять его молотить большой DataFrame через to_dict — расточительно.
Подход 3. pandera: схема поверх DataFrame
pandera даёт то же самое — типы, диапазоны, бизнес-правила, — но проверяет DataFrame целиком, векторно, не разбирая на словари. Это компромисс между скоростью pandas и строгостью Pydantic.
import pandera.pandas as pa
from pandera.pandas import Column, Check, DataFrameSchema
schema = DataFrameSchema({
"order_id": Column(int, unique=True),
"amount": Column(float, Check.greater_than(0)),
"order_date": Column("datetime64[ns]"),
"phone": Column(str, Check.str_matches(r"^7\d{10}$")),
})
# lazy=True собирает ВСЕ нарушения, а не падает на первом
try:
validated = schema.validate(df, lazy=True)
except pa.errors.SchemaErrors as exc:
# exc.failure_cases — DataFrame с колонкой, строкой и значением-нарушителем
print(exc.failure_cases)
Флаг lazy=True — главное, ради чего берут pandera на грязных данных: без него схема падает на первом же нарушении, и вы чините проблемы по одной. С ним получаете таблицу всех нарушений сразу, с указанием колонки, индекса строки и конкретного значения.
Обратите внимание на импорт: с pandera 0.20 движки разведены по подмодулям, и для pandas берут pandera.pandas, а не корневой pandera. Если у вас более ранняя версия, импорт короче — import pandera as pa; сверьтесь с документацией своей версии.
Что выбрать
| Критерий | Голый pandas | Pydantic 2.x | pandera 0.20 |
|---|---|---|---|
| Скорость на 100k+ строк | высокая, векторно | низкая, построчно | высокая, векторно |
| Бизнес-правила | вручную масками | валидаторы полей | Check в схеме |
| Отчёт о браке | сами считаете | исключение на строку | failure_cases целиком |
| Зависимости | нет | + email-validator для EmailStr | отдельный пакет |
| Где уместно | разовая чистка | объекты из API | регулярный ETL на DataFrame |
Понятный критерий выбора:
- Разово почистить выгрузку — pandas. Не тащите зависимости ради одного скрипта.
- Данные приходят как JSON/объекты, нужна строгая типизация в приложении — Pydantic.
- Регулярный пайплайн на больших DataFrame с фиксированной схемой — pandera. Схему кладёте в репозиторий, она же документирует контракт данных.
Где всё ломается
Типичные грабли, на которые наступают даже с правильным инструментом:
- Молчаливый
coerce. Разобрали 199 000 из 200 000, а про 1 000 забыли. Всегда логируйте число NaN после каждого преобразования. - Неоднозначные даты.
05.06.2024— это 5 июня или 6 мая? Безdayfirstи знания источника вы гарантированно перепутаете часть дат, и ошибка не всплывёт до отчёта. - Дубли по неполному ключу.
drop_duplicates(subset="order_id")сkeep="last"тихо выкинет строки — убедитесь, что «последняя» действительно верная, а не просто последняя в файле. - Кодировка и BOM. Файл из 1С часто в
cp1251, а не UTF-8; первая ячейка приходит с невидимым BOM и не совпадает при сравнении. Проверьтеencodingпри чтении. - Пробелы по краям.
" Москва"и"Москва"— разные значения для группировки. Прогоняйте строковые колонки через.str.strip().
Что делать дальше
Соберите один валидирующий слой на входе пайплайна и не размазывайте проверки по коду. Практичный порядок: сначала нормализуете форматы (даты, телефоны, суммы), потом прогоняете через схему pandera с lazy=True, брак из failure_cases складываете в отдельный файл на ручной разбор, чистые данные идёте грузить. Заведите метрику доли отбракованных строк за прогон — её рост обычно значит, что источник изменил формат выгрузки, и это стоит поймать до того, как испортится отчётность. Синтаксис проверок и список Check сверяйте с документацией вашей версии pandera — API там заметно менялся между релизами.
Проверка грамотности: Орфография, пунктуация, стиль — с объяснением каждой правки
Спросить за 10 ₽Источники: pandas: pandas.to_datetime — документация, pandera — Official documentation
Частые вопросы
У меня pandera старее 0.20, импорт pandera.pandas не находится. Что делать?
pd.to_datetime с format="mixed" ругается или разбирает даты неверно. Почему?
Что будет, если в данных попадётся ячейка, которую errors="coerce" не сможет разобрать?
Pydantic сильно тормозит на большом DataFrame. Это нормально?
EmailStr в Pydantic падает при импорте. В чём дело?
Как ловить не полные, а частичные дубли — когда ключ совпадает, а второстепенные поля различаются?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.