We wrote previously about the account deletion page the Play Store asks for, and what auditing our retention claim turned up.

This is the sequel nobody wants: the page was right, the button was there, and the endpoint behind it did not work.

One button, two failures: an active user gets an error, a new user leaves their data behind

The two failure modes, and why only one of them was visible

DELETE /api/auth/account ran a bare DELETE FROM users.

That single statement produced two entirely different outcomes depending on who pressed the button.

If you had ever played a game, the delete failed. A foreign key on profiles.user_id referencing users(id), with no ON DELETE behaviour declared, meant Postgres refused the row deletion and raised error 23503. The API returned a 500. The user saw an error, and their account remained.

If you had never played, the delete succeeded — and left every one of your other rows behind. Value records, social records, stats. The users row was gone, so nothing pointed at them any more, but the data was still sitting in the database.

The second one is worse and it is the one nobody reports, because from the user’s side it looks like success. They tapped delete, the app said done, and their personal data stayed.

What the test asserts: not that it returns 200, but that every table is covered

Why “we have a delete endpoint” is not the claim you think it is

Both store operators require a working deletion path, and Australian Privacy Principle 11.2 requires destruction or de-identification once information is no longer needed. Every one of those obligations is about the outcome, not the endpoint.

Ours existed. It was wired to a real button on a real page with correct copy. It returned 200 for a subset of users. Every check anyone had run said the feature was present.

It was present. It did not do the thing.

What a real erasure path has to handle

The fix was not “add ON DELETE CASCADE” — that hides the ordering problem rather than solving it, and it makes the blast radius invisible at review time. What it needed was an explicit, ordered, transactional erasure.

One transaction. Deleting a user across many tables in many statements means a failure halfway through leaves a half-deleted person. Either it all goes or none of it does.

Foreign-key-safe ordering. Children before parents, with users last. Get the order wrong and you are back to 23503.

Children first, users last, all inside one transaction

Column names verified against each service’s schema, not assumed. This is the part worth stealing. We did not guess that every table keys on user_id, because they do not:

  • game_history keys on winner_id and loser_id — no user_id column at all
  • clubs keys on owner_id
  • referrals involves both parties, so a user appears in two different columns

Not every table says user_id: game_history keys on winner_id and loser_id, clubs on owner_id, referrals on both parties

An erasure routine written against an assumed user_id column would have run clean, reported success, and left every game this person had ever played sitting in the database under a column it never looked at. That is the same failure as before wearing a different hat.

And an explicit exclusion, documented. app_events is anonymous — it carries no user identifier — so it is deliberately not touched. The reason is written down, because an undocumented exclusion is indistinguishable from an oversight six months later.

The test that makes it stay fixed

The routine came out into its own module with a contract test, and the test is the interesting half.

It does not assert “deletion returns 200”. It asserts the shape of the erasure: every table that holds user-identifying data is covered, and users is deleted last. Drop a table from the routine, or reorder anything past users, and the test goes red.

That matters because this class of bug is introduced by growth, not by carelessness. The endpoint was correct when it was written. Then a service added a table, and nobody remembered that a new table with a user column creates an obligation in a file three directories away. A coverage test turns that from something you have to remember into something CI tells you.

What to check in your own app this week

Four things, and the first two need no code.

1. Delete a real test account that has actually used the app. Not a fresh one — one with history, purchases, social connections. The two failure modes above split exactly on that line, and a fresh test account passes the broken version.

2. Then look in the database. The button reporting success is not the check. Query for the user’s ID across every table you have and confirm zero rows.

3. List the tables that hold user data, and diff that against what your erasure routine touches. If the list lives only in someone’s head, this is the gap.

4. Check what your tables actually key on. Any table using owner_id, winner_id, created_by, sender_id or similar is invisible to a routine that only looks for user_id.

Check your own app: delete a used account, look in the database, diff tables against the routine, check what they key on

The general shape

The failure here is not a database bug. It is that the feature was verified by its own report of success — the endpoint returned 200, so deletion worked.

Present is not the same as working: the button existed, the endpoint existed, the data remained

The version that survives contact with a real user is the one where you go and look at what is left behind. That is more work than reading a status code, and it is the only check that would have caught either failure mode.


Building something that has to satisfy store review and Australian privacy obligations? Talk to us — we do this work, and we have made this exact mistake so you can skip it.

Database hosting, backups and the infrastructure side of data retention sit with Cloud Geeks.

Ash Ganda writes on verification that cannot fail, which is the family this bug belongs to.

Part of the Ganda Tech Services family, Awesome Apps builds iOS and Android applications for Australian businesses.


Frequently asked questions

Do Apple and Google require in-app account deletion? Both require an accessible way for a user to request account and data deletion, and Google additionally requires a publicly reachable web page describing it. The requirement is about the outcome — data actually being removed — not merely about the presence of a button or an endpoint.

Should I use ON DELETE CASCADE for account deletion? It is tempting and it hides the problem. Cascade makes the blast radius invisible at review time, so nobody can tell from reading the code which tables a deletion touches. An explicit, ordered, transactional routine with a coverage test is auditable; a cascade is not.

Why did our deletion work in testing but fail in production? Most likely your test account had no history. A bare user delete succeeds for a user with no dependent rows and fails with a foreign key violation for one who has them, so a fresh test account passes the broken version every time.

What is Postgres error 23503? A foreign key violation — you tried to delete a row that another table still references. In a deletion path it usually means child rows are being deleted after the parent, or not at all.

How do we stop this regressing when we add features? Write a contract test that asserts coverage rather than outcome: every table holding user-identifying data appears in the erasure routine, and the users table is deleted last. Adding a table without updating the routine then fails CI instead of shipping.

Ready to build your app?

Talk to a Sydney app developer — free.

30 minutes. We'll tell you what your app needs, how long it takes, and what it costs. Real answers, no sales pitch.

Book Free App Strategy Call →

Free · 30 minutes · No obligation