|
6 min read

@atomic-ehr/codegen: Python FHIR Types

Summarize this blog post with:

FHIR development in Python usually means wrestling with raw dicts, guessing field names from a massive spec, and discovering typos only at runtime. We built a generator that eliminates all of that.

@atomic-ehr/codegen produces strongly typed Pydantic models from any FHIR package — with validation, IDE autocomplete, and optional fhirpy integration. It works with any FHIR server, not just Aidbox. The project is open source (MIT), and we welcome contributions.

This post updates our earlier Python SDK article. Since then, the generator has been rewritten from scratch in a single TypeScript stack as @atomic-ehr/codegen.

What you get:

  • Pydantic v2 models for every resource and data type
  • Value setsLiteral enums and generic CodeableConcept[T] with bindings in the type
  • Primitive extensions — typed _birthDate, _family fields
  • fhirpy async client — plug directly into AsyncFHIRClient
  • Any FHIR packages — R4, US Core, and custom in one pass; patch packages on the fly
  • Type Schema — tree shaking, logical model promotion, collision resolution

Generate Your FHIR Types

Install the generator (bun add and pnpm add work too):

npm install @atomic-ehr/codegen

Create a generate.ts script:

import { APIBuilder } from "@atomic-ehr/codegen";

const main = async () => {
  const builder = new APIBuilder()
    .fromPackage("hl7.fhir.r4.core", "4.0.1")
    .python({
      fieldFormat: "snake_case",
      allowExtraFields: false,
      primitiveTypeExtension: true,
    })
    .typeSchema({
      treeShake: {
        "hl7.fhir.r4.core": {
          "http://hl7.org/fhir/StructureDefinition/Patient": {},
          "http://hl7.org/fhir/StructureDefinition/Observation": {},
          "http://hl7.org/fhir/StructureDefinition/Bundle": {},
        },
      },
    })
    .outputTo("./fhir_types")
    .cleanOutput(true);

  const report = await builder.generate();
  if (!report.success) process.exit(1);
};

main();

Run it:

npx tsx generate.ts

You now have a ready-to-use Python package in ./fhir_types/. Tree shaking pulls in only the resources you listed plus their dependencies (DomainResource, Element, OperationOutcome, etc.) automatically.

What Gets Generated

fhir_types/
├── __init__.py                  # Exports + model_rebuild() for forward refs
├── requirements.txt             # pydantic, requests, fhirpy, mypy, pytest
├── README.md
└── hl7_fhir_r4_core/
    ├── __init__.py
    ├── base.py                  # Complex types: CodeableConcept, HumanName, ...
    ├── patient.py               # Patient + nested types (PatientContact, ...)
    ├── observation.py
    ├── operation_outcome.py
    ├── bundle.py
    ├── domain_resource.py
    ├── resource.py
    └── resource_families.py     # Polymorphic validators for Bundle.entry etc.

Each resource is a Pydantic model with proper field types, aliases, and validation:

# Simplified -- other fields also carry serialization_alias, omitted for brevity
class Patient(DomainResource):
    model_config = ConfigDict(
        validate_by_name=True,
        serialize_by_alias=True,
        extra="forbid"
    )
    resource_type: Literal['Patient'] = Field(
        default='Patient', alias='resourceType',
        serialization_alias='resourceType', frozen=True
    )
    birth_date: str | None = Field(None, alias="birthDate")
    birth_date_extension: Element | None = Field(None, alias="_birthDate")
    gender: Literal["male", "female", "other", "unknown"] | None = Field(None)
    marital_status: CodeableConcept[
        Literal["A", "D", "I", "L", "M", "P", "S", "T", "U", "W", "UNK"] | str
    ] | None = Field(None, alias="maritalStatus")
    name: PyList[HumanName] | None = Field(None)  # PyList = typing.List
    # ...

See full generated file for the complete Patient model.

Create and Work with Resources

Setup Python environment (Python 3.12+)
python3.12 -m venv .venv
source .venv/bin/activate
pip install -r fhir_types/requirements.txt

The generator produces types and validation — no HTTP client is bundled. For server communication, use fhirpy or any HTTP library. Here we work with models directly:

from fhir_types.hl7_fhir_r4_core import Element, Extension, HumanName, Patient

patient = Patient(
    name=[HumanName(given=["John"], family="Doe")],
    gender="male",
    birth_date="1980-01-01",
    birth_date_extension=Element(
        extension=[
            Extension(
                url="http://hl7.org/fhir/StructureDefinition/patient-birthTime",
                value_date_time="1980-01-01T08:30:00-05:00",
            ),
        ],
    ),
)

# Serialize to FHIR JSON (camelCase keys, nulls excluded)
print(patient.to_json(indent=2))
{
  "resourceType": "Patient",
  "birthDate": "1980-01-01",
  "_birthDate": {
    "extension": [{
      "url": "http://hl7.org/fhir/StructureDefinition/patient-birthTime",
      "valueDateTime": "1980-01-01T08:30:00-05:00"
    }]
  },
  "gender": "male",
  "name": [{ "family": "Doe", "given": ["John"] }]
}
# Round-trip: JSON -> Patient
restored = Patient.from_json(patient.to_json())
assert restored == patient

# Typed field access
patient.gender                                              # 'male'
assert patient.name is not None
patient.name[0].family                                      # 'Doe'
patient.birth_date                                          # '1980-01-01'
assert patient.birth_date_extension is not None
assert patient.birth_date_extension.extension is not None
patient.birth_date_extension.extension[0].value_date_time   # '1980-01-01T08:30:00-05:00'

Type Checking and Validation

What happens when we make mistakes?

from fhir_types.hl7_fhir_r4_core import HumanName, Patient

Patient(
    name=[HumanName(family="Doe")],
    gender="FOO",            # wrong value
    some_data="1990-01-01",  # wrong field
)

Static analysis with mypy. Add the Pydantic plugin to mypy.ini:

[mypy]
python_version = 3.12
plugins = pydantic.mypy

[pydantic-mypy]
init_typed = True

Then run:

$ mypy . --strict
main.py:10: error: Unexpected keyword argument "some_data" for "Patient"  [call-arg]
main.py:12: error: Argument "gender" to "Patient" has incompatible type "Literal['FOO']"; expected "Literal['male', 'female', 'other', 'unknown'] | None"  [arg-type]

Runtime validation — run the script and Pydantic catches both errors at instantiation:

$ python main.py
pydantic_core._pydantic_core.ValidationError: 2 validation errors for Patient
gender
  Input should be 'male', 'female', 'other' or 'unknown'
some_data
  Extra inputs are not permitted

No need to run a validator separately — models enforce constraints on construction. If you need to accept extra fields, generate with allowExtraFields: true.

Customization Options

OptionTypeDefaultEffect
fieldFormat"snake_case" | "camelCase""snake_case"Field naming: birth_date vs birthDate
allowExtraFieldsbooleanfalseAccept unknown fields in JSON input
primitiveTypeExtensionbooleanfalseGenerate typed _birthDate, _family companion fields
fhirpyClientbooleanfalsefhirpy-compatible base class and resourceType

These options combine with APIBuilder-level features — tree shaking to include only the resources you need, multi-package loading to mix R4 core with IGs and custom profiles, and package preprocessing to patch broken upstream packages before generation.

Coming Next: Profile Support for Python

Our TypeScript generator already supports FHIR profile classes — generated classes that auto-populate fixed values, provide typed accessors for slices and extensions, and include client-side validation. We're working on bringing the same capabilities to Python:

  • Slice accessors with discriminator values applied automatically
  • Extension getters/setters
  • Validation returning structured errors and warnings

If you're interested in this — feedback on what profile patterns matter most for your Python workflows is very welcome on GitHub.

Beyond Python: What Else @atomic-ehr/codegen Does

This post focused on the Python generator, but @atomic-ehr/codegen is a multi-language code generation platform. Here's what else it offers:

  • Four built-in generators — Python, TypeScript, C#, and a Mustache template engine for any language
  • Package preprocessing — real-world FHIR packages often ship with bugs: malformed canonical URLs, missing CodeSystem concepts, unavailable external ValueSets. The preprocessPackage hook lets you patch these before generation, so you don't have to fork the package or work around errors downstream
  • Logical model promotion — convert logical StructureDefinitions into first-class resources for code generation
  • Schema collision resolution — when multiple packages define overlapping bindings, you can specify which source wins
  • Multi-source loading — combine NPM packages, local TGZ archives, and loose StructureDefinition JSON files in a single generation pass

Working Python examples:

@atomic-ehr/codegen is open source under the MIT license. If you're working with FHIR in Python — whether you're building data pipelines, ML features, or clinical apps — give it a try and let us know what you think. Issues, feature requests, and PRs are all welcome.

GitHub | NPM | Aleksandr Penskoi on LinkedIn

Comments
Comments
Sign in
Loading comments...
Subscribe to our blog

Get the latest articles on FHIR, interoperability, and healthcare IT.