Home

Implementing Soft Deletions with Drizzle ORM and PostgreSQL

IF-LOOPI
IF-LOOP (@if-loop)
May 4, 2025

Soft deletion is a technique used in databases to mark records as deleted without actually removing them. This method is especially valuable in applications requiring audit trails, undo capabilities, or strict compliance with data retention policies.

The technique is typically implemented by adding a deleted_at timestamp column to your tables. Instead of executing a SQL DELETE operation, this field is updated to the current timestamp. While some high-level ORMs, such as Laravel’s Eloquent, provide native support for soft deletion, Drizzle ORM—at the time of writing—requires developers to implement it manually.

Implementation Guide

Note

We assume drizzle-orm v0.41.0 in this guide. Newer versions might require slight adjustments.

Let's walk through an example of implementing soft deletion using Drizzle ORM and TypeScript.

Define your Schema

To enable soft deletions in your database, you’ll need to add a deletedAt field to the relevant table. This field is typically a nullable timestamp that indicates when the row was marked as deleted. If the value is NULL, the row is considered active. The following code shows an example user entity:

export const users = pgTable(
  'users',
  {
    id: uuid().primaryKey().defaultRandom(),
    username: varchar({ length: 255 }).notNull(),
    deletedAt: timestamp(),
  },
  (table) => [
    uniqueIndex('users_username_idx')
      .on(sql`lower(${table.username})`)
      .where(sql`"users".deleted_at IS NULL`),
  ],
);

Partial Indexes

One challenge with soft deletions is enforcing uniqueness. If a field (like username) must be unique, soft-deleted rows can interfere unless handled properly.

PostgreSQL supports partial indexes, which allow you to enforce uniqueness only when a condition is met. In this case, we only want to enforce uniqueness for rows where deleted_at is NULL. This ensures soft-deleted records don't violate unique constraints and allows users to re-register with the same username, for example.

Example Queries

Deleting the entity

To soft-delete the user, we can simply update the deletedAt column to the current timestamp.

const deleteUser = async (id: string) => {
  const affected = await db
    .update(users)
    .set({ deletedAt: sql`now()` })
    .where(and(eq(users.id, id), isNull(users.deletedAt)))
    .returning({ id: users.id });

  if (affected.length === 0) {
    throw new Error('User not found')
  }
}

To permanently delete a record, you can require that the user be soft-deleted first:

const hardDeleteUser = async (id: string) => {
  const affected = await db
    .delete(users)
    .where(and(eq(users.id, id), isNotNull(users.deletedAt)))
    .returning({ id: users.id });

  if (affected.length === 0) {
    throw new Error('User not found')
  }
}

A common pattern is to schedule a background job that permanently removes soft-deleted records older than a specified number of days.

Selecting or Updating the entity

When querying for users, ensure soft-deleted records are excluded by checking that deletedAt is null:

const listUsers = async () => {
  return db.select().from(users).where(isNull(users.deletedAt));
}

Easier Querying with Views

To avoid repeating deleted_at IS NULL in your queries, you can create a view that abstracts this logic:

export const activeUsers = pgView("active_users").as((qb) =>
  qb.select().from(users).where(isNull(users.deletedAt)));

This simplifies your application logic and reduces the risk of accidentally including deleted records.

Conclusion

While Drizzle ORM does not offer soft deletion as a built-in feature, the implementation is still pretty straightforward. Combined with PostgreSQL Partial Indexes and Views, you can maintain clean and secure code.

Learn More