Prisma Advanced Patterns: Middleware, Query Extensions, Raw Queries, and Batch Operations
Master advanced Prisma patterns in production: client middleware for logging and soft deletes, query extensions with $extends, raw SQL with $queryRaw, batch operations with $transaction, and migration strategies.
Prisma's query API covers 90% of use cases with clean, type-safe syntax. The remaining 10% โ soft deletes, tenant scoping, audit logging, raw SQL for complex aggregates, and transactional batch imports โ requires understanding Prisma's extension and middleware system. Done right, these patterns let you add cross-cutting concerns without polluting every query call site.
This post covers Prisma client extensions ($extends), the older middleware API for comparison, raw queries with $queryRaw and $executeRaw, interactive transactions, and migration strategies for production databases.
1. Client Extensions with $extends
$extends is the modern (Prisma 4.16+) way to extend the client. It's composable, tree-shakeable, and type-safe โ unlike the older middleware API which was global and opaque.
// src/lib/db/extensions/soft-delete.ts
import { Prisma } from '@prisma/client';
// Models that support soft delete
type SoftDeletableModel = 'post' | 'comment' | 'organization';
export const softDeleteExtension = Prisma.defineExtension({
name: 'softDelete',
query: {
$allModels: {
// Intercept all findMany/findFirst/findUnique โ exclude soft-deleted rows
async findMany({ model, args, query }) {
if (isSoftDeletable(model)) {
args.where = { ...args.where, deletedAt: null };
}
return query(args);
},
async findFirst({ model, args, query }) {
if (isSoftDeletable(model)) {
args.where = { ...args.where, deletedAt: null };
}
return query(args);
},
async findUnique({ model, args, query }) {
if (isSoftDeletable(model)) {
// findUnique โ findFirst for soft delete support
return (query as any)({ ...args, where: { ...args.where, deletedAt: null } });
}
return query(args);
},
// Intercept delete โ update deletedAt instead
async delete({ model, args, query }) {
if (isSoftDeletable(model)) {
return (query as any)({
...args,
data: { deletedAt: new Date() },
});
}
return query(args);
},
async deleteMany({ model, args, query }) {
if (isSoftDeletable(model)) {
return (query as any)({
...args,
data: { deletedAt: new Date() },
});
}
return query(args);
},
},
},
});
function isSoftDeletable(model: string): model is SoftDeletableModel {
return ['post', 'comment', 'organization'].includes(model.toLowerCase());
}
// src/lib/db/extensions/audit-log.ts
import { Prisma } from '@prisma/client';
export const auditLogExtension = Prisma.defineExtension({
name: 'auditLog',
query: {
$allModels: {
async create({ model, args, query }) {
const result = await query(args);
await logAuditEvent(model, 'CREATE', null, result);
return result;
},
async update({ model, args, query }) {
// Capture before state
const before = await (Prisma as any)[model].findUnique({ where: args.where });
const result = await query(args);
await logAuditEvent(model, 'UPDATE', before, result);
return result;
},
async delete({ model, args, query }) {
const before = await (Prisma as any)[model].findUnique({ where: args.where });
const result = await query(args);
await logAuditEvent(model, 'DELETE', before, null);
return result;
},
},
},
});
async function logAuditEvent(
model: string,
operation: 'CREATE' | 'UPDATE' | 'DELETE',
before: unknown,
after: unknown
): Promise<void> {
// Don't audit the audit log itself โ infinite loop
if (model.toLowerCase() === 'auditlog') return;
// Fire and forget โ don't block the main operation
setImmediate(async () => {
try {
await rawDb.auditLog.create({
data: {
model,
operation,
before: before ? JSON.stringify(before) : null,
after: after ? JSON.stringify(after) : null,
occurredAt: new Date(),
},
});
} catch (err) {
console.error('Audit log write failed:', err);
}
});
}
// src/lib/db/client.ts โ compose extensions
import { PrismaClient } from '@prisma/client';
import { softDeleteExtension } from './extensions/soft-delete';
import { auditLogExtension } from './extensions/audit-log';
import { rlsExtension } from './extensions/rls';
const rawDb = new PrismaClient({
log: process.env.NODE_ENV === 'development'
? [{ emit: 'event', level: 'query' }]
: ['error'],
});
// Extensions compose left to right โ each wraps the previous
export const db = rawDb
.$extends(softDeleteExtension)
.$extends(auditLogExtension)
.$extends(rlsExtension);
// Query logging in development
if (process.env.NODE_ENV === 'development') {
rawDb.$on('query', (e) => {
if (e.duration > 100) {
console.warn(`Slow query (${e.duration}ms): ${e.query}`);
}
});
}
export type ExtendedPrismaClient = typeof db;
2. Model Extensions: Custom Methods
// src/lib/db/extensions/user-methods.ts
import { Prisma } from '@prisma/client';
import bcrypt from 'bcryptjs';
export const userMethodsExtension = Prisma.defineExtension({
name: 'userMethods',
model: {
user: {
// Add custom static methods to the User model
async findByEmail(email: string) {
return Prisma.getExtensionContext(this).findUnique({
where: { email: email.toLowerCase() },
});
},
async findActiveByTenant(tenantId: string) {
return Prisma.getExtensionContext(this).findMany({
where: { tenantId, status: 'active', deletedAt: null },
orderBy: { createdAt: 'desc' },
});
},
async createWithHashedPassword(data: {
email: string;
password: string;
name: string;
tenantId: string;
}) {
const hashedPassword = await bcrypt.hash(data.password, 12);
return Prisma.getExtensionContext(this).create({
data: { ...data, password: hashedPassword, email: data.email.toLowerCase() },
});
},
},
},
});
// Usage:
// const user = await db.user.findByEmail('alice@example.com');
// const users = await db.user.findActiveByTenant(tenantId);
๐ Looking for a Dev Team That Actually Delivers?
Most agencies sell you a project manager and assign juniors. Viprasol is different โ senior engineers only, direct Slack access, and a 5.0โ Upwork record across 100+ projects.
- React, Next.js, Node.js, TypeScript โ production-grade stack
- Fixed-price contracts โ no surprise invoices
- Full source code ownership from day one
- 90-day post-launch support included
3. Raw Queries
// src/lib/db/raw-queries.ts
import { db } from './client';
import { Prisma } from '@prisma/client';
// $queryRaw: returns typed results
// Use Prisma.sql template tag โ prevents SQL injection
export async function getMonthlyRevenue(
tenantId: string,
year: number
): Promise<Array<{ month: number; revenue: number; order_count: number }>> {
return db.$queryRaw`
SELECT
EXTRACT(MONTH FROM created_at)::int AS month,
SUM(amount_cents)::float / 100 AS revenue,
COUNT(*)::int AS order_count
FROM orders
WHERE tenant_id = ${tenantId}::uuid
AND EXTRACT(YEAR FROM created_at) = ${year}
AND status = 'completed'
GROUP BY EXTRACT(MONTH FROM created_at)
ORDER BY month
`;
}
// $executeRaw: for INSERT/UPDATE/DELETE that don't return rows
export async function bulkUpdateStatus(
ids: string[],
status: string
): Promise<number> {
const result = await db.$executeRaw`
UPDATE posts
SET status = ${status}, updated_at = NOW()
WHERE id = ANY(${ids}::uuid[])
`;
return result; // Number of affected rows
}
// $queryRawUnsafe: when you need dynamic SQL (use sparingly โ injection risk)
export async function searchWithDynamicSort(
tenantId: string,
sortColumn: 'createdAt' | 'title' | 'viewCount', // Allowlist โ never user input
sortDir: 'asc' | 'desc'
): Promise<Post[]> {
// Map to actual column names (safe allowlist)
const columnMap = {
createdAt: 'created_at',
title: 'title',
viewCount: 'view_count',
} as const;
const col = columnMap[sortColumn];
const dir = sortDir === 'asc' ? 'ASC' : 'DESC';
return db.$queryRawUnsafe(
`SELECT * FROM posts WHERE tenant_id = $1 ORDER BY ${col} ${dir} LIMIT 100`,
tenantId
);
}
4. Interactive Transactions
// src/services/billing/transfer.ts
import { db } from '../../lib/db/client';
// Interactive transaction: full Prisma client available inside callback
export async function transferCredits(
fromUserId: string,
toUserId: string,
amount: number
): Promise<void> {
await db.$transaction(
async (tx) => {
// All operations use the SAME database transaction
const sender = await tx.wallet.findUnique({
where: { userId: fromUserId },
select: { balance: true },
});
if (!sender || sender.balance < amount) {
throw new Error('Insufficient balance');
}
// Both updates succeed or both fail (ACID)
await tx.wallet.update({
where: { userId: fromUserId },
data: { balance: { decrement: amount } },
});
await tx.wallet.update({
where: { userId: toUserId },
data: { balance: { increment: amount } },
});
await tx.walletTransaction.create({
data: {
fromUserId,
toUserId,
amount,
type: 'transfer',
},
});
},
{
maxWait: 5000, // Max time to acquire connection (ms)
timeout: 10000, // Max transaction duration (ms)
isolationLevel: Prisma.TransactionIsolationLevel.Serializable,
}
);
}
// Batch transaction: array of operations (more efficient, less flexible)
export async function batchCreateTags(tags: string[], postId: string): Promise<void> {
await db.$transaction(
tags.map((name) =>
db.tag.upsert({
where: { name },
create: { name },
update: {},
})
)
);
}
๐ Senior Engineers. No Junior Handoffs. Ever.
You get the senior developer, not a project manager who relays your requirements to someone you never meet. Every Viprasol project has a senior lead from kickoff to launch.
- MVPs in 4โ8 weeks, full platforms in 3โ5 months
- Lighthouse 90+ performance scores standard
- Works across US, UK, AU timezones
- Free 30-min architecture review, no commitment
5. Bulk Insert with createMany and Chunking
// src/lib/db/bulk-operations.ts
// createMany: single INSERT ... VALUES (...), (...) โ much faster than N individual creates
export async function bulkInsertEvents(
events: Array<{ userId: string; type: string; payload: object; occurredAt: Date }>
): Promise<number> {
const CHUNK_SIZE = 1000; // PostgreSQL limit: ~65K params per statement
let total = 0;
for (let i = 0; i < events.length; i += CHUNK_SIZE) {
const chunk = events.slice(i, i + CHUNK_SIZE);
const result = await db.event.createMany({
data: chunk,
skipDuplicates: true, // Ignore conflicts on unique constraints
});
total += result.count;
}
return total;
}
// Upsert many: no built-in, but efficient with raw SQL
export async function upsertProducts(
products: Array<{ id: string; name: string; price: number; tenantId: string }>
): Promise<void> {
if (products.length === 0) return;
const values = products
.map((_, i) => `($${i * 4 + 1}::uuid, $${i * 4 + 2}, $${i * 4 + 3}::int, $${i * 4 + 4}::uuid)`)
.join(', ');
const params = products.flatMap((p) => [p.id, p.name, p.price, p.tenantId]);
await db.$executeRawUnsafe(
`INSERT INTO products (id, name, price, tenant_id)
VALUES ${values}
ON CONFLICT (id) DO UPDATE SET
name = EXCLUDED.name,
price = EXCLUDED.price,
updated_at = NOW()`,
...params
);
}
6. Migration Best Practices
# Development workflow
npx prisma migrate dev --name add_user_preferences
# Production: generate SQL, review, then apply
npx prisma migrate diff \
--from-schema-datasource prisma/schema.prisma \
--to-schema-datamodel prisma/schema.prisma \
--script > migration.sql
# Review migration.sql โ then apply:
npx prisma migrate deploy
# Never use migrate reset in production
# Never use migrate dev in production (drops + recreates)
// src/scripts/migrate-with-backfill.ts
// Pattern: add column + backfill data in same deploy (but separate steps)
async function addAndBackfillColumn() {
// Step 1: Migration already added nullable column `user.displayName`
// Step 2: Backfill in batches (never backfill entire table in one query)
let cursor: string | undefined;
let processed = 0;
const BATCH_SIZE = 500;
do {
const users = await db.user.findMany({
where: { displayName: null },
select: { id: true, name: true },
take: BATCH_SIZE,
cursor: cursor ? { id: cursor } : undefined,
skip: cursor ? 1 : 0,
});
if (users.length === 0) break;
await db.$transaction(
users.map((u) =>
db.user.update({
where: { id: u.id },
data: { displayName: u.name },
})
)
);
processed += users.length;
cursor = users[users.length - 1].id;
console.log(`Backfilled ${processed} users`);
// Rate limit: avoid overwhelming DB during business hours
await new Promise((r) => setTimeout(r, 50));
} while (true);
console.log(`โ
Backfill complete: ${processed} users`);
}
Cost Reference
| Prisma pattern | Query overhead | Use case |
|---|---|---|
| Standard Prisma queries | ~0.5โ2ms | 95% of operations |
| Extensions (softDelete, RLS) | +0.1โ0.5ms/extension | Cross-cutting concerns |
$queryRaw | Same as pg driver | Complex aggregates, window functions |
$transaction (batch) | Single round-trip | Bulk inserts, atomic multi-writes |
$transaction (interactive) | Per-query + serialization | Financial operations |
See Also
- PostgreSQL Row-Level Security: Multi-Tenant Isolation and Audit Policies
- Database Schema Versioning: Flyway, Liquibase, and Prisma Migrate
- PostgreSQL Full-Text Search: tsvector, tsquery, and Ranking
- PostgreSQL Partitioning: Range, List, and Hash Strategies
- TypeScript Branded Types: Nominal Typing and Type-Safe IDs
Working With Viprasol
Prisma codebase with N+1 queries, missing soft delete, no audit trail, or migrations causing downtime? We introduce client extensions for cross-cutting concerns, raw queries for complex analytics, interactive transactions for financial operations, and zero-downtime migration patterns โ all with full TypeScript type safety.
About the Author
Viprasol Tech Team
Custom Software Development Specialists
The Viprasol Tech team specialises in algorithmic trading software, AI agent systems, and SaaS development. With 100+ projects delivered across MT4/MT5 EAs, fintech platforms, and production AI systems, the team brings deep technical experience to every engagement. Based in India, serving clients globally.
Need a Modern Web Application?
From landing pages to complex SaaS platforms โ we build it all with Next.js and React.
Free consultation โข No commitment โข Response within 24 hours
Need a custom web application built?
We build React and Next.js web applications with Lighthouse โฅ90 scores, mobile-first design, and full source code ownership. Senior engineers only โ from architecture through deployment.