Hooks System
The hooks system provides data transformation and side effects during database operations.
Overview
Hooks allow you to:
- Transform data before it's saved to the database
- Transform data before it's returned to the user
- Perform validation beyond basic field rules
- Trigger side effects (logging, notifications, etc.)
Hook Types
List-Level Hooks
Defined at the list level, these hooks run for all operations on the list:
Post: list({
fields: {/* ... */},
hooks: {
resolveInput: async ({ resolvedData, operation, context }) => {
// Transform input data before database operation
if (operation === 'create' && resolvedData.status === 'published') {
resolvedData.publishedAt = new Date()
}
return resolvedData
},
validateInput: async ({ operation, resolvedData, addValidationError }) => {
if (operation === 'delete') return
// Custom validation logic
if (resolvedData.title?.includes('spam')) {
addValidationError('Title cannot contain spam')
}
},
beforeOperation: async ({ operation, resolvedData, context }) => {
// Side effects before database operation
console.log(`About to ${operation} a post`)
},
afterOperation: async ({ operation, item, originalItem, context }) => {
// Side effects after database operation
if (operation === 'create') {
// Send notification, invalidate cache, etc.
}
if (operation === 'update' && originalItem) {
// Compare previous and new values
console.log('Changed from:', originalItem, 'to:', item)
}
},
beforeTransaction: async ({ operation, inputData }) => {
// OUTSIDE the transaction — non-transactional side effects only.
},
afterTransaction: async (args) => {
// OUTSIDE the transaction — always runs; compensate on rollback.
if (args.status === 'rolled-back') {
// undo whatever beforeTransaction did externally (args.error explains why)
}
},
},
})
Field-Level Hooks
Defined on individual fields:
fields: {
password: password({
hooks: {
resolveInput: async ({ resolvedData, fieldKey }) => {
// Hash password before saving
const plaintext = resolvedData[fieldKey]
if (plaintext) {
return await bcrypt.hash(plaintext, 10)
}
},
resolveOutput: async ({ item, fieldKey }) => {
// Wrap with HashedPassword class
return new HashedPassword(item[fieldKey])
},
},
}),
}
Hook Execution Order
Write Operations (create/update)
- List-level
resolveInput- Transform input data at list level - Field-level
resolveInput- Transform individual field values - List-level
validateInput- Custom validation logic - Field validation - Built-in rules (isRequired, length, min/max)
- Field-level access control - Filter writable fields
- Field-level
beforeOperation- Side effects for individual fields - List-level
beforeOperation- Side effects at list level - Database operation
- List-level
afterOperation- Side effects at list level - Field-level
afterOperation- Side effects for individual fields
Read Operations (query)
- Database operation
- Field-level access control - Filter readable fields
- Field-level
resolveOutput- Transform individual field values - Field-level
afterOperation- Side effects for individual fields
In-transaction vs transaction-boundary hooks
Every write runs inside one database transaction (see ADR-0010). The side-effect hooks split into two families by where they run relative to that transaction:
In-transaction hooks —
beforeOperation/afterOperation. They run inside the transaction and roll back with it. Use them for work that must be atomic with the write — typically further database work throughcontext.db/context.prisma(which the pipeline binds to the transaction for the duration of the write). A throwingafterOperationrolls the write back. Do not make non-transactional external calls (HTTP, email, billing) here: holding a transaction open across a network call is bad, and such calls can't be rolled back.Transaction-boundary hooks —
beforeTransaction/afterTransaction. They run outside the transaction and form a compensation bracket around it:beforeTransactionruns before the transaction opens.afterTransactionruns after it settles and always runs (when its pairedbeforeTransactionran), receiving the outcome:status: 'committed' | 'rolled-back'. Oncommittedit gets the persisteditem; onrolled-backit gets theerrorthat caused the rollback and noitem— so it can undo whateverbeforeTransactiondid externally.
Both fire per list involved in the write (the top-level list plus each nested create/update/delete list). The bracket is symmetric: a list's
afterTransactionruns if and only if itsbeforeTransactionran, so every external action taken has its paired compensator. A throwingbeforeTransactionaborts the write (the transaction never opens) and triggersafterTransaction(rolled-back) only for the lists whosebeforeTransactionalready ran. If anafterTransactionitself throws, the remaining compensators still run and the error(s) are surfaced afterward — the database state is already final. Sudo does not affect these hooks; they always run.Important caveats for these hooks:
item/originalItemare populated only for the TOP-LEVEL record. Oncommitted, the persisteditem(andoriginalItemfor update/delete) is surfaced only for the top-level list; for nested lists they areundefined. The per-record persisted row is not reliably recoverable outside the transaction, and these hooks fire at list (not record) granularity. For per-record nested compensation use the in-transactionafterOperation, which receives the correct nesteditem. Transaction-boundary hooks on nested lists are for external-call compensation keyed offstatus/inputData.- Granularity is per-
(list, operation), not strictly per-list. A list reached under two operations (e.g. a nestedcreateand a nestedupdateof the same list) fires its boundary hooks twice — once per operation — and only the first nested record'sinputDatafor that operation is surfaced. Many nested records of the same(list, operation)fire the bracket once. connectOrCreateis enumerated as create-involvement best-effort. AconnectOrCreatethat resolves to connect (the row already exists) still fires the bracket as acreateinvolvement even though no row is written. Write your compensators to be idempotent so a no-op write is safe to compensate.afterTransactionreports the OUTERMOST transaction, not the write's own return (ADR-0028). A write that joins a transaction it did not open — one made insidecontext.transaction(), or a hook's owncontext.dbwrite — cannot itself observe when that enclosing transaction settles. ItsafterTransactionis deferred until the transaction owner (context.transaction(), or the Write Pipeline when it opened the transaction) observes the real settle, then fires with that outcome.beforeTransactionstays eager in every case — only the pairedafterTransactionis deferred.- Status is a conjunction:
committedif and only if the write itself succeeded and the enclosing transaction committed; otherwiserolled-back. A write's own error always wins over the transaction's outcome — a write that failed on its own reportsrolled-backeven if everything else in the transaction went on to commit. - The deferred
itemis stale-safe, not stale-free. It is the row as that write persisted it, captured at write time — not re-read at flush — so a later write to the same record in the same transaction leaves it stale in what the compensator sees. - A rejected
context.transaction()no longer implies rollback. If the transaction commits and a deferredafterTransactionthen throws,context.transaction()rejects withAfterTransactionErrorover data that is already final. A transaction/serialization error (e.g.P2034) still takes precedence and propagates unwrapped — a retry loop that catches broadly should not treat every rejection as "not committed". beforeTransactioncan now run with the transaction already open. Undercontext.transaction()it runs on the write's way in, so it holds that transaction open for its duration. Keep it fast, or hoist slow external work abovecontext.transaction()— acontext.dbwrite from inside it can otherwise block on rows the transaction itself is writing.- A write with no transaction owner at all (an application managing its own
prisma.$transaction, or a client that cannot open one — e.g. a bare test mock, with nocontext.transaction()wrapping it) still firesafterTransactionoptimistically at write time, exactly as before — there is no owner to defer to and no settle to wait for.
- Status is a conjunction:
Compensation pattern
Pair an external action in beforeTransaction with its undo in afterTransaction's rolled-back branch:
hooks: {
beforeTransaction: async ({ operation, inputData }) => {
// Non-transactional side effect — reserve an external resource.
await billing.reserveSeat(inputData.seatId)
},
afterTransaction: async (args) => {
if (args.status === 'rolled-back') {
// The DB write did not persist — release what beforeTransaction reserved.
await billing.releaseSeat(args.inputData.seatId)
} else {
// Committed — finalize the external action.
await billing.confirmSeat(args.item.seatId)
}
},
}
Hook Context
All hooks receive a context object with relevant information:
interface HookContext {
operation: 'create' | 'update' | 'delete' | 'query'
session: Session | null
context: Context
listKey: string
resolvedData?: any // For input hooks
item?: any // Current item (after operation)
originalItem?: any // Original item before operation (for update/delete)
originalInput?: any // Original input before transformations
}
Common Use Cases
Auto-Set Timestamps
resolveInput: async ({ resolvedData, operation }) => {
if (operation === 'create') {
resolvedData.createdAt = new Date()
}
if (operation === 'update') {
resolvedData.updatedAt = new Date()
}
return resolvedData
}
Slug Generation
fields: {
slug: text({
hooks: {
resolveInput: async ({ resolvedData, item, operation }) => {
// Generate slug from title if not provided
if (!resolvedData.slug && resolvedData.title) {
return resolvedData.title
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '')
}
},
},
}),
}
Password Hashing
password: password({
hooks: {
resolveInput: async ({ resolvedData, fieldKey }) => {
const plaintext = resolvedData[fieldKey]
if (plaintext) {
return await bcrypt.hash(plaintext, 10)
}
},
},
})
Cache Invalidation
afterOperation: async ({ operation, item, originalItem, context }) => {
if (['create', 'update', 'delete'].includes(operation)) {
// Invalidate cache
await redis.del(`post:${item.id}`)
// For updates, you can compare previous and new values
if (operation === 'update' && originalItem) {
if (originalItem.status !== item.status) {
console.log(`Status changed from ${originalItem.status} to ${item.status}`)
}
}
}
}
Audit Logging
beforeOperation: async ({ operation, resolvedData, context }) => {
await context.db.auditLog.create({
data: {
operation,
userId: context.session?.userId,
timestamp: new Date(),
data: resolvedData,
},
})
}
Best Practices
1. Keep Hooks Pure
Avoid side effects in resolveInput and resolveOutput. Use beforeOperation and afterOperation for side effects:
// ✅ Good: Pure transformation
resolveInput: ({ resolvedData }) => {
resolvedData.title = resolvedData.title.trim()
return resolvedData
}
// ❌ Bad: Side effects in resolveInput
resolveInput: async ({ resolvedData, context }) => {
await sendEmail() // Don't do this here!
return resolvedData
}
2. Use Async When Needed
All hooks can be async:
resolveInput: async ({ resolvedData }) => {
const result = await someAsyncOperation()
resolvedData.field = result
return resolvedData
}
3. Return Modified Data
Always return the modified data from resolveInput:
// ✅ Good: Returns modified data
resolveInput: ({ resolvedData }) => {
resolvedData.slug = generateSlug(resolvedData.title)
return resolvedData
}
// ❌ Bad: Doesn't return
resolveInput: ({ resolvedData }) => {
resolvedData.slug = generateSlug(resolvedData.title)
// Missing return!
}
Next Steps
- Access Control - Secure your data
- Field Types - Available field types
- Custom Fields - Create custom field types