Привет! Меня зовут Макс, я backend-разработчик в компании ИдаПроджект и автор YouTube-канала PyLounge.

Эта вторая часть большой статьи по Django-миграциям для начинающих. Если вы пропустили или потеряли первую часть — держите ссылку.

Что здесь будет? Разберем фиктивное применение, data-миграции, «сухую проверку» и основные проблемы, которые возникают у начинающих. Также поделюсь полезными советами и подсвечу тонкости работы. Примеры из практики — обязательно будут.

Дисклеймер (как и в первой части, чтобы не было недопониманий): все примеры специально упрощены, чтобы неокрепший ум выцепил концепции, а не детали реализации. Не бейте, или бейте там, где синяков не видно :)

Фиктивное применение миграций

Предположим, на production-окружении у нас в качестве типа данных для поля Статус используется CharField со значениями из перечисления. Мы решили отказаться от этого и на develop-окружении временно удалили поле Статус, чтобы заменить тип данных и как-то переработать (рефакторинг в самом разгаре).

На проде у нас так:

from django.db import models


class Developer(models.Model):
    ACTIVE = "active"
    INACTIVE = "inactive"
    PENDING = "pending"
    STATUS_CHOICES = (
        (ACTIVE, "Активный"),
        (INACTIVE, "Неактивный"),
        (PENDING, "На рассмотрении"),
    )

    title = models.CharField("Артикул", max_length=32)
    inn = models.CharField(verbose_name="ИНН", max_length=12, blank=True)
    rating = models.FloatField(verbose_name="Рейтинг", default=0.0)

    status = models.CharField("Статус", max_length=10, choices=STATUS_CHOICES, default="active")

    class Meta:
        indexes = [models.Index(fields=["title"])]
        verbose_name = "Застройщик"
        verbose_name_plural = "Застройщики"

    def __str__(self):
        return self.title

На develop-стенде вот так:

from django.db import models


class Developer(models.Model):
    title = models.CharField("Артикул", max_length=32)
    inn = models.CharField(verbose_name="ИНН", max_length=12, blank=True)
    rating = models.FloatField(verbose_name="Рейтинг", default=0.0)

    class Meta:
        indexes = [models.Index(fields=["title"])]
        verbose_name = "Застройщик"
        verbose_name_plural = "Застройщики"

    def __str__(self):
        return self.title

Кто-то из разработчиков сделал мерж свежей ветки master в ветку develop. После чего в dev-окружение попала следующая миграция 0007_alter_developer_developerstatus:

from django.db import migrations, models


class Migration(migrations.Migration):

    dependencies = [
        ('developers', '0006_alter_developer_rating'),
    ]

    operations = [
        migrations.AlterField(
            model_name='developer',
            name='status',
            field=models.CharField("Статус застройщика", max_length=10, choices=[
                ('active', 'Активный'),
                ('inactive', 'Неактивный'),
                ('pending', 'На рассмотрении'),
            ], default='active'),
        ),
    ]

На dev-стенде поле Статус уже давно отсутствует, однако, раз на проде оно есть, то при слиянии веток одна из миграций прода попала на dev и пытается поменять название поля Статус. Но его по-прежнему нет!

При попытке сделать python manage.py migrate получим ошибку:

$ python manage.py migrate

Operations to perform:
  Apply all migrations: admin, auth, contenttypes, developers, realty, sessions
Running migrations:
  Applying developers.0007_alter_developer_developerstatus...Traceback (most recent call last):
  File "/home/pylounge/.cache/pypoetry/virtualenvs/idashop-XU5c4BC3-py3.10/lib/python3.10/site-packages/django/db/models/options.py", line 681, in get_field
    return self.fields_map[field_name]
KeyError: 'status'

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File "/home/pylounge/Desktop/migration_habr/idashop/src/manage.py", line 22, in <module>
    main()
  File "/home/pylounge/Desktop/migration_habr/idashop/src/manage.py", line 18, in main
    execute_from_command_line(sys.argv)
  File "/home/pylounge/.cache/pypoetry/virtualenvs/idashop-XU5c4BC3-py3.10/lib/python3.10/site-packages/django/core/management/__init__.py", line 442, in execute_from_command_line
    utility.execute()
  File "/home/pylounge/.cache/pypoetry/virtualenvs/idashop-XU5c4BC3-py3.10/lib/python3.10/site-packages/django/core/management/__init__.py", line 436, in execute
    self.fetch_command(subcommand).run_from_argv(self.argv)
  File "/home/pylounge/.cache/pypoetry/virtualenvs/idashop-XU5c4BC3-py3.10/lib/python3.10/site-packages/django/core/management/base.py", line 413, in run_from_argv
    self.execute(*args, **cmd_options)
  File "/home/pylounge/.cache/pypoetry/virtualenvs/idashop-XU5c4BC3-py3.10/lib/python3.10/site-packages/django/core/management/base.py", line 459, in execute
    output = self.handle(*args, **options)
  File "/home/pylounge/.cache/pypoetry/virtualenvs/idashop-XU5c4BC3-py3.10/lib/python3.10/site-packages/django/core/management/base.py", line 107, in wrapper
    res = handle_func(*args, **kwargs)
  File "/home/pylounge/.cache/pypoetry/virtualenvs/idashop-XU5c4BC3-py3.10/lib/python3.10/site-packages/django/core/management/commands/migrate.py", line 356, in handle
    post_migrate_state = executor.migrate(
  File "/home/pylounge/.cache/pypoetry/virtualenvs/idashop-XU5c4BC3-py3.10/lib/python3.10/site-packages/django/db/migrations/executor.py", line 135, in migrate
    state = self._migrate_all_forwards(
  File "/home/pylounge/.cache/pypoetry/virtualenvs/idashop-XU5c4BC3-py3.10/lib/python3.10/site-packages/django/db/migrations/executor.py", line 167, in _migrate_all_forwards
    state = self.apply_migration(
  File "/home/pylounge/.cache/pypoetry/virtualenvs/idashop-XU5c4BC3-py3.10/lib/python3.10/site-packages/django/db/migrations/executor.py", line 252, in apply_migration
    state = migration.apply(state, schema_editor)
  File "/home/pylounge/.cache/pypoetry/virtualenvs/idashop-XU5c4BC3-py3.10/lib/python3.10/site-packages/django/db/migrations/migration.py", line 132, in apply
    operation.database_forwards(
  File "/home/pylounge/.cache/pypoetry/virtualenvs/idashop-XU5c4BC3-py3.10/lib/python3.10/site-packages/django/db/migrations/operations/fields.py", line 231, in database_forwards
    from_field = from_model._meta.get_field(self.name)
  File "/home/pylounge/.cache/pypoetry/virtualenvs/idashop-XU5c4BC3-py3.10/lib/python3.10/site-packages/django/db/models/options.py", line 683, in get_field
    raise FieldDoesNotExist(
django.core.exceptions.FieldDoesNotExist: Developer has no field named 'status'

django.core.exceptions.FieldDoesNotExist: Developer has no field named 'status' — миграция пытается произвести манипуляции с полем Статус, которого на dev-среде нет. Из-за этого у нас падает применение миграций, в том числе тех, которые должны идти после проблемной миграции. Процесс не пойдёт дальше до тех пор, пока эта миграция не будет отмечена в таблице миграций как примененная.

В таком случае можно фейкнуть эту миграцию с помощью флага --fake :

$ python manage.py migrate developers 0007 --fake
 
Operations to perform:
  Target specific migration: 0007_alter_developer_developerstatus, from developers
Running migrations:
  Applying developers.0007_alter_developer_developerstatus... FAKED
  
$ python manage.py showmigrations

admin
 [X] 0001_initial
 [X] 0002_logentry_remove_auto_add
 [X] 0003_logentry_add_action_flag_choices
auth
 [X] 0001_initial
 [X] 0002_alter_permission_name_max_length
 [X] 0003_alter_user_email_max_length
 [X] 0004_alter_user_username_opts
 [X] 0005_alter_user_last_login_null
 [X] 0006_require_contenttypes_0002
 [X] 0007_alter_validators_add_error_messages
 [X] 0008_alter_user_username_max_length
 [X] 0009_alter_user_last_name_max_length
 [X] 0010_alter_group_name_max_length
 [X] 0011_update_proxy_permissions
 [X] 0012_alter_user_first_name_max_length
contenttypes
 [X] 0001_initial
 [X] 0002_remove_content_type_name
developers
 [X] 0001_initial
 [X] 0002_developer_inn
 [X] 0002_developer_developers__title_0428ce_idx
 [X] 0003_merge_20240716_0544
 [X] 0004_developer_rating_squashed_0006_alter_developer_rating (3 squashed migrations)
 [X] 0007_alter_developer_developerstatus
realty
 [X] 0001_initial
 [X] 0002_flat_developer
sessions
 [X] 0001_initial

С помощью флага --fake мы сделали фиктивную запись этой миграции в таблицу миграций, без фактического применения изменений на базe данных.

Теперь Django думает, что миграция применена, отмечает её, но на базу данных она изменений не оказала и не пытается, а значит — инцидент исчерпан.

Миграции данных

Мы также можем создавать миграции вручную с помощью следующих команд:

python manage.py makemigrations <приложение> --empty

python manage.py makemigrations <приложение> --name <название_миграции> --empty

В такой пустой миграции мы можем самостоятельно описать код миграции, но чаще всего это нужно для реализации дата-миграций.

Предположим, нам принесли файл в котором хранится каталог квартир; хотелось бы перенести оттуда все данные в БД.

Чтобы при каждом запуске дата-миграция не создавала дубликаты записей и не кидала исключения, миграцию надо писать идемпотентной. В этом нам поможет использование get_or_create вместо обычного create.

Идемпотентными называются такие программы, запуск которых всегда приводит к одному и тому же результату — вне зависимости от того, первый это запуск или пятый-десятый.

Создадим пустой файл миграции:

python manage.py makemigrations realst --empty --name load_flats

Добавим туда следующий код:

from django.db import migrations

def load_flats(apps, schema_editor):
    Flat = apps.get_model('developers', 'Flat')
    Developer = apps.get_model('developers', 'Developer')
    with open('flats.txt', 'r') as file:
        new_flats = file.readlines()

    for flat_data in new_flats:
        article, area, price, developer_id = flat_data.strip().split(',')
        area = float(area)
        price = int(price)
        developer = Developer.objects.get(id=developer_id)
        
        Flat.objects.get_or_create(article=article, defaults={
            'area': area,
            'price': price,
            'developer': developer,
        })


class Migration(migrations.Migration):

    dependencies = [
        ('developers', '0004_developer_rating_squashed_0006_alter_developer_rating'),  # Обновите эту строку согласно номеру вашей предыдущей миграции
    ]

    operations = [
        migrations.RunPython(load_flats),
    ]

При старте миграции с помощью RunPython запускаем на выполнение нашу функцию для экспорта данных в БД.

Однако у такой дата-миграции есть проблема — при попытке откатить, возникнет ошибка.

$ python manage.py migrate realty load_flats

django.db.migrations.exceptions.IrreversibleError: Operation
<RunPython <function load_flats at 0x7f4822c7e950>>
in realty.load_flats is not reversible

При создании простых миграций создаются как бы две вариации миграции. Одна вносит изменения, а другая описывает, как вернуть всё назад. Дата-миграции создаются разработчиками самостоятельно, а не автоматически, поэтому самооткатывающий вариант миграции нам не светит.

Чтобы отменить дата-миграцию, нужно создать ещё одну функцию move_backward, которая и займётся откатом. Эту функцию надо передать вторым аргументом в RunPython.

from django.db import migrations

def load_flats(apps, schema_editor):
    Flat = apps.get_model('developers', 'Flat')
    Developer = apps.get_model('developers', 'Developer')
    with open('flats.txt', 'r') as file:
        new_flats = file.readlines()

    for flat_data in new_flats:
        article, area, price, developer_id = flat_data.strip().split(',')
        area = float(area)
        price = int(price)
        developer = Developer.objects.get(id=developer_id)
        
        Flat.objects.get_or_create(article=article, defaults={
            'area': area,
            'price': price,
            'developer': developer,
        })

def move_backward(apps, schema_editor):
    Flat = apps.get_model('developers', 'Flat')
    with open('flats.txt', 'r') as file:
        flats = file.readlines()

    for flat_data in flats:
        article, area, price, developer_id = flat_data.strip().split(',')
        Flat.objects.filter(article=article).delete()

class Migration(migrations.Migration):

    dependencies = [
        ('developers', '0004_developer_rating_squashed_0006_alter_developer_rating'),  # Update this line according to your previous migration number
    ]

    operations = [
        migrations.RunPython(load_flats, move_backward),
    ]

При откате move_backward удалит из БД все квартиры, которые есть в файле.

Конечно, можно было бы написать django-команду, наследника от BaseCommand, но тогда всем разработчикам в команде придётся вручную запускать эту команду у себя локально, а также не забыть запустить ее на каждом окружении. Дата-миграции хороши, когда они небольшие и не блокируют огромные таблицы надолго (об этом чуть дальше).

Обычные миграции (их называют схема-миграции). Они меняют структуру таблиц в базе данных: добавляют поля, удаляют поля, создают новые таблицы, удаляют старые и т.д.

Дата-миграции — это специальные миграции, которые не трогают структуру таблиц, но меняют данные в таблицах. Благодаря дата-миграциям можно автоматически рассчитать значение в столбце, перенести данные из одной таблицы в другую или импортировать внешние данные в таблицы.

«Сухая» проверка

Мы уже вскользь упоминали про флаг --check для проверки файлов миграций. Но было бы неплохо узнать ещё об одном флаге, который редко, но метко используется, и о котором мало кто знает (во всяком случае, из моих знакомых).

Флаг --dry-run (или --plan в новых версиях Django) используется с командой migrate, чтобы показать, какие миграции будут применены, но без применения сейчас. Благодаря этой опции можно предварительно увидеть, что произойдет, когда мы попробуем применить миграции.

Пример использования:

python manage.py migrate --plan

В более старых версиях Django:

python manage.py migrate --dry-run

Команда --check же используется с командой makemigrations, чтобы проверить, есть ли не примененные миграции, но она не создаёт новые файлы миграций. Если есть непримененные миграции, команда завершится с ненулевым кодом. Это используют при построении CI/CD-пайплайнов (Continuous Integration/Continuous Deployment), в джобах, где нужно быть уверенным, что все миграции применены и нет незафиксированных изменений.

Пример использования:

python manage.py makemigrations --check

Что, где, когда использовать:

  • --dry-run (--plan): Используем, когда хотите увидеть список миграций, которые будут применены, без их фактического применения

  • --check: Используем при разработке разработки или в CI/CD, чтобы убедиться, что все миграции созданы, и в моделях нет незафиксированных изменений, которые требуют создания новых миграций.

Добавим любое изменение в модель и попробуем запустить команды эти команды:

$ python manage.py makemigrations --check

Migrations for 'developers':
  apps/developers/migrations/0008_developerstatus_alter_developer_status.py
    - Create model DeveloperStatus
    - Alter field status on developer
    
    
$ python manage.py migrate --plan

Planned operations:
  No planned migration operations.


$ python manage.py makemigrations developers

Migrations for 'developers':
  apps/developers/migrations/0008_developerstatus_alter_developer_status.py
    - Create model DeveloperStatus
    - Alter field status on developer
    
    
$ python manage.py makemigrations --check
No changes detected


$ python manage.py migrate --plan

Planned operations:
developers.0008_developerstatus_alter_developer_status
    Create model DeveloperStatus
    Alter field status on developer

Так мы видим, что check обнаружил незафиксированные изменения, то есть указал на то, что изменения в модели есть, а миграции на них нет. plan же никак не отреагировал, поскольку все созданные миграции уже применены.

После создания миграции check уже ничего не обнаружил, а plan подсветил миграцию, которая будет применена, и отметил, что она должна сделать.

После того, как мы сделаем python manage.py migrate, увидим тишину:

$ python manage.py makemigrations --check

No changes detected

$ python manage.py migrate --plan

Planned operations:
  No planned migration operations.

Упомяну еще два флага, которые не так часто приходится использовать, но держу в курсе.

Флаг --run-syncdb в Django используется для синхронизации БД без использования системы миграций. Django создаст таблицы для моделей, которые еще не существуют в базе данных, но не будет выполнять никаких миграций.

Этот может быть полезно в следующих случаях:

  1. Начальная настройка базы данных: Когда только начинаете новый проект, и еще нет ни одной миграции, флаг --run-syncdb может создать начальные таблицы для всех моделей без необходимости создавать и применять миграции.

  1. Обход ошибок миграций: Если есть проблемы с миграциями, но нужно быстро создать таблицы для моделей без выполнения миграций. Это может быть временным решением; пока разберемся с проблемами миграций.

  1. Создание тестовых баз данных: Иногда полезно быстро создать таблицы для моделей в тестовой базе данных, не проходя через процесс миграций.

Флаг --prune удаляет несуществующие миграции из таблицы django_migrations. Это нужно, когда файлы миграции, замененные сквош миграцией, были удалены.

Проблемы с миграциями

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

Migration applied before its dependency

В случае возникновения ошибки вида Migration applied before its dependency, например, Migration account.0100_merge_20240613_1151 is applied before its dependency account.0068_merge_20240613_1442 on database 'default', необходимо предпринять следующие шаги:

1. Открыть файл миграции (которая применялась раньше её зависимости) и закомментировать строчку в dependencies с той миграцией-зависимостью:

# Generated by Django 4.2.8 on 2024-06-14 09:24
 
from django.db import migrations
 
 
class Migration(migrations.Migration):
    dependencies = [
        ("account", "0066_onlinedealfamiliarizestep_onlinedealpreparetobuystep_and_more"),
        # ("account", "0068_merge_20240613_1442"), <- Туть!!!!!!!
        ("account", "0099_merge_20240610_1417"),
    ]
 
    operations = []

2. При необходимости (если будет ругаться на конфликт) сделать новую мерж-миграцию (например, в данном случае создалась мерж-миграция 0103):

python manage.py makemigrations --merge

3. Откатить миграции этого приложения до самой первой или до первой точно работающей, например, в нашем случае 0099:

# откатываем полностью
python manage.py migrate account zero
 
# или откатываем до 0099
python manage.py migrate account 0099

4. Удалить свежесозданную мерж-миграцию руками (сам файл, например в нашем случае 0103, если создавали) и раскомментировать строчку в dependencies, которую закомментировали выше:

# если из-за прав не даёт удалить миграцию, то дать права
sudo chmod 777 app/apps/account/migrations/0103_merge_20240618_1649.py
 
 
# удалить (можно просто удалить внутри контейнера)
sudo rm app/apps/account/migrations/0103_merge_20240618_1649.py
# Generated by Django 4.2.8 on 2024-06-14 09:24
 
from django.db import migrations
 
 
class Migration(migrations.Migration):
    dependencies = [
        ("account", "0066_onlinedealfamiliarizestep_onlinedealpreparetobuystep_and_more"),
        ("account", "0068_merge_20240613_1442"), # убрал коммент
        ("account", "0099_merge_20240610_1417"),
    ]
 
    operations = []

4.5 Если какая-то миграция оказалась проблемной (что-то ломает, неактуальная и т. д.), то перед тем как перенакатить миграции, нужно фейкнуть эту проблемную миграцию, например:

python manage.py migrate account 0068_merge_20240613_1442 --fake

5. Сделать migrate, чтобы заново накатить все миграции:

python manage.py migrate

Альтернативный вариант

1.  Откатить миграции до проблемной (в нашем случае это до 0047), включая эту миграцию, через удаление записей в таблице миграций (таблица называется django_migrations).

DELETE FROM django_migrations WHERE app='приложение' AND name='название_миграции';

2. Затем начинаем накатывать миграции каждый раз, когда появлялась ошибка — убеждаемся, что миграция не применяется, так как пытается вернуть, например, проблемный enum, и после этого фейкуем её. И так, пока все миграции не будут применены.

python manage.py migrate account 0068_merge_20240613_1442 --fake

Таким образом, вручную можно восстановить граф миграций.

Примечание: будьте осторожны с удалением записей напрямую в таблице миграций — можно случайно всё снести.

Возможные причины появления такой ошибки:

  1. Нарушение порядка миграций: Когда миграции были созданы и применены в неправильном порядке.

  1. Отсутствие некоторых миграций: Когда некоторые миграции отсутствуют в проекте.

  1. Ручное изменение миграций: Если миграции были изменены вручную и зависимости были нарушены.

  1. Конфликты при слиянии веток в системе контроля версий: При слиянии веток могут возникнуть конфликты, которые нарушают порядок миграций (самый частый случай).

Makemigrations не создает новые миграции

Бывает что мы как-то изменили модель (добавили поле например), после этого хотим сделать новую миграцию командой python manage.py makemigrations, но в ответ получаем, что никаких изменений нет No changes detected. В таком случае стоит уточнить, в каком именно приложении было изменение моделей:

python manage.py makemigrations realty

Также имеет смысл убедиться, что приложение добавлено в список INSTALLED_APPS настроек проекта.

Ошибка в миграции

Допустим мы добавили новое поле в модель Застройщиков, например, поле Адрес, и создали миграцию через makemigrations. В результате в папке migrations появился файл 0009_developer_address.py.

На созвоне коллективно пришли к выводу, что это поле избыточно: оно нам просто не нужно или нужно, но необходимо или изменить тип данных, или представить адрес в виде не одного столбца, а трёх отдельных и так далее. Рассмотрим два случая — если мы уже запустили migrate, и изменения из свеженькой миграции применились к БД, либо если мы ещё этого не успели сделать.

Ситуация А. Если мы не запустили migrate

Вспоминаем, что makemigrations просто создаёт описание миграции. Таким образом, пока мы не сделали migrate — в базе данных не произойдет никаких изменений, а значит, бояться нечего. Мы можем просто зайти в папку migrations и удалить этот файлик миграции.

После этого спокойно редактируем модель и снова делаем makemigrations.

Ситуация Ж. Если мы уже запустили migrate

Тут становится несколько сложнее, поскольку migrate уже применил изменения и новый столбец уже появился в БД. Тогда нам нужно откатить изменения этой миграции из нашей БД.

Для этого нужно найти миграцию, которая была применена до 0009. В нашем случае это миграция 0008.

Узнать это можно с помощью команды python manage.py showmigrations. Просто нужна миграция, которая идёт до нашей:

 # .. что-то ещё
 [X] 0007_что_то_там
 [X] 0008_что_то_там
 [X] 0009_developer_address

Получилось, что проблемная миграция последняя, и мы можем просто откатить её:

python manage.py migrate developers 0009

Это откатит состояние БД к тому моменту, в котором она находилась на момент применения миграции 0008.

Теперь изменений в БД нет, и мы попали в Ситуацию А. Заходим в папку migrations, удаляем файлик миграций 0009_developer_address. После этого работаем с моделью дальше.

Отношение (столбец) не существует

Запускаем приложение и видим ошибку вида:

django.db.utils.ProgrammingError: ОШИБКА: отношение "realty_flat" не существует LINE 1: ...", "realty_flat"."title" FROM "realty... ^

Причина: где-то в приложении код обращается к таблице, которой нет в БД.

Наши действия:

  1. Есть вероятность, что мы или кто-то другой просто забыли сделать миграцию. Запускаем makemigrations, и если миграция с отсутствующей таблицей создаётся, применяем её через migrate.

  1. Если миграция с добавлением этой таблицы всё-таки есть, её надо найти и посмотреть, применена ли она в таблице миграций. По традиции смотрим через showmigrations. Если она не применена, делаем migrate.

    Даже если она есть в таблице миграций и отмечена как примененная, не факт, что изменения реально есть в БД. Возможно, эту миграцию фейкнули через
    --fake.

    В таком случае надо откатить миграции до предыдущей, либо откатить все миграции приложения через
    manage.py migrate <приложение> zero и накатить все миграции заново через migrate.

  1. Если даже после этого ошибка не пропадает, то возможно, мы запустили код в другом окружении (например, код на prodaction и develop-средах может значительно различаться) и пытаемся получить то, чего нет. Например, код мы запускаем с master-ветки, а дамп базы данных у нас от dev.

  1. Последний, более редкий, но меткий случай — мы обращаемся к этой таблице внутри миграции:

# Generated by Django 4.2.8 on 2024-05-30 18:07

from django.db import migrations, models


def set_is_default(apps, _):
    """Простановка флага дефолтности для старых записей"""
    # ПУ ПУ ПУ ПУ ТУТ БУДЕТ ОШИБКА
    InfraCategory.objects.update(is_default=True)


class Migration(migrations.Migration):

    dependencies = [
        ('panel', '0011_meetingresult_commercials'),
    ]

    operations = [
        migrations.AddField(
            model_name='infracategory',
            name='is_default',
            field=models.BooleanField(default=False, verbose_name='Выводить на карте инфраструктуры проекта'),
        ),
        migrations.RunPython(set_is_default, migrations.RunPython.noop),
    ]

В таком случае нужно обращаться через apps.get_model:

# Generated by Django 4.2.8 on 2024-05-30 18:07

from django.db import migrations, models


def set_is_default(apps, _):
    """Простановка флага дефолтности для старых записей"""
    InfraCategory = apps.get_model("panel", "infracategory")
    InfraCategory.objects.update(is_default=True)


class Migration(migrations.Migration):

    dependencies = [
        ('panel', '0011_meetingresult_commercials'),
    ]

    operations = [
        migrations.AddField(
            model_name='infracategory',
            name='is_default',
            field=models.BooleanField(default=False, verbose_name='Выводить на карте инфраструктуры проекта'),
        ),
        migrations.RunPython(set_is_default, migrations.RunPython.noop),
    ]

Аналогичные приемы помогут не только с отсутствием таблицы, но и отсутствием столбца (кроме 4 пункта, он столбцов не касается).

Отношение (столбец) уже существует

Запускаем migrate и видим сообщение об ошибке django.db.utils.ProgrammingError: relation "realty_flat" already exists

Ошибка «table already exists» в Django возникает при попытке создать таблицу, которая уже существует в базе данных.

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

Решить эту проблему можно несколькими способами:

  1. Фейкнуть эту миграцию командой python manage.py migrate <приложение> <миграция> --fake

  1. Если проблема в стартовых миграциях. Можно также фейкнуть их, но с опцией --fake-initial. Этот флаг позволяет пропустить начальную миграцию (самую первую в приложении, которая помечена как initial), если таблицы уже существуют.

    python manage.py migrate --fake-initial

  1. Если данных в таблице нет и терять вам нечего, можно просто удалить эту таблицу руками. Запустить python manage.py dbshell, а затем выполнить команду SQL, чтобы удалить таблицу DROP TABLE realty_flat;

  1. Также можно попробовать откатить все миграции в приложении через manage.py migrate realty zero и накатить через migrate.

Если возникает проблема «столбец уже существует», то решается аналогичным образом.

Dependencies reference nonexistent parent node

Ошибка «зависимости ссылаются на несуществующий родительский узел» указывает, что у миграции есть зависимая миграция (dependencies) файл, который не удаётся найти.

Причина:

  1. Файла действительно просто нет: Если файл миграции, который является зависимостью, удален или отсутствует в соответствующей папке, может возникнуть эта ошибка.

  1. Ошибка при ручном редактировании файлов миграций: Изменение файлов миграции вручную, особенно изменение их порядка, зависимостей или имен, может привести к этой ошибке.

  1. Ошибки при работе с git: например, другой разработчик забыл закоммитить и запушить этот файл миграции, произошёл конфликт при слиянии и так далее.

Ошибку не получится устранить, пока мы не восстановим целостность графа миграций. Вот что можно попробовать сделать:

  1. Просто закомментировать строчку с несуществующей зависимостью

# Generated by Django 4.2.8 on 2024-06-14 09:24
 
from django.db import migrations
 
 
class Migration(migrations.Migration):
    dependencies = [
        ("account", "0066_onlinedealfamiliarizestep_onlinedealpreparetobuystep_and_more"),
        # ("account", "0068_merge_20240613_1442"), <- Туть!!!!!!!
        ("account", "0099_merge_20240610_1417"),
    ]
 
    operations = []

Однако, это не всегда может сработать, поскольку есть вероятность появления ошибки сould not find common ancestor of

  1. Если мы работаем в нескольких окружениях (например, для прода и для дева), то сравним там файлы миграций. Возможно, проблема в том, что на production-окружении у этой миграции одна зависимость, а на dev другая. Если это так, нужно вручную отредактировать файл миграции и поставить в dependencies корректную миграцию.

  1. Проверить удаленные файлы миграции. Необходимо убедиться, что ни один из файлов миграций, на которые ссылаются зависимости, не был случайно удален. Посмотреть в корзине, в других папках миграций, сверить название и т. д. Очень много проблем возникают из-за невнимательности или случайных телодвижений в районе клавиатуры или мышки.

  1. Система контроля версий. Имеет смысл посмотреть в разных ветках git. Возможно, файл миграции не был закоммичен, лежит в stash или просто не создан через makemigrations.

  1. Проверить изменения, внесенные вручную: если мы вносили изменения в файлы миграции вручную, нужно внимательно просмотреть эти изменения и убедиться, что всё корректно.

  1. Вручную создать недостающую миграцию. Самостоятельно создать миграцию можно командой python manage.py makemigration <приложение> --name <имя_миграции> --empty
    Разумеется, имя мы даём такое, как у нашей «пропавшей миграции». Если мы примерно представляем, что должно в ней содержаться, можно попытаться руками восстановить её содержимое. Если нет — оставить пустой в надежде, что там не было никаких значительных изменений.

  1. Пересоздать миграции: в крайнем случае, если проблема осталась, придется заново создать проблемные миграции. Следует откатиться до последней корректной миграции в приложении, удалить файлы миграций, которые идут после миграции до которой мы откатились, а затем повторный создать миграции через makemigrations и применить их через migrate. Однако это опасная дорога, ведь мы можем изменить файлы и порядок миграций, что потенциально сломает приложение при их при в окружение. Ну и данные, разумеется, тоже никто не вернёт. Бекапы, бекапы и ещё раз бекапы наше всё в таком случае. Если это пет-проект, данных нет и терять нечего, то можно просто удалить БД и заново создать.

Could not find common ancestor of

При запуске migrate возникает конфликт миграций. Мы пытаемся создать мерж-миграцию, но получаем ещё одну ошибку:

ValueError: Could not find common ancestor of {'0012_auto_20150326_1647', '0019_add_year'}

Эта ошибка указывает на то, что Django не может определить общий предок между двумя миграциями. Это обычно происходит по одной из следующих причин:

  1. Две ветви миграций: У нас могут быть две разные ветви миграций, которые не имеют общего предка. Это случается, если миграции были созданы в разных ветках системы контроля версий (например, git) и потом обе ветки были объединены без должного учета миграций

  1. Удаленные миграции: один или несколько файлов миграций могли быть случайно удалены или переименованы, что нарушило цепочку миграций

Решение:

  1. Проверка ветвей миграций:

  • Проверяем структуру миграций в приложениях и убеждаемся, что все миграции правильно связаны друг с другом.

  • Смотрим таблицу миграций python manage.py showmigrations на предмет проблемных участков.

  1. Поиск и восстановление удаленных миграций:

  • Проверяем историю git, чтобы убедиться, что никакие миграции не были случайно удалены или переименованы.

  • Если миграции были удалены, пытаемся восстановить их.

  1. Разрешение конфликтов миграций:

  • Попытаться вручную создать новую миграцию, которая будет объединять изменения из конфликтующих миграций, объединив изменения из двух миграций в один файл.

Пример решения

  1. Создание новой «как бы мерж-миграции» в ручную:

  • Для этого объединим изменения из миграций 0012_auto_20150326_1647 и 0019_add_year в один файл. Создаём новый файл миграции, например, 0020_merge_20230713.py.

# Пример нового файла миграции 0020_merge_20230713.py
from django.db import migrations

class Migration(migrations.Migration):

    dependencies = [
        ('приложение', '0012_auto_20150326_1647'),
        ('приложение', '0019_add_year'),
    ]

    operations = [
        # Включите здесь объединенные изменения из обеих миграций
    ]

Как правило, такие ошибки возникают из-за удаления файлов миграций или записей в таблице миграций.

В любом случае, перед тем как решать проблемы dependencies reference nonexistent parent node и сould not find common ancestor of, имеет смысл делать бекап базы данных.

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

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

Тут мы либо обнуляем миграции, либо убираем их прогон, но добавляем проверку миграций при помощи check.

Миграция необязательного поля в обязательное

Допустим, у нас давно существует модель Клиентов:

from django.db import models

class Client(models.Model):
    last_name = models.CharField(max_length=200)
    first_name = models.CharField(max_length=200)
    mid_name = models.CharField(max_length=200)

Теперь добавляем поле для почты(email):

from django.db import models

class Client(models.Model):
    last_name = models.CharField(max_length=200)
    first_name = models.CharField(max_length=200)
    mid_name = models.CharField(max_length=200)
    email = models.EmailField()

В момент makemigrations видим сообщение:

$ python3 manage.py makemigrations

You are trying to add a non-nullable field `email` to client without a default;    
we can t do that (the database needs something to populate existing rows).    
Please select a fix:    
 1) Provide a one-off default now (will be set on all existing rows with a null value for this column)
 2) Quit, and let me add a default in models.py    
Select an option:    

Сообщение You are trying to add a non-nullable field without a default говорит о том, что мы пытаемся добавить обязательное к заполнению поле email, но не указали для него значение по умолчанию.

Для новых клиентов база данных будет требовать email. Однако записи старых клиентов, у которых не указаны email, уже существуют, поэтому мы целенаправленно сделали ограничение, что поле должно быть заполнено обязательно.

Что можно сделать в таком случае:

  1. Задать значение по умолчанию

Чтобы БД автоматически заполнила пустоту, можно задать значение по умолчанию несколькими способами.

1.1 Одноразовое значение по умолчанию

Одноразовое значение по умолчанию будет проставлено всем незаполненным полям во время миграции. makemigrations предложит нам выбор из двух пунктов, выбираем первый.

Please select a fix:    
 1) Provide a one-off default now (will be set on all existing rows with a null value for this column)
 2) Quit, and let me add a default in models.py    

В поле ввода необходимо указать значение, которое будет выставлено во все незаполненные ячейки — например, default@mail.ru.

После этого будет создан файл миграции. В нём будет зашита строка default@mail.ru.

1.2 Постоянное значение по умолчанию

Это значение задаёт email как для старых записей, которые уже сохранены в БД, так и для новых с пустым email. Оно задаётся в models.py с помощью аргумента default:

from django.db import models

class Client(models.Model):
    last_name = models.CharField(max_length=200)
    first_name = models.CharField(max_length=200)
    mid_name = models.CharField(max_length=200)
    email = models.EmailField(default="default@mail.ru")

Теперь запуск makemigrations пройдёт успешно. Кроме того, если при добавлении в БД нового клиента не будет указан email, то он автоматически примет значение по умолчанию:

>>> client = Client.objects.create(first_name="Моксим", last_name="Мельников", mid_name="Олегович")
>>> client.email

default@mail.ru
  1. Разрешить полю быть пустым

Некоторым полям и не обязательно всегда быть заполненными. В такой ситуации используем null=True и blank=True.

from django.db import models

class Client(models.Model):
    last_name = models.CharField(max_length=200)
    first_name = models.CharField(max_length=200)
    mid_name = models.CharField(max_length=200)
    email = models.EmailField(black=True, null=True)

Обычно не рекомендуется применять null=True к строковым полям типа CharField, TextField и производным. Вместо None в них лучше записывать пустую строку.
Если указать для строкового поля null=True, то оно может содержать два возможных «пустых» значения: None и пустую строку. Две версии пустого значение могут создать проблемы в коде.
Почитать про blank и null.

  1. Если не вариант ставить default или null

Иногда значение по умолчанию или null=True ставить не вариант. Например, в ситуации, когда у вас есть модель Клиента и Сделки, и нам необходимо их связать, но изначально этой связи нет:

class Client(models.Model):
    last_name = models.CharField(max_length=200)
    first_name = models.CharField(max_length=200)
    mid_name = models.CharField(max_length=200)

class Deal(models.Model):
    amount = DecimalField(max_digits = 12, decimal_places = 2)

Теперь мы решили добавить клиента в сделку, чтобы получать все сделки определенного клиента:

class Deal(models.Model):
    amount = DecimalField(max_digits = 12, decimal_places = 2)
    client = models.ForeignKey(Client, on_delete=models.CASCADE, related_name="deals")

При запуске makemigrations возникнет ошибка You are trying to add a non-nullable field without a default. Проблему можно обойти через добавление атрибутов null=True или default, но это неправильно, ведь у Сделки не может отсутствовать клиент, поле не должно быть пустым.

Предварительно не подумали про это. Однако неважно, кто прав и виноват, важно решить проблему.

Что-то вроде решения:

1. Создадим новую модель ClientDeal и укажем связь — ForeignKey:

class Client(models.Model):
    last_name = models.CharField(max_length=200)
    first_name = models.CharField(max_length=200)
    mid_name = models.CharField(max_length=200)

    
class Deal(models.Model):
    amount = DecimalField(max_digits = 12, decimal_places = 2)
    
  
class ClientDeal(models.Model):
    amount = DecimalField(max_digits = 12, decimal_places = 2)
    client = models.ForeignKey(Client, on_delete=models.CASCADE, related_name="deals")

2. Создадим и применим миграцию с помощью makemigrations и migrate.

3. Скопируем данные из Deal в ClientDeal с помощью дата-миграции или BaseCommand. Правда, надо придумать, как связать данные клиентов со сделками, но в данном случае мы имеем искусственный пример с минимальным набором полей и максимально простой структурой.

Начнём с дата-миграции:

python manage.py makemigrations --empty realty

from django.db import migrations, models

def migrate_data(apps, schema_editor):
    Client = apps.get_model('your_app_name', 'Client')
    Deal = apps.get_model('your_app_name', 'Deal')
    ClientDeal = apps.get_model('your_app_name', 'ClientDeal')

    for deal in Deal.objects.all():

        # client = ...  # как-то соотносим клиента и сделку
        ClientDeal.objects.create(
            amount=deal.amount,
            client=client
        )

class Migration(migrations.Migration):

    dependencies = [
        ('realty', 'предыдущая миграция'),
    ]

    operations = [
        migrations.RunPython(migrate_data),
    ]

Аналогичный вариант с django-командой:

import logging
from django.core.management.base import BaseCommand
from your_app_name.models import Client, Deal, ClientDeal

class Command(BaseCommand):
    help = 'Migrate data from Deal and Client to ClientDeal'

    def handle(self, *args, **options):
        logger = logging.getLogger(__name__)
        
        try:
            for deal in Deal.objects.all():
                # client = ...  # как-то соотносим клиента и сделку
                ClientDeal.objects.create(
                    amount=deal.amount,
                    client=client
                )
            
            self.stdout.write(self.style.SUCCESS('Successfully migrated data to ClientDeal'))
        except Exception as e:
            logger.error(f"Error during migration: {e}")
            self.stdout.write(self.style.ERROR(f'Error during migration: {e}'))

4. После перекачивания данных можно удалить модель Deal. Затем переименовать ClientDeal в Deal ещё через одну миграцию.

Разбор ошибок, связанных с миграциями перестаёт пугать, когда ты начинаешь понимать как они устроены. Если дело не касается распределёнки :)

«Восток дело тонкое, а миграции — ещё тоньше», — как говорит наш TeamLead Илья Филиппов. Когда дело доходит до масштабирования БД, появляется репликация и шардирование становится чересчур тонко. Но это уже тема для отдельной статьи.

Советы, которые помогут избежать проблем в будущем (типа Best Practice)

  1. Делайте обратно совместимые изменения схемы БД.

    В рамках релиза все миграции должны быть обратносовместимым:

    * нельзя одновременно убирать логику работы с полями и удалять их. В одном релизе мы должны удалить логику, и только в следующем сами поля. Это нужно, чтобы в случае отката релиза, или в случае ошибки при накате миграций во время деплоя не возникло ошибок из-за разницы моделей в коде и схемы БД.

    * Нельзя менять тип поля, поскольку не все типы поддерживают конвертацию друг в друга, например: число всегда можно конвертировать в строку, но не всегда можно сделать наоборот.

    * Нельзя делать поле обязательным, если оно было необязательным, или нужно задавать ему default.

    Например, если мы сначала применим миграцию, которая удаляет какое-то поле, а потом начнём деплой, то на время деплоя старые реплики могут давать 500е ошибки. Поэтому решаем тем, что в одном релизе убираем код работающий с полем, а в другом само поле.

  1. Делайте одну миграцию на одну таблицу.

  • Если у вашей БД есть реплики, то в рамках одной миграции не стоит блокировать несколько больших таблиц. Запросы летят на реплику, а несколько больших таблиц заблочены, вырастает лаг между репликой и мастером. Тогда запросы начинают ходить в мастер, и он может не выдержать нагрузку. Поэтому не записывайте изменение нескольких моделей в одну миграцию. Делайте makemigrations после каждого изменения отдельной модели.

  • Реплики приложения в аналогичной ситуации тоже могут спокойно уйти в dead-lock.

  1. Избегайте дата-миграций, которые могут надолго заблокировать таблицу.

    Это может привести к той же самой ошибке, что и в пункте 2. Тут стоит либо делать небольшие по количеству изменений дата-миграции, либо пользоваться django-command для перекачивания данных, поскольку миграции делает все изменения в рамках одной транзакции, что приводит к блокировке.

Для тяжёлых дата-миграций важно прописать atomic = False — эта настройка говорит, что миграция будет проходить не в рамках транзакции, К слову, параметр atomic может использоваться не только во всей миграции, но и в конкретном RunPython. Конечно, atomic=False может принести и другие проблемы, но в таких случаях надо отдавать отчёт о принимаемых рисках.

  1. Используйте линтер для миграций, например, django-migration-linter.

  1. Не пренебрегайте валидацией миграций: Используйте флаги check и plan. Также рекомендую добавить эти проверку в одну из джоб вашего CI/CD-пайплайна.

  1. Будьте аккуратны с добавлением новых индексов на гигантские таблицы, это может надолго заблокировать таблицу.

  1. Помни, что миграции могут вести себя по-разному себя с разными бэкендами баз данных, например, PostgreSQL, MySQL, SQLite и т.д. Например, в MySQL отсутствует поддержка транзакций, связанных с операциями изменения схемы, поэтому, чтобы повторить неудачную операцию миграции. Поэтому нам нужно будет вручную отменить частично примененные изменения в случае аварии. 

  1. Чтобы не мучиться с удалением столбцов, воспользуйтесь https://github.com/3YOURMIND/django-deprecate-fields

  1. Не делайте сквош миграций, если внутри команде не договорились их сквошить.

  1. Не забывайте добавлять миграции в git. PyCharm, например, по умолчанию их не добавляет в изменения.

Заключение

В этом материале я постарался охватить наиболее важные, на мой взгляд, вещи. Безусловно, миграции настолько обширная тема, что их невозможно охватить даже за серию статей. А когда дело доходит до распределёнки, я вообще молчу. Но после изучения этого материала, я надеюсь, вы станете лучше ориентироваться в вопросе django-миграций, а всплывающие трудности уже не поставят в ступор.

Если у вас есть какие-то замечания, предложения — жду ваши комментарии. Вместе мы сможем сделать материал, который действительно станет хорошим подспорьем для новичков, которые только начинают открывать для себя мир веб-разработки и фреймворка Django.

Источники

https://docs.djangoproject.com/en/5.0/topics/migrations/

https://realpython.com/django-migrations-a-primer/

https://dvmn.org/encyclopedia/#django-migrations

https://habr.com/ru/companies/yandex/articles/745534/

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