Всем привет! Меня зовут Макс, я Lead Backend и автор YouTube‑канала PyLounge

Это третья часть мини‑серии о Django‑миграциях. В первой части мы готовились к миграциям и разбирались с конфликтами, во второй чинили типичные подводные камни. Если их не читали, то рекомендую начать именно с них, а затем вернуться сюда.

В этом же материале поговорим о самом интересном: что происходит, когда python manage.py migrate запускается в 17:30 в пятницу на проде, под 3k RPS и таблицей в 200 миллионов строк. 

Расскажу какие блокировки в PostgreSQL берёт каждая операция Django, что внутри atomic = False, как пишется правильный паттерн expand — migrate — contract, зачем нужны AddIndexConcurrentlyAddConstraintNotValidSeparateDatabaseAndState и как обновлять данные на больших таблицах.

P. S. примеры намеренно упрощены, чтобы влезли в статью и не задушили. В реальной жизни всё ещё хуже — но шаги те же.

P. S.S. При подготовке этого материала ни одна продовая база данных не пострадала. 

Почему migrate в проде это не «просто одна команда»

У миграции есть три стула слоя, каждый из которых потенциально может привести к падению прода:

  1. Сгенерированный SQL. Иногда не такой, который ты ожидал. Например, AlterField(max_length=64)для CharField(max_length=32) — это ALTER TABLE ... ALTER COLUMN TYPE varchar(64), и, да, на PostgreSQL это будет очень быстро.

    А вот некоторые изменения типа действительно приводят к переписыванию всей таблицы (table rewrite), например, text -> integervarchar -> numericjson -> jsonb и другие небинарно‑совместимые преобразования.

    При этом varchar(n) -> text в PostgreSQL rewrite не требует — это binary‑compatible изменение и обычно выполняется как metadata‑only операция.

  2. Блокировки. PostgreSQL может блокировать таблицу так, что не пройдет даже SELECT. Очереди блокировок в PostgreSQL — это FIFO. То есть твоя миграция ждет долгую транзакцию пять минут, а за ней молча стоят ещё 200 запросов от пользователей. Никто не отвечает. Прод R.I.P.

  3. 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.

  2. ROW SHARE — SELECT FOR UPDATE/SHARE. Используется, когда запрос собирается блокировать строки; чуть строже обычного чтения.

  3. ROW EXCLUSIVE — INSERTUPDATEDELETE. Это стандартная DML‑нагрузка: изменения данных разрешены параллельно, пока нет «тяжёлого» DDL. То есть обычные DML‑операции не мешают друг другу, но серьёзные изменения структуры таблицы могут остановить или заблокировать их (REINDEXALTER TABLE ... TYPEALTER TABLE ... ADD COLUMN и так далее).

  4. SHARE UPDATE EXCLUSIVE — VACUUM (без FULL), ANALYZECREATE INDEX CONCURRENTLYALTER TABLE VALIDATE CONSTRAINTREINDEX CONCURRENTLY. Нужна для риалтайм‑операций обслуживания: таблицу можно продолжать читать и менять.

  5. SHARE — CREATE INDEX (без CONCURRENTLY). Разрешает чтение, но блокирует INSERT/UPDATE/DELETE, потому что индекс строится в одном консистентном состоянии.

  6. SHARE ROW EXCLUSIVE — CREATE TRIGGER, некоторые ALTER TABLE. Более жёсткий DDL‑режим: PostgreSQL защищает структуру таблицы от параллельных изменений.

  7. EXCLUSIVE — REFRESH MATERIALIZED VIEW CONCURRENTLY. Почти полная блокировка: читать можно, но любые изменения данных запрещены.

  8. ACCESS EXCLUSIVE — DROP TABLETRUNCATE, большинство ALTER TABLEREINDEX. Самая сильная блокировка: останавливает вообще всё, включая обычный 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 TABLETRUNCATECLUSTERVACUUM 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.py
from django.db import migrations


class 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

Блокировка

Время

Безопасна?

CreateModel

CREATE TABLE

мгновенно

DeleteModel

DROP TABLE

ACCESS EXCLUSIVE

мгновенно

⚠️ ломает старый код

AddField (nullable, без default)

ADD COLUMN NULL

ACCESS EXCLUSIVE

мгновенно

AddField (NOT NULL + constant default)

ADD COLUMN NOT NULL DEFAULT 'x'

ACCESS EXCLUSIVE

почти быстро

⚠️, но могут быть нюансы с большими таблицами

AddField (NOT NULL + volatile default: uuid4, now())

ADD COLUMN + UPDATE строк

ACCESS EXCLUSIVE

долго

AddField (FK)

ADD COLUMN + ADD CONSTRAINT FK

ACCESS EXCLUSIVE + полный скан

долго

⚠️

RemoveField

DROP COLUMN

ACCESS EXCLUSIVE

мгновенно

⚠️ ломает старый код

AlterField: расширение max_length для varchar

ALTER COLUMN TYPE

ACCESS EXCLUSIVE, без REWRITE

мгновенно

AlterField: сужение / смена типа

ALTER COLUMN TYPE с REWRITE

ACCESS EXCLUSIVE

очень долго

AlterField: смена null=True → null=False

ALTER COLUMN SET NOT NULL

ACCESS EXCLUSIVE + полный скан (PG 12+ обходится при CHECK)

долго

AlterField: смена default=

ALTER COLUMN SET DEFAULT

ACCESS EXCLUSIVE

мгновенно

AddIndex

CREATE INDEX

SHARE (блокирует write)

от секунд до часов

❌ → AddIndexConcurrently

RemoveIndex

DROP INDEX

lock на index + связанные table locks

может быть медленно

⚠️ → RemoveIndexConcurrently

AddConstraint (CheckConstraint)

ADD CONSTRAINT CHECK

ACCESS EXCLUSIVE + полный скан

долго

❌ → NOT VALID + VALIDATE

AddConstraint (UniqueConstraint)

ADD CONSTRAINT UNIQUE

SHARE на время создания индекса

долго

❌ → SeparateDatabaseAndState

RenameField

ALTER TABLE RENAME COLUMN

ACCESS EXCLUSIVE

мгновенно

⚠️ старый код упадёт

RenameModel

ALTER TABLE RENAME

ACCESS EXCLUSIVE

мгновенно

⚠️ старый код упадёт

RunPython (UPDATE без батчей)

UPDATE ...

ROW EXCLUSIVE на куче строк

очень долго

❌ → батчи

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),
),

Лечение — разделить на этапы:

  1. AddField(null=True) — без default.

  2. RunPython(backfill_uuid) чанками с atomic=False.

  3. 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+ релизов:

  1. Expand. Расширяем схему так, чтобы старый код продолжал работать (новые поля nullable, новые таблицы не используются, старые поля остаются).

  2. Migrate. Переводим логику и данные на новую схему. Обычно — несколько подэтапов с код‑релизами между ними.

  3. 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.py
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 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 deployRenameField может быть вполне допустим.

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, models


class 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_name
FROM pg_index
WHERE 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, ValidateConstraint
from django.db import migrations, models


# Файл 0045_add_rating_constraint_not_valid.py
class 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.py
class 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, models


class 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_atcreated_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 при ошибке:

  1. SQL, выполненные до места ошибки, остаются применёнными в БД (Django выполняет операции отдельными transaction scopes, поэтому часть операций может успеть примениться до места ошибки)

  2. Запись в django_migrations не добавляется — Django не знает, что миграция применена частично.

  3. При повторном запуске 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‑операции (AddFieldAddIndex) не умеют генерить 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 migrations

def 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 = end

class Migration(migrations.Migration):
    atomic = False

    dependencies = [('developers', '0050_add_status_field')]

    operations = [
        migrations.RunPython(
            backfill_status,
            reverse_code=migrations.RunPython.noop,
            elidable=True,
        ),
    ]

Несколько критичных моментов в этом коде:

  1. apps.get_model(...), а не прямой импорт модели. Мы это обсуждали во второй статье. Импортированная модель — это «текущая» версия из models.py, в которой могут быть поля, ещё не существующие в БД. apps.get_model возвращает «историческую» модель — ровно такую, какой она была на момент этой миграции.

  2. using(db_alias). На случай multi‑db schema_editor.connection.alias отдаст нужное имя БД. Если забыть — update()пойдёт в default‑базу.

  3. reverse_code=migrations.RunPython.noop. Хорошо бы написать обратную функцию, но в случае бэкфилла это часто бессмысленно (исходного состояния «без статуса» больше не существует логически). noop говорит Django: «можешь откатить, никаких действий не нужно».

  4. elidable=True. Бэкфилл — разовая операция. При squashmigrations Django удалит её из объединённой миграции.

  5. atomic = False на уровне Migration. Без этого каждый .update() всё равно был бы внутри общей транзакции миграции — то есть мы бы не получили никакого выигрыша.

Когда лучше команда, а не миграция

Для очень больших бэкфиллов (>10M строк, часы выполнения) data‑миграция — плохой выбор:

  • Миграция блокирует выкатку. Если бэкфилл идёт 6 часов, релизы стоят.

  • Миграцию нельзя поставить на паузу, перезапустить, мониторить отдельно.

  • Если разработчик пропустит её локально (migrate идёт слишком долго)

Альтернатива — BaseCommand:

# developers/management/commands/backfill_developer_status.py
from django.core.management.base import BaseCommand
from django.db import transaction
from developers.models import Developer


class 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 AddConstraintNotValid
from django.db import migrations, models


class 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 ValidateConstraint
from django.db import migrations, models


class 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 Migrator


def 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:

  1. python manage.py makemigrations --check --dry-run — есть ли несгенерированные миграции в коде? Если разработчик поменял модели, но не запустил makemigrations, миграции в проде не будет.

  2. python manage.py migrate --plan — что именно поедет.

  3. Тесты на data‑миграции (django-test-migrations).

  4. (Опционально, для крупных проектов) — python manage.py sqlmigrate <app> <migration> в артефакт CI: ревьюверам удобно сразу увидеть SQL без поднятия локального окружения.

Чек‑лист перед накатом миграции на прод

Бумажка над монитором. Перед каждой production‑миграцией:

  1. Запустил sqlmigrate, прочитал SQL глазами. Не смог понять глазами — прогнал нейронкой.

  2. Понимаю, какую блокировку возьмёт PostgreSQL для каждой операции.

  3. Если ACCESS EXCLUSIVE на большой таблице — миграция разделена на безопасные шаги (NOT VALID + VALIDATE, CONCURRENTLY, expand/contract).

  4. Долгие операции в отдельной миграции с atomic = False.

  5. Если есть сомнения — подумать над lock_timeout и statement_timeout

  6. Изменения обратно‑совместимы: старый код приложения корректно работает с новой схемой.

  7. Есть план отката: если миграция сломала прод, что мы делаем? (Откатить миграцию? Откатить код? И то и другое?)

  8. Есть бэкап актуальной БД, или это slave с актуальным lag.

  9. На staging миграция прогналась против дампа prod‑данных или их объёма.

  10. Время накатывания — не пятница вечер. Не «вот сейчас релиз, и сразу миграция в пиковый трафик».

Если хоть один пункт не закрыт — лучше переложить накат или помолиться.

Экстра

Несколько подводных камней, которые не вписались в основное повествование, но регулярно стреляют:

  • FK с CASCADEON 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 — стартовая точка, не финальная. На больших таблицах её надо править: SeparateDatabaseAndStateAddIndexConcurrentlyAddConstraintNotValidatomic = False.

  • Инструменты‑минимумsqlmigrate + тесты data‑миграций.

  • Не забываем про существование lock_timeout + statement_timeout

Миграции, которые катятся под нагрузкой — это инженерная задача, а не одна команда. Чем раньше команда осознает это, тем меньше инцидентов будет.

Буду рад фидбеку и кейсам из вашей практики в комментариях. А также замечаниям и исправлениям неточностей, который, вероятно, я мог допустить:)

Полезные ссылки

Документация PostgreSQL:

Пакеты:

Статьи на тему:

Предыдущие части серии: