From Seed Scripts to Desired-State Reference Data in PostgreSQL • pgmi
From Seed Scripts to Desired-State Reference Data in PostgreSQL#<br>Version the catalog that should exist — not the procedure that inserts it.<br>By Alexey Evlampiev<br>A seed script records how to construct a catalog. What you need to version is<br>the catalog that should exist.<br>The difference barely matters when seed.sql is twelve lookup rows. It starts to<br>matter once roles reference permissions, workflow states reference their legal<br>transitions, and plan tiers reference feature flags — once every edit has to<br>preserve foreign-key order by hand. At that point the script has quietly become a<br>hand-maintained topological sort of your data model: an acyclic insert-dependency<br>graph, revised on every new relationship and enforced by nothing but SQLSTATE<br>23503 at deploy time.<br>This article is about that kind of data — the catalogs a schema cannot function<br>without. Application roles and their permission grants. Status catalogs and<br>which transitions are legal. Tax rate tables and ISO currency codes. Call it<br>desired-state reference data : what you want to version is the catalog that<br>should exist, not the procedure that builds it. (It is not immutable — tax<br>rates and permissions change constantly — which is exactly why the maintenance<br>problem below bites.) Teams routinely carry tens of such tables; the worked<br>example here is three, and it already exhibits the core failure modes.<br>One file doing two jobs#<br>Here is the seed script for a three-table role/permission catalog, the way these<br>scripts actually look in the wild:<br>INSERT INTO permission (permission_id, key, description) VALUES<br>(1, 'invoice.read', 'View invoices'),<br>(2, 'invoice.approve', 'Approve invoices for payment'),<br>(3, 'report.run', 'Run financial reports'),<br>(4, 'catalog.edit', 'Maintain product catalog entries');
INSERT INTO role (role_id, key, description) VALUES<br>(1, 'auditor', 'Read-only access for audits'),<br>(2, 'controller', 'Approves spending'),<br>(3, 'catalog_manager', 'Owns the product catalog');
-- must run last, and every pair is a number puzzle<br>INSERT INTO role_permission (role_id, permission_id) VALUES<br>(1, 1), (1, 3),<br>(2, 1), (2, 2), (2, 3),<br>(3, 4);<br>The ordering ceremony. role_permission must come last; swap the statements<br>and the deploy fails with violates foreign key constraint (SQLSTATE 23503).<br>With three tables that is easy to keep straight. Each new relationship adds an<br>edge to the dependency graph you are sorting by hand. (PostgreSQL does offer an<br>escape hatch — constraints declared DEFERRABLE INITIALLY DEFERRED are checked<br>at commit rather than per statement — but deferring the check means violations<br>surface only at COMMIT, far from the statement that caused them, and it does<br>nothing for the surrogate keys or the validation gaps below.)<br>The surrogate-key puzzle. What does (2, 3) mean? You have to cross-<br>reference two other statements to find out it grants report.run to<br>controller. The diff of a permission change is a diff of integer pairs.<br>Django’s fixture system has surfaced this exact failure mode repeatedly —<br>hardcoded primary keys that collide and re-insert instead of updating<br>(ticket #31531<br>, and downstream<br>reports like NetBox #10940<br>where re-running loaddata hits duplicate keys).<br>Non-idempotent re-runs. Run the script twice and the second run dies on a<br>unique violation — unless every statement grows its own ON CONFLICT clause,<br>at which point the script is no longer data anyone can read.<br>The schema-evolution tax. Add a NOT NULL column without a default to<br>permission and every seed INSERT that omits it breaks. The script has to be<br>revised in lockstep with migrations. (This tax never fully disappears — it<br>moves, as we’ll see, from a hundred INSERTs into one loader contract.)<br>Constraints validate structure, not intent. A foreign key catches a<br>genuinely nonexistent id: (2, 99) where permission 99 does not exist fails<br>immediately with SQLSTATE 23503. What a foreign key cannot catch is an opaque<br>(2, 3) that satisfies the constraint while pointing at the wrong permission —<br>the mapping is valid SQL and wrong data. Nothing checks that the catalog is<br>internally coherent.<br>None of these is a fringe observation. Microsoft’s Entity Framework team renamed<br>EF Core’s “data seeding” feature to “model managed data” because, in their own<br>words, the old name “sets incorrect expectations, as the feature has a number<br>of limitations and is only appropriate for specific types of data” — the<br>documented limits: hardcoded primary keys even when the database would generate<br>them; data removal when a key changes; and explicit foreign-key values for<br>every child row (EF Core docs<br>this describes the HasData model-managed path, not EF’s runtime UseSeeding<br>hook). When a first-party vendor renames the category to lower expectations,<br>the category has a problem.<br>The common thread is that one file is doing two jobs: it encodes both the<br>desired catalog...