PostgreSQL migration left orphan empty tables behind? Audit schema leftovers with information_schema
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/INSERTanywhere in code, only theCREATEin 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:
- An early baseline migration
CREATEs a batch of tables (daily, monthly); - Once the business runs, the write path starts depending on them;
- Requirements change, new tables (weekly) are introduced, and writes migrate over;
- The old daily table's writes stop, and a migration
DROPs it; - 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 correspondingDROP.
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 SELECTinto an archive schema). - Check foreign-key dependencies: if another table has a FK pointing at it,
DROP TABLEfails. Confirm dependencies are resolved, or deliberately useCASCADEâ butCASCADEcascades 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 withLIKE '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