---
{
  "title": "SQL on FHIR: Interoperable Analytics",
  "description": "Wie SQL on FHIR Healthcare-Analysen interoperabel macht: ViewDefinition, die neuen Profile SQLQuery und SQLView, die run/export/materialize-API und eine dbt-ähnliche ELT-Pipeline für FHIR zu OMOP.",
  "date": "2026-06-11",
  "author": "Nikolai Ryzhikov",
  "reading-time": "22 min read",
  "tags": [
    "SQL on FHIR",
    "Analytics",
    "FHIR Standard"
  ],
  "tldr": "SQL on FHIR begann als Community-Spezifikation zum Flachklopfen von FHIR-Ressourcen. Heute ist es eine vollständige DSL zur Beschreibung von ELT und interoperabler Analytik: ViewDefinition für das Staging, SQLQuery und SQLView für mehrschichtige Transformationen sowie eine standardisierte run/export/materialize-API – jede Schicht trennt Authoring von Implementierung und Nutzung. Nächste Schritte: FHIR R6 Core, in dem ViewDefinition eine standardmäßige Ressource wird, und ein offener FHIR-zu-OMOP-Implementierungsleitfaden, der auf dieser DSL aufbaut.",
  "utm-campaign": "analytics",
  "utm-content": "interoperable-analytics"
}
---
Es gibt zwei ausgereifte Welten, die kaum miteinander kommunizieren.

Auf der einen Seite FHIR. Wir verfügen inzwischen über sehr viele FHIR-Daten – [die große Mehrheit der US-Krankenhäuser stellt FHIR-APIs bereit](https://healthit.gov/data/data-briefs/hospital-use-apis-enable-data-sharing-between-ehrs-and-apps/), und dieser Anteil wächst von Jahr zu Jahr, während FHIR-first-Systeme klinische Daten nativ in Ressourcen speichern. Auf der anderen Seite ein sehr reifes Ökosystem analytischer Werkzeuge: moderne Datenbanken, BI-Plattformen, Dataframes – der gesamte moderne Data Stack.

FHIR-Daten lassen sich bereits in diese Werkzeuge laden – moderne Datenbanken verarbeiten verschachtelte Daten gut: Postgres mit binärem JSON, BigQuery, Spark, DuckDB. Tatsächlich war die erste Version von SQL on FHIR genau das: verschachteltes FHIR direkt in der Datenbank abfragen. Das funktionierte – und standardisierte nichts. Jede Engine hat ihren eigenen Dialekt für verschachtelte Daten; eine für BigQuery geschriebene Abfrage hat nichts gemein mit derselben Abfrage in Postgres. Nichts zu standardisieren.

Darunter liegt das alte **Object-Relational-Impedance-Mismatch**: FHIR-Ressourcen sind verschachtelte Dokumente, und die analytische Welt – SQL, BI-Tools, Dataframes – will nach wie vor flache Tabellen. Die naive Lösung, für jedes FHIR-Element eine Tabelle zu erzeugen, funktioniert einfach nicht: Man erhält tausend Tabellen, die niemand überblickt. Also schreibt am Ende jeder anwendungsfallspezifisches ETL zum Flachklopfen von Ressourcen – wobei dieselbe Arbeit leicht variiert wiederholt wird, mit denselben subtilen Fehlern rund um Arrays, Choice-Typen und Referenzen.

[SQL on FHIR](https://build.fhir.org/ig/HL7/sql-on-fhir/) Version zwei ist unser Versuch, eine standardisierte Brücke zwischen diesen beiden Welten zu bauen – diesmal durch Standardisierung der *Flat Views* anstelle der verschachtelten Abfragen. Es begann als Community-Entwurf; heute ist es eine vollständige Spezifikation, die offen entwickelt wird, in den HL7-Ballotierungsprozess aufgenommen wurde und unter CC0 veröffentlicht ist – mit einem peer-reviewten Artikel in [npj Digital Medicine](https://www.nature.com/articles/s41746-025-01708-w), der den Ansatz an ~300.000 Patientinnen und Patienten validiert und eine veröffentlichte klinische Studie auf zwei unabhängigen Implementierungs-Stacks repliziert. Und in FHIR R6 ist ViewDefinition auf dem Weg, eine standardmäßige FHIR-Ressource zu werden – eine *Additional Resource*, R6s Mechanismus zur modularen Erweiterung der Kernspezifikation.

Was die Spezifikation wirklich bringt, ist Trennung: **Authoring wird von der Implementierung getrennt, und die Implementierung von der Nutzung**. Eine Person beschreibt die Analytik; jede Engine führt sie aus; jedes Werkzeug konsumiert das Ergebnis.

Und es sind nicht mehr nur View-Definitionen, die man schreibt. Man beschreibt seine *gesamte Analytik* als portable, standardisierte Artefakte – Flat Views, die Transformationsschichten darüber, Abfragen, vollständige Data Marts und Konversionspipelines. Man führt sie auf verschiedenen Engines aus, über verschiedene Datensätze, gegen Server verschiedener Anbieter – und kann das Ganze sogar als Implementierungsleitfaden verteilen, wie jeden anderen Teil des FHIR-Ökosystems. Das ist es, was „interoperable Analytics" bedeutet, und die Spezifikation liefert es nun auf drei Ebenen: Views, Abfragen und die API.

## ViewDefinition: eine Sprache zum Flachklopfen

Eine ViewDefinition ist, einfach gesagt, eine Sprache, um einem Server zu erklären, wie Ressourcen in eine flache Tabelle überführt werden sollen. Sie beschreibt eine tabellarische Sicht auf genau einen Ressourcentyp – Spalten, Filter und Unnesting – mithilfe einer [minimalen FHIRPath-Teilmenge](https://build.fhir.org/ig/HL7/sql-on-fhir/StructureDefinition-ViewDefinition.html#fhirpath-functionality):

```json
{
  "resourceType": "ViewDefinition",
  "resource": "Patient",
  "name": "patient_demographics",
  "select": [
    {
      "column": [
        {"name": "patient_id", "path": "getResourceKey()"},
        {"name": "gender", "path": "gender"},
        {"name": "dob", "path": "birthDate"},
        {"name": "active", "path": "active", "type": "boolean"}
      ]
    },
    {
      "forEach": "name.where(use = 'official').first()",
      "column": [
        {"path": "given.join(' ')", "name": "given_name"},
        {"path": "family", "name": "family_name"}
      ]
    }
  ]
}
```

Da die Definition deklarativ und engine-neutral ist, läuft dieselbe View auf einem JavaScript-ETL-Runner, innerhalb von PostgreSQL, auf Spark oder über einen Stapel ndjson-Dateien aus einem Bulk Export. Das gesamte Modell lässt sich auf fünf kombinierbare Funktionen reduzieren, und ein vollständiger Runner ist klein genug, um ihn an einem Nachmittag zu lesen – die [JavaScript-Referenzimplementierung](https://github.com/FHIR/sql-on-fhir.js) umfasst rund 400 Zeilen:

| Funktion | Was sie tut | SQL-Analogie |
|----------|-------------|--------------|
| `column` | Elemente per FHIRPath in Spalten extrahieren | `SELECT` |
| `where` | Ressourcen filtern (z. B. nach Profil oder Status) | `WHERE` |
| `forEach` / `forEachOrNull` | Eine Collection in Zeilen unnesten | `INNER JOIN` / `LEFT OUTER JOIN` gegen eine verschachtelte Tabelle |
| `select` | Elternspalten mit unnesteten Zeilen kreuzverbinden | Join von Teilabfragen |
| `unionAll` | Zeilen aus verschiedenen Zweigen zusammenführen | `UNION ALL` |

Ein ehrlicher Hinweis: Dieses Flachklopfen ist **verlustbehaftet, und das ist uns bewusst**. Es gibt keine universelle tabellarische Darstellung von FHIR; Views sind daher konstruktionsbedingt anwendungsfallspezifisch – ich sage manchmal scherzhaft, eine ViewDefinition sei der CSV-Modus von FHIR. Das ist keine Schwäche; es ist der Vertrag: Eine Ingenieurin oder ein Ingenieur definiert die View einmal, alle anderen Ingenieure und Werkzeuge nutzen einfach die flache Tabelle.

Eine natürliche Einheit für eine View ist ein Profil. Man stelle sich ein Blutdruckprofil vor – und darüber eine schöne `blood_pressure`-Tabelle mit den Spalten `systolic` und `diastolic`, eine Zeile pro Messung. Das Profil legt fest, wo die Daten in der Ressource liegen; die View überführt dieses Wissen in Spalten. Jedes klar definierte Profil ist eine flache Tabelle, die darauf wartet, deklariert zu werden.

Zwei neuere Ergänzungen sind erwähnenswert:

- **`repeat`** behandelt wirklich rekursive Strukturen – QuestionnaireResponse-Items, CodeSystem-Konzepte –, bei denen man die Tiefe im Voraus nicht kennt. Man gibt die zu durchlaufenden Pfade an, und jedes verschachtelte Item wird zu einer Zeile, egal auf welcher Ebene.
- **`%rowIndex`** erfasst die Position eines Elements während der Iteration. SQL-Ergebnismengen sind ungeordnet, FHIR-Arrays hingegen nicht – der erste `name` ist nicht dasselbe wie der dritte. Der Index ermöglicht es, die FHIR-Reihenfolge zu bewahren und Surrogate Keys zu bilden.

### Joins ohne Joins

Eine einzelne ViewDefinition verbindet niemals Ressourcen – by Design. Stattdessen emittieren zwei Funktionen Schlüssel, auf denen die Datenbank joined: `getResourceKey()` für den Primärschlüssel der Zeile und `subject.getReferenceKey(Patient)` für den Fremdschlüssel (mit einem Typfilter, der eine leere Collection – also null in der Ausgabe – zurückgibt, wenn die Referenz auf etwas anderes zeigt). Eine Condition-View trägt `patient_id`; Conditions mit Patients zu verbinden ist ein einfacher SQL-Join in jeder beliebigen Engine.

Wie Schlüssel tatsächlich abgeleitet werden – einfache `id`, primärer Identifier, Hash – bleibt der Implementierung überlassen. Das ist beabsichtigt: Es ist das, was dieselbe View über Systeme mit unterschiedlichen Dateninvarianten portierbar macht.

Und was ViewDefinition bewusst *nicht* tut – keine ressourcenübergreifenden Joins, keine Aggregation, keine Sortierung, keine Ausgabeformate – sind keine Lücken. Jedes davon war ein Kompromiss, den wir diskutiert haben: Sollen wir jeden View-Runner verkomplizieren, oder an die Datenbank delegieren? Datenbanken sind auf Joins spezialisiert; wir überschreiten keine Ressourcengrenze. Diese Zurückhaltung ist es, die einen Runner überall implementierbar macht – von einer 400-Zeilen-Bibliothek bis zu einer verteilten SQL-Engine.

Runner selbst gibt es in zwei Varianten. **ETL-Runner** nehmen einen Strom von Ressourcen entgegen und erzeugen Zeilen – trivial zu schreiben, wenn man eine FHIRPath-Engine hat (Implementierende haben berichtet, einen in Rust in etwa einem Monat geschrieben zu haben). **ELT-Runner** ähneln eher Transpilern als Runnern: Sie kompilieren eine ViewDefinition in eine komplexe SQL-Abfrage über eine FHIR-native Datenbank, sodass die View zu einer echten Datenbankview wird und nichts dupliziert wird – Hunderte von View-Definitionen können über dieselbe Ressourcentabelle laufen.

Und die Spezifikation wurde von unten nach oben aufgebaut, nicht von oben nach unten: [Implementierungen existierten *vor* dem ersten Release](https://sql-on-fhir.org/extra/impls.html). Eine gemeinsame Testsuite ist Teil der Spezifikation – Datensatz, View-Definition, erwartetes Ergebnis – und die Konformanzmatrix entsteht dadurch, dass Implementierende dieselben Tests ausführen und zurückmelden. So stellen wir sicher, dass die Implementierungen untereinander kompatibel bleiben.

## SQLQuery und SQLView: Abfragen werden teilbar

Flache Tabellen sind die eine Hälfte der Brücke. Die andere Hälfte: Wo leben die *Abfragen*? Die Kohortendefinition, das Qualitätsmaß, die Dashboard-Abfrage – das sind die am wenigsten portablen Artefakte in der heutigen Healthcare-Analytik, verstreut über Wikis und Notebooks, gebunden an das Schema eines einzelnen Standorts.

Deshalb haben wir die View-Definition mit einer Abfrageressource kombiniert, und jetzt kann man das Ganze teilen. **SQLQuery** ist ein Profil auf der FHIR-Library-Ressource, das eine logische SQL-Abfrage kapselt:

```json
{
  "resourceType": "Library",
  "meta": {"profile": ["https://sql-on-fhir.org/ig/StructureDefinition/SQLQuery"]},
  "type": {"coding": [{"system": "https://sql-on-fhir.org/ig/CodeSystem/LibraryTypesCodes", "code": "sql-query"}]},
  "name": "DiagnosisByAgeSummary",
  "status": "active",
  "relatedArtifact": [
    {"type": "depends-on", "resource": "https://example.org/ViewDefinition/patient_demographics", "label": "pt"},
    {"type": "depends-on", "resource": "https://example.org/ViewDefinition/diagnoses_view", "label": "dg"}
  ],
  "parameter": [
    {"name": "from_date", "type": "date", "use": "in"}
  ],
  "content": [{
    "contentType": "application/sql",
    "extension": [{
      "url": "https://sql-on-fhir.org/ig/StructureDefinition/sql-text",
      "valueString": "SELECT pt.gender, dg.code, count(*) FROM pt JOIN dg USING (patient_id) WHERE dg.onset >= :from_date GROUP BY 1, 2"
    }],
    "data": "..."
  }]
}
```

Die Struktur lässt sich in drei Ideen aufschlüsseln:

- **Abhängigkeiten als Aliasse.** Man deklariert, von welchen View-Definitionen die Abfrage abhängt, und das `label` wird zum Tabellennamen im SQL. Die Abfrage enthält keine hartkodierten physischen Tabellennamen – die Ausführungsumgebung löst `pt` und `dg` zu dem auf, als was die Views materialisiert sind.
- **Sichere Parameter.** Parameter werden in der Library deklariert und als `:from_date`-Platzhalter referenziert. Die Spezifikation ist unmissverständlich: String-Interpolation DARF NICHT verwendet werden – nur echtes Binding.
- **Dialekt-Varianten.** Eine Library kann ein portables `application/sql`-Standard-Attachment und `;dialect=postgresql`- oder `;dialect=spark`-Anhänge enthalten, sofern sie funktional äquivalent sind.

Außerdem beschreibt die Spezifikation eine Authoring-Erleichterung für Werkzeuge: Man schreibt eine einfache `.sql`-Datei mit einigen Annotationskommentaren (`@name`, `@param`, `@relatedDependency`) und lässt einen Builder daraus die Library-Ressource erzeugen. SQL bleibt die Quelle der Wahrheit; FHIR wird zur Verpackung.

**SQLView** ist das neueste Profil, und es existiert aus einem einzigen Grund: damit man Abfragen übereinander stapeln kann. Es ist fast dasselbe wie SQLQuery, aber ohne Parameter – und die Intention ist eine andere. Eine Abfrage ist etwas, das man ausführt; eine View beschreibt eine Tabelle. Wenn man sagt „Ich brauche eine View in einer Datenbank", weiß jeder, worum es geht – deshalb ist es ein eigenes Profil und kein Flag auf SQLQuery.

### Wie SQLViews gestapelt werden

Eine SQLView ist eine Library mit `type = sql-view`, einer kanonischen URL, Abhängigkeiten und SQL. Hier ist eine, die „aktive Patientinnen und Patienten" auf Basis der `patient_demographics`-ViewDefinition definiert:

```json
{
  "resourceType": "Library",
  "meta": {"profile": ["https://sql-on-fhir.org/ig/StructureDefinition/SQLView"]},
  "type": {"coding": [{"system": "https://sql-on-fhir.org/ig/CodeSystem/LibraryTypesCodes", "code": "sql-view"}]},
  "url": "https://example.org/Library/ActivePatientsView",
  "name": "ActivePatientsView",
  "status": "active",
  "relatedArtifact": [
    {"type": "depends-on", "resource": "https://example.org/ViewDefinition/patient_demographics", "label": "pt"}
  ],
  "content": [{
    "contentType": "application/sql",
    "extension": [{
      "url": "https://sql-on-fhir.org/ig/StructureDefinition/sql-text",
      "valueString": "SELECT patient_id, gender, dob FROM pt WHERE active = true"
    }],
    "data": "..."
  }]
}
```

Das Entscheidende ist die `url`. Da die View eine kanonische URL hat, kann *alles* nun davon abhängen, genau wie es von einer ViewDefinition abhängen würde – einfach einen `relatedArtifact`-Eintrag hinzufügen und das `label` als Tabellennamen verwenden. Eine zweite View baut auf der ersten auf:

```sql
-- SQLView: DiabeticPatientsView
-- depends on: .../Library/ActivePatientsView as ap
-- depends on: .../ViewDefinition/diagnoses_view as dg
SELECT ap.patient_id, ap.gender, ap.dob, dg.onset
  FROM ap
  JOIN dg USING (patient_id)
 WHERE dg.code = '44054006'   -- type 2 diabetes (SNOMED)
```

Und eine parametrisierte SQLQuery sitzt für den abschließenden Bericht auf beiden Schichten. Die Abhängigkeitsregeln sind einfach:

- Eine **SQLView** darf von ViewDefinitions und anderen SQLViews abhängen – niemals von SQLQueries.
- Eine **SQLQuery** darf von ViewDefinitions und SQLViews abhängen.
- Die Referenzen bilden einen gerichteten Graphen, der zyklenfrei gehalten werden muss – wie Views in jeder Datenbank.

Genug davon gestapelt, und man hat ein vollständiges analytisches System beschrieben – in denselben Begriffen, die ein Data-Team für jedes beliebige Warehouse verwenden würde. ViewDefinitions bilden die **Staging-Schicht**: rohes FHIR, als flache Tabellen geladen. SQLViews sind die **Zwischenmodelle**: bereinigte, gefilterte, verknüpfte Bausteine. Die Spitze des Graphen ist der **Data Mart**: die Kohorten-Register, Faktentabellen und parametrisierten Berichte, mit denen Analytikerinnen und Analytiker tatsächlich arbeiten.

```mermaid
flowchart LR
    subgraph staging ["Staging-Schicht — ViewDefinitions"]
        VD1[patient_demographics]
        VD2[diagnoses_view]
        VD3[observations_view]
    end
    subgraph views ["Zwischenmodelle — SQLViews"]
        SV1[ActivePatientsView]
        SV2[DiabeticPatientsView]
        SV3[LatestHbA1cView]
        SV4[DiabetesRegisterView]
    end
    subgraph queries ["Data Mart — SQLQueries"]
        Q1["RegisterByAgeReport(:from_date)"]
        Q2["PatientDrilldown(:patient_id)"]
    end
    VD1 --> SV1
    SV1 --> SV2
    VD2 --> SV2
    VD3 --> SV3
    SV2 --> SV4
    SV3 --> SV4
    SV4 --> Q1
    SV4 --> Q2
```

Jeder Knoten ist klein, lesbar und für sich testbar: Staging-Views formen lediglich um, jedes Zwischenmodell fügt einen Transformationsschritt hinzu, Abfragen parametrisieren nur den finalen Schnitt. Komplexität liegt in der *Komposition*, nicht in einem einzelnen Artefakt – genau so werden ausgereifte analytische Systeme heute in Warehouses gebaut. Es gibt hier keine Obergrenze: ein Krankheitsregister, eine Suite von Qualitätsmaßen, ein dimensionales Modell mit Fakten und Dimensionen, eine Konversion mit hundert Tabellen – alles, was sich als geschichtetes SQL über flachen Views ausdrücken lässt, lässt sich als dieser Graph ausdrücken.

Zwei Boni fallen dabei kostenlos an. Der Abhängigkeitsgraph *ist* die Datenherkunft: Für jede Spalte im Mart kann man Artefakt für Artefakt den Pfad zurück zum FHIRPath-Ausdruck nachverfolgen, der sie erzeugt hat. Und der gesamte Graph wird als FHIR-Ressourcen ausgeliefert.

Was die Spezifikation bewusst *nicht* vorschreibt, ist die Ausführung: ob `ActivePatientsView` zu einem inlierten CTE, einer Datenbankview oder einer materialisierten Tabelle wird, entscheidet die Engine – der Graph beschreibt die Logik, die Engine wählt die Physik.

### Eine vollständige DSL für ELT

Man sage einem Data Engineer „wir haben hier einen DAG" – und er versteht sofort. Der eben gezeigte Graph *ist* ELT, das dominante Muster des modernen Data Stacks: rohe Daten zuerst laden, sie dann in Schichten innerhalb der Engine transformieren. Wer dbt kennt, kennt diese Form bereits. In dem Moment, als wir die Möglichkeit hinzufügten, Abfragen auf Abfragen aufzubauen, wurde SQL on FHIR zu einer vollständigen DSL für die Beschreibung von ELT-Pipelines: ViewDefinition liefert das **EL** – FHIR extrahieren, flache Tabellen laden – und SQLView mit SQLQuery fügen das **T** hinzu. Der Kreis ist geschlossen.

Was ist also wirklich neu hier, verglichen mit dem ELT-Tooling, das Data-Teams bereits einsetzen? Das Distributionsmodell. Ein Transformationsprojekt ist normalerweise Code in *Ihrem* Repository, der *Ihr* Warehouse voraussetzt. Eine SQL on FHIR-Pipeline ist eine Menge von Ressourcen mit kanonischen URLs, die man in einem Implementierungsleitfaden veröffentlichen, versionieren und gegen jeden konformen Stack ausführen kann. Und weil die Artefakte technologieneutral sind, werden Menschen Übersetzer von ihnen in Stack-spezifische Assets schreiben – eine Warehouse-View, einen Schritt in Ihrem Orchestrator, ein Modell in welchem Transformations-Framework Ihr Team auch verwendet. Wir beschreiben den Data Mart einmal; die Zieltechnologie ist ein Compilierungsdetail.

Das vervollständigt auch die Authoring-Geschichte. Man nehme das Blutdruck-Beispiel von vorhin – das Profil und seine `systolic`/`diastolic`-View – und füge nun einen Satz nützlicher Abfragen darüber hinzu: Hypertonie-Berichte, Trend-Dashboards. Profil, View, Abfragen – ein auslieferbares Paket. Wir glauben, SQL on FHIR wird Teil des Authorings selbst: Ein IG, der Daten definiert, sollte die Views und Abfragen zu deren Analyse mitliefern.

## Die API: portable Clients, kein Vendor Lock-in

Das dritte Stück ist eine standardisierte HTTP-API, und sie ist aus demselben Grund wichtig wie die Artefakte: Ohne sie ist das *Tooling* an einen Anbieter gebunden, selbst wenn es die Views nicht sind. Mit ihr funktioniert ein Dashboard, eine Pipeline, ein Notebook – alles, das die API spricht – gegen jeden konformen Server. Das Backend austauschen, den Workflow behalten. Clients erfahren, was ein Server unterstützt, über das standardmäßige CapabilityStatement.

Die API umfasst drei Verben. Jedes beantwortet eine andere Frage:

| Verb | Beantwortet die Frage | Operationen | Modus |
|------|----------------------|-------------|-------|
| **run** | „Gib mir die Zeilen, jetzt" | `$viewdefinition-run`, `$sqlview-run`\*, `$sqlquery-run` | Synchron, gestreamt |
| **export** | „Bau die Dateien, sag mir Bescheid, wenn fertig" | `$viewdefinition-export`, `$sqlview-export`\*, `$sqlquery-export` | Asynchron, in Dateispeicher |
| **materialize** | „Halte mir eine Tabelle aktuell" | `$materialize`\* | Asynchron, serverseitig verwaltet |

\* `$sqlview-run`, `$sqlview-export` und `$materialize` kommen noch in die Spezifikation – die Arbeitsgruppe standardisiert sie gerade.

Man beachte die Symmetrie: Jedes Artefakt im Abhängigkeitsgraphen – ViewDefinition, SQLView, SQLQuery – bekommt dieselben Verben. Man kann jeden Knoten der Pipeline ausführen oder exportieren, egal ob es sich um eine rohe Staging-View, ein Zwischenmodell oder eine finale parametrisierte Abfrage handelt. Und materialize gilt sowohl für ViewDefinitions als auch für SQLViews – jeder nicht-parametrisierte Knoten kann zu einer verwalteten Tabelle werden.

### run – für Authoring und Echtzeit

`$viewdefinition-run` – aufgerufen als `$run` auf der ViewDefinition-Ressource – ist eine synchrone Operation, die für das Authoring und Anwendungsfälle mit kleinen Datensätzen konzipiert ist. Der einfachste Aufruf ist ein GET auf eine gespeicherte View:

```http
GET /ViewDefinition/patient-demographics/$run?_format=csv&_limit=100
Accept: text/csv
```

```
id,birthDate,family,given
pt-1,1990-01-15,Smith,John
pt-2,1985-03-22,Johnson,Mary
```

Wenn die View noch nicht gespeichert ist, postet man sie als `Parameters`-Body – optional zusammen mit den zu transformierenden Ressourcen. Das ist die Authoring-Schleife: Definition bearbeiten, POSTen, Zeilen sehen, wiederholen. Es ist auch der Echtzeit-Pfad: ein Dashboard-Widget oder ein KI-Agent, der die Diagnosen einer Patientin als flache Tabelle statt als Ressourcengraph erhalten möchte. Laufzeitbelange bleiben zur Laufzeit: `patient`, `group`, `_since` und `_limit` sind Operationsparameter, keine View-Eigenschaften – dieselbe View-Definition bedient den Gesamt-Populations-Export und die Einzelpatienten-Abfrage.

`$sqlquery-run` ist dasselbe Verb eine Schicht höher: Eine gespeicherte oder inline übergebene SQLQuery wird gegen die materialisierten Views ausgeführt, Abfrageparameter werden namentlich übergeben (als verschachtelte `Parameters`-Ressource, sicher an die deklarierten `Library.parameter`-Einträge gebunden), mit `_format` und `_limit` zur Ausgabesteuerung. Und `$sqlview-run` (kommt in die Spezifikation) füllt die Mitte: Beliebige SQLViews auswerten – mit dem gesamten Abhängigkeits-Teilgraphen, der vom Server aufgelöst wird – und die Zeilen zurückstreamen. Sehr nützlich beim Debuggen eines Knotens in einer tiefen Pipeline.

### export – Bulk Export, verbessert

Man stelle sich `$viewdefinition-export` als verbesserten [FHIR Bulk Export](https://hl7.org/fhir/uv/bulkdata/) vor. Beim klassischen Bulk Export erhält man *alle* Ressourcen als rohes ndjson – und das Flachklopfen ist dann das eigene Problem: Man baut eine ETL-Pipeline allein um die Daten abfragbar zu machen. Mit dem SQL on FHIR-Export fragt man nach den Views, die man wirklich benötigt, und was im Bucket landet, ist bereits flach – csv, ndjson oder parquet, bereit für Spark, DuckDB, Athena oder das Laden in ein Warehouse.

Der Ablauf besteht aus vier Schritten:

1. **Anstoßen.** Eine Liste von Views mit dem Async-Header POSTen:

```http
POST /ViewDefinition/$viewdefinition-export HTTP/1.1
Prefer: respond-async
Content-Type: application/fhir+json

{
  "resourceType": "Parameters",
  "parameter": [
    {"name": "view", "part": [{"name": "viewReference",
      "valueReference": {"reference": "ViewDefinition/patient-demographics"}}]},
    {"name": "view", "part": [{"name": "viewReference",
      "valueReference": {"reference": "ViewDefinition/diagnoses"}}]},
    {"name": "_format", "valueCode": "parquet"}
  ]
}
```

2. **Ein Ticket erhalten.** Der Server antwortet mit `202 Accepted` und einem `Content-Location`-Header – der Status-URL.

3. **Pollen.** Die Status-URL gibt `202` zurück, während der Job läuft (optional mit Fortschrittsangabe). Wenn der Job abgeschlossen ist, antwortet er mit `303 See Other` und einem `Location`-Header, der auf das Ergebnis zeigt.

4. **Dateien abholen.** Die Ergebnis-URL per GET abrufen – die Antwort listet eine Ausgabe-URL pro View auf:

```json
{
  "resourceType": "Parameters",
  "parameter": [
    {"name": "exportId", "valueString": "job-42"},
    {"name": "status", "valueCode": "completed"},
    {"name": "output", "part": [
      {"name": "name", "valueString": "patient_demographics"},
      {"name": "location", "valueUri": "https://storage.example.org/exports/patient_demographics.parquet"}
    ]},
    {"name": "output", "part": [
      {"name": "name", "valueString": "diagnoses"},
      {"name": "location", "valueUri": "https://storage.example.org/exports/diagnoses.parquet"}
    ]}
  ]
}
```

Wer Bulk Data Export implementiert hat, kennt diesen Ablauf – dieselbe asynchrone Choreografie, aber die Nutzlast sind analysebereit Tabellen statt roher Ressourcen. Es gibt weniger zu exportieren, man überspringt den ETL-Schritt vollständig, und ein Server, der Export nativ unterstützt, kann unter der Haube stark optimieren.

Dieselben Filter gelten wie für run – `patient`, `group`, `_since` –, sodass ein Kostenträger die Views eines einzelnen Mitglieds genauso einfach exportieren kann wie die gesamte Population. Und das Verb erstreckt sich den Graphen hinauf: `$sqlview-export` (kommt in die Spezifikation) materialisiert ein Zwischenmodell in Dateien, `$sqlquery-export` macht dasselbe für Abfrageergebnisse, die zu groß für eine synchrone Antwort sind. Die Staging-Schicht für den Data Lake exportieren oder den fertigen Mart – die Wahl des Schnittpunkts liegt beim Nutzer.

### materialize – „Hey Server, halte das aktuell"

`$materialize` ist die Operation, bei der man dem Server sagt: Hier ist meine View-Definition – baue daraus eine verwaltete View und halte sie aktuell, während sich die Daten ändern. Es ist eine asynchrone Operation: Man übergibt einen Zielnamen und eine Aktualisierungsrichtlinie (`manual` oder `scheduled` mit einem Cron-Ausdruck), der Server baut die View im Hintergrund auf, und wenn der Job abgeschlossen ist, erhält man eine Referenz auf die materialisierte View, die man von da an abfragen kann:

```http
POST /ViewDefinition/patient-demographics/$materialize HTTP/1.1
Prefer: respond-async
Content-Type: application/fhir+json

{
  "resourceType": "Parameters",
  "parameter": [
    {"name": "targetName", "valueString": "patient_demographics"},
    {"name": "updatePolicy", "valueCode": "scheduled"},
    {"name": "schedule", "valueString": "0 0 * * *"}
  ]
}
```

Wenn der Job abgeschlossen ist, erhält man eine Referenz auf die materialisierte View; wie sie zum Abfragen bereitgestellt wird – Schema, Benennung, Zugriff – liegt bei der Implementierung, wobei `targetName` als gewünschtes Handle dient. Ab diesem Zeitpunkt ist der Server für die Aktualität zuständig (in diesem Beispiel nächtlich), und jedes SQL-Werkzeug fragt einfach das Ergebnis ab:

```sql
SELECT * FROM patient_demographics WHERE dob > '1990-01-01';
```

Das BI-Tool verbindet sich mit einer Tabelle und weiß nicht, dass FHIR involviert war.

Dasselbe Verb gilt für SQLViews. Ein Zwischenmodell zu materialisieren ist genau das, was man in einem Warehouse tut, wenn eine View heiß wird: Der Server löst den Abhängigkeits-Teilgraphen auf, baut die Tabelle und übernimmt deren Aktualisierung. Welche Knoten des DAG materialisiert und welche virtuell bleiben, wird zu einer Laufzeit-Tuning-Entscheidung – die Pipeline-Definition ändert sich nicht.

Dieses Muster ist bereits produktionsbewährt. In Aidbox haben wir [$materialize über PostgreSQL](/articles/introducing-materialize-sql-interface-for-fhir-data?utm_source=blog&utm_medium=article&utm_campaign=analytics&utm_content=interoperable-analytics) implementiert, plus Adapter für ClickHouse, BigQuery und Databricks: ein sehr effizienter initialer Load – Millionen von Ressourcen in Sekunden – und dann wird die Tabelle nahezu in Echtzeit mithilfe von Subscriptions aktuell gehalten. Dieselbe View-Definition, vier verschiedene Engines – was gewissermaßen der Punkt ist. Die Operation wird nun standardisiert, damit „halte diese Tabelle aktuell" zu einer portablen Anfrage wird und kein Anbietermerkmal bleibt.

Zusammengefasst: **run** für Entwicklung und Echtzeitzugriff, **export** für die Versorgung externer Engines, **materialize** für In-Database-Analytik – alle Endpunkte standardisiert, alle anbieterneutral.

Wohin das führt: Aus der Perspektive der Nutzenden wird der Server zur Wunderbox. Man sendet ihm View-Definitionen und Abfragen; die Views bleiben aktuell; man führt einfach seine Berichte aus. Und da LLMs bereits recht gut im Erstellen von SQL und View-Definitionen sind, ist die nächste Schnittstelle über dieser Box natürliche Sprache – mit der Standard-API als dem, was der Agent darunter steuert.

## FHIR zu OMOP: die DSL im Härteeinsatz

Das ist also das vollständige Toolkit: eine DSL für das Staging (ViewDefinition), eine DSL für Transformationen (SQLView/SQLQuery) und eine API, um alles auszuführen. Der beste Weg herauszufinden, ob ein Toolkit praxistauglich ist, besteht darin, die härteste bekannte Konversion darauf anzuwenden – und das ist FHIR zu OMOP. Offen gesagt hat dieses Projekt *Teile des Designs bereits diktiert*: Wir brauchten Views über Views, um es auszudrücken, und dieser Bedarf ist ein wesentlicher Grund dafür, dass SQLView existiert.

OMOP CDM ist der OHDSI-Standard für Beobachtungsforschung, und was mir an OMOP gefällt, ist seine extreme Pragmatik: ein fester Satz von Tabellen, alle Codes auf Standardkonzepte normiert, die gesamte Infrastruktur – Terminology eingeschlossen – direkt in der Datenbank. FHIR ist das transaktionale Modell; OMOP ist das analytische. Je mehr sich FHIR-first-Systeme verbreiten, desto mehr wird „FHIR für OLTP, OMOP für OLAP" zur Standardarchitektur, und die Konversionschicht zwischen ihnen wird zu kritischer Infrastruktur.

Die OMOP-Community selbst baut Pipelines in der Regel im ELT-Stil. Wir auch – die Konversion ist ein geschichteter DAG aus genau den oben beschriebenen Artefakten:

```mermaid
flowchart LR
    A[FHIR-Ressourcen] -->|"ViewDefinitions<br/>(Staging-Schicht)"| B[condition_staging<br/>patient_staging<br/>observation_staging]
    B -->|"SQLViews<br/>(Mapping-Schicht<br/>+ OMOP-Vokabularien)"| C[condition_mapped<br/>person_mapped<br/>domain_routed]
    C -->|"SQLQueries<br/>(Load-Schicht)"| D[OMOP CDM<br/>condition_occurrence<br/>person, measurement]
    D --> E[OHDSI-Tools<br/>Atlas, HADES]
```

Die Grundlagen, Schicht für Schicht.

**Staging-ViewDefinitions** klopfen jede Ressource genau auf das flach, was OMOP benötigt – Schlüssel, Datumsangaben und Quell-Codings, eine Zeile pro Coding:

```json
{
  "resourceType": "ViewDefinition",
  "name": "condition_staging",
  "resource": "Condition",
  "select": [
    {
      "column": [
        {"name": "condition_id", "path": "getResourceKey()", "type": "string"},
        {"name": "person_id", "path": "subject.getReferenceKey(Patient)", "type": "string"},
        {"name": "start_date", "path": "onset.ofType(dateTime)", "type": "dateTime"}
      ]
    },
    {
      "forEach": "code.coding",
      "column": [
        {"name": "source_system", "path": "system", "type": "uri"},
        {"name": "source_code", "path": "code", "type": "code"}
      ]
    }
  ]
}
```

**Mapping-SQLViews** enthalten die Semantik: Die OMOP-Athena-Vokabularien werden direkt in die Datenbank geladen, und **die Concept-ID-Auflösung ist ein JOIN, kein Terminology-Server-Aufruf**:

```sql
-- SQLView: ConditionMappedView
-- depends on: .../ViewDefinition/condition_staging as cs
SELECT cs.condition_id,
       cs.person_id,
       std.concept_id_2   AS condition_concept_id,
       cs.start_date,
       src.concept_id     AS condition_source_concept_id,
       cs.source_code     AS condition_source_value,
       std_c.domain_id    AS target_domain
  FROM cs
  JOIN concept src
    ON src.concept_code = cs.source_code AND src.vocabulary_id = 'ICD10CM'
  JOIN concept_relationship std
    ON std.concept_id_1 = src.concept_id AND std.relationship_id = 'Maps to'
  JOIN concept std_c
    ON std_c.concept_id = std.concept_id_2
```

Es sieht knifflig aus, ist aber im Grunde trivial: Es führt das Mapping durch, macht die Joins und übersetzt on the fly. Und die Joins behandeln auf natürliche Weise die wirklich schwierigen Teile – `Maps to`-Fan-out (ein ICD-Code wird zu mehreren SNOMED-Zeilen), Domain-Routing (eine FHIR-Condition landet je nach Domain des Zielkonzepts in `condition_occurrence`, `observation` oder `measurement`) und Verwerfungsregeln für nicht abbildbare Datensätze.

**Load-SQLQueries** lesen die gemappten Views und befüllen die CDM-Tabellen:

```sql
INSERT INTO condition_occurrence
SELECT condition_id, person_id, condition_concept_id,
       start_date, condition_source_concept_id, condition_source_value
  FROM cm
 WHERE target_domain = 'Condition'
```

Warum SQL und nicht FHIRPath oder die FHIR-Mapping-Language? Weil man für diese Aufgabe Lookups in Mapping-Tabellen, das Aufteilen eines Datensatzes in viele sowie bedingte Logik über Vokabularien hinweg benötigt. Man könnte prinzipiell jeden Code über den `$translate`-Endpunkt eines Terminology-Servers routen – aber das ist für Massentransformationen nicht praktikabel; Systeme würden CPUs damit verbrennen, es Zeile für Zeile zu tun. Und das muss im Populationsmaßstab effizient sein – Milliarden von Datensätzen, nicht Tausende. Für uns ist das einfach SQL, und mengenbasierte Joins über Milliarden von Zeilen ist genau das Problem, an dem Datenbanken fünfzig Jahre lang gefeilt haben.

Ein vollständiger Hinweis: Dieses Projekt – [fhir2omop](https://github.com/lampadephoros/fhir2omop) – befindet sich in einem sehr frühen Stadium, ein offenes Work in Progress. Die Ideen, die wir erkunden: profilgesteuertes Konvertieren (eine Ressource wird *genau dann* in eine OMOP-Tabelle konvertiert, wenn sie gegen ein steuerndes FHIR-Profil validiert), goldene Testfälle (eine FHIR-Ressource rein, die exakten OMOP-Zeilen heraus – weil Randfälle genau das sind, was Beispiele sichtbar machen) sowie Jurisdiktionsmodule wie *US Core zu OMOP* oder *Deutsches ICD zu OMOP*, die die Community erweitern kann.

Das Ziel ist jedoch größer als ein einzelner Konverter. FHIR-zu-OMOP ist die Art, wie wir **SQL on FHIR im Härteeinsatz beweisen**: Es ist die schwierigste bekannte Konversion, und wenn die DSL sie ausdrücken kann – das Staging, die Vokabular-Joins, das Domain-Routing, alles als portable Artefakte –, dann kommt alles Einfachere kostenlos dazu. Wir laden deshalb alle ein: OMOP-Leute, FHIR-Leute, Data Engineers. Bringen Sie Ihre Mappings, Ihre Randfälle, Ihre Skepsis mit – die Arbeitsgruppe widmet OMOP eigene Sitzungen, und der Raum ist offen.

## Bauen Sie mit uns

Treten Sie dem [#analytics-on-FHIR-Stream](https://chat.fhir.org/#narrow/stream/179219-analytics-on-FHIR) auf chat.fhir.org und den wöchentlichen Arbeitsgruppengesprächen bei. Probieren Sie den [Playground](https://sql-on-fhir.org/extra/playground.html) für einen Fünf-Minuten-Eindruck, oder den [vollständigen DevDays-Workshop](/articles/lets-build-sql-on-fhir-video-nikolai-ryzhikov-at-fhir-devdays-2025?utm_source=blog&utm_medium=article&utm_campaign=analytics&utm_content=interoperable-analytics) – PostgreSQL, Grafana, Jupyter, Synthea-Daten – für einen praktischen Einstieg. Und tragen Sie sich [FHIR Analytics](/events/fhir-analytics-2025?utm_source=blog&utm_medium=article&utm_campaign=analytics&utm_content=interoperable-analytics) in den Kalender ein – unsere kostenlose Online-Konferenz, die genau diesem Thema gewidmet ist.

Und wenn Sie all das heute ausführen möchten: [Aidbox](/fhir-server?utm_source=blog&utm_medium=article&utm_campaign=analytics&utm_content=interoperable-analytics) ist ein transaktionaler FHIR-Server mit integrierter Echtzeit-Analytik auf SQL on FHIR – und der erste FHIR-Server, der alle SQL on FHIR-Tests besteht. ViewDefinitions über PostgreSQL, ein visueller ViewDefinition-Builder und SQL-View/Query-Manager in der Benutzeroberfläche, `$materialize` sowie Adapter, die Ihre Tabellen in ClickHouse, BigQuery und Databricks aktuell halten. Die transaktionale und die analytische Welt, endlich auf einer Brücke.

---

*Das ist nicht die Arbeit einer einzelnen Person. Besonderer Dank gilt John Grimes, Arjun Sanyal, Gino Canessa und Steve Munini – sowie der gesamten SQL on FHIR-Arbeitsgruppe, die Woche für Woche erscheint, um diese Ideen zu einem Standard zu diskutieren.*