fhir-rust

A Rust monorepo for FHIR: the data model, six relational database ports, and an HTTP surface over them.

Family What it is Where
Model FHIR R3/R4/R5 (and R2/R6 in spec) as serde-serializable Rust types, generated from the official specification packages fhir/
Persistence core the engine-agnostic half of storage: audit chain, attribution, result types fhir-store/
Databases those resources stored as real relational tables, six SQL engines fhir-postgresql/ + 5 siblings
HTTP surface a FHIR RESTful API server over one of those stores fhir-loco/

They stack in one direction: the model knows nothing about databases, the database ports carry no HTTP or CLI, and the server adds only status codes. spec/index.md is the root of every specification and says which one governs what.

The database ports#

Store FHIR resources in a SQL database as real relational tables — typed columns, child tables, foreign keys, check constraints — not JSON blobs. Get them back losslessly.

Six ports, one specification, one engine.

Port Database Status
fhir-postgresql PostgreSQL 18 Reference — full store, full test suite
fhir-sqlite SQLite 3 Store — native, embeddable, no server
fhir-mysql MySQL 8.4 Store
fhir-mariadb MariaDB 11.4 Store
fhir-mssql SQL Server Store — live-verified against azure-sql-edge, incl. upgrade (F-65)
fhir-oracle Oracle Database Store — live-verified against gvenzl/oracle-free (F-68); no upgrade, R4.5 open

Those are conformance levels, defined in spec/00-conformance.md. They are the honest version: what has been verified for that port, not what its code contains. The conformance matrix breaks them down requirement by requirement, and it is the document to read before choosing one.

Status: pre-release. Each port's README now describes that port at its own conformance level; until 2026-07-31 all six carried the PostgreSQL reference's measured results with the engine name substituted, including in two ports with no store at all (spec/audit.md F-01, fixed).

The per-port book/ directories were rewritten on 2026-08-03: each names its own engine and attributes every serve/endpoint to fhir-loco (F-56). The conformance matrix is the status document to trust, and spec/audit.md lists the five findings still open.

Also here: the model crate#

fhir/ is the FHIR data model itself — every resource, complex datatype, primitive newtype, and code system as idiomatic Rust, produced by a generator that reads the official specification JSON. It has no database dependency and no I/O, and the database ports do not depend on it: they shred serde_json::Value against a generated relational map rather than against Rust types. The two families share a domain and a discipline, not code.

Releases modelled in code R5 (5.0.0), R4 (4.0.1), R3 (3.0.2)
Releases modelled in its spec additionally R2 (1.0.2) and R6 (ballot, publish = false)
Shape one crate per release behind a cargo feature, ~135k generated lines each
Specification fhir/spec/index.md — 14 sections, ids R1.xR14.x

Its requirement ids share the R4 prefix with this specification's §4 and mean something entirely different; spec/index.md records the clash and how to cite around it.

Also here: the persistence core and the HTTP surface#

fhir-store/ is the engine-agnostic half of persistence — the tamper-evident audit chain, the attribution and disclosure records, and the value types every port's operations return. It links no driver and opens no socket. It exists because chain.rs alone was 618 lines byte-identical in all six ports, and unwatched by the shared-core gate.

fhir-loco/ is a FHIR RESTful API server — Loco, Axum, Tokio and Hyper — over fhir-sqlite. The ports deliberately ship no server so that a program wanting FHIR storage does not also acquire a web framework; this is where that surface lives, and its job is narrow: translate HTTP to store calls and get the status codes right, so that a resource which was deleted answers 410 Gone and one that never existed answers 404 Not Found.

It has no specification yet, which means none of those externally visible promises can be cited by number or shown to have regressed. That gap is recorded in spec/index.md and, since it is now known to be the service §10 and §12 describe, in spec/audit.md F-58.

Why relational#

JSON storage makes writing FHIR easy and querying it painful. Normalized storage inverts that trade, and for a production clinical system the trade is right:

  • Integrity the database enforces — enum columns backed by FHIR value sets, CHECK constraints on choice elements, typed dates and decimals, reference columns that can be joined.
  • SQL that reads like the domainSELECT family FROM r5.patient_name, no ->>'…' path spelunking, and the query planner sees real column statistics.
  • Search that is just SQL — FHIR search parameters compile to indexed predicates on ordinary columns.

The cost is thousands of generated tables per FHIR version — 7,355 for R5. That is fine for a database and impossible for a human, so everything is generated and every name is recorded in the relational map.

How it works#

FHIR spec packages          ──gen──▶  relational map + DDL  ──▶  assets/*.json.gz
(StructureDefinitions,                                            (committed)
 SearchParameters)
                                          │
  FHIR resource  ──shred──▶  rows  ───────┼───────  database
  FHIR resource  ◀─reconstruct──  rows  ──┘

A build-time generator reads each FHIR version's StructureDefinitions and SearchParameters and emits two artifacts per version: the DDL and a compact relational map. At runtime one generic engine walks any resource against the map to shred it into rows, and walks the map in reverse to reconstruct the identical resource. There is no per-resource handwritten code and no generated Rust — three versions × ~150 resource types × deep nesting would explode compile times for zero runtime benefit.

Round-trip fidelity is a tested invariant, decimal precision and partial dates included. Search parameters compile against the same map into SQL.

What differs between ports is two files. map/src/ddl.rs decides which SQL the generator emits; the store crate decides driver, transactions, and placeholder syntax. Everything else — shred, reconstruct, fold, canonical JSON, the whole generator — is byte-identical across all six.

Quick start#

The ports are libraries: no CLI, and no server crate in any port (C0.17, C0.18). The REST server is a separate crate — fhir-loco, on Loco.rs, Axum, Tokio and Hyper — which mounts a FHIR API over a store.

SQLite needs no server, so it is the shortest path to a working store:

[dependencies]
fhir-sqlite-map   = "0.4"
fhir-sqlite-store = "0.4"   # add `features = ["r3", "r4"]` for other versions
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
serde_json = "1"
use std::sync::Arc;
use fhir_sqlite_map::model::RelMap;
use fhir_sqlite_store::{Audit, sqlite::SqliteStore};

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    // The map is compiled in — feature `r5`, on by default. No file to read,
    // and no FHIR specification packages needed.
    let map = Arc::new(RelMap::bundled("r5")?);

    let store = SqliteStore::open("clinic.sqlite", map).await?;
    store.init("r5-baseline").await?;        // ~7,400 tables, one transaction

    let patient = serde_json::json!({
        "resourceType": "Patient",
        "id": "example",
        "name": [{ "family": "Ærø", "given": ["Anna"] }],
        "birthDate": "1974-12"               // a partial date, preserved verbatim
    });

    store.put(&patient, &Audit::cli()).await?;

    let back = store.get("Patient", "example").await?.unwrap();
    assert_eq!(back, patient);               // losslessly, including "1974-12"

    // Accent- and case-insensitive by construction: "aero" finds "Ærø".
    let hits = store.search("Patient", &[("name".into(), "aero".into())], 50, 0).await?;
    println!("{hits:?}");
    Ok(())
}

Then query it as what it is — a relational schema:

SELECT n.family, count(o.id) AS observations
  FROM patient p
  JOIN patient_name n ON n.rid = p.id AND n.ords = '{1}'
  LEFT JOIN observation o
    ON o.subject_ref_type = 'Patient' AND o.subject_ref_id = p.id
 GROUP BY n.family
 ORDER BY observations DESC;

Every child table addresses its rows with rid (the resource id) and ords (the 1-based index path through repeating elements), so arbitrarily nested — even recursive — structure stays joinable.

Full walkthroughs: doc/.

Which port#

If you want Use
Production today, with the full test suite behind it fhir-postgresql
Embedded, no server, a single file fhir-sqlite
An existing MySQL or MariaDB estate fhir-mysql, fhir-mariadb
SQL Server or Oracle not yet — see the matrix

doc/choosing-an-engine.md has the long version, including what each engine costs you in its type bindings.

What these libraries guarantee, and what they do not#

Because the data is protected health information, the boundary is stated rather than implied (§12):

Guaranteed here Your deployment must provide
Attribution on every write Authentication — unless you run fhir-loco, which verifies a PASETO v4.public token on every request and has no unauthenticated mode
A disclosure record on every read Authorization, scopes, compartments, consent
Tamper-evident history (SHA-256 + SHA3-256, optional HMAC) TLS termination
Append-only history enforced in the database Rate limiting per identity
Erasure that leaves a verifiable tombstone Terminology validation
No PHI in logs at default level Everything else a certified system needs

These are components, not certified systems. They cannot make a deployment compliant; they are built so as not to be the reason one cannot be. §13 maps each obligation to a numbered requirement and to the test that evidences it.

Documentation#

index.md every entry point in the repository, in one page
doc/index.md documentation hub — tutorials, examples, comparisons
spec/index.md the root of every specification — which one governs what
spec/databases/index.md the normative core, shared by all six ports
spec/databases/conformance-matrix.md what each port actually satisfies
spec/databases/audit.md every known divergence, with evidence
spec/publishing.md what blocks these crates from crates.io
fhir/spec/index.md the model crate's specification
AGENTS.md contributing — human or agent
fhir-<engine>/book/ that port's user guide
fhir-<engine>/plan.md that port's design decisions

Specification-driven#

Behaviour is decided in spec/ before it is written in Rust, and every requirement keeps a permanent number so a test, a commit, and an auditor's workpaper can all point at the same thing years later.

The database specification is one copy, at the root, in spec/databases/. Until this revision it was six copies, one per port, identical apart from the product name — and they had begun to drift exactly as you would expect. Each port now states only where it departs, in a numbered dialect annex, so a departure has to be written down to exist.

The model crate keeps its own specification in fhir/spec/, because it is a different artifact with a different release cadence and a different conformance question. Neither family's requirements bind the other; spec/index.md states that precedence rule and the one prefix collision between them.

License#

MIT OR Apache-2.0 OR BSD-3-Clause OR GPL-2.0-only OR GPL-3.0-only — declared identically by all 33 crates in the repository, across all four families. OR means you choose; no obligation from one option carries into another.

Full statement: LICENSE.md. Versions already on crates.io keep the terms they were published under, since a published version is immutable.

Edit this page on GitHub — the repository is the source of truth; this site renders it.