The short answer
In FHIR, DELETE [base]/Patient/123 removes the resource from normal reads and search: a read then returns 410 Gone, and searches no longer find it.1 The spec treats a delete as a special kind of history entry with no content, so on a server that keeps versions, the earlier versions can still be read through history.1 That’s a soft delete.
Expunge is the hard delete. On HAPI FHIR, the $expunge operation and DELETE ... ?_expunge=true physically remove data, and HAPI’s own source calls this unrecoverable without backups. Both are off by default.2 An AI agent may sometimes need DELETE. It should almost never be able to expunge.
Deedglass
The server said “saved”. Deedglass looked.
Deedglass reads it back
What the FHIR spec says a delete is
The R4 RESTful API spec defines the delete interaction in a few rules worth knowing exactly:
| Situation | What the spec says |
|---|---|
| Successful delete | 200 with a body, 204 without, or 202 if the server won’t commit to the outcome1 |
| Read after delete | 410 Gone, or 404 if the server doesn’t track deletions1 |
| Search after delete | The resource no longer appears1 |
| Delete of a missing or already-deleted resource | No effect, still 200 or 2041 |
| Server won’t delete this type | 4051 |
| Server won’t delete this one (say, it’s referenced) | 4091 |
| Conditional delete matching several resources | Delete them all, or refuse with 412; the CapabilityStatement says which1 |
PUT to the deleted id |
Re-creates the resource1 |
Two of these trip people up.
First, a 200 or 204 doesn’t tell you a resource existed. Deleting something that was never there returns the same success. If an agent reports “I deleted the duplicate Patient”, the status code alone doesn’t confirm there was one. A read before the delete does.
Second, the spec leaves retention to the server. It says servers are free to completely delete a resource and its history when policy or business rules require it.1 So “soft delete” is the common behaviour, not a promise. Check your server’s documentation. Our post on FHIR versioning and history covers how version ids and _history work on the servers that keep them.
Expunge: the hard delete
Expunge isn’t part of the FHIR specification. It’s an operation HAPI FHIR (and products built on it, such as Smile CDR) add for when data must actually go: a record created by mistake with the wrong patient’s details, test data in production, or a legal request to erase.
Smile CDR’s documentation describes how it works on that stack:
- A standard delete is logical; the old content stays readable through version-specific reads and history.4
$expungeruns at instance, type or system level.4- Its parameters choose what goes:
expungeDeletedResources(resources already deleted),expungePreviousVersions(old versions of live resources) andexpungeEverything, withlimitcapping each batch (default 1,000). Each needs its own permission in Smile CDR.4 - The current version of a resource can only be expunged once the resource has been deleted.4
HAPI’s source code is blunt about the risk. Its $expunge setting is false by default, and the comment says the operation “is potentially dangerous since it allows a client to physically delete data in a way that can not be recovered (without resorting to backups)”, recommending appropriate security so non-administrators can’t use it.2
There’s a faster path too. DELETE Patient?_id=123&_expunge=true deletes and expunges in one request. HAPI’s setting for it is also off by default, and it skips most of the usual delete checks; the only check left is that no other resource references what’s being removed.2 Smile CDR runs it as a background batch job at the type level.4 An asynchronous job means the HTTP response comes back before the work is done, so “the request succeeded” and “the data is gone” are two separate facts.
Cascades and references
By default HAPI enforces referential integrity on delete: you can’t delete a Patient that an Encounter still points to.2 That’s a useful brake. An agent that tries to delete a patient with history gets a refusal rather than a hole in the record.
HAPI can relax it. With the CascadingDeleteInterceptor registered, a request with _cascade=delete (or the header X-Cascade: delete) deletes the target and the resources that reference it.3 Combine it with _expunge=true and HAPI submits an asynchronous job that expunges the target and every resource referencing it, transitively, up to the configured number of cascade rounds.3 If the rounds run out first, only the resources collected so far are expunged, and deeper ones stay.3
Read that once more from an agent’s point of view. One request, DELETE Patient?_id=P-1&_expunge=true&_cascade=delete, can permanently remove a patient and everything that points to them, in the background, with a partial result if the graph is deep. It’s the FHIR equivalent of ON DELETE CASCADE plus VACUUM FULL. Our post on cascades that deleted more rows shows how quickly that grows in SQL.
Delete and expunge side by side
| FHIR DELETE | HAPI $expunge / DELETE _expunge |
|
|---|---|---|
| Part of the FHIR spec | Yes1 | No, a HAPI operation |
| Data still readable afterwards | Usually, through history14 | No2 |
| Can be undone | Yes, re-create with PUT or restore from history1 |
Only from backups2 |
| On by default in HAPI | Delete is | No2 |
| Respects references | 409 if the server enforces it1 | _expunge checks references; cascade overrides23 |
| Runs synchronously | Yes | _expunge and cascades run as batch jobs34 |
What to let an agent do
None of this is clinical advice; it’s about who can change a record system. A sensible split for AI tools writing to FHIR:
- Agents create and update; they don’t delete by default. Most agent work (drafting notes, filling forms, reconciling data) never needs DELETE. Our post on which EHR writes need approval sorts operations by risk.
- If an agent must delete, require approval and read first. Read the resource, show a person what will go, then delete. Because a delete of a missing resource also returns success1, the read is what confirms the target existed.
- Never give an agent expunge. Keep
$expungeand_expungedisabled, or restricted to administrators, as HAPI’s own comments advise.2 Erasure should be a human decision with a ticket behind it. - Block cascades from automated clients. If you register
CascadingDeleteInterceptor, refuse_cascadeandX-Cascadefrom agent credentials at your gateway.3 - Check, don’t trust, the response. After any delete, read the resource and expect
410. After an expunge job, check history is empty, and keep a backup you’ve tested restoring. - Log who asked. Record which agent, on whose behalf, deleted what. A soft delete leaves the content in history; your log should say who caused it.
Questions people ask
Does a FHIR DELETE remove data from the database?
Usually not. The spec treats it as a history entry with no content, and earlier versions can remain readable, though servers may fully delete if policy requires.1
How do I permanently delete a FHIR resource on HAPI?
Enable expunge on the server, delete the resource, then run $expunge with expungeDeletedResources, or use DELETE ... ?_expunge=true if delete-expunge is enabled. Both are off by default.24
Can a deleted FHIR resource be restored?
After a plain delete, yes: a PUT to the same id re-creates it, and history keeps the old content on servers that version.1 After expunge, only from backups.2
For the full picture of agents writing to patient records, see AI agents writing to patient records. To try approvals and read-backs on a real database in your browser, open the playground; to grade what an agent can reach on your own machine, run npx deedglass scan (the grade, explained).
Written with AI assistance and checked against our claims register.