Всем привет! Меня зовут Макс, я 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 операция.

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

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

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

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

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

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

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

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

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

Пакеты:

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

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

Комментарии (4)


  1. kondratevdev
    16.05.2026 07:50

    Материал огонь! Спасибо за цикл статей по миграциям!


  1. Granulex
    16.05.2026 07:50

    Хорошая систематизация – особенно Expand-Migrate-Contract: без этого шаблона большинство "безопасных" миграций превращаются в лотерею. Кстати, к блоку про CONCURRENTLY стоит добавить один сценарий: если CREATE INDEX CONCURRENTLY прервётся на полпути (дроп соединения, OOM), PostgreSQL оставит индекс с indisvalid = false. Планировщик его игнорирует, а DML прилежно обновляет. Django об этом не предупреждает – нужно самому делать SELECT из pg_indexes по indisvalid и дропать руками перед повтором.