Импорт больших объёмов данных в Django: рабочие приёмы
Загрузка миллиона строк через objects.create() в цикле займёт часы и съест память. Разбираем bulk_create, чанки, отключение сигналов и прямой COPY — с ценой каждого подхода.

Задача звучит просто: залить в базу таблицу на миллион строк из CSV. Наивная реализация — цикл с Model.objects.create() — на таком объёме работает десятки минут, держит открытой одну транзакцию на всё и раздувает память, потому что Django копит объекты в памяти при DEBUG=True (лог запросов). Разберём приёмы, которые убирают эти проблемы, и цену каждого. Примеры проверены на Django 5.x и PostgreSQL 16.
Углы, которые сознательно оставляем за рамками: миграции данных внутри RunPython — отдельная тема; асинхронный импорт через Celery — тоже; здесь только сам процесс записи в базу и то, чем платишь за скорость.
Почему цикл create() не масштабируется
Каждый вызов create() — это отдельный INSERT и отдельный round-trip к базе. На миллион строк это миллион сетевых обращений. Даже при локальной базе латентность на запрос складывается в минуты. Плюс на каждый объект срабатывают сигналы pre_save/post_save, если они подключены, и вызывается полная валидация модели, если вы её запускаете вручную.
Первое, что снимает основную боль, — пакетная вставка.
bulk_create: базовый инструмент
# Django 5.x, PostgreSQL 16
import csv
from myapp.models import Product
def import_products(path):
products = []
with open(path, newline="", encoding="utf-8") as f:
reader = csv.DictReader(f)
for row in reader:
products.append(
Product(
sku=row["sku"],
title=row["title"],
price=row["price"],
)
)
# batch_size режет один список на несколько INSERT-ов
Product.objects.bulk_create(products, batch_size=1000)
Здесь весь миллион строк вставляется пачками по 1000 в одном (по умолчанию) наборе запросов. Разница с циклом create() — на порядки: вместо миллиона INSERT получаете около тысячи многострочных INSERT.
Но у bulk_create есть цена, о которой забывают:
- Сигналы не вызываются.
pre_saveиpost_saveне сработают. Если на них висит заполнение поля или обновление счётчика — оно не произойдёт. - Метод
save()модели не вызывается. Любая логика внутри переопределённогоsave()будет пропущена. - Валидация не запускается.
full_clean()не вызывается — мусорные данные уедут в базу, если их не проверить заранее. - Первичные ключи. На PostgreSQL Django возвращает сгенерированные
idв объектах после вставки, на некоторых других СУБД — нет. На PostgreSQL 16 это работает.
Скорость bulk_create вы покупаете отказом от всей per-object логики Django. Если она вам нужна — либо переносите её в подготовку данных до вставки, либо этот приём не ваш.
Память: не держите весь файл в списке
Код выше собирает миллион объектов в один список products перед вставкой. Каждый экземпляр модели — это несколько килобайт. Миллион объектов легко займёт гигабайт и больше. На большом файле процесс упрётся в OOM.
Решение — читать и вставлять чанками, не накапливая всё в памяти:
# Django 5.x
import csv
from itertools import islice
from myapp.models import Product
def chunked_reader(reader, size):
while True:
batch = list(islice(reader, size))
if not batch:
break
yield batch
def import_products(path, chunk=5000):
with open(path, newline="", encoding="utf-8") as f:
reader = csv.DictReader(f)
for rows in chunked_reader(reader, chunk):
objs = [
Product(sku=r["sku"], title=r["title"], price=r["price"])
for r in rows
]
Product.objects.bulk_create(objs, batch_size=1000)
Теперь в памяти одновременно живёт максимум chunk строк и объектов. chunk и batch_size — разные вещи: первый ограничивает чтение файла, второй — размер одного INSERT. Держать chunk кратным batch_size удобно, но не обязательно.
Отключите лог запросов
При DEBUG=True Django складывает каждый выполненный запрос в connection.queries. На импорте это утечка памяти, растущая линейно. Импорт всегда гоняйте с DEBUG=False. Если по каким-то причинам нельзя — сбрасывайте лог:
from django.db import reset_queries
# после каждого чанка
reset_queries()
Когда bulk_create мало: прямой COPY
Даже пакетный INSERT строит SQL-текст и парсит его на стороне базы. Для по-настоящему больших объёмов PostgreSQL даёт команду COPY — самый быстрый путь загрузки, который читает данные потоком без пораздельного разбора строк.
В Django нет обёртки над COPY в ORM, но её даёт драйвер. На psycopg (версия 3) есть контекстный менеджер cursor.copy():
# Django 5.x, psycopg 3.x, PostgreSQL 16
from django.db import connection
def copy_products(path):
sql = "COPY myapp_product (sku, title, price) FROM STDIN WITH CSV HEADER"
with connection.cursor() as cursor:
# cursor.copy — метод курсора psycopg 3
with open(path, "r", encoding="utf-8") as f, cursor.copy(sql) as copy:
copy.write(f.read())
Здесь myapp_product — реальное имя таблицы в базе (Django формирует его как приложение_модель; проверьте своё через Product._meta.db_table). Синтаксис cursor.copy() и работа с потоком описаны в документации psycopg 3 — сверьтесь с ней, если у вас другая мажорная версия драйвера, поведение между psycopg2 и psycopg3 отличается.
Цена COPY: вы полностью в обход ORM. Никаких сигналов, никакой валидации, никакого приведения типов средствами Django. Формат файла должен точно совпадать со схемой таблицы. Ошибка в одной строке откатит весь COPY.
Индексы и транзакции
Каждая вставка обновляет все индексы таблицы. На импорте в уже заполненную таблицу с несколькими индексами это может быть дороже самих вставок. Приём: удалить неуникальные индексы перед импортом и пересоздать после. Уникальные и внешние ключи трогать опаснее — они защищают целостность прямо во время загрузки.
По транзакциям: не оборачивайте весь миллион в одну транзакцию с atomic(). Длинная транзакция держит блокировки и раздувает WAL. Разумнее коммитить по чанкам — тогда при падении на середине уже загруженное останется, и вы допишете хвост. Если импорт обязан быть «всё или ничего», это придётся балансировать против нагрузки на базу.
Сравнение подходов
| Подход | Относительная скорость | Сигналы и save() | Валидация | Когда брать |
|---|---|---|---|---|
| Цикл create() | очень медленно | да | по желанию | десятки строк, нужна вся логика модели |
| bulk_create чанками | быстро | нет | нет (готовьте данные заранее) | сотни тысяч строк, чистые данные |
| COPY через драйвер | максимально | нет | нет | миллионы строк, формат под контролем |
Точные цифры сильно зависят от железа, ширины строки, числа индексов и латентности до базы. Замерьте на своих данных: возьмите один и тот же файл на 100 тысяч строк и прогоните три подхода с time и логированием памяти — разброс покажет, стоит ли вам вообще уходить с bulk_create.
Типичные ошибки
- bulk_create без batch_size на огромном списке. Django построит один гигантский
INSERT, и база может отказать по лимиту параметров запроса. Всегда задавайтеbatch_size. - Ожидание срабатывания сигналов. После
bulk_createсчётчики, денормализованные поля и кеши не обновятся. Обновляйте их отдельным запросом после импорта. - Импорт с DEBUG=True. Память течёт, процесс падает на большом файле по непонятной для новичка причине.
- Загрязнённый CSV в COPY. Одна строка с лишним разделителем или неверной кодировкой откатывает всю загрузку. Прогоняйте файл через предварительную проверку.
Что делать дальше
Начните с bulk_create чанками и DEBUG=False — этого хватает для большинства импортов до нескольких миллионов строк. Замерьте время и пик памяти на своём объёме. Если bulk_create упирается в потолок, а данные чистые и формат под вашим контролем — переходите на COPY. Отдельно продумайте инвалидацию кешей и пересчёт денормализованных полей после импорта — это то, что bulk_create вам не сделает.
Создатель презентаций: Структура и текст каждого слайда: заголовки-утверждения, одна мысль на слайд, заметки докладчику.
Спросить за 150 ₽Источники: Django QuerySet API: bulk_create, psycopg 3 — Using COPY TO and COPY FROM
Частые вопросы
У меня psycopg2, а не psycopg3 — пример с cursor.copy() не работает
bulk_create вернул объекты без id — почему
Как быть с внешними ключами при импорте
Что если импорт упал на середине
Нужна валидация данных, но bulk_create её не запускает
Стоит ли удалять индексы перед импортом в продакшене
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.