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

flowchart LR Form["ModelForm.is_valid()
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.

Table 1. What each layer guarantees
Layer Guarantees Does not guarantee

Field options (unique=True, validators)

Single-field rules; unique=True is also a database constraint

Rules across fields or rows

clean() / form validation

Friendly, translatable messages; cross-field logic in Python

Anything written without calling it (bulk writes, scripts, raw SQL); race conditions between two requests

Meta.constraints

The rule in the database and in validate_constraints()

Rules that cannot be expressed as a row-local condition or a uniqueness guarantee (use triggers or application logic)

Meta.indexes

Speed only

Nothing about validity; an index never rejects a row (use UniqueConstraint for that)

The models used on this page live in one file; the sections below show the parts they discuss.

catalog/models.py (the parts used on this page)
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

Index options at a glance
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"),
        ]

Index(*expressions, fields=(), name=None, db_tablespace=None, opclasses=(), condition=None, include=None) takes the following arguments.

Table 2. Arguments of models.Index
Argument Meaning

fields

List or tuple of field names. Columns are ascending by default; prefix a name with - for descending (["headline", "-pub_date"] produces (headline, pub_date DESC)).

*expressions (positional)

Creates a functional index on expressions and database functions. name is required.

name

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 expressions, condition, include and opclasses).

condition

A Q object that makes the index partial: only rows matching the condition are indexed. name is required.

include

Fields stored as non-key columns, making a covering index (PostgreSQL only; ignored elsewhere). name is required.

opclasses

PostgreSQL operator classes, one per field (for example jsonb_path_ops, varchar_pattern_ops). Ignored on other databases. name is required.

db_tablespace

The tablespace for this index. For a single-field index it defaults to the field’s db_tablespace; otherwise to the model’s Meta.db_tablespace; otherwise to the table’s tablespace.

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.

catalog/models.py: names in an abstract base class
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.

catalog/tests.py
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.

catalog/models.py
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"),
        ]
catalog/tests.py: the query uses the functional index
from django.db.models.functions import Lower

print(Book.objects.annotate(l=Lower("title")).filter(l="dune").explain())
Output (SQLite plan detail)
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.

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:

catalog/models.py
class Book(models.Model):
    ...

    class Meta:
        indexes = [
            models.Index(
                fields=["published_on"],
                name="book_published_idx",
                condition=Q(status="published"),
            ),
        ]
Generated SQL (manage.py sqlmigrate)
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())
Output (SQLite plan detail)
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.

catalog/models.py (PostgreSQL)
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).

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.

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, or author and published_on) but not on published_on alone.

  • Every index costs disk space and slows INSERT, UPDATE and DELETE; 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.

Table 3. Arguments shared by all constraints (BaseConstraint)
Argument Meaning

name

Required and unique. It names the database object and appears in IntegrityError messages. In an abstract base class use %(app_label)s and %(class)s placeholders, for example name="%(app_label)s_%(class)s_is_adult".

violation_error_code

The code of the ValidationError raised by model validation. Default None.

violation_error_message

The message of that ValidationError. Default Constraint ā€œ%(name)sā€ is violated.

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.

catalog/models.py
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.",
            ),
        ]
Generated SQL (SQLite table-rebuild form, abbreviated)
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:

catalog/tests.py
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:

  • condition replaced the old check keyword. check= was deprecated in Django 5.1 and removed in 6.0; in 6.1 CheckConstraint(check=Q(…​), name=…​) raises TypeError: CheckConstraint.init() got an unexpected keyword argument 'check'. Rename it to condition=.

  • Order inside a Q is not necessarily preserved, but the order of the Q objects you combine is. If the database cares about expression order for performance, write Q(age__gte=18) & Q(expensive_check=condition).

  • On Oracle before 23c, a check on a nullable field must allow NULL explicitly (Q(agegte=18) | Q(ageisnull=True)) so that validate() agrees with the database.

  • In SQL a CHECK that evaluates to NULL passes. Q(published_on__isnull=False) is explicit about nulls; a bare comparison on a nullable column silently lets NULL through.

  • 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; the sqlmigrate output 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.

Table 4. Arguments of UniqueConstraint
Argument Meaning

fields

The columns that must be unique together: UniqueConstraint(fields=["room", "date"], name="unique_booking").

*expressions

A functional unique constraint, for example UniqueConstraint(Lower("name").desc(), "category", name="unique_lower_name_category"). Same database restrictions as Index.expressions.

condition

A Q that makes the uniqueness partial: UniqueConstraint(fields=["user"], condition=Q(status="DRAFT"), name="unique_draft_user"). Same database restrictions as Index.condition.

deferrable

Deferrable.DEFERRED (checked at the end of the transaction) or Deferrable.IMMEDIATE (checked after every statement). Ignored on MySQL, MariaDB and SQLite; cannot be combined with condition, include, opclasses or expressions, which create indexes. Deferred constraints can cost performance.

include

Non-key columns for a covering unique index (PostgreSQL only; ignored elsewhere).

opclasses

PostgreSQL operator classes (ignored elsewhere).

nulls_distinct

Whether rows with NULL in the covered columns count as distinct from each other. None (default) uses the database default, which is distinct on most backends; False allows only one NULL. Database-level support is PostgreSQL only.

violation_error_code, violation_error_message

As in BaseConstraint. With fields and no condition the defaults are the unique_together message and code for several fields, or the Field.unique ones for a single field.

The Bookshelf rules, from simplest to most specific:

catalog/models.py
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
            ),
        ]
Generated SQL (SQLite)
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.

catalog/tests.py
    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:

Deferrable and covering unique constraints (PostgreSQL)
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.

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 RunPython step 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 by ValidateConstraint (4.0), in separate migrations. Both live in django.contrib.postgres.operations.

  • Every index and constraint name must be unique; renaming one needs RenameIndex or a remove-and-add pair.

See Migrations for zero-downtime patterns and Schema Evolution: Core Concepts for the general approach.

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.

catalog/tests.py: validation errors carry your code and 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()
catalog/forms.py and tests: a ModelForm reports constraint violations as non-field errors
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 a ModelForm that omits a field does not validate constraints touching it; the database still enforces them. book.validate_constraints(exclude={"pages"}) does not raise for the pages check, while book.validate_constraints() does.

  • save() does not validate. create(), save() and bulk_create() go straight to the database, which raises IntegrityError. 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 the IntegrityError as 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_code and violation_error_message are always used when provided; before 5.2 they were ignored when fields was set without a condition.

  • 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 a GeneratedField.

  • Async. full_clean() and validate_constraints() are synchronous and query the database; from async code call them through sync_to_async(), or skip them and rely on the IntegrityError from await book.asave().

Turning a race into a normal validation error
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.

All of the tests above ran together:

python manage.py test catalog
Output
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.

catalog/models.py
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)
Generated SQL (SQLite)
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).

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.

catalog/tests.py
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")

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).

catalog/tests.py
    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

Table 5. What does not work with composite primary keys
Area Behaviour

ForeignKey, OneToOneField, ManyToManyField to the model

Not supported. System check fields.E347 reports "Field defines a relation involving model 'BookTranslation' which has a CompositePrimaryKey and such relations are not supported." Generic relations are not supported either. Use ForeignObject (below).

Django admin

Models with a composite key cannot be registered: admin.site.register(BookTranslation) raises ImproperlyConfigured: The model BookTranslation has a composite primary key, so it cannot be registered with admin.

ModelForm

The pk field is excluded, so the form has book, language and title. Listing pk in fields raises FieldError: Unknown field(s) (pk) specified. Set editable=False on the key fields to keep them off forms.

Database functions

Most functions take one expression. Max("pk") raises ValueError; Count("pk") is the exception and works.

Model validation

pk is not a real field, so exclude={"pk"} in clean_fields() has no effect; exclude each key field by name. validate_unique(exclude={"pk"}) does skip the key check.

Changing a key value

As with any primary key, changing the value on a saved object and calling save() inserts a new row beside the old one.

Migrations

Migrating to or from a composite key, or adding and removing key fields, is not supported after the table is created.

catalog/tests.py
    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)

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.

catalog/models.py
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()
catalog/tests.py
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.

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.

legacy/migrations/0003_stock.py
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"},
                ),
            ],
        ),
    ]
legacy/models.py
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.

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:

Output
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:

Before (Django 4.1 and earlier) and after
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:

Output
Migrations for 'legacy':
  legacy/migrations/0002_index_together_to_indexes.py
    ~ Rename unnamed index for ('rank', 'name') on edition to edition_rank_name_idx
legacy/migrations/0002_index_together_to_indexes.py
operations = [
    migrations.RenameIndex(
        model_name="edition",
        new_name="edition_rank_name_idx",
        old_fields=("rank", "name"),
    ),
]
python manage.py sqlmigrate legacy 0002 (SQLite)
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:

  1. On Django 4.2 or 5.0 (where index_together still exists but warns), convert to Meta.indexes and run makemigrations; commit the RenameIndex migration.

  2. Optionally squash migrations to remove AlterIndexTogether from history (squashmigrations). The AlterIndexTogether operation is kept in 6.1 so that old migrations keep running, and a historical migration with index_together in its options applied cleanly in the project above.

  3. 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 and Index.opclasses (2.2), functional indexes and covering indexes (3.2), functional unique constraints (4.0) and validate_constraints() (4.1); uniqueness across fields is taught with unique_together and multi-column indexes with index_together, the latter of which no longer exists.

  • They predate composite primary keys (5.2), so they present a surrogate id as 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; use condition=.

  • Advice about enforcing rules only in clean() or save() overrides ignores that bulk operations and other writers bypass both; declare the rule as a constraint.

What changed recently

Table 6. Constraints, indexes and composite keys by version
Version Change

2.2

CheckConstraint and UniqueConstraint, Meta.constraints, partial indexes (Index.condition), operator classes (Index.opclasses).

3.1

UniqueConstraint.deferrable; boolean expressions allowed in CheckConstraint.

3.2

Functional indexes (Index.expressions), covering indexes and covering unique constraints (include), UniqueConstraint.opclasses.

4.0

Functional unique constraints (UniqueConstraint.expressions); AddConstraintNotValid and ValidateConstraint for PostgreSQL.

4.1

Constraints are validated in Python: BaseConstraint.validate(), Model.validate_constraints(), violation_error_message; RenameIndex used for index renames.

4.2

index_together deprecated in favour of Meta.indexes.

5.0

violation_error_code, UniqueConstraint.nulls_distinct.

5.1

index_together removed; CheckConstraint(check=) deprecated in favour of condition=; Expression.constraint_validation_compatible.

5.2

CompositePrimaryKey; AlterConstraint; validation of constraints that use GeneratedField; UniqueConstraint.violation_error_code and violation_error_message always used when set.

6.0

CheckConstraint(check=) removed; positional arguments to BaseConstraint removed; constraints implement check(); subqueries returning a composite key.

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)s placeholders in abstract bases.

  • Assuming save() validates: it does not; only forms and an explicit full_clean() do.

  • Treating validate_constraints() as race-proof: only the database is.

  • Writing constraints that depend on features your database lacks and not noticing: check prints models.W040 and models.W047 on 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_together in code or in blog posts you copy from: it is gone.