The short answer
In FHIR R4, a transaction Bundle is all-or-nothing: “all interactions or operations either succeed or fail together”.1 Its entries can refer to each other through temporary ids, and the server rewrites those references to the real ids it assigns. A batch Bundle is a set of independent requests: entries must not depend on each other, and the server answers 200 if it processed the batch, even when some entries failed.1
So use a transaction when the entries only make sense together, such as a Patient and the Encounter that points at it. Use a batch for unrelated requests you want in one round trip. Either way, read every entry in the response, and check that your server really runs transactions atomically: some servers need a setting for that.
Deedglass
The server said “saved”. Deedglass looked.
Deedglass reads it back
Side by side
| Batch | Transaction | |
|---|---|---|
Bundle type |
batch |
transaction |
| Atomic? | No | Yes, all or nothing1 |
| Entries may depend on each other? | No; references to resources created in the same batch are non-conformant1 | Yes, through fullUrl |
| HTTP status when some entries fail | 2001 | 400 or 500 type, nothing applied1 |
| Response on success | batch-response Bundle |
transaction-response Bundle |
| Response on failure | batch-response with failing entries |
One OperationOutcome1 |
| Same resource twice? | Not allowed by the independence rule | Not allowed: once per identity1 |
The 200 for a batch is the row that causes trouble. A client that checks only the HTTP status will believe every entry succeeded.
How a transaction is processed
The spec says the outcome of a transaction SHALL NOT depend on the order of its entries, and fixes the order the server works in:1
DELETEinteractionsPOSTinteractionsPUTorPATCHinteractionsGETorHEADinteractions- Resolve conditional references
Two consequences. First, a read inside a transaction sees the results of the writes in it, because reads come after writes. Second, you can’t delete a resource and recreate it in the same transaction, or update it twice: a resource can appear only once by identity, and overlapping identities fail the transaction.1
Linking entries with urn:uuid
The reason to use a transaction is usually that one new resource must point at another new resource. You don’t know the server’s ids yet, so you use temporary ones:
Code Data in JSON, the format software uses to pass records and settings.
{
"resourceType": "Bundle",
"type": "transaction",
"entry": [
{
"fullUrl": "urn:uuid:7c1e4f9a-2b6d-4d1e-9f0a-1a2b3c4d5e6f",
"resource": { "resourceType": "Patient", "name": [{ "family": "Example" }] },
"request": { "method": "POST", "url": "Patient" }
},
{
"resource": {
"resourceType": "Encounter",
"status": "finished",
"class": { "code": "AMB" },
"subject": { "reference": "urn:uuid:7c1e4f9a-2b6d-4d1e-9f0a-1a2b3c4d5e6f" }
},
"request": { "method": "POST", "url": "Encounter" }
}
]
}
For a POST, the server treats the fullUrl as the resource’s id on the client side, ignores it, assigns a new id, and SHALL replace all matching links in the Bundle.1 The stored Encounter’s subject then points at Patient/<new id>.
Send the same thing as a batch and the Encounter’s reference to urn:uuid:… is non-conformant, because batch entries may not refer to resources created in the same batch.1 What a server does with it varies; the spec doesn’t promise a helpful error.
Conditional references
Inside a transaction, a reference can be a search instead of an id, such as Patient?identifier=http://hospital.example/mrn|12345. The server runs the search. If exactly one resource matches, it replaces the search with a reference to that resource. If none or several match, the transaction fails.1
This is the safe way to attach new data to an existing patient without looking up the id first, and the failure on several matches is a feature: it stops a write from being attached to the wrong person when identifiers aren’t unique. Conditional references aren’t available in batches.
A related tool, conditional create with If-None-Exist, behaves differently: one match returns 200 and creates nothing, and more than one returns 412.1 Inside a transaction, plan for both outcomes.
Choosing between them
A simple rule covers most cases. If any entry refers to another new entry, or if a partial result would leave the record in a state nobody intended, use a transaction. A new patient with their first encounter, or a medication request with the dispense that depends on it, belong together.
If the entries are unrelated and a partial result is fine, a batch saves round trips: loading a set of independent observations, or reading several resources at once. Expect some entries to fail and plan to retry only those.
When an AI agent builds the Bundle, prefer transactions for anything it writes. A model is likely to assume that “one request” means “one outcome”, which is only true for a transaction.
Reading the response
On success, the response Bundle has one entry per request entry, in the same order.1 Each entry’s response carries a status, and for creates and updates a location with the new id and version.
A checklist for clients, and for agents’ FHIR tools in particular:
- For batches, check every entry’s
response.status. Anything other than 2xx failed.1 - For transactions, treat any non-200 as “nothing happened” and read the OperationOutcome.
- Record the
locationof each created resource, including the version. You need it to read the write back. - Don’t count requests as writes. A conditional create can answer 200 with nothing new stored.1
- Send
If-Matchon updates inside the Bundle where you know the version, so a concurrent change makes the entry fail rather than overwrite.1
When a transaction is quietly a batch
The spec describes what a transaction must do. Not every server does it by default. Medplum, for example, documents that it processes a transaction Bundle as a batch unless the project turns on its transaction-bundles feature. In our benchmark on Medplum 5.2.6 without that feature, a transaction with one invalid entry was answered 200, and two of its three Observations were stored.2 To the client, it looked like a successful transaction; on the server, it was a partly applied batch.
That is one of 17 scenarios in our FHIR write benchmark, where a 200 or 201 didn’t mean the server stored what the client meant. The lesson generalises: check your server’s settings, then test with a deliberately invalid entry and confirm nothing was stored.
For the versioning that makes read-backs possible, see FHIR versioning and history; for the idea of checking a write after it lands, the read-back check. Nothing here is clinical; it’s about whether the server stored what was sent. To try checks like this on a real database in your browser, open the playground; to grade what an agent can reach on your machine, run npx deedglass scan (the grade, explained).
Written with AI assistance and checked against our claims register.