Implementing GDPR right to erasure without corrupting your database

GDPR Article 17 gives EU data subjects the right to have their personal data erased. For a SaaS product, this sounds straightforward until you try to implement it: users are referenced by foreign keys across dozens of tables, audit logs specifically need to retain activity records for legal and security reasons, and a naive hard delete can destroy data integrity or hide evidence of fraud. This post covers the practical implementation patterns that satisfy the regulation without breaking your database.

What erasure actually requires

Article 17 doesn't require that you delete every byte ever associated with a user. It requires that you delete personal data that was processed based on consent or contract when that basis no longer applies. Exemptions explicitly include:

  • Data necessary for legal compliance (financial records, fraud investigation)
  • Data needed for public interest, scientific, or historical research
  • Data for the establishment, exercise, or defense of legal claims

For a typical SaaS, this means: personal identifiers (name, email, IP addresses, behavioral data) must be erased; transaction records and audit logs may be retained in pseudonymized form.

Tombstoning vs hard delete

A tombstone replaces identifying fields with null or anonymized values while preserving the row's existence and foreign key integrity. The user row continues to exist as a reference point for other records, but no longer contains personal data.

-- Before erasure
SELECT id, email, name, created_at FROM users WHERE id = 'usr_abc';
-- usr_abc | james@example.com | James Smith | 2024-03-15

-- Tombstone: zero out personal data, preserve the ID
UPDATE users SET
  email          = CONCAT('deleted_', id, '@erased.invalid'),
  name           = '[Deleted]',
  phone          = NULL,
  avatar_url     = NULL,
  raw_user_meta  = '{}',
  erased_at      = NOW(),
  erasure_type   = 'gdpr_article_17'
WHERE id = 'usr_abc';

-- After erasure: row still exists, but no PII
-- usr_abc | deleted_usr_abc@erased.invalid | [Deleted] | 2024-03-15

This preserves referential integrity. Your audit_logs table that references user_id = 'usr_abc' remains valid. The audit logs themselves don't need to be deleted — they're retained as part of your legal compliance exemption, and they no longer reveal PII because the user record has been anonymized.

Hard delete with cascades: when it's appropriate

For data that truly has no retention purpose, hard delete with carefully designed cascades is the right choice. Design your schema with erasure in mind:

-- Tables that should cascade-delete on user erasure
CREATE TABLE sessions (
  user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE
);

CREATE TABLE email_verification_tokens (
  user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE
);

CREATE TABLE mfa_credentials (
  user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE
);

-- Tables that should retain records with anonymized foreign key reference
CREATE TABLE audit_logs (
  user_id UUID REFERENCES users(id) ON DELETE SET NULL,
  -- When user is deleted, user_id becomes NULL but the log entry persists
  action     TEXT NOT NULL,
  ip_address INET,  -- this should also be zeroed during erasure
  created_at TIMESTAMPTZ NOT NULL
);

The erasure pipeline

async function eraseUser(userId: string, db: DB, reason: string): Promise<void> {
  await db.transaction(async (trx) => {
    // 1. Verify the user exists and hasn't already been erased
    const user = await trx.users.findById(userId);
    if (!user) throw new Error('User not found');
    if (user.erasedAt) throw new Error('User already erased');

    // 2. Log the erasure request (this log itself is exempt from erasure)
    await trx.erasureRequests.create({
      userId,
      requestedAt: new Date(),
      reason,
      completedAt: null,
    });

    // 3. Hard delete cascading data (sessions, tokens, MFA devices)
    // These cascade automatically via ON DELETE CASCADE

    // 4. Anonymize the user record
    await trx.query(`
      UPDATE users SET
        email          = $2,
        name           = '[Deleted]',
        phone          = NULL,
        avatar_url     = NULL,
        password_hash  = NULL,
        erased_at      = NOW()
      WHERE id = $1
    `, [userId, `deleted_${userId}@erased.invalid`]);

    // 5. Anonymize audit log IP addresses for this user
    await trx.query(`
      UPDATE audit_logs
      SET ip_address = '0.0.0.0'
      WHERE user_id = $1
    `, [userId]);

    // 6. Mark erasure complete
    await trx.erasureRequests.markComplete(userId);
  });

  // 7. Purge from search indexes, caches, CDN
  await purgeFromExternalSystems(userId);
}

External system purge checklist

Your database is not the only place user data lives. For each user erasure, you must also purge:

  • Search indexes (Elasticsearch, Typesense) — user profiles, email addresses in search
  • Cache layers (Redis) — cached user objects, session data
  • Analytics platforms (Mixpanel, Amplitude, PostHog) — user event data
  • Email service providers (SendGrid, Postmark) — contact lists, email history
  • Error monitoring (Sentry) — user context attached to error events
  • Cloud storage (S3) — profile avatars, user-uploaded files
  • Backups — GDPR doesn't require you to purge from backups immediately, but you must ensure the backup is never restored in a way that un-erases the user
async function purgeFromExternalSystems(userId: string): Promise<void> {
  await Promise.allSettled([
    searchIndex.deleteUser(userId),
    redis.del(`user:${userId}`, `user:profile:${userId}`),
    analyticsService.deleteUser(userId),
    emailProvider.deleteContact(userId),
    s3.deleteObject({ Bucket: 'avatars', Key: `users/${userId}/avatar.jpg` }),
  ]);
  // Note: use allSettled, not all — partial failures shouldn't roll back the DB erasure.
  // Log failures separately for manual follow-up.
}

Handling erasure requests: SLA and process

GDPR requires you to respond to erasure requests within 30 days. Build a queue-based system rather than processing synchronously:

CREATE TABLE erasure_requests (
  id            UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id       UUID NOT NULL,
  requested_at  TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  request_type  TEXT NOT NULL CHECK (request_type IN ('gdpr', 'ccpa', 'voluntary')),
  requester_ip  INET,
  status        TEXT NOT NULL DEFAULT 'pending'
                CHECK (status IN ('pending', 'in_progress', 'completed', 'rejected')),
  rejection_reason TEXT,
  completed_at  TIMESTAMPTZ,
  deadline      TIMESTAMPTZ NOT NULL GENERATED ALWAYS AS
                (requested_at + INTERVAL '30 days') STORED
);
Erasure is not immediate — a user may have outstanding invoices, active subscriptions, or be under investigation. Build a review step for edge cases, and document your legitimate interest grounds for deferral. A 7-day processing SLA is reasonable for most cases, with legal review extending to the full 30 days for complex situations.