Orphaned Audit Rows from Soft-Delete Seeds
Most audit-log failures in test environments aren't schema bugs — they're lifecycle gaps. A seed script creates a users row with deleted_at set and status = 'deleted', but the audit_events table has nothing: no CREATED, no ACTIVE, no SUSPENDED transition ever fired. The application query that joins users to their audit trail returns zero rows, and your test either asserts on empty data or silently passes because it never checked the join.
The root cause is treating soft-delete as a column toggle rather than a state machine. When seeds jump straight to the terminal state, every downstream assertion that depends on the transition history — compliance reports, retention policies, event-sourced projections — is testing against structurally impossible data.
By the end of this article you'll have a factory pattern that walks entities through their full status graph before landing on the deleted state, a SQL assertion to catch orphans in CI, and a mental model for why this class of bug keeps recurring.
Learn Node.js, Cucumber, GitHub Copilot, APIs, CI/CD, and modern automation by building a complete framework.
Soft-Delete as a State Machine, Not a Flag
A soft-delete column (deleted_at TIMESTAMPTZ, is_deleted BOOLEAN, or a status enum) is the terminal state of a finite state machine. In most production systems that state machine looks something like: PENDING → ACTIVE → SUSPENDED? → DELETED. The audit table is the ledger of every edge traversal. If a seed writes the terminal node without writing the edges, the audit table is referentially hollow — the foreign key exists, but the event history that gives it meaning does not.
This matters beyond unit tests. Integration tests for GDPR deletion workflows, SOC 2 audit queries, and event-sourced read models all depend on a plausible event history. A seed that skips transitions produces the same class of silent corruption as orphaned snapshot rows left behind by schema migrations that skip backfill steps — the schema is valid, the data is not. Understanding the difference between a fixture, a seed, and test data is the prerequisite; if that line is blurry, the distinction between fixtures, seeds, and test data is worth nailing down before going further.
Building a Lifecycle-Aware Soft-Delete Factory
The fix is a factory that models transitions explicitly. Using factory_boy with a Pydantic model for the audit event, define a SoftDeletedUserFactory that emits both the user row and its required audit trail in a single call. The key is a _after_create hook that walks the state graph before setting the deleted state.
import factory
from datetime import datetime, timezone
from myapp.models import User, AuditEvent
STATUS_GRAPH = ["PENDING", "ACTIVE", "DELETED"]
class SoftDeletedUserFactory(factory.django.DjangoModelFactory):
class Meta:
model = User
email = factory.Faker("email")
status = "DELETED"
deleted_at = factory.LazyFunction(lambda: datetime.now(timezone.utc))
@factory.post_generation
def audit_trail(obj, create, extracted, **kwargs):
if not create:
return
transitions = STATUS_GRAPH[: STATUS_GRAPH.index(obj.status) + 1]
for i, state in enumerate(transitions):
AuditEvent.objects.create(
entity_id=obj.pk,
entity_type="user",
to_status=state,
from_status=transitions[i - 1] if i > 0 else None,
occurred_at=datetime.now(timezone.utc),
)
The STATUS_GRAPH slice is the critical detail — it ensures every intermediate state gets an audit row, not just the terminal one. Adding a new intermediate state (say, SUSPENDED between ACTIVE and DELETED) requires only one list edit; all factories that call this path inherit the change automatically.
Pair this with a SQL assertion that runs as a Great Expectations custom expectation or a raw Pytest fixture teardown check:
-- Orphan detection: deleted users with no CREATED audit event
SELECT u.id, u.status, u.deleted_at
FROM users u
LEFT JOIN audit_events ae
ON ae.entity_id = u.id
AND ae.entity_type = 'user'
AND ae.to_status = 'PENDING'
WHERE u.status = 'DELETED'
AND ae.entity_id IS NULL;
Run this query in a GitHub Actions step after seed execution and before any integration test suite. In one internal migration project, adding this gate caught 34 orphaned user seeds that had been silently skewing a retention-period report for two sprints — the kind of failure that shows up in a compliance review, not a test run. Be mindful of timestamp handling in the occurred_at column: timezone-naive datetimes here will corrupt any audit query that joins against a fiscal or interval boundary, a problem covered in depth when looking at timezone-naive timestamps that break interval assertions at seed time.
For teams using dbt seeds, the equivalent is a singular test in tests/ that joins the seed CSV against the audit seed CSV and asserts zero orphans. Keep both CSVs version-controlled together; a PR that modifies seeds/users.csv without touching seeds/audit_events.csv should fail the dbt test run by design.
Where Senior Engineers Still Get Burned
The most common mistake is writing a deleted_user factory as a one-liner override of a base factory — status='DELETED', deleted_at=now() — without touching the audit table. This happens because the base factory was written before the audit table existed, and nobody updated it when the audit requirement landed. The mental model is "I'm seeding a user," not "I'm seeding a user lifecycle." The fix is to make the audit trail a required output of the factory, not an optional post-generation step that callers can forget to invoke.
The second mistake is using database-level triggers to generate audit rows in production but not in the test database. Engineers assume the trigger fires in test too, seed the deleted state directly, and the trigger never runs because the test DB was stood up from migrations only — triggers weren't included. Always verify trigger parity between environments with a schema diff step in CI, or better, move audit generation into the application layer where it's environment-agnostic and testable.
Myths That Keep Orphan Rows in Production Seeds
Myth 1: "A referentially valid row is a realistic row." Foreign keys enforce existence, not plausibility. A deleted user with a valid id but no audit history passes every FK constraint and fails every business-logic assertion that touches the event log. Referential integrity is a floor, not a ceiling. Myth 2: "Prod data clones solve this." A prod clone has real audit trails, but it also has real PII, real edge cases from years of organic growth, and no guarantees about which deleted users have complete vs. incomplete histories — legacy migrations often left gaps. Clones are not a substitute for intentionally modeled lifecycle seeds.
Myth 3: "The soft-delete column is the source of truth." In any system with an audit table, the event log is the source of truth; the status column is a materialized cache of the latest event. Seeds that write the cache without writing the log invert the architecture. When you later add an event-sourced projection or a Kafka consumer that replays audit_events, your test environment produces results that diverge from production behavior on day one of the feature — not because the feature is broken, but because the seed data was never structurally honest.
Orphaned audit rows are a seed design problem, not a test design problem — fixing them at the assertion layer is treating symptoms. Model your soft-delete factories as state-machine walkers, add an orphan-detection SQL gate to CI, and enforce trigger parity between environments. For the next layer of seed correctness, the patterns around nullable foreign keys seeding as zero apply the same structural-honesty principle to a different class of referential ambiguity.
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.