Deleting and Exporting a User's Health Data
Updated July 27, 2026
A user deletes their account on a Tuesday. On Friday, their sleep data is back on your dashboard. Nothing went wrong with the DELETE — it committed, the row is gone. What happened is that their provider OAuth grant was never revoked, a webhook fired against it, and your ingest path upserted on a provider user id it did not recognise and helpfully re-created the account. Meanwhile the daily-rollup table still holds every step total they ever had, because deleting raw samples does not touch a materialized aggregate until a refresh runs, and the CSV you generated for their export request last month is still sitting behind a signed URL on a CDN.
The design decision is this: model deletion as a tombstone plus a checkpointed, per-store purge job driven by a checked-in registry of stores — never as a synchronous cascade of DELETE statements, and never as a script someone runs. The constraint that forces it is that a health record does not live in one place. It lives in raw samples, in every rollup derived from them, in caches, in queues and dead-letter queues, in application logs that captured a provider payload, in your warehouse, in a search index, in whatever embeddings feed your coaching model, in backups you cannot surgically edit — and in an OAuth grant at the provider that will refill all of it tomorrow if you leave it live.
This page is the implementation. Health data retention and deletion owns the policy — how long you keep what, and what you promise in the privacy notice. GDPR for fitness apps owns the legal obligation, including the part that reaches downstream controllers you passed data to. Neither of those is restated here; what follows is the traversal.
What "delete the user" actually has to reach
The table below is a starting inventory, not an audit of your systems. The point of writing it down is that the gotcha column is where the bugs are — in every case the naive delete succeeds and reports success.
| Store | How it is deleted | Gotcha |
|---|---|---|
| Raw sample table (time-partitioned) | Chunked DELETE ... WHERE user_id = $1, committed per batch | Partitioning does not help you here. Postgres documents the fast path as dropping or detaching a partition, which "entirely avoid[s] the VACUUM overhead caused by a bulk DELETE" — that is a time-range drop. A per-user delete is a scattered delete across every partition, the one access pattern partitioning does nothing for. |
Daily/weekly rollups keyed on (user_id, metric, local_date, source) | Delete by user_id | For a full account delete this is easy. For a single-provider disconnect it is wrong: the rollup is a merge of several sources, so the correct operation is recompute without that source, not delete. |
| Continuous aggregates / materialized rollups | Explicit delete against the materialized view, then a forced refresh of the affected range | The aggregate is a second copy of the user's data in aggregated form. Deleting the raw rows leaves it in place until a refresh runs. Timescale documents refresh_continuous_aggregate() with force => TRUE to "Force refresh every bucket in the time range... even when the bucket has already been refreshed", with its own warning that this "can be very expensive". |
| Per-user read caches (summary blobs, home-screen totals) | Key-prefix invalidation plus a short TTL as the backstop | A cached "last 7 days" payload is a full copy of the exact numbers the user asked you to erase. |
| Generated export files | Delete the object, invalidate the CDN path, expire the signed URL | The export you produced to satisfy a data request is itself a health-data store, and it usually has no owner and no expiry. |
| In-flight queue messages | Drain, or filter on consume against the tombstone | This is the classic resurrection: a message enqueued before the tombstone lands is consumed after it. Filtering at consume time is the only version that actually holds. |
| Dead-letter queue | Purge by user_id | Routinely missed, and usually unpurgeable, because most DLQs store only the raw provider payload. If user_id, provider and the affected time range are not stored alongside the payload, you cannot find the user without parsing vendor JSON. That is a DLQ schema decision made long before the first deletion request. |
| Webhook dedupe / idempotency tables | Delete by user_id | Harmless-looking, but these tables key on provider-side identifiers, which are themselves personal data linking your system to an account at the provider. |
| Provider cursors, anchors and change tokens | Delete with the connection record | Delete the cursor but not the grant and the next sync starts from a null anchor and backfills the user's entire history from scratch. |
| Application logs and APM traces | Retention expiry. Redaction at write time is the only real control | The most commonly missed copy. A provider response body logged at ERROR contains actual heart-rate and sleep values, and log stores are usually append-only by design. You cannot delete your way out of this one after the fact; you can only stop doing it and wait out the window. |
| Analytics / product metrics / warehouse | Delete by user_id, then rebuild dependent models | Event properties are the leak: a workout_completed event with duration_min, avg_hr and calories in its properties is health data sitting in a product-analytics tool, often a third-party SaaS with its own retention. |
| Search index | Delete by document id | Index deletes are frequently asynchronous, so a purge that reports success can still serve a hit. Verify with a read, not with a return code. |
| Embeddings, feature stores, model inputs | Delete vectors and features by user_id; exclude from the next training run | An embedding of a user's workout notes or a feature vector of their recovery trend is derived personal data. Separately: if their data went into a prompt to a model vendor, that vendor's retention is now part of your deletion surface. |
| Backups, PITR windows, WAL archives | Not surgically. Documented retention window plus deletion-on-restore | See below. This is the one where honesty beats cleverness. |
| The upstream provider grant and webhook subscription | Revoke the token, delete the subscription — first | Leave it live and you resume collecting tomorrow. This is the single most-missed item on the list. |
| Records your app wrote into HealthKit or Health Connect | Delete your own records from the platform store | Your writes into the user's on-device store are your data in their store. On Android these are identifiable because dataOrigin.packageName is set automatically and deleteRecords takes the record type and id. On iOS, HealthKit assigns every object a UUID and Apple documents HKMetadataKeyExternalUUID for attaching your own id — which is exactly what makes your own writes findable later. |
Two rows on that list are the ones that separate a real implementation from a plausible one: the dead-letter queue, and the provider grant.
Revoke the grant before you touch a single row
Almost every implementation gets the ordering backwards. It purges the data, then revokes the connection last, or not at all, because revocation feels like cleanup. It is not cleanup — it is the first step, and everything else is downstream of it.
The health-specific reason is that your providers push. A wearable backend does not care that you deleted a row; it cares that there is a live grant and a registered callback. It is also widely reported that some providers deliver requested historical backfill asynchronously through the same webhook callback as live data, which means a backfill requested weeks ago can land in the middle of your purge — check your provider's documentation for whether that applies to you, and assume it does when designing the ordering. Every one of those deliveries hits an ingest path whose entire job is to create records for a user id it recognises.
The ordering that works:
- Write the tombstone and make it authoritative. Reads, exports, coaching, notifications and ingest all check it and fail closed. Not "the user row is missing" — a positive deleted marker, because a missing row is exactly what an upsert-on-unknown-id path treats as "create it".
- Revoke upstream. Revoke the OAuth grant at every connected provider, delete the webhook subscription, and record the result per provider. A revocation that returns an error is a purge task that has not completed, not a warning to ignore.
- Drain or filter the queues, including the DLQ.
- Purge each store, checkpointed, with a completion record per store.
- Attest. One row per store per deletion request, with a timestamp and a row count.
Between steps 1 and 2 there is still a race window, which is why step 1 has to fail closed rather than merely delete. A webhook that arrives for a tombstoned user should be acknowledged and dropped, and it should raise a counter — that counter is your resurrection detector, and it is the only instrument that will find the queue you forgot about.
There is a wrinkle on mobile worth knowing. Google documents that if a user deletes your Android app, all Health Connect permissions including the history permission are revoked, and reinstalling resets the 30-day default read window to the new grant date. Users reasonably believe uninstalling deleted their data. It revoked your access; it did nothing to your servers. Say so in the UI, because the mismatch between what the platform did and what you did is where trust goes.
Disconnecting one provider is harder than deleting the account
Full account deletion is the case everyone builds. The case that actually happens weekly is partial: the user unlinks Fitbit, keeps their account, and expects Fitbit-sourced data to disappear.
Deleting raw samples where provider = 'fitbit' is again the easy ten percent. The hard part is that your rollups are merged — a daily step total is a resolution across overlapping sources, not a sum of one. Remove one source and every affected (user, metric, local_date) cell has a different correct value, which means the operation is a recompute, not a delete. If you already maintain a dirty-day queue for late-arriving and retro-edited data (the shape argued for in time-series storage), partial deletion is free: mark every day the disconnected source touched as dirty and let the existing recompute path do the work. If you increment counters instead, there is no correct operation available to you at all, because you cannot subtract a source's contribution from a number you never decomposed.
Two consequences fall out of that:
- Deletion is a reason to never store an incremented counter as the user-facing number. A rollup that is a pure function of the raw rows for a
(user, day)can be repaired by recomputation. A counter can only be guessed at. - A partial delete changes historical numbers the user has already seen. Their step total for last March will drop when they unlink a source. That is correct, and it will still generate a support ticket unless the UI says why.
Streaks, personal records and "best ever" badges are computed over a history that just shrank. Recompute those in the same pass, or a user will keep a PR that was set by a device whose data you no longer hold.
Backups, honestly
You cannot surgically delete one user from a snapshot, and any design that claims to is either rewriting backups — destroying their integrity as a recovery point — or quietly lying.
The defensible answer is three things, all written down where a reviewer can read them: a bounded retention window stated for every backup class, with the commitment that erasure completes when the last backup containing the user ages out; a deletion tombstone list stored outside the backups, holding an internal user id and hashed provider account identifiers and nothing else; and deletion-on-restore as a mandatory runbook step that re-applies every tombstone recorded since the snapshot was taken.
The third is the one that fails, because restores are rare so the step is never exercised. Put it in the restore drill — a drill that does not verify that a previously deleted test user is still absent afterwards is not testing the thing that will actually go wrong.
The uncomfortable part is that preventing resurrection requires retaining an identifier of the deleted person indefinitely. Name that tension in your documentation rather than pretending it away; the narrow answer is a salted hash of the provider account id and nothing else, so the list is useful for suppression and useless for everything else.
Deletions that flow inward, and why you may never see them
There is a second deletion problem pointing the other way: the user deletes a sample in Apple Health or Health Connect and expects it gone from your app. Both platforms make this genuinely hard, in mirror-image ways.
Apple documents that HKDeletedObject carries only a uuid and metadata — no type, no dates, no value — so unless you stored a uuid mapping at ingest, a deletion notification is unactionable. Worse, Apple documents that "Deleted objects are temporary; the system may remove them from the HealthKit store at any time to free up space", and states that the way to guarantee you receive them is an HKObserverQuery registered for background delivery which then runs an anchored query. A client that is offline or not being woken for long enough can therefore permanently miss a deletion. There is no server-side reconciliation that recovers it by diffing, because the sample is no longer in HealthKit to diff against. The only mitigation is periodic full-window re-reads with set-difference reconciliation on top of your normal incremental sync.
Android has the same shape from the other side: Google documents that a DeletionChange "only contains the id of the deleted record, and not the record type, due to privacy", and instructs that if your app reads data from Health Connect you must store the id — which is a schema decision you make months before the first deletion arrives, and cannot retrofit. Change tokens expire after 30 days, and Google's own ranked recovery strategies for an expired token top out at "read and dedupe all data" and bottom out at "read last 30 days without deduping", which the docs themselves label least ideal because it "results in duplicate data displayed to users".
Practical position: treat inbound deletions as best-effort no matter how carefully you implement them, and schedule a periodic reconciliation window per user where a full re-read of the last N days is set-differenced against what you hold. Anything you hold that the platform no longer has, you delete. It is expensive, so run it on a slow cadence, but it is the only mechanism that converges.
Export is the same traversal in reverse
Every store in that table is a store you may have to read back. An export that walks only the raw sample table is the mirror of a delete that walks only the raw sample table — and in our judgement it is arguably incomplete, for a reason specific to health data: the derived number is the one the user acted on.
Nobody makes a decision based on a heart-rate sample at 07:42:13. They make decisions based on the daily rollup, the weekly trend, the sleep-stage breakdown, the readiness-style score your app computed and showed them. Those are personal data derived from their data, and they are the only part of the export that corresponds to something the user has actually seen. An export of bare (timestamp_utc, value) rows is technically defensible and practically useless.
What a good health export carries, beyond the values:
- Provenance per sample. Source app or device, plus the recording-method flag that Health Connect's
recordingMethodand HealthKit'sHKMetadataKeyWasUserEnteredcarry. Without it, a user cannot tell which numbers they typed in and which their watch measured, and neither can anyone importing the file. - Time as three columns, not one. The instant, the UTC offset in effect at that instant, and the civil
local_dateyou bucketed on. Google documents that all data written to Health Connect must specify zone offset information, because "specifying the zone offset enables apps to read the data to represent it in civil time". If you export only UTC instants, a user who travelled cannot reconstruct their own days, and the export will disagree with the app's own daily chart. - Derived metrics, with their version. Ship the rollups and computed scores, each tagged with the calculation version that produced them, so an export taken in March and one taken in July are comparable rather than mysteriously different (this is the same versioning discipline as metric versioning and recompute).
- A manifest. One newline-delimited JSON file per metric plus a manifest listing files, row counts, date ranges, schema version and the earliest date you actually hold, rather than one enormous JSON document nobody can open. The "earliest date held" field matters because a gap in a health export reads as "I did nothing that month" unless you say otherwise.
Export and delete should share the store registry and nothing else. They are not inverse code paths: export is a read that must be complete and comprehensible, delete is a write that must be verifiable. Sharing a traversal list keeps them honest; sharing an implementation makes both worse.
One rule that saves a support queue: generate the export from the same read model the app renders from. If the export path re-derives numbers independently, it will disagree with the user's screen, and the ticket you get is not "the export is wrong", it is "which one of these is my real data?".
What we'd actually build
A concrete shape, in the order we would build it:
A store registry, checked into the repo. One entry per store, each with an identifier, an owner, a purge handler, an export handler, and an explicit disposition of purge, expire (backups and logs, with a stated window), or retain (with a stated reason). The registry is the artefact — it is what you show someone who asks whether user X's data is gone.
create table deletion_request (
id uuid primary key,
user_id uuid not null,
requested_at timestamptz not null,
tombstoned_at timestamptz,
completed_at timestamptz
);
create table purge_task (
request_id uuid not null references deletion_request(id),
store text not null, -- must exist in the registry
disposition text not null, -- purge | expire | retain
state text not null, -- pending | running | done | failed
cursor jsonb, -- resumable: last partition, last page
rows_affected bigint,
completed_at timestamptz,
primary key (request_id, store)
);
A completeness test in CI. Enumerate every table with a user_id column, every topic, every index, every bucket prefix, and fail the build if any of them has no registry entry. This is the difference between a job and a script: a script is correct on the day it is written, and a registry with a completeness test stays correct when someone adds a table on a Thursday. If you build one thing from this page, build this.
A production canary, weekly. Create a synthetic user, run real data through the full pipeline — provider connection, webhook, backfill, rollups, cache warm, an analytics event, an export — then delete them, and afterwards sweep every registered store for their identifiers and alert on any hit. Deletion is unusual among backend jobs in that its failures are silent and its correctness is externally auditable, so it is exactly the case where an end-to-end canary earns its keep. A deletion path that has never been tested end to end in production is a deletion path that does not work; you simply have not been asked yet.
Monitoring, with three alarms. Purge tasks older than your stated completion window. Any write to any store keyed on a tombstoned user. And the rate of webhook deliveries arriving for tombstoned users per provider — a non-zero, non-decaying value there means a revocation is failing silently.
The honest limits
This design does not erase backups; it bounds them and re-applies deletions on restore. If your commitment is "gone immediately, everywhere", you cannot make it truthfully, and the compliance page is where that gets written down rather than here.
It does not fix inbound deletion loss. Apple documents that deleted objects are purgeable at any time, so some deletions are unrecoverable at the source; reconciliation converges but does not guarantee.
It does not cover third parties by itself. Every vendor you forwarded health data to — analytics, crash reporting, a model provider, a support tool — is a store on somebody else's infrastructure with its own retention. Keep them in the same registry with a contact and a documented deletion mechanism, and check the authoritative legal text on the obligation to inform downstream controllers rather than a summary of it.
When to buy instead. If your deletion surface is dominated by provider connections rather than by derived data, an aggregator is often the right answer and we would not talk you out of it: it collapses N grants, N webhook subscriptions and N revocation quirks into one connection with one documented delete call, which removes the most-missed row in the table above. See health data aggregator APIs and what a health data aggregator is. The trade is that you have added a store you cannot inspect: their purge is now part of your compliance story, and you need their attestation, their retention window, and a plan for the day you leave. We would build this in-house when the derived layer dominates the surface — heavy rollups, model features, an analytics warehouse — because none of that is the aggregator's to delete, or when you must prove completion to an enterprise buyer and need per-store attestations you control.
Deletion is the one pipeline whose success looks identical to never having run. Make it a job with a registry, a test, a canary and an alarm, and it will be boring. Leave it as a script, and you will find out it was broken from someone else.
Next, on the same traversal: identity and account linking for which identifiers a purge has to cover, and store health data securely for the controls around the stores you keep.
Frequently asked questions
- If I delete a user's rows but leave their wearable OAuth grant active, what happens?
- You start collecting them again. Wearable backends push: the provider does not know you deleted anything, it knows there is a live grant and a registered callback. The next webhook hits an ingest path whose job is to create records, and if that path upserts on a provider user id it does not recognise, it will recreate the account. Backfill makes it worse, because some providers deliver historical data asynchronously through the same callback as live data, so a request made weeks earlier can land in the middle of your purge. Revocation is therefore the first step of deletion, not cleanup afterwards: revoke the token, delete the webhook subscription, record the result per provider, and treat a failed revocation as an incomplete purge task rather than a warning.
- Can I surgically delete one user out of my database backups?
- Realistically no, and a design that claims to is either rewriting backups, which destroys their value as a recovery point, or quietly lying. The defensible answer has three parts. First, a documented and bounded retention window for every backup class, including snapshots, the point-in-time recovery window, WAL archives and warehouse copies, with the stated commitment that erasure completes when the last backup containing the user ages out. Second, a tombstone list stored outside the backups that outlives them, containing only the internal user id and hashed provider account identifiers. Third, deletion-on-restore as a mandatory first step of any restore runbook, re-applying every tombstone recorded since the snapshot was taken. That third step is the one that fails, because restores are rare, so it has to be exercised in the restore drill.
- A user unlinks one wearable but keeps their account. What has to change beyond deleting that provider's rows?
- Every merged number derived from that source. A daily total is a resolution across overlapping sources rather than a sum of one, so removing a source gives every affected user, metric and local-date cell a different correct value. The operation is a recompute, not a delete. If you already maintain a dirty-day queue for late-arriving and retro-edited data, partial deletion is nearly free: mark every day the disconnected source touched as dirty and let the existing recompute path run. If you store incremented counters as the user-facing number instead, there is no correct operation available at all, because you cannot subtract a contribution from a number you never decomposed. Streaks, personal records and best-ever badges must be recomputed in the same pass, or the user keeps a record set by data you no longer hold.
- Does a health data export have to include derived metrics like daily rollups and computed scores?
- In our judgement yes, and this is where most exports fall down. Nobody makes a decision from a heart-rate sample at 07:42:13; they act on the daily rollup, the weekly trend, the sleep-stage breakdown and whatever readiness-style score the app showed them. Those are personal data derived from the user's data and they are the only part that corresponds to something the user actually saw, so an export of bare timestamp and value pairs is technically defensible and practically useless. Ship the rollups tagged with the calculation version that produced them so exports taken months apart are comparable, carry provenance per sample including source device and the manual-entry flag, and carry time as three fields: the instant, the offset in effect at that instant, and the civil local date you bucketed on.
- How do I stop a deleted user reappearing from a queue or a dead-letter queue?
- Write a positive tombstone rather than relying on the absence of a row, and check it at consume time. A missing user row is exactly what an upsert-on-unknown-id ingest path treats as a signal to create the account, so the marker has to be an explicit deleted flag that reads, exports, coaching and ingest all fail closed against. Then drain or filter in-flight messages and purge the dead-letter queue, which is the one routinely missed. Most DLQs are unpurgeable by user because they store only the raw provider payload, so store user id, provider and the affected time range alongside the payload from the start. Finally, alarm on webhook deliveries and writes arriving for tombstoned users: that counter is your resurrection detector and the only instrument that finds the queue you forgot.
Keep reading
Independent comparison, last reviewed July 27, 2026. Pricing, rate limits, and feature availability change often — confirm current details in each provider’s official documentation before you commit. Product and company names are trademarks of their respective owners; AIFitnessAPI is not affiliated with, endorsed by, or sponsored by any product listed here.
← All architecture · by AIFitnessAPI