How middleware works
Middleware lets you run your own code around every query your app sends through Prisma ORM.
For example, you can log every query with its latency, block a DELETE that has no WHERE clause before it reaches the database, or serve a repeated read from memory instead of running it again. You write the policy once, register it once, and every query follows it, with no call sites to update.
If you used $use in Prisma ORM 7, the middleware array is its replacement. There is no next(): you pick the hook that runs at the moment you care about. Coming from Prisma ORM 7 lists Prisma ORM 7 features next to the Prisma ORM 8 form each one takes.
A middleware is a plain object with a name and one or more hooks, and a hook is a function that Prisma ORM calls at a fixed moment around a query. You register the whole object once, in the middleware option of your client setup.
The setup below registers all three middleware Prisma ships. lints and budgets are already inside @prisma/orm-postgres, but the cache ships as its own package, so install that one first:
bun add @prisma/orm-extension-middleware-cachepnpm add @prisma/orm-extension-middleware-cacheyarn add @prisma/orm-extension-middleware-cachenpm install @prisma/orm-extension-middleware-cacheThe src/prisma/db.ts below imports two files from your own project, contract.json and contract.d.ts. Both come from your contract, which is the Prisma ORM 8 name for your schema: contract.prisma in a generated project, in place of schema.prisma. npx prisma contract emit writes the two files from it. If you set your project up by hand, The data contract covers where they come from.
import { createCacheMiddleware } from '@prisma/orm-extension-middleware-cache';
import postgres from '@prisma/orm-postgres/runtime';
import { budgets, lints } from '@prisma/orm-postgres/family-runtime';
import type { Contract } from './contract.d';
import contractJson from './contract.json' with { type: 'json' };
export const db = postgres<Contract>({
contractJson,
url: process.env['DATABASE_URL']!,
middleware: [
createCacheMiddleware({ maxEntries: 1_000 }),
lints(),
budgets({ maxRows: 10_000, maxLatencyMs: 1_000 }),
],
});npm create prisma@latest generates a src/prisma/db.ts that already builds this client, and the middleware option is what you add to it. The generated file builds the client itself only when the app is started with DATABASE_URL exported, as npm run dev does. With npm run dev:composer it takes its client from Composer through service.load() instead, and the middleware option in db.ts is never used.
The client itself, postgres(...), comes from @prisma/orm-postgres/runtime. The built-in middleware and the SqlMiddleware type are imported from @prisma/orm-postgres/family-runtime, which works with any SQL database. lints() and budgets() both take an options object, and lints and budgets list what goes in it.
Writing your own middleware means writing that same kind of object: a name, a familyId saying which kind of database it works with, and the hooks you want. Here is a complete one that prints every query it sees:
import type { SqlMiddleware } from '@prisma/orm-postgres/family-runtime';
export function queryLogger(): SqlMiddleware {
return {
name: 'query-logger',
familyId: 'sql',
async afterQuery(plan, result) {
console.log(`${result.rowCount} rows in ${Math.round(result.latencyMs)}ms · ${plan.sql}`);
},
};
}The name is the label Prisma ORM uses when it has to say which middleware it means in an error message, and nothing requires it to be unique. familyId: 'sql' means this middleware works with SQL databases only, so Prisma ORM rejects it on a MongoDB client.
Add queryLogger() to the middleware array in src/prisma/db.ts, alongside the built-in middleware or on its own, and every query on that client runs through it. The authoring guide builds this logger step by step, with the options and the other hooks.
Every query goes through that list, whichever of the two APIs you wrote it with. With the ORM API you write db.orm.public.User..., where public is the PostgreSQL schema and User is the model in your contract.
With the SQL query builder you build a query and run it in two steps. .build() returns a plan, which is the query as a plain object, not yet run. db.runtime().query(plan) runs it and returns rows, and db.runtime().execute(plan) runs it and returns how many rows it changed.
Both APIs send their queries on the same db object, so both see the same middleware. If your database is MongoDB, pass the same middleware option to mongo(...) from @prisma/orm-mongo/runtime and type your own middleware as MongoMiddleware from @prisma/orm-mongo/family-runtime.
The src/prisma/db.ts below imports two files from your own project, contract.json and contract.d.ts. Both come from your contract, which is the Prisma ORM 8 name for your schema: contract.prisma in a generated project, in place of schema.prisma. npx prisma contract emit writes the two files from it. If you set your project up by hand, The data contract covers where they come from.
import { createCacheMiddleware } from '@prisma/orm-extension-middleware-cache';
import postgres from '@prisma/orm-postgres/runtime';
import { budgets, lints } from '@prisma/orm-postgres/family-runtime';
import type { Contract } from './contract.d';
import contractJson from './contract.json' with { type: 'json' };
export const db = postgres<Contract>({
contractJson,
url: process.env['DATABASE_URL']!,
middleware: [
createCacheMiddleware({ maxEntries: 1_000 }),
lints(),
budgets({ maxRows: 10_000, maxLatencyMs: 1_000 }),
],
});npm create prisma@latest generates a src/prisma/db.ts that already builds this client, and the middleware option is what you add to it. The generated file builds the client itself only when the app is started with DATABASE_URL exported, as npm run dev does. With npm run dev:composer it takes its client from Composer through service.load() instead, and the middleware option in db.ts is never used.
The client itself, postgres(...), comes from @prisma/orm-postgres/runtime. The built-in middleware and the SqlMiddleware type are imported from @prisma/orm-postgres/family-runtime, which works with any SQL database. lints() and budgets() both take an options object, and lints and budgets list what goes in it.
Writing your own middleware means writing that same kind of object: a name, a familyId saying which kind of database it works with, and the hooks you want. Here is a complete one that prints every query it sees:
import type { SqlMiddleware } from '@prisma/orm-postgres/family-runtime';
export function queryLogger(): SqlMiddleware {
return {
name: 'query-logger',
familyId: 'sql',
async afterQuery(plan, result) {
console.log(`${result.rowCount} rows in ${Math.round(result.latencyMs)}ms · ${plan.sql}`);
},
};
}The name is the label Prisma ORM uses when it has to say which middleware it means in an error message, and nothing requires it to be unique. familyId: 'sql' means this middleware works with SQL databases only, so Prisma ORM rejects it on a MongoDB client.
Add queryLogger() to the middleware array in src/prisma/db.ts, alongside the built-in middleware or on its own, and every query on that client runs through it. The authoring guide builds this logger step by step, with the options and the other hooks.
Every query goes through that list, whichever of the two APIs you wrote it with. With the ORM API you write db.orm.public.User..., where public is the PostgreSQL schema and User is the model in your contract.
With the SQL query builder you build a query and run it in two steps. .build() returns a plan, which is the query as a plain object, not yet run. db.runtime().query(plan) runs it and returns rows, and db.runtime().execute(plan) runs it and returns how many rows it changed.
Both APIs send their queries on the same db object, so both see the same middleware. If your database is MongoDB, pass the same middleware option to mongo(...) from @prisma/orm-mongo/runtime and type your own middleware as MongoMiddleware from @prisma/orm-mongo/family-runtime.
How a query moves through middlewareStep 1 of 4
Every query passes through your middleware on its way to the database.
You write only the hooks you care about, and Prisma ORM skips the ones you leave out. Which hooks it calls depends on what your query gives back: if the query returns rows, one set of hooks runs, and if it only reports how many rows it changed, a shorter set runs instead.
A row query is anything that returns rows. On the ORM API that is every call except createAndCount, updateAndCount, and deleteAndCount, so create, update, and delete are row queries too. It is also what you get from db.runtime().query(plan). If those names are new, the queries table puts each of them next to the Prisma ORM 7 call it replaces. A row query calls your hooks in this order:
beforeCompile → beforeQuery → interceptQuery → onRow (once per row) → afterQuery
A non-returning write is a statement that reports how many rows it affected rather than returning them. On the ORM API those are createAndCount, updateAndCount, and deleteAndCount, and with the SQL query builder it is whatever you hand to db.runtime().execute(plan). These run a shorter sequence:
beforeCompile → beforeExecute → interceptExecute → afterExecute
beforeQuery never runs for a non-returning write, and beforeExecute never runs for a row query, so a middleware that has to cover reads and writes both needs a hook in each sequence. In practice that means writing the check once and calling it from beforeQuery and from beforeExecute:
import type { SqlMiddleware } from '@prisma/orm-postgres/family-runtime';
export function noDeleteAll(): SqlMiddleware {
const check = (sql: string) => {
if (sql.startsWith('DELETE ') && !sql.includes(' WHERE ')) {
throw new Error('blocked by policy');
}
};
return {
name: 'no-delete-all',
familyId: 'sql',
async beforeQuery(plan) {
check(plan.sql);
},
async beforeExecute(plan) {
check(plan.sql);
},
};
}lints and budgets are built exactly that way.
The five hooks, in the order they runStep 1 of 4
beforeCompile can change the query while it is still a typed AST.
At each step Prisma ORM calls that hook on every middleware you registered, and it waits for each one to finish before the query moves on, so a hook can be async and a slow afterQuery delays the await in your own code.
Prisma ORM calls your middleware in the order you listed them in the middleware array, at every hook, so the middleware at index 0 goes first. If one middleware rewrites the query in beforeCompile, the one after it sees the rewritten version. The after-hooks follow that same order rather than unwinding in reverse, so the middleware at index 0 is also the first to log. Order decides who answers a query as well: when several middleware implement interceptQuery, the first one that returns rows wins and the rest are not called at all, and the same holds for interceptExecute.
Reach for beforeCompile when you want to change what a query asks for rather than only look at it. It runs on both kinds of query, and it hands you a draft with two fields: meta, facts about the query that you read but do not change, such as which API built it, and ast, the query as a tree of typed objects. The tree is the part you can change, because the SQL text has not been written yet. Return a copy of the draft with a changed ast and Prisma ORM runs your version instead, which is how you add a condition to queries without editing a single call site. Return undefined and the query goes through as written.
The middleware below adds a condition to every SELECT whose own FROM clause names the User table:
import type { SqlMiddleware } from '@prisma/orm-postgres/family-runtime';
import { AndExpr, type BinaryExpr } from '@prisma/orm-postgres/relational-core/ast';
export function scopeUserSelects(condition: () => BinaryExpr | undefined): SqlMiddleware {
return {
name: 'scope-user-selects',
familyId: 'sql',
async beforeCompile(draft) {
if (draft.ast.kind !== 'select') return undefined;
if (draft.ast.from?.kind !== 'table-source') return undefined;
if (draft.ast.from.name !== 'User') return undefined;
const predicate = condition();
if (predicate === undefined) return undefined;
const where = draft.ast.where ? AndExpr.of([draft.ast.where, predicate]) : predicate;
return { ...draft, ast: draft.ast.withWhere(where) };
},
};
}The three checks above are how you say "only SELECTs, and only ones over the User table". draft.ast.kind is 'select', 'insert', 'update', 'delete', or 'raw-query', and draft.ast.from.kind is 'table-source' when a SELECT reads a plain table rather than a subquery or a function. Use the table name as it is in your database rather than the model name in your contract. They are the same, User, unless the model sets @@map, in which case use the name it maps to. withWhere returns a new SELECT with that WHERE on it and leaves the original alone, which is why the hook returns a copy of the draft.
These checks read only the table in the query's own FROM clause, so User rows that a query loads with .include(...) or through a join are not filtered. Do not rely on a middleware like this alone to keep one tenant's rows away from another.
The function you pass in returns the comparison to add, or undefined to add none, and the middleware calls it for each SELECT whose FROM clause names User, after the three checks. You build the comparison yourself, and the types for building one come from @prisma/orm-postgres/relational-core/ast. A value you compare against needs its codecId, an id that says how the value is stored, such as pg/text@1 for text and pg/int4@1 for a 32-bit integer. Authoring custom middleware lists the id for each field type:
import { BinaryExpr, ColumnRef, ParamRef } from '@prisma/orm-postgres/relational-core/ast';
import { scopeUserSelects } from './scope-user-selects';
const onlyMia = BinaryExpr.eq(
ColumnRef.of('User', 'name'),
ParamRef.of('Mia', { codec: { codecId: 'pg/text@1' } }),
);Then scopeUserSelects(() => onlyMia) goes in the middleware array of your postgres(...) client, like any other entry. Rewrite queries with beforeCompile shows how the function can return a different value for each request, and covers the other comparisons and why a value has to go through ParamRef.of. beforeCompile is not available on MongoDB, because the tree it works on is made of SQL objects.
beforeQuery and beforeExecute: validate or block
Section titled “beforeQuery and beforeExecute: validate or block”These two hooks are where you inspect a query and refuse it. beforeQuery runs on a row query and beforeExecute on a non-returning write, both after the SQL text has been written out and before anything reaches the database. Your hook receives the plan, so you can read the SQL text in plan.sql and read the same query as objects in plan.ast, which is the tree beforeCompile works on. Throwing from the hook blocks the query, and returning normally lets it carry on to the database. The hook itself goes in the object your middleware returns, next to name and familyId:
async beforeQuery(plan) {
if (isForbidden(plan)) throw new Error("blocked by policy");
}This is where lints stops a DELETE without WHERE, and where budgets rejects a query that would read too many rows. Both of them assign one check to beforeQuery and to beforeExecute, the way noDeleteAll above does, so the check applies to reads and writes alike.
Every hook also receives a context object, ctx, which tells it about the query it is running inside. ctx is the argument after plan, or after result in the after-hooks. ctx.planExecutionId is the same string in every hook that runs for one query, so you can match a line your beforeQuery logged to the line your afterQuery logged. ctx.scope is 'transaction' for a query inside db.transaction(...) and 'runtime' for one you sent on db directly, so your middleware does see the queries you run in a transaction. The context object lists every field it holds.
Either hook can also change the values the query sends to the database, and it does that through a third argument, params, which you declare as beforeQuery(plan, ctx, params) when you want it. params.entries() walks those values one at a time, and each entry has three fields:
entry.valueis the value itself.entry.codecIdis the stored type, an id such aspg/text@1,pg/int4@1, orpg/timestamptz-temporal@1.entry.refidentifies that one value in the query, and it is what you pass toreplaceValue.
Call params.replaceValue(entry.ref, newValue) and the database receives your value instead of the original. params is typed as optional and Prisma ORM always passes it, so the guard on the first line is there to satisfy TypeScript:
async beforeQuery(plan, ctx, params) {
if (!params) return;
for (const entry of params.entries()) {
if (entry.codecId === 'pg/text@1' && typeof entry.value === 'string') {
params.replaceValue(entry.ref, entry.value.trim());
}
}
},Check entry.codecId before you call replaceValue, as the example does. Write the hook as beforeQuery(plan) instead and nothing about the values changes.
interceptQuery and interceptExecute: answer without the database
Section titled “interceptQuery and interceptExecute: answer without the database”These hooks let you answer a query yourself, so the database never sees it. Return { rows } from interceptQuery, or { stats } from interceptExecute, and that is what your caller gets back, but return undefined and the query carries on to the database as usual. The stats you return is the count that statement reports, for example { stats: { affectedRows: 0 } }.
The rows you return are plain objects, keyed by the column aliases in the SQL, holding the values in the form the database driver hands them over: numbers for integers, strings for text and timestamps. A read that selects id, email, and createdAt produces rows like this one:
{ id: 3, email: 'mia+1@prisma.io', createdAt: '2026-09-17 08:00:08.775+00' }So for a query whose SQL is SELECT "User"."id" AS "id", "User"."email" AS "email" FROM "public"."User" LIMIT 5, return { rows: [{ id: 1, email: 'mia@prisma.io' }] } is a complete answer to it. Prisma ORM converts the rows you return the same way it converts rows from the database. So if you return a createdAt as a string, a caller on the ORM API gets a Temporal.Instant, which is the type of a DateTime field.
Before-hooks still run on a query that an intercept hook answers, because every middleware's before-hooks run before any intercept hook, whatever order you list the middleware in. So in the setup at the top of this page, where the cache comes first in the array, lints still checks every query before the cache answers it.
onRow(row, plan, ctx) lets you see each row of a result on its way to your code, and row is one of those plain row objects. Prisma ORM calls it once for every row as it reads the result, before your await on .all() or .first() resolves, so you can count rows, sample them, or throw to stop the query part way through. If you throw, your await rejects with that error and you get no rows at all, not the rows read so far. That is what budgets does when a query returns more rows than its maxRows budget allows. onRow does not run on a non-returning write, and it does not run for rows that an interceptQuery hook answered.
afterQuery and afterExecute: observe the finished operation
Section titled “afterQuery and afterExecute: observe the finished operation”These two hooks are for recording what happened once the work is over. Prisma ORM calls them once at the end of the operation, whether it succeeded or failed.
After a row query, afterQuery receives result.rowCount, how many rows came back, and result.latencyMs, how long the query took. result.completed is true when the query finished and false when it failed, and result.source is 'driver' for a query the database answered and 'middleware' when an interceptQuery answered it, so you can tell where the rows came from.
After a non-returning write, afterExecute receives the same result.completed, result.latencyMs, and result.source, and on a run that completed it also gets the number of rows the statement changed, in result.stats.affectedRows. There is no result.rowCount on this one, and there is no result.stats on a write that failed, so read the count only when result.completed is true.
Timing, logging, and latency budgets all belong in these two hooks.
@prisma/orm-postgres already contains lints, which blocks risky query shapes, and budgets, which caps row counts and reports slow queries, so you can register either one without installing anything else. Middleware that ships as its own package is an extension like any other, and the extension directory lists those, including middleware written by the community:
| Name | What it adds | Package | Databases | By |
|---|---|---|---|---|
| Cache middleware | Serve repeated reads from an in-memory or pluggable cache, opted in per query. | @prisma/orm-extension-middleware-cache |
PostgreSQL, MongoDB | Prisma |
"Opted in per query" in that row means nothing is cached until you say so on one particular read, which you do by attaching cacheAnnotation({ ttl: 60_000 }) to it, where ttl is how many milliseconds its rows stay usable. On the ORM API the annotation goes in a callback you pass after the arguments. That callback receives a meta object whose annotate method attaches the annotation to that one call:
import { cacheAnnotation } from '@prisma/orm-extension-middleware-cache';
import { db } from './db';
export async function getUserCached(id: number) {
return db.orm.public.User.first({ id }, (meta) =>
meta.annotate(cacheAnnotation({ ttl: 60_000 })),
);
}Opt a query in shows the same annotation on a query you built with the SQL query builder.
If none of them do what you need, the authoring guide builds a working query logger step by step. Once your own middleware is on npm, submit it to the directory so other Prisma ORM users can find it.
Prisma ORM sorts the databases it supports into two families, SQL and MongoDB, and a middleware cannot always work on both. The familyId key next to name is where a middleware says which family it was written for, and a middleware that leaves familyId out runs on either.
budgetsandlintsdeclarefamilyId: 'sql'. They register on PostgreSQL and are rejected on MongoDB.- The cache declares no
familyId. It never reads SQL, so it runs on PostgreSQL and on MongoDB.
If you register a middleware against a database it was not written for, that client throws at its first query (or at connect(), if you call it), before anything reaches the database. The error's code is RUNTIME.MIDDLEWARE_FAMILY_MISMATCH.
A middleware of your own that leaves familyId out can still be typed as SqlMiddleware, as long as you only ever register it on postgres(...). To write one object that you register on a postgres(...) client and a mongo(...) client both, type it as CrossFamilyMiddleware instead, which you import from @prisma/orm-postgres/components/runtime.
When your hook throws from beforeCompile, beforeQuery, beforeExecute, interceptQuery, or interceptExecute, the whole operation fails before anything reaches the database, and your caller gets the error you threw. A throw from onRow also fails the operation, but by then the query has run: your caller gets your error and none of the rows, including the ones already read. Your own middleware can throw whatever error you like, as noDeleteAll throws a plain Error. lints and budgets throw an ordinary Error with a code property on it, so you catch them like any other error and read code to tell them apart. budgets throws two codes, BUDGET.ROWS_EXCEEDED and BUDGET.TIME_EXCEEDED, and the lints page lists the codes lints throws.
When the database driver is the thing that fails, Prisma ORM still calls afterQuery on every middleware, or afterExecute after a non-returning write, with result.completed set to false, so your logging sees failed queries as well as successful ones. A throw from afterQuery or afterExecute while it is handling a failure cannot hide the real error: your caller still gets the failure the driver reported.
Projects created with npm create prisma@latest install Prisma ORM skills for your coding agent. Prompts that work for middleware:
- "Register the lints and budgets middleware on our Prisma ORM client with a 10k row budget."
- "Add the cache middleware and opt the dashboard queries in with a 60 second time to live."
- "Write a middleware that logs every query slower than 250ms."
- Authoring custom middleware: build and test a query logger step by step
- Writing data: the mutations that lints and budgets guard
- Built-in: budgets, Built-in: lints, Built-in: cache
- Using extensions for adding database features rather than wrapping queries