Soft delete with Prisma Client extensions
In production systems, permanently deleting data is risky—you lose audit trails, break foreign keys, and can't recover mistakes. Soft delete with Prisma Client extensions lets you hide deleted rows everywhere without repeating where clauses. I'll walk you through implementing a clean, type-safe approach that scales across your entire codebase.
Why soft deletes matter in production
I've seen incidents where a single DELETE cascaded through a payment system, wiping transaction history. Soft deletes solve this by marking rows as deleted instead of removing them. The challenge is consistency—you can't expect every query to remember where: { deletedAt: null }.
Prisma Client extensions let you intercept and modify queries at runtime. This means you can apply soft-delete logic globally, in one place, and forget about it everywhere else.
Add deletedAt DateTime? to the model
Start by adding an optional timestamp field to your Prisma schema:
model User {
id String @id @default(cuid())
email String @unique
name String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime?
posts Post[]
}
model Post {
id String @id @default(cuid())
title String
content String
userId String
user User @relation(fields: [userId], references: [id])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime?
}The deletedAt DateTime? field is nullable. When it's null, the record is active. When it has a timestamp, the record is logically deleted. Run your migration:
npx prisma migrate dev --name add_deleted_atThis is foundational—without the field, you can't track deletion state. I always add it before soft-delete logic touches any data.
An extension filters deletedAt: null on reads
Now create a Prisma Client extension that intercepts findUnique, findFirst, findMany, and count operations. Here's the implementation:
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient().$extends({
query: {
async $allOperations({ operation, model, args, query }) {
// Apply soft-delete filter only on read operations for models with deletedAt
const modelsWithSoftDelete = ['User', 'Post']; // Add all your soft-delete models
if (modelsWithSoftDelete.includes(model)) {
if (['findUnique', 'findFirst', 'findMany', 'count'].includes(operation)) {
// Merge the deletedAt: null filter into the existing where clause
args.where = {
...args.where,
deletedAt: null,
};
}
}
return query(args);
},
},
});
export default prisma;This extension works by:
- Checking if the operation is a read (find or count)
- Ensuring the model supports soft deletes
- Injecting
deletedAt: nullinto thewhereclause - Passing the modified args back to the original query
Now when you call prisma.user.findMany(), it automatically becomes prisma.user.findMany({ where: { deletedAt: null } }). No boilerplate, no mistakes.
Override delete to set deletedAt
Instead of calling prisma.user.delete(), we override it to do a soft delete. Extend the client further:
const prisma = new PrismaClient().$extends({
query: {
async $allOperations({ operation, model, args, query }) {
const modelsWithSoftDelete = ['User', 'Post'];
// Apply soft-delete filter on reads
if (modelsWithSoftDelete.includes(model)) {
if (['findUnique', 'findFirst', 'findMany', 'count'].includes(operation)) {
args.where = {
...args.where,
deletedAt: null,
};
}
}
return query(args);
},
},
model: {
user: {
async delete(args: any) {
return prisma.user.update({
...args,
data: { deletedAt: new Date() },
});
},
async deleteMany(args: any) {
return prisma.user.updateMany({
...args,
data: { deletedAt: new Date() },
});
},
},
post: {
async delete(args: any) {
return prisma.post.update({
...args,
data: { deletedAt: new Date() },
});
},
async deleteMany(args: any) {
return prisma.post.updateMany({
...args,
data: { deletedAt: new Date() },
});
},
},
},
});
export default prisma;Now await prisma.user.delete({ where: { id: userId } }) sets deletedAt instead of removing the row. Your data is safe, and reads automatically exclude soft-deleted records.
Keep a raw client for admin restores
Admins sometimes need to restore deleted records or inspect deleted data. Create a raw Prisma Client without the soft-delete extension:
import { PrismaClient } from '@prisma/client';
// Extension client—soft-delete filtering applied
export const prisma = new PrismaClient().$extends({
// ... extensions from above
});
// Raw client—no filtering, admin use only
export const prismaRaw = new PrismaClient();In your admin service:
import { prismaRaw } from './prisma';
export class AdminService {
async listDeletedUsers() {
// See all deleted users
return prismaRaw.user.findMany({
where: { deletedAt: { not: null } },
});
}
async restoreUser(userId: string) {
return prismaRaw.user.update({
where: { id: userId },
data: { deletedAt: null },
});
}
async permanentlyDelete(userId: string) {
return prismaRaw.user.delete({
where: { id: userId },
});
}
}This keeps your main code clean while giving admins the tools they need. The prismaRaw client bypasses all soft-delete logic, so you can query and modify deleted records freely.
Common mistakes
Forgetting to add models to the extension. If you add a new model with deletedAt but don't include it in the extension, reads won't filter deleted records. Keep a checklist or use a helper to generate the extension from your schema.
Mixing the two clients. Using prismaRaw in application code defeats the purpose. Reserve it for admin operations, and document this clearly. I add a comment like // @admin-only to make it obvious.
Overlooking cascade behavior. If User soft-deletes, should related Posts also soft-delete? You need explicit rules. In my projects, I handle this in the application layer—when deleting a user, I explicitly soft-delete their posts.
Not testing soft-delete logic. Write tests that verify soft-deleted records don't appear in reads, and that deletes actually set deletedAt. This catches bugs early.
Real-world scenario
Imagine a ride-hailing platform. A driver deletes their account, and soft-delete sets User.deletedAt. Months later:
- A passenger queries
prisma.user.findUnique({ where: { id: driverId } })—it returnsnull, correctly hiding the deleted driver - An auditor uses
prismaRaw.user.findUnique()—they see the deleted driver and can verify the account was in good standing - A compliance team runs
prismaRaw.user.findMany({ where: { deletedAt: { not: null } } })—they export deleted accounts for legal review
Soft deletes enable all of this without data loss.
Key takeaways
- Add
deletedAt DateTime?to every model that needs soft deletes - Use Prisma Client extensions to inject
where: { deletedAt: null }on all reads—consistency without repetition - Override
delete()anddeleteMany()to setdeletedAtinstead of removing rows - Maintain a
prismaRawclient for admin operations like restores and audits - Keep soft-delete models documented and test the filtering behavior






Comments
Loading comments…