Index tuning recommendations
In closed beta.
Indexes are the single biggest lever you have over query
performance in PostgreSQL, and one of the easiest to get wrong, especially
as your database usage evolves.
Picking the right ones usually means stitching together
pg_stat_statements, pg_stat_user_indexes, pg_stats, bloat estimates,
and a fair amount of judgment.
DBtune does that work for you, against your real workload.
Notably, we differ from other index tuning services, in that you do not need any additional extensions beyond pg_stat_statements enabled on your database. All index analysis and recommendations are offloaded to a statistical snapshot of your database, analyzing query plans using the native PostgreSQL planner and HypoPG to ensure we find the best indices for your running workload.
We surface the following with no extra queries run against your database:
- New index suggestions, concrete and ranked by how much they would speed up real queries you have actually run, with ready-to-paste DDL.
- Health checks on the indexes you already have, so you can spot the ones that are unused, redundant, bloated, or otherwise not earning their keep.
Both update continuously, so the picture reflects what your database is doing now, not what it was doing when the schema was designed.
DBtune does not apply index recommendations automatically. You stay in control: review each suggestion, decide whether it fits, and run the DDL yourself. Any future automation around this will be strictly opt-in.
What you see in the platform
Index tuning lives on the Indices tab of each database. The page has two modes you can switch between:
- Recommendations: new indexes DBtune is suggesting you create, ranked by estimated speedup.
- Existing indexes: every index already on the database, each with its own health check.
Selecting an item on the left opens a detail panel on the right. A "Request a scan now" button lets you trigger an immediate re-analysis instead of waiting for the next daily refresh.
Recommendations
Not every query is worth optimizing.
Before generating recommendations, DBtune ranks the queries running against your database by the total time the database spends on them (total_exec_time from pg_stat_statements, i.e. average runtime × number of calls), and focuses on the ones that actually move the needle.
A 200 ms query that runs ten thousand times an hour matters more than a 30-second query that runs once a day, and the recommendations reflect that.
Each recommendation card shows the headline benefit up front: average query speedup and the number of queries it affects. Open one and you get:
- The columns,
INCLUDEcolumns, and anyWHEREclause that make up the suggested index. - Estimated disk size and estimated write cost per day, so you can weigh the cost against the speedup before you commit.
- The list of queries the index would help, each with the cost reduction the planner expects. Click a query to see its actual runtime over the last 7 days.
- The raw SQL and ORM specific templates to incorporate this index into your code. Pick the one that fits your migration tooling.
Recommendations are ranked by speedup, so the most impactful suggestions are always at the top.
Reported speedups may not exactly match what you observe in production, for a few reasons:
- Stale statistics: recommendations are based on snapshots of
reltuplesandpg_statstaken at generation time, not the live values. - Use of
EXPLAIN (GENERIC_PLAN): to protect your data we only see parameterized queries frompg_stat_statements, so we plan them withEXPLAIN (GENERIC_PLAN). This matches what most prepared statements do, but conditional plans may deviate. - Missing histogram values: statistics such as
pg_stats.most_common_valshelp the planner but can contain sensitive data (e.g. user emails), so we do not collect them by default. You can opt in for better recommendations by enablinginclude_table_datafor thepg_statscollector in our agent.
If your workload runs significant logic through stored procedures, functions, or DO blocks, set pg_stat_statements.track = all. With the default top, only the outer CALL / SELECT is recorded and the inner queries are invisible to DBtune, so no index recommendations can be generated for them. The wrapping statement itself is rarely the right target for an index — it is the nested queries that drive cost.
Existing indexes
The Existing indexes view groups every index in the database into three buckets so you can see at a glance where attention is needed and what is already healthy:
- Attention: something is likely wrong and worth acting on (e.g. a long-unused index, a redundant index, severe bloat).
- Informational: worth being aware of, not necessarily worth acting on (e.g. an early signal that scan rate is declining).
- Healthy: checks that passed, listed explicitly so you know what was actually verified rather than just absent.
You can filter the index list by finding type, so questions like "show me everything that has bloat" or "show me indexes nobody uses" are one click.
Open any index and you get an at-a-glance dashboard of how it is actually being used:
- Usage: scans per hour, share of table scans this index serves, total scans observed.
- Size: on-disk bytes, bloat bytes and ratio (smaller is better), and the index's size relative to its table.
- Cache hit rate: percentage of reads served from the buffer cache, with a quick label (Excellent / Good / Fair / Poor).
- Correlation: how well the leading column's order matches the heap's physical order, on a -1 to +1 scale.
Each card has a sparkline you can expand into a full historical chart, so trends are visible alongside the latest number. When everything checks out, DBtune tells you so explicitly rather than leaving you to guess from an empty page.
When a finding fires, you get the full picture inline: a plain-English summary, the supporting evidence (numbers, ratios, charts), and the threshold used. You can judge whether the call fits your workload before acting on it.
Index types
DBtune recommends multiple types of indexes. The right shape depends on the predicate, the data type, and how the query is written. Here are the index types DBtune may propose, and the situations in which each is the right tool.
Create
Single-column B-tree
A B-tree on one column. Generated for any column that appears in equality predicates, range predicates, or ORDER BY keys. An additional ordered variant (e.g. col DESC) is suggested when the sort direction is non-default.
Why use this?
The default index type, and the right answer most of the time. Speeds up equality lookups (col = ?) and range scans (col > ?, BETWEEN, etc.) on one column, and lets the planner read rows already sorted by that column without an extra sort step.
Composite (multi-column) B-tree
Multiple columns combined into one index. Column order follows the equality, range, sort heuristic, with multiple width and ordering variants considered for each query.
Why use this?
When a query filters on several columns at once, a single composite index is usually faster than combining two single-column indexes: the planner walks straight to the matching rows instead of intersecting two index scans. Column order matters: only a leading prefix of the index can be used, so the equality, range, sort ordering ensures the most selective filters come first.
Partial index
An index with a WHERE clause derived from predicates seen in the workload, so the index only covers the relevant subset of rows.
Why use this?
If your queries always restrict to a small slice of the table (e.g. WHERE status = 'active', or WHERE deleted_at IS NULL), a partial index over just that slice is dramatically smaller than a full index. Smaller means faster lookups, less write overhead on every INSERT / UPDATE, and less storage.
Today, DBtune only proposes partial indexes for IS NULL predicates. We read queries from pg_stat_statements, which parameterizes literals (status = $1 rather than status = 'active'), so we cannot tell which concrete value would make a useful partial. IS NULL is the one predicate that survives parameterization, so it is the only one we recommend partials on for now.
Covering index (INCLUDE)
Extra non-key columns added via INCLUDE so the planner can satisfy queries from index-only scans without heap fetches.
Why use this?
A normal index lookup finds the right rows but still has to visit the heap to read the columns you actually SELECT. With INCLUDE columns, those values live in the index itself, so the planner can answer the query entirely from the index: an index-only scan. Worth it when a query reads a few extra columns alongside its filter keys.
Expression index
An index on a function call or computed expression (e.g. LOWER(email), date_trunc('day', created_at)). Triggered by expressions appearing in WHERE predicates, GROUP BY, ORDER BY, or join conditions.
Why use this?
A plain B-tree on email cannot serve WHERE LOWER(email) = ?. Postgres would have to recompute LOWER for every row and fall back to a sequential scan. An expression index stores the computed result, so the predicate becomes directly indexable. The same applies to date_trunc, casts, JSON path extraction, and any other deterministic expression you filter on repeatedly.
Drop
Indexes are not free: every one costs write throughput, disk space, and eventually someone's attention during an incident. DBtune flags indexes that are no longer earning their keep so you can clean them up with confidence:
- An index that backs a constraint (
PRIMARY KEY,UNIQUE,EXCLUSION) is never recommended for removal. Those are structural and DBtune leaves them alone. - Indexes unused for 7+ days are surfaced as informational, in case the index serves a monthly batch job. After 35 days they escalate to a drop candidate, with the bytes you would reclaim shown alongside.
- Indexes whose key columns are already covered by a wider index on the same table are flagged as redundant. The wider index does the same job, so the narrower one is dead weight.
- When DBtune has not been watching long enough to be confident, it says so explicitly rather than suggesting you drop something it has not had a chance to observe.
Index diagnostics
For every index already on your database, DBtune runs a battery of health checks. The checks below are the ones that can be raised on an index; each one comes with the underlying evidence (charts, ratios, timestamps) so you can verify the call yourself rather than taking it on faith.
Unused indexes, no scan usage
The index has not been used at all over the time DBtune has been observing it. If the observation window is short, this is informational; if it has been long enough that the index really should have been hit by something, it escalates and becomes a drop candidate.
Redundant
Another index on the same table already covers everything this one does: same leading columns, same access method, same constraints. The narrower index is dead weight, costing writes and disk for queries the wider index would handle anyway.
Bloat
The index is significantly larger on disk than its data structurally warrants. Bloated B-trees waste cache, slow down scans, and quietly consume storage; rebuilding (REINDEX CONCURRENTLY) reclaims the space.
Low cache hit rate
The index is being read from disk more often than from PostgreSQL's buffer cache. That usually means the working set has outgrown shared buffers, or the index is too cold to stay resident. Either way, lookups are paying I/O latency they should not be.
Underused
The index does get used, but only rarely compared to other access paths on the same table. Worth a closer look: it might be specialized for a query that is no longer hot, or it might be losing out to a better index that already exists.
Declining usage
The index's scan rate is trending down over time. Often an early signal that a query has been rewritten, a feature has been deprecated, or a workload has shifted, and the index is on its way to becoming unused.
Low correlation
The leading indexed column does not match the physical order of the heap. Range scans on this index end up jumping around the disk instead of reading sequentially, which kills throughput. Sometimes a sign that a CLUSTER or a different index choice would help.
Invalid index
The index exists but is marked invalid, typically the leftover of a CREATE INDEX CONCURRENTLY that failed partway through. The planner ignores it, but every write still maintains it. Almost always safe to drop and recreate.