Skip to main content

2 posts tagged with "SQL"

View all tags

PostgreSQL migration left orphan empty tables behind? Audit schema leftovers with information_schema

ยท 7 min read

After switching an ads data source from daily tables to weekly tables, I found an empty table still sitting in the schema โ€” one that was only ever CREATEd in the earliest migration, held zero rows, and was never referenced by runtime code. It even carried stale field names and unnormalized columns.

Encountered this while building AI Ops โ€” AI-powered analytics that surfaces market trends, user behavior, and sales data to drive precise operational strategy. After this data-source switch, the write path had long since moved to the new weekly tables and a cleanup migration had already dropped the old daily table. The one thing missed was a monthly table that existed only in the baseline CREATE โ€” it had no new writes, no corresponding DROP, and just sat dormant in the schema with its deprecated field definitions.

TL;DRโ€‹

The signature of an orphan table: only CREATEd in an early/baseline migration, zero references in current code, often with stale or unnormalized column names. Batch migrations don't touch these automatically. You have to actively list every table in the schema with information_schema.tables, compare against code references, identify the orphans, and write a DROP migration to clean them up โ€” not just psql your way to a one-off delete.

The symptomโ€‹

A typical orphan table looks like this:

  • Zero rows โ€” the business stopped writing to it long ago;
  • Zero runtime references โ€” no SELECT/INSERT anywhere in code, only the CREATE in a migration file;
  • Stale fields โ€” column names from a previous naming convention (e.g. ad_plan_id/product_id), out of step with current standards;
  • Unnormalized columns โ€” possibly even Chinese column names that were never cleaned up.

It doesn't crash and doesn't affect production, so from a "nothing's broken online" perspective it's invisible. But the harm is implicit: it misleads newcomers into thinking it's still in use, pollutes the schema namespace, adds noise to cross-table audits, and could be read as dirty data by some mistaken SELECT *.

Root causeโ€‹

Database migrations follow a pervasive pattern: migrations are "additive".

A data-source switch typically evolves like this:

  1. An early baseline migration CREATEs a batch of tables (daily, monthly);
  2. Once the business runs, the write path starts depending on them;
  3. Requirements change, new tables (weekly) are introduced, and writes migrate over;
  4. The old daily table's writes stop, and a migration DROPs it;
  5. But the monthly table (or any table that was only ever CREATEd in the baseline and never directly used by the write path) gets no corresponding DROP.

The problem is step 5: migration attention focuses on "tables in use right now" โ€” which ones are being written, which ones queries hit. A table that "once existed but never entered the main path" is in neither the write path nor the query path, so it never triggers a DROP and becomes an orphan. This is the same family as Airflow DAG metadata lingering after deletion: "removed the entry, forgot to clean the structure" โ€” a high-frequency failure mode in migration work.

The fixโ€‹

Core flow: list all tables โ†’ compare references โ†’ confirm empty โ†’ write a DROP migration โ†’ verify.

Step 1: list all base tables in the schema with information_schemaโ€‹

-- List all base tables in a schema (exclude views)
SELECT table_name
FROM information_schema.tables
WHERE table_schema = 'your_schema'
AND table_type = 'BASE TABLE'
ORDER BY table_name;

information_schema.tables is a SQL-standard catalog view, portable across PostgreSQL/MySQL/SQL Server with stable fields โ€” ideal for baking into an audit script.

Step 2: grep the codebase to confirm runtime referencesโ€‹

For each candidate table, search for references in the codebase, excluding migration files themselves:

# Search runtime code references, excluding the migrations directory
grep -rn "ad_product_monthly_stats" src/ --include="*.py" \
| grep -v "migrations/"
# 0 lines of output โ†’ no runtime reference, it's a candidate

Zero references is the key evidence for an orphan. Make sure to exclude the migration directory โ€” the CREATE in the baseline doesn't count as a "reference".

Step 3: confirm it's emptyโ€‹

SELECT count(*) FROM your_schema.ad_product_monthly_stats;
-- 0 โ†’ confirmed no data, safe to clean up

Be extra careful with tables that have data: first confirm they're truly abandoned (not just recently unwritten), and back up logically if in doubt.

Step 4: write a DROP migration (not a manual delete)โ€‹

-- db-migrations/{project}/027_drop_ad_product_monthly_stats.sql
DROP TABLE IF EXISTS your_schema.ad_product_monthly_stats;

Always go through a migration file: it's version-controlled, replayed consistently across environments (dev/staging/prod), and leaves an audit trail. A one-off psql delete only works on the current machine โ€” on another box, the table grows back.

Step 5: verify the dropโ€‹

SELECT to_regclass('your_schema.ad_product_monthly_stats');
-- Returns NULL โ†’ the table no longer exists

to_regclass() is the standard way to check whether a relation exists; NULL confirms the drop succeeded.

Batch audit: sweep same-prefix siblings at onceโ€‹

After dropping one table, list all same-prefix siblings and walk through each โ€” avoid "dropped one, missed its siblings":

-- List all tables under a prefix, run steps 2-5 on each
SELECT table_name
FROM information_schema.tables
WHERE table_schema = 'your_schema'
AND table_name LIKE 'ad_%'
ORDER BY table_name;

Caveats

  • Back up / snapshot before DROP: dropping a production table is irreversible. For any table with data, confirm it's abandoned and export a logical backup first (e.g. CREATE TABLE ... AS SELECT into an archive schema).
  • Check foreign-key dependencies: if another table has a FK pointing at it, DROP TABLE fails. Confirm dependencies are resolved, or deliberately use CASCADE โ€” but CASCADE cascades the deletion to dependent objects, so use it carefully in production.
  • Use a migration, not manual psql: a manual delete only affects the current environment; a migration file guarantees multi-environment consistency and leaves a record.
  • Audit by prefix: one switch usually involves a group of same-prefix tables (e.g. ad_*). After cleaning one, sweep the siblings with LIKE 'ad_%' to proactively catch the same class of leftovers.

FAQโ€‹

How do I list all tables in a PostgreSQL database?โ€‹

Query information_schema.tables, filtering by table_schema and table_type = 'BASE TABLE' to list all base tables in a schema. It's more scriptable than psql's \dt, and because it's the SQL standard, the same query is portable across databases.

How do I find unused or orphan tables in PostgreSQL?โ€‹

List all tables with information_schema.tables, then compare against references in your codebase or query logs. Tables with zero runtime references and no writes are orphan candidates; confirm the row count with SELECT count(*), and once you've verified no data and no foreign-key dependencies, write a DROP migration to clean them up.

What's the difference between information_schema and pg_catalog?โ€‹

information_schema is the SQL-standard catalog view โ€” portable across PostgreSQL/MySQL/SQL Server with stable fields, ideal for portable audit scripts. pg_catalog is the PostgreSQL-specific catalog, richer and more detailed (e.g. precise row-count estimates, storage details) but subject to change between versions. For portable schema audits, prefer information_schema.

CCLEE

Independent developer, 24 years in e-commerce, focused on grounding AI in real business scenarios.

Work with me

PostgreSQL ON CONFLICT: there is no unique constraint? Sync INSERTs after changing the unique key

ยท 6 min read

Right after tightening a table's unique key โ€” dropping a column that no longer discriminated between rows โ€” every previously working UPSERT immediately failed in bulk with there is no unique or exclusion constraint matching the ON CONFLICT specification.

Encountered this while building AI Analytics โ€” an LLM-powered analytics pipeline that surfaces market trends, user behavior, and sales data for precise operations.

TL;DRโ€‹

PostgreSQL's ON CONFLICT (cols) requires cols to exactly match an existing unique constraint or unique index (same columns, same order โ€” otherwise SQL state 42P10). The moment you ALTER the unique key, every INSERT ... ON CONFLICT that references it must be updated; and once the migration lands, the write side must deploy immediately, because the in-between window keeps erroring.

The symptomโ€‹

As soon as the unique-key change went live, the scheduled import job failed across the board, with only this line in the write log:

ERROR: there is no unique or exclusion constraint matching the ON CONFLICT specification
SQL state: 42P10

Zero rows written to the business table, while plain SELECTs against the same table worked fine โ€” the failure was isolated to the ON CONFLICT write path.

Root causeโ€‹

The column set you pass to ON CONFLICT (cols) is the arbiter. PostgreSQL requires it to exactly match some UNIQUE constraint or unique index on the table:

  • the set of columns must be the same;
  • the order of columns must be the same;
  • for a partial unique index (one with a WHERE), ON CONFLICT must carry the same WHERE.

When nothing matches, PostgreSQL has no index to decide what "conflict" means, and raises 42P10.

The classic trigger is shrinking a unique key. The original key had 3 columns; you realize one of them (say audience) has 4 distinct values whose metric rows are 100% identical โ€” pure redundancy โ€” so you drop it down to 2 columns. That's the right optimization, but the old INSERT still says ON CONFLICT (c1, c2, c3) while only (c1, c2) remains as a unique constraint. The arbiter has no landing spot, and the statement errors out.

old unique key: UNIQUE (store_id, metric_key, audience)
new unique key: UNIQUE (store_id, metric_key)

old INSERT: ON CONFLICT (store_id, metric_key, audience) โ† no match

The fixโ€‹

Here is a minimal reproduction โ€” create, trigger, and fix in one go, runnable directly in psql:

-- 1. A table with a 3-column unique key
CREATE TABLE daily_metric (
store_id TEXT NOT NULL,
metric_key TEXT NOT NULL,
audience TEXT NOT NULL,
value NUMERIC,
CONSTRAINT daily_metric_unique UNIQUE (store_id, metric_key, audience)
);

-- 2. Old UPSERT: ON CONFLICT includes audience
INSERT INTO daily_metric (store_id, metric_key, audience, value)
VALUES ('s1', 'revenue', 'visitor', 100)
ON CONFLICT (store_id, metric_key, audience)
DO UPDATE SET value = EXCLUDED.value;

-- 3. Shrink the unique key: drop audience
ALTER TABLE daily_metric
DROP CONSTRAINT daily_metric_unique,
ADD CONSTRAINT daily_metric_unique_new UNIQUE (store_id, metric_key);

-- 4. Re-run the INSERT from step 2 โ€” it now errors โ†“
-- ERROR: there is no unique or exclusion constraint matching the ON CONFLICT specification

The fix is to shrink the INSERT's ON CONFLICT columns to match the 2-column key. Since audience no longer discriminates, pin it to a literal on the write side so incoming parameters can't synthesize extra rows:

INSERT INTO daily_metric (store_id, metric_key, audience, value)
VALUES ('s1', 'revenue', 'visitor', 100)
ON CONFLICT (store_id, metric_key) -- โ† shrunk to match
DO UPDATE SET value = EXCLUDED.value;

The part that actually bites is the deployment order, not the SQL itself:

  1. Ship the migration first (DROP old constraint + ADD new constraint);
  2. Immediately ship the write-side code (the INSERT's ON CONFLICT becomes 2 columns);
  3. Leave no gap between the two โ€” old code against the new schema raises 42P10, and new code against the old schema raises 42P10 just the same (no 2-column unique constraint exists yet).

If you use an ORM like Drizzle, an ON CONFLICT column list baked into a sql template is easy to forget when the schema changes โ€” the cost of schema/write-side drift shows up in another Drizzle + PostgreSQL pitfall too.

Caveatsโ€‹

Caveats

  • Column order matters: ON CONFLICT (a, b) does not match UNIQUE (b, a) โ€” the order must agree.
  • Partial unique indexes need the WHERE: if the arbiter is UNIQUE ... WHERE active, the INSERT must read ON CONFLICT (cols) WHERE active DO ..., or you get 42P10 all the same.
  • "Just skip on any conflict": use ON CONFLICT DO NOTHING without columns โ€” it specifies no arbiter and matches no specific index, catching every conflict.
  • During rollout: old and new write-side versions may briefly coexist. Make sure both can match the current schema, or ship the migration and the code together with no window in between.

FAQโ€‹

Does PostgreSQL ON CONFLICT require a unique constraint?โ€‹

Only when you name columns. ON CONFLICT (cols) must match an existing UNIQUE constraint or unique index exactly, or you get 42P10. If you just want "skip on any conflict" without caring which constraint fired, use ON CONFLICT DO NOTHING without columns โ€” it needs no specific index.

Can PostgreSQL ON CONFLICT target multiple unique constraints?โ€‹

No. A single INSERT's ON CONFLICT can name only one arbiter constraint (one column set, or one index name). A table may have multiple unique keys, but a single statement picks exactly one for conflict detection. To handle different unique keys differently, either split into multiple writes or query first in the application layer before choosing INSERT vs UPDATE.

How to fix there is no unique or exclusion constraint matching the ON CONFLICT specification?โ€‹

That is error code 42P10: the ON CONFLICT column set has no matching unique index on the table. Check in order: a UNIQUE constraint covers those columns, the columns and their order match exactly, and any INSERT was updated after a recent unique-key change. If the arbiter is a partial unique index with a WHERE, add the same WHERE clause to ON CONFLICT.

CCLEE

Independent developer, 24 years in e-commerce, focused on grounding AI in real business scenarios.

Work with me