Все материалы
Продуктовая аналитикапрактикумсредний

Как ускорить pandas: память, типы и обработка больших файлов

Что делать, если pandas медленно работает или не помещает файл в память: категории, downcast, chunksize, Parquet и контроль размера данных.

КейсПрактика8 августа 2026 г.обновлено 12 сентября 2026 г.10 мин

Большой CSV начинает медленно открываться, ноутбук съедает всю память, а простая группировка занимает минуты. Первая реакция — переписать всё на другой инструмент. Но часто проблема проще: pandas читает лишние колонки, хранит категории как длинные строки и создаёт несколько копий DataFrame.

Коротко

Оптимизация начинается с измерения: сколько строк, колонок и памяти занимает таблица. Затем уменьши вход — выберите поля и период, задайте типы, переведите повторяющиеся категории в category, а большой файл обрабатывай кусками или в Parquet.

  • Не загружайте в память поля, которые не участвуют в расчёте.
  • Сначала проверьте memory_usage, потом меняйте dtype.
  • При chunking агрегируйте каждый кусок и объединяйте маленькие результаты.
  • Не делайте .copy() и .merge() для огромной таблицы без необходимости.

Узнайте, что занимает память

Размер файла на диске не равен размеру DataFrame в памяти. Строки и object-колонки часто занимают больше, чем ожидается. Профиль по колонкам помогает найти главный источник расхода, а не оптимизировать случайное поле.

pythonПрофиль памяти по колонкам
memory = (
    orders.memory_usage(deep=True)
    .sort_values(ascending=False)
)
print(memory)
print('total MB:', memory.sum() / 1024**2)

Выберите колонки и типы до чтения

Если отчёт использует пять полей, не загружайте пятьдесят. Для целых чисел укажите подходящий тип, а повторяющиеся строки переведите в category. Это не универсальное правило: сначала убедитесь, что диапазон значений помещается в выбранный dtype и что категория не содержит почти уникальную строку на каждую запись.

pythonЧитать только нужное и уменьшить повторяющиеся категории
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 по средним кускам может быть неверным; для среднего сохраняйте сумму и количество.

pythonСумма и количество по каналам без загрузки всего файла
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 не исправляет неверное зерно и не заменяет контроль источника.

pythonСохранить подготовленный слой в 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.

pythonИзмерить этапы без гадания
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 или целые копейкидопустимая точность и возвраты
timestampdatetime64[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 кусков с разными словарямирезультат съезжает в objectCategoricalDtype с полным списком
merge по категориальному ключуключ приводится к общему типу, выигрыш теряетсяодинаковый dtype с обеих сторон
Во что превращается колонка channel на миллионе строк

Числа — то, что покажет memory_usage(deep=True): указатель плюс объект строки против одного байта кода и словаря уникальных значений.

Память колонки, МБ
pythonСловарь задан один раз, решётка выключена явно
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-агрегаты.

Правило chunking

Сначала выпишите, какое состояние нужно сохранить между кусками. Если итог нельзя получить из суммы маленьких итогов, обычный concat и groupby дадут неверный ответ.

Когда pandas уже не лучший инструмент

Pandas хорош для исследования, подготовки небольших и средних наборов и понятных batch-расчётов. Если файл стабильно не помещается в память, нужен повторяемый параллельный запуск или таблица живёт в десятках миллиардов строк, проблема уже не в одной оптимизации. Стоит рассмотреть SQL в warehouse, DuckDB для запросов по локальным CSV и Parquet, Polars или Spark — в зависимости от среды и требований команды.

Переход не должен быть реакцией на один медленный ноутбук. Сначала зафиксируйте объём, SLA, частоту обновления, необходимость точных distinct и стоимость поддержки. Иногда перенос одного тяжёлого JOIN в SQL решает проблему, а весь pipeline на новую библиотеку только увеличивает число мест, которые нужно сопровождать.

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

  • Оставаться в pandas: исследование, отчёт, файл помещается в память.
  • DuckDB или SQL: повторяемые запросы по локальным или складским таблицам.
  • Polars: локальный tabular pipeline с большим объёмом и строгими типами.
  • Spark: распределённая обработка, когда её действительно требует объём.
Продолжить чтение
Вся библиотека