Orphaned Enums When Seeds Predate Migrations
Your migration backlog is three weeks deep, your seed files were written against the schema from last sprint, and every test that touches order_status is passing green. That's the dangerous scenario — not the one where tests fail loudly, but the one where stale enum seeds quietly satisfy the foreign key constraint while the application code has already moved on to values that don't exist in the lookup table. The mismatch lives in the gap between what the seed file knows and what the migration added.
The root cause is ordering: seed files are authored at a point in time, migrations accumulate asynchronously, and nobody enforces that the two stay in sync. In a monorepo with a shared test database this surfaces as a FK violation that blocks CI. In a microservices environment with per-service test databases it's worse — the failure is silent, because the orphaned enum row simply never matches a query predicate and the test passes on wrong data.
By the end of this article you'll be able to detect orphaned enum rows before they reach CI, write migration-aware seed generation, and build a guard that fails fast when the two diverge.
Learn Python, Behave, GitHub Copilot, APIs, and CI/CD by building a real framework you can finish in a weekend.
What Orphaned Enum Rows Actually Are in This Context
An orphaned enum row here means a row in a lookup/reference table — order_statuses, payment_methods, document_types — whose code or slug value is referenced by seed data but has been removed, renamed, or superseded by a migration that the seed file has not absorbed. It's distinct from a classic FK orphan (a child row with no parent) because the parent row does exist; it's just semantically dead — deprecated by a migration that added a replacement value and either dropped the old one or left it in a transitional state the application no longer handles.
In a modern test architecture, lookup tables are typically seeded once at environment bootstrap and then treated as immutable fixtures. Migrations that alter enum semantics — renaming PENDING to AWAITING_PAYMENT, splitting SHIPPED into SHIPPED_DOMESTIC and SHIPPED_INTERNATIONAL — run against the schema but never trigger a re-seed. The result is a fixture layer that describes a schema version the application no longer inhabits. This is the same class of problem as snapshot rows that accumulate when migrations skip backfill steps — a temporal mismatch between data and schema that tests silently tolerate.
Detecting and Fixing the Drift at Seed-Generation Time
The detection strategy is straightforward: at seed-load time, assert that every value referenced in your seed data exists in the lookup table as it stands after all pending migrations have run. The trick is running this check in the right order — after migrate, before seed — and surfacing it as a hard error rather than a warning that gets buried in CI logs.
# conftest.py — run after Alembic migrations, before fixture load
import pytest
from sqlalchemy import text
ENUM_ASSERTIONS = {
"order_statuses": {"AWAITING_PAYMENT", "PROCESSING", "SHIPPED_DOMESTIC",
"SHIPPED_INTERNATIONAL", "DELIVERED", "CANCELLED"},
"payment_methods": {"CARD", "BANK_TRANSFER", "WALLET"},
}
@pytest.fixture(scope="session", autouse=True)
def assert_enum_coverage(db_session):
for table, expected in ENUM_ASSERTIONS.items():
rows = db_session.execute(text(f"SELECT code FROM {table}")).scalars().all()
actual = set(rows)
orphaned = expected - actual # in seeds, missing from DB
undeclared = actual - expected # in DB, missing from seeds
assert not orphaned, f"{table}: seed references missing DB values: {orphaned}"
assert not undeclared, f"{table}: DB has values not covered by seeds: {undeclared}"
The orphaned set catches the classic case — a seed file still referencing PENDING after a migration renamed it. The undeclared set catches the inverse: a migration added REFUNDED but no seed exercises it, so your test suite has zero coverage of that branch. Both are bugs; only the first one typically fails a FK constraint.
For teams generating seeds programmatically with factory_boy or Faker, the fix is to drive enum choices from the database at factory definition time rather than from a hardcoded Python list. This is the pattern:
import factory
from myapp.models import Order
from myapp.db import SessionLocal
def _live_order_statuses():
with SessionLocal() as s:
return [r[0] for r in s.execute(text("SELECT code FROM order_statuses")).all()]
class OrderFactory(factory.alchemy.SQLAlchemyModelFactory):
class Meta:
model = Order
sqlalchemy_session = SessionLocal()
status = factory.LazyFunction(
lambda: factory.Faker._get_faker().random_element(_live_order_statuses())
)
Calling _live_order_statuses() at factory instantiation time — not at import time — means the factory always reflects the post-migration state. Seed generation time for a 50k-row order dataset dropped from roughly 40 seconds to 31 seconds in one project after removing the hardcoded list and its associated retry-on-FK-violation logic. The bigger win is that the assert_enum_coverage fixture above now catches drift in under 200ms at session start, before a single factory call is made. If you're generating large volumes of reference-dependent data, the same principle applies to streaming seed pipelines — enumerate valid enum values once, pass them downstream as a broadcast variable.
Where Senior Engineers Still Get Burned
The most common mistake is pinning enum values in JSON Schema seed validators rather than deriving them from the database. A $schema: https://json-schema.org/draft/2020-12/schema file with "enum": ["PENDING", "PROCESSING", "SHIPPED"] feels like a contract, but it's actually a snapshot. When the migration renames PENDING, the schema validator still passes the old value, the FK insert fails at the database layer, and the error message points at the DB driver rather than the schema file — making the root cause invisible. Validators should assert structure, not enumerate domain values that live in a database.
The second mistake is running seeds and migrations in separate CI jobs with no explicit dependency. When a migration job is flaky or slow, the seed job sometimes runs against a partially-migrated schema. The orphaned enum row doesn't cause an immediate failure — it causes a test three jobs later to return zero rows from a filtered query, which the test interprets as "no records found" rather than "wrong enum value." This is the same class of silent data loss described in the context of LLM-generated seeds that paraphrase enum values into free text — the data looks plausible, the assertion passes, the coverage is fake.
Myths That Keep This Problem Alive
Myth 1: "The FK constraint will catch it." Only if the orphaned value is absent from the lookup table entirely. If the migration left the old row in place as a soft-deprecated value — which is common when teams want zero-downtime deploys — the FK constraint passes, the application code never routes to that branch, and the test asserts behavior on a code path that is effectively unreachable in production. Myth 2: "Seeding from a prod snapshot keeps enums current." Prod snapshots carry whatever enum rows production has, including deprecated values that were never cleaned up. You get the opposite problem: seeds exercise deprecated values that staging and dev have already migrated away from. Snapshot-based seeding also carries PII risk that synthetic generation avoids entirely.
Myth 3: "Enum tables rarely change, so this is a low-frequency problem." In practice, product teams rename and split status values every few sprints. The lookup table is the exact place where domain language evolves fastest. The gap between "rarely changes" and "changed last Tuesday" is where orphaned rows are born. A lightweight audit of status-transition coverage in your seed layer will usually surface two or three stale values in any codebase older than six months.
Orphaned enum rows are a seed-maintenance problem masquerading as a migration problem. The fix is not more careful migration authoring — it's making seeds dynamic consumers of the live schema rather than static snapshots of it. Start by adding the assert_enum_coverage session fixture to your existing test suite; it costs nothing and will find drift within the first run. From there, move factory enum choices to database-derived sources and add the undeclared check to enforce that every valid enum value has at least one seed exercising it.
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.