erasureMap listing the foreign keys and the no-foreign-key columns that hold a user, from pasted CREATE TABLE statements

Someone asks you to delete their data. You open the schema and the real question shows up: which tables hold this person? The ones with a foreign key to users are easy, because the database tells you (or refuses the delete). The rest are the problem: an actor_id in the audit log, an email in the newsletter table, a partition that nobody added the constraint to.

You don't need a copy of production to start. A schema-only dump is enough to draw the map. We pasted the CREATE TABLE statements of four public schemas into erasureMap, a free browser tool that reads them, and counted what it found. Nothing was run against a database for this article: every number below is what the tool read from the schema files on 2026-11-15, and where that has limits, we say so.

What you paste in

Any of these, whichever engine you use:

bash
pg_dump --schema-only --no-owner mydb > schema.sql      # PostgreSQL
mysqldump --no-data --skip-comments mydb > schema.sql        # MySQL / MariaDB
sqlite3 app.db .schema > schema.sql                          # SQLite

Then pick the users table. The tool (all in your tab, nothing uploaded) does three things:

- Foreign keys to the users table, direct and through other tables (order_items → orders → users), each with its ON DELETE rule and whether the child column starts an index.
- Columns with no foreign key that look like a user: user_id, actor_id, created_by, post_author, plus personal data such as email, phone, ip_address.
- A script: children first, user last, plus the queries that should all read 0 afterwards.

Four real schemas, counted

Sakila (MySQL's sample DVD-rental database, in the jOOQ copy), Pagila (its PostgreSQL port), Chinook (a music store) and the WordPress core schema (parsed from schema.php in WordPress's trunk, the 18 tables of a multisite install). The users table is customer, customer, Customer and wp_users.

text
schema      tables  FKs to users   ON DELETE rules                    FK column      columns with no FK that
                    (direct+deeper)                                   not indexed    name a user (tables)
Sakila         16     2 + 1         RESTRICT 2, SET NULL 1             1              0
Pagila         71     7 + 6         NO ACTION 12, RESTRICT 1           7              50 (50)
Chinook        11     1 + 1         NO ACTION 2                        0              0
WordPress      18     0             -                                  -              5 (4)

Read it as four different answers to the same question:

- Chinook is the tidy case. Two foreign keys, both NO ACTION, so a plain DELETE FROM Customer fails until InvoiceLine and Invoice are gone. The script order is invoice_line, invoice, customer.
- Sakila has RESTRICT on both payment and rental, and one SET NULL that reaches the customer only through rental (payment.rental_id). Nothing cascades: you delete payment and rental yourself.
- WordPress has no foreign keys at all. DELETE FROM wp_users never fails, and it leaves every post, comment and link behind. By name the tool finds wp_posts.post_author, wp_comments.user_id, wp_links.link_owner and wp_usermeta.user_id: four tables that hold the user and are not connected to it by anything the database knows.
- Pagila is the one that surprised us (next section).

Pagila: 49 partitions that nobody wired up

Pagila's payment table is partitioned by month, 55 partitions from payment_p2022_01 to payment_p2026_07. In the schema file, a customer_id foreign key to customer is declared on the first six partitions only. The partitioned parent payment and the other 49 partitions have a customer_id column and no constraint.

What that means on delete: Postgres checks a foreign key against the table that declares it. A customer with payments in July 2024 can be deleted without an error from the partitions that have no constraint, and the payment rows stay, pointing at a customer that no longer exists. The leftover-count query the tool writes finds them: payment_p2024_07.customer_id reads above 0 for any customer who paid in those months. (This is read from the file; we didn't load Pagila to run it.)

The tool lists all 50 as 'no foreign key, looks like a user', one line each, which is noisy but is exactly what you want to see: it's one logical table and one missing constraint, repeated. The fix isn't in the delete script. It is ALTER TABLE payment ADD FOREIGN KEY (customer_id) REFERENCES customer on the partitioned table (foreign keys on partitioned tables exist since PostgreSQL 11), and a delete script that goes through the parent payment.

A worked example you can reproduce

The built-in sample schema is a small shop: users, sessions (cascade), orders, order_items, reviews, audit_log and a newsletter table with an email. Pick PostgreSQL and this is what comes out:

text
Foreign keys to users (3)
  sessions.user_id     -> users.id    CASCADE     child column indexed: no   depth 1
  orders.user_id       -> users.id    NO ACTION   child column indexed: yes  depth 1
  order_items.order_id -> orders.id   NO ACTION   child column indexed: no   depth 2 (via orders)

No foreign key, but looks like it holds a user (5)
  sessions.ip_address   personal data
  reviews.author_id     user reference
  audit_log.actor_id    user reference
  audit_log.ip_address  personal data
  newsletter.email      personal data
erasureMap on its sample schema: three foreign keys with ON DELETE rules and index status, five columns found by name, the delete script and the leftover counts

And the script:

sql
-- psql: \set uid 42
BEGIN;

-- personal data with no foreign key: e-mail and phone are matched on the user's own value, so run before the user row goes
-- sessions.ip_address: review by hand (an IP address is not stored on the user row)
-- audit_log.ip_address: review by hand (an IP address is not stored on the user row)
UPDATE newsletter SET email = 'erased' WHERE email = (SELECT email FROM users WHERE id = :uid);

-- columns that name a user but have no foreign key: delete the row, or keep it and null the column
UPDATE reviews SET author_id = NULL WHERE author_id = :uid;
UPDATE audit_log SET actor_id = NULL WHERE actor_id = :uid;

-- foreign keys that would block the delete (NO ACTION / RESTRICT), children first
DELETE FROM order_items WHERE order_id IN (SELECT id FROM orders WHERE user_id = :uid);
DELETE FROM orders WHERE user_id = :uid;

-- handled by the database when the user row is deleted: sessions.user_id (CASCADE)

DELETE FROM users WHERE id = :uid;
COMMIT;

Some choices in it are worth knowing, because you will want to change them:

- Children first. order_items goes before orders, because both are NO ACTION. The sessions cascade is left to the database and listed in a comment.
- The email is matched on the user's own value. newsletter.email is cleared only where it equals this user's address, and that statement runs before the users row is deleted, because afterwards there's nothing left to look the address up from.
- Names are never matched. An earlier version of the script also updated first_name = (the user's first name); on Sakila that would also have blanked every staff member and actor who shares the customer's first name. Only identifying values (email, phone) are matched automatically. A name, or an IP address that isn't on the user row, becomes a comment: review by hand.
- NOT NULL decides delete or null. reviews.author_id allows NULL, so the review stays and loses its author. A NOT NULL column gets a DELETE or a placeholder user: that's your call, not the tool's, and it's why those lines are separate.

The leftover counts

sql
SELECT 'sessions.user_id' AS holder, COUNT(*) AS rows_left FROM sessions WHERE user_id = :uid
UNION ALL
SELECT 'orders.user_id', COUNT(*) FROM orders WHERE user_id = :uid
UNION ALL
SELECT 'order_items.order_id (orphans)', COUNT(*) FROM order_items WHERE order_id NOT IN (SELECT id FROM orders)
UNION ALL
SELECT 'reviews.author_id (no FK)', COUNT(*) FROM reviews WHERE author_id = :uid
UNION ALL
SELECT 'audit_log.actor_id (no FK)', COUNT(*) FROM audit_log WHERE actor_id = :uid;

Run it before (it tells you how many rows you are about to remove) and after (every line must be 0). Tables more than one step from the user are checked as orphans of their parent, since the parent rows are gone by then.

What a name match cannot do

- It misses what it isn't told. The first version of the tool found four columns in WordPress: wp_comments.user_id, wp_usermeta.user_id, and two in wp_registration_log. post_author and link_owner were missed until we added *_author and *_owner to the name list, which took it to ten. Your schema has its own names (buyer, placed_by, uid): check the list the tool shows against your table names, and add what's missing.
- It guesses wrong too. wp_comments.comment_author is a display name as text, not an id. Sakila's actor is a film actor, not a user. Treat the amber list as a checklist to read, not a verdict.
- It can't see inside values. An email in a JSON column, a user id inside an events.payload, a user in your analytics warehouse or in backups: none of that has a column name. Search those separately.
- MySQL indexes silently. InnoDB creates an index for every foreign key itself, so 'not indexed' in a hand-written MySQL file (Sakila's payment.rental_id) disappears on a real server. In a dump from mysqldump the KEY lines are there. PostgreSQL does not index the referencing column, and that's where the flag matters: an unindexed child column makes every delete of a parent row scan the child table.

The same job on a live database

If you can reach the database, the catalog tells you the same things, including columns whose foreign key was added later. Erasing a user for real has the catalog query for PostgreSQL, MySQL and SQLite, measured on 5.1 million rows, plus how to keep orders you owe the tax office while removing the person. Once you know which tables are involved, Everything you hold on one user covers the same list in the other direction: exporting it. And for the slow delete you may hit on the first run, see Foreign keys without an index.

Paste your schema into erasureMap and get the foreign keys with their ON DELETE rules, the columns with no constraint, the delete script and the counts to check afterwards. Free, in your browser.

Open erasureMap

Run the script and the counts against your own database in one place, on PostgreSQL, MySQL, SQLite and more. Pilotbase is free for Windows, macOS and Linux, or Docker on a server.

Get Pilotbase

Sources: Sakila (jOOQ copy of the MySQL sample schema), Pagila (pagila-schema.sql), Chinook, WordPress wp-admin/includes/schema.php, all read on 2026-11-15; PostgreSQL docs on foreign keys and ON DELETE (the referencing column is not indexed automatically); MySQL 8.4 docs on FOREIGN KEY constraints (an index is created if none exists); SQLite foreign key support (off unless PRAGMA foreign_keys = ON); GDPR Article 17.