Query icon
Log icon
If icon
SlackIncomingWebhook icon
Loop icon
Fail icon
Schedule icon

PostgreSQL Unindexed Foreign Key Guard

Find PostgreSQL foreign keys without an index, alert Slack, create the indexes with CREATE INDEX CONCURRENTLY and verify every key is covered.

Categories
DataInfrastructure

PostgreSQL indexes the referenced side of a foreign key (it needs a primary key or a unique constraint), but never the referencing side. So every DELETE on the parent table, and every update of its key, has to scan the whole child table for matching rows. With ON DELETE CASCADE, deleting one order can lock and scan millions of line items. It works fine in development, and becomes a nightly cleanup job that runs for hours, or a lock that blocks the checkout.

This blueprint finds every foreign key whose columns are not covered by an index, with the size of the child table and the delete rule. It alerts Slack, and can create the missing indexes with CREATE INDEX CONCURRENTLY, one at a time and without blocking writes. It then audits again, so the run only succeeds when each key is covered by a valid index.

This blueprint was created by zkasuran.

How it works

  1. audit (io.kestra.plugin.jdbc.postgresql.Query, read-only) lists the foreign keys outside system schemas that no valid, non-partial index covers. An index covers a key when its leading columns are exactly the key's columns, in any order. Each row has the child and parent tables, the columns, the delete rule, the child row count, and a CREATE INDEX CONCURRENTLY IF NOT EXISTS statement built with format('%I') and regclass.
  2. summary logs every finding. Child tables below min_table_rows are marked report only: an index there costs more than it saves.
  3. route (io.kestra.plugin.core.flow.If):
    • covered: logs it.
    • findings: alert posts to Slack.
      • remediate_gate runs only when auto_remediate is true and dry_run is false.
      • remediate (io.kestra.plugin.core.flow.Loop) builds one index at a time, each in its own task.
      • reaudit runs the audit again, and verify_result fails if an indexed key is still uncovered. A failed concurrent build leaves an INVALID index, which does not count as coverage.
  4. errors alerts Slack when the audit cannot run.

What you get

  • Every foreign key that turns parent deletes into child table scans, sorted by child table size.
  • Indexes built concurrently, one at a time, with names derived from the table and columns.
  • Proof that each key is covered by a valid index after the run.

Who it's for

  • Teams whose schemas grew through migrations and ORMs that create foreign keys without indexes.
  • DBAs tracking down slow deletes, cascading deletes and lock waits on parent tables.
  • Teams evaluating the PostgreSQL plugin who want an example of a catalog query driving index builds.

Why orchestrate this with Kestra

The catalog query finds the keys once. Kestra runs it every week, after every migration lands, and turns the result into a Slack alert. It builds the indexes behind auto_remediate and dry_run, one per task so one failure stays isolated, verifies the result, and keeps a history of which index was added where and when.

Tested end to end

On Kestra 2.0.5 OSS against PostgreSQL 16, with a local HTTP sink standing in for Slack. Foreign keys without an index:

  • order_items.order_id: 400,000 rows, ON DELETE CASCADE to orders;
  • shipments.customer_id: 150,000 rows;
  • invoices(region, cust_id): an empty table.

shipments.order_id already has an index.

Run Inputs Result
1 defaults 3 unindexed keys, invoices marked report only (0 rows). shipments.order_id not flagged. Alert sent
2 auto_remediate: true, dry_run: true Same report, no index created
3 auto_remediate: true, dry_run: false 2 indexes built concurrently and verified, both valid in pg_index. Deleting 500 orders in a transaction went from 9,734 ms to 7.8 ms
4 min_table_rows: 0, remediate The composite index on invoices(region, cust_id) built and verified
5 defaults Every foreign key covered
6 unreachable database errors alert sent

Prerequisites

  • For the audit, any role can read the catalog. For remediation, the owner of the child tables. Concurrent builds need no long lock, but they read the whole table.
  • Local testing:
    • docker run -d --name pg -p 5432:5432 -e POSTGRES_PASSWORD=pg postgres:16;
    • create a parent and a large child table with a REFERENCES constraint and no index.

Secrets

  • POSTGRES_USERNAME / POSTGRES_PASSWORD: credentials used by every query.
  • SLACK_WEBHOOK_URL: used by alert and the errors block.
  • In Kestra OSS, provide each one as a base64-encoded environment variable prefixed SECRET_.

Inputs

Input Default Purpose
jdbc_url jdbc:postgresql://localhost:5432/app Database
min_table_rows 10000 Smallest child table that gets an index
auto_remediate false Allow index builds
dry_run true Must be false for an index to be created

Quick start

  1. Add the POSTGRES_USERNAME, POSTGRES_PASSWORD and SLACK_WEBHOOK_URL secrets.
  2. Import the flow and set jdbc_url.
  3. Run it once and read the unindexed keys, largest child tables first.
  4. Run it with auto_remediate: true and dry_run: false outside peak hours.
  5. Enable the weekly trigger, which always runs in safe mode, or call the flow at the end of your migration pipeline.

How to extend

  • Only index keys whose parent table actually sees deletes, using n_tup_del from pg_stat_user_tables.
  • Loop over a list of JDBC URLs to check every database of a cluster.
  • Chain the invalid index rebuild guard after this flow, to clean up a build that failed.
  • Ask for approval with a Pause before building indexes on very large tables.

Links

See How

New to Kestra?

Use blueprints to kickstart your first workflows.