5 min read

When a FastAPI Upgrade Becomes a Platform Migration

A practical EDVM case study on FastAPI version bounds, compatibility wrappers, request telemetry, and phased rollout control in a healthcare platform.

When a FastAPI Upgrade Becomes a Platform Migration

Not every risky migration looks risky at first.

EDVM was brought in to help a large healthcare software company handle what initially looked like a modest FastAPI upgrade. The company operates at meaningful scale in the U.S. home-based care sector, serving roughly 350 agencies and supporting well over 100 million documented visits per year. In that environment, a narrow framework behavior change can turn into an operational problem quickly.

The trigger was FastAPI 0.132.0, which introduced stricter JSON Content-Type validation by default. Requests with JSON bodies now need a valid JSON Content-Type header, such as application/json, or FastAPI can reject them before application code handles the request.

That is a good default. It makes the HTTP boundary more explicit and reduces ambiguity around request parsing.

It is also the kind of change that can break real integrations. In a multi-repository platform with internal services, older clients, external integrations, and independently deployed APIs, the hard part is not only upgrading the framework. The hard part is controlling when each service crosses the behavior boundary and how legacy callers are detected before compatibility is removed.

The actual migration problem

The question was not simply:

Should we upgrade FastAPI?

The real question was:

How do we pin, audit, upgrade, observe, and finally enforce a framework behavior change across several repositories without breaking clients that still depend on legacy request behavior?

That required five separate controls:

  1. identify every repository exposed to the FastAPI behavior change,
  2. prevent accidental upgrades while the audit was in progress,
  3. separate request surfaces that were safe to enforce from those that still needed compatibility,
  4. preserve backward compatibility for clients omitting the expected header,
  5. collect enough telemetry to retire compatibility mode with evidence instead of guesswork.

The fifth point mattered as much as the first four. Compatibility without telemetry only hides the problem. It keeps traffic flowing, but it does not tell the platform team which clients still need to change.

Version bounds are part of architecture

One of the clearest lessons from this work is that dependency policy is architecture, not housekeeping.

In Python services, it is common to see framework dependencies declared with broad lower bounds:

[project]
dependencies = [
  "fastapi>=0.110",
]

That looks reasonable when the dependency is stable and upgrades are routine. It is risky when a later version changes behavior at an API boundary. The resolver can legally select a version the service has never been reviewed against.

During containment, we prefer an explicit pin:

[project]
dependencies = [
  "fastapi==0.131.0",
]

Or, when the team wants controlled flexibility inside an approved line:

[project]
dependencies = [
  "fastapi>=0.131,<0.132",
]

Those declarations express different operational intent:

  • == means “do not move until we intentionally change this.”
  • <0.132 means “patch movement inside the approved line is acceptable, but do not cross this behavior boundary.”

That distinction becomes important when several repositories deploy independently. A platform team cannot control rollout risk if every service is allowed to drift into a new behavior on its own schedule.

Building the dependency inventory

Before changing application code, we built an inventory.

In a multi-repository environment, the first mistake is assuming you already know where a dependency lives. FastAPI might appear in application services, internal APIs, integration adapters, background workers with admin endpoints, template repositories, and shared packages used by newer services.

The audit started by searching dependency declarations and lock files across the portfolio. The goal was not only to find repositories already using FastAPI, but also repositories whose version bounds would allow them to resolve into the breaking version during the next install or lock refresh.

A simplified search looks like this:

rg -n 'fastapi' . \
  -g 'pyproject.toml' \
  -g 'requirements*.txt' \
  -g 'poetry.lock' \
  -g 'uv.lock'

The raw list is only the first step. The useful work is classification:

  • repositories already pinned below the behavior boundary,
  • repositories with loose bounds and therefore exposed on the next dependency refresh,
  • repositories already upgraded,
  • repositories that inherited FastAPI through shared internal packages,
  • repositories with externally consumed endpoints and higher compatibility risk.

That turned a vague platform concern into a concrete migration board. Each repository had an owner, a current dependency state, a compatibility status, and a path to enforcement.

Containment before migration

The first wave was containment.

We did not treat each repository as a separate debate. That creates version drift, duplicated reasoning, and rollout confusion. Instead, we applied the same temporary version policy across affected repositories so the platform would stop moving while the audit was running.

The temporary rule was simple:

  • pin unaudited services to a known-safe FastAPI version,
  • block dependency resolution past the behavior boundary,
  • refresh lock files so CI reflected the intended state,
  • unpin or widen only after the service completed compatibility review.

This phase does not finish the migration, but it prevents the migration from happening accidentally. That is the part many teams skip. They start testing one service while another service quietly resolves a newer framework version and ships a different behavior to production.

Compatibility is useful only when it is observable

The breaking change centered on JSON requests without a valid Content-Type header.

FastAPI provides an application-level compatibility setting:

from fastapi import FastAPI

app = FastAPI(strict_content_type=False)

This restores the older behavior for clients that send a JSON body without a Content-Type header. It is useful when some callers cannot be fixed immediately.

But this setting should not become a permanent blind spot. Disabling strict checking can be the right move during migration, but only if the platform also records which clients still depend on that compatibility path.

The pattern we used was a small wrapper around FastAPI application creation. It gave services a consistent place to configure temporary compatibility and a consistent place to add request diagnostics.

import logging
from collections.abc import Awaitable, Callable

from fastapi import FastAPI, Request, Response
from starlette.middleware.base import BaseHTTPMiddleware


logger = logging.getLogger("api.compatibility")


class JsonContentTypeAuditMiddleware(BaseHTTPMiddleware):
    async def dispatch(
        self,
        request: Request,
        call_next: Callable[[Request], Awaitable[Response]],
    ) -> Response:
        content_type = request.headers.get("content-type")
        content_length = request.headers.get("content-length", "0")
        has_json_candidate_body = (
            request.method in {"POST", "PUT", "PATCH"}
            and content_length != "0"
            and not content_type
        )

        if has_json_candidate_body:
            client_id = request.headers.get("x-client-id", "unknown")

            logger.warning(
                "legacy_json_content_type_detected",
                extra={
                    "client_id": client_id,
                    "method": request.method,
                    "path": request.url.path,
                    "content_type": None,
                },
            )

        return await call_next(request)


def create_app() -> FastAPI:
    app = FastAPI(strict_content_type=False)
    app.add_middleware(JsonContentTypeAuditMiddleware)
    return app

The important detail is what this code does not log. It does not record request bodies, patient data, payload fragments, authorization values, or anything that could contain protected health information. For a healthcare platform, the useful migration signal is metadata: client identity, method, route, service, and whether the expected header was missing.

That signal became a practical migration list. Instead of sending a generic message to every integration owner, the team could focus on clients actually observed sending legacy requests:

  • the client identifier,
  • the affected route,
  • the service receiving the request,
  • the request method,
  • the date the behavior was last observed.

This changed the rollout from broad warning to evidence-backed coordination.

Separating safe boundaries from risky ones

A dependency upgrade becomes risky when different API boundaries have different caller behavior.

We classified endpoints by caller ownership and migration control:

  • internally owned callers,
  • externally coordinated integrations,
  • legacy clients with incomplete header discipline,
  • workflows where rejection risk was unacceptable during the transition,
  • services where strict behavior could be enabled immediately.

That changed the review. We were no longer asking whether the whole platform was “ready” in the abstract. We were asking which boundaries were ready, which were not, and what policy each boundary required.

The technical review focused on:

  • request examples captured from tests and logs,
  • SDK or client code that built JSON requests,
  • proxy layers that might normalize, add, or drop headers,
  • endpoint groups most likely to receive traffic from older clients,
  • service-level tests proving strict behavior where enforcement was already safe.

Many compatibility bugs do not live in server code. They live in callers, API gateways, old integration scripts, test fixtures, and unofficial client implementations. A framework migration that ignores those edges is only partially audited.

The rollout plan

The migration became manageable once containment, compatibility, telemetry, and enforcement were treated as separate phases.

Phase 1: Contain

  • Pin FastAPI below the behavior-changing version across affected repositories.
  • Prevent resolver drift.
  • Build the dependency and service inventory.

Phase 2: Audit

  • Identify request surfaces that accept JSON bodies.
  • Check which callers send valid Content-Type headers.
  • Flag high-risk integrations and legacy clients.
  • Decide where compatibility mode is justified.

Phase 3: Upgrade and observe

  • Move audited services to the newer FastAPI release.
  • Use strict_content_type=False only where backward compatibility is still required.
  • Log or emit metrics for legacy requests missing the expected header.
  • Keep strict behavior enabled for services and endpoints that are already clean.

Phase 4: Notify and track

  • Notify only clients observed using legacy behavior.
  • Track non-compliant traffic by client, route, and service.
  • Confirm fixes through production telemetry, not assumptions.
  • Keep a visible migration burndown for the compatibility window.

Phase 5: Enforce

  • Fix or block remaining non-compliant clients.
  • Add tests that assert supported clients send Content-Type: application/json for JSON bodies.
  • Remove compatibility mode when the boundary is clean.
  • Keep the version bounds explicit so future upgrades cross behavior boundaries intentionally.

This approach is slower than blindly upgrading one repository. It is much faster than cleaning up a production incident spread across several services.

Hardening the pattern

The wrapper pattern worked because it preserved compatibility without sacrificing visibility. There are several ways to strengthen it further.

First, emit structured metrics in addition to logs. Logs are useful for investigation, but metrics make migration progress visible over time. A dashboard showing non-compliant request counts by client, route, and service gives platform owners a compatibility burndown.

Second, put compatibility behavior behind a feature flag. That allows teams to test strict behavior in staging, enable it for a canary service, or temporarily re-enable compatibility during a controlled rollback without changing application code.

Third, add a response-level deprecation signal for affected requests where appropriate. Direct client notification still matters, especially for external integrations, but a header such as Deprecation: true can help SDK owners and integration teams detect the issue in their own logs.

Fourth, include official SDKs and integration examples in the contract test suite. Server-side tests should verify strict behavior, but client-side tests should also prove that supported clients always send the correct headers when sending JSON bodies.

Finally, assign an owner and removal date to compatibility mode. Temporary compatibility becomes technical debt when it has no expiry. The temporary state should be visible in code, configuration, dashboards, and release planning.

What this revealed about dependency governance

The central lesson is that version bounds are part of system design.

Good pyproject.toml hygiene is not only about reproducible installs. It gives teams a language for rollout intent:

  • exact pins for freeze and containment,
  • bounded ranges for controlled flexibility,
  • explicit review before crossing known behavior boundaries,
  • lock-file refreshes that make CI test the intended dependency state.

In a multi-repository platform, that discipline reduces three common failure modes:

  • silent drift between services,
  • partial upgrades that create inconsistent behavior,
  • emergency debugging after a dependency resolver makes a decision no one reviewed.

What EDVM contributed here was not a one-line framework setting. It was a migration strategy built around operational control: portfolio-wide dependency discovery, coordinated version pinning, compatibility wrappers, telemetry for legacy clients, targeted client notification, and a phased path back to stricter defaults.

For teams operating complex healthcare systems, that is the difference between “we upgraded a dependency” and “we migrated a risk boundary under control.”

Public sources

FastAPI 0.132.0 release notes: FastAPI release notes

FastAPI strict JSON Content-Type behavior and compatibility setting: Strict Content-Type Checking

Want to build something similar?

If you are validating a product, modernizing operations, or shipping a technical MVP, we can help.

Start a Conversation