Implement a GDPR Right-to-Erasure Deletion Job
Build an idempotent erasure job that deletes disposable personal data, anonymizes retained records, records a minimal audit tombstone, and verifies completion across tables.
Task
Implement an idempotent erasure job that deletes or anonymizes a user's personal data across related PostgreSQL tables.
When to use this
A verified erasure request must remove a customer's profile, marketing events, and order contact details without deleting financial records the company must retain.
Prerequisites
- A verified data-subject request with a user ID and a documented legal scope
- PostgreSQL 14+ with the affected tables and foreign keys mapped
- A job runner with retries and a secret manager for the erasure pepper
- Legal approval for the retention basis stored in the tombstone
Steps
1. Discover every table that stores the user ID or contact fields, then review likely JSON and log columns manually.
psql "$DATABASE_URL" -c "
SELECT table_schema, table_name, column_name
FROM information_schema.columns
WHERE table_schema NOT IN ('pg_catalog', 'information_schema')
AND (column_name = 'user_id'
OR column_name ILIKE '%email%'
OR column_name ILIKE '%phone%')
ORDER BY table_schema, table_name;"
Expected output: A list that includes app.profiles.email, app.profiles.phone, app.orders.customer_email, app.audit_events.actor_email, and every other table that needs a decision.
2. Create the request queue and an append-only tombstone table that stores only a keyed hash and the legal retention basis.
psql "$DATABASE_URL" <<'SQL'
CREATE TABLE IF NOT EXISTS app.erasure_requests (
user_id uuid PRIMARY KEY,
requested_at timestamptz NOT NULL DEFAULT now(),
completed_at timestamptz,
attempts integer NOT NULL DEFAULT 0,
last_error text
);
CREATE TABLE IF NOT EXISTS app.erasure_tombstones (
user_id_hash text PRIMARY KEY,
erased_at timestamptz NOT NULL DEFAULT now(),
retained_legal_basis text NOT NULL
);
SQL
Expected output: psql prints CREATE TABLE twice and the two erasure tables exist in the app schema.
3. Implement a single-transaction job that locks the request, deletes disposable rows, anonymizes retained rows, and records completion.
import { createHash } from 'node:crypto';
import pg from 'pg';
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL, max: 2 });
const userId = process.argv[2];
const pepper = process.env.ERASURE_PEPPER;
if (!userId || !pepper) {
console.error('usage: ERASURE_PEPPER=<secret> node jobs/erase-user.mjs <verified-user-id>');
process.exit(2);
}
async function eraseUser(id) {
const client = await pool.connect();
try {
await client.query('BEGIN');
await client.query('SELECT pg_advisory_xact_lock(hashtext($1))', [id]);
const { rows } = await client.query(
'SELECT completed_at FROM app.erasure_requests WHERE user_id = $1 FOR UPDATE',
[id],
);
if (rows.length === 0) throw new Error(`no verified erasure request for ${id}`);
if (rows[0].completed_at) {
await client.query('COMMIT');
return 'already-erased';
}
await client.query('DELETE FROM app.marketing_events WHERE user_id = $1', [id]);
await client.query('DELETE FROM app.sessions WHERE user_id = $1', [id]);
await client.query(
`UPDATE app.orders
SET customer_email = NULL, shipping_address = NULL
WHERE user_id = $1`,
[id],
);
await client.query(
'UPDATE app.audit_events SET actor_email = NULL WHERE actor_user_id = $1',
[id],
);
const userHash = createHash('sha256').update(`${pepper}:${id}`).digest('hex');
await client.query(
`UPDATE app.profiles
SET email = $2, display_name = 'Erased user', phone = NULL, erased_at = now()
WHERE user_id = $1`,
[id, `erased-${userHash.slice(0, 16)}@invalid.example`],
);
await client.query(
`INSERT INTO app.erasure_tombstones (user_id_hash, retained_legal_basis)
VALUES ($1, 'tax-records-7y')
ON CONFLICT (user_id_hash) DO NOTHING`,
[userHash],
);
await client.query(
'UPDATE app.erasure_requests SET completed_at = now(), last_error = NULL WHERE user_id = $1',
[id],
);
await client.query('COMMIT');
return 'erased';
} catch (error) {
await client.query('ROLLBACK').catch(() => {});
await pool
.query(
'UPDATE app.erasure_requests SET attempts = attempts + 1, last_error = $2 WHERE user_id = $1',
[id, String(error instanceof Error ? error.message : error)],
)
.catch(() => {});
throw error;
} finally {
client.release();
}
}
try {
console.log(await eraseUser(userId));
} catch (error) {
console.error(error instanceof Error ? error.message : error);
process.exitCode = 1;
} finally {
await pool.end();
}
Expected output: The first run returns erased, a second run returns already-erased, and neither run throws a foreign-key error.
4. Seed a disposable user, run the job twice, and assert that disposable rows are gone, retained rows contain no contact data, and one tombstone exists.
psql "$DATABASE_URL" <<'SQL'
INSERT INTO app.profiles (user_id, email, display_name, phone)
VALUES ('11111111-1111-4111-8111-111111111111', '[email protected]', 'Erase Me', '+15550100')
ON CONFLICT (user_id) DO UPDATE
SET email = EXCLUDED.email, display_name = EXCLUDED.display_name,
phone = EXCLUDED.phone, erased_at = NULL;
INSERT INTO app.orders (user_id, customer_email, shipping_address)
VALUES ('11111111-1111-4111-8111-111111111111', '[email protected]', '1 Test Street');
INSERT INTO app.marketing_events (user_id, event)
VALUES ('11111111-1111-4111-8111-111111111111', 'signup');
INSERT INTO app.erasure_requests (user_id)
VALUES ('11111111-1111-4111-8111-111111111111')
ON CONFLICT (user_id) DO UPDATE
SET completed_at = NULL, attempts = 0, last_error = NULL;
SQL
ERASURE_PEPPER=local-test-pepper \
node jobs/erase-user.mjs 11111111-1111-4111-8111-111111111111
ERASURE_PEPPER=local-test-pepper \
node jobs/erase-user.mjs 11111111-1111-4111-8111-111111111111
psql "$DATABASE_URL" -c "
SELECT
(SELECT count(*) FROM app.marketing_events WHERE user_id = '11111111-1111-4111-8111-111111111111') AS marketing_rows,
(SELECT count(*) FROM app.orders WHERE user_id = '11111111-1111-4111-8111-111111111111'
AND (customer_email IS NOT NULL OR shipping_address IS NOT NULL)) AS order_pii_rows,
(SELECT count(*) FROM app.profiles WHERE user_id = '11111111-1111-4111-8111-111111111111'
AND (email NOT LIKE 'erased-%@invalid.example' OR phone IS NOT NULL OR display_name <> 'Erased user')) AS profile_pii_rows,
(SELECT count(*) FROM app.erasure_requests WHERE user_id = '11111111-1111-4111-8111-111111111111'
AND completed_at IS NOT NULL) AS completed,
(SELECT count(*) FROM app.erasure_tombstones WHERE erased_at > now() - interval '10 minutes') AS tombstones;"
Expected output: The job prints erased and then already-erased; the assertion row is 0 | 0 | 0 | 1 | 1.
Troubleshooting
Problem
The job fails with a foreign-key violation
Solution
A retained table still references the row being deleted. Anonymize the referencing rows or change the delete order; do not disable the constraint.
Problem
The second run reports no verified erasure request
Solution
The erasure_requests row was deleted. Never delete the request row; it is the idempotency key and the operational record of completion.
Problem
Tombstone hashes change between environments
Solution
The pepper differs or is missing. Store one stable pepper in the secret manager and include it in the documented rotation plan.
Limitations
- Database backups and snapshots retain the data until their retention period expires; document this in the privacy notice and do not rewrite backups.
- Tax, fraud, and accounting records may require pseudonymization rather than deletion; legal counsel must approve the retained basis.
- Data warehouses, email vendors, and log systems need separate deletion jobs; this recipe covers only the PostgreSQL schema.
Map the data before deleting anything
An erasure request is a data-flow problem, not a single DELETE. Start with information_schema to find direct identifiers, then inspect JSON columns, text search indexes, logs, backups, and downstream systems by hand. For each location, choose one of three actions: delete, anonymize, or retain under a documented legal basis. The job should encode those decisions, and the runbook should explain why a row remains.
Orders are a common example. The company may be required to keep the transaction amount and date for tax purposes, but it does not need the customer’s email and shipping address in that row. Anonymizing those columns satisfies both obligations. Marketing events and sessions usually have no retention requirement, so they can be deleted outright. Audit events often need to stay for security review, but the actor’s email can be removed.
Make retries harmless
The request table is the idempotency key. The job locks the user row with pg_advisory_xact_lock, checks completed_at, and returns already-erased on a retry. The row is never deleted, because deleting it would make a second run look like an unverified request. All destructive work happens in one transaction, so a partial failure leaves the database unchanged. The separate failure update records the attempt count and error without pretending the erasure succeeded.
The tombstone stores a keyed hash, not the raw user ID. It proves that the request was processed and supports duplicate-request detection, while revealing little if the table is exposed. Keep the pepper stable and in a secret manager. Rotating it without a migration breaks deduplication and makes old tombstones unlinkable.
Verify with a disposable user
Seed a user with a profile, an order, and a marketing event. Run the job; it should print erased. Run it again; it should print already-erased without an error. Then assert that marketing rows are gone, order contact fields are null, the profile contains only the anonymized address and display name, the request is completed, and exactly one tombstone exists. The counts should be 0, 0, 0, 1, 1.
Test the failure path as well: remove a table from the transaction or introduce a constraint violation in staging and confirm the transaction rolls back, the request stays incomplete, and attempts increments. A job that only works on the happy path is not an erasure process.
Pitfalls
Do not log the raw user ID or the email while processing the request; logs are another personal-data store. Do not send the entire row to a data warehouse for “audit” before deletion. Do not assume a backup restore will remove the data; design retention so old snapshots expire. And do not promise immediate erasure everywhere when downstream vendors have their own latency; state the scope and timeline in the privacy notice.
Example
import { createHash } from 'node:crypto';
import pg from 'pg';
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL, max: 2 });
const userId = process.argv[2];
const pepper = process.env.ERASURE_PEPPER;
if (!userId || !pepper) {
console.error('usage: ERASURE_PEPPER=<secret> node jobs/erase-user.mjs <verified-user-id>');
process.exit(2);
}
async function eraseUser(id) {
const client = await pool.connect();
try {
await client.query('BEGIN');
await client.query('SELECT pg_advisory_xact_lock(hashtext($1))', [id]);
const { rows } = await client.query(
'SELECT completed_at FROM app.erasure_requests WHERE user_id = $1 FOR UPDATE',
[id],
);
if (rows.length === 0) throw new Error(`no verified erasure request for ${id}`);
if (rows[0].completed_at) {
await client.query('COMMIT');
return 'already-erased';
}
await client.query('DELETE FROM app.marketing_events WHERE user_id = $1', [id]);
await client.query('DELETE FROM app.sessions WHERE user_id = $1', [id]);
await client.query(
`UPDATE app.orders
SET customer_email = NULL, shipping_address = NULL
WHERE user_id = $1`,
[id],
);
await client.query(
'UPDATE app.audit_events SET actor_email = NULL WHERE actor_user_id = $1',
[id],
);
const userHash = createHash('sha256').update(`${pepper}:${id}`).digest('hex');
await client.query(
`UPDATE app.profiles
SET email = $2, display_name = 'Erased user', phone = NULL, erased_at = now()
WHERE user_id = $1`,
[id, `erased-${userHash.slice(0, 16)}@invalid.example`],
);
await client.query(
`INSERT INTO app.erasure_tombstones (user_id_hash, retained_legal_basis)
VALUES ($1, 'tax-records-7y')
ON CONFLICT (user_id_hash) DO NOTHING`,
[userHash],
);
await client.query(
'UPDATE app.erasure_requests SET completed_at = now(), last_error = NULL WHERE user_id = $1',
[id],
);
await client.query('COMMIT');
return 'erased';
} catch (error) {
await client.query('ROLLBACK').catch(() => {});
await pool
.query(
'UPDATE app.erasure_requests SET attempts = attempts + 1, last_error = $2 WHERE user_id = $1',
[id, String(error instanceof Error ? error.message : error)],
)
.catch(() => {});
throw error;
} finally {
client.release();
}
}
try {
console.log(await eraseUser(userId));
} catch (error) {
console.error(error instanceof Error ? error.message : error);
process.exitCode = 1;
} finally {
await pool.end();
}