Creating partial and expression indexes from SQLAlchemy models

Declare partial indexes with Index("ix_jobs_pending", "run_after", postgresql_where=text("status = 'pending'")) and expression indexes with Index("ix_customers_email_lower", func.lower(Customer.email)) in the model — so autogenerate keeps them, and the migration only has to decide whether to build them concurrently. This guide belongs to managing enums, constraints and indexes in migrations.

Quick Answer

Two index shapes cover most of the cases a plain column index handles badly: a predicate that restricts which rows are stored, and an expression that stores a computed value.

Index the rows you query Left: an index over every row of a large table, most of which no query ever looks for — a status column where ninety-nine percent of rows are complete and every query asks for pending. Right: a partial index with a WHERE clause covering only the pending rows, which is a fraction of the size, faster to scan, and cheaper to maintain because completed rows are not in it. CREATE INDEX ON jobs (status) every row indexed large, and mostly unread every write updates it the planner may ignore it anyway CREATE INDEX ... WHERE status = 'pending' only the rows queries want a fraction of the size writes to other rows skip it and it matches the query exactly A partial index is smaller, faster and cheaper to write — provided the predicate matches the query.

Before — full indexes that queries cannot use or do not need:

import datetime as dt

from sqlalchemy import Index, func, select
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column


class Base(DeclarativeBase):
    pass


class Job(Base):
    __tablename__ = "jobs"
    id: Mapped[int] = mapped_column(primary_key=True)
    status: Mapped[str]
    run_after: Mapped[dt.datetime]
    __table_args__ = (Index("ix_jobs_status", "status"),)   # 99% of rows are 'complete'


# And a case-insensitive lookup that cannot use an index at all:
select(Customer).where(func.lower(Customer.email) == email.lower())   # Seq Scan

After — a partial index and an expression index:

import datetime as dt

from sqlalchemy import Index, func, text
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column


class Base(DeclarativeBase):
    pass


class Job(Base):
    __tablename__ = "jobs"

    id: Mapped[int] = mapped_column(primary_key=True)
    status: Mapped[str]
    run_after: Mapped[dt.datetime]

    __table_args__ = (
        # Only the rows a worker ever looks for.
        Index(
            "ix_jobs_pending_run_after",
            "run_after",
            postgresql_where=text("status = 'pending'"),
        ),
    )


class Customer(Base):
    __tablename__ = "customers"

    id: Mapped[int] = mapped_column(primary_key=True)
    email: Mapped[str]
    deleted_at: Mapped[dt.datetime | None] = mapped_column(default=None)


# Declared after the class, because the expression needs the mapped attribute.
Index("ix_customers_email_lower", func.lower(Customer.email))

# Unique among live rows only.
Index(
    "uq_customers_email_live",
    Customer.email,
    unique=True,
    postgresql_where=Customer.deleted_at.is_(None),
)

The partial index holds a few thousand rows instead of millions, and the expression index is what turns the case-insensitive lookup from a sequential scan into an index scan.

Execution Context & Async Workflow Integration

A partial index stores only rows satisfying its predicate. That makes it smaller — often by orders of magnitude for a status column with a skewed distribution — faster to scan, and cheaper to maintain, because an update to a row outside the predicate does not touch the index at all.

When the planner can use it Four steps. The query arrives with a WHERE clause. For a partial index, the planner must prove the query predicate implies the index predicate — a query for status equals pending can use an index restricted to pending, and a query with no status filter cannot. For an expression index, the query expression must match the indexed expression exactly, textually equivalent after parsing. If either check fails, the index is ignored and the plan falls back to a scan. the query WHERE clause status = 'pending' plus other predicates partial: does the query imply the index predicate? pending implies pending a missing filter does not expression: does the expression match? lower(email) = lower($1) exactly, not equivalently match → index scan no match → sequential scan "Equivalent" is not enough: lower(email) and upper(email) are different expressions to the planner.

The condition for using it is that the planner must be able to prove the query's predicate implies the index's. WHERE status = 'pending' AND run_after <= now() can use an index restricted to status = 'pending'. WHERE run_after <= now() alone cannot, even if every matching row happens to be pending. That is why a partial index and the query it serves have to be designed together: the predicate is part of the contract.

An expression index stores the result of a function or expression, and the matching rule is stricter still — the query's expression must be textually equivalent to the indexed one after parsing. lower(email) matches lower(email); it does not match upper(email), lower(email || ''), or a query that applies lower() to the parameter instead of the column. The expression must also be IMMUTABLE: lower() qualifies, now() does not, and a function of your own needs to be declared immutable to be indexable.

In SQLAlchemy, both are declared in the model so that autogenerate compares them. postgresql_where takes a SQL expression — either a text() fragment or a SQLAlchemy expression such as Customer.deleted_at.is_(None) — and expression indexes take the expression directly, which means they have to be declared after the class, because mapped_column() inside the class body is a placeholder rather than a usable attribute.

Two SQLAlchemy-specific notes. postgresql_include=[...] adds non-key columns to the index so a query can be answered from the index alone, which is PostgreSQL's covering-index support. And postgresql_using="gin" or "gist" selects the index type, which matters for the JSONB and full-text cases in querying Postgres JSONB, arrays and full-text search.

postgresql_concurrently is deliberately not declared in the model. It describes how an index is built, not what the index is, and autogenerate does not compare it — so it belongs only in the migration, wrapped in an autocommit block as creating indexes concurrently in Alembic migrations describes.

Resolving Warnings, Errors & Common Mistakes

Exact error or symptomRoot CauseProduction Fix
The plan shows Seq Scan despite the indexThe query predicate does not imply the index predicate.Include the index's predicate in the query, or widen the index.
An expression index is never usedThe query expression is not textually identical.Match it exactly — same function, same argument, same order.
ERROR: functions in index expression must be marked IMMUTABLEA volatile or stable function in the expression.Use an immutable function, or mark your own IMMUTABLE.
NameError declaring an expression index in __table_args__The expression referenced a mapped_column() placeholder.Declare the index after the class, using the mapped attribute.
Autogenerate proposes dropping the index every runIt exists in the database but not in the model.Declare it in the model with the same name.
Autogenerate proposes recreating a partial index repeatedlyThe predicate text differs from what PostgreSQL reports.Match the normalised form, or exclude the index from comparison.
IntegrityError on a value that should be allowedA plain unique constraint where a partial one was intended.A partial unique index, and drop the full constraint.
Four index shapes Four tiles. A plain index covers every row of one or more columns. A partial index adds a WHERE clause, so only matching rows are stored. An expression index stores the result of a function or expression, which is what makes a filter on lower of a column indexable. And a covering index adds included columns so the query can be answered from the index alone. plain Index("ix_a", "col") every row partial postgresql_where=... only matching rows expression Index("ix_b", func.lower(col)) indexes the result covering postgresql_include=[...] index-only scans All four are declared in the model, so autogenerate keeps them and the schema in step.

The repeated-recreation case is the one most likely to be met in practice. PostgreSQL normalises index predicates — status = 'pending' becomes (status = 'pending'::text) — and Alembic compares the text, so a predicate written differently in the model produces a difference on every autogenerate run. Two workable responses: write the predicate the way PostgreSQL reports it, which can be read from pg_indexes, or keep the index in the model for documentation and exclude it from comparison with include_object.

Reading back what the database actually has is a one-line query and settles the question:

from sqlalchemy import text

INDEX_DEFINITIONS = text("""
    SELECT indexname, indexdef
    FROM pg_indexes
    WHERE schemaname = 'public' AND tablename = :table
    ORDER BY indexname
""")

The other frequent surprise is a partial index that could be used but is not, because the planner estimates a sequential scan to be cheaper. That is often correct for a small table, and worth verifying rather than arguing with — the EXPLAIN workflow in reading EXPLAIN output for a SQLAlchemy query shows both the chosen plan and the estimated costs, so the comparison is visible.

For the unique-constraint case, note that a partial unique index is not a constraint: it cannot be the target of a foreign key, and it cannot be named in ON CONFLICT (...) by constraint name — though ON CONFLICT with the same column list and predicate does use it. Where a real constraint is needed, the full constraint has to stay, and the partial rule has to be expressed some other way.

Advanced: Partial Unique Indexes and Covering Indexes

Two shapes solve problems a plain index cannot express at all.

Three uses for a partial unique index Three cases. Soft deletion, where an email address must be unique among live rows but a deleted row should not reserve it forever. One active record per owner, such as a single default address per customer, which a plain unique constraint cannot express. And a uniqueness rule that applies only to a subset, such as unique invoice numbers per issued invoice while drafts are unnumbered. unique among live rows UNIQUE (email) WHERE deleted_at IS NULL — a deleted user frees the address one active row per owner UNIQUE (customer_id) WHERE is_default — a plain constraint cannot say "only the active one" uniqueness for a subset only UNIQUE (number) WHERE status = 'issued' — drafts have no number to collide

A partial unique index enforces uniqueness over a subset of rows. The canonical case is "one active row per owner", which no plain unique constraint can state:

from sqlalchemy import Boolean, ForeignKey, Index, text
from sqlalchemy.orm import Mapped, mapped_column


class Address(Base):
    __tablename__ = "addresses"

    id: Mapped[int] = mapped_column(primary_key=True)
    customer_id: Mapped[int] = mapped_column(ForeignKey("customers.id"))
    is_default: Mapped[bool] = mapped_column(Boolean, default=False)

    __table_args__ = (
        # At most one default address per customer; any number of non-default ones.
        Index(
            "uq_addresses_one_default_per_customer",
            "customer_id",
            unique=True,
            postgresql_where=text("is_default"),
        ),
    )

That single line replaces application logic that would otherwise have to check-then-write under a lock, and it is enforced for every writer. Setting a new default becomes two statements in one transaction — clear the old, set the new — and the index guarantees the invariant even under concurrency, in the same way described in handling IntegrityError on concurrent inserts.

A covering index adds non-key columns so a query can be satisfied without reading the table at all:

from sqlalchemy import Index

Index(
    "ix_orders_customer_placed_covering",
    Order.customer_id,
    Order.placed_at.desc(),
    postgresql_include=["status", "total_cents"],
)

A query selecting status and total_cents for one customer, ordered by date, can then be an index-only scan: PostgreSQL reads the index and never touches the heap. The gain is real for hot list endpoints and comes at the cost of a larger index and more work per write, so it is worth adding with a measurement rather than by default.

Combining the shapes is often the most valuable version. A partial covering index for a job queue holds only pending jobs and carries everything the claim query reads:

Index(
    "ix_jobs_pending_claim",
    Job.run_after,
    postgresql_where=text("status = 'pending'"),
    postgresql_include=["id", "payload"],
)

For a table where pending rows are a tiny fraction of the total, that index stays small enough to remain in cache permanently — which is what makes a queue on a large table behave like a queue on a small one, and the reason it appears in building a job queue with SELECT FOR UPDATE SKIP LOCKED.

Verifying the Index Is Actually Used

An index that the planner ignores costs write throughput and buys nothing, and the only way to know which you have is to look. Three checks cover it.

Declare it where autogenerate can see it Left: the index exists only in a migration, so the model does not describe it and the next autogenerate run proposes dropping it. Right: the index is declared in the model with the same name and definition, so autogenerate sees no difference and the migration only decides how it is built. only in the migration the model does not know it exists autogenerate proposes a drop and someone approves it the index disappears declared in the model Index(...) in __table_args__ autogenerate is empty the migration adds CONCURRENTLY which is not part of the definition postgresql_concurrently describes how to build it, not what it is — so it belongs only in the migration.

Read the plan for the exact query. Compile the statement SQLAlchemy will send, with its parameters, and run EXPLAIN on it. What you want to see is the index by name:

from sqlalchemy import text


async def explain(session, stmt) -> str:
    compiled = stmt.compile(session.bind, compile_kwargs={"literal_binds": True})
    rows = await session.execute(text(f"EXPLAIN (ANALYZE, BUFFERS) {compiled}"))
    return "\n".join(row[0] for row in rows)

For a partial index, also check the negative case: the same query without the predicate should fall back to a scan. If it does not, the index is wider than you thought.

Check usage counts after a week. pg_stat_user_indexes.idx_scan counts how often each index has been used. An index at zero after real traffic is serving nothing:

from sqlalchemy import text

UNUSED = text("""
    SELECT relname AS table_name, indexrelname AS index_name,
           idx_scan, pg_size_pretty(pg_relation_size(indexrelid)) AS size
    FROM pg_stat_user_indexes
    WHERE idx_scan = 0
    ORDER BY pg_relation_size(indexrelid) DESC
""")

Check replicas too before dropping anything: statistics are per server, and an index idle on the primary may be busy serving read traffic elsewhere.

Assert the definition in a test. For indexes that matter — a partial unique index enforcing an invariant, in particular — a test that reads pg_indexes and compares the definition catches a migration that dropped or altered it:

import pytest
from sqlalchemy import text


@pytest.mark.asyncio
async def test_one_default_address_per_customer(session, customer_factory, address_factory):
    from sqlalchemy.exc import IntegrityError

    customer_id = await customer_factory()
    await address_factory(customer_id=customer_id, is_default=True)
    await session.commit()

    await address_factory(customer_id=customer_id, is_default=True)
    with pytest.raises(IntegrityError):
        await session.commit()

That test asserts the behaviour rather than the definition, which is better: it keeps passing if the index is renamed and fails if the invariant stops being enforced — which is the thing that actually matters. Running it against a schema built by the migrations, as in running tests against a Postgres testcontainer, is what makes it meaningful.

Frequently Asked Questions

How do I declare a partial index in SQLAlchemy?

Index("name", "column", postgresql_where=text("status = 'pending'")) in __table_args__, or with a SQLAlchemy expression such as Customer.deleted_at.is_(None).

Why is my expression index not used?

Because the query expression must be textually equivalent to the indexed one after parsing. lower(email) in the index only serves queries that also apply lower() to the same column, and the function must be IMMUTABLE.

Can a partial unique index replace a unique constraint?

For enforcing uniqueness over a subset, yes — and it is the only way to express "unique among live rows". It is not a constraint, so it cannot be a foreign key target or be named by constraint name in ON CONFLICT.

Should postgresql_concurrently go in the model?

No. It describes how the index is built, not what it is, and autogenerate does not compare it. Declare the index in the model and add the concurrent flag in the migration.