How to find Missing Indexes in PostgreSQL

- How to find Missing Indexes in PostgreSQL - 11 September 2026
- How to do Performance Testing in PostgreSQL with pgbench - 9 September 2026
- How to find alerts in PostgreSQL through Plain Text Logs with a query - 7 September 2026
Those of us who come from the world of SQL Server, we are used to our favorites Missing Index DMVs (sys.dm_db_missing_index_details, sys.dm_db_missing_index_group_stats), which give us the ready-made CREATE INDEX command along with Avg User Impact and Equality/Inequality/Include columns.
In PostgreSQL, the default installation does not offer this information out of the box. However, by combining two powerful extensions, the pg_qualstats (which records the WHERE / JOIN clauses) and the pg_stat_statements (which records the SQL text), we can create a very useful query that suggests Indexes and generates DDL of it dynamically.
This Query:
- Locates the filtering fields (
WHERE/JOIN). - Automatically suggests Covering Indexes placing the remaining fields of
SELECTin theINCLUDE. - Excludes duplicate fields (does not put in
INCLUDEfields that already exist inWHERE). - Calculates the Impact Score.
- Checks the size of the table in GB and puts
🔥 HIGHPriority only when the table is >= 1GB and the Impact Score is high. - Excludes tables that already have an Index in the same fields (
WHERE DOES NOT EXIST).
Prerequisites:
For the script to work, two extensions are required:
- pg_stat_statements: Records the queries executed in the database.
- pg_qualstats: Records the
WHERE,JOINandHAVINGclauses, offering the necessary metrics (executions, filtered rows).
In the example we will setup on RHEL Unix with PostgreSQL version 11:
sudo yum install -y pg_qualstats11 --nogpgcheck -y sudo yum install -y postgresql11-contrib --nogpgcheck -y
Because in the example we have included a version that is now out of support, we can also do a manual setup:
curl -O https://yum-archive.postgresql.org/11/redhat/rhel-7-x86_64/pg_qualstats11-2.0.2-1.rhel7.x86_64.rpm curl -O https://yum-archive.postgresql.org/11/redhat/rhel-7-x86_64/postgresql11-libs-11.9-1PGDG.rhel7.x86_64.rpm curl -O https://yum-archive.postgresql.org/11/redhat/rhel-7-x86_64/postgresql11-contrib-11.9-1PGDG.rhel7.x86_64.rpm sudo yum localinstall -y pg_qualstats11-2.0.2-1.rhel7.x86_64.rpm sudo yum localinstall -y postgresql11-libs-11.9-1PGDG.rhel7.x86_64.rpm postgresql11-contrib-11.9-1PGDG.rhel7.x86_64.rpm
Next we need to declare the extentions in postgresql.conf:
su postgres vi $PGDATA/postgresql.conf
shared_preload_libraries = 'pg_stat_statements, pg_qualstats'
Then we need to restart the service:
sudo systemctl restart postgresql-11
After PostgreSQL starts, we connect and enable the extensions:
sudo -u postgres psql -d db_test CREATE EXTENSION IF NOT EXISTS pg_stat_statements; CREATE EXTENSION IF NOT EXISTS pg_qualstats;
To see that they have been activated, run the following:
SHOW shared_preload_libraries; SHOW pg_qualstats.enabled; SHOW pg_qualstats.sample_rate;
Depending on the version, the default sample_rate is 0.01 so if we want to run something manually from the same session and have it record it, we definitely run it from the query session where we will run the query below:
SET pg_qualstats.sample_rate = 1;
If we want to reset the statistics, we run the following:
SELECT pg_qualstats_reset();
The query:
WITH qual_details AS (
SELECT
q.queryid,
q.qualid,
q.lrelid::regclass::text AS table_name,
q.lrelid AS table_oid,
a.attname AS filter_column,
q.occurences,
q.nbfiltered
FROM pg_qualstats q
JOIN pg_attribute a ON a.attrelid = q.lrelid AND a.attnum = q.lattnum
WHERE q.lrelid IS NOT NULL AND q.lrelid > 0
),
where_columns_per_qual AS (
SELECT
qualid,
table_name,
table_oid,
array_agg(DISTINCT filter_column) AS all_where_cols
FROM qual_details
GROUP BY qualid, table_name, table_oid
),
query_payload AS (
SELECT
qd.qualid,
qd.table_name,
qd.table_oid,
qd.filter_column,
qd.occurences,
qd.nbfiltered,
ARRAY(
SELECT DISTINCT a_select.attname
FROM pg_stat_statements pss
JOIN pg_attribute a_select ON a_select.attrelid = (qd.table_name::regclass)
JOIN where_columns_per_qual w ON w.qualid = qd.qualid
WHERE pss.queryid = qd.queryid
AND a_select.attnum > 0
AND NOT a_select.attisdropped
AND NOT (a_select.attname = ANY(w.all_where_cols))
AND pss.query ILIKE '%' || a_select.attname || '%'
) AS payload_columns
FROM qual_details qd
),
dedup_where AS (
SELECT DISTINCT
qualid,
table_name,
table_oid,
filter_column,
occurences,
nbfiltered
FROM query_payload
),
payload_flattened AS (
SELECT DISTINCT
qualid,
unnest(payload_columns) AS inc_col
FROM query_payload
),
include_aggregated AS (
SELECT
qualid,
string_agg(quote_ident(inc_col), ', ' ORDER BY quote_ident(inc_col)) AS include_list
FROM payload_flattened
GROUP BY qualid
),
calculated_metrics AS (
SELECT
dw.qualid,
dw.table_name,
dw.table_oid,
string_agg(dw.filter_column, ', ' ORDER BY dw.filter_column) AS where_columns,
COALESCE(inc.include_list, '—') AS include_columns,
MAX(dw.occurences) AS execution_count,
MAX(dw.nbfiltered) AS total_rows_filtered,
ROUND((pg_relation_size(dw.table_oid)::numeric / 1073741824.0), 2) AS table_size_gb,
(MAX(dw.occurences) * MAX(dw.nbfiltered)) AS impact_score,
FORMAT(
'CREATE INDEX CONCURRENTLY idx_%s_%s%s ON %s (%s)%s;',
dw.table_name,
string_agg(dw.filter_column, '_' ORDER BY dw.filter_column),
CASE WHEN inc.include_list IS NOT NULL THEN '_covering' ELSE '' END,
dw.table_name,
string_agg(quote_ident(dw.filter_column), ', ' ORDER BY quote_ident(dw.filter_column)),
CASE
WHEN inc.include_list IS NOT NULL AND inc.include_list != ''
THEN ' INCLUDE (' || inc.include_list || ')'
ELSE ''
END
) AS create_smart_index_command
FROM dedup_where dw
LEFT JOIN include_aggregated inc ON inc.qualid = dw.qualid
GROUP BY dw.qualid, dw.table_name, dw.table_oid, inc.include_list
)
SELECT
cm.table_name,
cm.where_columns,
cm.include_columns,
cm.execution_count,
cm.total_rows_filtered,
cm.table_size_gb,
cm.impact_score,
CASE
WHEN cm.impact_score > 100000 AND cm.table_size_gb >= 1.00 THEN '🔥 HIGH'
WHEN cm.impact_score BETWEEN 1000 AND 100000 THEN '⚠️ MEDIUM'
ELSE 'ℹ️ LOW'
END AS priority,
cm.create_smart_index_command
FROM calculated_metrics cm
-- ΕΞΑΙΡΕΣΗ: Μην προτείνεις αν υπάρχει ήδη Index
WHERE NOT EXISTS (
SELECT 1
FROM pg_index i
JOIN pg_class c ON c.oid = i.indrelid
WHERE c.oid = cm.table_oid
AND i.indkey[0] IN (
SELECT attnum
FROM pg_attribute
WHERE attrelid = cm.table_oid
AND attname = ANY(string_to_array(cm.where_columns, ', '))
)
)
ORDER BY cm.impact_score DESC, cm.execution_count DESC;


