Всем привет! Меня зовут Макс, я Lead Backend и автор YouTube-канала PyLounge.
Это третья часть мини-серии о Django-миграциях. В первой части мы готовились к миграциям и разбирались с конфликтами, во второй чинили типичные подводные камни. Если их не читали, то рекомендую начать именно с них, а затем вернуться сюда.
В этом же материале поговорим о самом интересном: что происходит, когда python manage.py migrate запускается в 17:30 в пятницу на проде, под 3k RPS и таблицей в 200 миллионов строк.
Расскажу какие блокировки в PostgreSQL берёт каждая операция Django, что внутри atomic = False, как пишется правильный паттерн expand — migrate — contract, зачем нужны AddIndexConcurrently, AddConstraintNotValid, SeparateDatabaseAndState и как обновлять данные на больших таблицах.
P.S. примеры намеренно упрощены, чтобы влезли в статью и не задушили. В реальной жизни всё ещё хуже — но шаги те же.
P.S.S. При подготовки этого материала ни одна продовая база данных не пострадала.

Почему migrate в проде это не «просто одна команда»
У миграции есть три стула слоя, каждый из которых потенциально может привести к падению прода:
-
Сгенерированный SQL. Иногда не такой, который ты ожидал. Например,
AlterField(max_length=64)дляCharField(max_length=32)— этоALTER TABLE ... ALTER COLUMN TYPE varchar(64), и, да, на PostgreSQL это будет очень быстро.
А вот некоторые изменения типа действительно приводят к переписыванию всей таблицы (table rewrite), например, text -> integer, varchar -> numeric, json -> jsonb и другие небинарно-совместимые преобразования.
При этом varchar(n) -> text в PostgreSQL rewrite не требует — это binary-compatible изменение и обычно выполняется как metadata-only операция.
-
Блокировки. PostgreSQL может блокировать таблицу так, что не пройдет даже
SELECT. Очереди блокировок в PostgreSQL — это FIFO. То есть твоя миграция ждет долгую транзакцию пять минут, а за ней молча стоят ещё 200 запросов от пользователей. Никто не отвечает. Прод R.I.P. -
Python-код в
RunPython. Он запускается прямо в транзакции миграции (еслиatomic = True, а это значение по умолчанию) и держит её открытой всё время выполнения.Developer.objects.all().update(...)на 50 миллионов строк — R.I.P.
Из практики (все персонажи и числа выдуманы, я актер, это все постановка):
-
Кейс 1. Славик добавил поле
is_archived =models.BooleanField(default=False)
в таблицу с 80 000 000 строк на PostgreSQL 13. Миграция отработала за 14 минут. Всё это время таблица была недоступна на запись. Прод лежал, весь автобус плакал. -
Кейс 2. Владислав добавил
models.Index(fields=['created_at'])вMetaмоделиOrder.CREATE INDEXбезCONCURRENTLYвзялSHAREна таблице — все вставки заказов встали в очередь на десять минут. -
Кейс 3. Васян написал data-миграцию для бэкфилла на 20M строк через
Order.objects.filter(...).update(...). Миграция была атомарной по умолчанию. Один большойUPDATEсгенерил гигантский WAL, реплики залагали — R.I.P согласованность данных.
Все три случая лечатся одинаково — понимать, что именно делает каждая твоя миграция на уровне PostgreSQL.
Минимально необходимая теория блокировок в PostgreSQL
PostgreSQL имеет 8 уровней табличных блокировок. Запомнить все не обязательно — достаточно понять «лестницу»: чем выше уровень, тем больше других операций он блокирует. Уровни в иерархии (от слабого к сильному):
1. ACCESS SHARE — берет SELECT. Самая слабая блокировка: обычное чтение почти никому не мешает и конфликтует только с ACCESS EXCLUSIVE.
-
ROW SHARE —
SELECT FOR UPDATE/SHARE. Используется, когда запрос собирается блокировать строки; чуть строже обычного чтения. -
ROW EXCLUSIVE —
INSERT,UPDATE,DELETE. Это стандартная DML-нагрузка: изменения данных разрешены параллельно, пока нет «тяжёлого» DDL. То есть обычные DML-операции не мешают друг другу, но серьёзные изменения структуры таблицы могут остановить или заблокировать их (REINDEX,ALTER TABLE ... TYPE,ALTER TABLE ... ADD COLUMNи т.д.). -
SHARE UPDATE EXCLUSIVE —
VACUUM(без FULL),ANALYZE,CREATE INDEX CONCURRENTLY,ALTER TABLE VALIDATE CONSTRAINT,REINDEX CONCURRENTLY. Нужна для риалтайм-операций обслуживания: таблицу можно продолжать читать и менять. -
SHARE —
CREATE INDEX(без CONCURRENTLY). Разрешает чтение, но блокируетINSERT/UPDATE/DELETE, потому что индекс строится в одном консистентном состоянии. -
SHARE ROW EXCLUSIVE —
CREATE TRIGGER, некоторыеALTER TABLE. Более жёсткий DDL-режим: PostgreSQL защищает структуру таблицы от параллельных изменений. -
EXCLUSIVE —
REFRESH MATERIALIZED VIEW CONCURRENTLY. Почти полная блокировка: читать можно, но любые изменения данных запрещены. -
ACCESS EXCLUSIVE —
DROP TABLE,TRUNCATE, большинствоALTER TABLE,REINDEX. Самая сильная блокировка: останавливает вообще всё, включая обычныйSELECT.
Для миграций нас в основном волнует разница между CONCURRENTLY-вариантами (SHARE UPDATE EXCLUSIVE — совместимо с DML) и обычными DDL (SHARE / ACCESS EXCLUSIVE — НЕ совместимо с DML).
Великий и ужасный — ACCESS EXCLUSIVE
Эта блокировка берется:
-
ALTER TABLE ... ADD COLUMN(даже если мгновенно). -
ALTER TABLE ... DROP COLUMN. -
ALTER TABLE ... ALTER COLUMN TYPE(даже безREWRITE). -
ALTER TABLE ... ADD CONSTRAINT(безNOT VALID). -
CREATE INDEX(безCONCURRENTLY) — берёт SHARE, что не полный ACCESS EXCLUSIVE, но всё равно блокирует write. -
DROP INDEX(безCONCURRENTLY). -
ALTER TABLE ... RENAME. -
DROP TABLE,TRUNCATE,CLUSTER,VACUUM FULL.
ACCESS EXCLUSIVE мгновенен, если операция не требует физического переписывания таблицы или сканирования всех строк. Например, ADD COLUMN без default — это просто изменение метаданных в pg_attribute, миллисекунды. Но даже мгновенная ACCESS EXCLUSIVE может уронить прод из-за очереди блокировок.
Очередь блокировок и почему она опасна
PostgreSQL старается не допускать ситуации, когда более сильные блокировки ждут бесконечно долго. Поэтому если ALTER TABLE уже ждёт ACCESS EXCLUSIVE, новые запросы, которые формально совместимы с текущими lock’ами, могут начать вставать в очередь за ним.
На практике это выглядит так — одна ожидающая DDL-операция начинает тормозить весь поток запросов к таблице.
Сценарий:
T0: Аналитик запустил SELECT pg_dump таблицы users → берёт ACCESS SHARE на 5 минут.T1: Запускается миграция ALTER TABLE users ADD COLUMN foo -> пытается взять ACCESS EXCLUSIVE -> ждёт.T2: Пришёл API-запрос: SELECT * FROM users WHERE id = 42 -> пытается взять ACCESS SHARE -> совместим с тем, что у аналитика, НО несовместим с тем, что ЖДЁТ миграция -> встаёт в очередь.T3: Ещё 200 запросов -> все в очереди.T4: Аналитик закончил.T5: Миграция отработала за 5 мс.T6: Очередь рассасывается.
Между T1 и T5 прошло 5 минут полной недоступности сервиса. Миграция при этом фактически отработала за 5 мс.
Лечение — lock_timeout. Это настройка PostgreSQL, которая говорит «если не могу взять блокировку за N секунд — упади». Лучше упасть и попробовать снова через минуту, чем стоять и блокировать прод:
# 0042_safe_alter.pyfrom django.db import migrationsclass Migration(migrations.Migration): atomic = False dependencies = [...] operations = [ migrations.RunSQL( sql="SET lock_timeout = '3s'; SET statement_timeout = '5min';", reverse_sql=migrations.RunSQL.noop, ), # ... основные операции ]
lock_timeout действует на текущую сессию, поэтому строка обязательно должна быть внутри той же транзакции/сессии, что и опасный ALTER.
Конкретные значения timeout’ов сильно зависят от вашей текущей нагрузки.
Для высоконагруженных систем 3 с. может быть слишком агрессивным значением и приводить к постоянным рестартам.
Timeout’ы стоит подбирать исходя из:
-
средней длительности транзакций;
-
профиля нагрузки;
-
maintenance window;
-
replication lag;
-
количества параллельных записей.
statement_timeout — соседний предохранитель — «если сам SQL-стейтмент выполняется дольше N — упади».
В PostgreSQL 17 появился ещё один уровень — transaction_timeout. Он ограничивает время всей транзакции, не отдельного оператора.
migrations.RunSQL( sql=( "SET lock_timeout = '3s'; " "SET statement_timeout = '5min'; " "SET transaction_timeout = '10min';" # PG 17+ ), reverse_sql=migrations.RunSQL.noop,),
sqlmigrate — твой лучший друг перед migrate
Перед каждым накатом миграции на прод запускаем:
python manage.py sqlmigrate developers 0042
И читаем глазами. Команда показывает SQL, который Django сгенерирует, не применяя его. Это первая и обязательная проверка. По нему ты сразу видишь:
-
сколько операторов будет выполнено;
-
есть ли
ALTER TABLE ... ALTER COLUMN TYPE(потенциальноREWRITE); -
есть ли
CREATE INDEXбезCONCURRENTLY; -
есть ли
ADD CONSTRAINTбезNOT VALID; -
завернет ли Django все в
BEGIN ... COMMIT(если миграция атомарная).
Пример «опасного» вывода:
BEGIN;---- Alter field rating on developer--ALTER TABLE "developers_developer" ALTER COLUMN "rating" TYPE numeric(10, 2) USING "rating"::numeric(10, 2);COMMIT;
ALTER COLUMN ... TYPE ... USING ... — это REWRITE на всю таблицу под ACCESS EXCLUSIVE. На таблице в 50М строк это часы простоя.
Пример «безопасного»:
BEGIN;---- Add field nickname to developer--ALTER TABLE "developers_developer" ADD COLUMN "nickname" varchar(64) NULL;COMMIT;
ACCESS EXCLUSIVE, но мгновенный (только метаданные). Безопасно, если есть lock_timeout.
Каталог операций х безопасность
Шпаргалка, на которую можно +-ориентироваться.
|
Операция Django |
SQL |
Блокировка |
Время |
Безопасна? |
|---|---|---|---|---|
|
|
|
— |
мгновенно |
✅ |
|
|
|
|
мгновенно |
⚠️ ломает старый код |
|
|
|
|
мгновенно |
✅ |
|
|
|
|
почти быстро |
⚠️, но могут быть нюансы с большими таблицами |
|
|
|
|
долго |
❌ |
|
|
|
|
долго |
⚠️ |
|
|
|
|
мгновенно |
⚠️ ломает старый код |
|
|
|
|
мгновенно |
✅ |
|
|
|
|
очень долго |
❌ |
|
|
|
|
долго |
❌ |
|
|
|
|
мгновенно |
✅ |
|
|
|
|
от секунд до часов |
❌ → |
|
|
|
lock на index + связанные table locks |
может быть медленно |
⚠️ → |
|
|
|
|
долго |
❌ → |
|
|
|
|
долго |
❌ → |
|
|
|
|
мгновенно |
⚠️ старый код упадёт |
|
|
|
|
мгновенно |
⚠️ старый код упадёт |
|
|
|
|
очень долго |
❌ → батчи |
P.S. Не является строгой спецификаций, это больше шпаргалка для общего понимая. Что можно держать в голове.
Разберём ключевые ячейки подробнее.
AddField + DEFAULT на PostgreSQL 14+
# PG 14+ (и даже 11+), БЕЗОПАСНО:migrations.AddField( model_name='developer', name='is_archived', field=models.BooleanField(default=False),),
Эта миграция мгновенна на любой таблице. ACCESS EXCLUSIVE берётся, но удерживается миллисекунды.
Но! Это работает только для константных default. Если default — это callable, оптимизация PG не применяется и таблица переписывается:
# ОПАСНО на любом PG:migrations.AddField( model_name='developer', name='external_id', field=models.UUIDField(default=uuid.uuid4),),
Лечение — разделить на этапы:
-
AddField(null=True)— без default. -
RunPython(backfill_uuid)чанками сatomic=False. -
AlterField(null=False)— черезAddConstraintNotValid+ValidateConstraint(см. ниже).
AddField + FK на большой таблице
ADD CONSTRAINT FOREIGN KEY валидирует существующие строки и может долго сканировать таблицу.
Основная проблема здесь — не столько тип блокировки, сколько длительность validation scan на больших таблицах. Во время валидации PostgreSQL берёт несколько lock’ов на referencing/referenced tables, а сама операция может идти очень долго на десятках миллионов строк. На таблице в 100M строк это может занять часы.
Решение — NOT VALID + VALIDATE CONSTRAINT. Django не имеет встроенной операции для FK с NOT VALID, поэтому делаем руками через RunSQL + SeparateDatabaseAndState:
class Migration(migrations.Migration): atomic = False dependencies = [...] operations = [ # 1. Колонка nullable, мгновенно. migrations.AddField( model_name='order', name='customer', field=models.ForeignKey( 'customers.Customer', null=True, on_delete=models.PROTECT, db_constraint=False, ), ), # 2. Добавляем FK как NOT VALID - мгновенно (берёт ACCESS EXCLUSIVE, # но не сканирует таблицу). migrations.RunSQL( sql=( 'ALTER TABLE "orders_order" ' 'ADD CONSTRAINT "orders_order_customer_fk" ' 'FOREIGN KEY ("customer_id") ' 'REFERENCES "customers_customer" ("id") NOT VALID;' ), reverse_sql=( 'ALTER TABLE "orders_order" DROP CONSTRAINT "orders_order_customer_fk";' ), ), # 3. Валидируем существующие строки - SHARE UPDATE EXCLUSIVE, # совместимо с DML, может идти долго, но прод работает. migrations.RunSQL( sql='ALTER TABLE "orders_order" VALIDATE CONSTRAINT "orders_order_customer_fk";', reverse_sql=migrations.RunSQL.noop, ), ]
VALIDATE CONSTRAINT обычно совместим с обычным DML и значительно безопаснее прямого ADD CONSTRAINT.
Но на очень горячих таблицах validation всё равно может создавать заметную IO-нагрузку и влиять на latency.
db_constraint=False в ForeignKey. Это говорит Django — в БД constraint не создавай, я его сделаю руками.
AlterConstraint — подарок от Django 5.2
Это маленькая, но очень важная для прода фича. Раньше любое изменение метаданных constraint — например, добавление violation_error_message для красивого сообщения юзеру при нарушении уникальности — приводило к миграции вида «DROP CONSTRAINT + ADD CONSTRAINT». На большой таблице это DROP INDEX + CREATE INDEX = боль.
Начиная с Django 5.2:
# Было в модели:class Meta: constraints = [ models.UniqueConstraint(fields=['email'], name='user_email_uniq'), ]# Стало:class Meta: constraints = [ models.UniqueConstraint( fields=['email'], name='user_email_uniq', violation_error_message='Email уже занят', ), ]
В Django 5.1 и ранее makemigrations сгенерил бы RemoveConstraint + AddConstraint с реальным DROP/CREATE в БД. В Django 5.2 — AlterConstraint (no-op для БД, обновление только in-memory state):
# Django 5.2 makemigrations:operations = [ migrations.AlterConstraint( model_name='user', name='user_email_uniq', constraint=models.UniqueConstraint( fields=['email'], name='user_email_uniq', violation_error_message='Email уже занят', ), ),]
Никакого ALTER TABLE. Просто Django запоминает новые метаданные. Минус одна потенциально долгая миграция — это здорово.
Главный паттерн: Expand — Migrate — Contract
Принцип 1: Старый код должен работать с новой схемой.
Принцип 2: Новый код должен работать со старой схемой.
Принцип 3: Между ними — отдельные шаги по миграции данных.
Это значит, что почти любое «опасное» изменение схемы — это не одна миграция и не один деплой. Это последовательность из 3+ релизов:
-
Expand. Расширяем схему так, чтобы старый код продолжал работать (новые поля nullable, новые таблицы не используются, старые поля остаются).
-
Migrate. Переводим логику и данные на новую схему. Обычно — несколько подэтапов с код-релизами между ними.
-
Contract. Удаляем старое.
Между этими этапами — обязательно деплои с проверкой, что всё работает. Никогда не пытайся уместить переименование поля в один PR.
Сквозной пример: переименование Developer.title в Developer.name
Это та же модель, что в первых статьях. Допустим, мы решили, что title — плохое имя для имени разработчика, нужно переименовать в name. Сделать это в лоб через RenameField значит:
-
На уровне PG:
ALTER TABLE RENAME COLUMN— мгновенно, ACCESS EXCLUSIVE. -
На уровне приложения: между моментом, когда миграция применилась, и моментом, когда задеплоился новый код, старые инстансы приложения ходят в БД с запросом
SELECT title FROM developers_developerи получают ошибкуcolumn "title" does not exist.
В rolling deploy это особенно весело: пока новые поды поднимаются, старые продолжают отдавать 500-ки.
Правильный путь через 3 релиза:
Релиз 1 (Expand): добавляем name, оставляем title.
# developers/models.pyclass Developer(models.Model): title = models.CharField(max_length=64) # старое поле name = models.CharField(max_length=64, null=True) # новое поле @property def display_name(self): return self.name or self.title
Миграция 1 — schema:
class Migration(migrations.Migration): dependencies = [('developers', '0041_previous')] operations = [ migrations.AddField( model_name='developer', name='name', field=models.CharField(max_length=64, null=True), ), ]
Миграция 2 — data (отдельной миграцией, не в одном файле!):
def copy_title_to_name(apps, schema_editor): Developer = apps.get_model('developers', 'Developer') db_alias = schema_editor.connection.alias from django.db.models import F BATCH_SIZE = 5000 last_pk = 0 while True: # Забираем очередной блок первичных ключей, строго pk > last_pk chunk = list( Developer.objects.using(db_alias) .filter(name__isnull=True, pk__gt=last_pk) .order_by('pk') .values_list('pk', flat=True)[:BATCH_SIZE] ) if not chunk: break # Обновляем ровно этот блок ( Developer.objects.using(db_alias) .filter(pk__in=chunk) .update(name=F('title')) ) # Двигаем курсор last_pk = chunk[-1]class Migration(migrations.Migration): atomic = False # обязательно — батчи коммитятся независимо dependencies = [('developers', '0042_add_name_field')] operations = [ migrations.RunPython( copy_title_to_name, reverse_code=migrations.RunPython.noop, elidable=True, # при squash удалится — это разовая операция ), ]
Не собирай PK всей таблицы в Python-список (list(qs.values_list(...))) на десятках миллионов строк легко приводит к огромному потреблению памяти.
Для больших таблиц безопаснее keyset pagination (pk > last_pk) или cursor-based batching.
order_by('pk') + pk__gt=last_pk позволяет стабильно и предсказуемо проходить таблицу небольшими чанками без материализации всего набора строк в памяти Python-процесса.
Для реально огромной таблицы даже пример выше необходимо будет оптимизировать
В коде приложения продолжаем читать title, но при создании/обновлении пишем в оба поля:
def update_developer(developer: Developer, new_title: str) -> None: developer.title = new_title developer.name = new_title developer.save(update_fields=['title', 'name'])
Это позволит старым инстансам читать title, а новым — name. Бэкфилл закроет существующие строки.
Релиз 2 (Migrate): переключаем чтение на name.
После того как релиз 1 деплоится и бэкфилл проходит — в новой версии кода:
class Developer(models.Model): title = models.CharField(max_length=64) name = models.CharField(max_length=64, null=True) @property def display_name(self): return self.name # больше не fallback на title
Пишем в оба поля (на случай отката), читаем только из name.
Релиз 3 (Contract): удаляем title.
В коде убираем title совсем. Делаем name обязательным.
class Developer(models.Model): name = models.CharField(max_length=64) # теперь NOT NULL
Миграция:
operations = [ # На этот момент 100% строк имеют name, проверяем CHECK NOT VALID + VALIDATE. AddConstraintNotValid(...), ValidateConstraint(...), migrations.AlterField( model_name='developer', name='name', field=models.CharField(max_length=64), # null=False ), migrations.RemoveField( model_name='developer', name='title', ),]
Да, три релиза вместо одного. Зато 0 минут даунтайма.
Антипаттерн: давайте просто RenameField
Иногда соблазнительно:
operations = [ migrations.RenameField( model_name='developer', old_name='title', new_name='name', ),]
makemigrations даже спросит: «It looked like you renamed title to name. Is that correct?» — yes.
Для rolling deploy и distributed environments такой подход опасен без обратной совместимости. В controlled deployment сценариях (blue-green deploy) RenameField может быть вполне допустим.
PostgreSQL-специфичные операции Django
В django.contrib.postgres.operations лежит небольшой, но критически важный набор операций, который автогенерация Django никогда не предложит сама. Их нужно вставлять руками.
AddIndexConcurrently и RemoveIndexConcurrently
CREATE INDEX без CONCURRENTLY берёт SHARE на таблице — это значит, что INSERT/UPDATE/DELETE встают в очередь. На таблице, в которую активно пишут, такой AddIndex = гарантированный инцидент.
CONCURRENTLY обходит блокировку, читая таблицу в несколько проходов. Цена: индекс создаётся в 2-3 раза дольше и не может выполняться внутри транзакции.
from django.contrib.postgres.operations import ( AddIndexConcurrently, RemoveIndexConcurrently,)from django.db import migrations, modelsclass Migration(migrations.Migration): atomic = False # обязательно — CONCURRENTLY вне транзакции dependencies = [('developers', '0044_some_migration')] operations = [ AddIndexConcurrently( model_name='developer', index=models.Index( fields=['rating'], name='developer_rating_idx', ), ), ]
Подвох: если CREATE INDEX CONCURRENTLY упадёт посередине (например, по lock_timeout или из-за дубликата в unique-индексе), индекс останется в БД со статусом INVALID. Он виден в \d table и pg_indexes, но не используется планировщиком. Django об этом ничего не знает.
Лечение — перед повторным накатом проверить и удалить:
-- Найти INVALID-индексы:SELECT indexrelid::regclass AS index_name, indrelid::regclass AS table_nameFROM pg_indexWHERE indisvalid = false;-- Удалить:DROP INDEX CONCURRENTLY IF EXISTS developer_rating_idx;
И после этого перезапустить миграцию.
AddConstraintNotValid + ValidateConstraint
Появились в Django 4.0 для PostgreSQL. Только для CheckConstraint (для FK — руками через RunSQL, как мы делали выше).
Обычный ADD CONSTRAINT ... CHECK блокирует таблицу под ACCESS EXCLUSIVE и сканирует все строки. На 50M строк это надолго.
NOT VALID говорит: не сканируй существующие, проверяй только новые INSERT/UPDATE. Берёт ACCESS EXCLUSIVE, но мгновенно. Потом отдельной операцией валидируем существующие — это идёт под SHARE UPDATE EXCLUSIVE (совместимо с DML).
from django.contrib.postgres.operations import AddConstraintNotValid, ValidateConstraintfrom django.db import migrations, models# Файл 0045_add_rating_constraint_not_valid.pyclass Migration(migrations.Migration): dependencies = [('developers', '0044_previous')] operations = [ AddConstraintNotValid( model_name='developer', constraint=models.CheckConstraint( condition=models.Q(rating__gte=0), # ВАЖНО name='developer_rating_non_negative', ), ), ]# ОТДЕЛЬНЫЙ файл 0046_validate_rating_constraint.pyclass Migration(migrations.Migration): atomic = False # VALIDATE может быть долгим dependencies = [('developers', '0045_add_rating_constraint_not_valid')] operations = [ ValidateConstraint( model_name='developer', name='developer_rating_non_negative', ), ]
Важно про CheckConstraint: в Django 5.1+ параметр стал называться condition вместо check (старое имя deprecated, в 6.0 ещё работает с warning, но в будущем удалят).
Важно про две миграции: если положить в одну, то транзакция вокруг них (atomic = True по умолчанию) сведёт всю оптимизацию на нет.
UniqueConstraint CONCURRENTLY: лайфхак через SeparateDatabaseAndState
Django не имеет AddConstraintConcurrently. Если нужно навесить UNIQUE на большую таблицу, обычный AddConstraint(UniqueConstraint(...)) создаст индекс под SHARE — а это write-блокировка.
Обход: создать UNIQUE INDEX CONCURRENTLY руками, а Django сказать считай, что constraint у меня есть через SeparateDatabaseAndState:
from django.db import migrations, modelsclass Migration(migrations.Migration): atomic = False dependencies = [('developers', '0046_previous')] operations = [ migrations.SeparateDatabaseAndState( database_operations=[ migrations.RunSQL( sql=( 'CREATE UNIQUE INDEX CONCURRENTLY "developer_inn_uniq" ' 'ON "developers_developer" ("inn");' ), reverse_sql=( 'DROP INDEX CONCURRENTLY IF EXISTS "developer_inn_uniq";' ), ), ], state_operations=[ migrations.AddConstraint( model_name='developer', constraint=models.UniqueConstraint( fields=['inn'], name='developer_inn_uniq', ), ), ], ), ]
database_operations идут в БД (создаём индекс CONCURRENTLY), state_operations обновляют in-memory представление Django (autodetector думает, что constraint существует и не предлагает создать его снова).
PostgreSQL умеет использовать unique-индекс как backing для constraint поэтому такой подход корректен и с точки зрения семантики.
SeparateDatabaseAndState
SDAS — главный инструмент тонкой работы, когда автогенерация Django делает правильно по семантике, но не подходит по перформансу. Ещё несколько кейсов, где он нужен.
Кейс 1: переход с index_together на Meta.indexes
index_together deprecated в Django 4.2 и удалён в 5.1. Просто перенести в indexes нельзя — Django сгенерирует миграцию, которая пересоздаст индекс: DROP + CREATE. На большой таблице будет плохо.
Хитрость: оставить индекс в БД, но сказать Django, что мы «переименовали» его в state:
# Найти текущее имя индекса в БД:# \d+ developers_developer в psql# Или: SELECT indexname FROM pg_indexes WHERE tablename='developers_developer';# Допустим, было developers_dev_a_b_idx.# В Meta модели:class Meta: indexes = [ models.Index(fields=['a', 'b'], name='developers_dev_a_b_idx'), ]# Миграция:operations = [ migrations.SeparateDatabaseAndState( database_operations=[], # в БД ничего не делаем state_operations=[ migrations.AlterIndexTogether( name='developer', index_together=set(), ), migrations.AddIndex( model_name='developer', index=models.Index( fields=['a', 'b'], name='developers_dev_a_b_idx', ), ), ], ),]
Главное — указать то же имя, что у физического индекса в БД. Тогда Django считает, что всё в порядке, а реально индекс не трогался.
Кейс 2: переименование колонки через db_column
Альтернатива expand/contract для маленьких таблиц или внутренних рефакторингов: переименовать только в коде Python, оставив физическое имя колонки.
class Developer(models.Model): # В коде — name, в БД — title name = models.CharField(max_length=64, db_column='title')
Миграция:
operations = [ migrations.SeparateDatabaseAndState( database_operations=[], state_operations=[ migrations.RenameField( model_name='developer', old_name='title', new_name='name', ), migrations.AlterField( model_name='developer', name='name', field=models.CharField(max_length=64, db_column='title'), ), ], ),]
Кейс 3: превращение M2M в явную through-модель
Когда нужно к ManyToManyField добавить дополнительные поля (например, created_at, created_by), приходится переходить на through. Django по умолчанию предложит удалить промежуточную таблицу и создать новую — данные потеряются.
Через SeparateDatabaseAndState мы оставляем таблицу в БД, но говорим Django — вот теперь это твоя through-модель:
# В models.py — определяем through:class Project(models.Model): developers = models.ManyToManyField( 'developers.Developer', through='ProjectDeveloper', )class ProjectDeveloper(models.Model): project = models.ForeignKey('Project', on_delete=models.CASCADE) developer = models.ForeignKey( 'developers.Developer', on_delete=models.CASCADE, ) class Meta: db_table = 'projects_project_developers' # имя авто-таблицы M2M
SeparateDatabaseAndState — очень мощный, но опасный инструмент.
Он позволяет «разводить»:
-
реальное состояние БД;
-
migration state Django.
При неаккуратном использовании это приводит к state drift, странным auto-generated migrations и трудноотлавливаемым проблемам в графе миграций.
Чем больше SDAS в проекте тем важнее дисциплина вокруг ревью миграций.
atomic = False
По умолчанию каждая миграция Django оборачивается в транзакцию (BEGIN ... COMMIT). Это безопасно для большинства операций: упала миграция — БД откатилась к исходному состоянию.
Но иногда атомарность не нужна и даже вредит:
-
Операции с
CONCURRENTLYвообще запрещены внутри транзакции. -
Долгий бэкфилл данных в одной транзакции = ROW EXCLUSIVE на куче строк надолго + раздутый WAL.
-
AddConstraintNotValid+VALIDATEхотим выполнить независимо, чтобы VALIDATE мог идти на проде без блокировок.
В таких случаях ставим:
class Migration(migrations.Migration): atomic = False # ...
Что происходит при сбое в non-atomic миграции
Тонкий момент. В обычной (atomic) миграции при ошибке Django делает ROLLBACK — БД возвращается в исходное состояние, и запись в django_migrations не добавляется. Можно безопасно перезапустить.
В non-atomic при ошибке:
-
SQL, выполненные до места ошибки, остаются применёнными в БД (Django выполняет операции отдельными transaction scopes, поэтому часть операций может успеть примениться до места ошибки)
-
Запись в
django_migrationsне добавляется — Django не знает, что миграция применена частично. -
При повторном запуске Django начнёт миграцию с самого начала и упадёт на первой же операции с
column already exists/index already exists.
Как лечить:
Вариант 1: писать идемпотентные SQL руками. Это в первую очередь касается RunSQL:
migrations.RunSQL( sql='ALTER TABLE foo ADD COLUMN IF NOT EXISTS bar integer;', reverse_sql='ALTER TABLE foo DROP COLUMN IF EXISTS bar;',),
К сожалению, Django-операции (AddField, AddIndex) не умеют генерить IF NOT EXISTS — для них этот трюк не работает.
Вариант 2: разбивать миграцию на максимально мелкие шаги. Если упадёт — упадёт на конкретном шаге, и руками легче понять, что сделалось, а что нет.
Вариант 3: ручная очистка перед перезапуском. Зашёл в psql, посмотрел \d table, удалил лишнее, повторил миграцию.
Вариант 4: --fake после ручного применения. Если ты руками докатил все, что нужно, скажи Django «считай, что миграция применена»:
python manage.py migrate developers 0045 --fake
(--fake мы разбирали во второй статье)
Batch updates: data-миграции на больших таблицах
Один UPDATE на 50M строк это:
-
ROW EXCLUSIVE на куче строк надолго;
-
гигабайт WAL — реплики залагают;
-
если в
atomic = True= одна гигантская транзакция, увеличение размера shared buffers, autovacuum не может работать; -
невозможно прервать без отката.
PostgreSQL 17 содержит ряд заметных улучшений вокруг vacuum/WAL и обработке конкурентной нагрузки, но конкретный выигрыш сильно зависит от этой самой нагрузки и конфигурации системы. То есть это не значит, что можно теперь не думать про батчи — это значит, что последствия твоих ошибок проще пережить. Но писать всё равно надо нормально.
Лечение — батчи с atomic = False:
from django.db import migrationsdef backfill_status(apps, schema_editor): Developer = apps.get_model('developers', 'Developer') db_alias = schema_editor.connection.alias BATCH_SIZE = 10_000 qs = ( Developer.objects.using(db_alias) .filter(status__isnull=True) ) last_pk = 0 while True: batch_qs = ( qs.filter(pk__gt=last_pk) .order_by('pk') ) # берём только границу диапазона batch = list( batch_qs.values_list('pk', flat=True)[:BATCH_SIZE] ) if not batch: break start = batch[0] end = batch[-1] Developer.objects.using(db_alias).filter( pk__gte=start, pk__lte=end, status__isnull=True ).update(status='active') last_pk = endclass Migration(migrations.Migration): atomic = False dependencies = [('developers', '0050_add_status_field')] operations = [ migrations.RunPython( backfill_status, reverse_code=migrations.RunPython.noop, elidable=True, ), ]
Несколько критичных моментов в этом коде:
-
apps.get_model(...), а не прямой импорт модели. Мы это обсуждали во второй статье. Импортированная модель — это «текущая» версия изmodels.py, в которой могут быть поля, ещё не существующие в БД.apps.get_modelвозвращает «историческую» модель — ровно такую, какой она была на момент этой миграции. -
using(db_alias). На случай multi-dbschema_editor.connection.aliasотдаст нужное имя БД. Если забыть —update()пойдёт в default-базу. -
reverse_code=migrations.RunPython.noop. Хорошо бы написать обратную функцию, но в случае бэкфилла это часто бессмысленно (исходного состояния «без статуса» больше не существует логически).noopговорит Django: «можешь откатить, никаких действий не нужно». -
elidable=True. Бэкфилл — разовая операция. ПриsquashmigrationsDjango удалит её из объединённой миграции. -
atomic = Falseна уровне Migration. Без этого каждый.update()всё равно был бы внутри общей транзакции миграции — то есть мы бы не получили никакого выигрыша.
Когда лучше команда, а не миграция
Для очень больших бэкфиллов (>10M строк, часы выполнения) data-миграция — плохой выбор:
-
Миграция блокирует выкатку. Если бэкфилл идёт 6 часов, релизы стоят.
-
Миграцию нельзя поставить на паузу, перезапустить, мониторить отдельно.
-
Если разработчик пропустит её локально (
migrateидёт слишком долго)
Альтернатива — BaseCommand:
# developers/management/commands/backfill_developer_status.pyfrom django.core.management.base import BaseCommandfrom django.db import transactionfrom developers.models import Developerclass Command(BaseCommand): help = 'Backfill Developer.status' def add_arguments(self, parser): parser.add_argument('--batch-size', type=int, default=5000) parser.add_argument('--sleep', type=float, default=0.0) def handle(self, *args, batch_size, sleep, **options): import time qs = Developer.objects.filter(status__isnull=True) total = qs.count() self.stdout.write(f'To backfill: {total}') done = 0 while True: pks = list( qs.order_by('pk').values_list('pk', flat=True)[:batch_size] ) if not pks: break with transaction.atomic(): Developer.objects.filter(pk__in=pks).update(status='active') done += len(pks) self.stdout.write(f'Done: {done}/{total}') if sleep: time.sleep(sleep)
Запускаем:
python manage.py backfill_developer_status --batch-size 2000 --sleep 0.5
Плюсы команды:
-
запускаешь руками, когда нагрузка ниже;
-
можешь прервать
Ctrl+Cи продолжить позже (она идемпотентна — берёт только строки сstatus IS NULL); -
параметры (batch_size, sleep) на лету;
-
легко мониторить — отдельный процесс, отдельный лог.
Минус: нужно не забыть запустить руками.
NOT NULL без даунтайма: классический сценарий
Во второй статье мы разбирали, как пройти ошибку «You are trying to add a non-nullable field» при makemigrations. Сейчас тот же сценарий в production-разрезе.
Задача: у нас есть поле Developer.status, которое сейчас nullable, нужно сделать обязательным.
Наивный путь:
operations = [ migrations.AlterField( model_name='developer', name='status', field=models.CharField(max_length=16), # null=False ),]
Что Django сгенерит:
ALTER TABLE "developers_developer" ALTER COLUMN "status" SET NOT NULL;
Эта операция берёт ACCESS EXCLUSIVE и сканирует всю таблицу для проверки, что нет NULL. На 50M строк = минуты простоя.
Безопасный путь — 4 шага:
Шаг 1: миграция — добавить CHECK (status IS NOT NULL) NOT VALID.
from django.contrib.postgres.operations import AddConstraintNotValidfrom django.db import migrations, modelsclass Migration(migrations.Migration): dependencies = [('developers', '0050_previous')] operations = [ AddConstraintNotValid( model_name='developer', constraint=models.CheckConstraint( condition=models.Q(status__isnull=False), name='developer_status_not_null', ), ), ]
После этой миграции новые строки уже не могут вставить NULL в status. Существующие — пока могут быть NULL, мы их не трогаем.
Шаг 2: код приложения пишет в status для всех новых записей.
Деплоим релиз. Теперь поток новых записей чист.
Шаг 3: data-миграция — бэкфиллим существующие строки.
def backfill_status(apps, schema_editor): # Как реализовано ранееclass Migration(migrations.Migration): atomic = False dependencies = [('developers', '0051_check_status_not_null')] operations = [ migrations.RunPython( backfill_status, reverse_code=migrations.RunPython.noop, elidable=True, ), ]
Шаг 4: миграция — VALIDATE CONSTRAINT + AlterField.
from django.contrib.postgres.operations import ValidateConstraintfrom django.db import migrations, modelsclass Migration(migrations.Migration): atomic = False # VALIDATE может быть долгим dependencies = [('developers', '0052_backfill_status')] operations = [ # 1. Валидируем CHECK — SHARE UPDATE EXCLUSIVE, совместимо с DML. ValidateConstraint( model_name='developer', name='developer_status_not_null', ), # 2. Меняем колонку на NOT NULL. # На PG 12+ при наличии валидного CHECK NOT NULL операция мгновенна: # PG использует существующий CHECK как доказательство. migrations.AlterField( model_name='developer', name='status', field=models.CharField(max_length=16), ), # 3. CHECK больше не нужен (его роль теперь у NOT NULL constraint). migrations.RemoveConstraint( model_name='developer', name='developer_status_not_null', ), ]
Django по умолчанию не сгенерит такую последовательность сам. makemigrations для смены null=True → null=Falseсоздаст обычный AlterField. Шаги 1, 3, 4 нужно писать руками; шаг 2 — обычная code-only data-миграция.
Тестирование миграций
Data-миграции — это код. Код должен иметь тесты. Без тестов миграция, отработавшая на 12 строках на dev, может рухнуть на 100M строк на проде.
Пакет django-test-migrations от wemake-services даёт удобный API:
from django_test_migrations.migrator import Migratordef test_backfill_status_fills_existing_rows(transactional_db): migrator = Migrator(database='default') # 1. Применяем миграции до состояния "ДО" нашей data-миграции. old_state = migrator.apply_initial_migration( ('developers', '0050_add_status_field'), ) Developer = old_state.apps.get_model('developers', 'Developer') # 2. Создаём данные — как они выглядели до бэкфилла. Developer.objects.create(title='Alice', status=None) Developer.objects.create(title='Bob', status=None) Developer.objects.create(title='Charlie', status='admin') # 3. Применяем нашу data-миграцию. new_state = migrator.apply_tested_migration( ('developers', '0052_backfill_status'), ) Developer = new_state.apps.get_model('developers', 'Developer') # 4. Проверяем результат. assert Developer.objects.filter(status='unknown').count() == 2 assert Developer.objects.filter(status='admin').count() == 1 assert Developer.objects.filter(status__isnull=True).count() == 0 migrator.reset()
Что полезного:
-
Тест действительно прогоняет миграцию против БД, а не моки.
-
Может тестировать reverse: применил A — создал данные — откатился до B — проверил, что данные адекватны.
-
Интегрируется с pytest-django (фикстура
transactional_db).
CI-минимум для миграций (чек-лист)
В каждом PR:
-
pythonmanage.pymakemigrations --check --dry-run— есть ли несгенерированные миграции в коде? Если разработчик поменял модели, но не запустилmakemigrations, миграции в проде не будет. -
pythonmanage.pymigrate --plan— что именно поедет. -
Тесты на data-миграции (
django-test-migrations). -
(Опционально, для крупных проектов) —
pythonmanage.pysqlmigrate <app> <migration>в артефакт CI: ревьюверам удобно сразу увидеть SQL без поднятия локального окружения.
Чек-лист перед накатом миграции на прод
Бумажка над монитором. Перед каждой production-миграцией:
-
Запустил
sqlmigrate, прочитал SQL глазами. Не смог понять глазами — прогнал нейронкой. -
Понимаю, какую блокировку возьмёт PostgreSQL для каждой операции.
-
Если ACCESS EXCLUSIVE на большой таблице — миграция разделена на безопасные шаги (NOT VALID + VALIDATE, CONCURRENTLY, expand/contract).
-
Долгие операции в отдельной миграции с
atomic = False. -
Если есть сомнения — подумать над
lock_timeoutиstatement_timeout -
Изменения обратно-совместимы: старый код приложения корректно работает с новой схемой.
-
Есть план отката: если миграция сломала прод, что мы делаем? (Откатить миграцию? Откатить код? И то и другое?)
-
Есть бэкап актуальной БД, или это slave с актуальным lag.
-
На staging миграция прогналась против дампа prod-данных или их объёма.
-
Время накатывания — не пятница вечер. Не «вот сейчас релиз, и сразу миграция в пиковый трафик».
Если хоть один пункт не закрыт — лучше переложить накат или помолиться.
Экстра
Несколько подводных камней, которые не вписались в основное повествование, но регулярно стреляют:
-
FK с CASCADE.
ON DELETE CASCADEсам по себе не блокирует. НоDELETEродительской строки берёт ROW EXCLUSIVE на дочерних — на больших таблицах это медленно. -
Изменение
choicesв Django обычно приводит кAlterFieldмиграции, даже если схема БД фактически не меняется. На PostgreSQL лишниеALTER TABLEмогут брать сильные блокировки, поэтому для часто меняющихся choices лучше использовать callable choices (Django 5.0+) либо state-only миграции черезSeparateDatabaseAndState. -
max_lengthдля varchar. Расширение мгновенно. Сужение — REWRITE. -
AlterFieldдляdefault. Изменениеdefault=...в Python-коде модели не приводит к изменению default в БД — Django применяет default при INSERT на уровне Python. Ноmakemigrationsвсё равно сгенеритAlterField. Это no-op для БД, но лишнийALTER TABLE(мгновенный, но ACCESS EXCLUSIVE). -
Кейс с
migrate --fakeпосле non-atomic краха. Если ты руками докатил часть SQL, помни:--fakeфиксирует запись вdjango_migrations, но не проверяет, действительно ли применены все операции. Ответственность за консистентность — на тебе. Лучше написать checklist в комментарии PR: «применил руками: ALTER TABLE X, CREATE INDEX Y; запускаю migrate —fake». -
Reverse data-миграций. По умолчанию ставим
reverse_code=migrations.RunPython.noop— на reverse ничего не происходит. Это нормально для бэкфиллов: смысла откатывать данные нет. Для обратимых преобразований (например, миграции enum) — пишим явный reverse. Не оставляйRunPythonбез второго аргумента: Django выдаст ошибку при попытке откатить.
Заключение
Главные правила, которые стоит унести с собой:
-
Перед
migrate—sqlmigrate. Всегда. На любой миграции в любой ветке. Это бесплатно и спасает от 90% инцидентов. -
Малые шаги, отдельные миграции. В идеале одна миграция — одна логическая операция. NOT NULL — это четыре миграции, а не одна. Да, миграций станет много, но их можно будет сквошнуть.
-
Expand — Migrate — Contract. Любое значимое изменение схемы — это минимум три релиза. Старый и новый код должны уметь работать с одной и той же БД.
-
Автогенерация Django — стартовая точка, не финальная. На больших таблицах её надо править:
SeparateDatabaseAndState,AddIndexConcurrently,AddConstraintNotValid,atomic = False. -
Инструменты-минимум:
sqlmigrate+ тесты data-миграций. -
Не забываем про существование
lock_timeout+statement_timeout
Миграции, которые катятся под нагрузкой — это инженерная задача, а не одна команда. Чем раньше команда осознает это, тем меньше инцидентов будет.
Буду рад фидбеку и кейсам из вашей практики в комментариях. А также замечаниям и исправлениям неточностей, который, вероятно, я мог допустить 🙂
Полезные ссылки
Документация PostgreSQL:
Пакеты:
Статьи на тему:
Предыдущие части серии:
ссылка на оригинал статьи https://habr.com/ru/articles/1035830/