Indexes
Speed up lookups, joins and sorting: when to add an index, the index types, multi-column, unique and partial indexes, reading size and scans, unused and invalid indexes, and building on large tables.


An index lets Postgres find rows without reading the whole table — the difference between milliseconds and seconds once a table has more than a few thousand rows. Each index takes disk space and makes every insert and update a little slower, so add the ones your queries need and remove the ones nobody uses.
When to add an index
- Columns you filter on often:
where user_id = $1,where status = 'open'. - Foreign key columns. Postgres indexes the referenced key, not the column that points to it, so
orders.customer_idneeds its own index for joins and for deleting customers quickly. - Columns you sort or paginate by:
order by created_at desc limit 20. - Columns used in row-level security policies (
owner_id): the policy is added to every query (Row-level security and access).
Primary keys and unique constraints get an index automatically. The Performance advisor suggests indexes from your slowest queries, and Performance explains how to read a query plan with Explain in the SQL editor.
In the console
| Column | Shows |
|---|---|
| Name | With tags: Primary key, Unique constraint, Unique, Partial (has a where), Invalid (a failed build; Postgres doesn't use it). |
| Table and columns | Columns in index order, or the expression. |
| Type | The index method: btree, gin, gist, hash, brin. |
| Size | Disk space it uses. The header shows the total for the schema. |
| Scans | How many times Postgres has used it since statistics were last reset. Unused marks an index over 1 MB with no scans that enforces nothing: a candidate for removal. |
Indexes that enforce a primary key or unique constraint can't be deleted here: remove the constraint in the Table editor and the index goes with it.
Create an index
New index
Pick the table, then tick columns in the order you want them: the one you filter on most first.
Type
Leave btree unless the table below says otherwise.
Unique and partial
Unique refuses two rows with the same values. Only rows where makes a partial index, e.g.
deleted_at IS NULL.Check, then create
Check builds it in a transaction that is rolled back; Create index keeps it as a recorded migration you can undo.
-- Foreign key used in joins
create index orders_customer_id_idx on orders (customer_id);
-- "My open orders, newest first"
create index orders_customer_status_created_idx on orders (customer_id, status, created_at desc);
-- One active subscription per user; cancelled ones don't count
create unique index subscriptions_one_active_key on subscriptions (user_id) where cancelled_at is null;
-- Case-insensitive email lookups: where lower(email) = lower($1)
create index users_lower_email_idx on users (lower(email));
-- jsonb containment: where tags @> '["sale"]'
create index products_tags_idx on products using gin (tags);Index types
| Type | Speeds up | Use for |
|---|---|---|
btree (default) | =, <, >, between, in, order by, like 'abc%' | Nearly everything: ids, foreign keys, dates, status. |
gin | @>, ?, &&, @@ | jsonb, arrays and full-text search; pg_trgm for ilike '%abc%' (Extensions). |
gist | Overlaps, distance, nearest first | Ranges, PostGIS shapes, exclusion constraints. |
brin | Ranges over naturally ordered data | Very large, append-only tables keyed by time; tiny but approximate. |
hash | = only | Rarely better than btree. |
hnsw, ivfflat | Nearest-neighbour search on vectors | Embeddings with pgvector — see Vector search and embeddings. |
Multi-column, unique and partial indexes
- Order matters. An index on
(customer_id, created_at)serveswhere customer_id = $1, andwhere customer_id = $1 order by created_at, but notwhere created_at > $1alone. - Put equality columns first and the range or sort column last.
- Unique indexes enforce uniqueness like a unique constraint.
nulls don't clash with each other unless you addnulls not distinctin SQL. - Partial indexes cover only matching rows: smaller, faster to update, and the way to enforce "unique among active rows". A query uses one only when its
whereimplies the index's condition.
Building on large tables
Indexes created from the console or a migration are built inside a transaction, which blocks writes to the table while it runs: well under a second for small tables, longer for millions of rows. Migrations wait at most 15 seconds for a lock and run for at most 120 seconds (Migrations).
For big tables in production, build it with create index concurrently over a direct connection, then record it with a migration that uses if not exists — Safe schema changes shows how. If a concurrent build fails it leaves an Invalid index: delete it and build it again.
Keep indexes lean
- Review the Unused tag now and then. Check a replica or staging environment before deleting, as some indexes serve rare but important queries (month-end reports).
- Two indexes where one's columns start the other (
(a)and(a, b)) usually means the shorter one can go. - Delete is undoable: Version History re-creates the index from its saved definition.
- Index size counts toward your database disk (Database: connection, compute, disk and backups).
Limits
Every index counts against the plan, including those behind primary keys and unique constraints. Indexes on Sovaria's own tables don't.
| Free | Pro | Ultra | Team | Enterprise | |
|---|---|---|---|---|---|
| Indexes | 100 | 10,000 | 30,000 | 100,000 | 500,000 |
| Database disk | 500 MB | 8 GB | 25 GB | 100 GB | 1 TB |
Coming from Firebase or Supabase?
| Platform | What this is called there |
|---|---|
| Supabase | Supabase Indexes and Index Advisor |
| Sovaria | Indexes |
Moving an existing app? Migrate from Supabase or Firebase imports users (with their passwords), data, files and rules.