Импорт миллионов строк в Django: сравниваем способы
Импорт CSV через save() в цикле упирается в тысячи запросов и часы работы. Разбираем bulk_create, COPY и внешний ETL — что выбрать под ваш объём и почему.

Задача звучит просто: загрузить в базу 3 миллиона строк из CSV. Наивный вариант — прочитать файл и в цикле вызвать obj.save() — работает на тысяче строк и намертво встаёт на миллионе: каждая строка это отдельный INSERT, отдельный round-trip до базы и отдельная транзакция при autocommit. На PostgreSQL 16 и типовом сервере такой импорт легко растягивается на несколько часов и грузит базу так, что живые запросы начинают отваливаться по таймауту.
Ниже — четыре способа загрузить много данных в Django (проверено на Django 5.x и PostgreSQL 16), с ценой каждого: что выигрываете по скорости и чем платите в валидации, памяти и читаемости.
Почему save() в цикле не масштабируется
Каждый Model.save() внутри при вставке нового объекта делает как минимум один SQL-запрос, а с сигналами pre_save/post_save и внешними ключами — больше. При autocommit каждый запрос ещё и коммитится отдельно, а fsync на диск после каждого коммита — это как раз то, что превращает импорт в часы.
Первое, что убирает часть боли, — обернуть цикл в одну транзакцию:
from django.db import transaction
# Django 5.x: одна транзакция вместо тысяч коммитов
with transaction.atomic():
for row in rows:
Product.objects.create(name=row["name"], price=row["price"])Это ускоряет за счёт одного коммита в конце, но проблему N запросов не решает: строк по-прежнему миллион, запросов — миллион. Настоящий выигрыш даёт пакетная вставка.
bulk_create — рабочая лошадка
Метод QuerySet.bulk_create() собирает несколько объектов в один многострочный INSERT. Вместо миллиона запросов получается несколько тысяч — по одному на батч.
from itertools import islice
from myapp.models import Product
def chunked(iterable, size):
it = iter(iterable)
while chunk := list(islice(it, size)):
yield chunk
# batch_size ограничивает число строк в одном INSERT
for chunk in chunked(rows, 2000):
objs = [Product(name=r["name"], price=r["price"]) for r in chunk]
Product.objects.bulk_create(objs, batch_size=1000)Важные ограничения bulk_create, которые кусают на проде:
- не вызываются сигналы
pre_saveиpost_save— если на них висит бизнес-логика, она не отработает; - не вызывается
Model.save()и переопределённая в нём логика; - на некоторых бэкендах первичные ключи в объектах не проставляются после вставки (для PostgreSQL Django обычно возвращает id, но проверьте на своей версии в документации);
- валидация полей (
full_clean()) не запускается — мусор уедет в базу молча.
Параметр batch_size подбирайте под ширину таблицы: чем больше колонок, тем меньше строк в батче, иначе упрётесь в лимит числа параметров запроса. Для узкой таблицы 1000–2000 — разумная отправная точка, но замерьте на своих данных.
bulk_create молчит про валидацию. Если в CSV затесалась строка с price="" вместо числа, save() бы её отверг, а bulk_create спокойно попытается вставить — и вы получите либо ошибку типа на всём батче, либо тихо испорченные данные. Валидируйте до вставки, а не после.
Upsert: update_conflicts
Если импорт повторяется и часть строк уже есть, начиная с Django 4.1 у bulk_create появились параметры update_conflicts, update_fields и unique_fields — это ON CONFLICT на стороне PostgreSQL. Точный набор параметров и их поведение сверяйте с документацией вашей версии Django, поведение между 4.1 и 5.x уточнялось.
COPY — когда bulk_create всё ещё медленный
Даже пакетный INSERT проходит через SQL-парсер и планировщик. PostgreSQL умеет грузить данные командой COPY — это самый быстрый способ загрузки, потоковый и без построчного разбора SQL. Django своего API для COPY не даёт, но вы можете дойти до курсора напрямую.
import io
from django.db import connection
# CSV в памяти или файловый объект; колонки в порядке таблицы
buffer = io.StringIO()
for r in rows:
buffer.write(f"{r['name']}\t{r['price']}\n")
buffer.seek(0)
with connection.cursor() as cursor:
# copy_expert доступен через psycopg; синтаксис зависит
# от версии драйвера (psycopg2 vs psycopg 3) — сверьте с докой
cursor.copy_expert(
"COPY myapp_product (name, price) FROM STDIN WITH (FORMAT csv, DELIMITER E'\\t')",
buffer,
)COPY даёт кратный выигрыш на миллионах строк, но платите вы дорого:
- никакой Django-валидации и ORM-логики вообще — вы пишете в таблицу мимо модели;
- придётся руками экранировать разделители и переносы строк в данных, иначе COPY развалит структуру;
- имя таблицы (
myapp_product) прибито гвоздями — переименуете модель и забудете править импорт; - API драйвера отличается между psycopg2 и psycopg 3. Точное имя метода и сигнатуру берите из документации вашего драйвера, не копируйте вслепую.
Внешний ETL — когда данные не помещаются в один процесс
Если источник — не разовый CSV, а регулярный поток из другой системы, тащить его через Django-процесс вообще необязательно. Разумнее грузить напрямую в БД инструментом вроде \copy в psql или отдельным ETL-пайплайном, а Django оставить для чтения. Тогда импорт не конкурирует с веб-воркерами за коннекты и не держит память приложения.
Сравнение подходов
| Способ | Скорость на 1–3 млн строк | Валидация и сигналы | Когда брать |
|---|---|---|---|
| save() в цикле | Часы | Полная | До тысяч строк, разовые задачи |
| save() + transaction.atomic | Десятки минут | Полная | Десятки тысяч, нужна логика save() |
| bulk_create | Минуты | Нет сигналов, нет full_clean | Основной выбор для сотен тысяч–миллионов |
| COPY | Секунды–минуты | Нет ничего | Миллионы строк, скорость критична |
Когда что выбирать
- Нужна логика save() или сигналы (пересчёт агрегатов, отправка событий) — остаётесь на save() в транзакции и мельчите батчами вручную, либо выносите логику из сигналов в отдельный шаг после импорта.
- Данные из доверенного источника, объём до нескольких миллионов — bulk_create с валидацией на входе. Это дефолт, к которому стоит стремиться.
- Миллионы строк, узкое окно загрузки — COPY, но с полным контролем над экранированием и осознанием, что валидации нет.
- Регулярный поток из внешней системы — отдельный ETL мимо Django, приложение только читает.
Что ломается на проде
Три вещи, которые всплывают именно под реальным объёмом, а не на тестовом файле в сто строк:
- Память. Читать весь CSV в список объектов на 3 млн строк — это гигабайты RAM. Читайте и вставляйте батчами, не собирайте всё в памяти сразу.
- Блокировки и live-трафик. Длинный импорт держит транзакцию и коннекты. Если он конкурирует с веб-воркерами за пул соединений, боевые запросы начнут ждать. Гоняйте тяжёлый импорт в отдельное время или на реплике/отдельном коннекте.
- Индексы и триггеры. Каждый индекс на таблице замедляет вставку. На разовой массовой загрузке иногда выгоднее удалить индексы, залить данные и построить индексы заново — но это осознанное решение для DBA, не для регулярного импорта на живой таблице.
Что делать дальше
Начните с замера: возьмите репрезентативный кусок данных (не сто строк, а хотя бы сотню тысяч) и прогоните текущий способ с логированием времени и числа запросов через django.db.connection.queries в DEBUG или через django-debug-toolbar в dev-окружении. Если запросов столько же, сколько строк, — вы на save() в цикле, и первый шаг это bulk_create. Дальше смотрите на окно загрузки и требования к валидации: если минуты bulk_create вас устраивают, COPY не нужен и его хрупкость вам ни к чему.
Проектная работа: От проблемы до защиты: продукт, этапы, вопросы комиссии
Спросить за 15 ₽Источники: Django QuerySet API — bulk_create, PostgreSQL 16 — COPY
Частые вопросы
У меня Django 3.2, работает ли update_conflicts в bulk_create?
Почему после bulk_create у объектов нет id?
bulk_create падает с ошибкой про слишком много параметров запроса. Что делать?
Как импортировать, не завалидировав мусор в базу?
Импорт вешает боевые запросы по таймауту. Как быть?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.