SteelmemoSign in

Steelmemo · Sample memo

Migration crashes deleting an index_together if there is a unique_together on the same fields

Oct 20, 2026 · 4 models from 4 labs · 13 file reads · 6 min 41 s

Verdict

Change django/django/db/backends/base/schema.py line 396 from {'index': True} to {'index': True, 'unique': False}, and leave the exactly-one check at lines 410–415 alone. The crash happens because removing index_together looks up the index with index=True only, so the unique constraint on the same columns matches too. Treat "moving an index to Meta.indexes shouldn't rebuild it" as a separate ticket, because this fix doesn't address it.

First step this week: Change schema.py line 396 to {'index': True, 'unique': False} and leave lines 380 and 410–415 as they are.

Confidence

Medium. All four labs agree on the fix and it was checked against the code you sent, but the part about the unique constraint also showing up as an index rests on the user's error message, because none of the database backend files were attached.

Agreement

All 4 models agree.

What changed under review

  • Claude first said the main risk was a unique index reported with unique=None. After its own further review and Grok's, the panel moved the risk to a plain index reported with unique=None, because None != False at schema.py:1156 means only that case turns into zero matches.
  • Claude first said it hadn't read the body of _constraint_names. On review, Claude and GPT both read line 1156 and confirmed it really does exclude entries whose unique value is different.
  • Gemini first proposed filtering on 'type': Index.suffix. Grok pointed out that a unique constraint's index is often also a btree (introspection.py:185), and Gemini's later review backed unique=False instead.

Why

  1. The call that removes index_together filters only on index=True. _delete_composed_index raises 'Found wrong number of constraints' unless it finds exactly one match, so the _uniq and _idx objects on the same columns cause the crash. (django/django/db/backends/base/schema.py, lines 396 and 410–415)
  2. _constraint_names drops any entry whose unique value differs from the one asked for. Passing unique=False therefore leaves only the plain _idx index, which is the only thing sql_delete_index should drop. (django/django/db/backends/base/schema.py, line 1156)
  3. The unique side already narrows its lookup with unique=True, and the documented contract says unique is True for unique constraints and False otherwise. The fix just makes the index side match the unique side. (django/django/db/backends/base/schema.py, line 380; django/django/db/backends/base/introspection.py, line 180)

Where they disagreed

  • How to tell the plain index apart from the unique constraint. Gemini first proposed filtering on index type (Index.suffix). Claude, GPT and Grok used unique=False, and Grok said type is the wrong signal because a unique constraint's index is often also a btree. Settled. Gemini's later review backed unique=False.
  • Whether to filter on flags or delete by the known _idx name. Grok, as challenger, said the panel never explained why it filters on introspection flags when it could delete the _idx name built at line 400, and warned that a backend reporting the unique constraint as index=True, unique=False would get it silently dropped. The other labs, and Grok's own review, backed the flag filter. Not settled. To settle it: Dump get_constraints() for a model with the same two fields in unique_together and index_together on every backend you ship. Confirm the plain index comes back with unique=False and the unique constraint with unique=True.

The case against

The core claim, that a unique_together constraint is also reported with index=True, comes only from the user's "found 2" error. The base get_constraints just raises NotImplementedError, and the backend files weren't reviewed. If a backend reports the unique constraint with index=True, unique=False, the new filter finds exactly one match and sql_delete_index silently drops the unique constraint. That's a data-integrity bug, not a gap in the tests. The panel also never explained why it filters on flags when it could delete the _idx name. And after this fix, the reporter's migration runs but still drops and recreates the index, which can be worse than the crash on a large table.

Not verified

  • That database introspection reports a unique_together constraint with index=True. This rests on the user's error message, not on the attached files (introspection.py:190 only raises NotImplementedError).
  • That every backend returns unique=False exactly for plain indexes. Claude's and Gemini's statements that PostgreSQL, MySQL, SQLite and Oracle do this weren't checked, and GPT withdrew its own version of the claim.
  • That no backend subclass overrides alter_index_together or _delete_composed_index. The backend files weren't attached.
  • That upstream Django fixed this exact bug with this exact filter. Claude recalled this from memory.
  • Where the migration autodetector lives and how it behaves. That code wasn't in the attached files.
  • No tests were run.

What would change the answer

  • A backend reports the plain index_together index with unique set to anything other than False. The new filter would find zero matches and still crash.
  • A backend reports the unique constraint as index=True, unique=False. The filter would then drop the unique constraint.
  • A backend subclass overrides alter_index_together or _delete_composed_index, so the base-class edit never runs there.
  • A backend stores the index and the unique constraint as one combined object. Removing one would then need logic that handles both together.

Next steps

  1. This week: change schema.py line 396 to {'index': True, 'unique': False} and leave lines 380 and 410–415 as they are.
  2. In the same change, add a schema test with the same two columns in unique_together and index_together. Remove only index_together and check the _idx index is gone and the unique constraint is still there. Then do the reverse.
  3. Run that test on every supported database backend in CI, and dump get_constraints() for this model on each one.
  4. Search the backend subclasses for overrides of alter_index_together and _delete_composed_index.
  5. Open a separate ticket so that moving an index from index_together to Meta.indexes is detected as a rename. Tell the reporter that this fix stops the crash but still rebuilds the index.
Each model's position (4)

Claude (Anthropic): Line 396 filters only on index=True, so the unique_together constraint matches along with the _idx index and _delete_composed_index raises. Adding unique=False fixes it, the same way the unique side already uses unique=True at line 380. Wants a regression test in both directions, run on every backend. Keep the Meta.indexes request as its own ticket and check backend subclasses for overrides.

GPT (OpenAI): Agrees with the root cause and the one-line fix, and read lines 1156–1157 to confirm the unique filter works. Accepted that the main risk points the other way from Claude's first statement. Withdrew its claim that every backend sets these flags correctly. Wants two-direction tests on every backend and a check for overrides. Avoiding the index rebuild is a separate migration-level question.

Gemini (Google): First suggested filtering by index type ('type': Index.suffix) or by unique=False. Later fully backed unique=False because it mirrors the unique side. Says the autodetector issue belongs in a separate patch. Thinks the unique=None risk is negligible on the core backends, and argues that the user seeing 2 matches proves both objects exist.

Grok (xAI): In its review, said to ship the line 396 change only. Don't relax the len != 1 check, because index-drop SQL would then also run against the unique constraint. Index type is the wrong way to tell them apart. Changing the exclude set won't help. As challenger, argued the mechanism isn't proven by these files, and that one wrong match could quietly drop the unique constraint.

Written by AI models. They can be wrong: check anything important before you act on it. Not legal, financial or professional advice.

Ask your own question