Constraints, Indexes & Composite Keys
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against Django 6.1 (6.1.1, released 2026-09-02; 6.1.2 is scheduled for 2026-10-06) on Python 3.12 to 3.14 and PostgreSQL 15 or later, with the 5.2 LTS differences noted where they matter, and was checked on 2026-10-03. The examples ran on Django 6.1.1 and Python 3.13 with SQLite 3.53 in a throw-away project with a catalog app (the Bookshelf catalogue that grows across this section). SQLite does not implement every option shown here (covering indexes, nulls_distinct, deferrable unique constraints); where an example depends on PostgreSQL the page says so, and the SQL shown for PostgreSQL-only features is what the documentation describes, not output captured from a PostgreSQL server.
A model describes the shape of the data; indexes and constraints describe two things the database itself enforces. An index makes a query fast without changing which rows are valid. A constraint makes some rows invalid, whatever code tries to write them: a view, the admin, a management command, a Celery task, a raw SQL script or a colleague with a database console. Application-level checks (clean(), form validation, serializers) give friendly messages but can be bypassed and can race; database constraints are the only guarantee. Django lets you declare both in Meta, creates them through migrations and, since 4.1, also checks constraints in Python during model validation so that forms report violations as ordinary errors.
This page covers Meta.indexes (including functional, partial and covering indexes), CheckConstraint and UniqueConstraint, constraint validation, composite primary keys, and how to move code off index_together, which was removed in Django 5.1. For field types and Meta as a whole see Models & Fields; for how Meta changes become SQL see Migrations; for PostgreSQL-only indexes and ExclusionConstraint see PostgreSQL & Database Backends; for reading query plans see Query Optimization & Fetch Modes and Diagnosing Slow Queries; for the SQL itself see SQL: DDL.
Where constraints and indexes sit
or full_clean()"] -->|"clean_fields, clean,
validate_unique,
validate_constraints"| Py["Python-side check
(friendly errors)"] Py --> Save["save() / create() / bulk_create()
(no validation)"] Save --> DB[("Database
CHECK, UNIQUE,
indexes")] DB -->|"violation"| Err["IntegrityError"] Raw["Raw SQL, admin actions,
other apps, scripts"] --> DB
The diagram shows the rule behind the rest of the page. Model.save() never calls full_clean(); only ModelForm.is_valid() (for the fields on the form) or an explicit full_clean() runs the Python-side checks. Whatever skips those checks reaches the database, which answers with django.db.IntegrityError. Declare the rule once in Meta.constraints and you get both: the database enforces it, and validation reports it nicely.
| Layer | Guarantees | Does not guarantee |
|---|---|---|
Field options ( |
Single-field rules; |
Rules across fields or rows |
|
Friendly, translatable messages; cross-field logic in Python |
Anything written without calling it (bulk writes, scripts, raw SQL); race conditions between two requests |
|
The rule in the database and in |
Rules that cannot be expressed as a row-local condition or a uniqueness guarantee (use triggers or application logic) |
|
Speed only |
Nothing about validity; an index never rejects a row (use |
The models used on this page live in one file; the sections below show the parts they discuss.
from django.db import models
from django.db.models import Q
from django.db.models.functions import Lower
class Author(models.Model):
name = models.CharField(max_length=200)
class Book(models.Model):
class Status(models.TextChoices):
DRAFT = "draft", "Draft"
PUBLISHED = "published", "Published"
title = models.CharField(max_length=200)
slug = models.SlugField(max_length=200)
isbn = models.CharField(max_length=13, blank=True, null=True)
author = models.ForeignKey(Author, on_delete=models.CASCADE, related_name="books")
status = models.CharField(max_length=10, choices=Status, default=Status.DRAFT)
price = models.DecimalField(max_digits=8, decimal_places=2)
pages = models.PositiveIntegerField()
published_on = models.DateField(null=True, blank=True)
is_featured = models.BooleanField(default=False)
Source: Models.
Indexes
An index is a separate data structure, kept in step with the table by the database, that lets it find rows without reading the whole table. Django creates some indexes for you: the primary key, every unique=True field, and a B-tree index for every ForeignKey (db_index=True is the default on relations). Everything else you declare in Meta.indexes with models.Index (the classes live in django.db.models.indexes and are re-exported from django.db.models).
Meta.indexes and the Index options
class Book(models.Model):
...
class Meta:
indexes = [
# Multi-column B-tree; "-" means DESC for that column.
models.Index(fields=["author", "-published_on"], name="book_author_pub_idx"),
]
Source: Model index reference.
Index(*expressions, fields=(), name=None, db_tablespace=None, opclasses=(), condition=None, include=None) takes the following arguments.
| Argument | Meaning |
|---|---|
|
List or tuple of field names. Columns are ascending by default; prefix a name with |
|
Creates a functional index on expressions and database functions. |
|
The index name. If omitted, Django generates one. Names cannot be longer than 30 characters and must not start with a digit or an underscore. Always set an explicit name for anything you may later rename or reference (it is required with |
|
A |
|
Fields stored as non-key columns, making a covering index (PostgreSQL only; ignored elsewhere). |
|
PostgreSQL operator classes, one per field (for example |
|
The tablespace for this index. For a single-field index it defaults to the field’s |
On most databases an index name must be unique within the schema, not just within its table, so two indexes with the same name collide. That has a consequence for abstract base classes: because Meta.indexes is inherited with identical attributes, a fixed name would be duplicated in every subclass. Put %(app_label)s and %(class)s in the name and Django substitutes the lowercased app label and class name of the concrete model.
class Timestamped(models.Model):
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
abstract = True
indexes = [
models.Index(fields=["created_at"], name="%(app_label)s_%(class)s_created_idx")
]
class Shelf(Timestamped):
name = models.CharField(max_length=100)
The test below passed on Django 6.1.1 and confirms the expansion (catalog_shelf_created_idx); the same placeholders work in constraint names.
class AbstractMetaTests(TestCase):
def test_names_are_expanded_per_concrete_model(self):
self.assertEqual(
[i.name for i in Shelf._meta.indexes], ["catalog_shelf_created_idx"]
)
Source: Index.name.
Functional indexes
A functional index indexes the result of an expression instead of a raw column. The classic use is a case-insensitive lookup: without it, Lower("title") == "dune" cannot use a plain index on title.
class Book(models.Model):
...
class Meta:
indexes = [
models.Index(Lower("title"), name="book_title_lower_idx"),
# Mixed: an expression and a column, with the expression descending.
# models.Index(Lower("title").desc(), "published_on", name="lower_title_date_idx"),
]
from django.db.models.functions import Lower
print(Book.objects.annotate(l=Lower("title")).filter(l="dune").explain())
SEARCH catalog_book USING INDEX book_title_lower_idx (<expr>=?)
The restrictions come from the database. PostgreSQL requires every function and operator in an index to be IMMUTABLE, so functions such as Concat() are rejected (Django does not validate this; PostgreSQL errors). Oracle requires functions to be DETERMINISTIC (Random() is rejected). MariaDB does not support functional indexes and ignores them. Functional indexes are available since Django 3.2.
Source: Index.expressions.
Partial indexes
If a table is large and queries mostly target a subset of rows, index only that subset. The index is smaller, cheaper to maintain and faster to scan. The catalogue pages only ever show published books, so an index over published_on for published rows is enough:
class Book(models.Model):
...
class Meta:
indexes = [
models.Index(
fields=["published_on"],
name="book_published_idx",
condition=Q(status="published"),
),
]
CREATE INDEX "book_published_idx" ON "catalog_book" ("published_on") WHERE "status" = 'published';
A partial index is only used when the query’s own filter implies the index condition. The two plans below, taken from SQLite, show the same published_on filter with and without the status predicate:
print(Book.objects.filter(status="published", published_on__gte="2020-01-01").explain())
print(Book.objects.filter(status="draft", published_on__gte="2020-01-01").explain())
SEARCH catalog_book USING INDEX book_published_idx (published_on>?)
SCAN catalog_book
Backend notes. PostgreSQL requires functions in the condition to be IMMUTABLE, so date functions and Concat are not accepted, and comparing a DateTimeField with a datetime may need an explicit tzinfo to avoid a mutable cast. SQLite has its own restrictions on partial index conditions. Oracle has no partial indexes (emulate them with functional indexes and Case). MySQL and MariaDB ignore condition. Partial indexes were added in Django 2.2.
Source: Index.condition.
Covering indexes
A covering index stores extra non-key columns with each entry, so a query that filters on the key columns and selects only the included ones can be answered from the index alone (an index-only scan), without touching the table. include is smaller than a wider multi-column index, with the drawback that included columns cannot be used for filtering or sorting.
class Book(models.Model):
...
class Meta:
indexes = [
models.Index(fields=["slug"], name="book_slug_cover_idx", include=["title"]),
]
On PostgreSQL this serves Book.objects.filter(slug="dune").values_list("title", flat=True) from the index. On other backends Django ignores include: SQLite created a plain index on slug in the project and check reported warning models.W040 ("SQLite does not support indexes with non-key columns"), which you can silence once you accept that. Covering indexes and UniqueConstraint.include were added in Django 3.2.
Source: Index.include.
Operator classes, tablespaces and PostgreSQL index types
opclasses selects PostgreSQL operator classes (for example GinIndex(name="json_index", fields=["jsonfield"], opclasses=["jsonb_path_ops"])) and db_tablespace places an index in a specific tablespace. Beyond B-tree, PostgreSQL also supports GIN, GiST, BRIN, Hash, SP-GiST and Bloom indexes through django.contrib.postgres.indexes, covered in PostgreSQL & Database Backends. On a busy PostgreSQL table, create indexes without blocking writes with AddIndexConcurrently and RemoveIndexConcurrently from django.contrib.postgres.operations in a non-atomic migration (see Migrations).
Source: Index.opclasses and Index.db_tablespace.
The SQL Django generates
Always read the SQL of a migration before it runs. For the model above, python manage.py sqlmigrate catalog 0001 produces (excerpt, SQLite):
CREATE INDEX "book_author_pub_idx" ON "catalog_book" ("author_id", "published_on" DESC);
CREATE INDEX "book_title_lower_idx" ON "catalog_book" ((LOWER("title")));
CREATE INDEX "book_published_idx" ON "catalog_book" ("published_on") WHERE "status" = 'published';
CREATE INDEX "book_slug_cover_idx" ON "catalog_book" ("slug");
makemigrations renders each declaration as migrations.AddIndex(model_name="book", index=models.Index(…)). RemoveIndex and RenameIndex are the other index operations. To check that an index is used, run QuerySet.explain() (as above) or EXPLAIN ANALYZE on PostgreSQL and read the plan; see Query Optimization & Fetch Modes.
Source: sqlmigrate and QuerySet.explain().
Choosing indexes
-
Index what queries filter, join and sort on; the foreign keys already have indexes.
-
Column order matters in a multi-column index: it serves queries on a leftmost prefix (
author, orauthorandpublished_on) but not onpublished_onalone. -
Every index costs disk space and slows
INSERT,UPDATEandDELETE; drop indexes no query uses. -
Prefer a partial index when queries always include the same predicate, and a functional index when queries always apply the same function.
-
Index behaviour is database-specific; measure with the real database and realistic data, not SQLite.
Constraints
Constraints are declared in Meta.constraints. They live in django.db.models.constraints and are re-exported from django.db.models; the convention is models.CheckConstraint and models.UniqueConstraint. Every constraint takes the keyword arguments of BaseConstraint.
| Argument | Meaning |
|---|---|
|
Required and unique. It names the database object and appears in |
|
The |
|
The message of that |
A subclass implements constraint_sql(), create_sql(), remove_sql() and validate(). Since Django 6.0 constraints also implement check() for system checks, and passing positional arguments to BaseConstraint was removed in 6.0: always pass name as a keyword.
Source: BaseConstraint.
CheckConstraint
CheckConstraint(*, condition, name, violation_error_code=None, violation_error_message=None) creates a database CHECK. condition is a Q object or a boolean expression.
class Book(models.Model):
...
class Meta:
constraints = [
models.CheckConstraint(condition=Q(price__gte=0), name="book_price_gte_0"),
models.CheckConstraint(
condition=Q(pages__gt=0),
name="book_pages_gt_0",
violation_error_code="invalid_pages",
violation_error_message="A book needs at least one page.",
),
# Cross-field rule: published books must have a date.
models.CheckConstraint(
condition=Q(status="draft") | Q(published_on__isnull=False),
name="book_published_has_date",
violation_error_message="Published books need a publication date.",
),
]
CONSTRAINT "book_price_gte_0" CHECK ("price" >= '0')
CONSTRAINT "book_pages_gt_0" CHECK ("pages" > 0)
CONSTRAINT "book_published_has_date" CHECK (("status" = 'draft' OR "published_on" IS NOT NULL))
The database rejects violating writes with IntegrityError:
class BookConstraintTests(TestCase):
@classmethod
def setUpTestData(cls):
cls.author = Author.objects.create(name="Frank Herbert")
def test_database_rejects_negative_price(self):
book = make_book(self.author, price=Decimal("-1"))
with self.assertRaises(IntegrityError), transaction.atomic():
book.save()
The transaction.atomic() block matters in tests and in code that catches the error: after an IntegrityError inside a transaction the transaction is unusable (PostgreSQL aborts it) until it is rolled back to a savepoint. See Transactions, Raw SQL & Multiple Databases.
Points to know:
-
conditionreplaced the oldcheckkeyword.check=was deprecated in Django 5.1 and removed in 6.0; in 6.1CheckConstraint(check=Q(…), name=…)raisesTypeError: CheckConstraint.init() got an unexpected keyword argument 'check'. Rename it tocondition=. -
Order inside a
Qis not necessarily preserved, but the order of theQobjects you combine is. If the database cares about expression order for performance, writeQ(age__gte=18) & Q(expensive_check=condition). -
On Oracle before 23c, a check on a nullable field must allow
NULLexplicitly (Q(agegte=18) | Q(ageisnull=True)) so thatvalidate()agrees with the database. -
In SQL a
CHECKthat evaluates toNULLpasses.Q(published_on__isnull=False)is explicit about nulls; a bare comparison on a nullable column silently letsNULLthrough. -
Boolean expressions, not just
Q, have been accepted since Django 3.1. -
Check constraints on SQLite are added by rebuilding the table (
CREATE TABLE new__…, copy, drop, rename), once per constraint; thesqlmigrateoutput above shows that. Plan large tables accordingly.
Source: CheckConstraint.
UniqueConstraint
UniqueConstraint(*expressions, fields=(), name, condition=None, deferrable=None, include=None, opclasses=(), nulls_distinct=None, violation_error_code=None, violation_error_message=None) creates a uniqueness guarantee. Setting only fields creates a real UNIQUE constraint (ADD CONSTRAINT … UNIQUE); using any of expressions, opclasses, condition or include creates a unique index (CREATE UNIQUE INDEX) instead. The documentation uses "unique constraint" for both.
| Argument | Meaning |
|---|---|
|
The columns that must be unique together: |
|
A functional unique constraint, for example |
|
A |
|
|
|
Non-key columns for a covering unique index (PostgreSQL only; ignored elsewhere). |
|
PostgreSQL operator classes (ignored elsewhere). |
|
Whether rows with |
|
As in |
The Bookshelf rules, from simplest to most specific:
class Book(models.Model):
...
class Meta:
constraints = [
# Plain: a slug is unique per author.
models.UniqueConstraint(fields=["author", "slug"], name="book_author_slug_uniq"),
# Functional: titles are unique per author, ignoring case.
models.UniqueConstraint(
Lower("title"), "author", name="book_author_lower_title_uniq"
),
# Partial: at most one featured book per author.
models.UniqueConstraint(
fields=["author"],
condition=Q(is_featured=True),
name="book_one_featured_per_author",
violation_error_message="This author already has a featured book.",
),
# NULL handling: at most one book without an ISBN (PostgreSQL 15+).
models.UniqueConstraint(
fields=["isbn"], name="book_isbn_uniq", nulls_distinct=False
),
]
CONSTRAINT "book_author_slug_uniq" UNIQUE ("author_id", "slug")
CREATE UNIQUE INDEX "book_author_lower_title_uniq" ON "catalog_book" ((LOWER("title")), "author_id");
CREATE UNIQUE INDEX "book_one_featured_per_author" ON "catalog_book" ("author_id") WHERE "is_featured" = 1;
Note how the first is a true constraint and the next two are unique indexes, exactly as described above. The last one produced no SQL on SQLite: check warned models.W047 ("SQLite does not support UniqueConstraint.nulls_distinct. A constraint won’t be created"). The Python side still honours it, though, as the tests below show.
def test_functional_unique_is_case_insensitive(self):
make_book(self.author).save()
duplicate = make_book(self.author, title="DUNE", slug="dune-2")
with self.assertRaises(IntegrityError), transaction.atomic():
duplicate.save()
with self.assertRaises(ValidationError):
duplicate.validate_constraints()
def test_partial_unique_one_featured_per_author(self):
make_book(self.author, is_featured=True).save()
make_book(self.author, title="Messiah", slug="messiah").save() # not featured
second = make_book(self.author, title="Children", slug="children", is_featured=True)
with self.assertRaisesMessage(
ValidationError, "This author already has a featured book."
):
second.validate_constraints()
def test_nulls_distinct_false_is_checked_in_python(self):
make_book(self.author).save() # isbn is NULL
other = make_book(self.author, title="Other", slug="other") # isbn is NULL too
with self.assertRaisesMessage(ValidationError, "Book with this Isbn already exists."):
other.validate_constraints()
Source: UniqueConstraint.
Variations worth knowing:
from django.db.models import Deferrable, UniqueConstraint
# Checked at COMMIT, so a transaction may swap two values temporarily.
UniqueConstraint(name="unique_order", fields=["order"], deferrable=Deferrable.DEFERRED)
# Covering: index-only scans for filters on room/date that select full_name.
UniqueConstraint(name="unique_booking", fields=["room", "date"], include=["full_name"])
# Operator class on the unique index.
UniqueConstraint(name="unique_username", fields=["username"], opclasses=["varchar_pattern_ops"])
These come from the documentation and need PostgreSQL to take effect.
unique_together and Field.unique versus UniqueConstraint
Meta.unique_together still works and is still used by the admin, but the Django documentation says to use UniqueConstraint instead: it offers more (conditions, expressions, deferral, covering columns, nulls_distinct) and unique_together "may be deprecated in the future". A ManyToManyField cannot appear in unique_together. Use unique=True for a single column, UniqueConstraint(fields=[…]) for several, and UniqueConstraint(…, condition=…) or expressions for anything conditional.
Source: Meta.unique_together.
Changing constraints in migrations
makemigrations emits AddConstraint and RemoveConstraint for constraints, and AddIndex, RemoveIndex and RenameIndex for indexes. Django 5.2 added AlterConstraint, a no-op operation (no SQL) for changes to a constraint that do not affect the database definition, such as a different violation_error_message, so those edits no longer drop and recreate the constraint. Three practical points:
-
Adding a constraint to a table that already holds violating rows fails. Clean the data first (a
RunPythonstep in the same or an earlier migration), then add the constraint. -
On PostgreSQL, add a check constraint to a large table without a long lock in two steps with
AddConstraintNotValid(new rows are checked; existing rows are not) followed byValidateConstraint(4.0), in separate migrations. Both live indjango.contrib.postgres.operations. -
Every index and constraint name must be unique; renaming one needs
RenameIndexor a remove-and-add pair.
See Migrations for zero-downtime patterns and Schema Evolution: Core Concepts for the general approach.
Source: Migration operations.
Constraint validation
Constraints are checked during model validation. Model.full_clean(exclude=None, validate_unique=True, validate_constraints=True) runs clean_fields(), clean(), validate_unique() and then validate_constraints(), in that order, and raises one ValidationError carrying the errors of all four steps. Model.validate_constraints(exclude=None) validates everything in Meta.constraints. A ModelForm calls the same machinery from is_valid(), restricted to the fields on the form, so violations appear as form errors with your violation_error_message.
def test_full_clean_reports_check_constraint(self):
book = make_book(self.author, pages=0)
with self.assertRaises(ValidationError) as ctx:
book.full_clean()
error = ctx.exception.error_dict["__all__"][0]
self.assertEqual(error.code, "invalid_pages")
self.assertEqual(error.message, "A book needs at least one page.")
def test_published_book_needs_a_date(self):
book = make_book(self.author, status=Book.Status.PUBLISHED)
with self.assertRaisesMessage(
ValidationError, "Published books need a publication date."
):
book.full_clean()
book.published_on = date(1965, 8, 1)
book.full_clean()
class BookForm(forms.ModelForm):
class Meta:
model = Book
fields = ["title", "slug", "author", "status", "price", "pages", "published_on"]
form = BookForm(data={"title": "Dune", "slug": "dune", "author": author.pk,
"status": "published", "price": "9.99", "pages": "0"})
assert not form.is_valid()
assert form.non_field_errors() == [
"A book needs at least one page.",
"Published books need a publication date.",
]
Rules to remember:
-
The exclude argument. If a field a constraint needs is in
exclude, the constraint is skipped. That is why aModelFormthat omits a field does not validate constraints touching it; the database still enforces them.book.validate_constraints(exclude={"pages"})does not raise for thepagescheck, whilebook.validate_constraints()does. -
save()does not validate.create(),save()andbulk_create()go straight to the database, which raisesIntegrityError. Catch that where concurrency can cause it (see below). -
Validation is a pre-check, not a lock.
validate_constraints()runs a query to look for a duplicate; two requests can both pass it and one will fail at the database. Treat theIntegrityErroras the source of truth: either let it propagate, or catch it and return the same error to the user. -
Which message you get. Since Django 5.2
UniqueConstraint.violation_error_codeandviolation_error_messageare always used when provided; before 5.2 they were ignored whenfieldswas set without acondition. -
Expressions in checks. Constraint validation supports a limited set of expressions; Django 5.1 added
Expression.constraint_validation_compatible, which lets an expression declare that it should be ignored during constraint validation. Django 5.2 added validation support for constraints that reference aGeneratedField. -
Async.
full_clean()andvalidate_constraints()are synchronous and query the database; from async code call them throughsync_to_async(), or skip them and rely on theIntegrityErrorfromawait book.asave().
from django.core.exceptions import ValidationError
from django.db import IntegrityError, transaction
def publish(book):
try:
with transaction.atomic():
book.save()
except IntegrityError:
raise ValidationError("This author already has a featured book.") from None
Wrap the write in atomic() so the failed statement rolls back to a savepoint (and the surrounding transaction stays usable), then translate the error. Do not parse database error messages to find out which constraint failed in code you intend to keep portable.
Source: Validating objects and BaseConstraint.validate().
All of the tests above ran together:
python manage.py test catalog
Creating test database for alias 'default'...
.................
----------------------------------------------------------------------
Ran 17 tests in 0.015s
OK
Destroying test database for alias 'default'...
Composite primary keys
Django 5.2 added CompositePrimaryKey. Until then every model had a single-column key, and a table keyed on several columns needed a surrogate id plus a unique constraint. The usual advice still holds for new schemas: a surrogate id with a UniqueConstraint is the best-supported design, because relations, the admin and most third-party packages assume one key column. Reach for a composite key when you map a table that already has one, or when the pair really is the identity of the row (translations, stock per warehouse, join tables with attributes).
Declaring one
Set the pk attribute to a CompositePrimaryKey naming the fields. The field names go in as strings; for foreign keys use the _id attribute names.
class BookTranslation(models.Model):
pk = models.CompositePrimaryKey("book_id", "language")
book = models.ForeignKey(Book, on_delete=models.CASCADE, related_name="translations")
language = models.CharField(max_length=8)
title = models.CharField(max_length=200)
CREATE TABLE "catalog_booktranslation" (
"language" varchar(8) NOT NULL,
"title" varchar(200) NOT NULL,
"book_id" bigint NOT NULL REFERENCES "catalog_book" ("id") DEFERRABLE INITIALLY DEFERRED,
PRIMARY KEY ("book_id", "language")
);
There is no id column. A composite primary key is a virtual field: it does not map to one column, is excluded from ModelForm`s and cannot be added to or removed from an existing key by a migration. The migration Django writes records it as `models.CompositePrimaryKey('book_id', 'language', blank=True, editable=False, primary_key=True, serialize=False).
Source: Composite primary keys.
Working with tuple keys
The primary key value is a tuple. Assign a tuple to pk to set the underlying fields, and filter or fetch by tuple.
class CompositeKeyTests(TestCase):
@classmethod
def setUpTestData(cls):
author = Author.objects.create(name="Frank Herbert")
cls.book = make_book(author)
cls.book.save()
def test_tuple_pk(self):
t = BookTranslation.objects.create(book=self.book, language="es", title="Dune")
self.assertEqual(t.pk, (self.book.pk, "es"))
self.assertEqual(BookTranslation.objects.get(pk=(self.book.pk, "es")), t)
self.assertEqual(BookTranslation.objects.filter(pk=(self.book.pk, "es")).count(), 1)
def test_assign_tuple(self):
t = BookTranslation(pk=(7, "fr"))
self.assertEqual((t.book_id, t.language), (7, "fr"))
def test_duplicate_pair_rejected(self):
BookTranslation.objects.create(book=self.book, language="es", title="Dune")
with self.assertRaises(IntegrityError), transaction.atomic():
BookTranslation.objects.create(book=self.book, language="es", title="Duna")
Source: Composite primary keys.
Finding the key fields in generic code
Field.primary_key is True for at most one field per model, so on a composite-key model no field has it. Code that introspects the key must use Model._meta.pk_fields, which returns the key fields as a list (one element for an ordinary model).
def test_pk_fields(self):
names = [f.name for f in BookTranslation._meta.pk_fields]
self.assertEqual(names, ["book", "language"])
self.assertEqual([f.name for f in Book._meta.pk_fields], ["id"])
Limitations in Django 6.1
| Area | Behaviour |
|---|---|
|
Not supported. System check |
Django admin |
Models with a composite key cannot be registered: |
|
The |
Database functions |
Most functions take one expression. |
Model validation |
|
Changing a key value |
As with any primary key, changing the value on a saved object and calling |
Migrations |
Migrating to or from a composite key, or adding and removing key fields, is not supported after the table is created. |
def test_max_of_pk_raises_count_works(self):
BookTranslation.objects.create(book=self.book, language="es", title="Dune")
with self.assertRaises(ValueError):
BookTranslation.objects.aggregate(Max("pk"))
self.assertEqual(BookTranslation.objects.aggregate(n=Count("pk"))["n"], 1)
Source: Composite primary keys and database functions, Composite primary keys in forms and Composite primary keys in model validation
Django 6.0 added the ability to use subqueries that return a CompositePrimaryKey.
Referencing a composite-key model with ForeignObject
A ForeignKey cannot point at such a model. ForeignObject can: it describes a multi-column relation, but creates no columns, no database foreign key constraint and no index, and its on_delete is ignored. You declare the local columns yourself and, if you want integrity, add a constraint or index by hand.
class TranslationNote(models.Model):
translation_book_id = models.BigIntegerField()
translation_language = models.CharField(max_length=8)
translation = models.ForeignObject(
BookTranslation,
on_delete=models.CASCADE,
from_fields=("translation_book_id", "translation_language"),
to_fields=("book_id", "language"),
related_name="notes",
)
text = models.TextField()
class ForeignObjectTests(TestCase):
def test_foreign_object_workaround(self):
author = Author.objects.create(name="Frank Herbert")
book = make_book(author)
book.save()
t = BookTranslation.objects.create(book=book, language="es", title="Dune")
TranslationNote.objects.create(
translation_book_id=book.pk, translation_language="es", text="Revised"
)
note = TranslationNote.objects.get()
self.assertEqual(note.translation, t)
self.assertEqual(list(t.notes.all()), [note])
The documentation example names the targets with the _id attribute names of the key fields, as above. Because Django does not create a foreign key constraint, deleting a translation does not cascade to its notes at the database level, and the referencing columns are not indexed unless you add an index.
Source: Composite primary keys and relations.
Mapping an existing table with a composite key
Django cannot migrate a table onto or off a composite key, so for a legacy table do the database change with the backend’s own tools (or a RunSQL), then tell Django about it. SeparateDatabaseAndState runs the backend-specific SQL and Django’s description of the result in a single operation.
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [("legacy", "0002_index_together_to_indexes")]
operations = [
migrations.SeparateDatabaseAndState(
database_operations=[
# Backend-specific SQL: the composite key is created (or changed)
# with the database's own tooling.
migrations.RunSQL(
sql="""
CREATE TABLE legacy_stock (
sku varchar(20) NOT NULL,
warehouse varchar(20) NOT NULL,
quantity integer NOT NULL DEFAULT 0,
PRIMARY KEY (sku, warehouse)
)
""",
reverse_sql="DROP TABLE legacy_stock",
),
],
state_operations=[
# What Django believes about the table.
migrations.CreateModel(
name="Stock",
fields=[
("pk", models.CompositePrimaryKey(
"sku", "warehouse",
blank=True, editable=False, primary_key=True, serialize=False,
)),
("sku", models.CharField(max_length=20)),
("warehouse", models.CharField(max_length=20)),
("quantity", models.PositiveIntegerField(default=0)),
],
options={"db_table": "legacy_stock"},
),
],
),
]
class Stock(models.Model):
"""Maps a table that already has PRIMARY KEY (sku, warehouse)."""
pk = models.CompositePrimaryKey("sku", "warehouse")
sku = models.CharField(max_length=20)
warehouse = models.CharField(max_length=20)
quantity = models.PositiveIntegerField(default=0)
class Meta:
db_table = "legacy_stock"
After python manage.py migrate legacy, makemigrations --check reported no changes and Stock.objects.get(pk=("DUNE-HC", "MAD")) returned the row. For a table that already exists in production you would keep only the state_operations, or use migrate --fake, as the documentation suggests; the RunSQL above stands in for the existing table so that the example runs. See Migrations for SeparateDatabaseAndState, and Transactions, Raw SQL & Multiple Databases for inspectdb, which generates models (including key fields) from a legacy schema.
Source: Migrating to a composite primary key.
Migrating from index_together
Meta.index_together declared multi-column indexes. It was deprecated in Django 4.2 in favour of Meta.indexes and removed in Django 5.1. In 6.1 a model that still declares it fails at import time:
TypeError: 'class Meta' got invalid attribute(s): index_together
Convert each tuple to a models.Index with the same fields in the same order, and give it an explicit name:
class Edition(models.Model):
rank = models.IntegerField()
name = models.CharField(max_length=100)
class Meta:
# Before:
# index_together = [["rank", "name"]]
# After:
indexes = [models.Index(fields=["rank", "name"], name="edition_rank_name_idx")]
Then let makemigrations handle the rename. The old index already exists in the database with an auto-generated name; Django 4.2 and later detect that the same columns are indexed and write a RenameIndex that matches the old index by its fields instead of dropping and re-creating it (a rebuild of a large index can take a long time and, on some databases, lock the table). With a project whose first migration still carried index_together in its model options, makemigrations printed:
Migrations for 'legacy':
legacy/migrations/0002_index_together_to_indexes.py
~ Rename unnamed index for ('rank', 'name') on edition to edition_rank_name_idx
operations = [
migrations.RenameIndex(
model_name="edition",
new_name="edition_rank_name_idx",
old_fields=("rank", "name"),
),
]
DROP INDEX "legacy_edition_rank_name_c5765841_idx";
CREATE INDEX "edition_rank_name_idx" ON "legacy_edition" ("rank", "name");
On SQLite there is no ALTER INDEX … RENAME, so RenameIndex is a drop and create there; PostgreSQL renames in place (ALTER INDEX … RENAME TO …). A second makemigrations --check reported "No changes detected".
The upgrade path across versions:
-
On Django 4.2 or 5.0 (where
index_togetherstill exists but warns), convert toMeta.indexesand runmakemigrations; commit theRenameIndexmigration. -
Optionally squash migrations to remove
AlterIndexTogetherfrom history (squashmigrations). TheAlterIndexTogetheroperation is kept in 6.1 so that old migrations keep running, and a historical migration withindex_togetherin its options applied cleanly in the project above. -
Upgrade to Django 5.1 or later.
If you jumped from an older version straight to 5.1 or later, convert the model first; the autodetector still writes the RenameIndex against the migration state that carries the old option. If it cannot (for instance the index was never named by Django), write the RenameIndex or RemoveIndex plus AddIndex by hand and check the result with sqlmigrate.
unique_together is a different case: it is not removed in 6.1 (see above), but new code should use UniqueConstraint. To convert it, replace the option with a UniqueConstraint of the same fields and a name, run makemigrations, and read the generated SQL with sqlmigrate to see whether the existing unique index is dropped and re-created, which matters for large tables.
Where the books are outdated
The reference books for this section were written for Django 1.11 to 3.x, so they predate most of this page. In particular:
-
They predate
Meta.constraints(Django 2.2), partial indexes andIndex.opclasses(2.2), functional indexes and covering indexes (3.2), functional unique constraints (4.0) andvalidate_constraints()(4.1); uniqueness across fields is taught withunique_togetherand multi-column indexes withindex_together, the latter of which no longer exists. -
They predate composite primary keys (5.2), so they present a surrogate
idas the only option and recommend unique-together workarounds that are still valid but are no longer the only way. -
Examples that write
CheckConstraint(check=…)(the keyword was renamed in 5.1 and removed in 6.0) fail on Django 6.1; usecondition=. -
Advice about enforcing rules only in
clean()orsave()overrides ignores that bulk operations and other writers bypass both; declare the rule as a constraint.
What changed recently
| Version | Change |
|---|---|
2.2 |
|
3.1 |
|
3.2 |
Functional indexes ( |
4.0 |
Functional unique constraints ( |
4.1 |
Constraints are validated in Python: |
4.2 |
|
5.0 |
|
5.1 |
|
5.2 |
|
6.0 |
|
6.1 |
No change to the APIs on this page. |
The rows are taken from the release notes of each version.
Pitfalls
-
Forgetting
name, or reusing a name: every constraint and index needs a unique name; use the%(app_label)s_%(class)splaceholders in abstract bases. -
Assuming
save()validates: it does not; only forms and an explicitfull_clean()do. -
Treating
validate_constraints()as race-proof: only the database is. -
Writing constraints that depend on features your database lacks and not noticing:
checkprintsmodels.W040andmodels.W047on SQLite, and covering indexes,nulls_distinct, deferrable unique constraints and partial-index options are silently ignored on some backends. Test on the production database engine (see PostgreSQL & Database Backends). -
Adding a constraint to a populated table without checking existing data first.
-
Putting a function in an index or constraint that the database considers mutable (
Concat()on PostgreSQL). -
Building a partial index and querying without its predicate: the planner will not use it.
-
Using composite keys with
ForeignKey, the admin or generic relations: unsupported in 6.1. -
Using
index_togetherin code or in blog posts you copy from: it is gone.