Referentially-intact EHR seeding · on DuckDB
Ferry
Seed masked EHR slices into your lower environments.
Ferry uses DuckDB as a cross-database compute layer to FK-walk a complete patient record out of PROD, mask the direct identifiers in the Synthea schema on the way through, and load a slice into UAT with masked identifiers and zero broken foreign keys — in seconds.
Verified numbers from a real PostgreSQL → PostgreSQL run against a throwaway PostgreSQL 16 container
How it works
DuckDB sits between your two databases and does the hard part.
A naïve WHERE id IN (…) subset leaves dangling foreign keys that break the app the moment it boots. Ferry attaches both environments through DuckDB, walks the foreign-key graph to a fix-point, masks in one vectorised pass, and loads parents-first into an FK-enforced target.
Introspect
Read primary and foreign keys straight from duckdb_constraints() or PostgreSQL's
information_schema. No hand-maintained schema map.
Subset to a fix-point
Downward from the anchored patients for the full longitudinal record; upward to a fix-point for every parent it references — until the slice is referentially closed.
Mask deterministically
One vectorised DuckDB pass. Re-keying a patient id applies the same pure function to every FK, so joins survive with no side mapping table.
Load & prove
Load parents-first into an FK-enforced target, then count dangling references — expected 0. Export FHIR R4 and HL7 v2.
Features
Everything you need to hand a realistic slice to UAT — and nothing you can't ship.
FK-walk subset with provenance
Fix-point expansion pulls each anchored patient's complete record plus every parent it needs — providers, organizations, payers. The report shows exactly why each table is in the slice.
Join-preserving PHI masking
Deterministic, salted transforms mask the direct identifiers in the Synthea patient record (NHI, names, address, dates). Dates shift per-patient so ages and intervals survive; re-keys cascade so joins never break. Not a full de-identification tool on its own — expert review is still needed before real PHI.
Dirty-source integrity handling
Real prod has orphaned FKs. A pre-flight scan flags them, then the on_orphan policy decides:
error aborts, prune cascade-removes, report loads and flags.
FHIR R4 + HL7 v2 export
Emit the masked slice as FHIR R4 bundles and HL7 v2 ADT^A01 / ORU^R01 messages — the same re-keyed patient id in every format, so downstream systems still line up.
DuckDB or PostgreSQL
Point at local .duckdb files or ATTACH real PostgreSQL — DuckDB is the compute
layer either way. Ferry reads the source's real keys and loads against the live database.
pip install & ferry demo
One command generates synthetic Synthea data, seeds a slice, exports both formats, and opens a clinical-grade HTML report. No warehouse, no cluster, no setup.
Live example
The clinical-grade seed report Ferry generates — every run, one command.
Not a log line. A self-contained, EHR-styled report that proves what happened: the slice, the masking, and the integrity check, in the visual language of the systems your data came from.
- Headline KPIs — rows seeded, PHI columns masked, identifiers re-keyed, orphaned FKs
- Referential-integrity verdict with every FK relationship checked
- Per-table subset provenance — click a row to see why it's included
- Before → after PHI samples and the identifier-masking checklist
- FHIR & HL7 export tiles — 145 bundles, 631 ADT + 631 ORU this run
Get started
Two commands to a masked slice and a report.
Ferry installs as an editable Python package and ships a self-contained demo — no external database required. Point it at your own environments with a small ferry.yml.
# uses the pyenv virtualenv `ferry` $ pip install -e . # generate → seed → export → open the report $ ferry demo
salt: "change-me-per-project" # seeds masking source: { duckdb: out/demo/prod.duckdb } target: { duckdb: out/demo/uat.duckdb } root: patients anchor: where: > # SQL on the root EXISTS (SELECT 1 FROM src.encounters e WHERE e.PATIENT = patients."Id") rekey_root: true # cascade to every FK export: { formats: [fhir, hl7], out: out/demo }