From 5481e438c846f7a734332175a0e9d9b93b335f1e Mon Sep 17 00:00:00 2001 From: Chris Gwilliams <517923+encima@users.noreply.github.com> Date: Tue, 4 Aug 2026 08:32:30 +0300 Subject: [PATCH 1/8] feat: add checks for invalid indexes --- docs/0030_invalid_index.md | 58 ++++++++++++++++++++++++++++ lints/0030_invalid_index.sql | 54 ++++++++++++++++++++++++++ splinter.sql | 56 ++++++++++++++++++++++++++- test/expected/0030_invalid_index.out | 48 +++++++++++++++++++++++ test/sql/0030_invalid_index.sql | 41 ++++++++++++++++++++ 5 files changed, 256 insertions(+), 1 deletion(-) create mode 100644 docs/0030_invalid_index.md create mode 100644 lints/0030_invalid_index.sql create mode 100644 test/expected/0030_invalid_index.out create mode 100644 test/sql/0030_invalid_index.sql diff --git a/docs/0030_invalid_index.md b/docs/0030_invalid_index.md new file mode 100644 index 0000000..3cedc8f --- /dev/null +++ b/docs/0030_invalid_index.md @@ -0,0 +1,58 @@ + +**Level:** WARN + +**Summary:** Index is marked invalid and is never used by the query planner. + +**Ramification:** The index still consumes disk space and is maintained (slowing writes) on every insert/update, but provides zero query benefit and, if it was meant to back a unique constraint, that constraint is not being enforced. + +--- + +### Rationale + +Postgres marks an index `invalid` (`pg_index.indisvalid = false`) when it is left in a partially built state, most commonly: + +- `CREATE INDEX CONCURRENTLY` fails partway through (e.g. a conflicting row, a timeout, or the session was killed) +- `REINDEX CONCURRENTLY` fails partway through +- A crash occurred while `CREATE INDEX CONCURRENTLY` was running + +An invalid index is never used by the planner, but Postgres does not automatically drop it. It sits on disk, still gets updated on every write to the underlying table, and does nothing useful in return. + +### How to Resolve + +**Option 1: Drop and recreate concurrently** + +```sql +drop index concurrently if exists public.idx_orders_customer_id; + +create index concurrently idx_orders_customer_id +on public.orders (customer_id); +``` + +**Option 2: Investigate why it failed first** + +If the original `CREATE INDEX CONCURRENTLY` failed due to a constraint violation (common for unique indexes), fix the underlying data before recreating the index, otherwise the rebuild will fail the same way. + +### Example + +Given this problematic configuration: + +```sql +-- This fails partway through, e.g. due to a lock timeout or duplicate values +create unique index concurrently idx_orders_order_number +on public.orders (order_number); +``` + +The resulting index is left behind as invalid — still consuming space, still slowing down writes, enforcing nothing. + +Fix by dropping and rebuilding it: + +```sql +drop index concurrently idx_orders_order_number; + +create unique index concurrently idx_orders_order_number +on public.orders (order_number); +``` + +### False Positives + +None expected — a valid, functioning index will never have `indisvalid = false`. If this lint fires, the index genuinely needs to be dropped or rebuilt. diff --git a/lints/0030_invalid_index.sql b/lints/0030_invalid_index.sql new file mode 100644 index 0000000..3e5ad81 --- /dev/null +++ b/lints/0030_invalid_index.sql @@ -0,0 +1,54 @@ +create view lint."0030_invalid_index" as + +-- Detects indexes marked as invalid in pg_index.indisvalid, typically left behind +-- by a failed `CREATE INDEX CONCURRENTLY` or `REINDEX CONCURRENTLY`. Invalid +-- indexes are never used by the query planner but still consume disk space and +-- take a write penalty to maintain. +select + 'invalid_index' as name, + 'Invalid Index' as title, + 'WARN' as level, + 'EXTERNAL' as facing, + array['PERFORMANCE'] as categories, + 'Detects indexes marked as invalid, typically left behind by a failed `CREATE INDEX CONCURRENTLY` or `REINDEX CONCURRENTLY`. Invalid indexes are ignored by the planner but still incur maintenance overhead.' as description, + format( + 'Index `%s` on table `%s.%s` is invalid and is not used by the query planner.', + ic.relname, + nsp.nspname, + tc.relname + ) as detail, + 'https://supabase.com/docs/guides/database/database-linter?lint=0030_invalid_index' as remediation, + jsonb_build_object( + 'schema', nsp.nspname, + 'name', tc.relname, + 'type', 'table', + 'index_name', ic.relname + ) as metadata, + format('invalid_index_%s_%s_%s', nsp.nspname, tc.relname, ic.relname) as cache_key +from + pg_catalog.pg_index pi + join pg_catalog.pg_class ic + on pi.indexrelid = ic.oid + join pg_catalog.pg_class tc + on pi.indrelid = tc.oid + join pg_catalog.pg_namespace nsp + on tc.relnamespace = nsp.oid + left join pg_catalog.pg_depend dep + on dep.objid = ic.oid + and dep.deptype = 'e' + and dep.classid = 'pg_catalog.pg_class'::regclass +where + not pi.indisvalid + and dep.objid is null -- exclude indexes owned by extensions + and nsp.nspname not in ( + '_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', + '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', + 'graphql_public', 'information_schema', 'net', 'pgmq', 'pgroonga', + 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', + 'pgtle', 'realtime', 'repack', 'storage', 'supabase_functions', + 'supabase_migrations', 'tiger', 'topology', 'vault' + ) +order by + nsp.nspname, + tc.relname, + ic.relname; diff --git a/splinter.sql b/splinter.sql index 9cd66c9..bbbcb20 100644 --- a/splinter.sql +++ b/splinter.sql @@ -1838,4 +1838,58 @@ from order by schema_name, function_name, - function_args) \ No newline at end of file + function_args) +union all +( +-- Detects indexes marked as invalid in pg_index.indisvalid, typically left behind +-- by a failed `CREATE INDEX CONCURRENTLY` or `REINDEX CONCURRENTLY`. Invalid +-- indexes are never used by the query planner but still consume disk space and +-- take a write penalty to maintain. +select + 'invalid_index' as name, + 'Invalid Index' as title, + 'WARN' as level, + 'EXTERNAL' as facing, + array['PERFORMANCE'] as categories, + 'Detects indexes marked as invalid, typically left behind by a failed `CREATE INDEX CONCURRENTLY` or `REINDEX CONCURRENTLY`. Invalid indexes are ignored by the planner but still incur maintenance overhead.' as description, + format( + 'Index `%s` on table `%s.%s` is invalid and is not used by the query planner.', + ic.relname, + nsp.nspname, + tc.relname + ) as detail, + 'https://supabase.com/docs/guides/database/database-linter?lint=0030_invalid_index' as remediation, + jsonb_build_object( + 'schema', nsp.nspname, + 'name', tc.relname, + 'type', 'table', + 'index_name', ic.relname + ) as metadata, + format('invalid_index_%s_%s_%s', nsp.nspname, tc.relname, ic.relname) as cache_key +from + pg_catalog.pg_index pi + join pg_catalog.pg_class ic + on pi.indexrelid = ic.oid + join pg_catalog.pg_class tc + on pi.indrelid = tc.oid + join pg_catalog.pg_namespace nsp + on tc.relnamespace = nsp.oid + left join pg_catalog.pg_depend dep + on dep.objid = ic.oid + and dep.deptype = 'e' + and dep.classid = 'pg_catalog.pg_class'::regclass +where + not pi.indisvalid + and dep.objid is null -- exclude indexes owned by extensions + and nsp.nspname not in ( + '_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', + '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', + 'graphql_public', 'information_schema', 'net', 'pgmq', 'pgroonga', + 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', + 'pgtle', 'realtime', 'repack', 'storage', 'supabase_functions', + 'supabase_migrations', 'tiger', 'topology', 'vault' + ) +order by + nsp.nspname, + tc.relname, + ic.relname) \ No newline at end of file diff --git a/test/expected/0030_invalid_index.out b/test/expected/0030_invalid_index.out new file mode 100644 index 0000000..63e7d69 --- /dev/null +++ b/test/expected/0030_invalid_index.out @@ -0,0 +1,48 @@ +begin; + set local search_path = ''; + -- BASELINE: 0 issues on empty schema + select * from lint."0030_invalid_index"; + name | title | level | facing | categories | description | detail | remediation | metadata | cache_key +------+-------+-------+--------+------------+-------------+--------+-------------+----------+----------- +(0 rows) + + savepoint a; + -- NEGATIVE EXAMPLE: a normal, valid index should NOT trigger + create table public.orders( + id int primary key, + customer_id int + ); + create index idx_orders_customer_id on public.orders (customer_id); + select * from lint."0030_invalid_index"; -- expect 0 rows + name | title | level | facing | categories | description | detail | remediation | metadata | cache_key +------+-------+-------+--------+------------+-------------+--------+-------------+----------+----------- +(0 rows) + + rollback to savepoint a; + -- POSITIVE EXAMPLE: an index left invalid (as happens when + -- CREATE INDEX CONCURRENTLY / REINDEX CONCURRENTLY fails partway through) + -- Simulated here via direct catalog update since CONCURRENTLY cannot + -- run inside a transaction block. + create table public.orders( + id int primary key, + order_number int + ); + create unique index idx_orders_order_number on public.orders (order_number); + update pg_catalog.pg_index + set indisvalid = false + where indexrelid = 'public.idx_orders_order_number'::regclass; + select name, detail, cache_key from lint."0030_invalid_index"; -- expect 1 row + name | detail | cache_key +---------------+-----------------------------------------------------------------------------------------------------------+----------------------------------------------------- + invalid_index | Index `idx_orders_order_number` on table `public.orders` is invalid and is not used by the query planner. | invalid_index_public_orders_idx_orders_order_number +(1 row) + + -- RESOLUTION: drop and recreate the index + drop index public.idx_orders_order_number; + create unique index idx_orders_order_number on public.orders (order_number); + select * from lint."0030_invalid_index"; -- expect 0 rows + name | title | level | facing | categories | description | detail | remediation | metadata | cache_key +------+-------+-------+--------+------------+-------------+--------+-------------+----------+----------- +(0 rows) + +rollback; diff --git a/test/sql/0030_invalid_index.sql b/test/sql/0030_invalid_index.sql new file mode 100644 index 0000000..3e3a165 --- /dev/null +++ b/test/sql/0030_invalid_index.sql @@ -0,0 +1,41 @@ +begin; + set local search_path = ''; + + -- BASELINE: 0 issues on empty schema + select * from lint."0030_invalid_index"; + + savepoint a; + + -- NEGATIVE EXAMPLE: a normal, valid index should NOT trigger + create table public.orders( + id int primary key, + customer_id int + ); + create index idx_orders_customer_id on public.orders (customer_id); + + select * from lint."0030_invalid_index"; -- expect 0 rows + + rollback to savepoint a; + + -- POSITIVE EXAMPLE: an index left invalid (as happens when + -- CREATE INDEX CONCURRENTLY / REINDEX CONCURRENTLY fails partway through) + -- Simulated here via direct catalog update since CONCURRENTLY cannot + -- run inside a transaction block. + create table public.orders( + id int primary key, + order_number int + ); + create unique index idx_orders_order_number on public.orders (order_number); + update pg_catalog.pg_index + set indisvalid = false + where indexrelid = 'public.idx_orders_order_number'::regclass; + + select name, detail, cache_key from lint."0030_invalid_index"; -- expect 1 row + + -- RESOLUTION: drop and recreate the index + drop index public.idx_orders_order_number; + create unique index idx_orders_order_number on public.orders (order_number); + + select * from lint."0030_invalid_index"; -- expect 0 rows + +rollback; From f20f486d159d7a6f7c055a2211a9e04fe3ab04af Mon Sep 17 00:00:00 2001 From: Chris Gwilliams <517923+encima@users.noreply.github.com> Date: Tue, 4 Aug 2026 09:10:24 +0300 Subject: [PATCH 2/8] update installcheck and query unions --- bin/installcheck | 2 +- test/expected/queries_are_unionable.out | 4 +++- test/sql/queries_are_unionable.sql | 4 +++- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/bin/installcheck b/bin/installcheck index b54f633..659aa2f 100755 --- a/bin/installcheck +++ b/bin/installcheck @@ -52,7 +52,7 @@ else fi # Execute the test fixtures -psql -v ON_ERROR_STOP= -f test/fixtures.sql -f lints/0001*.sql -f lints/0002*.sql -f lints/0003*.sql -f lints/0004*.sql -f lints/0005*.sql -f lints/0006*.sql -f lints/0007*.sql -f lints/0008*.sql -f lints/0009*.sql -f lints/0010*.sql -f lints/0011*.sql -f lints/0013*.sql -f lints/0014*.sql -f lints/0015*.sql -f lints/0016*.sql -f lints/0017*.sql -f lints/0018*.sql -f lints/0019*.sql -f lints/0020*.sql -f lints/0021*.sql -f lints/0022*.sql -f lints/0023*.sql -f lints/0024*.sql -f lints/0025*.sql -f lints/0026*.sql -f lints/0027*.sql -f lints/0028*.sql -f lints/0029*.sql -d contrib_regression +psql -v ON_ERROR_STOP= -f test/fixtures.sql -f lints/0001*.sql -f lints/0002*.sql -f lints/0003*.sql -f lints/0004*.sql -f lints/0005*.sql -f lints/0006*.sql -f lints/0007*.sql -f lints/0008*.sql -f lints/0009*.sql -f lints/0010*.sql -f lints/0011*.sql -f lints/0013*.sql -f lints/0014*.sql -f lints/0015*.sql -f lints/0016*.sql -f lints/0017*.sql -f lints/0018*.sql -f lints/0019*.sql -f lints/0020*.sql -f lints/0021*.sql -f lints/0022*.sql -f lints/0023*.sql -f lints/0024*.sql -f lints/0025*.sql -f lints/0026*.sql -f lints/0027*.sql -f lints/0028*.sql -f lints/0029*.sql -f lints/0030*.sql -d contrib_regression # Run tests ${REGRESS} --use-existing --dbname=contrib_regression --inputdir=${TESTDIR} ${TESTS} diff --git a/test/expected/queries_are_unionable.out b/test/expected/queries_are_unionable.out index 3b1005d..fbfa37e 100644 --- a/test/expected/queries_are_unionable.out +++ b/test/expected/queries_are_unionable.out @@ -54,7 +54,9 @@ begin; union all select * from lint."0028_anon_security_definer_function_executable" union all - select * from lint."0029_authenticated_security_definer_function_executable"; + select * from lint."0029_authenticated_security_definer_function_executable" + union all + select * from lint."0030_invalid_index"; name | title | level | facing | categories | description | detail | remediation | metadata | cache_key ------+-------+-------+--------+------------+-------------+--------+-------------+----------+----------- (0 rows) diff --git a/test/sql/queries_are_unionable.sql b/test/sql/queries_are_unionable.sql index fe43d3d..53e08b2 100644 --- a/test/sql/queries_are_unionable.sql +++ b/test/sql/queries_are_unionable.sql @@ -56,6 +56,8 @@ begin; union all select * from lint."0028_anon_security_definer_function_executable" union all - select * from lint."0029_authenticated_security_definer_function_executable"; + select * from lint."0029_authenticated_security_definer_function_executable" + union all + select * from lint."0030_invalid_index"; rollback; From be66e00182c8c339838efe6bcd7d0b47efbbbe80 Mon Sep 17 00:00:00 2001 From: Chris Gwilliams <517923+encima@users.noreply.github.com> Date: Tue, 4 Aug 2026 09:40:56 +0300 Subject: [PATCH 3/8] add edge case feedback to docs and check for indexes already built --- docs/0030_invalid_index.md | 3 ++- lints/0030_invalid_index.sql | 1 + mkdocs.yaml | 1 + 3 files changed, 4 insertions(+), 1 deletion(-) diff --git a/docs/0030_invalid_index.md b/docs/0030_invalid_index.md index 3cedc8f..0c966b2 100644 --- a/docs/0030_invalid_index.md +++ b/docs/0030_invalid_index.md @@ -55,4 +55,5 @@ on public.orders (order_number); ### False Positives -None expected — a valid, functioning index will never have `indisvalid = false`. If this lint fires, the index genuinely needs to be dropped or rebuilt. +1. Partitioned parent indexes can be marked as invalid even after the children have been repaired, see the [Postgres mailing list for more information](https://www.postgresql.org/message-id/CAGnOmWqi1D9ycBgUeOGf6mOCd2Dcf%3D6sKhbf4sHLs5xAcKVCMQ%40mail.gmail.com) +2. 1. In progress indexes - if an index is being created then it can show as invalid (we filter on !`indisready`) to address this but there may be edge cases diff --git a/lints/0030_invalid_index.sql b/lints/0030_invalid_index.sql index 3e5ad81..33e1631 100644 --- a/lints/0030_invalid_index.sql +++ b/lints/0030_invalid_index.sql @@ -39,6 +39,7 @@ from and dep.classid = 'pg_catalog.pg_class'::regclass where not pi.indisvalid + and pi.indisready -- exclude indexes that are still being built and dep.objid is null -- exclude indexes owned by extensions and nsp.nspname not in ( '_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', diff --git a/mkdocs.yaml b/mkdocs.yaml index 1f5d05d..9c77a37 100644 --- a/mkdocs.yaml +++ b/mkdocs.yaml @@ -37,6 +37,7 @@ nav: - Signed-In Users Can See Object in GraphQL Schema: '0027_pg_graphql_authenticated_table_exposed.md' - Public Can Execute SECURITY DEFINER Function: '0028_anon_security_definer_function_executable.md' - Signed-In Users Can Execute SECURITY DEFINER Function: '0029_authenticated_security_definer_function_executable.md' + - Invalid indexes exist in the database: '0030_invalid_indexes_exist_in_the_database.md' theme: name: 'material' From 02e038c8b51e6b82ed5d037ba7ab26ebf7fa3c1e Mon Sep 17 00:00:00 2001 From: Chris Gwilliams <517923+encima@users.noreply.github.com> Date: Tue, 4 Aug 2026 11:37:18 +0300 Subject: [PATCH 4/8] Update mkdocs.yaml Co-authored-by: Bobbie Soedirgo <31685197+soedirgo@users.noreply.github.com> --- mkdocs.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mkdocs.yaml b/mkdocs.yaml index 9c77a37..9dc469f 100644 --- a/mkdocs.yaml +++ b/mkdocs.yaml @@ -37,7 +37,7 @@ nav: - Signed-In Users Can See Object in GraphQL Schema: '0027_pg_graphql_authenticated_table_exposed.md' - Public Can Execute SECURITY DEFINER Function: '0028_anon_security_definer_function_executable.md' - Signed-In Users Can Execute SECURITY DEFINER Function: '0029_authenticated_security_definer_function_executable.md' - - Invalid indexes exist in the database: '0030_invalid_indexes_exist_in_the_database.md' + - Invalid indexes exist in the database: '0030_invalid_index.md' theme: name: 'material' From 11d5c6262eadc97b1adc0bbd9ae4b6056c8192bf Mon Sep 17 00:00:00 2001 From: Chris Gwilliams <517923+encima@users.noreply.github.com> Date: Wed, 5 Aug 2026 09:52:20 +0300 Subject: [PATCH 5/8] fix: modify to only show indexes that failed creation --- lints/0030_invalid_index.sql | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/lints/0030_invalid_index.sql b/lints/0030_invalid_index.sql index 33e1631..03c0392 100644 --- a/lints/0030_invalid_index.sql +++ b/lints/0030_invalid_index.sql @@ -39,13 +39,17 @@ from and dep.classid = 'pg_catalog.pg_class'::regclass where not pi.indisvalid - and pi.indisready -- exclude indexes that are still being built + and pi.indisready -- exclude indexes that are still being built (phase 1) + and not exists ( -- exclude indexes actively being built (phase 2+) + select 1 from pg_catalog.pg_stat_progress_create_index pci + where pci.index_relid = ic.oid + ) and dep.objid is null -- exclude indexes owned by extensions and nsp.nspname not in ( '_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgmq', 'pgroonga', - 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', + 'pgsodium', 'pgsodium_masks', 'pgbouncer', 'pg_catalog', 'pgtle', 'realtime', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault' ) From 8e915629aa800a4b1dbc8eb35293888f42dc8751 Mon Sep 17 00:00:00 2001 From: Oliver Rice Date: Tue, 11 Aug 2026 14:02:02 -0500 Subject: [PATCH 6/8] handle exclusion constraints --- docs/0030_invalid_index.md | 22 ++++++++-------- lints/0030_invalid_index.sql | 27 +++++++++++++++----- splinter.sql | 34 ++++++++++++++++++++----- test/expected/0030_invalid_index.out | 38 +++++++++++++++++++++++----- test/sql/0030_invalid_index.sql | 28 +++++++++++++++++--- 5 files changed, 117 insertions(+), 32 deletions(-) diff --git a/docs/0030_invalid_index.md b/docs/0030_invalid_index.md index 0c966b2..297112a 100644 --- a/docs/0030_invalid_index.md +++ b/docs/0030_invalid_index.md @@ -19,13 +19,18 @@ An invalid index is never used by the planner, but Postgres does not automatical ### How to Resolve -**Option 1: Drop and recreate concurrently** +**Option 1: Reindex concurrently** ```sql -drop index concurrently if exists public.idx_orders_customer_id; +reindex index concurrently public.idx_orders_customer_id; +``` + +This rebuilds and validates the index without blocking writes to the table. -create index concurrently idx_orders_customer_id -on public.orders (customer_id); +Indexes backing exclusion constraints cannot be reindexed concurrently, and a plain `drop index` is rejected because the constraint requires the index. Only in that case, reindex non-concurrently. This blocks writes to the table and may take significant time on large tables, so run it in a maintenance window: + +```sql +reindex index public.reservations_during_excl; ``` **Option 2: Investigate why it failed first** @@ -44,16 +49,13 @@ on public.orders (order_number); The resulting index is left behind as invalid — still consuming space, still slowing down writes, enforcing nothing. -Fix by dropping and rebuilding it: +Fix by rebuilding it: ```sql -drop index concurrently idx_orders_order_number; - -create unique index concurrently idx_orders_order_number -on public.orders (order_number); +reindex index concurrently idx_orders_order_number; ``` ### False Positives 1. Partitioned parent indexes can be marked as invalid even after the children have been repaired, see the [Postgres mailing list for more information](https://www.postgresql.org/message-id/CAGnOmWqi1D9ycBgUeOGf6mOCd2Dcf%3D6sKhbf4sHLs5xAcKVCMQ%40mail.gmail.com) -2. 1. In progress indexes - if an index is being created then it can show as invalid (we filter on !`indisready`) to address this but there may be edge cases +2. In progress indexes - an index that is still being built can show as invalid. To address this the lint only considers indexes where `indisready` is true and excludes builds reported in `pg_stat_progress_create_index`, but there may be edge cases diff --git a/lints/0030_invalid_index.sql b/lints/0030_invalid_index.sql index 03c0392..c151682 100644 --- a/lints/0030_invalid_index.sql +++ b/lints/0030_invalid_index.sql @@ -11,12 +11,24 @@ select 'EXTERNAL' as facing, array['PERFORMANCE'] as categories, 'Detects indexes marked as invalid, typically left behind by a failed `CREATE INDEX CONCURRENTLY` or `REINDEX CONCURRENTLY`. Invalid indexes are ignored by the planner but still incur maintenance overhead.' as description, - format( - 'Index `%s` on table `%s.%s` is invalid and is not used by the query planner.', - ic.relname, - nsp.nspname, - tc.relname - ) as detail, + case + when con.oid is not null then format( + 'Index `%s` on table `%s.%s` is invalid and is not used by the query planner. It backs an exclusion constraint and cannot be reindexed concurrently. Rebuild it with `reindex index %s.%s;`. Note that a non-concurrent reindex blocks writes to the table and may take significant time on large tables.', + ic.relname, + nsp.nspname, + tc.relname, + pg_catalog.quote_ident(nsp.nspname), + pg_catalog.quote_ident(ic.relname) + ) + else format( + 'Index `%s` on table `%s.%s` is invalid and is not used by the query planner. Rebuild it with `reindex index concurrently %s.%s;`', + ic.relname, + nsp.nspname, + tc.relname, + pg_catalog.quote_ident(nsp.nspname), + pg_catalog.quote_ident(ic.relname) + ) + end as detail, 'https://supabase.com/docs/guides/database/database-linter?lint=0030_invalid_index' as remediation, jsonb_build_object( 'schema', nsp.nspname, @@ -37,6 +49,9 @@ from on dep.objid = ic.oid and dep.deptype = 'e' and dep.classid = 'pg_catalog.pg_class'::regclass + left join pg_catalog.pg_constraint con + on con.conindid = ic.oid + and con.contype = 'x' where not pi.indisvalid and pi.indisready -- exclude indexes that are still being built (phase 1) diff --git a/splinter.sql b/splinter.sql index bbbcb20..3d3b244 100644 --- a/splinter.sql +++ b/splinter.sql @@ -1852,12 +1852,24 @@ select 'EXTERNAL' as facing, array['PERFORMANCE'] as categories, 'Detects indexes marked as invalid, typically left behind by a failed `CREATE INDEX CONCURRENTLY` or `REINDEX CONCURRENTLY`. Invalid indexes are ignored by the planner but still incur maintenance overhead.' as description, - format( - 'Index `%s` on table `%s.%s` is invalid and is not used by the query planner.', - ic.relname, - nsp.nspname, - tc.relname - ) as detail, + case + when con.oid is not null then format( + 'Index `%s` on table `%s.%s` is invalid and is not used by the query planner. It backs an exclusion constraint and cannot be reindexed concurrently. Rebuild it with `reindex index %s.%s`. Note that a non-concurrent reindex blocks writes to the table and may take significant time on large tables.', + ic.relname, + nsp.nspname, + tc.relname, + pg_catalog.quote_ident(nsp.nspname), + pg_catalog.quote_ident(ic.relname) + ) + else format( + 'Index `%s` on table `%s.%s` is invalid and is not used by the query planner. Rebuild it with `reindex index concurrently %s.%s`', + ic.relname, + nsp.nspname, + tc.relname, + pg_catalog.quote_ident(nsp.nspname), + pg_catalog.quote_ident(ic.relname) + ) + end as detail, 'https://supabase.com/docs/guides/database/database-linter?lint=0030_invalid_index' as remediation, jsonb_build_object( 'schema', nsp.nspname, @@ -1878,14 +1890,22 @@ from on dep.objid = ic.oid and dep.deptype = 'e' and dep.classid = 'pg_catalog.pg_class'::regclass + left join pg_catalog.pg_constraint con + on con.conindid = ic.oid + and con.contype = 'x' where not pi.indisvalid + and pi.indisready -- exclude indexes that are still being built (phase 1) + and not exists ( -- exclude indexes actively being built (phase 2+) + select 1 from pg_catalog.pg_stat_progress_create_index pci + where pci.index_relid = ic.oid + ) and dep.objid is null -- exclude indexes owned by extensions and nsp.nspname not in ( '_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgmq', 'pgroonga', - 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', + 'pgsodium', 'pgsodium_masks', 'pgbouncer', 'pg_catalog', 'pgtle', 'realtime', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault' ) diff --git a/test/expected/0030_invalid_index.out b/test/expected/0030_invalid_index.out index 63e7d69..b6cb8db 100644 --- a/test/expected/0030_invalid_index.out +++ b/test/expected/0030_invalid_index.out @@ -32,14 +32,40 @@ begin; set indisvalid = false where indexrelid = 'public.idx_orders_order_number'::regclass; select name, detail, cache_key from lint."0030_invalid_index"; -- expect 1 row - name | detail | cache_key ----------------+-----------------------------------------------------------------------------------------------------------+----------------------------------------------------- - invalid_index | Index `idx_orders_order_number` on table `public.orders` is invalid and is not used by the query planner. | invalid_index_public_orders_idx_orders_order_number + name | detail | cache_key +---------------+----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+----------------------------------------------------- + invalid_index | Index `idx_orders_order_number` on table `public.orders` is invalid and is not used by the query planner. Rebuild it with `reindex index concurrently public.idx_orders_order_number;` | invalid_index_public_orders_idx_orders_order_number (1 row) - -- RESOLUTION: drop and recreate the index - drop index public.idx_orders_order_number; - create unique index idx_orders_order_number on public.orders (order_number); + -- RESOLUTION: reindex validates the index. The docs recommend + -- `reindex index concurrently` but CONCURRENTLY cannot run inside a + -- transaction block, so the test uses the non-concurrent form. + reindex index public.idx_orders_order_number; + select * from lint."0030_invalid_index"; -- expect 0 rows + name | title | level | facing | categories | description | detail | remediation | metadata | cache_key +------+-------+-------+--------+------------+-------------+--------+-------------+----------+----------- +(0 rows) + + rollback to savepoint a; + -- POSITIVE EXAMPLE (exception): an invalid index backing an exclusion + -- constraint cannot be reindexed concurrently, so the message recommends + -- a non-concurrent reindex instead. + create table public.reservations( + id int primary key, + during int4range, + constraint reservations_during_excl exclude using gist (during with &&) + ); + update pg_catalog.pg_index + set indisvalid = false + where indexrelid = 'public.reservations_during_excl'::regclass; + select name, detail, cache_key from lint."0030_invalid_index"; -- expect 1 row + name | detail | cache_key +---------------+------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+------------------------------------------------------------ + invalid_index | Index `reservations_during_excl` on table `public.reservations` is invalid and is not used by the query planner. It backs an exclusion constraint and cannot be reindexed concurrently. Rebuild it with `reindex index public.reservations_during_excl;`. Note that a non-concurrent reindex blocks writes to the table and may take significant time on large tables. | invalid_index_public_reservations_reservations_during_excl +(1 row) + + -- RESOLUTION: a non-concurrent reindex validates the index + reindex index public.reservations_during_excl; select * from lint."0030_invalid_index"; -- expect 0 rows name | title | level | facing | categories | description | detail | remediation | metadata | cache_key ------+-------+-------+--------+------------+-------------+--------+-------------+----------+----------- diff --git a/test/sql/0030_invalid_index.sql b/test/sql/0030_invalid_index.sql index 3e3a165..7aa392c 100644 --- a/test/sql/0030_invalid_index.sql +++ b/test/sql/0030_invalid_index.sql @@ -32,9 +32,31 @@ begin; select name, detail, cache_key from lint."0030_invalid_index"; -- expect 1 row - -- RESOLUTION: drop and recreate the index - drop index public.idx_orders_order_number; - create unique index idx_orders_order_number on public.orders (order_number); + -- RESOLUTION: reindex validates the index. The docs recommend + -- `reindex index concurrently` but CONCURRENTLY cannot run inside a + -- transaction block, so the test uses the non-concurrent form. + reindex index public.idx_orders_order_number; + + select * from lint."0030_invalid_index"; -- expect 0 rows + + rollback to savepoint a; + + -- POSITIVE EXAMPLE (exception): an invalid index backing an exclusion + -- constraint cannot be reindexed concurrently, so the message recommends + -- a non-concurrent reindex instead. + create table public.reservations( + id int primary key, + during int4range, + constraint reservations_during_excl exclude using gist (during with &&) + ); + update pg_catalog.pg_index + set indisvalid = false + where indexrelid = 'public.reservations_during_excl'::regclass; + + select name, detail, cache_key from lint."0030_invalid_index"; -- expect 1 row + + -- RESOLUTION: a non-concurrent reindex validates the index + reindex index public.reservations_during_excl; select * from lint."0030_invalid_index"; -- expect 0 rows From 1accf8e1f65fb2de730f452ea28e2fe1e87884ed Mon Sep 17 00:00:00 2001 From: Oliver Rice Date: Tue, 11 Aug 2026 14:29:24 -0500 Subject: [PATCH 7/8] handle partitioned tables --- lints/0030_invalid_index.sql | 3 +++ splinter.sql | 3 +++ test/expected/0030_invalid_index.out | 13 +++++++++++++ test/sql/0030_invalid_index.sql | 12 ++++++++++++ 4 files changed, 31 insertions(+) diff --git a/lints/0030_invalid_index.sql b/lints/0030_invalid_index.sql index c151682..1776132 100644 --- a/lints/0030_invalid_index.sql +++ b/lints/0030_invalid_index.sql @@ -54,6 +54,9 @@ from and con.contype = 'x' where not pi.indisvalid + -- partitioned parent indexes ('I') are invalid by design until every + -- child index attaches + and ic.relkind = 'i' and pi.indisready -- exclude indexes that are still being built (phase 1) and not exists ( -- exclude indexes actively being built (phase 2+) select 1 from pg_catalog.pg_stat_progress_create_index pci diff --git a/splinter.sql b/splinter.sql index 3d3b244..9889fc7 100644 --- a/splinter.sql +++ b/splinter.sql @@ -1895,6 +1895,9 @@ from and con.contype = 'x' where not pi.indisvalid + -- partitioned parent indexes ('I') are invalid by design until every + -- child index attaches + and ic.relkind = 'i' and pi.indisready -- exclude indexes that are still being built (phase 1) and not exists ( -- exclude indexes actively being built (phase 2+) select 1 from pg_catalog.pg_stat_progress_create_index pci diff --git a/test/expected/0030_invalid_index.out b/test/expected/0030_invalid_index.out index b6cb8db..20a8a38 100644 --- a/test/expected/0030_invalid_index.out +++ b/test/expected/0030_invalid_index.out @@ -16,6 +16,19 @@ begin; select * from lint."0030_invalid_index"; -- expect 0 rows name | title | level | facing | categories | description | detail | remediation | metadata | cache_key ------+-------+-------+--------+------------+-------------+--------+-------------+----------+----------- +(0 rows) + + rollback to savepoint a; + -- NEGATIVE EXAMPLE: a partitioned parent index created with ON ONLY is + -- marked invalid by design until every child index is attached; it should + -- NOT trigger + create table public.events(id int, ts date) partition by range (ts); + create table public.events_2026 partition of public.events + for values from ('2026-01-01') to ('2027-01-01'); + create index idx_events_ts on only public.events (ts); + select * from lint."0030_invalid_index"; -- expect 0 rows + name | title | level | facing | categories | description | detail | remediation | metadata | cache_key +------+-------+-------+--------+------------+-------------+--------+-------------+----------+----------- (0 rows) rollback to savepoint a; diff --git a/test/sql/0030_invalid_index.sql b/test/sql/0030_invalid_index.sql index 7aa392c..63b1bd2 100644 --- a/test/sql/0030_invalid_index.sql +++ b/test/sql/0030_invalid_index.sql @@ -17,6 +17,18 @@ begin; rollback to savepoint a; + -- NEGATIVE EXAMPLE: a partitioned parent index created with ON ONLY is + -- marked invalid by design until every child index is attached; it should + -- NOT trigger + create table public.events(id int, ts date) partition by range (ts); + create table public.events_2026 partition of public.events + for values from ('2026-01-01') to ('2027-01-01'); + create index idx_events_ts on only public.events (ts); + + select * from lint."0030_invalid_index"; -- expect 0 rows + + rollback to savepoint a; + -- POSITIVE EXAMPLE: an index left invalid (as happens when -- CREATE INDEX CONCURRENTLY / REINDEX CONCURRENTLY fails partway through) -- Simulated here via direct catalog update since CONCURRENTLY cannot From 5d41fc1284904a5dea117248774d8e852e111c6b Mon Sep 17 00:00:00 2001 From: Utkarash Singh Date: Wed, 12 Aug 2026 13:09:31 +0100 Subject: [PATCH 8/8] test: cover leaf-partition and excluded-schema branches for 0030 invalid_index Adds two cases to test/sql/0030_invalid_index.sql (expected output regenerated via pg_regress; full suite passes): - a leaf partition's invalid index IS flagged with concurrent-reindex advice (relkind 'i') -- the positive counterpart to the existing partitioned-parent ('I') exclusion test, so the partition handling can't silently over-exclude - an invalid index in an excluded internal schema (repack) is NOT flagged --- test/expected/0030_invalid_index.out | 31 ++++++++++++++++++++++++++++ test/sql/0030_invalid_index.sql | 28 +++++++++++++++++++++++++ 2 files changed, 59 insertions(+) diff --git a/test/expected/0030_invalid_index.out b/test/expected/0030_invalid_index.out index 20a8a38..24cfe46 100644 --- a/test/expected/0030_invalid_index.out +++ b/test/expected/0030_invalid_index.out @@ -82,6 +82,37 @@ begin; select * from lint."0030_invalid_index"; -- expect 0 rows name | title | level | facing | categories | description | detail | remediation | metadata | cache_key ------+-------+-------+--------+------------+-------------+--------+-------------+----------+----------- +(0 rows) + + rollback to savepoint a; + -- POSITIVE EXAMPLE: a LEAF partition's index left invalid IS flagged. Only the + -- partitioned PARENT index (relkind 'I') is excluded; a leaf partition's index + -- (relkind 'i') is an ordinary index and must still be linted. + create table public.events(id int, ts date) partition by range (ts); + create table public.events_2026 partition of public.events + for values from ('2026-01-01') to ('2027-01-01'); + create index idx_events_2026_ts on public.events_2026 (ts); + update pg_catalog.pg_index + set indisvalid = false + where indexrelid = 'public.idx_events_2026_ts'::regclass; + select name, detail, cache_key from lint."0030_invalid_index"; -- expect 1 row + name | detail | cache_key +---------------+-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+----------------------------------------------------- + invalid_index | Index `idx_events_2026_ts` on table `public.events_2026` is invalid and is not used by the query planner. Rebuild it with `reindex index concurrently public.idx_events_2026_ts;` | invalid_index_public_events_2026_idx_events_2026_ts +(1 row) + + rollback to savepoint a; + -- NEGATIVE EXAMPLE: an invalid index in an internal (excluded) schema is NOT + -- flagged -- the lint only reports customer-facing schemas. + create schema repack; + create table repack.t(id int); + create unique index idx_repack_t_id on repack.t (id); + update pg_catalog.pg_index + set indisvalid = false + where indexrelid = 'repack.idx_repack_t_id'::regclass; + select * from lint."0030_invalid_index"; -- expect 0 rows + name | title | level | facing | categories | description | detail | remediation | metadata | cache_key +------+-------+-------+--------+------------+-------------+--------+-------------+----------+----------- (0 rows) rollback; diff --git a/test/sql/0030_invalid_index.sql b/test/sql/0030_invalid_index.sql index 63b1bd2..57ceee8 100644 --- a/test/sql/0030_invalid_index.sql +++ b/test/sql/0030_invalid_index.sql @@ -72,4 +72,32 @@ begin; select * from lint."0030_invalid_index"; -- expect 0 rows + rollback to savepoint a; + + -- POSITIVE EXAMPLE: a LEAF partition's index left invalid IS flagged. Only the + -- partitioned PARENT index (relkind 'I') is excluded; a leaf partition's index + -- (relkind 'i') is an ordinary index and must still be linted. + create table public.events(id int, ts date) partition by range (ts); + create table public.events_2026 partition of public.events + for values from ('2026-01-01') to ('2027-01-01'); + create index idx_events_2026_ts on public.events_2026 (ts); + update pg_catalog.pg_index + set indisvalid = false + where indexrelid = 'public.idx_events_2026_ts'::regclass; + + select name, detail, cache_key from lint."0030_invalid_index"; -- expect 1 row + + rollback to savepoint a; + + -- NEGATIVE EXAMPLE: an invalid index in an internal (excluded) schema is NOT + -- flagged -- the lint only reports customer-facing schemas. + create schema repack; + create table repack.t(id int); + create unique index idx_repack_t_id on repack.t (id); + update pg_catalog.pg_index + set indisvalid = false + where indexrelid = 'repack.idx_repack_t_id'::regclass; + + select * from lint."0030_invalid_index"; -- expect 0 rows + rollback;