Как ускорить pandas: память, типы и обработка больших файлов
Что делать, если pandas медленно работает или не помещает файл в память: категории, downcast, chunksize, Parquet и контроль размера данных.
Содержание статьи
Большой CSV начинает медленно открываться, ноутбук съедает всю память, а простая группировка занимает минуты. Первая реакция — переписать всё на другой инструмент. Но часто проблема проще: pandas читает лишние колонки, хранит категории как длинные строки и создаёт несколько копий DataFrame.
Коротко
Оптимизация начинается с измерения: сколько строк, колонок и памяти занимает таблица. Затем уменьши вход — выберите поля и период, задайте типы, переведите повторяющиеся категории в category, а большой файл обрабатывай кусками или в Parquet.
- Не загружайте в память поля, которые не участвуют в расчёте.
- Сначала проверьте
memory_usage, потом меняйте dtype. - При chunking агрегируйте каждый кусок и объединяйте маленькие результаты.
- Не делайте
.copy()и.merge()для огромной таблицы без необходимости.
Узнайте, что занимает память
Размер файла на диске не равен размеру DataFrame в памяти. Строки и object-колонки часто занимают больше, чем ожидается. Профиль по колонкам помогает найти главный источник расхода, а не оптимизировать случайное поле.
memory = (
orders.memory_usage(deep=True)
.sort_values(ascending=False)
)
print(memory)
print('total MB:', memory.sum() / 1024**2)Выберите колонки и типы до чтения
Если отчёт использует пять полей, не загружайте пятьдесят. Для целых чисел укажите подходящий тип, а повторяющиеся строки переведите в category. Это не универсальное правило: сначала убедитесь, что диапазон значений помещается в выбранный dtype и что категория не содержит почти уникальную строку на каждую запись.
orders = pd.read_csv(
'data/orders.csv',
usecols=['order_id', 'created_at', 'channel', 'revenue'],
dtype={'order_id': 'int32', 'channel': 'category'},
parse_dates=['created_at'],
)
orders['revenue'] = pd.to_numeric(orders['revenue'], downcast='float')Большой CSV обрабатывай частями
chunksize возвращает итератор DataFrame. Не нужно складывать все куски в список: посчитайте агрегат внутри цикла и объедините небольшие результаты. Важно помнить, что mean по средним кускам может быть неверным; для среднего сохраняйте сумму и количество.
parts = []
for chunk in pd.read_csv('data/orders.csv', chunksize=100_000):
part = (
chunk.groupby('channel', as_index=False)
.agg(revenue=('revenue', 'sum'), orders=('order_id', 'nunique'))
)
parts.append(part)
report = (
pd.concat(parts)
.groupby('channel', as_index=False)
.sum()
)Суммы и количества можно складывать по кускам. Уникальных пользователей нельзя просто суммировать: один user_id может попасть в несколько chunks. Для точного результата используйте другое хранилище или сохраняйте множество ключей, если оно помещается в память.
Parquet для повторных чтений
Если один и тот же CSV читается много раз, преобразуйте его в Parquet после первичной очистки. Колонночный формат хранит типы, читает только нужные поля и обычно быстрее подходит для аналитических выборок. Но Parquet не исправляет неверное зерно и не заменяет контроль источника.
orders.to_parquet('data/clean/orders.parquet', index=False)
report = pd.read_parquet(
'data/clean/orders.parquet',
columns=['created_at', 'channel', 'revenue'],
)Сначала найдите узкое место
Медленная строка кода не всегда является причиной медленного анализа. Чтение CSV может занимать больше времени, чем сама агрегация, а merge может создать временную копию, которая съест остаток памяти. Поэтому замерь этапы отдельно: чтение, очистку, соединение и группировку. В ноутбуке достаточно time.perf_counter, а для устойчивого сравнения — нескольких прогонов на одном и том же файле.
Сравнивайте не только секунды, но и результат. После оптимизации должны совпадать число уникальных заказов, сумма выручки, диапазон дат и количество unknown. Если новая версия быстрее потому, что потеряла строки, это не оптимизация. Для больших наборов сохраните небольшой контрольный файл, на котором старая и новая реализации дают одинаковый ответ.
Отдельно следите за пиковым потреблением памяти. Три DataFrame по 400 МБ могут появиться в одной цепочке из-за временных результатов, хотя каждый объект по отдельности выглядит допустимым. Иногда достаточно разбить выражение на этапы и удалить промежуточный объект, иногда лучше изменить порядок операций и отфильтровать данные до merge.
from time import perf_counter
started = perf_counter()
orders = pd.read_csv('data/orders.csv', usecols=USECOLS)
read_seconds = perf_counter() - started
started = perf_counter()
report = orders.groupby('channel', as_index=False).agg(revenue=('revenue', 'sum'))
group_seconds = perf_counter() - started
print({'read': read_seconds, 'groupby': group_seconds})dtype — это договор о диапазоне и точности
Уменьшать тип числа можно только после проверки его диапазона. int16 не подходит для идентификатора, если тот может выйти за пределы 32 767, а float32 способен дать заметную погрешность в денежных расчётах. Для денег чаще храните исходное значение в целых копейках или используйте decimal-логику в слое, где нужна точность. Экономия памяти не должна менять финансовый итог.
Категория хорошо работает для канала, страны или тарифа, когда набор значений повторяется. Для почти уникального transaction_id она может не дать выигрыша и усложнить merge. После изменения типа сравните размер памяти, уникальные значения и пропуски. Проверка orders.dtypes сама по себе не гарантирует, что данные стали корректными.
С датами есть отдельная ловушка: parse_dates ускоряет дальнейшую работу, но не исправляет смешанные форматы. Если часть строк превратилась в NaT, посчитайте их долю и вынесите записи в quarantine. Иначе фильтр периода молча исключит часть заказов и создаст красивую, но неполную линию на графике.
| Поле | Кандидат | Что проверить |
|---|---|---|
| канал или тариф | category | число уникальных значений и unknown |
| счётчик событий | int32 или int64 | максимум за период и риск переполнения |
| день или флаг | int8 / boolean | нет ли специальных кодов кроме 0 и 1 |
| выручка | float64 или целые копейки | допустимая точность и возвраты |
| timestamp | datetime64[ns, UTC] | ошибки парсинга и часовой пояс |
category хранит колонку кодами, но groupby потом дорисовывает пустые категории
В таблице заказов миллион строк и шесть каналов. memory_usage(deep=True) по колонке channel покажет около 64 МБ: восемь байт указателя в массиве на строку плюс примерно 56 байт на сам объект короткой строки. После astype('category') от колонки останется словарь из шести значений и массив кодов по одному байту на строку — около 1 МБ. Это не экономия на проценты, это другой порядок величины, и заметен он сразу в профиле памяти.
Выигрыш держится до тех пор, пока словарь короче колонки. Тип кодов pandas выбирает по числу категорий: до 127 значений — int8, до 32 767 — int16, дальше int32. Возьмите колонку, где уникальных значений столько же, сколько строк: словарь повторит всю колонку целиком, те же 60 МБ, а сверху лягут ещё 4 МБ кодов. Памяти стало не меньше, а операций сравнения и выравнивания — больше. Отношение «уникальных значений к строкам» и есть критерий: в разы меньше — переводите, близко к единице — не трогайте.
Вторая половина истории начинается после конвертации. Категория несёт с собой список допустимых значений, и часть операций считает по этому списку, а не по данным. Отфильтруйте таблицу до одного канала: orders[orders['channel'] == 'paid_vk'].groupby('channel').agg(...) вернёт шесть строк — одну с данными и пять нулевых. Фильтр убрал строки, но не убрал категории.
На двух категориальных колонках это уже решётка. Шесть каналов и сорок стран дают 240 комбинаций, хотя реально встречается, скажем, 38 пар. В лишних строках суммы равны нулю, а mean и nunique дают NaN — и отчёт, который делит выручку на число заказов, получает деление на ноль там, где строки вообще не должно было быть. Отключает решётку observed=True. В pandas 2.1 прежний дефолт observed=False помечен как устаревший, в 3.0 он меняется на True: указывайте параметр явно, чтобы обновление библиотеки не переписало отчёт за вас.
С обработкой частями, о которой шла речь выше, есть отдельная ловушка. Если переводить колонку в категорию внутри каждого куска, словари у кусков будут разными: в первых восьми встретилось шесть каналов, в девятом — седьмой. pd.concat двух категориальных колонок с несовпадающими словарями возвращает object, молча и без предупреждения. Промежуточный результат снова занимает память как строки, и непонятно, почему после оптимизации профиль не изменился. Лечится это тем, что набор значений задаётся один раз — pd.CategoricalDtype в dtype при чтении, — либо склейкой через union_categoricals.
После любого среза полезна привычка вызывать remove_unused_categories(). Без неё value_counts печатает нули для значений, которых в срезе нет, а pivot_table раскрывает их в пустые колонки. Иногда это именно то, что нужно: в таблице «все каналы, включая простаивающие» нули честнее пропусков. Решает не библиотека, а тот, кто знает, нужен ли отчёту полный справочник значений или только встреченные.
| Операция | Поведение с category | Что задать явно |
|---|---|---|
| groupby по одной колонке | строки для всех категорий, даже отсутствующих в срезе | observed=True |
| groupby по двум колонкам | полная решётка вместо встреченных пар | observed=True |
| value_counts | нули у неиспользуемых значений | remove_unused_categories() до подсчёта |
| concat кусков с разными словарями | результат съезжает в object | CategoricalDtype с полным списком |
| merge по категориальному ключу | ключ приводится к общему типу, выигрыш теряется | одинаковый dtype с обеих сторон |
Числа — то, что покажет memory_usage(deep=True): указатель плюс объект строки против одного байта кода и словаря уникальных значений.
import pandas as pd
CHANNELS = pd.CategoricalDtype(
['organic', 'paid_vk', 'paid_search', 'referral', 'email', 'unknown'],
)
orders = pd.read_csv('data/orders.csv', dtype={'channel': CHANNELS})
report = orders.groupby(['channel', 'country'], observed=True, as_index=False).agg(
revenue=('revenue', 'sum'),
orders=('order_id', 'nunique'),
)
paid = orders[orders['channel'] == 'paid_vk'].copy()
paid['channel'] = paid['channel'].cat.remove_unused_categories()Переводя колонку в category, вы объявляете список допустимых значений. Любая операция, которая умеет его читать, будет считать по справочнику, а не по данным: в отчёт придут строки без наблюдений, а в знаменатель — ноль. Если такой справочник отчёту не нужен, память дешевле сэкономить иначе — не читать колонку вовсе.
Chunking меняет не только память, но и алгоритм
Обработка кусками подходит для ассоциативных агрегатов: суммы, количества, минимумы и максимумы можно посчитать частями и затем объединить. Среднее нужно собирать через сумму и количество. Медиана, точные уникальные пользователи и некоторые квантили не складываются так просто. Для них заранее выберите: хранить необходимое состояние, использовать приближённый алгоритм или перенести расчёт в аналитическую базу.
Порядок операций тоже важен. Если в каждой части оставить только нужные колонки и отфильтровать период, память снизится. Если сначала сделать дорогой merge со справочником, выигрыш от chunksize может исчезнуть. А если справочник маленький, его можно загрузить один раз и присоединять к очередному куску, не создавая копии большого лога.
Никогда не складывайте nunique по chunks без проверки. Один пользователь, который был активен в двух кусках, будет посчитан дважды. Для точного DAU по всему файлу можно сохранять множества user_id по дню, но это снова требует памяти. Для регулярного отчёта лучше выполнять дедупликацию в хранилище или использовать движок, который умеет распределённые distinct-агрегаты.
Сначала выпишите, какое состояние нужно сохранить между кусками. Если итог нельзя получить из суммы маленьких итогов, обычный concat и groupby дадут неверный ответ.
Когда pandas уже не лучший инструмент
Pandas хорош для исследования, подготовки небольших и средних наборов и понятных batch-расчётов. Если файл стабильно не помещается в память, нужен повторяемый параллельный запуск или таблица живёт в десятках миллиардов строк, проблема уже не в одной оптимизации. Стоит рассмотреть SQL в warehouse, DuckDB для запросов по локальным CSV и Parquet, Polars или Spark — в зависимости от среды и требований команды.
Переход не должен быть реакцией на один медленный ноутбук. Сначала зафиксируйте объём, SLA, частоту обновления, необходимость точных distinct и стоимость поддержки. Иногда перенос одного тяжёлого JOIN в SQL решает проблему, а весь pipeline на новую библиотеку только увеличивает число мест, которые нужно сопровождать.
Практичный критерий — воспроизводимый бенчмарк. Возьмите одинаковый вход, одинаковые фильтры и сравните время, пик памяти, совпадение контрольных чисел и сложность запуска. Выбирайте инструмент, который команда сможет объяснить и поддерживать через полгода, а не только тот, у которого лучший результат в одном эксперименте.
- Оставаться в pandas: исследование, отчёт, файл помещается в память.
- DuckDB или SQL: повторяемые запросы по локальным или складским таблицам.
- Polars: локальный tabular pipeline с большим объёмом и строгими типами.
- Spark: распределённая обработка, когда её действительно требует объём.
Материалы по теме

Как загружать Excel, JSON и Parquet в pandas
Практический разбор источников в pandas: прочитать Excel и JSON, выбрать лист, сохранить типы и перейти с CSV на Parquet без сюрпризов.

DuckDB для аналитика: SQL по CSV и Parquet без сервера
Что такое DuckDB и как выполнять SQL по CSV, Parquet и pandas DataFrame без сервера: примеры в Python, задача от выгрузки до ответа, отличия от PostgreSQL и ограничения.

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