Opened 5 hours ago

Last modified 5 hours ago

#37425 new Bug

Adding a nullable ForeignKey on MySQL rebuilds the table — at Version 1

Reported by: Tim Kedmenec Owned by:
Component: Migrations Version: 6.1
Severity: Normal Keywords: mysql, instant, foreign key
Cc: Triage Stage: Unreviewed
Has patch: no Needs documentation: no
Needs tests: no Patch needs improvement: no
Easy pickings: no UI/UX: no

Description (last modified by Tim Kedmenec)

On MySQL, adding a nullable ForeignKey to an existing table rebuilds the whole table. ADD COLUMN can be ALGORITHM=INSTANT. ADD FOREIGN KEY can't be INSTANT, and it
can't be INPLACE unless foreign_key_checks is off (error 1846: "Adding foreign keys needs foreign_key_checks=OFF"). Django leaves checks on, so the constraint step is
ALGORITHM=COPY.

Before 3.0, add_field() ran the column add on its own and queued the FK in deferred_sql (code
(​https://github.com/django/django/blob/9a0cc54524422dbdd9213e83a8ad7e8a4c13bd3e/django/db/backends/base/schema.py)). The column was instant, but the FK statement was
still a COPY. Since 3.0, 738faf9da2 (​https://github.com/django/django/commit/738faf9da2a5cd03148a36375db80746c99c9623) (#30108) puts both in one statement:

ALTER TABLE t
    ADD COLUMN owner_id integer NULL,
    ADD CONSTRAINT t_owner_id_fk FOREIGN KEY (owner_id) REFERENCES owner (id);

so the column add is no longer instant either (see comment:11 on #30108). On a ~100GB table this takes hours.

Our workaround is a hand-written migration:

migrations.SeparateDatabaseAndState(
    state_operations=[
        migrations.AddField(
            model_name="book",
            name="owner",
            field=models.ForeignKey(
                "app.Owner",
                null=True,
                on_delete=models.SET_NULL,
            ),
        ),
    ],
    database_operations=[
        migrations.RunSQL(
            "ALTER TABLE app_book "
            "ADD COLUMN owner_id integer NULL, ALGORITHM=INSTANT",
            reverse_sql=(
                "ALTER TABLE app_book DROP COLUMN owner_id, ALGORITHM=INSTANT"
            ),
        ),
        migrations.RunSQL("SET foreign_key_checks = 0"),
        migrations.RunSQL(
            "ALTER TABLE app_book "
            "ADD CONSTRAINT app_book_owner_fk "
            "FOREIGN KEY (owner_id) REFERENCES app_owner (id), "
            "ALGORITHM=INPLACE, LOCK=NONE"
        ),
        migrations.RunSQL("SET foreign_key_checks = 1"),
    ],
)

For a nullable FK, all existing rows are NULL, so there's nothing to validate and it's safe to disable foreign_key_checks for the constraint step.

The patch makes the MySQL schema editor do this for nullable FKs with db_constraint=True. Non-null FKs keep the single statement. The test checks that
information_schema.innodb_tables.total_row_versions goes up by one, which is how an instant add shows up. A rebuild resets it to 0.

PR: ​https://github.com/django/django/pull/22146

Change History (1)

comment:1 by Tim Kedmenec, 5 hours ago

Description: modified (diff)
Summary: Adding a nullable ForeignKey on MySQL rebuilds the table (regression from #30108) → Adding a nullable ForeignKey on MySQL rebuilds the table
Note: See TracTickets for help on using tickets.
Back to Top