Skip to main content
Back to Blog
Guide
2026-08-07

Database Testing Soft Delete Constraints: Prove Deleted Rows Stay Safe

Apply database testing soft delete constraints to catch uniqueness, restore, foreign-key, and retention bugs before production data drifts badly.

Database Testing Soft Delete Constraints: Prove Deleted Rows Stay Safe

Database testing soft delete constraints means proving that rows marked as deleted still obey the rules your product depends on. A soft delete is not only an UPDATE users SET deleted_at = now(). It changes uniqueness, foreign keys, restore behavior, search filters, retention jobs, audit history, and API semantics. If those rules live only in application code, they will eventually drift across services, background jobs, admin tools, and AI-generated migrations.

The useful testing strategy is to place invariant checks at the database boundary, then exercise the user-facing behavior through API and integration tests. For example, a deleted account might release its email address for reuse, but restoring that account should fail if another active account has claimed the email. A deleted project might hide from default queries, but its child audit events may need to remain immutable. Those are not UI details. They are data contracts.

This guide builds a practical workflow for QA and test-automation engineers: model the lifecycle, encode constraints where the database supports them, write migration tests, add API tests for delete and restore paths, and diagnose the failures that appear only after real production data accumulates. The API examples pair well with Supertest Node API testing, and the test layering choices fit the broader JavaScript testing frameworks guide.

Start With The Row Lifecycle, Not The Delete Button

A soft-deleted row is usually active, deleted, maybe restored, and eventually purged or retained for audit. Each state has different rules. Teams get into trouble when they test only the transition from active to deleted, then assume the rest is obvious. The restore path is where many constraint bugs surface because the old row must rejoin the active data set.

StateExample column valuesProduct expectationConstraint or test concern
Activedeleted_at is nullRow appears in normal reads and can be updatedUnique rules apply among active rows
Deleteddeleted_at has timestampRow is hidden from ordinary readsChild rows and audit history remain consistent
RestoringTimestamp is being clearedRow returns if active constraints still allow itRestore must fail on uniqueness conflict
Purge candidateDeleted before retention cutoffRow may be physically removedForeign keys and audit needs decide purge order
Held for auditDeleted but not purgeableRow remains queryable by privileged pathsAccess controls and retention jobs must respect hold

Write these states as examples before picking implementation. "Soft delete users" is too broad. "An active user email must be unique among active users; deleting a user releases the email for a new user; restoring the old user fails if the email is now active elsewhere" is testable.

Put Active-Row Uniqueness Where The Database Can Enforce It

The classic soft-delete bug is duplicate active records. A simple unique constraint on email prevents reuse after soft delete. Removing uniqueness allows two active users with the same email. The better design in databases that support it is a partial unique index over active rows. PostgreSQL documents partial indexes at https://www.postgresql.org/docs/current/indexes-partial.html.

CREATE TABLE app_user (
  id uuid PRIMARY KEY,
  email text NOT NULL,
  display_name text NOT NULL,
  deleted_at timestamptz NULL,
  created_at timestamptz NOT NULL DEFAULT now(),
  updated_at timestamptz NOT NULL DEFAULT now()
);

CREATE UNIQUE INDEX app_user_active_email_unique
  ON app_user (lower(email))
  WHERE deleted_at IS NULL;

That index says exactly what the product means: active email addresses are unique. Deleted rows remain in the table, but they do not block a new active row. If your database does not support partial unique indexes, do not pretend the same DDL is portable. Use the documented feature set of your database, such as generated columns, filtered indexes where available, or a separate active identity table. The test should still express the same invariant.

Business ruleWeak implementationStronger database-backed implementationTest case
Active emails are uniqueApp checks before insertPartial unique index on normalized email where activeInsert second active email fails
Deleted emails can be reusedDrop unique constraint entirelySame partial unique indexInsert active email after soft delete succeeds
Restore must respect current active dataApp checks before updateSame index blocks clearing deleted_atRestore old row after reuse fails
Email comparison is case-insensitiveLowercase in one serviceIndex on normalized expression or stored normalized columnA@EXAMPLE.COM and a@example.com conflict

What people get wrong: they write a single happy-path API test that deletes a user, creates another with the same email, and stops. That proves reuse. It does not prove active uniqueness, case handling, concurrent restore behavior, or direct database writes from another service.

Write Constraint Tests That Execute Real SQL

Unit tests around repository methods are useful, but they cannot prove that the database constraint exists. Use a real database engine in integration tests for migration-sensitive behavior. In Node projects, that may mean Testcontainers, a local Docker service in CI, or a dedicated test database. The key is that the test executes the actual schema.

import { describe, expect, it } from 'vitest';
import { db } from '../test/db';

describe('app_user soft delete uniqueness', () => {
  it('allows email reuse only after the first row is soft deleted', async () => {
    await db.query(
      'insert into app_user (id, email, display_name) values ($1, $2, $3)',
      ['11111111-1111-1111-1111-111111111111', 'sam@example.com', 'Sam One']
    );

    await expect(
      db.query(
        'insert into app_user (id, email, display_name) values ($1, $2, $3)',
        ['22222222-2222-2222-2222-222222222222', 'SAM@example.com', 'Sam Two']
      )
    ).rejects.toThrow();

    await db.query(
      'update app_user set deleted_at = now() where id = $1',
      ['11111111-1111-1111-1111-111111111111']
    );

    await expect(
      db.query(
        'insert into app_user (id, email, display_name) values ($1, $2, $3)',
        ['33333333-3333-3333-3333-333333333333', 'sam@example.com', 'Sam Three']
      )
    ).resolves.toBeDefined();
  });
});

This test intentionally checks case-insensitive conflict before soft delete, then reuse after soft delete. It will fail if someone removes the partial index, changes normalization, or accidentally makes deleted_at non-null by default.

Add a restore conflict test because restore failures are often missed in CRUD suites.

import { describe, expect, it } from 'vitest';
import { db } from '../test/db';

describe('app_user restore constraints', () => {
  it('blocks restoring a deleted row when another active row owns the email', async () => {
    await db.query(
      'insert into app_user (id, email, display_name, deleted_at) values ($1, $2, $3, now())',
      ['aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'lee@example.com', 'Lee Old']
    );

    await db.query(
      'insert into app_user (id, email, display_name) values ($1, $2, $3)',
      ['bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', 'lee@example.com', 'Lee New']
    );

    await expect(
      db.query(
        'update app_user set deleted_at = null where id = $1',
        ['aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa']
      )
    ).rejects.toThrow();
  });
});

Do not assert the exact database error text unless your driver and database version make it stable enough for your team. In most test suites, it is enough to assert rejection and then separately test that the API converts the database failure into a product-level error response.

Test Query Filters Against Deleted Rows

Another common bug is forgetting deleted_at IS NULL in a read query. It may happen in a new endpoint, a reporting view, an autocomplete search, or an AI-generated repository method. Database constraints do not automatically hide deleted rows. You need tests around default scopes and privileged reads.

CREATE VIEW active_project AS
SELECT id, account_id, name, created_at, updated_at
FROM project
WHERE deleted_at IS NULL;

If your architecture supports database views, they can centralize the active-row filter. If not, use repository helpers and test them. Be careful with admin and audit features. They may intentionally include deleted rows, but that should be explicit in the method name, route, permission, and tests.

Query typeShould include deleted rows?Test fixtureFailure symptom
Customer list endpointNoOne active, one deleted customerDeleted customer appears in UI
Admin audit lookupYes, if authorizedDeleted row with audit eventSupport cannot investigate deletion
Search autocompleteNoDeleted project matching search termUser selects unavailable entity
Foreign-key validationUsually active onlyDeleted parent id in requestNew child attaches to deleted parent
Retention purge jobYes, with cutoffOld deleted and recent deleted rowsRecent data is purged too early

A repository test can pin the default behavior:

import { describe, expect, it } from 'vitest';
import { createProject, listActiveProjects, softDeleteProject } from './projectRepository';

describe('projectRepository active scope', () => {
  it('does not return soft-deleted projects from ordinary listing', async () => {
    const active = await createProject({ accountId: 'acct-1', name: 'Checkout Tests' });
    const deleted = await createProject({ accountId: 'acct-1', name: 'Legacy Tests' });

    await softDeleteProject(deleted.id);

    const projects = await listActiveProjects('acct-1');

    expect(projects.map((project) => project.id)).toContain(active.id);
    expect(projects.map((project) => project.id)).not.toContain(deleted.id);
  });
});

This looks simple, but it catches high-impact regressions when a query is rewritten for performance and loses the active filter.

Foreign Keys Need A Soft-Delete Policy Too

Foreign keys protect physical references, not product availability. If a parent row is soft deleted, the child row can still reference it because the parent still exists. That may be exactly what you want for invoices, audit events, or historical test runs. It may be wrong for new comments, active memberships, or queue jobs that should not attach to deleted records.

RelationshipPossible policyDatabase supportTest focus
Audit event -> deleted userKeep referenceOrdinary foreign keyDeleted user remains available for audit lookup
Active membership -> accountBlock new child if account deletedApplication transaction or triggerCreating membership under deleted account fails
Project -> accountSoft delete children with parentTransactional update or jobChildren hidden after parent delete
Invoice -> customerPreserve historical customer referenceOrdinary foreign key plus restricted purgePurge job does not break invoice history
Invite -> teamExpire or delete when team deletedApplication logic or cascading jobInvite cannot be accepted after team deletion

For constraints that cannot be expressed as ordinary foreign keys, write integration tests around the transaction boundary. If you use triggers, test the trigger with direct SQL. If you rely on application services, test the service and add a direct database test for the invariant the database still owns.

CREATE TABLE account (
  id uuid PRIMARY KEY,
  name text NOT NULL,
  deleted_at timestamptz NULL
);

CREATE TABLE membership (
  id uuid PRIMARY KEY,
  account_id uuid NOT NULL REFERENCES account(id),
  user_id uuid NOT NULL,
  deleted_at timestamptz NULL
);

That schema says membership rows must reference existing account rows. It does not say new active memberships are forbidden for deleted accounts. If the product requires that rule, add an application transaction test or a database trigger depending on your architecture.

import { expect, test } from 'vitest';
import { createMembership } from './membershipService';
import { insertAccount, softDeleteAccount } from '../test/fixtures';

test('active membership cannot be created under a deleted account', async () => {
  const account = await insertAccount({ name: 'Old Workspace' });
  await softDeleteAccount(account.id);

  await expect(
    createMembership({ accountId: account.id, userId: 'user-42' })
  ).rejects.toThrow('account is not active');
});

The exact error message should be a product-level error, not a raw database exception. The database may still be the source of truth for uniqueness, but the service should translate failures into responses that clients can use.

Exercise Delete And Restore Through The API

Database tests prove invariants. API tests prove that users encounter those invariants correctly. A delete endpoint should be idempotent if the product contract says repeated delete requests are safe. A restore endpoint should return a clear conflict when the database rejects the restore. A list endpoint should hide deleted rows by default.

import request from 'supertest';
import { describe, expect, it } from 'vitest';
import { app } from '../src/app';
import { createUser, softDeletedUser } from './fixtures';

describe('user soft delete API', () => {
  it('hides deleted users and returns conflict on blocked restore', async () => {
    const oldUser = await softDeletedUser({ email: 'riley@example.com' });
    await createUser({ email: 'riley@example.com' });

    const restore = await request(app)
      .post('/admin/users/' + oldUser.id + '/restore')
      .expect(409);

    expect(restore.body.error.code).toBe('USER_EMAIL_ALREADY_ACTIVE');

    const list = await request(app)
      .get('/admin/users')
      .expect(200);

    expect(list.body.users.some((user: { id: string }) => user.id === oldUser.id)).toBe(false);
  });
});

Use API tests to assert response codes, error envelopes, permissions, and filtering. Use database tests to assert direct invariants. Do not make API tests inspect internal index names unless you are testing an admin diagnostics endpoint. Users do not care that the index is called app_user_active_email_unique. They care that restore gives a clear conflict and no duplicate active account appears.

Test Migrations With Production-Like Dirty Data

Soft-delete constraints are often added after a product already has data. The migration may fail because duplicate active rows already exist, deleted rows have impossible timestamps, or child rows reference records that the new policy considers unavailable. A migration test should include dirty but realistic data.

INSERT INTO app_user (id, email, display_name, deleted_at)
VALUES
  ('10000000-0000-0000-0000-000000000001', 'case@example.com', 'Case One', NULL),
  ('10000000-0000-0000-0000-000000000002', 'CASE@example.com', 'Case Two', NULL);

CREATE UNIQUE INDEX app_user_active_email_unique
  ON app_user (lower(email))
  WHERE deleted_at IS NULL;

That migration should fail because two active rows conflict after normalization. The test should make the failure visible before deployment, then the migration plan should include a cleanup step or a product decision. Do not let the database surprise you during a production deploy.

Dirty-data caseMigration riskTest fixture
Duplicate active normalized emailsUnique index creation failsTwo active rows with case variation
Deleted row has null audit actorNew not-null audit policy failsDeleted row from legacy path
Child references deleted parentNew active-parent policy failsActive child under deleted account
Old deleted rows exceed retentionPurge job deletes too much at onceMix of old and recent deleted rows
Restore token exists for deleted rowUser can revive invalid stateDeleted row with active restore token

Migration tests do not need the entire production database. They need curated examples that represent the messiest legal states your system may contain.

Diagnose The Failure Where Restore Creates A Duplicate Active Row

A realistic failure mode: the app checks for active email conflicts before restore, finds none, then another request creates a new active row with the same email before the restore transaction commits. Without a database constraint, both succeed. The bug appears as duplicate active users, login ambiguity, or a support ticket where password reset reaches the wrong account.

Diagnosis path:

EvidenceInterpretationNext step
Duplicate active rows existInvariant is not enforced in databaseInspect unique indexes or constraints
App logs show conflict check passedTime-of-check to time-of-use raceMove invariant to database transaction or constraint
Restore endpoint returned successAPI trusted stale precheckAdd conflict handling for database rejection
Only one service enforces ruleAnother writer bypassed serviceCentralize invariant or constrain database
Duplicates differ only by caseNormalization mismatchNormalize in index or stored column

Reproduce with two concurrent operations if your test infrastructure supports it, but do not depend only on a race test. Race tests can be flaky. The durable fix is a database invariant that rejects the impossible state. The API test should then assert graceful handling of that rejection.

import { expect, test } from 'vitest';
import { db } from '../test/db';

test('database prevents duplicate active users even when writers race', async () => {
  await db.query(
    'insert into app_user (id, email, display_name, deleted_at) values ($1, $2, $3, now())',
    ['90000000-0000-0000-0000-000000000001', 'race@example.com', 'Old Race']
  );

  const restoreOld = db.query(
    'update app_user set deleted_at = null where id = $1',
    ['90000000-0000-0000-0000-000000000001']
  );

  const createNew = db.query(
    'insert into app_user (id, email, display_name) values ($1, $2, $3)',
    ['90000000-0000-0000-0000-000000000002', 'race@example.com', 'New Race']
  );

  const results = await Promise.allSettled([restoreOld, createNew]);
  const rejected = results.filter((result) => result.status === 'rejected');

  expect(rejected).toHaveLength(1);
});

This test should be run against a clean isolated database because it intentionally creates a conflict. It proves the invariant, not the exact ordering of operations.

Add Retention And Purge Tests Before The Table Gets Huge

Soft delete often starts as a product convenience and later becomes a retention problem. Rows accumulate. Indexes grow. Queries slow down because the table contains years of deleted data. Then someone adds a purge job under pressure. Without tests, that job may delete rows still needed for audit, invoices, legal hold, or restore windows.

Define retention by entity type:

EntityDeleted row retentionPurge allowed?Test proof
User profileProduct policy dependentMaybeDeleted profile older than cutoff is anonymized or removed
Audit eventLong retentionUsually noPurge job leaves audit event intact
Project draftShort retentionYesOld deleted draft is removed
Invoice customer referenceAccounting policy dependentUsually restrictedInvoice still resolves display details
Session tokenVery short retentionYesDeleted user tokens are revoked quickly

A purge test should include old deleted, recent deleted, active, and held rows. It should assert both what disappears and what remains. Do not write a test that only checks "some rows were deleted".

import { expect, test } from 'vitest';
import { purgeDeletedProjects } from './retentionJob';
import { createProjectFixture, findProjectById } from '../test/fixtures';

test('purge removes only deleted projects older than retention cutoff', async () => {
  const active = await createProjectFixture({ name: 'Active', deletedAt: null });
  const recent = await createProjectFixture({ name: 'Recent', deletedAt: '2026-08-01T00:00:00Z' });
  const old = await createProjectFixture({ name: 'Old', deletedAt: '2025-01-01T00:00:00Z' });

  await purgeDeletedProjects({ now: new Date('2026-08-07T00:00:00Z'), retentionDays: 180 });

  expect(await findProjectById(active.id)).not.toBeNull();
  expect(await findProjectById(recent.id)).not.toBeNull();
  expect(await findProjectById(old.id)).toBeNull();
});

Use fixed dates in tests. Relative "now" makes retention tests brittle and difficult for AI agents to reason about when they update fixtures.

Frequently Asked Questions

Should soft delete rules live in the database or application code?

Use both, but give each layer the right responsibility. The database should enforce invariants that must never be false, such as active-row uniqueness, required relationships, and impossible restore states where your database supports that expression. Application code should enforce workflow, permissions, product messages, and business decisions that depend on context. If a rule would corrupt data when bypassed by a background job or another service, it is a strong candidate for a database constraint or trigger.

How do I test soft delete uniqueness when my database lacks partial indexes?

Keep the same behavioral tests, but adapt the implementation to documented database features. Some systems use filtered indexes, generated columns, separate active identity tables, or transactionally maintained lookup rows. Do not copy PostgreSQL DDL into a database that does not support it. Your tests should still prove three outcomes: duplicate active rows fail, deleted rows can release identifiers if the product allows it, and restore fails when it would create an active conflict.

Should deleted rows appear in admin APIs?

Only when the admin route explicitly needs them and access control is clear. Ordinary list, search, autocomplete, and validation endpoints should usually exclude deleted rows. Audit, support, legal, and retention views may include them. Test both paths with the same fixture: one active row and one deleted row. The default endpoint should hide the deleted row, while the privileged endpoint should include it with deletion metadata so reviewers can see the distinction.

Why do restore bugs escape normal CRUD tests?

Restore combines old data with current constraints. A create test checks new input. A delete test checks hiding. A restore test must ask whether the old row is still valid in today's active data set. Emails may have been reused, parent accounts may be deleted, roles may no longer exist, and retention holds may apply. Add explicit restore conflict tests at the database and API layers, especially for unique identifiers and relationships.