FAQ
Why not just store FHIR as JSONB?#
Because JSON storage makes writing easy and querying painful, and a clinical system does far more querying than writing.
With normalized storage you get:
- Integrity the database enforces — enum columns from FHIR value sets,
CHECKconstraints on choice elements, typed dates and decimals. - SQL that reads like the domain —
SELECT family FROM r5.patient_namerather than->>'…'path spelunking, and a planner that sees real column statistics rather than guessing at a document. - Search that is just SQL — parameters compile to indexed predicates on ordinary columns.
The trade is real and it is deliberate: writes are more work, and there are thousands of tables. For live queryable clinical data the trade is right.
JSON does survive in exactly three places, each for a stated reason: history
rows, contained resources, and elements typed Resource. See
the storage model.
Seven thousand tables? Really?#
7,355 for R5. Every repeating element of every resource type gets one.
That is fine for a database and impossible for a human, which is why everything is generated — the DDL, the relational map, the search predicates, and the path-to-identifier index. Nobody writes or maintains a table definition by hand, and nobody is expected to know the names; you look them up in the generated index or read them off the element path.
Installing them takes about 9.5 seconds on PostgreSQL 18.
Is this a FHIR server?#
The database ports are not; the repository has one.
Each port is a library. Every port workspace contains three crates — -map,
-gen, -store — with no server crate and no CLI crate (C0.17, C0.18).
Sections 7 (REST API) and 8 (CLI) of the database specification are retired for
that reason (C0.15): they are out of scope for the ports.
The REST server is fhir-loco — Loco.rs, Axum, Tokio, Hyper —
a separate crate that mounts a FHIR API over a store. It serves metadata,
search, create, read, update, delete, _history and vread, and requires a
PASETO v4.public token on every request.
That split is deliberate: a program that wants FHIR storage should not also acquire a web framework.
This answer used to be a flat "No." It was written when nothing here served
HTTP, and it stayed that way after fhir-loco arrived — so a reader asking the
title question about the repository got the wrong answer. Corrected under
audit F-63.
Historical note: every per-port README documented fhir-<engine> serve and
cargo install --path crates/fhir-<engine> until 2026-07-31. Neither ever
worked — that crate has never existed in any port (F-01, fixed). The books
described the same fiction until 2026-08-03 (F-56, fixed); they now
attribute every endpoint to fhir-loco.
Can I use it in production?#
fhir-postgresql: it is the reference port, its full test suite runs against
live PostgreSQL 18 in CI, and its measured results are its own. It has one open
high-severity defect (F-07, chain portability) that does not affect
correctness within the port.
fhir-sqlite, fhir-mysql, fhir-mariadb: working stores, and their
concurrency, redaction, and round-trip suites now exist and run green against
live engines (an earlier revision of this answer predated them — writing
those suites found five real defects, F-20–F-24). The remaining ?
cells are narrower — see the
conformance matrix.
fhir-mssql: a working store, live-verified (F-65) — weigh the TLS
advisory risk (F-67) first. fhir-oracle: a working store (F-68),
but R4.5 snapshot reads are a confirmed open gap and it has no concurrency
or redaction tests — not yet, for patient data.
In every case you also need the perimeter — authentication, authorization, consent, TLS. See the trust boundary.
Why is my search not matching?#
Nine times in ten, fold both sides by hand:
use fhir_sqlite_map::fold::fold;
println!("{} vs {}", fold("Ærø"), fold("aero"));
String search compares folded values (P6.6). If those two do not agree, the
stored _norm value may predate a fold change — which is a migration, not a
code change (L12), and needs a backfill (L13, O10.4a). Four of the six
ports have no upgrade path, so for them it is a reload (F-15).
If you are writing SQL by hand, remember to compare against family_norm rather
than family, and to use a range rather than LIKE for prefixes (P6.6a).
Why is birthDate a text column?#
Because FHIR dates can be partial. "1974" and "1974-12" are valid
date values, and neither survives a native DATE column without inventing
precision the source did not have.
So the pattern is: store the lexical form, derive a typed _sort column at
write time for ordering and range search (M3.6). Filter on birth_date_sort;
display birth_date.
Decimals work the same way and for the same reason — 1.50 is not 1.5
clinically, and no fixed-scale or floating-point column can hold the difference
(M3.6a).
What is ords?#
The position path: the 1-based index at each repeating ancestor crossing.
{1,2} is "the second given name of the first name".
An array rather than one column per level, because that is what lets recursive
elements — Questionnaire.item.item.item… — share one table at any depth.
The database never orders, compares, or subscripts it, which is why engines without an array type can store the literal as text. Details: storage model.
Why are there six copies of everything?#
There were, and that was the problem this revision fixed.
The pure-Rust core — shredding, reconstruction, folding, canonical JSON, the
whole generator — is byte-identical across all six ports (X15.1), and that is
by design: it operates on Rust types and never emits SQL, so there is nothing in
it for a dialect to change.
The specification was also six copies, and that was not by design. Sections
1–13 were duplicated per port, identical apart from the product name, and they
had begun to drift. They are now one copy at /spec, with each port stating
only its departures (F-13, fixed).
Can I move data between engines?#
Yes, by export and load. The logical content of a store is engine-independent
(X15.10): the same resource shredded by two ports produces the same logical
rows under the same identifiers, because identifier budgets are set to the
tightest target precisely so names are comparable (X15.3).
What does not carry across is the hash chain. Verify it at the source before
exporting; the destination starts a new chain, reported as beginning where it
begins and never backfilled (M3.16e).
Does it validate resources?#
Structurally, always — element existence, cardinality, primitive lexical rules,
choice exclusivity, and required bindings, because that is inherent to shredding
against the map (V9.1). Unknown elements are rejected naming the path
(R4.3), never silently dropped.
Terminology validation is out of scope (V9.4). A required binding is
checked against the literal set of codes the generator extracted; no code system
is expanded, no subsumption computed, no $validate-code performed. Put a
terminology service in front if you need it.
Profile and implementation-guide validation beyond the base specification is also out of scope.
What FHIR versions?#
R5 (5.0.0, the default), R4 (4.0.1), and R3 (3.0.2) — all resource types of
each, no exceptions (S1.1, S1.3). Versions are independent and never share a
table; a database may host any subset.
Why two hash algorithms?#
Family diversity, not digest length (M3.16a). MD5 and SHA-1 both fell to the
same line of cryptanalysis, and both are Merkle–Damgård. SHA-256 is
Merkle–Damgård; SHA3-256 is a sponge. A clinical record may be retained for
decades — longer than anyone can promise a single construction will stand.
Both are FIPS-approved, and verification reports each separately so a reader can rely on whichever their regime recognises.
Does the hash chain stop tampering?#
Unkeyed, no — and the specification says so plainly rather than letting you
assume otherwise (M3.16b).
Unkeyed it detects careless or unaware modification and supports an external witness. It does not stop an informed attacker with write access, because the digests are unkeyed over a published pre-image: whoever can write the row can compute a correct digest for it.
The keyed HMAC tag is the actual fix, and only because the key lives where the database does not.
Neither catches a row that is gone — a truncated chain verifies perfectly. Only an off-box checkpoint closes that gap.
Who can I blame for the READMEs?#
They were inherited from the PostgreSQL reference by text substitution, in ports where the claims were never measured — including two with no store at all. Three were even titled "FHIR in PostgreSQL" while targeting SQLite, MySQL, and MariaDB. That was F-01, the most serious finding in the audit register, and all six were rewritten on 2026-07-31.
The same substitution produced an Oracle DDL emitter that emits MySQL types
(F-08, still open) and two dialect annexes titled "14. MySQL dialect" in
ports targeting neither (F-16, rewritten). The book/ directories are the
remaining substituted documentation.
How do I contribute?#
AGENTS.md, then the relevant topic guide.
The rule that catches everyone: the shared Rust core must be changed in all
six ports in one commit (W16.7). Check with ./scripts/check-shared-core.sh
before and after; CI runs it too (F-10 fixed).