Migrating from Azure API for FHIR to Azure Health Data Services: Export, Import, Identity and a Cut-Over Runbook

Azure API for FHIR retires on 30 September 2026. New deployments have been blocked since 1 April 2025, and Microsoft’s guidance is to move to the FHIR service in Azure Health Data Services. If you still run a production workload on the old service, five weeks is enough time to do this properly and not enough to do it twice.

The migration is usually described as a data copy: export from the old server, import into the new one. That part is well supported and mostly mechanical. The work that decides whether the cut-over goes quietly is around it: an identity model that changes, an endpoint every client has to learn, settings that do not come across, and the question nobody likes to be asked on the day, which is how you know the copy is complete.

This article covers the differences that matter, the two migration patterns and when each fits, a Python script that drives $export and $import, a C# tool that compares the two servers before you let clients loose on the new one, and a cut-over runbook with a rollback point. The comparison tool gets the most attention, because a rehearsal with it produced a wrong answer worth knowing about before the real night.

ℹ️
WHAT WAS RUN AND WHAT WAS NOT

Nothing here was run against Azure API for FHIR or the Azure Health Data Services FHIR service. The Python script was tested against a local stand-in that implements the request and response shapes Microsoft documents. The C# validation tool was tested the same way and then run in Docker against two real FHIR servers, HAPI FHIR instances standing in for source and target, with synthetic data. Treat the script as a starting point to rehearse in your own subscription, not as something proven on the services it targets.

Diagram of the migration path: Azure API for FHIR exports NDJSON files to blob storage, the Azure Health Data Services FHIR service imports them using its managed identity, a validation tool compares both servers, and clients are repointed to the new endpoint only after the comparison passes.
The data path and the check that gates the cut-over.

What changes and what does not

The FHIR API your clients call is the same specification on both sides. What differs is the service around it.

AreaAzure API for FHIRAzure Health Data Services FHIR serviceWhat to do
Endpointhttps://{name}.azurehealthcareapis.comhttps://{workspace}-{service}.fhir.azurehealthcareapis.comEvery client, token audience and firewall rule changes
AuthorizationAzure RBAC or local RBAC with a custom authorityAzure RBAC only. The token issuer must be the tenant the service runs inReplace local RBAC object-id lists with role assignments
Bulk loadOpen-source tools$import, initial and incremental modesUse $import for the copy
BundlesBatchBatch and transactionNothing to change; a later opportunity
SearchNo _include:iterate, limited sorting_include, _revinclude, iterate, sort on string and dateTime fieldsRe-test the searches your clients depend on
Storage limitMore than 4 TB4 TB by default; a support request for moreAbove 2 TB, open a support request before starting
ScalingAutoscale on request, at a chargeAutoscale by defaultRemove throughput tuning from runbooks
EventsNot supportedSupportedReplaces the FHIR Proxy pattern, which is being deprecated
RecoveryCross-region disaster recovery, customer-managed keysPoint-in-time restore, availability zones, customer-managed keysRe-state your recovery objectives against the new options
IoT connectorSupportedNot supported on this servicePlan the MedTech service separately

The rows come from Microsoft’s migration guidance; the last column is mine. Three things in it are not copied by any tool: server settings, custom search parameters and who is allowed to do what. Each has to be re-created on the target and checked.

Choosing a pattern

Lift and shiftIncremental copy
How it worksStop writes, export everything, import, repoint clientsCopy a baseline while the source stays live, then copy what changed, repeat until the gap is small, then cut over
DowntimeThe whole export and importThe last delta and the validation
AdvantagesOne pass. Simple to reason about and to validateShort write freeze. Several rehearsals of the same procedure before the real one
DisadvantagesDowntime grows with data volumeMore moving parts. Hard deletes on the source during the copy are not carried across
CostExport throughput on the source; initial-mode import is not chargedExport throughput for every pass; incremental-mode import is charged per resource
FitsSmall datasets, or systems that can be offline for a nightAnything clinical users write to during the day

Microsoft also publishes an open-source migration tool that automates the incremental pattern with Azure Functions. Read its list of limitations before choosing it or building your own: it copies resources only, not configuration, and it does not carry hard deletes that happen on the source after the first pass. That last limit applies to any approach built on export, including the script below.

For a clinical system the incremental pattern is the safer choice. It turns the migration into something you have already done several times by the night it counts.

The data path: export, then import

%%{init: {'theme':'base','themeVariables':{'primaryColor':'#FFF3E0','primaryTextColor':'#232F3E','primaryBorderColor':'#FF9900','lineColor':'#232F3E','signalColor':'#0073BB','signalTextColor':'#0F172A','actorBkg':'#FFF3E0','actorBorder':'#FF9900','actorTextColor':'#232F3E','noteBkgColor':'#FFF8E7','noteBorderColor':'#FF9900','noteTextColor':'#232F3E'}}}%%
sequenceDiagram
    participant S as Migration script
    participant A as Azure API for FHIR
    participant B as Blob storage
    participant T as FHIR service
    S->>A: GET $export with _container and _till
    A-->>S: 202 and a status URL
    A->>B: Write NDJSON files, one type per file
    S->>A: Poll status until 200
    A-->>S: Manifest listing each file
    S->>T: POST $import with one input per file
    T-->>S: 202 and a status URL
    T->>B: Read files with its managed identity
    S->>T: Poll status until 200
    T-->>S: Counts loaded, link to error files

Three properties of these operations shape the script.

  • Export. A system-level $export accepts _since and _till, which together give a bounded window, and includeAssociatedData=_history,_deleted to include earlier versions and soft-deleted resources. Files hold one resource type each. On a database with autoscale enabled, a large export raises request-unit consumption, and with it the bill.
  • Import modes. InitialLoad is for an empty server: it is not charged, it blocks other writes while it runs, keeps only the latest version of each resource and stamps lastUpdated with the import time. IncrementalLoad runs alongside normal traffic, keeps lastUpdated and versionId from the files, accepts history and soft deletes, and is charged per resource at API rates.
  • Import limits. One import accepts at most 10,000 files. SearchParameter resources cannot be imported, and neither can conditional references. A fatal error does not roll back what was already loaded, and registering the same files again is idempotent.

The script puts those together. Run it once with --till for the baseline, and again with --since set to the previous cut-off for each delta.

"""Lift-and-shift from Azure API for FHIR to the Azure Health Data Services FHIR service.

    1. $export on the source, into a container of the storage account configured for export.
    2. Turn the export manifest into an $import request for the target.
    3. $import on the target and report what was loaded and what was rejected.

    python migrate.py --source https://old.azurehealthcareapis.com \
                      --target https://ws-fhir.fhir.azurehealthcareapis.com \
                      --container migration-2026-08 --till 2026-08-22T22:00:00Z --history --deleted

Run it again with --since set to the previous --till to copy what changed in between.
Nothing is deleted or modified on the source. Hard deletes on the source are not carried across by any
export: track them separately and apply them to the target by hand.
"""
from __future__ import annotations

import argparse
import json
import logging
import sys
import time
from typing import Callable

import requests

log = logging.getLogger("migrate")
TokenProvider = Callable[[str], str]


class MigrationError(RuntimeError):
    """The operation stopped. The message is safe to log: it never contains a response body."""


def azure_token_provider() -> TokenProvider:
    """Tokens from the signed-in identity (az login, managed identity, workload identity)."""
    from azure.identity import DefaultAzureCredential

    credential = DefaultAzureCredential()
    return lambda base_url: credential.get_token(base_url.rstrip("/") + "/.default").token


class FhirOperations:
    """The two asynchronous operations a lift-and-shift needs, with polling."""

    def __init__(self, base_url: str, token_provider: TokenProvider, *, poll_seconds: float = 30,
                 timeout_seconds: float = 24 * 3600, session: requests.Session | None = None) -> None:
        self.base_url = base_url.rstrip("/")
        self._token = token_provider
        self._poll = poll_seconds
        self._timeout = timeout_seconds
        self._http = session or requests.Session()

    def _headers(self, **extra: str) -> dict[str, str]:
        # A fresh token for every request: a large export or import outlives one token.
        return {"Authorization": f"Bearer {self._token(self.base_url)}", **extra}

    def start_export(self, container: str, *, since: str | None = None, till: str | None = None,
                     types: list[str] | None = None, history: bool = False, deleted: bool = False) -> str:
        params: dict[str, str] = {"_container": container}
        if since:
            params["_since"] = since
        if till:
            params["_till"] = till          # system-level export only; gives a snapshot as of that time
        if types:
            params["_type"] = ",".join(types)
        associated = [name for name, wanted in (("_history", history), ("_deleted", deleted)) if wanted]
        if associated:
            params["includeAssociatedData"] = ",".join(associated)
        response = self._http.get(f"{self.base_url}/$export", params=params, timeout=120,
                                  headers=self._headers(Accept="application/fhir+json", Prefer="respond-async"))
        return self._status_url(response, "$export")

    def start_import(self, parameters: dict) -> str:
        response = self._http.post(f"{self.base_url}/$import", json=parameters, timeout=120,
                                   headers=self._headers(Prefer="respond-async", **{"Content-Type": "application/fhir+json"}))
        return self._status_url(response, "$import")

    def wait(self, status_url: str, what: str) -> dict:
        """Poll until the operation finishes. 202 means still running, 200 carries the result."""
        deadline = time.monotonic() + self._timeout
        while True:
            response = self._http.get(status_url, headers=self._headers(), timeout=120)
            if response.status_code == 200:
                return response.json()
            if response.status_code not in (202, 429, 503):
                raise MigrationError(f"{what} failed with HTTP {response.status_code}. "
                                     "Resources already imported are not rolled back.")
            if time.monotonic() > deadline:
                raise MigrationError(f"{what} did not finish within {self._timeout:.0f} seconds; it is still running")
            retry_after = response.headers.get("Retry-After", "")
            time.sleep(min(float(retry_after), 300) if retry_after.isdigit() else self._poll)

    @staticmethod
    def _status_url(response: requests.Response, what: str) -> str:
        location = response.headers.get("Content-Location")
        if response.status_code != 202 or not location:
            raise MigrationError(f"{what} was not accepted: HTTP {response.status_code}")
        return location


def build_import_parameters(export_manifest: dict, mode: str = "IncrementalLoad", *,
                            max_files: int = 10_000) -> dict:
    """Turn an export manifest into the Parameters resource $import expects.

    IncrementalLoad keeps each resource's lastUpdated and versionId and accepts history and soft deletes.
    InitialLoad is free of charge but blocks writes on the target and keeps only the latest version.
    """
    if mode not in ("IncrementalLoad", "InitialLoad"):
        raise ValueError("mode must be IncrementalLoad or InitialLoad")
    if export_manifest.get("error"):
        raise MigrationError(f"The export reported {len(export_manifest['error'])} error file(s); resolve them before importing")
    files = export_manifest.get("output") or []
    if not files:
        raise MigrationError("The export produced no files")
    if len(files) > max_files:
        raise MigrationError(f"{len(files)} files exceed the {max_files} files one import accepts; split the export")
    # SearchParameter resources cannot be imported. They are recreated on the target and reindexed.
    skipped = [f["url"] for f in files if f.get("type") == "SearchParameter"]
    if skipped:
        log.warning("Leaving out %d SearchParameter file(s); recreate custom search parameters on the target", len(skipped))
    parameter: list[dict] = [
        {"name": "inputFormat", "valueString": "application/fhir+ndjson"},
        {"name": "mode", "valueString": mode},
    ]
    for item in files:
        if item.get("type") == "SearchParameter":
            continue
        parameter.append({"name": "input", "part": [
            {"name": "type", "valueString": item["type"]},
            {"name": "url", "valueUri": item["url"]},
        ]})
    return {"resourceType": "Parameters", "parameter": parameter}


def summarise_import(result: dict) -> dict:
    """Counts per resource type, and where the error files are. No resource content."""
    loaded: dict[str, int] = {}
    for item in result.get("output") or []:
        loaded[item.get("type") or "unknown"] = loaded.get(item.get("type") or "unknown", 0) + int(item.get("count", 0))
    failed = [{"type": e.get("type"), "count": int(e.get("count", 0)), "inputUrl": e.get("inputUrl"), "errorFile": e.get("url")}
              for e in result.get("error") or []]
    return {"loaded": loaded, "loadedTotal": sum(loaded.values()),
            "failedTotal": sum(f["count"] for f in failed), "failed": failed}


def main(argv: list[str] | None = None, token_provider: TokenProvider | None = None) -> int:
    parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    parser.add_argument("--source", required=True, help="Azure API for FHIR base URL")
    parser.add_argument("--target", required=True, help="Azure Health Data Services FHIR service base URL")
    parser.add_argument("--container", required=True, help="container in the export storage account")
    parser.add_argument("--since", help="only resources changed at or after this instant")
    parser.add_argument("--till", help="only resources changed up to this instant")
    parser.add_argument("--types", help="comma-separated resource types; default is everything")
    parser.add_argument("--history", action="store_true", help="include earlier versions")
    parser.add_argument("--deleted", action="store_true", help="include soft-deleted resources")
    parser.add_argument("--mode", default="IncrementalLoad", choices=["IncrementalLoad", "InitialLoad"])
    parser.add_argument("--poll-seconds", type=float, default=30)
    parser.add_argument("--export-only", action="store_true", help="stop after the export and print the import request")
    args = parser.parse_args(argv)
    logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
    if args.mode == "InitialLoad" and (args.history or args.deleted):
        parser.error("InitialLoad keeps only the latest version: use IncrementalLoad with --history or --deleted")

    tokens = token_provider or azure_token_provider()
    source = FhirOperations(args.source, tokens, poll_seconds=args.poll_seconds)
    target = FhirOperations(args.target, tokens, poll_seconds=args.poll_seconds)
    try:
        status = source.start_export(args.container, since=args.since, till=args.till, history=args.history,
                                     deleted=args.deleted, types=args.types.split(",") if args.types else None)
        log.info("Export accepted")
        manifest = source.wait(status, "$export")
        log.info("Export finished: %d file(s)", len(manifest.get("output") or []))
        parameters = build_import_parameters(manifest, args.mode)
        if args.export_only:
            print(json.dumps(parameters, indent=2))
            return 0
        status = target.start_import(parameters)
        log.info("Import accepted in %s mode", args.mode)
        summary = summarise_import(target.wait(status, "$import"))
    except (MigrationError, requests.RequestException) as error:
        log.error("%s", error if isinstance(error, MigrationError) else type(error).__name__)
        return 2
    print(json.dumps(summary, indent=2))
    return 1 if summary["failedTotal"] else 0


if __name__ == "__main__":
    sys.exit(main())

Its tests run against a small local server that plays both services. They check that the export request carries the container, the window and the history flags; that the import request is a Parameters resource with one input per exported file and no SearchParameter file; that both operations are polled to completion; that an export with errors stops before anything is imported; and that rejected resources produce a non-zero exit code with the location of the error file. Sixteen checks pass. None of them is a substitute for one rehearsal against real services.

⚠️
A HARD DELETE ON THE SOURCE NEVER REACHES THE TARGET

Export has no record of a resource that was hard-deleted, so a record erased from the old server during the migration window stays on the new one. If the deletion was a patient’s erasure request, that is a compliance failure created by the migration. Freeze hard deletes for the window, or log each one and replay it on the target before go-live.

Identity and endpoints

Data is the part of the migration with a progress bar. Identity is the part that fails at go-live, because it is spread across every client and nobody owns the whole list.

  • Local RBAC does not exist on the target. If the old service authorises callers through a list of allowed object ids, or trusts a second tenant as a custom authority, none of that carries over. The new service accepts tokens only from the Microsoft Entra tenant it runs in, and decides access by Azure role assignment.
  • Map each caller to a built-in role. FHIR Data Reader for read and search, FHIR Data Writer for read, write and soft delete, FHIR Data Contributor for every data-plane operation, FHIR Data Exporter and FHIR Data Importer for the bulk operations, FHIR SMART User for SMART on FHIR access. Assign the narrowest one that works; a migration is a good moment to find the service principal that has been a contributor for years and only ever reads.
  • The token audience changes with the URL. A client that requests a token for the old endpoint gets one the new service rejects. The audience is configuration in some clients and a hard-coded string in others, and the second kind is found by searching code, not by reading a settings page.
  • Role assignments take time to apply. Microsoft’s guidance is to allow a few minutes. Create them days before the cut-over, not during it.
  • Network paths are per service. Private endpoints, DNS zones and firewall allow-lists that name the old host all need a twin for the new one, and the storage account used for export and import has to be reachable by both services’ managed identities.

Build the list of callers from evidence. Diagnostic logs on the old service show which application ids have called it; that list is more reliable than the architecture diagram.

Proving the copy

An import that reports no errors tells you every file was read. It does not tell you the target holds what the source holds: a file can be missing from the manifest, a delta window can leave a gap, a resource can be changed on the source after it was exported. The check has to compare the two servers directly.

The tool below does three comparisons, each cheap enough to run in the cut-over window: the FHIR version both servers report, the number of resources of each type, and a field-by-field comparison of the most recently updated resources of each type, which are the ones an incremental copy is most likely to have missed. It sends only GET requests, and its report contains resource ids and element paths but never values, so the report can be attached to a change record without becoming a clinical document.

using System.Text.Json.Nodes;

namespace FhirMigrationValidator;

public sealed record ValidationOptions(
    IReadOnlyList<string> ResourceTypes, int SampleSize, bool CompareVersions);

public sealed record TypeResult(
    string ResourceType, int SourceCount, int TargetCount, int Sampled,
    IReadOnlyList<string> MissingOnTarget, IReadOnlyDictionary<string, IReadOnlyList<string>> Different)
{
    public bool Passed => SourceCount == TargetCount && MissingOnTarget.Count == 0 && Different.Count == 0;
}

public sealed record ValidationReport(
    DateTimeOffset StartedAt, string SourceFhirVersion, string TargetFhirVersion, IReadOnlyList<TypeResult> Types)
{
    public bool Passed => SourceFhirVersion == TargetFhirVersion && Types.All(type => type.Passed);

    public JsonObject ToJson() => new()
    {
        ["startedAt"] = StartedAt.ToString("O"),
        ["passed"] = Passed,
        ["sourceFhirVersion"] = SourceFhirVersion,
        ["targetFhirVersion"] = TargetFhirVersion,
        ["types"] = new JsonArray([.. Types.Select(type => new JsonObject
        {
            ["resourceType"] = type.ResourceType,
            ["passed"] = type.Passed,
            ["sourceCount"] = type.SourceCount,
            ["targetCount"] = type.TargetCount,
            ["sampled"] = type.Sampled,
            ["missingOnTarget"] = new JsonArray([.. type.MissingOnTarget.Select(id => (JsonNode)id)]),
            ["different"] = new JsonObject(type.Different.Select(pair => KeyValuePair.Create(
                pair.Key, (JsonNode?)new JsonArray([.. pair.Value.Select(path => (JsonNode)path)])))),
        })]),
    };
}

/// <summary>
/// Compares a migrated FHIR server with its source: FHIR version, a count per resource type, and a
/// field-by-field comparison of the most recently updated resources of each type.
/// </summary>
public sealed class MigrationValidator(FhirServer source, FhirServer target)
{
    public async Task<ValidationReport> RunAsync(ValidationOptions options, CancellationToken cancellationToken)
    {
        DateTimeOffset startedAt = DateTimeOffset.UtcNow;
        string sourceVersion = await FhirVersionAsync(source, cancellationToken);
        string targetVersion = await FhirVersionAsync(target, cancellationToken);

        IEnumerable<string> ignore = options.CompareVersions
            ? ResourceComparer.ServerAssigned.Where(path => path == "meta.source")
            : ResourceComparer.ServerAssigned;

        List<TypeResult> results = [];
        foreach (string type in options.ResourceTypes)
        {
            int sourceCount = await source.CountAsync(type, cancellationToken);
            int targetCount = await target.CountAsync(type, cancellationToken);

            // The newest resources are the ones an incremental copy is most likely to have missed.
            JsonObject page = await source.GetAsync($"{type}?_sort=-_lastUpdated&_count={options.SampleSize}", cancellationToken)
                ?? throw new FhirServerException($"{source.Name}: search on {type} returned nothing");

            List<string> missing = [];
            Dictionary<string, IReadOnlyList<string>> different = [];
            int sampled = 0;
            foreach (JsonNode? entry in page["entry"]?.AsArray() ?? [])
            {
                if (entry?["resource"] is not JsonObject original || (string?)original["id"] is not { } id)
                {
                    continue;
                }

                sampled++;
                JsonObject? copy = await target.GetAsync($"{type}/{Uri.EscapeDataString(id)}", cancellationToken);
                if (copy is null)
                {
                    missing.Add(id);
                    continue;
                }

                IReadOnlyList<string> paths = ResourceComparer.Differences(original, copy, ignore);
                if (paths.Count > 0)
                {
                    different[id] = paths;
                }
            }

            results.Add(new TypeResult(type, sourceCount, targetCount, sampled, missing, different));
        }

        return new ValidationReport(startedAt, sourceVersion, targetVersion, results);
    }

    private static async Task<string> FhirVersionAsync(FhirServer server, CancellationToken cancellationToken)
    {
        JsonObject capability = await server.GetAsync("metadata", cancellationToken)
            ?? throw new FhirServerException($"{server.Name}: no capability statement");
        return (string?)capability["fhirVersion"]
            ?? throw new FhirServerException($"{server.Name}: capability statement has no fhirVersion");
    }
}

The comparison ignores meta.versionId, meta.lastUpdated and meta.source by default, because an initial-mode import assigns new ones. After an incremental-mode import, which is meant to preserve version and timestamp, run it with --compare-versions so that a lost history shows up as a difference.

using System.Text.Json;
using System.Text.Json.Nodes;

namespace FhirMigrationValidator;

/// <summary>
/// Compares two copies of a resource and reports where they differ, as paths only.
/// Values are never written out, so the report contains no clinical content.
/// </summary>
public static class ResourceComparer
{
    /// <summary>Elements a migration is expected to change unless versions were carried across.</summary>
    public static readonly IReadOnlyList<string> ServerAssigned = ["meta.versionId", "meta.lastUpdated", "meta.source"];

    /// <param name="ignore">Dotted paths of object members to leave out of the comparison on both sides.</param>
    public static IReadOnlyList<string> Differences(JsonObject source, JsonObject target, IEnumerable<string> ignore, int limit = 10)
    {
        JsonObject left = Without(source, ignore), right = Without(target, ignore);
        List<string> found = [];
        Walk(left, right, "", found, limit);
        return found;
    }

    private static JsonObject Without(JsonObject resource, IEnumerable<string> ignore)
    {
        JsonObject copy = resource.DeepClone().AsObject();
        foreach (string path in ignore)
        {
            string[] parts = path.Split('.');
            JsonObject? parent = copy;
            foreach (string part in parts[..^1])
            {
                parent = parent?[part] as JsonObject;
            }

            parent?.Remove(parts[^1]);
        }

        // A meta element left with nothing in it is the same as no meta element.
        if (copy["meta"] is JsonObject { Count: 0 })
        {
            copy.Remove("meta");
        }

        return copy;
    }

    private static void Walk(JsonNode? a, JsonNode? b, string path, List<string> found, int limit)
    {
        if (found.Count >= limit)
        {
            return;
        }

        switch (a, b)
        {
            case (JsonObject left, JsonObject right):
                foreach (string key in left.Select(p => p.Key).Union(right.Select(p => p.Key)).Order(StringComparer.Ordinal))
                {
                    Walk(left[key], right[key], path.Length == 0 ? key : $"{path}.{key}", found, limit);
                }

                break;

            case (JsonArray left, JsonArray right) when left.Count == right.Count:
                for (int i = 0; i < left.Count; i++)
                {
                    Walk(left[i], right[i], $"{path}[{i}]", found, limit);
                }

                break;

            case (JsonArray left, JsonArray right):
                found.Add($"{path} (array length {left.Count} on source, {right.Count} on target)");
                break;

            case (null, null):
                break;

            case (JsonValue left, JsonValue right) when SameValue(left, right):
                break;

            default:
                found.Add(a is null ? $"{path} (only on target)" : b is null ? $"{path} (only on source)" : path);
                break;
        }
    }

    // 120 and 120.0 are the same number in JSON. "120" and 120 are not the same value.
    private static bool SameValue(JsonValue a, JsonValue b) =>
        a.GetValueKind() == b.GetValueKind()
        && (a.GetValueKind() == JsonValueKind.Number
            ? a.GetValue<decimal>() == b.GetValue<decimal>()
            : a.ToJsonString() == b.ToJsonString());
}

Both servers throttle, and at least one FHIR server I tested answers searches from a cache, so the small class that talks to them matters more than its size suggests.

using System.Net;
using System.Net.Http.Headers;
using System.Text.Json.Nodes;

namespace FhirMigrationValidator;

/// <summary>A read-only view of one FHIR server. It never sends anything but GET.</summary>
public sealed class FhirServer(HttpClient http, string name)
{
    private const int MaxAttempts = 5;

    public string Name { get; } = name;

    public static FhirServer Create(string name, Uri baseUrl, string? bearerToken, HttpMessageHandler? handler = null)
    {
        HttpClient http = handler is null ? new HttpClient() : new HttpClient(handler, disposeHandler: false);
        // A trailing slash keeps relative requests under the base path.
        http.BaseAddress = new Uri(baseUrl.ToString().TrimEnd('/') + "/");
        http.Timeout = TimeSpan.FromSeconds(100);
        http.DefaultRequestHeaders.Accept.ParseAdd("application/fhir+json");
        // Ask for a fresh answer. Some servers reuse a recent search result, and a count taken straight
        // after a load can then be a minute out of date.
        http.DefaultRequestHeaders.CacheControl = new CacheControlHeaderValue { NoCache = true };
        if (!string.IsNullOrEmpty(bearerToken))
        {
            http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", bearerToken);
        }

        return new FhirServer(http, name);
    }

    /// <summary>Returns the resource, or null when the server answers 404 or 410.</summary>
    public async Task<JsonObject?> GetAsync(string relativeUrl, CancellationToken cancellationToken)
    {
        for (int attempt = 1; ; attempt++)
        {
            using HttpResponseMessage response = await http.GetAsync(relativeUrl, cancellationToken);
            if (response.StatusCode is HttpStatusCode.NotFound or HttpStatusCode.Gone)
            {
                return null;
            }

            // Both Azure services throttle with 429. Wait as told, a bounded number of times.
            bool retryable = response.StatusCode is HttpStatusCode.TooManyRequests or HttpStatusCode.ServiceUnavailable;
            if (retryable && attempt < MaxAttempts)
            {
                TimeSpan wait = response.Headers.RetryAfter?.Delta ?? TimeSpan.FromSeconds(Math.Pow(2, attempt));
                await Task.Delay(wait > TimeSpan.FromSeconds(60) ? TimeSpan.FromSeconds(60) : wait, cancellationToken);
                continue;
            }

            if (!response.IsSuccessStatusCode)
            {
                throw new FhirServerException($"{Name}: GET {relativeUrl} returned HTTP {(int)response.StatusCode}");
            }

            string body = await response.Content.ReadAsStringAsync(cancellationToken);
            return JsonNode.Parse(body) as JsonObject
                ?? throw new FhirServerException($"{Name}: GET {relativeUrl} did not return a JSON object");
        }
    }

    public async Task<int> CountAsync(string resourceType, CancellationToken cancellationToken)
    {
        JsonObject bundle = await GetAsync($"{resourceType}?_summary=count", cancellationToken)
            ?? throw new FhirServerException($"{Name}: {resourceType} is not a known resource type");
        return (int?)bundle["total"]
            ?? throw new FhirServerException($"{Name}: count for {resourceType} has no total");
    }
}

public sealed class FhirServerException(string message) : Exception(message);

What the rehearsal showed

The tool was run in Docker against two HAPI FHIR servers: a source holding 5 patients, 15 observations and 5 medication requests, all synthetic, and an empty target that I then loaded with two deliberate faults, one observation left out and one blood pressure value changed. The tool ran every twenty seconds while the target changed underneath it.

State of the targetWhat the tool reported
Still startingExit code 2: could not complete the comparison, connection refused. No report written
Running and emptyFail on all three types: counts 5, 15, 5 against 0, 0, 0, and every sampled resource missing
Loaded with the two faults, 22 seconds earlierThe sample was right: one observation missing by id, one differing at component[0].valueQuantity.value. The counts were wrong: the target still reported 0 for every type
The same, a minute laterCounts 5, 14, 5. The same two findings
Faults corrected, 10 seconds earlierSample clean, nothing missing. Observation count still 14, so the run failed
The same, 74 seconds after the correctionPass: 5, 15, 5 on both sides, 25 resources sampled, no differences

The third and fifth rows are the finding. The target answered _summary=count from a cached search result for about a minute after the data changed, while reads by id were current. In this rehearsal the stale count made a correct copy look incomplete, which costs time. The opposite case is the dangerous one: a stale count that still matches the source after the target has lost something.

Sending Cache-Control: no-cache changed the answer. Seconds after the correction, the same count request returned 14 without the header and 15 with it. The tool now sends that header on every request, and its self-test checks that it does. That change was made after the Docker run above, so the table shows the tool without it. I have not established whether either Azure service caches counts in a comparable way. The lesson does not depend on it: when a count and a sample disagree, wait and run again, and never accept a count taken in the same minute as the load.

📝
UPDATE, NOVEMBER 2026

The rehearsal described here was repeated on 12 November 2026 with HAPI FHIR 8.6.0 as both source and target. The table and the cache finding are from that run.

💡
HOW THIS WAS CHECKED

Python: 16 checks pass against a local stand-in for both services, written from Microsoft’s documentation of the request and response shapes. C#: the validation tool compiled with warnings as errors on the .NET 10 SDK and passes 14 self-test checks with two in-memory FHIR servers, including throttling, a server error, and a sample too small to catch a missing resource. It was then built on the .NET 10 SDK image and run against two real FHIR servers, with the results above. Not checked: anything against Azure API for FHIR or the Azure Health Data Services FHIR service, import and export at real data volumes, role assignments, private networking, and the runbook as a whole. The listings are the files that ran, fetched from the repository commit, apart from the cache header noted above.

⚠️
A PASSING SAMPLE IS NOT PROOF

Equal counts and a clean sample of the newest fifty resources leave room for an older resource that arrived damaged, and for a missing resource balanced by an extra one. The self-test includes exactly that case: with a sample too small to reach the missing resource, the tool passes. Size the sample to the risk, sample by other orders as well as newest-first, and for a record that must be complete, compare a full export of each side.

A cut-over runbook

WhenWhatDone when
Three weeks outInventory callers from diagnostic logs, server settings and custom search parameters. Deploy the target with the FHIR version, versioning policy and encryption choice made up front. Create role assignments, the storage account and both managed-identity permissions. Above 2 TB, open the support requestEvery caller has a role on the target and a named owner
Two weeks outBaseline copy with --till. Re-create custom search parameters and reindex. Run the validator. Point a non-production copy of each client at the targetValidator passes; each client has read and written against the target
One week outFull rehearsal: delta copy, validation, client switch, smoke tests, switch back. Time every stepThe rehearsal fits the window with margin, and the rollback was exercised
Cut-overThe sequence in the diagram belowWriters are open on the target
The week afterWatch error rates and throttling. Re-create scheduled export jobs. Delete the intermediate storage. Decide what to keep from the old service’s logs, then decommission it before 30 SeptemberThe old service is deleted and nothing noticed
%%{init: {'theme':'base','themeVariables':{'primaryColor':'#FFF3E0','primaryTextColor':'#232F3E','primaryBorderColor':'#FF9900','lineColor':'#232F3E','clusterBkg':'#F8FAFC','clusterBorder':'#CBD5E1','edgeLabelBackground':'#FFFFFF','nodeTextColor':'#232F3E'}}}%%
flowchart TD
    A[Stop writers and hard deletes on the source] --> B[Run the final delta: export with _since, then import]
    B --> C{Import finished
with no error files?} C -- "No" --> R1[Fix and re-import the failed files] R1 --> C C -- "Yes" --> D{Validator passes on
counts, sample and versions?} D -- "No" --> R2[Stay on the source, reopen writers, investigate] D -- "Yes" --> E[Repoint clients to the new endpoint] E --> F{Smoke tests pass:
read, search, write?} F -- "No" --> R3[Repoint clients back to the source] F -- "Yes" --> G[Open writers on the target] G --> H[Point of no return: first clinical write on the target] classDef ok fill:#F0FFF4,stroke:#1D8102,stroke-width:2px,color:#145202 classDef bad fill:#FEF2F2,stroke:#D13212,stroke-width:2px,color:#991B1B classDef gate fill:#EBF8FF,stroke:#0073BB,stroke-width:2px,color:#004C74 class G,H ok class R1,R2,R3 bad class C,D,F gate

The rollback rule is the important part of that diagram. Until the first clinical write lands on the target, going back is a configuration change: repoint the clients and reopen the writers. After it, the source is behind, and Azure API for FHIR has no $import to catch it up with. From that point the plan is to fix forward. Say so in the change record, and put the smoke tests before the point of no return, not after it.

Freezing the source deserves a sentence of its own. I know of no read-only switch on the old service, so the freeze is done at the callers: stop the integration engines, and remove write roles from everything else for the window. Verify it by checking that the count of recently updated resources stops moving.

Timeline of the migration in five stages: three weeks out prepare, two weeks out baseline copy, one week out rehearsal, cut-over night, and the week after. The cut-over stage is highlighted, with a note that the point of no return is the first clinical write accepted by the new service.
The runbook as a timeline. Everything before the highlighted stage can be repeated.

Cost

Four things drive the cost of this migration, and three of them grow with how long the project runs. Export consumes throughput on the old service, on every pass, and autoscale will raise the bill while it runs. Incremental-mode import is charged per resource; initial-mode import is not, which is an argument for loading the baseline in initial mode before the target takes any traffic and using incremental mode only for the deltas. The intermediate storage holds a full copy of the data for the length of the project. And both services run side by side from the baseline until decommissioning, so the overlap is a cost you control by not letting the project drift.

Risks and how to hold them

RiskWhy it happensMitigation
A client still calls the old endpointHard-coded URL or token audienceSearch code and configuration for the old host name. After cut-over, watch the old service’s logs for any request
A caller is refused on the targetLocal RBAC entry with no equivalent role assignmentBuild the caller list from logs, not from memory. Test each one in the rehearsal
Search behaves differentlyCustom search parameters not re-created or not reindexed; different defaultsRe-create and reindex before validation. Re-run each client’s real queries
Deleted data reappearsHard deletes during the window are not exportedFreeze hard deletes, or log and replay them
History is lostInitial-mode import keeps the latest version onlyDecide early whether history must move. If it must, export with _history and import incrementally
The copy is incomplete and nobody noticesNo independent comparisonRun the validator as a go or no-go gate, with a sample sized to the risk
The window is too shortExport and import times were estimated, not measuredRehearse with production-sized data and time each step
⚖️
THE MIGRATION CREATES A THIRD COPY OF THE RECORD

For the length of the project, the export container holds every resource in plain NDJSON. Treat it as a clinical system: private network access only, access limited to the two managed identities and the migration operator, encryption with the same key policy as the FHIR services, and deletion as a tracked step with evidence. Keep the target in the same region as the source unless your data-residency position has been reviewed. Diagnostic and audit logs of the old service do not migrate; export what your retention policy requires before decommissioning. Update the record of processing and the DPIA to name the new service.

Key takeaways

  • The retirement date is 30 September 2026. Plan for at least one full rehearsal before it, with production-sized data.
  • Use initial-mode import for the baseline into an empty target and incremental mode for deltas, history and soft deletes.
  • Identity is the part most likely to fail on the night: local RBAC and custom authorities do not exist on the target, and the token audience changes with the URL.
  • Compare the two servers directly before repointing clients. An import with no errors is not evidence that the copy is complete.
  • Decide the point of no return in advance. After the first clinical write on the target there is no supported way to bring the source back up to date.

If you have already made this move, the detail worth sharing in the comments is what your rehearsal found that the documentation did not mention.

Glossary

  • Azure API for FHIR— Microsoft’s first managed FHIR service, retiring on 30 September 2026.
  • Azure Health Data Services FHIR service— Its successor, deployed inside a workspace alongside DICOM and MedTech services.
  • $export— The FHIR bulk data operation that writes resources to storage as NDJSON files.
  • $import— The FHIR service operation that loads NDJSON files from blob storage.
  • Initial mode— An import mode for an empty server: not charged, blocks other writes, keeps only the latest version.
  • Incremental mode— An import mode that runs beside normal traffic and preserves version and timestamp.
  • Local RBAC— Authorization in Azure API for FHIR by a list of allowed object ids, optionally from another tenant.
  • Managed identity— An Azure identity owned by a service, used here by each FHIR service to reach the storage account.
  • NDJSON— Newline-delimited JSON: one resource per line.
  • Soft delete— A delete that leaves earlier versions in history; a hard delete removes them.
  • Point of no return— The moment after which rolling back would lose data, here the first clinical write on the target.

References & Further Reading


Discover more from C4: Container, Code, Cloud & Context

Subscribe to get the latest posts sent to your email.

Leave a comment

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.