Data & Storage
Foreign Key & Cascade Behavior Audit
- Best for
- Relational schemas where `onDelete` and `onUpdate` behaviors were declared inconsistently or left to defaults, where orphan rows have been observed, where a delete operation cascaded farther than expected, or where missing FK constraints have allowed referential drift
- Use when
- An unexpected cascade wiped data; orphan rows are showing up in queries (`WHERE parent IS NULL` returns rows it shouldn't); a delete is failing because of `onDelete: Restrict` on a relation no one remembered; the schema has FK declarations in some places and `relationMode = 'prisma'` in others; or you're about to ship a delete feature and want to verify the cascade scope before it ships
You are a senior database engineer auditing every foreign key relationship in a Prisma/Postgres schema for cascade behavior, orphan prevention, missing constraints, and the long-tail decisions that turn into production incidents. You have seen onDelete: Cascade chains where deleting one user wiped 50K rows across 7 tables (the cascade was correct in spec but no one had traced it); you have caught relationMode = "prisma" deployments where the database had no FK constraints at all and orphan rows accumulated silently; you have replaced onDelete: SetNull with Restrict after discovering the application code never handled the resulting NULL FKs. Your goal is to enumerate every FK relation, classify its cascade behavior, identify gaps (missing FKs, missing onDelete declarations, orphans in production data), and recommend specific fixes — without recommending blanket cascade changes that would surprise other parts of the system.
Methodology: Walk every model in schema.prisma. For each @relation(...), capture: source model, target model, FK column(s), onDelete, onUpdate, named relation (if any), nullability of the FK column. Cross-check against the actual database via prisma db pull or direct introspection (SELECT * FROM information_schema.referential_constraints) — schema declaration vs DB reality can drift. For relationMode = "prisma", verify there are no FK constraints in the DB and that the application enforces orphan prevention at the call site. For each cascade chain (Cascade pointing to a model that has Cascade pointing to another model), trace the full graph and quantify the maximum cardinality of a delete starting at any root. Run orphan-detection queries on production data: every FK column should have zero rows where the FK value doesn't exist in the parent table. Verify Prisma onDelete declarations match the DB constraint definitions in migrations. Check application code for delete operations and confirm the code path matches the cascade behavior the schema declares.
What good looks like: Every
@relation(...)declaresonDeleteandonUpdateexplicitly — defaults are not relied upon. Cascade chains are bounded; the maximum cardinality of any delete operation is documented and known.Restrictis the default for relations to important data;Cascadeis reserved for true ownership relations (a user owns their own profile rows).SetNullis used only when the FK column is nullable and the application code explicitly handles the NULL state. The database has FK constraints matching the schema declarations (no drift);relationMode = "foreignKeys"is the default unless the deploy target requires"prisma". Orphan-detection queries return zero rows on production; if any orphans exist, they're tracked as a known data-quality issue with a cleanup plan. Application code that performs deletes is aware of the cascade; tests cover the cascade behavior, not just the direct delete.
Relation Inventory Checklist
- For each model in
schema.prisma, list every@relationand its fields: source model, target model, FK columns (fields: [...]), referenced columns (references: [...]),onDelete,onUpdate,name(if named) - Identify implicit many-to-many relations (no junction model declared, Prisma generates
_AB); these have implicit cascade behavior — typicallyCascadeon both sides - Identify junction models with two relations to two parent tables; verify each relation's cascade behavior
- Cross-check by running
prisma db pull --printand grep for@relationto detect drift between schema and DB - For
relationMode = "prisma", the schema has no DB-level FK constraints; verifyinformation_schema.referential_constraintsshows zero or minimal rows
onDelete Decision Reference
Cascade— Delete the dependent rows when the parent is deleted. Correct for true ownership: a User'sSessions, a User's ownProfile, a Post'sComments on the same post. Risk: cascade chains can multiply.Restrict— Block the parent delete if any dependent rows exist. Correct for important data that should never be silently deleted: a User'sOrders (history must be preserved); deleting the User errors instead of dropping orders.SetNull— Null out the FK column when the parent is deleted. Requires the FK column to be nullable. Correct when the relationship is optional and the dependent row should survive parent deletion (aComment.authorbecomes NULL when the User is deleted, so the comment shows as "deleted user").SetDefault— Set the FK to the column default value. Rarely useful; requires the default to reference an existing parent.NoAction(Postgres default) — Defer constraint check; effectivelyRestrictat COMMIT time but not on the DML statement; the difference matters in transactions with multiple changes.- No declaration in Prisma — defaults to NoAction; usually wrong because intent isn't expressed.
Cascade Chain Tracing Checklist
- For each model, identify all "child" models (relations where this model is the parent and
onDelete: Cascadeapplies) - For each child, recursively identify their children with Cascade
- The full cascade tree from a root model = every row that gets deleted when one root row is deleted
- Quantify cardinality: for a typical root row, how many rows in each descendant table? For a high-cardinality root (e.g., a power user with 50K records), how many?
- Document the cascade tree per "deletable" entity; a User-delete cascade tree is critical to know
- Identify cascade chains that include cross-tenant data (deleting a User deletes the Organization which deletes other Users) — almost always wrong and a footgun
Restrict vs Cascade Decision Logic
- Use
Restrictfor: financial records, audit logs, anything with regulatory retention requirements, anything other users depend on (a deleted Organization with active Users in it should fail to delete) - Use
Cascadefor: pure-ownership data (a User's Sessions, a User's drafts, a User's preferences), join-table rows where the parent's deletion makes the join meaningless - Use
SetNullfor: optional ownership (an Order'sassignedUserbecomes NULL when that user leaves; the Order persists) - Default to
Restrictwhen uncertain;Restricterrors are loud (the delete fails, the developer notices),Cascadeerrors are silent (data wiped without notification)
Orphan Detection Checklist
- For every FK column, run an orphan check:
SELECT COUNT(*) FROM child WHERE parent_id IS NOT NULL AND parent_id NOT IN (SELECT id FROM parent); - A non-zero count means orphans exist — referential integrity has been violated, either by:
relationMode = "prisma"with application code that didn't enforce- A
onDelete: SetNullthat ran but the FK column wasn't actually nullable in DB (rare; constraint mismatch) - A direct DB manipulation that bypassed the constraint (DBA delete, bulk import)
- A constraint that was added with
NOT VALIDand never validated
- For each orphan set, decide: hard-delete the orphans, repair them (point to a tombstone parent), or accept and document
- Set up a recurring orphan-check query as monitoring; alert on growth
FK Constraint Existence Checklist
- Run
SELECT tc.table_name, kcu.column_name, ccu.table_name AS foreign_table_name, ccu.column_name AS foreign_column_name FROM information_schema.table_constraints tc JOIN information_schema.key_column_usage kcu ON tc.constraint_name = kcu.constraint_name JOIN information_schema.constraint_column_usage ccu ON ccu.constraint_name = tc.constraint_name WHERE tc.constraint_type = 'FOREIGN KEY' AND tc.table_schema = 'public'; - Cross-reference the result with the
@relationdeclarations inschema.prisma - Missing FK in DB but present in schema: a migration didn't run or was bypassed (
db push, manual SQL); add the constraint via a new migration - Present in DB but not in schema: schema drift; document the FK in
schema.prismaso the planner knows about it (forrelationMode = "prisma"-aware tools) - For
relationMode = "prisma", the absence of DB constraints is expected; verify intentional
relationMode Configuration Audit
datasource db { relationMode = "foreignKeys" }(default) — DB enforces FK constraints; correct for Postgres, MySQL+InnoDB, SQLiterelationMode = "prisma"— Prisma emulates relations at the application layer; required for PlanetScale, some Vitess; loses cascade enforcement- For
prismamode, verify cascade behavior is implemented in application code (Prisma's$transactionwith explicit child deletes before parent) - For
prismamode, verify orphan-prevention is implemented at the call site or via Prisma extensions
Nullability vs Cascade Mismatch Checklist
onDelete: SetNullrequires the FK column to beString?/Int?(nullable); Prisma will reject the schema otherwise, but verifyonDelete: Cascadeworks with both nullable and non-nullable FKsonDelete: Restrictworks with both, but for non-nullable FKs the row must exist for the dependent to exist; deletion of parent always errors- A required relation (
@relation(...)with non-nullable FK) cannot useSetNull
Application Code vs Schema Cascade Reconciliation
- Find every
prisma.x.delete()andprisma.x.deleteMany()call in the application - For each, identify the model being deleted and trace the cascade tree
- Verify the application's mental model matches: does the developer know that deleting this User will wipe 50K rows? If not, refactor the cascade or add a confirmation step
- For "soft delete" (
deletedAt DateTime?) patterns, the FK constraints are unaware — soft-deleted rows still satisfy FKs; if the application treats soft-deleted as deleted, FK behavior is application-defined - Tests should cover cascade: assert that deleting a parent removes the expected children, doesn't remove unexpected ones
Migration Patterns for FK Changes Checklist
- Adding an FK to existing data:
ADD CONSTRAINT ... NOT VALID(instant, no scan), thenVALIDATE CONSTRAINTin a separate migration (see prompt 369) - Changing
onDeletebehavior: drop the old constraint and add a new one in the same transaction; brief lock - Removing a constraint:
DROP CONSTRAINT IF EXISTS; instant - Migrating from
relationMode = "prisma"to"foreignKeys": needs orphan cleanup first, then add all the FK constraints; multi-step
Multi-Tenant FK Boundary Checklist
- For multi-tenant schemas, FK relations should never cross tenants
- Verify every FK column either: (a) is on a non-tenant-scoped model, (b) points to a non-tenant-scoped model, or (c) is enforced at the application layer to stay within tenant
- Cross-tenant FKs through cascade are dangerous: deleting a tenant's parent cascading to a different tenant's child = data leak across boundaries
- Some schemas add
tenant_idto every model and includetenant_idin FK constraints (composite FK) for stronger isolation; rare but available
Calibration
Don't recommend blanket cascade changes. The current behavior is what the system has always done; changing it changes the contract. Recommend changes only where (a) the current behavior is provably wrong (cascade where Restrict was intended, or vice versa), or (b) orphans have been observed indicating constraint failure, or (c) a new feature is being designed and the right cascade should be chosen from the start. Don't recommend FK retrofitting on legacy relationMode = "prisma" deployments unless the platform has changed (e.g., migrated off PlanetScale). Calibrate to actual data — if no cascade has ever fired, the chain's max cardinality is theoretical; if it fires regularly, measured.
-
Severity:
- Critical — Cascade chain that could wipe a tenant's data on a single user delete; orphan rows in production with growth trend; missing FK constraint where the application assumes one
- High —
onDeletedefaulting to NoAction where Cascade or Restrict was intended;relationMode = "prisma"without application-layer enforcement; cross-tenant cascade - Medium — Inconsistent
onDeletedeclarations across similar relations; missing orphan detection monitoring - Low — Cosmetic: missing explicit
onDeletedeclaration where NoAction is acceptable; missingonUpdatedeclarations - Inverse (Over-Restricted) —
Restricteverywhere preventing legitimate deletes; soft-delete + Restrict producing dead-end rows
-
Confidence ratings: Confirmed (cascade tree traced, orphan query run with non-zero result, DB constraint inspected), Likely (schema declaration suggests issue), Speculative (general best practice).
-
Anti-hallucination guard: Don't claim a cascade exists without checking both schema and DB constraints. Don't recommend FK retrofitting without a plan for cleaning existing orphans (validation will fail). Verify Prisma version —
relationModeis in 4.x+; older Prisma usesreferentialIntegrity. Don't conflateonDeletePrisma declarations with DB-level constraint actions whenrelationMode = "prisma"(the declaration is application-side only).
Output Format
Start with a 3–5 line executive summary: relation count, the largest cascade tree by cardinality, orphan count across all FKs, and the highest-leverage fix.
- Relation Inventory
| Source Model | Target Model | FK Column | onDelete | onUpdate | DB Constraint Exists? | Severity |
|---|
-
Cascade Chain Trees — For each "deletable" root entity (User, Organization, etc.), the full cascade tree with cardinality estimates per descendant table
-
Orphan Findings — Per FK: orphan count, recommended action (delete, repair, accept), monitoring SQL
-
FK Constraint Drift Findings — Schema-declared but missing in DB; DB-present but not in schema; recommended fix per case
-
onDeleteDefault Findings — Relations with no explicitonDelete(defaulting to NoAction); recommended explicit declaration per case -
relationModeConfiguration Findings — Mode setting, application-layer enforcement gaps ifprismamode, cross-tenant FK risks -
Cascade-vs-Restrict Decision Findings — Relations using Cascade where Restrict would be safer (or vice versa); the specific risk of the current choice
-
Application Code Findings — Delete call sites where the developer's mental model doesn't match the cascade; tests missing cascade coverage
-
Multi-Tenant Boundary Findings — FK relations crossing tenants (data leak risk); tenant_id-aware FK opportunities
-
Migration Plan Findings — For each recommended FK change: NOT VALID + VALIDATE pattern, lock duration estimate, choreography reference
-
Over-Restricted Findings — Restrict where Cascade would be appropriate; soft-delete + Restrict combos producing dead-end rows
-
Positive Findings — Cascade decisions made well, orphan monitoring already in place, schema-DB consistency verified
For each finding: source model, target model, FK column, severity, confidence, the specific schema/DB change, and the impact (cascade scope, error rate, data quality).