---
name: safe-migrations
description: "Безопасные Django-миграции для PostgreSQL в production: expand-and-contract, zero-downtime, lock management, PgBouncer, data migrations, squashing. Загружать при создании/изменении миграций, добавлении/удалении/переименовании полей и таблиц, работе с большими таблицами (500K+ строк), написании data migrations (RunPython/RunSQL), планировании деплоя с миграциями, ревью миграций, или при ошибках lock timeout / deadlock / advisory lock."
user-invocable: false
---

# Safe Migrations — Django + PostgreSQL

Справочник паттернов безопасных миграций. Читай перед созданием или ревью миграций.

Контекст проекта: PostgreSQL 17, Django 5.2+, таблицы до 500K строк, rolling deployments,
PgBouncer в transaction pooling mode.

## Принцип: Expand-and-Contract

Любое изменение схемы разбивается на два деплоя, чтобы старый и новый код
работали одновременно без ошибок.

| Фаза | Что происходит | Порядок деплоя |
|------|---------------|----------------|
| **Expand** | Добавляем новое (столбец, таблицу, индекс) | Миграция ПЕРЕД кодом |
| **Contract** | Удаляем старое (столбец, таблицу) | Код ПЕРЕД миграцией |

Между фазами — отдельный PR/деплой. Никогда не совмещать expand и contract в одном деплое.

## PostgreSQL: Уровни блокировок

Понимание блокировок — ключ к zero-downtime миграциям.

| Уровень блокировки | Блокирует | Операции |
|-------------------|-----------|----------|
| **ACCESS EXCLUSIVE** | Всё (включая SELECT) | DROP TABLE, TRUNCATE, большинство ALTER TABLE, REINDEX, VACUUM FULL |
| **SHARE** | INSERT/UPDATE/DELETE | CREATE INDEX (без CONCURRENTLY) |
| **SHARE ROW EXCLUSIVE** | INSERT/UPDATE/DELETE | CREATE TRIGGER, некоторые ALTER TABLE |
| **SHARE UPDATE EXCLUSIVE** | Только DDL друг с другом | CREATE INDEX CONCURRENTLY, VACUUM, ANALYZE |
| **ROW EXCLUSIVE** | Другие UPDATE/DELETE тех же строк | UPDATE, DELETE, INSERT |
| **ACCESS SHARE** | Ничего | SELECT |

**Правило:** если операция берёт ACCESS EXCLUSIVE — она блокирует ВСЕ запросы к таблице,
включая SELECT. На таблице с 500K строк это может длиться секунды-минуты.

## Паттерн 1: Добавление NOT NULL столбца

**Небезопасно** — берёт ACCESS EXCLUSIVE + перезаписывает таблицу:
```python
# ЗАПРЕЩЕНО на больших таблицах
migrations.AddField(
    model_name="company",
    name="rating",
    field=models.IntegerField(default=0),  # NOT NULL + default = full table rewrite
)
```

**Безопасный 3-фазный паттерн:**

### Фаза 1 — Expand (деплой 1): добавить nullable столбец

```python
# migrations/0042_company_add_rating.py
from django.db import migrations, models

class Migration(migrations.Migration):
    dependencies = [("companies", "0041_previous")]

    operations = [
        migrations.AddField(
            model_name="company",
            name="rating",
            field=models.IntegerField(null=True),  # nullable — мгновенная операция
        ),
    ]
```

Код в этом деплое пишет в оба поля (если есть старое) и в новое.

### Фаза 2 — Backfill (деплой 2): заполнить данные

```python
# migrations/0043_company_backfill_rating.py
from django.db import migrations

def backfill_rating(apps, schema_editor):
    Company = apps.get_model("companies", "Company")
    # Чанкованное обновление — не блокирует таблицу надолго
    batch_size = 5000
    while True:
        ids = list(
            Company.objects.filter(rating__isnull=True)
            .values_list("pk", flat=True)[:batch_size]
        )
        if not ids:
            break
        Company.objects.filter(pk__in=ids).update(rating=0)

class Migration(migrations.Migration):
    dependencies = [("companies", "0042_company_add_rating")]

    operations = [
        migrations.RunPython(backfill_rating, migrations.RunPython.noop),
    ]
```

### Фаза 3 — Contract (деплой 3): добавить NOT NULL через CHECK CONSTRAINT

```python
# migrations/0044_company_rating_not_null.py
from django.db import migrations

class Migration(migrations.Migration):
    dependencies = [("companies", "0043_company_backfill_rating")]

    operations = [
        # Шаг 1: CHECK NOT VALID — мгновенно, не сканирует существующие строки
        migrations.RunSQL(
            sql="ALTER TABLE companies_company ADD CONSTRAINT company_rating_not_null "
                "CHECK (rating IS NOT NULL) NOT VALID;",
            reverse_sql="ALTER TABLE companies_company DROP CONSTRAINT company_rating_not_null;",
        ),
        # Шаг 2: VALIDATE — сканирует таблицу, но берёт только SHARE UPDATE EXCLUSIVE
        migrations.RunSQL(
            sql="ALTER TABLE companies_company VALIDATE CONSTRAINT company_rating_not_null;",
            reverse_sql=migrations.RunSQL.noop,
        ),
        # Шаг 3: SET NOT NULL — PostgreSQL 12+ использует существующий CHECK, мгновенно
        migrations.RunSQL(
            sql="ALTER TABLE companies_company ALTER COLUMN rating SET NOT NULL;",
            reverse_sql="ALTER TABLE companies_company ALTER COLUMN rating DROP NOT NULL;",
        ),
        # Шаг 4: Убрать временный CHECK (NOT NULL уже гарантирует)
        migrations.RunSQL(
            sql="ALTER TABLE companies_company DROP CONSTRAINT company_rating_not_null;",
            reverse_sql=migrations.RunSQL.noop,
        ),
        # Шаг 5: Обновить состояние Django
        migrations.AlterField(
            model_name="company",
            name="rating",
            field=models.IntegerField(default=0),
        ),
    ]
```

**Альтернатива (Django 5.0+):** `db_default` — устанавливает DEFAULT на уровне БД,
PostgreSQL добавляет NOT NULL столбец без перезаписи таблицы:

```python
migrations.AddField(
    model_name="company",
    name="rating",
    field=models.IntegerField(db_default=0),  # DEFAULT на уровне БД — мгновенно
)
```

## Паттерн 2: Удаление столбца

Нельзя просто удалить столбец — старый код будет падать с `OperationalError`.

### Фаза 1 — Expand (деплой 1): убрать из кода + состояния Django

```python
# migrations/0050_remove_company_old_field_from_state.py
from django.db import migrations, models

class Migration(migrations.Migration):
    dependencies = [("companies", "0049_previous")]

    operations = [
        # Сначала сделать nullable (если ещё не было)
        migrations.AlterField(
            model_name="company",
            name="old_field",
            field=models.CharField(max_length=255, null=True, blank=True),
        ),
        # Убрать из состояния Django, но оставить в БД
        migrations.SeparateDatabaseAndState(
            state_operations=[
                migrations.RemoveField(model_name="company", name="old_field"),
            ],
            database_operations=[],  # ничего не делаем в БД
        ),
    ]
```

В коде убрать все ссылки на `old_field`. Django больше не знает о столбце,
но он физически есть в БД — старый код продолжает работать.

### Фаза 2 — Contract (деплой 2): удалить из БД

```python
# migrations/0051_remove_company_old_field_from_db.py
from django.db import migrations

class Migration(migrations.Migration):
    dependencies = [("companies", "0050_remove_company_old_field_from_state")]

    operations = [
        migrations.SeparateDatabaseAndState(
            state_operations=[],  # Django уже забыл о поле
            database_operations=[
                migrations.RunSQL(
                    sql="ALTER TABLE companies_company DROP COLUMN IF EXISTS old_field;",
                    reverse_sql=migrations.RunSQL.noop,
                ),
            ],
        ),
    ]
```

## Паттерн 3: Переименование столбца

`RenameField` берёт ACCESS EXCLUSIVE lock. Безопасная альтернатива:

**Вариант A (предпочтительный): db_column — без изменения БД:**

```python
# Было:
class Company(models.Model):
    old_name = models.CharField(max_length=255)

# Стало — Python-имя поменялось, столбец в БД остался:
class Company(models.Model):
    new_name = models.CharField(max_length=255, db_column="old_name")
```

```python
# migrations/0060_rename_company_field.py
class Migration(migrations.Migration):
    operations = [
        migrations.SeparateDatabaseAndState(
            state_operations=[
                migrations.RenameField(
                    model_name="company",
                    old_name="old_name",
                    new_name="new_name",
                ),
            ],
            database_operations=[],  # столбец в БД не трогаем
        ),
        migrations.AlterField(
            model_name="company",
            name="new_name",
            field=models.CharField(max_length=255, db_column="old_name"),
        ),
    ]
```

**Вариант B: полное переименование через expand-contract (3 деплоя):**

Деплой 1 — добавить новый столбец + dual-write:
```python
# models.py
class Company(models.Model):
    old_name = models.CharField(max_length=255)
    new_name = models.CharField(max_length=255, null=True)

    def save(self, *args, **kwargs):
        if self.old_name and not self.new_name:
            self.new_name = self.old_name
        super().save(*args, **kwargs)
```

Деплой 2 — backfill + переключить код на `new_name`:
```python
def backfill_new_name(apps, schema_editor):
    Company = apps.get_model("companies", "Company")
    batch_size = 5000
    while True:
        ids = list(
            Company.objects.filter(new_name__isnull=True)
            .values_list("pk", flat=True)[:batch_size]
        )
        if not ids:
            break
        from django.db.models import F
        Company.objects.filter(pk__in=ids).update(new_name=F("old_name"))
```

Деплой 3 — удалить `old_name` (паттерн 2).

## Паттерн 4: Создание индексов

`CREATE INDEX` берёт SHARE lock — блокирует все записи на время создания.
На таблице 500K строк это минуты.

```python
# migrations/0070_company_add_index.py
from django.contrib.postgres.operations import AddIndexConcurrently
from django.db import migrations, models

class Migration(migrations.Migration):
    atomic = False  # ОБЯЗАТЕЛЬНО — CONCURRENTLY не работает в транзакции

    dependencies = [("companies", "0069_previous")]

    operations = [
        AddIndexConcurrently(
            model_name="company",
            index=models.Index(
                fields=["inn", "status"],
                name="idx_company_inn_status",
            ),
        ),
    ]
```

**Ключевые правила:**
- `atomic = False` обязателен
- Если индекс упал (invalid state) — удалить и пересоздать:
  ```sql
  DROP INDEX CONCURRENTLY IF EXISTS idx_company_inn_status;
  CREATE INDEX CONCURRENTLY idx_company_inn_status ON companies_company (inn, status);
  ```
- Проверка invalid индексов:
  ```sql
  SELECT indexrelid::regclass, indisvalid FROM pg_index WHERE NOT indisvalid;
  ```

**Удаление индексов тоже через CONCURRENTLY:**
```python
from django.contrib.postgres.operations import RemoveIndexConcurrently

class Migration(migrations.Migration):
    atomic = False

    operations = [
        RemoveIndexConcurrently(model_name="company", name="idx_company_inn_status"),
    ]
```

## Паттерн 5: Добавление UNIQUE / PRIMARY KEY

Прямой `ADD CONSTRAINT UNIQUE` берёт ACCESS EXCLUSIVE + сканирует таблицу.

```python
# migrations/0075_company_unique_inn.py
class Migration(migrations.Migration):
    atomic = False

    operations = [
        # Шаг 1: создать unique index CONCURRENTLY
        migrations.RunSQL(
            sql="CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS idx_company_inn_unique "
                "ON companies_company (inn);",
            reverse_sql="DROP INDEX CONCURRENTLY IF EXISTS idx_company_inn_unique;",
        ),
        # Шаг 2: использовать готовый индекс для constraint (мгновенно)
        migrations.RunSQL(
            sql="ALTER TABLE companies_company ADD CONSTRAINT company_inn_unique "
                "UNIQUE USING INDEX idx_company_inn_unique;",
            reverse_sql="ALTER TABLE companies_company DROP CONSTRAINT company_inn_unique;",
        ),
    ]
```

## Паттерн 6: CHECK Constraints (NOT VALID + VALIDATE)

```python
# migrations/0080_add_check_constraint.py
from django.contrib.postgres.operations import AddConstraintNotValid, ValidateConstraint
from django.db import migrations, models

class Migration(migrations.Migration):
    operations = [
        # Шаг 1: добавить без валидации существующих строк (мгновенно)
        AddConstraintNotValid(
            model_name="company",
            constraint=models.CheckConstraint(
                condition=models.Q(rating__gte=0, rating__lte=10),
                name="company_rating_range",
            ),
        ),
    ]

# ОТДЕЛЬНАЯ миграция (чтобы commit первой прошёл)
# migrations/0081_validate_check_constraint.py
class Migration(migrations.Migration):
    operations = [
        # Шаг 2: валидация — сканирует таблицу, но SHARE UPDATE EXCLUSIVE
        ValidateConstraint(model_name="company", name="company_rating_range"),
    ]
```

`AddConstraintNotValid` и `ValidateConstraint` ОБЯЗАТЕЛЬНО в разных миграциях.
Если ValidateConstraint упадёт — БД останется в консистентном состоянии.

## Data Migrations: Идемпотентность и чанки

### Идемпотентный RunPython

```python
def populate_slug(apps, schema_editor):
    """Идемпотентно: обрабатывает только записи без slug."""
    Company = apps.get_model("companies", "Company")
    from django.utils.text import slugify

    batch_size = 5000
    while True:
        # Только записи, которые ещё не обработаны
        ids = list(
            Company.objects.filter(slug__isnull=True)
            .values_list("pk", flat=True)[:batch_size]
        )
        if not ids:
            break
        for company in Company.objects.filter(pk__in=ids):
            company.slug = slugify(company.name)
            company.save(update_fields=["slug"])

class Migration(migrations.Migration):
    operations = [
        migrations.RunPython(populate_slug, migrations.RunPython.noop),
    ]
```

**Правила data migrations:**
- `apps.get_model()` — никогда не импортировать модель напрямую
- `schema_editor.connection.alias` — для multi-db
- Чанки 2000-5000 строк — не блокировать таблицу надолго
- `filter(field__isnull=True)` — идемпотентность через проверку состояния
- `migrations.RunPython.noop` для reverse — если обратная операция невозможна
- Не полагаться на кастомные методы модели (`.save()` без custom logic)

### RunSQL с IF NOT EXISTS / IF EXISTS

```python
migrations.RunSQL(
    sql=[
        "CREATE TABLE IF NOT EXISTS companies_archive ("
        "    id SERIAL PRIMARY KEY,"
        "    company_id INTEGER REFERENCES companies_company(id),"
        "    archived_at TIMESTAMPTZ DEFAULT NOW()"
        ");",
    ],
    reverse_sql="DROP TABLE IF EXISTS companies_archive;",
),

migrations.RunSQL(
    sql="ALTER TABLE companies_company ADD COLUMN IF NOT EXISTS legacy_id VARCHAR(50);",
    reverse_sql="ALTER TABLE companies_company DROP COLUMN IF EXISTS legacy_id;",
),
```

### Тяжёлый backfill через Celery (для таблиц >100K строк)

Если backfill занимает >30 секунд — выносить в Celery task, а не RunPython:

```python
# migrations/0090_trigger_backfill.py
from django.db import migrations

def trigger_backfill(apps, schema_editor):
    """Запускает backfill через Celery. Идемпотентно."""
    from django.apps import apps as real_apps
    # Проверяем что есть что заполнять
    Company = apps.get_model("companies", "Company")
    if Company.objects.filter(new_field__isnull=True).exists():
        from companies.tasks import backfill_new_field_task
        backfill_new_field_task.delay()

class Migration(migrations.Migration):
    operations = [
        migrations.RunPython(trigger_backfill, migrations.RunPython.noop),
    ]
```

## Lock Management

### lock_timeout — защита от длинных блокировок

```python
# migrations/0095_safe_alter.py
class Migration(migrations.Migration):
    operations = [
        # Установить lock_timeout перед опасной операцией
        migrations.RunSQL("SET lock_timeout = '4s';", migrations.RunSQL.noop),

        # Если не получит lock за 4 секунды — миграция упадёт
        # (лучше упасть и retry, чем заблокировать все запросы)
        migrations.AlterField(
            model_name="company",
            name="status",
            field=models.CharField(max_length=50, default="active"),
        ),

        # Сбросить timeout
        migrations.RunSQL("SET lock_timeout = '0';", migrations.RunSQL.noop),
    ]
```

**Рекомендации:**
- `lock_timeout = '4s'` для ALTER TABLE операций
- Для `CREATE INDEX CONCURRENTLY` — не ставить short lock_timeout (индекс долгий)
- Если миграция упала по lock_timeout — retry через 30 секунд (автоматизировать в CI)

### Мониторинг блокировок

```sql
-- Найти заблокированные запросы
SELECT
    blocked_locks.pid AS blocked_pid,
    blocked_activity.usename AS blocked_user,
    blocking_locks.pid AS blocking_pid,
    blocking_activity.usename AS blocking_user,
    blocked_activity.query AS blocked_statement,
    blocking_activity.query AS blocking_statement
FROM pg_catalog.pg_locks blocked_locks
JOIN pg_catalog.pg_stat_activity blocked_activity ON blocked_activity.pid = blocked_locks.pid
JOIN pg_catalog.pg_locks blocking_locks
    ON blocking_locks.locktype = blocked_locks.locktype
    AND blocking_locks.relation = blocked_locks.relation
    AND blocking_locks.pid != blocked_locks.pid
JOIN pg_catalog.pg_stat_activity blocking_activity ON blocking_activity.pid = blocking_locks.pid
WHERE NOT blocked_locks.granted;
```

## PgBouncer: ограничения

В transaction pooling mode следующее НЕ работает через PgBouncer:

| Операция | Причина | Решение |
|----------|---------|---------|
| `CREATE INDEX CONCURRENTLY` | Нельзя внутри транзакции | Прямое подключение к PostgreSQL |
| `DROP INDEX CONCURRENTLY` | Нельзя внутри транзакции | Прямое подключение к PostgreSQL |
| Advisory locks (`pg_advisory_lock`) | Session-level, pooler переключает сессии | Прямое подключение |
| Server-side cursors (`.iterator()`) | Курсор привязан к сессии | `DISABLE_SERVER_SIDE_CURSORS=True` |
| `SET search_path` | Сбрасывается между транзакциями | Указывать schema явно |

**Решение: отдельное подключение для миграций:**

```python
# settings.py
DATABASES = {
    "default": {
        "HOST": os.getenv("PGBOUNCER_HOST", "pgbouncer"),
        "PORT": os.getenv("PGBOUNCER_PORT", "6432"),
        # ...
    },
    "direct": {
        "HOST": os.getenv("POSTGRES_HOST", "db"),
        "PORT": os.getenv("POSTGRES_PORT", "5432"),
        # ...
    },
}
```

```bash
# Миграции — через прямое подключение
docker compose exec web python manage.py migrate --database=direct
```

## Squashing миграций

### Когда сквошить

- >20 миграций в приложении
- Много отменяющих друг друга операций (AddField + RemoveField)
- Замедление `makemigrations` / `migrate`

### Команда

```bash
# Сквошить миграции 0001-0020 в приложении companies
docker compose exec web python manage.py squashmigrations companies 0020

# С пользовательским именем
docker compose exec web python manage.py squashmigrations companies 0020 --squashed-name initial_schema
```

### Поведение replaces

Сквошенная миграция содержит `replaces` — список заменённых миграций:

```python
class Migration(migrations.Migration):
    replaces = [
        ("companies", "0001_initial"),
        ("companies", "0002_add_field"),
        # ...
        ("companies", "0020_some_change"),
    ]
```

- **Новые инсталляции** — применяют сквошенную миграцию, пропускают оригиналы
- **Существующие** — продолжают использовать оригиналы до полного применения
- **Удалять оригиналы** можно ТОЛЬКО когда ВСЕ среды (dev, staging, prod) прошли сквошенную

### RunPython в сквошенных миграциях

`RunPython` и `RunSQL` блокируют оптимизатор — он не может "свернуть" операции через них.

```python
# Помечай data migrations как elidable если они безопасно пропускаются:
migrations.RunPython(
    populate_initial_data,
    migrations.RunPython.noop,
    elidable=True,  # оптимизатор может удалить при squash
)
```

`elidable=True` — для одноразовых data migrations (начальные данные, backfill),
которые не нужны на чистой БД.

### Переход от сквошенной к обычной миграции

1. Убедиться что все среды прошли squash
2. Удалить оригинальные файлы миграций
3. Обновить зависимости в других миграциях
4. Убрать `replaces` из сквошенной миграции
5. `python manage.py migrate --prune` — очистить ссылки в БД

## Deployment Order: Чеклист

### Expand-фаза (миграция ПЕРЕД кодом)

```
1. [ ] Миграция добавляет nullable столбец / новую таблицу / индекс
2. [ ] Старый код продолжает работать без изменений
3. [ ] PR содержит ТОЛЬКО миграцию
4. [ ] Деплой: migrate → restart app
```

### Contract-фаза (код ПЕРЕД миграцией)

```
1. [ ] Код убирает все ссылки на старый столбец / таблицу
2. [ ] PR содержит ТОЛЬКО код (без миграции)
3. [ ] Деплой: restart app → убедиться что всё работает → migrate
4. [ ] Миграция удаляет столбец / таблицу — отдельный PR
```

### Rollback-стратегия

| Ситуация | Действие |
|----------|----------|
| Expand-миграция упала | Повторить (если идемпотентная) или откатить `migrate <app> <prev>` |
| Код деплоя упал после expand | Откатить код — expand-миграция безвредна для старого кода |
| Contract-миграция упала | Повторить — код уже не ссылается на удаляемое |
| Нужен полный rollback | Откатить код, затем `migrate <app> <prev>` для expand |

## Чеклист безопасности миграций

Перед каждой миграцией проверяй:

```
[ ] sqlmigrate — проверить реальный SQL
[ ] Нет ACCESS EXCLUSIVE на таблицах >10K строк без lock_timeout
[ ] AddIndex → AddIndexConcurrently + atomic=False
[ ] NOT NULL → 3-фазный паттерн (nullable → backfill → CHECK + SET NOT NULL)
[ ] RemoveField → SeparateDatabaseAndState (2 деплоя)
[ ] RenameField → db_column или expand-contract
[ ] RunPython идемпотентен
[ ] RunPython использует apps.get_model(), не import
[ ] RunPython имеет reverse_code или RunPython.noop
[ ] Backfill чанкован (batch_size 2000-5000)
[ ] Data migration на >100K строк → Celery task
[ ] Деплой-порядок: expand перед кодом, contract после кода
[ ] Миграция через прямое подключение (не PgBouncer) если есть CONCURRENTLY
```

## Типичные ошибки

| Ошибка | Последствие | Исправление |
|--------|-------------|-------------|
| `AddField(default=X)` на большой таблице | Table rewrite + ACCESS EXCLUSIVE на минуты | `null=True` → backfill → SET NOT NULL |
| `CreateIndex` без CONCURRENTLY | SHARE lock блокирует записи | `AddIndexConcurrently` + `atomic=False` |
| `RemoveField` без SeparateDatabaseAndState | Старый код падает при rolling deploy | 2-фазное удаление |
| `RenameField` напрямую | ACCESS EXCLUSIVE + старый код падает | `db_column` |
| RunPython без `apps.get_model()` | Ломается при squash / изменении модели | Всегда `apps.get_model()` |
| Миграция через PgBouncer с CONCURRENTLY | `CREATE INDEX CONCURRENTLY cannot run inside a transaction` | Прямое подключение к PostgreSQL |
| Backfill без чанков | Long-running transaction + bloat | `batch_size=5000` + цикл |
| Нет lock_timeout | ALTER TABLE ждёт часы за длинной транзакцией | `SET lock_timeout = '4s'` |

## Проверка SQL перед деплоем

```bash
# Посмотреть SQL который сгенерирует миграция
docker compose exec web python manage.py sqlmigrate <app> <migration_number>

# Проверить что нет новых незапущенных миграций
docker compose exec web python manage.py makemigrations --check --dry-run
```
