Skip to content
ExponaBLOG
NESTJS · DAY 019 / 100

Soft delete with Prisma Client extensions for production

Hide deleted rows everywhere without repeating where clauses.

Expona Admin6 min read

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:

typescript
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:

typescript
npx prisma migrate dev --name add_deleted_at

This 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:

typescript
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: null into the where clause
  • 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:

typescript
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:

typescript
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:

typescript
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 returns null, 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() and deleteMany() to set deletedAt instead of removing rows
  • Maintain a prismaRaw client for admin operations like restores and audits
  • Keep soft-delete models documented and test the filtering behavior
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles