Delete Behavior (@onDelete)
Controlling what happens to related records when a referenced record is deleted
When you delete a record that other records reference via foreign keys, Cerial needs to decide what to do with those dependent records. Should they be deleted too? Should their FK be cleared? Should the deletion be blocked entirely?
Cerial handles this automatically based on the FK field type, and you can customize the behavior with the @onDelete decorator.
Default Behavior
The default action depends on the FK field's type and modifiers:
| FK Type | Default Behavior | Configurable? |
|---|---|---|
Required (Record) | Cascade — deletes dependent records | No (always cascades) |
Optional (Record?) | SetNone — removes FK field (NONE) | Yes, via @onDelete |
Optional + nullable (Record? @nullable) | SetNull — sets FK to null | Yes, via @onDelete |
Array (Record[]) | Auto cleanup — removes ID from arrays | Automatic |
You don't need @onDelete for most cases — the defaults match the most common behavior for each FK type. Only add @onDelete when you want to override the default.
Required Relations (Auto-Cascade)
When a FK field is required (Record without ?), deleting the referenced record always cascades to delete all direct dependents. The @onDelete decorator is not allowed on required relations — they always cascade.
model User {
id Record @id
name String
posts Relation[] @model(Post)
}
model Post {
id Record @id
title String
authorId Record
author Relation @field(authorId) @model(User)
}// Deleting a user cascades to all their posts
await client.db.User.deleteMany({ where: { id: userId } });
// All Posts where authorId = userId are also deletedThe logic is straightforward: if a Post requires an author, it cannot exist without one. Deleting the author must delete the post.
Cascade depth
Cascade processes the direct dependents of the deleted records. If those deleted dependents have their own required relations, Cerial generates cascade statements for each level it can see during query building. However, this is not unlimited recursive traversal — Cerial processes each level of the dependency chain within a single transaction.
@onDelete Options
The @onDelete decorator overrides the default behavior for optional and nullable relations. There are five strategies:
Cascade
Deletes the dependent record when the referenced record is deleted.
model Profile {
id Record @id
bio String?
userId Record?
user Relation? @field(userId) @model(User) @onDelete(Cascade)
}await client.db.User.deleteMany({ where: { id: userId } });
// All Profiles where userId = userId are DELETEDUse Cascade when the dependent record has no meaning without its parent — even though the FK is technically optional.
SetNull
Sets the FK to null on the dependent record. The record itself is preserved, and the FK field remains present but holds a null value. This is the default for optional relations where the FK has @nullable.
model Post {
id Record @id
title String
authorId Record? @nullable
author Relation? @field(authorId) @model(User) @onDelete(SetNull)
}await client.db.User.deleteMany({ where: { id: userId } });
// All Posts where authorId = userId have authorId set to null
// The Posts themselves are preservedSince SetNull is the default for @nullable FK fields, these two declarations are equivalent:
author Relation? @field(authorId) @model(User) @onDelete(SetNull)# When authorId has @nullable, SetNull is the default
author Relation? @field(authorId) @model(User)@onDelete(SetNull) requires the backing Record field to have @nullable. Without @nullable, the field cannot hold null — use SetNone instead.
SetNone
Removes the FK field entirely (sets to NONE) when the referenced record is deleted. The record is preserved, but the FK field becomes absent. This is the default for optional relations where the FK does not have @nullable.
model Post {
id Record @id
title String
authorId Record?
author Relation? @field(authorId) @model(User) @onDelete(SetNone)
}await client.db.User.deleteMany({ where: { id: userId } });
// All Posts where authorId = userId have authorId removed (NONE)
// The Posts themselves are preserved@onDelete(SetNone) requires the backing Record field to be optional (?). Without ?, the field cannot be absent.
Restrict
Prevents deletion of the referenced record if any dependents exist. The delete operation throws an error instead of proceeding.
model Order {
id Record @id
total Float
customerId Record?
customer Relation? @field(customerId) @model(Customer) @onDelete(Restrict)
}// If the customer has any orders, this throws an error
await client.db.Customer.deleteMany({ where: { id: customerId } });
// Error: Cannot delete Customer because related Order records existUse Restrict to protect referential integrity when dependents should never become orphaned.
NoAction
Leaves the FK as-is, creating a dangling reference. The dependent record will have an FK pointing to a non-existent record.
model AuditLog {
id Record @id
action String
actorId Record?
actor Relation? @field(actorId) @model(User) @onDelete(NoAction)
}await client.db.User.deleteMany({ where: { id: userId } });
// AuditLog records still have actorId = userId (dangling reference)
// Querying the actor relation will return nullUse NoAction sparingly — it creates dangling references that can cause unexpected null results when querying relations. It's appropriate for audit logs or historical records where you intentionally want to preserve the original reference even after the target is gone.
Array Relations (Auto Cleanup)
When a record is deleted, its ID is automatically removed from all Record[] arrays that reference it. This happens regardless of any @onDelete setting — array cleanup is always automatic.
model User {
id Record @id
tagIds Record[]
tags Relation[] @field(tagIds) @model(Tag)
}
model Tag {
id Record @id
userIds Record[]
users Relation[] @field(userIds) @model(User)
}// Delete a tag
await client.db.Tag.deleteMany({ where: { name: 'deprecated' } });
// All Users who had this tag in their tagIds array
// will have the tag's ID removed automaticallyFor N
relations, both sides are cleaned up atomically — the deleted record's ID is removed from all arrays on both sides.@onDelete is not allowed on array relations (Relation[]). Array cleanup is always automatic, so there's nothing to configure.
Cascade Chains
When multiple models form a chain of required relations, cascade operations propagate through the chain within a single transaction:
model Organization {
id Record @id
name String
teams Relation[] @model(Team)
}
model Team {
id Record @id
name String
orgId Record
org Relation @field(orgId) @model(Organization)
members Relation[] @model(Member)
}
model Member {
id Record @id
name String
teamId Record
team Relation @field(teamId) @model(Team)
}// Deleting an Organization cascades through the chain:
await client.db.Organization.deleteMany({ where: { id: orgId } });
// 1. All Teams where orgId = orgId are deleted
// 2. All Members where teamId = (any deleted team) are deletedCerial generates the appropriate cascade statements for each level of the dependency chain, and they execute within a single transaction to ensure atomicity.
Rules and Restrictions
Where @onDelete is allowed
@onDelete is only valid on forward relations (those with @field) that are optional (?) or nullable (@nullable):
# ✅ Valid — optional forward relation
author Relation? @field(authorId) @model(User) @onDelete(Cascade)
# ✅ Valid — nullable (even without ?) forward relation
author Relation @nullable @field(authorId) @model(User) @onDelete(SetNull)Where @onDelete is NOT allowed
# ❌ Required relations — always auto-cascade
author Relation @field(authorId) @model(User) @onDelete(Cascade)
# ❌ Reverse relations — no @field, so no FK to manage
posts Relation[] @model(Post) @onDelete(Cascade)
# ❌ Array relations — auto-cleanup is always automatic
tags Relation[] @field(tagIds) @model(Tag) @onDelete(Cascade)Strategy-specific requirements
| Strategy | Requirement on backing Record field |
|---|---|
SetNull | Must have @nullable |
SetNone | Must be optional (?) |
Cascade | No special requirements |
Restrict | No special requirements |
NoAction | No special requirements |
Transaction Guarantees
All delete operations — including cascades, SetNull/SetNone updates, Restrict checks, and array cleanup — execute within a transaction. This ensures:
- Either all cascade operations complete, or none do
- Restrict checks happen before any deletions
- Array cleanup is atomic with the delete
- No partial state if any operation fails
Delete operations with cascade behavior also work inside $transaction, covered by a single atomic transaction.
Summary
| Strategy | Behavior | Default for | Use case |
|---|---|---|---|
| (required FK) | Cascade (always) | Record (required) | Child can't exist without parent |
@onDelete(SetNone) | Remove FK (NONE) | Record? (optional) | Preserve child, clear the reference |
@onDelete(SetNull) | Set FK to null | Record? @nullable | Preserve child, keep queryable null |
@onDelete(Cascade) | Delete dependents | (must be explicit) | Optional FK but child is meaningless without parent |
@onDelete(Restrict) | Block deletion | (must be explicit) | Protect referential integrity |
@onDelete(NoAction) | Leave dangling ref | (must be explicit) | Audit logs, historical records |
| (array FK) | Remove ID from array | Record[] (always) | Automatic for all array relations |