pandas это первое, что называют в ответ на «какие библиотеки используете для данных», и первое, что ломается на проде, когда файл вырос. Он хорош ровно для того, для чего задуман: посмотреть данные, сверить две таблицы, собрать отчёт из пары сотен тысяч строк. Урок про то, как делать это правильно, и про границу, за которой pandas нужно заменить.
Чтение: типы задаются явно
import pandas as pd
orders = pd.read_csv(
"orders.csv",
dtype={"order_id": "int64", "city": "string", "amount": "Int64"},
parse_dates=["created_at"],
)Без dtype pandas угадывает типы по содержимому, и угадывает по-своему: колонка целых с одним пропуском становится float64 (100 превращается в 100.0), идентификаторы с ведущими нулями теряют нули, колонка из цифр и одной строки "n/a" становится object. Явный dtype на входе это тот же принцип, что в уроке про файлы: типы приводятся на границе, с ошибкой на плохом значении. Int64 с большой буквы это целые с поддержкой пропусков, string это строковый тип вместо object.
parse_dates превращает колонку в datetime64, иначе дата останется строкой, и orders[orders.created_at >= "2026-06-01"] будет сравнивать строки. Для дат в нестандартном формате есть date_format.
Числа из pandas это типы NumPy: orders.amount.sum() вернёт numpy.int64, а не int. В json.dumps и в параметры psycopg такой объект не пройдёт. На выходе из pandas приводите к обычным типам: int(total), .tolist(), .to_dict() с последующим приведением.
Групповые операции и сверки
by_city = orders.groupby("city", as_index=False)["amount"].sum()
share = orders.groupby("city")["amount"].sum() / orders["amount"].sum()groupby это GROUP BY, и думать о нём стоит так же: какое зерно у результата, что агрегируется. as_index=False оставляет ключ группировки колонкой, а не индексом, с которым потом неудобно соединять.
Соединение через merge это JOIN со всеми его ловушками из модуля SQL. Главная: дубли ключа в справочнике размножают строки факта, и сумма после merge становится больше, чем до. pandas умеет это проверять:
joined = orders.merge(customers, on="customer_id", how="left", validate="many_to_one")validate="many_to_one" проверяет, что customer_id уникален в правой таблице, и падает с MergeError, если нет. Строка стоит ноль усилий и ловит самую частую ошибку сверок. Проверять число строк до и после merge всё равно полезно.
Сверка двух источников это merge с how="outer" и indicator=True: колонка _merge показывает, откуда пришла строка (left_only, right_only, both), и это готовый отчёт о расхождениях.
Что не делать в pandas
Цикл по строкам for i, row in df.iterrows() это самый медленный способ сделать что угодно: каждая строка превращается в Series, миллион строк это минуты. Почти всегда есть векторная операция над всей колонкой: df["total"] = df["qty"] * df["price"], df["city"].str.strip().str.lower(), np.where(условие, a, b). apply с функцией по строкам быстрее iterrows, но всё равно цикл на Python; он уместен для логики, которую нельзя выразить векторно.
Цепочка df[df.x > 0]["y"] = 1 даёт SettingWithCopyWarning и, возможно, не меняет df. Запись через df.loc[df.x > 0, "y"] = 1.
Собирать DataFrame через concat в цикле по одной строке это квадратичная сложность: копить списки словарей и создать DataFrame один раз в конце.
Файл больше памяти
DataFrame занимает в памяти в 2–5 раз больше, чем CSV на диске: индекс, выравнивание, object для строк. CSV на 2 ГБ это 5–10 ГБ в pandas, и задача в контейнере с лимитом 4 ГБ умрёт на чтении. Три выхода.
Читать только нужные колонки: usecols=[...] и правильные dtype (категории для колонок с малым числом значений, int32 где хватает).
Читать чанками, если результат агрегат или фильтр:
total = 0
for chunk in pd.read_csv("orders.csv", chunksize=100_000, dtype=dtypes):
total += int(chunk["amount"].sum())Это тот же приём, что генераторы: в памяти один чанк. Не работает для операций, которым нужна вся таблица сразу: сортировка, merge с большой таблицей, оконные функции по всему набору.
Сменить инструмент. Если данные лежат в базе, агрегировать в SQL и забирать результат: база сделает GROUP BY на миллиарде строк быстрее, чем pandas на миллионе. Если это файлы в Parquet, DuckDB выполняет SQL прямо по файлам, а Polars делает то же, что pandas, но многопоточно и с ленивым планом. Если данные на кластере, Spark. pandas остаётся для последней мили: результат агрегата в отчёт.
Где pandas уместен
Разведка нового источника: посмотреть распределения, пропуски, уникальность ключей. Сверка выгрузки с витриной на сотнях тысяч строк. Отчёт в Excel или график. Трансформация, которую нельзя выразить в SQL и которая помещается в память. Тесты: собрать маленький DataFrame руками и проверить функцию.
Не уместен как движок пайплайна на регулярных объёмах в гигабайты: нет параллелизма, память непредсказуема, типы меняются незаметно. Загрузки пишутся на SQL в базе или на Spark, а pandas в них появляется на краях.
Как отвечать на собеседовании
Когда используете pandas? Для разведки, сверок, отчётов и трансформаций, которые помещаются в память. Для регулярных загрузок объёмом в гигабайты предпочитаю SQL в базе, Polars или Spark: pandas однопоточный и держит в памяти в несколько раз больше, чем размер файла.
Как не задвоить строки при merge? Проверить уникальность ключа в справочнике, validate="many_to_one", сравнить число строк до и после.
Как обработать CSV больше памяти в pandas? chunksize и агрегация по чанкам, usecols и явные dtype, или сменить инструмент: DuckDB по файлам, база, Spark.
Почему int из pandas не пишется в JSON? Потому что это numpy.int64, а не Python int. На выходе из pandas типы приводятся к обычным.
Практика
В песочнице pandas загружается отдельно, первый запуск занимает заметно дольше обычного. Результаты функций возвращайте обычными типами Python, а не NumPy: тесты сравнивают печать.
Функция revenue_by_city(path) читает orders.csv и возвращает словарь «город → сумма amount», ключи в алфавитном порядке, значения обычными int, а не numpy.int64. Заготовка возвращает Series с типами NumPy, и такой результат не уходит ни в JSON, ни в параметры запроса.
import pandas as pd
| Ввод | Ваш вывод | Ожидаемый вывод | |
|---|---|---|---|
| result = revenue_by_city("orders.csv") print(result) print(type(result).__name__, type(result["Казань"]).__name__) | — | {'Казань': 350, 'Омск': 90, 'Тверь': 80} dict int | |
| import json print(json.dumps(revenue_by_city("orders.csv"), ensure_ascii=False)) | — | {"Казань": 350, "Омск": 90, "Тверь": 80} |