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

PostgreSQL Unvalidated Constraint Validation Guard

Detect PostgreSQL constraints left NOT VALID, validate them behind an approval gate, then re-read pg_constraint to confirm each one is now trusted.

Categories
DataInfrastructure

ALTER TABLE ... ADD CONSTRAINT ... NOT VALID is the standard way to add a FOREIGN KEY or a CHECK to a busy table without a long lock. PostgreSQL starts enforcing it on new rows straight away and skips the scan of the rows already there. The migration is supposed to come back later with VALIDATE CONSTRAINT. That second step gets forgotten. pg_constraint.convalidated then stays false for months with nobody looking at it. Two things are then quietly wrong. The data may already break a rule the application believes is enforced. The planner also refuses to trust an unvalidated CHECK, so constraint exclusion stops working and partition pruning on that table silently degrades. This guard reads the catalog, reports every constraint still in that state with the size of the table behind it, validates them under a gate and proves the catalog now says they are trusted.

This blueprint was created by zkasuran.

Safety

  • Read-only by default. dry_run is true and auto_remediate is false, so a plain run reports and validates nothing.
  • A validation happens only when auto_remediate is true and dry_run is false. It then waits for a human to resume the Pause with approved set true. An unanswered gate reads as not approved.
  • VALIDATE CONSTRAINT is not free. It takes a SHARE UPDATE EXCLUSIVE lock on the table and reads every row, so it blocks DDL and other maintenance on that table for the length of a full scan. Reads and writes keep working. The log and the Slack message carry each table's data size and estimated row count before the gate, so the cost is visible at approval time.
  • max_table_mb is a ceiling on which tables take part. Anything larger is still reported but left out of the validation set, so an unattended run cannot start a scan of a table far bigger than the window allows.
  • Validation changes no row. It either marks the constraint trusted or refuses, which makes this one of the safer pieces of DDL to automate.
  • A refusal is a real finding. PostgreSQL rejects the validation when rows already in the table break the rule. The re-read then fails the run rather than reporting success. Fix or quarantine those rows, do not drop the constraint.
  • Scope is FOREIGN KEY and CHECK, the two kinds PostgreSQL accepts NOT VALID for. System schemas are skipped.

How it works

  1. detect (io.kestra.plugin.jdbc.postgresql.Query) reads pg_constraint joined to pg_class and pg_namespace for rows where convalidated is false and contype is f or c. Each row carries pg_get_constraintdef, the table data size from pg_table_size, the estimated row count from pg_class.reltuples, the exact ALTER TABLE ... VALIDATE CONSTRAINT statement built with format('%I') so the database quotes the identifiers, plus an in_scope flag derived from max_table_mb.
  2. summarize (io.kestra.plugin.core.output.OutputValues) builds the found list, the target list, the skipped list and the JSON the loop iterates, so every step reads the same set.
  3. log_detect (io.kestra.plugin.core.log.Log) logs the count, the mode and each constraint with its table size and row estimate.
  4. guard (io.kestra.plugin.core.flow.If) branches on the count. With none found, clean logs and the run ends. Otherwise findings_alert posts the finding on every detection.
  5. mode (io.kestra.plugin.core.flow.If) enters the remediation branch only when auto_remediate is true, dry_run is false and at least one target is inside the size ceiling. Otherwise report_only lists what would be validated and stops.
  6. approval_gate (io.kestra.plugin.core.flow.Pause) holds the run until a human resumes it. validate_constraints (io.kestra.plugin.core.flow.Loop, concurrencyLimit: 1) then runs one VALIDATE CONSTRAINT per target, with allowFailure so a rejected one does not end the pass.
  7. reverify re-runs the same catalog query and verify_result fails the run through io.kestra.plugin.core.execution.Fail if any targeted constraint still reports convalidated false. A constraint added NOT VALID during the run was never a target, so it does not fail the run.

What you get

  • A catalog-level answer to a question most teams cannot answer: which enforced rules have never been checked against the data already in the table.
  • The cost of the fix before the fix. Table data size and estimated row count per constraint, in the log, the Slack message and the approval gate.
  • A size ceiling, so a scheduled run validates the small tables now and leaves a 400 GB table for a maintenance window.
  • Honest failure. A constraint existing rows break cannot be validated. The run fails with the constraint definition instead of claiming a clean pass.
  • A real verification step. The run passes only when the second read of pg_constraint shows every target trusted.
  • Slack alerts on the findings and on any failure.

Who it's for

  • DBAs and platform engineers who ship online migrations in the NOT VALID then VALIDATE pattern and want the second half tracked rather than remembered.
  • Teams whose partition pruning or constraint exclusion stopped helping and who have not connected that to an untrusted CHECK.
  • Anyone auditing data integrity who needs the gap between what the schema claims and what was actually verified.
  • Teams evaluating the PostgreSQL plugin who want an example that reads the catalog and drives gated, verified DDL.

Why orchestrate this with Kestra

PostgreSQL will not remind you. There is no job queue for a forgotten VALIDATE CONSTRAINT, no dry run, no approval step and no proof afterwards. A migration tool does not help either, since the whole point of NOT VALID is that the validation is deferred past the deployment. Doing it by hand means a DBA reading the catalog, judging the scan cost per table, running the statements in turn and checking the catalog again. Kestra does all of that on a schedule. It reads the live catalog, reports the cost per target, keeps the change behind dry-run plus approval, serialises the scans so they do not fight for the same lock, re-reads pg_constraint to turn a hopeful statement into a verified one and posts to Slack the moment something fails.

Tested end to end

Run on Kestra 2.0.5 against a PostgreSQL 16 server, with a local HTTP sink standing in for Slack. Two schemas were seeded. app held a CHECK added NOT VALID over clean rows, a FOREIGN KEY added NOT VALID over clean rows, a CHECK added NOT VALID on a 2400 kB table of 40000 rows, plus a CHECK added normally that is already validated. badapp held a CHECK added NOT VALID on a table holding a row that breaks it. Every count was confirmed with psql against pg_constraint.convalidated before and after each run.

  • Detector can fail. On the seeded schema it returned exactly the 3 unvalidated constraints in app and never the already-validated one. After the validation pass the same query returned 0.
  • Dry run reported all 3 with their table sizes and row estimates then alerted. psql confirmed convalidated was still false on all 3.
  • A declined gate (resumed with approved false) validated nothing. psql showed the same 4 unvalidated constraints as before the run.
  • Remediate with approval at max_table_mb 1 validated the 2 small targets, reported the 2400 kB table as above the ceiling and left it alone. The re-read found 0 targets still unvalidated and psql showed exactly those 2 flipped to true.
  • Remediate with approval and no ceiling then validated the 2400 kB table, so app went from 3 unvalidated to 0, confirmed with psql.
  • A second dry run returned 0 and took the clean branch, so the guard is idempotent.
  • The violating row in badapp made PostgreSQL refuse with check constraint "ledger_qty_positive" of relation "ledger" is violated by some row. The re-read reported that constraint still NOT VALID, the run failed with the constraint definition in the message and the Slack alert fired. psql confirmed the constraint was left untouched.
  • constraint_type was checked both ways against one state holding a single unvalidated FOREIGN KEY: check returned 0 and took the clean branch, foreign_key returned that 1.
  • An unreachable host failed the first query and fired the Slack alert.

Prerequisites

A PostgreSQL server reachable over JDBC. The connecting user must be able to read the catalog and must own the tables it validates, since ALTER TABLE requires table ownership.

Secrets

  • POSTGRES_USERNAME: PostgreSQL user, read when use_secret_auth is true.
  • POSTGRES_PASSWORD: password for that user.
  • SLACK_WEBHOOK_URL: used by findings_alert and the errors block.
  • In Kestra OSS, provide each secret as a base64-encoded environment variable prefixed SECRET_.

Inputs

Name Type Default Description
postgres_url STRING jdbc:postgresql://localhost:5432/postgres PostgreSQL JDBC URL for the target database.
use_secret_auth BOOL true Read credentials from secrets. Set false only for inline test credentials.
schema STRING "" Optional single schema filter. Empty scans every user schema.
constraint_type SELECT all all, foreign_key or check.
max_table_mb INT 0 Size ceiling in MB of table data for a constraint to be validated. 0 means no ceiling.
dry_run BOOL true Report without validating anything.
auto_remediate BOOL false Validate behind the approval gate when dry_run is false.

Quick start

  1. Set POSTGRES_USERNAME, POSTGRES_PASSWORD and SLACK_WEBHOOK_URL as namespace secrets.
  2. Import this flow.
  3. Run it with postgres_url pointed at your database. The default dry run lists every constraint still NOT VALID with the size of its table.
  4. To validate them, run again with dry_run false and auto_remediate true, then resume the paused execution with approved true.

How to extend

  • Set max_table_mb to the largest table you are willing to scan in the window, then raise it for a maintenance run.
  • Set constraint_type to check to clear the constraints that block constraint exclusion first, since those are the ones costing you query plans.
  • Set schema to walk one schema at a time on a large database.
  • Add ?options=-c%20statement_timeout%3D600000 to the JDBC URL to cap how long a single validation scan may run.
  • Fail the run as soon as detection finds anything, for a read-only migration hygiene check with no remediation.

Links

See How

New to Kestra?

Use blueprints to kickstart your first workflows.