Skip to content

Soft deletes (e.g. deleted_at) #3398

Open

Description

@matthewmueller

It could be nice to add this kind of feature to core, so you get filtered views without cluttering up your application queries

Open questions:

  • Would it be slow to add this filter to all queries?
  • Can we do it only when we need it?
  • Would this be better handled in Photon?

Activity

  1. schickling commented on Jun 27, 2019

    @schickling
    Member

    I'm not sure whether this should be a core feature or rather be a pattern that's easy to be implemented in "user land" e.g. by using scoped queries.

  2. nelsonpecora commented on Jul 11, 2019

    @nelsonpecora

    I have a slight preference for this being a userland thing, as I've definitely mixed "hard delete" and "soft delete" models in the same system depending on product requirements (e.g. soft delete Article records, but hard delete Tag records)

  3. transferred this issue fromprisma/ormon Dec 13, 2019
  4. duro commented on Jun 6, 2020

    @duro

    Some kind of mechinism to support this would be a big help.

  5. leohxj commented on Aug 16, 2020

    @leohxj

    something like GORM, if model have deleteAt field, it auto have soft_delete ability.

    http://gorm.io/docs/delete.html

  6. Hebilicious commented on Aug 17, 2020

    @Hebilicious

    Maybe we could have the ability to tag a model with something like @@softDelete or a field with @deleteAt in the prisma schema?

  7. LinboLen commented on Aug 18, 2020

    @LinboLen

    It shoud be a core feature

  8. transferred this issue fromprisma/specson Aug 22, 2020
  9. carlosfiori commented on Sep 4, 2020

    @carlosfiori

    It would be great

  10. pantharshit00 commented on Sep 7, 2020

    @pantharshit00
    Contributor

    This can be possibly be created using a middleware for now(thanks @mhwelander for the example)

    import { PrismaClient } from "@prisma/client";
    const prisma = new PrismaClient({});
    async function mainAsync() {
      prisma.$use(async (params, next) => {
        // Check incoming query type
        if (params.model == "Post" && params.action == "delete") {
          // Change action to an update
          params.action = "update";
          // Set field value
          params.args["data"] = { deleted: true };
        }
        return next(params);
      });
      const createPost = await prisma.post.create({
        data: { title: "How to use Prisma Client" },
      });
      const deletePost = await prisma.post.delete({
        where: {
          id: createPost.id,
        },
      });
      const getPost = await prisma.post.findOne({
        where: {
          id: deletePost.id,
        },
      });
      console.log(getPost); // Will return post with deleted:true
    }
    mainAsync();
  11. 64 remaining items

  12. zzzhim commented on Mar 16, 2025

    @zzzhim

    The soft delete feature has been a topic of discussion for many years now, from 2019 to 2025. Could you let us know when this feature might be built-in? Currently, implementing soft delete manually often leads to the following issues:

    1. Each project needs to implement its own soft delete logic, resulting in a lot of repetitive code.
    2. Query filters (e.g., WHERE deletedAt IS NULL) need to be manually added to every query.
    3. Different teams implement this functionality in varying ways, making the codebase difficult to maintain.

    I sincerely hope this functionality can be built-in. I believe it's not just one development team using Prisma that has encountered this issue. I look forward to feedback from the Prisma ORM team regarding this feature suggestion. I am willing to provide more use cases or participate in related discussions to help move this feature forward.

  13. ryanelian commented on Mar 24, 2025

    @ryanelian

    If it's too difficult to implement soft delete functionality for all mutation queries (UPDATE, INSERT, DELETE) can we at the very least have way to add soft delete to all where calls, regardless it is a top-level where or a nested where or an include..where?

    Because I personally do not need the ORM to handle just setting the delete field for me. That is easily accomplished with UPDATE by myself.

    What is hard is not peppering the whole codebase with WHERE deletedAt = null.

    I feel that will significantly improve the ergonomics of the framework. For example:

     Prisma.defineExtension({
    	name: "softDelete",
            // proposed new middleware feature just for modifying WHERE clauses, and nothing else
            // to be used by the framework for all WHERE filters, regardless it's a top-level WHERE, nested WHERE, or include (JOIN)
            globalQueryFilter: {
                $allModels: ({ model, where }) {
                    if (where.deletedAt) {
                         return where;
                    }
    
                    return { ...where, deletedAt: null }
                }
            }
    });

    Prior arts: https://learn.microsoft.com/en-us/ef/core/querying/filters

    From this foundation, the Prisma team can iterate on the full soft delete feature if desired.

  14. jove4015 commented on Mar 25, 2025

    @jove4015

    @ryanelian - as you've demonstrated at least somewhat, this alone is pretty easy to achieve with client extensions, especially if you go on to implement nested structures.

    For us, we are already using a repository pattern, so it's fairly straightforward to centralize adding deletedAt: null to where clauses. I highly recommend it, you will probably find more use cases than just this for centralizing your query logic.

    The bigger issue we face is data integrity. Since there is no support for partial unique indexes (a UNIQUE index only on not-soft-deleted records), unique fields in a soft-delete model are virtually impossible to implement on the database layer. As a result we have to constantly check for uniqueness manually, which opens us up to the potential for a lot more bugs and also blocks us from using much better native Postgres functionality.

    Unfortunately, the Prisma team appears to have closed most of the open requests for this functionality as "closed / not planned". You can just add the index yourself manually, but you won't be able to use any upsert / whereUnique functionality - so performance on certain operations will suffer - and your index will be repeatedly deleted every time you run prisma migrate. They refuse to implement workarounds for these issues as well.

    In general, Prisma is just not-so-subtly hostile to the idea of soft deletes. I suspect they are philosophically opposed to soft deletes, otherwise they might try to open up some kind of pathway towards using them in a consistent and straightforward manner (after all, it has to be one of the most common use cases for any ORM). If you want to work with Prisma, you'll have to embrace a fully own-baked (probably half-baked) solution.

  15. yisibl commented on Apr 20, 2025

    @yisibl

    This is easily done in Sequelize with the paranoid option.
    https://sequelize.org/docs/v7/models/paranoid/

  16. SemajDraw commented on May 4, 2025

    @SemajDraw

    I am struggling to understand how a feature this critical has not been prioritised? Offloading the implementation of this functionality to the developer, particularly when "per entity" granularity (not a global middleware) is required is a massive time sink, especially for large teams where legal compliance and recoverability are a requirement, which is many if not most companies.

  17. haZya commented on Oct 27, 2025

    @haZya

    I don't think this is something Prisma should directly add to the core. The logic depends on how exactly you want the soft-delete filter to work and how you apply it. We could easily implement a helper to recursively add the where: { deletedAt: null } filter using the dmmf.datamodel that is exposed by the generated prisma client. Then we could use it in the lifecycle hooks to apply the constraints to the args for all models/operations (or the ones you want selectively).

    import _ from "lodash";
    
    import { Prisma } from "@/prisma/generated/client";
    
    interface PrismaArgs {
      include?: any;
      select?: any;
      where?: any;
      [x: string]: any;
    }
    
    export const DELETED_AT_FIELD = "deletedAt";
    
    function withSoftDeleteCondition<T extends PrismaArgs>(args: T): T {
      return {
        ...args,
        where: {
          ...args.where,
          [DELETED_AT_FIELD]: null,
        },
      };
    }
    
    function getPopulateArgs<T extends PrismaArgs>(args: T, argsPath: string): T {
      if (!argsPath)
        return args;
    
      const populateArgs = _.get(args, argsPath);
    
      // If it's an object, return it
      if (typeof populateArgs === "object" && populateArgs !== null) {
        return populateArgs;
      }
    
      // If 'true', null, undefined, or primitive, return empty object
      return {} as T;
    }
    
    function applySoftDeleteFilterToArgs<T extends PrismaArgs>(args: T, argsPath: string): T {
      const populateArgs = getPopulateArgs(args, argsPath);
      const populateArgsWithDeleteCondition = withSoftDeleteCondition(populateArgs);
    
      if (argsPath) {
        return _.set(args, argsPath, populateArgsWithDeleteCondition);
      }
    
      return populateArgsWithDeleteCondition;
    }
    
    const getPopulateKey = (args: PrismaArgs) => args.include ? "include" : "select";
    
    function addRecursiveSoftDeleteFilter<T extends PrismaArgs>(
      modelName: Prisma.ModelName,
      operation: string | null,
      args: T,
      argsPath = "",
    ): T {
      const model = Prisma.dmmf.datamodel.models.find(m => m.name === modelName);
    
      if (!model) {
        throw new Error(`Model "${modelName}" not found.`);
      }
    
      const isCreateOperation = operation === "create" || operation === "createMany" || operation === "createManyAndReturn";
      const isUpdateOperation = operation === "update" || operation === "updateMany" || operation === "updateManyAndReturn" || operation === "upsert";
      const isRestoreOperation = isUpdateOperation && args.data?.[DELETED_AT_FIELD] === null;
    
      // Skip 'createOperations', as they don't allow top level 'where' clause.
      // Also skip 'restoreOperations', with data that contains the `deleteAt: null` which is an operation to remove the 'deletedAt' value.
      if (!isCreateOperation && !isRestoreOperation) {
        // Apply filter to the current model level
        for (const field of model.fields) {
          if (field.name === DELETED_AT_FIELD) {
            args = applySoftDeleteFilterToArgs(args, argsPath);
            break;
          }
        }
      }
    
      // Recurse into included/selected relations
      const populateArgs = getPopulateArgs(args, argsPath);
      const populateKey = getPopulateKey(populateArgs);
      const populateObject = populateArgs[populateKey];
    
      // Check if 'populateObject' exists
      if (!populateObject || typeof populateObject !== "object") {
        return args;
      }
    
      // Iterate keys of the populate object
      for (const key of Object.keys(populateObject)) {
        const field = model.fields.find(f => f.name === key);
    
        // Check if it's a relation (kind: 'object'). 1 to 1 required relations have no 'where' clause, so we only add soft-delete to to-many and optional relations.
        if (!field || field.kind !== "object" || (!field.isList && field.isRequired)) {
          continue;
        }
    
        const populateArgsKey = argsPath ? `${argsPath}.${populateKey}.${key}` : `${populateKey}.${key}`;
    
        // Recurse: This mutates 'args' object by reference
        addRecursiveSoftDeleteFilter(field.type as Prisma.ModelName, null, args, populateArgsKey);
      }
    
      return args;
    }
    
    /**
     * Recursively applies the 'deletedAt: null' filter to the current model and
     * all supported relational fields (includes/selects) that support a 'where' clause.
     *
     * @template T
     * @param {Prisma.ModelName} model - The PascalCase name of the current model (e.g., "User").
     * @param {string | null} [operation] - The operation type (e.g., 'findMany').
     * @param {T} args - The root Prisma arguments object. This object is MUTATED by reference.
     * @returns {T} The mutated arguments object with soft-delete filters applied.
     */
    export function applySoftDeleteConstraints<T extends PrismaArgs>(
      model: Prisma.ModelName,
      operation: string,
      args: T,
    ): T {
      return addRecursiveSoftDeleteFilter(model, operation, args);
    }
    

    Use middleware or client extensions to hook into query lifecycles and apply the constraints to args before they execute. Here I'm also overriding the delete and deleteMany to turn them into update operations instead, so when using a query like prisma.user.delete() it will actually run a prisma.user.update() instead that will set the deletedAt to the current datetime.

    const prisma = new PrismaClient().$extends({
      query: {
        // Use $allModels to apply to every model
        $allModels: {
          // Apply soft-delete constraints to all operations
          $allOperations({ model, operation, args, query }) {
            args = applySoftDeleteConstraints(model as Prisma.ModelName, operation, args);
            return query(args);
          },
          // #region Override 'delete' and 'deleteMany' to transform these into 'update' operations.
          async delete({ model, args }): Promise<any> {
            return (prisma as any)[model].update({
              ...args,
              data: {
                [DELETED_AT_FIELD]: new Date(),
              },
            });
          },
          async deleteMany({ model, args }): Promise<any> {
            return (prisma as any)[model].updateMany({
              ...args,
              data: {
                [DELETED_AT_FIELD]: new Date(),
              },
            });
          },
          // #endregion
        },
      },
    });
    
  18. minhtan143 commented on Dec 8, 2025

    @minhtan143

    @haZya There are many issues beyond queries, like handling soft deletes for tables with @unique columns and findUnique, upsert related

  19. gerkim62 commented on Dec 26, 2025

    @gerkim62

    Unfortunately prisma is no longer exposing DMMF, which makes implementing this in userland even harder. The recommended client extension no longer works for the new prisma-client-js in Prisma@7. Please consider removing the status/has-client-extension

  20. EduardMcfly commented on Feb 15, 2026

    @EduardMcfly

    Hi everyone!

    I’m the author of prisma-paranoid. I developed this plugin to simplify how we handle soft deletes in Prisma, making the "paranoid" behavior feel more native and automated.

    The main goal is to avoid the boilerplate of manually adding { deletedAt: null } filters to every single query. It leverages Prisma’s $extends client and generated metadata to handle everything under the hood.

    1. Schema Setup

    Add the generator and the paranoid field to your schema.prisma:

    generator metadata {
      provider = "prisma-metadata-generator"
      output   = "./prisma/generated/metadata.ts"
    }
    
    model User {
      id        String    @id @default(uuid())
      email     String    @unique
      deletedAt DateTime? // default paranoid field name
    }
    

    Important

    Remember to run the generator to update your metadata:

    npx prisma generate
    

    2. Client Extension

    Then, simply extend your Prisma client:

    import prismaParanoid from 'prisma-paranoid';
    import metadata from './prisma/generated/metadata';
    
    const prisma = new PrismaClient().$extends(
      prismaParanoid({
        metadata,
        auto: true,
        defaultConfig: {
          field: {
            name: 'deletedAt', // default
            type: 'date',      // 'date' | 'boolean' | 'other'
          },
        },
      }),
    );
  21. arentalb commented on Mar 15, 2026

    @arentalb

    This issue has been open since 2019. If this feature is not planned, it might be better to close the issue. Otherwise, it would be helpful to have an update on its status

  22. chbybnwr commented on Mar 15, 2026

    @chbybnwr

    A workaround is to use row-level security, if your database can use it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions