Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion bin/installcheck
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
61 changes: 61 additions & 0 deletions docs/0030_invalid_index.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@

**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: Reindex concurrently**

```sql
reindex index concurrently public.idx_orders_customer_id;
```

This rebuilds and validates the index without blocking writes to the table.

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**

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 rebuilding it:

```sql
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. 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
77 changes: 77 additions & 0 deletions lints/0030_invalid_index.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
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,
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,
'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
left join pg_catalog.pg_constraint con
on con.conindid = ic.oid
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
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', 'pgbouncer', 'pg_catalog',
'pgtle', 'realtime', 'repack', 'storage', 'supabase_functions',
'supabase_migrations', 'tiger', 'topology', 'vault'
)
order by
nsp.nspname,
tc.relname,
ic.relname;
1 change: 1 addition & 0 deletions mkdocs.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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_index.md'

theme:
name: 'material'
Expand Down
79 changes: 78 additions & 1 deletion splinter.sql
Original file line number Diff line number Diff line change
Expand Up @@ -1838,4 +1838,81 @@ from
order by
schema_name,
function_name,
function_args)
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,
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,
'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
left join pg_catalog.pg_constraint con
on con.conindid = ic.oid
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
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', 'pgbouncer', 'pg_catalog',
'pgtle', 'realtime', 'repack', 'storage', 'supabase_functions',
'supabase_migrations', 'tiger', 'topology', 'vault'
)
order by
nsp.nspname,
tc.relname,
ic.relname)
118 changes: 118 additions & 0 deletions test/expected/0030_invalid_index.out
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
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;
-- 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;
-- 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. Rebuild it with `reindex index concurrently public.idx_orders_order_number;` | invalid_index_public_orders_idx_orders_order_number
(1 row)

-- 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
------+-------+-------+--------+------------+-------------+--------+-------------+----------+-----------
(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;
Loading
Loading