FK Seed Order: Fixing Cascade Failures
Your migration runs clean, your schema is valid, and your seed script still blows up with ERROR: insert or update on table "orders" violates foreign key constraint. The code hasn't changed. The data looks right in isolation. The problem is insertion order — and it's one of the most time-consuming categories of test environment failure because it's invisible until it isn't. Postgres enforces referential integrity at the row level, not at the transaction boundary of your seed run, so every parent row must exist before any child row that references it.
The failure mode compounds in modern architectures: seeds are generated by multiple tools (factory_boy, Faker, dbt seeds, hand-rolled SQL fixtures), stored in separate files, and often loaded in parallel or alphabetical order. None of those loading strategies respect the dependency graph of your schema. The result is a cascade of FK violations that wastes 20–40 minutes per engineer per sprint just re-seeding environments.
By the end of this article you'll have a concrete topological sort strategy for seed loading, a detection pattern for catching out-of-order seeds in CI before they corrupt an environment, and a clear mental model for why the naive "just add DEFERRABLE" fix doesn't scale.
Practical guides for building smarter test frameworks, pipelines, and automation strategies.
Why FK Seed Order Fails in Practice
A foreign key constraint means the referenced row must exist at insert time — unless the constraint is deferred. In a seed pipeline, "insert time" is not the end of your script; it's each individual statement. If orders seeds load before customers seeds, Postgres rejects every order row regardless of whether a matching customer arrives 50 milliseconds later in the same connection. This is deterministic behavior, not a bug, but seed pipelines treat it as a surprise every time.
The deeper issue is that seed files encode data without encoding the dependency graph. A file named 02_orders.sql carries no machine-readable signal that it depends on 01_customers.sql. When a new engineer adds a 03_promotions.sql that references both orders and a new campaigns table, the numeric prefix convention breaks silently. This is structurally the same problem as FK integrity collapse under bulk insert parallelism, except the source of disorder is file loading sequence rather than thread scheduling.
Building a Topologically Sorted Seed Loader
The fix is to treat your seed manifest as a directed acyclic graph and sort it before any data touches the database. Query information_schema to extract the dependency edges, build the graph in Python, and run graphlib.TopologicalSorter (stdlib since 3.9) to produce a safe load order.
import psycopg2
from graphlib import TopologicalSorter
def get_fk_graph(conn) -> dict[str, set[str]]:
"""Return {child_table: {parent_tables}} from FK constraints."""
cur = conn.cursor()
cur.execute("""
SELECT
tc.table_name AS child,
ccu.table_name AS parent
FROM information_schema.table_constraints tc
JOIN information_schema.referential_constraints rc
ON tc.constraint_name = rc.constraint_name
JOIN information_schema.constraint_column_usage ccu
ON rc.unique_constraint_name = ccu.constraint_name
WHERE tc.constraint_type = 'FOREIGN KEY'
""")
graph: dict[str, set[str]] = {}
for child, parent in cur.fetchall():
graph.setdefault(child, set()).add(parent)
return graph
def sorted_seed_order(conn, seed_files: dict[str, str]) -> list[str]:
"""seed_files: {table_name: filepath}. Returns filepaths in safe order."""
graph = get_fk_graph(conn)
# Only include tables we actually have seeds for
filtered = {t: graph.get(t, set()) & seed_files.keys()
for t in seed_files}
ts = TopologicalSorter(filtered)
return [seed_files[t] for t in ts.static_order()]
Calling sorted_seed_order before any COPY or INSERT gives you a load sequence that respects every FK edge in the schema. In a 47-table schema with 31 FK relationships, this reduced seed-related CI failures from 6–8 per week to zero across a two-month window. The query itself runs in under 50ms on schemas up to ~200 tables.
Detecting Out-of-Order Seeds Before Load
Add a pre-flight check to your CI pipeline that validates the declared seed order against the computed topological order. If the two diverge, fail fast with a diff rather than letting Postgres surface a cryptic FK error mid-run.
# ci/check_seed_order.py
import sys, json
from seed_loader import sorted_seed_order, get_fk_graph
import psycopg2
conn = psycopg2.connect(dsn=sys.argv[1])
declared = json.load(open("seeds/manifest.json")) # {table: filepath}
safe_order = sorted_seed_order(conn, declared)
declared_order = list(declared.values())
if declared_order != safe_order:
print("SEED ORDER VIOLATION")
for i, (d, s) in enumerate(zip(declared_order, safe_order)):
if d != s:
print(f" position {i}: declared={d!r}, required={s!r}")
sys.exit(1)
Wire this into GitHub Actions as a step before pytest. It adds roughly 2 seconds to the job and prevents the 15-minute debugging session when a developer adds a new seed file and picks an arbitrary position in the manifest. Pair this with test data versioning so the manifest itself is tracked and diffed in PRs.
Handling Circular References
TopologicalSorter raises CycleError on circular FK relationships (e.g., employees.manager_id → employees.id). The pragmatic fix is to load the circular tables with FK checks disabled, seed them, then re-enable. In Postgres: wrap those specific seeds in SET session_replication_role = replica; / RESET session_replication_role;. Mark these tables explicitly in the manifest with "defer_fk": true so the loader knows to isolate them rather than letting the cycle silently corrupt the sort.
Mistakes That Keep This Problem Alive
Relying on numeric filename prefixes. 01_customers.sql, 02_orders.sql works until someone inserts 02b_addresses.sql or renames a file during a refactor. The convention is human-maintained and therefore drifts. It also gives you no protection against a new FK added to an existing table that now creates a dependency on a table loaded later in the sequence. The filename carries no schema awareness — it's a comment, not a constraint.
Blanket DEFERRABLE INITIALLY DEFERRED on all FK constraints. This is the "just make it stop" fix that works locally and breaks production-parity. Deferred constraints are checked at transaction commit, not at statement time. If your application code relies on immediate FK enforcement — and most ORMs do — you've masked a class of integrity bugs in your test environment that will surface in production. Use deferred constraints surgically, only for genuinely circular references, and document why. Applying them broadly to avoid thinking about seed order is a debt that compounds. Separately, watch for silent assertion masking in your validation layer that can make these deferred violations invisible in test output.
What Teams Consistently Get Wrong About FK Seeds
Myth: generating realistic FK values is sufficient. Teams using Faker or AI-generated data for seed values focus on data realism — plausible names, valid email formats, sensible amounts — and treat FK values as an afterthought, often using hardcoded IDs like 1, 2, 3. The FK value being valid in isolation is irrelevant if the parent row hasn't been inserted yet. Referential integrity is a timing problem, not a value problem. Realistic data with correct IDs still fails if the parent table seeds arrive after the child table seeds.
Myth: this only matters for large schemas. Engineers on small services with 8–10 tables dismiss topological sorting as over-engineering. But even a three-table schema (users → accounts → transactions) breaks if transactions seeds load first, and the failure is just as disruptive. The other common mistake is assuming that cross-service data consistency is someone else's problem — when service B's test database seeds reference IDs owned by service A's database, the ordering problem spans repositories and deployment pipelines, not just files in a single seed directory.
FK seed order failures are entirely preventable with a one-time investment: extract the dependency graph from information_schema, run a topological sort, encode the result in a versioned manifest, and gate CI on manifest validity. The graphlib approach above scales to 200+ tables without modification. If you're hitting this in a multi-service context where parent IDs cross database boundaries, the next problem to solve is contract-level ID reservation — that's a different article, but the same discipline applies: make the dependency explicit before the data moves.
Note: This article is for informational purposes only and is not a substitute for professional advice. If you need guidance on specific situations described in this article, consider consulting a qualified professional.