[{"data":1,"prerenderedAt":4744},["ShallowReactive",2],{"page-\u002Fscaling-and-operating-production-python-apis\u002Fversioning-and-evolving-public-apis\u002F":3,"faq-schema-\u002Fscaling-and-operating-production-python-apis\u002Fversioning-and-evolving-public-apis\u002F":4723},{"id":4,"title":5,"body":6,"description":4713,"extension":4714,"meta":4715,"navigation":301,"path":4719,"seo":4720,"stem":4721,"__hash__":4722},"content\u002Fscaling-and-operating-production-python-apis\u002Fversioning-and-evolving-public-apis\u002Findex.md","Versioning and Evolving Public APIs Without Losing Customers",{"type":7,"value":8,"toc":4698},"minimark",[9,13,23,31,34,39,52,140,143,236,243,384,386,390,406,571,574,1153,1168,1170,1174,1181,1645,1656,1791,1793,1797,1800,1917,1925,2466,2474,2476,2480,2483,2863,2870,2877,3209,3211,3215,3238,3544,3558,3650,3652,3656,3659,4066,4069,4197,4203,4205,4209,4350,4364,4366,4370,4388,4398,4412,4422,4435,4437,4441,4444,4554,4561,4568,4584,4586,4590,4598,4600,4604,4610,4616,4622,4628,4634,4636,4640,4645,4674,4679,4694],[10,11,5],"h1",{"id":12},"versioning-and-evolving-public-apis-without-losing-customers",[14,15,16,17,22],"p",{},"The moment a stranger's payment method is attached to your API, its response shape stops being your code and starts being your contract. This guide builds the machinery that lets you keep shipping anyway: a versioning scheme you can defend, a routing layout in FastAPI that isolates old behaviour from new, additive-change rules enforced by tests, deprecation and sunset headers that machines can read, and a migration campaign that actually moves customers off the old version instead of politely asking. Part of the ",[18,19,21],"a",{"href":20},"\u002Fscaling-and-operating-production-python-apis\u002F","Scaling and Operating Production Python APIs"," guide.",[14,24,25,26,30],{},"Two opinions up front, because half of versioning is picking a default and living with it. First, put the major version in the URL path and put everything smaller behind a dated request header — the path gives you clean routing, separate documentation and readable access logs, while the dated header lets you land a hundred small behaviour changes without ever minting ",[27,28,29],"code",{},"\u002Fv3",". Second, treat every version you ship as a maintenance liability with a price tag, and publish its sunset date on the day you release it. A version with no end date is a permanent tax on every future feature, and indie teams pay that tax out of the only budget that matters: the hours you have left this month.",[32,33],"hr",{},[35,36,38],"h2",{"id":37},"prerequisites","Prerequisites",[14,40,41,42,46,47,51],{},"This guide assumes Python 3.11 or newer and a ",[18,43,45],{"href":44},"\u002Fgetting-started-with-python-apis-for-builders\u002Fsetting-up-fastapi\u002F","FastAPI application"," on 0.115 or later, served by Uvicorn behind a reverse proxy or a platform router. You need an existing public API with at least one integrator, a database of accounts, and some form of request logging — the migration section leans on per-account version usage, which is exactly what ",[18,48,50],{"href":49},"\u002Fbuilding-monetizing-api-driven-micro-saas\u002Ftracking-api-usage-and-analytics\u002F","tracking API usage and analytics"," gives you. If you have no usage table yet, build that first; you cannot deprecate what you cannot measure.",[53,54,59],"pre",{"className":55,"code":56,"language":57,"meta":58,"style":58},"language-bash shiki shiki-themes github-light github-dark","pip install \\\n  \"fastapi>=0.115\" \\\n  \"uvicorn[standard]>=0.32\" \\\n  \"pydantic>=2.9\" \\\n  \"httpx>=0.28\" \\\n  \"sqlalchemy[asyncio]>=2.0.36\" \\\n  \"asyncpg>=0.30\" \\\n  \"pytest>=8.3\" \\\n  \"deepdiff>=8.0\"\n","bash","",[27,60,61,78,86,94,102,110,118,126,134],{"__ignoreMap":58},[62,63,66,70,74],"span",{"class":64,"line":65},"line",1,[62,67,69],{"class":68},"sScJk","pip",[62,71,73],{"class":72},"sZZnC"," install",[62,75,77],{"class":76},"sj4cs"," \\\n",[62,79,81,84],{"class":64,"line":80},2,[62,82,83],{"class":72},"  \"fastapi>=0.115\"",[62,85,77],{"class":76},[62,87,89,92],{"class":64,"line":88},3,[62,90,91],{"class":72},"  \"uvicorn[standard]>=0.32\"",[62,93,77],{"class":76},[62,95,97,100],{"class":64,"line":96},4,[62,98,99],{"class":72},"  \"pydantic>=2.9\"",[62,101,77],{"class":76},[62,103,105,108],{"class":64,"line":104},5,[62,106,107],{"class":72},"  \"httpx>=0.28\"",[62,109,77],{"class":76},[62,111,113,116],{"class":64,"line":112},6,[62,114,115],{"class":72},"  \"sqlalchemy[asyncio]>=2.0.36\"",[62,117,77],{"class":76},[62,119,121,124],{"class":64,"line":120},7,[62,122,123],{"class":72},"  \"asyncpg>=0.30\"",[62,125,77],{"class":76},[62,127,129,132],{"class":64,"line":128},8,[62,130,131],{"class":72},"  \"pytest>=8.3\"",[62,133,77],{"class":76},[62,135,137],{"class":64,"line":136},9,[62,138,139],{"class":72},"  \"deepdiff>=8.0\"\n",[14,141,142],{},"Every knob below reads from the environment so the same image runs in staging with an aggressive sunset date and in production with the real one. Nothing about a version lives in code that would require a redeploy to correct.",[53,144,146],{"className":55,"code":145,"language":57,"meta":58,"style":58},"export API_VERSIONS_FILE=\"versions.toml\"\nexport API_VERSION_HEADER=\"X-Api-Version\"\nexport API_DEFAULT_VERSION=\"2026-03-01\"\nexport API_UNKNOWN_VERSION_POLICY=\"reject\"\nexport API_VERSION_POLICY_URL=\"https:\u002F\u002Fexample.com\u002Fdocs\u002Fversioning\"\nexport API_BROWNOUT_WINDOWS=\"\"\nexport OPENAPI_BASELINE_PATH=\"contracts\u002Fopenapi-v2.json\"\n",[27,147,148,164,176,188,200,212,224],{"__ignoreMap":58},[62,149,150,154,158,161],{"class":64,"line":65},[62,151,153],{"class":152},"szBVR","export",[62,155,157],{"class":156},"sVt8B"," API_VERSIONS_FILE",[62,159,160],{"class":152},"=",[62,162,163],{"class":72},"\"versions.toml\"\n",[62,165,166,168,171,173],{"class":64,"line":80},[62,167,153],{"class":152},[62,169,170],{"class":156}," API_VERSION_HEADER",[62,172,160],{"class":152},[62,174,175],{"class":72},"\"X-Api-Version\"\n",[62,177,178,180,183,185],{"class":64,"line":88},[62,179,153],{"class":152},[62,181,182],{"class":156}," API_DEFAULT_VERSION",[62,184,160],{"class":152},[62,186,187],{"class":72},"\"2026-03-01\"\n",[62,189,190,192,195,197],{"class":64,"line":96},[62,191,153],{"class":152},[62,193,194],{"class":156}," API_UNKNOWN_VERSION_POLICY",[62,196,160],{"class":152},[62,198,199],{"class":72},"\"reject\"\n",[62,201,202,204,207,209],{"class":64,"line":104},[62,203,153],{"class":152},[62,205,206],{"class":156}," API_VERSION_POLICY_URL",[62,208,160],{"class":152},[62,210,211],{"class":72},"\"https:\u002F\u002Fexample.com\u002Fdocs\u002Fversioning\"\n",[62,213,214,216,219,221],{"class":64,"line":112},[62,215,153],{"class":152},[62,217,218],{"class":156}," API_BROWNOUT_WINDOWS",[62,220,160],{"class":152},[62,222,223],{"class":72},"\"\"\n",[62,225,226,228,231,233],{"class":64,"line":120},[62,227,153],{"class":152},[62,229,230],{"class":156}," OPENAPI_BASELINE_PATH",[62,232,160],{"class":152},[62,234,235],{"class":72},"\"contracts\u002Fopenapi-v2.json\"\n",[14,237,238,239,242],{},"The version catalogue itself belongs in a TOML file that ships with the application, not in a database and not in a Python dictionary buried in a router module. TOML because ",[27,240,241],{},"tomllib"," is in the standard library on 3.11, because your release checklist can diff it, and because a support engineer can read it without opening an editor.",[53,244,248],{"className":245,"code":246,"language":247,"meta":58,"style":58},"language-toml shiki shiki-themes github-light github-dark","# versions.toml\n[versions.\"2025-06-15\"]\nreleased = \"2025-06-15T00:00:00Z\"\ndeprecated = \"2026-04-01T00:00:00Z\"\nsunset = \"2027-04-01T00:00:00Z\"\n\n[versions.\"2026-03-01\"]\nreleased = \"2026-03-01T00:00:00Z\"\n\n[majors.v1]\ndefault_version = \"2025-06-15\"\nsunset = \"2027-04-01T00:00:00Z\"\n\n[majors.v2]\ndefault_version = \"2026-03-01\"\n","toml",[27,249,250,256,273,281,289,297,303,316,323,327,342,351,358,363,377],{"__ignoreMap":58},[62,251,252],{"class":64,"line":65},[62,253,255],{"class":254},"sJ8bj","# versions.toml\n",[62,257,258,261,264,267,270],{"class":64,"line":80},[62,259,260],{"class":156},"[",[62,262,263],{"class":68},"versions",[62,265,266],{"class":156},".",[62,268,269],{"class":68},"\"2025-06-15\"",[62,271,272],{"class":156},"]\n",[62,274,275,278],{"class":64,"line":88},[62,276,277],{"class":156},"released = ",[62,279,280],{"class":72},"\"2025-06-15T00:00:00Z\"\n",[62,282,283,286],{"class":64,"line":96},[62,284,285],{"class":156},"deprecated = ",[62,287,288],{"class":72},"\"2026-04-01T00:00:00Z\"\n",[62,290,291,294],{"class":64,"line":104},[62,292,293],{"class":156},"sunset = ",[62,295,296],{"class":72},"\"2027-04-01T00:00:00Z\"\n",[62,298,299],{"class":64,"line":112},[62,300,302],{"emptyLinePlaceholder":301},true,"\n",[62,304,305,307,309,311,314],{"class":64,"line":120},[62,306,260],{"class":156},[62,308,263],{"class":68},[62,310,266],{"class":156},[62,312,313],{"class":68},"\"2026-03-01\"",[62,315,272],{"class":156},[62,317,318,320],{"class":64,"line":128},[62,319,277],{"class":156},[62,321,322],{"class":72},"\"2026-03-01T00:00:00Z\"\n",[62,324,325],{"class":64,"line":136},[62,326,302],{"emptyLinePlaceholder":301},[62,328,330,332,335,337,340],{"class":64,"line":329},10,[62,331,260],{"class":156},[62,333,334],{"class":68},"majors",[62,336,266],{"class":156},[62,338,339],{"class":68},"v1",[62,341,272],{"class":156},[62,343,345,348],{"class":64,"line":344},11,[62,346,347],{"class":156},"default_version = ",[62,349,350],{"class":72},"\"2025-06-15\"\n",[62,352,354,356],{"class":64,"line":353},12,[62,355,293],{"class":156},[62,357,296],{"class":72},[62,359,361],{"class":64,"line":360},13,[62,362,302],{"emptyLinePlaceholder":301},[62,364,366,368,370,372,375],{"class":64,"line":365},14,[62,367,260],{"class":156},[62,369,334],{"class":68},[62,371,266],{"class":156},[62,373,374],{"class":68},"v2",[62,376,272],{"class":156},[62,378,380,382],{"class":64,"line":379},15,[62,381,347],{"class":156},[62,383,187],{"class":72},[32,385],{},[35,387,389],{"id":388},"step-1-choose-one-versioning-axis-and-write-the-rule-down","Step 1: Choose one versioning axis and write the rule down",[14,391,392,393,396,397,400,401,405],{},"The argument about URL versus header versioning burns more engineering hours than the implementation ever will, so resolve it once. Ship majors in the path — ",[27,394,395],{},"\u002Fv1\u002Finvoices",", ",[27,398,399],{},"\u002Fv2\u002Finvoices"," — because a path segment survives proxies, appears in every access log line, gives each generation its own OpenAPI document, and is trivially cacheable at the edge without teaching your CDN about a custom header. Ship everything else as a dated version header, the way Stripe does, because dates sort naturally, communicate age at a glance, and let a customer pin behaviour on the day they integrate and forget about you for two years. The detailed trade-offs live in ",[18,402,404],{"href":403},"\u002Fscaling-and-operating-production-python-apis\u002Fversioning-and-evolving-public-apis\u002Furl-versioning-vs-header-versioning\u002F","URL versioning versus header versioning","; what follows assumes the hybrid.",[407,408,416,417,416,421,416,425,416,432,416,440,416,450,416,457,416,460,416,464,416,467,416,471,416,475,416,479,416,485,416,489,416,493,416,497,416,500,416,503,416,506,416,510,416,513,416,515,416,517,416,520,416,522,416,524,416,527,416,531,416,535,416,538,416,540,416,542,416,544,416,546,416,549,416,553,416,556,416,558,416,560,416,562,416,564,416,566],"svg",{"viewBox":409,"role":410,"ariaLabelledBy":411,"xmlns":414,"style":415},"0 0 720 300","img",[412,413],"apiver-axis-t","apiver-axis-d","http:\u002F\u002Fwww.w3.org\u002F2000\u002Fsvg","width:100%;height:auto;margin:1.5rem 0;font-family:var(--font-sans);","\n  ",[418,419,420],"title",{"id":412},"Comparison matrix of three API versioning axes",[422,423,424],"desc",{"id":413},"Four operational properties compared across URL path versioning, a dated request header, and a query parameter, showing that the path wins on logs, caching and documentation while the header wins on date pinning.",[426,427],"rect",{"x":428,"y":428,"width":429,"height":430,"fill":431},"0","720","300","var(--c-surface)",[433,434,439],"text",{"x":435,"y":436,"fill":437,"style":438},"24","28","var(--c-text-muted)","text-anchor:start;font-size:12;font-family:var(--font-sans);","Operational properties by versioning axis",[426,441],{"x":442,"y":443,"width":444,"height":445,"rx":446,"fill":447,"stroke":448,"style":449},"210","42","164","34","6","var(--c-surface-alt)","var(--c-border)","stroke-width:1;",[433,451,456],{"x":452,"y":453,"fill":454,"style":455},"292","64","var(--c-text)","text-anchor:middle;font-size:12;font-family:var(--font-sans);","URL path \u002Fv2",[426,458],{"x":459,"y":443,"width":444,"height":445,"rx":446,"fill":447,"stroke":448,"style":449},"378",[433,461,463],{"x":462,"y":453,"fill":454,"style":455},"460","Dated header",[426,465],{"x":466,"y":443,"width":444,"height":445,"rx":446,"fill":447,"stroke":448,"style":449},"546",[433,468,470],{"x":469,"y":453,"fill":454,"style":455},"628","Query parameter",[64,472],{"x1":435,"y1":473,"x2":474,"y2":473,"stroke":448,"style":449},"84","710",[433,476,478],{"x":435,"y":477,"fill":454,"style":438},"112","Visible in access logs",[480,481],"circle",{"cx":482,"cy":483,"r":446,"fill":484},"238","108","var(--c-teal)",[433,486,488],{"x":487,"y":477,"fill":454,"style":438},"256","yes",[480,490],{"cx":491,"cy":483,"r":446,"fill":492},"406","var(--c-yellow)",[433,494,496],{"x":495,"y":477,"fill":454,"style":438},"424","log the header",[480,498],{"cx":499,"cy":483,"r":446,"fill":484},"574",[433,501,488],{"x":502,"y":477,"fill":454,"style":438},"592",[64,504],{"x1":435,"y1":505,"x2":474,"y2":505,"stroke":448,"style":449},"128",[433,507,509],{"x":435,"y":508,"fill":454,"style":438},"156","Safe CDN cache key",[480,511],{"cx":482,"cy":512,"r":446,"fill":484},"152",[433,514,488],{"x":487,"y":508,"fill":454,"style":438},[480,516],{"cx":491,"cy":512,"r":446,"fill":492},[433,518,519],{"x":495,"y":508,"fill":454,"style":438},"needs Vary",[480,521],{"cx":499,"cy":512,"r":446,"fill":484},[433,523,488],{"x":502,"y":508,"fill":454,"style":438},[64,525],{"x1":435,"y1":526,"x2":474,"y2":526,"stroke":448,"style":449},"172",[433,528,530],{"x":435,"y":529,"fill":454,"style":438},"200","Pin behaviour by date",[480,532],{"cx":482,"cy":533,"r":446,"fill":534},"196","var(--c-coral)",[433,536,537],{"x":487,"y":529,"fill":454,"style":438},"no",[480,539],{"cx":491,"cy":533,"r":446,"fill":484},[433,541,488],{"x":495,"y":529,"fill":454,"style":438},[480,543],{"cx":499,"cy":533,"r":446,"fill":484},[433,545,488],{"x":502,"y":529,"fill":454,"style":438},[64,547],{"x1":435,"y1":548,"x2":474,"y2":548,"stroke":448,"style":449},"216",[433,550,552],{"x":435,"y":551,"fill":454,"style":438},"244","Own OpenAPI doc page",[480,554],{"cx":482,"cy":555,"r":446,"fill":484},"240",[433,557,488],{"x":487,"y":551,"fill":454,"style":438},[480,559],{"cx":491,"cy":555,"r":446,"fill":534},[433,561,537],{"x":495,"y":551,"fill":454,"style":438},[480,563],{"cx":499,"cy":555,"r":446,"fill":534},[433,565,537],{"x":502,"y":551,"fill":454,"style":438},[433,567,570],{"x":435,"y":568,"fill":437,"style":569},"278","text-anchor:start;font-size:11;font-family:var(--font-sans);","Recommended hybrid: majors in the path, dated behaviour changes in the header.",[14,572,573],{},"Load the catalogue once at import time and hand the rest of the application immutable objects. Freezing the dataclass and caching the loader means a version's sunset date cannot drift mid-request, which matters when a middleware and a route handler both consult it.",[53,575,579],{"className":576,"code":577,"language":578,"meta":58,"style":58},"language-python shiki shiki-themes github-light github-dark","# app\u002Fversioning\u002Fregistry.py\nfrom __future__ import annotations\n\nimport os\nimport tomllib\nfrom dataclasses import dataclass\nfrom datetime import datetime, timezone\nfrom functools import lru_cache\nfrom pathlib import Path\n\n\n@dataclass(frozen=True, slots=True)\nclass ApiVersion:\n    name: str\n    released: datetime\n    deprecated: datetime | None = None\n    sunset: datetime | None = None\n\n    def is_deprecated(self, now: datetime | None = None) -> bool:\n        now = now or datetime.now(timezone.utc)\n        return self.deprecated is not None and now >= self.deprecated\n\n\ndef _parse(value: str | None) -> datetime | None:\n    if value is None:\n        return None\n    return datetime.fromisoformat(value).astimezone(timezone.utc)\n\n\n@lru_cache(maxsize=1)\ndef load_versions() -> dict[str, ApiVersion]:\n    path = Path(os.getenv(\"API_VERSIONS_FILE\", \"versions.toml\"))\n    raw = tomllib.loads(path.read_text(encoding=\"utf-8\"))\n    return {\n        name: ApiVersion(\n            name=name,\n            released=_parse(meta[\"released\"]),\n            deprecated=_parse(meta.get(\"deprecated\")),\n            sunset=_parse(meta.get(\"sunset\")),\n        )\n        for name, meta in raw[\"versions\"].items()\n    }\n\n\ndef latest_version() -> ApiVersion:\n    versions = load_versions()\n    return versions[max(versions)]\n","python",[27,580,581,586,600,604,612,619,631,643,655,667,671,675,704,715,723,728,746,760,765,793,810,843,848,853,882,897,904,913,918,923,941,957,979,1000,1008,1014,1025,1042,1059,1074,1080,1101,1107,1112,1117,1128,1139],{"__ignoreMap":58},[62,582,583],{"class":64,"line":65},[62,584,585],{"class":254},"# app\u002Fversioning\u002Fregistry.py\n",[62,587,588,591,594,597],{"class":64,"line":80},[62,589,590],{"class":152},"from",[62,592,593],{"class":76}," __future__",[62,595,596],{"class":152}," import",[62,598,599],{"class":156}," annotations\n",[62,601,602],{"class":64,"line":88},[62,603,302],{"emptyLinePlaceholder":301},[62,605,606,609],{"class":64,"line":96},[62,607,608],{"class":152},"import",[62,610,611],{"class":156}," os\n",[62,613,614,616],{"class":64,"line":104},[62,615,608],{"class":152},[62,617,618],{"class":156}," tomllib\n",[62,620,621,623,626,628],{"class":64,"line":112},[62,622,590],{"class":152},[62,624,625],{"class":156}," dataclasses ",[62,627,608],{"class":152},[62,629,630],{"class":156}," dataclass\n",[62,632,633,635,638,640],{"class":64,"line":120},[62,634,590],{"class":152},[62,636,637],{"class":156}," datetime ",[62,639,608],{"class":152},[62,641,642],{"class":156}," datetime, timezone\n",[62,644,645,647,650,652],{"class":64,"line":128},[62,646,590],{"class":152},[62,648,649],{"class":156}," functools ",[62,651,608],{"class":152},[62,653,654],{"class":156}," lru_cache\n",[62,656,657,659,662,664],{"class":64,"line":136},[62,658,590],{"class":152},[62,660,661],{"class":156}," pathlib ",[62,663,608],{"class":152},[62,665,666],{"class":156}," Path\n",[62,668,669],{"class":64,"line":329},[62,670,302],{"emptyLinePlaceholder":301},[62,672,673],{"class":64,"line":344},[62,674,302],{"emptyLinePlaceholder":301},[62,676,677,680,683,687,689,692,694,697,699,701],{"class":64,"line":353},[62,678,679],{"class":68},"@dataclass",[62,681,682],{"class":156},"(",[62,684,686],{"class":685},"s4XuR","frozen",[62,688,160],{"class":152},[62,690,691],{"class":76},"True",[62,693,396],{"class":156},[62,695,696],{"class":685},"slots",[62,698,160],{"class":152},[62,700,691],{"class":76},[62,702,703],{"class":156},")\n",[62,705,706,709,712],{"class":64,"line":360},[62,707,708],{"class":152},"class",[62,710,711],{"class":68}," ApiVersion",[62,713,714],{"class":156},":\n",[62,716,717,720],{"class":64,"line":365},[62,718,719],{"class":156},"    name: ",[62,721,722],{"class":76},"str\n",[62,724,725],{"class":64,"line":379},[62,726,727],{"class":156},"    released: datetime\n",[62,729,731,734,737,740,743],{"class":64,"line":730},16,[62,732,733],{"class":156},"    deprecated: datetime ",[62,735,736],{"class":152},"|",[62,738,739],{"class":76}," None",[62,741,742],{"class":152}," =",[62,744,745],{"class":76}," None\n",[62,747,749,752,754,756,758],{"class":64,"line":748},17,[62,750,751],{"class":156},"    sunset: datetime ",[62,753,736],{"class":152},[62,755,739],{"class":76},[62,757,742],{"class":152},[62,759,745],{"class":76},[62,761,763],{"class":64,"line":762},18,[62,764,302],{"emptyLinePlaceholder":301},[62,766,768,771,774,777,779,781,783,785,788,791],{"class":64,"line":767},19,[62,769,770],{"class":152},"    def",[62,772,773],{"class":68}," is_deprecated",[62,775,776],{"class":156},"(self, now: datetime ",[62,778,736],{"class":152},[62,780,739],{"class":76},[62,782,742],{"class":152},[62,784,739],{"class":76},[62,786,787],{"class":156},") -> ",[62,789,790],{"class":76},"bool",[62,792,714],{"class":156},[62,794,796,799,801,804,807],{"class":64,"line":795},20,[62,797,798],{"class":156},"        now ",[62,800,160],{"class":152},[62,802,803],{"class":156}," now ",[62,805,806],{"class":152},"or",[62,808,809],{"class":156}," datetime.now(timezone.utc)\n",[62,811,813,816,819,822,825,828,830,833,835,838,840],{"class":64,"line":812},21,[62,814,815],{"class":152},"        return",[62,817,818],{"class":76}," self",[62,820,821],{"class":156},".deprecated ",[62,823,824],{"class":152},"is",[62,826,827],{"class":152}," not",[62,829,739],{"class":76},[62,831,832],{"class":152}," and",[62,834,803],{"class":156},[62,836,837],{"class":152},">=",[62,839,818],{"class":76},[62,841,842],{"class":156},".deprecated\n",[62,844,846],{"class":64,"line":845},22,[62,847,302],{"emptyLinePlaceholder":301},[62,849,851],{"class":64,"line":850},23,[62,852,302],{"emptyLinePlaceholder":301},[62,854,856,859,862,865,868,871,873,876,878,880],{"class":64,"line":855},24,[62,857,858],{"class":152},"def",[62,860,861],{"class":68}," _parse",[62,863,864],{"class":156},"(value: ",[62,866,867],{"class":76},"str",[62,869,870],{"class":152}," |",[62,872,739],{"class":76},[62,874,875],{"class":156},") -> datetime ",[62,877,736],{"class":152},[62,879,739],{"class":76},[62,881,714],{"class":156},[62,883,885,888,891,893,895],{"class":64,"line":884},25,[62,886,887],{"class":152},"    if",[62,889,890],{"class":156}," value ",[62,892,824],{"class":152},[62,894,739],{"class":76},[62,896,714],{"class":156},[62,898,900,902],{"class":64,"line":899},26,[62,901,815],{"class":152},[62,903,745],{"class":76},[62,905,907,910],{"class":64,"line":906},27,[62,908,909],{"class":152},"    return",[62,911,912],{"class":156}," datetime.fromisoformat(value).astimezone(timezone.utc)\n",[62,914,916],{"class":64,"line":915},28,[62,917,302],{"emptyLinePlaceholder":301},[62,919,921],{"class":64,"line":920},29,[62,922,302],{"emptyLinePlaceholder":301},[62,924,926,929,931,934,936,939],{"class":64,"line":925},30,[62,927,928],{"class":68},"@lru_cache",[62,930,682],{"class":156},[62,932,933],{"class":685},"maxsize",[62,935,160],{"class":152},[62,937,938],{"class":76},"1",[62,940,703],{"class":156},[62,942,944,946,949,952,954],{"class":64,"line":943},31,[62,945,858],{"class":152},[62,947,948],{"class":68}," load_versions",[62,950,951],{"class":156},"() -> dict[",[62,953,867],{"class":76},[62,955,956],{"class":156},", ApiVersion]:\n",[62,958,960,963,965,968,971,973,976],{"class":64,"line":959},32,[62,961,962],{"class":156},"    path ",[62,964,160],{"class":152},[62,966,967],{"class":156}," Path(os.getenv(",[62,969,970],{"class":72},"\"API_VERSIONS_FILE\"",[62,972,396],{"class":156},[62,974,975],{"class":72},"\"versions.toml\"",[62,977,978],{"class":156},"))\n",[62,980,982,985,987,990,993,995,998],{"class":64,"line":981},33,[62,983,984],{"class":156},"    raw ",[62,986,160],{"class":152},[62,988,989],{"class":156}," tomllib.loads(path.read_text(",[62,991,992],{"class":685},"encoding",[62,994,160],{"class":152},[62,996,997],{"class":72},"\"utf-8\"",[62,999,978],{"class":156},[62,1001,1003,1005],{"class":64,"line":1002},34,[62,1004,909],{"class":152},[62,1006,1007],{"class":156}," {\n",[62,1009,1011],{"class":64,"line":1010},35,[62,1012,1013],{"class":156},"        name: ApiVersion(\n",[62,1015,1017,1020,1022],{"class":64,"line":1016},36,[62,1018,1019],{"class":685},"            name",[62,1021,160],{"class":152},[62,1023,1024],{"class":156},"name,\n",[62,1026,1028,1031,1033,1036,1039],{"class":64,"line":1027},37,[62,1029,1030],{"class":685},"            released",[62,1032,160],{"class":152},[62,1034,1035],{"class":156},"_parse(meta[",[62,1037,1038],{"class":72},"\"released\"",[62,1040,1041],{"class":156},"]),\n",[62,1043,1045,1048,1050,1053,1056],{"class":64,"line":1044},38,[62,1046,1047],{"class":685},"            deprecated",[62,1049,160],{"class":152},[62,1051,1052],{"class":156},"_parse(meta.get(",[62,1054,1055],{"class":72},"\"deprecated\"",[62,1057,1058],{"class":156},")),\n",[62,1060,1062,1065,1067,1069,1072],{"class":64,"line":1061},39,[62,1063,1064],{"class":685},"            sunset",[62,1066,160],{"class":152},[62,1068,1052],{"class":156},[62,1070,1071],{"class":72},"\"sunset\"",[62,1073,1058],{"class":156},[62,1075,1077],{"class":64,"line":1076},40,[62,1078,1079],{"class":156},"        )\n",[62,1081,1083,1086,1089,1092,1095,1098],{"class":64,"line":1082},41,[62,1084,1085],{"class":152},"        for",[62,1087,1088],{"class":156}," name, meta ",[62,1090,1091],{"class":152},"in",[62,1093,1094],{"class":156}," raw[",[62,1096,1097],{"class":72},"\"versions\"",[62,1099,1100],{"class":156},"].items()\n",[62,1102,1104],{"class":64,"line":1103},42,[62,1105,1106],{"class":156},"    }\n",[62,1108,1110],{"class":64,"line":1109},43,[62,1111,302],{"emptyLinePlaceholder":301},[62,1113,1115],{"class":64,"line":1114},44,[62,1116,302],{"emptyLinePlaceholder":301},[62,1118,1120,1122,1125],{"class":64,"line":1119},45,[62,1121,858],{"class":152},[62,1123,1124],{"class":68}," latest_version",[62,1126,1127],{"class":156},"() -> ApiVersion:\n",[62,1129,1131,1134,1136],{"class":64,"line":1130},46,[62,1132,1133],{"class":156},"    versions ",[62,1135,160],{"class":152},[62,1137,1138],{"class":156}," load_versions()\n",[62,1140,1142,1144,1147,1150],{"class":64,"line":1141},47,[62,1143,909],{"class":152},[62,1145,1146],{"class":156}," versions[",[62,1148,1149],{"class":76},"max",[62,1151,1152],{"class":156},"(versions)]\n",[14,1154,1155,1156,1159,1160,1163,1164,1167],{},"Dated names sort lexicographically because ISO dates do, so ",[27,1157,1158],{},"max()"," is a correct \"latest\" and no separate ordering column is needed. That small property saves you an entire class of bug later, when a customer asks whether ",[27,1161,1162],{},"2026-03-01"," is newer than ",[27,1165,1166],{},"2025-06-15"," and your code has to agree with their intuition.",[32,1169],{},[35,1171,1173],{"id":1172},"step-2-mount-each-major-version-as-its-own-asgi-application","Step 2: Mount each major version as its own ASGI application",[14,1175,1176,1177,1180],{},"Do not build a single router that branches on version inside handlers. Mount a separate FastAPI application per major under the root app. Each mounted app owns its schemas, its docs URL and its dependency overrides, so a change in v2 physically cannot alter a v1 response — the isolation is structural rather than disciplinary. It also means deleting v1 on sunset day is one line, not a week of archaeology through ",[27,1178,1179],{},"if version == ..."," branches.",[53,1182,1184],{"className":576,"code":1183,"language":578,"meta":58,"style":58},"# app\u002Fmain.py\nimport os\n\nfrom fastapi import FastAPI\n\nfrom app.versioning.registry import latest_version\nfrom app.v1.routes import router as v1_router\nfrom app.v2.routes import router as v2_router\n\n\ndef build_major(title: str, prefix: str) -> FastAPI:\n    app = FastAPI(\n        title=title,\n        version=prefix,\n        docs_url=\"\u002Fdocs\",\n        openapi_url=\"\u002Fopenapi.json\",\n        root_path=os.getenv(\"API_ROOT_PATH\", \"\") + f\"\u002F{prefix}\",\n    )\n    return app\n\n\ndef create_app() -> FastAPI:\n    root = FastAPI(title=os.getenv(\"API_TITLE\", \"Public API\"), docs_url=None, openapi_url=None)\n\n    v1 = build_major(\"Public API v1 (deprecated)\", \"v1\")\n    v1.include_router(v1_router)\n    v2 = build_major(\"Public API v2\", \"v2\")\n    v2.include_router(v2_router)\n\n    root.mount(\"\u002Fv1\", v1)\n    root.mount(\"\u002Fv2\", v2)\n\n    @root.get(\"\u002Fhealthz\", include_in_schema=False)\n    async def healthz() -> dict[str, str]:\n        return {\"status\": \"ok\", \"latest_version\": latest_version().name}\n\n    return root\n\n\napp = create_app()\n",[27,1185,1186,1191,1197,1201,1213,1217,1229,1247,1263,1267,1271,1291,1301,1311,1321,1334,1346,1390,1395,1402,1406,1410,1420,1466,1470,1490,1495,1514,1519,1523,1534,1544,1548,1570,1592,1616,1620,1627,1631,1635],{"__ignoreMap":58},[62,1187,1188],{"class":64,"line":65},[62,1189,1190],{"class":254},"# app\u002Fmain.py\n",[62,1192,1193,1195],{"class":64,"line":80},[62,1194,608],{"class":152},[62,1196,611],{"class":156},[62,1198,1199],{"class":64,"line":88},[62,1200,302],{"emptyLinePlaceholder":301},[62,1202,1203,1205,1208,1210],{"class":64,"line":96},[62,1204,590],{"class":152},[62,1206,1207],{"class":156}," fastapi ",[62,1209,608],{"class":152},[62,1211,1212],{"class":156}," FastAPI\n",[62,1214,1215],{"class":64,"line":104},[62,1216,302],{"emptyLinePlaceholder":301},[62,1218,1219,1221,1224,1226],{"class":64,"line":112},[62,1220,590],{"class":152},[62,1222,1223],{"class":156}," app.versioning.registry ",[62,1225,608],{"class":152},[62,1227,1228],{"class":156}," latest_version\n",[62,1230,1231,1233,1236,1238,1241,1244],{"class":64,"line":120},[62,1232,590],{"class":152},[62,1234,1235],{"class":156}," app.v1.routes ",[62,1237,608],{"class":152},[62,1239,1240],{"class":156}," router ",[62,1242,1243],{"class":152},"as",[62,1245,1246],{"class":156}," v1_router\n",[62,1248,1249,1251,1254,1256,1258,1260],{"class":64,"line":128},[62,1250,590],{"class":152},[62,1252,1253],{"class":156}," app.v2.routes ",[62,1255,608],{"class":152},[62,1257,1240],{"class":156},[62,1259,1243],{"class":152},[62,1261,1262],{"class":156}," v2_router\n",[62,1264,1265],{"class":64,"line":136},[62,1266,302],{"emptyLinePlaceholder":301},[62,1268,1269],{"class":64,"line":329},[62,1270,302],{"emptyLinePlaceholder":301},[62,1272,1273,1275,1278,1281,1283,1286,1288],{"class":64,"line":344},[62,1274,858],{"class":152},[62,1276,1277],{"class":68}," build_major",[62,1279,1280],{"class":156},"(title: ",[62,1282,867],{"class":76},[62,1284,1285],{"class":156},", prefix: ",[62,1287,867],{"class":76},[62,1289,1290],{"class":156},") -> FastAPI:\n",[62,1292,1293,1296,1298],{"class":64,"line":353},[62,1294,1295],{"class":156},"    app ",[62,1297,160],{"class":152},[62,1299,1300],{"class":156}," FastAPI(\n",[62,1302,1303,1306,1308],{"class":64,"line":360},[62,1304,1305],{"class":685},"        title",[62,1307,160],{"class":152},[62,1309,1310],{"class":156},"title,\n",[62,1312,1313,1316,1318],{"class":64,"line":365},[62,1314,1315],{"class":685},"        version",[62,1317,160],{"class":152},[62,1319,1320],{"class":156},"prefix,\n",[62,1322,1323,1326,1328,1331],{"class":64,"line":379},[62,1324,1325],{"class":685},"        docs_url",[62,1327,160],{"class":152},[62,1329,1330],{"class":72},"\"\u002Fdocs\"",[62,1332,1333],{"class":156},",\n",[62,1335,1336,1339,1341,1344],{"class":64,"line":730},[62,1337,1338],{"class":685},"        openapi_url",[62,1340,160],{"class":152},[62,1342,1343],{"class":72},"\"\u002Fopenapi.json\"",[62,1345,1333],{"class":156},[62,1347,1348,1351,1353,1356,1359,1361,1364,1367,1370,1373,1376,1379,1382,1385,1388],{"class":64,"line":748},[62,1349,1350],{"class":685},"        root_path",[62,1352,160],{"class":152},[62,1354,1355],{"class":156},"os.getenv(",[62,1357,1358],{"class":72},"\"API_ROOT_PATH\"",[62,1360,396],{"class":156},[62,1362,1363],{"class":72},"\"\"",[62,1365,1366],{"class":156},") ",[62,1368,1369],{"class":152},"+",[62,1371,1372],{"class":152}," f",[62,1374,1375],{"class":72},"\"\u002F",[62,1377,1378],{"class":76},"{",[62,1380,1381],{"class":156},"prefix",[62,1383,1384],{"class":76},"}",[62,1386,1387],{"class":72},"\"",[62,1389,1333],{"class":156},[62,1391,1392],{"class":64,"line":762},[62,1393,1394],{"class":156},"    )\n",[62,1396,1397,1399],{"class":64,"line":767},[62,1398,909],{"class":152},[62,1400,1401],{"class":156}," app\n",[62,1403,1404],{"class":64,"line":795},[62,1405,302],{"emptyLinePlaceholder":301},[62,1407,1408],{"class":64,"line":812},[62,1409,302],{"emptyLinePlaceholder":301},[62,1411,1412,1414,1417],{"class":64,"line":845},[62,1413,858],{"class":152},[62,1415,1416],{"class":68}," create_app",[62,1418,1419],{"class":156},"() -> FastAPI:\n",[62,1421,1422,1425,1427,1430,1432,1434,1436,1439,1441,1444,1447,1450,1452,1455,1457,1460,1462,1464],{"class":64,"line":850},[62,1423,1424],{"class":156},"    root ",[62,1426,160],{"class":152},[62,1428,1429],{"class":156}," FastAPI(",[62,1431,418],{"class":685},[62,1433,160],{"class":152},[62,1435,1355],{"class":156},[62,1437,1438],{"class":72},"\"API_TITLE\"",[62,1440,396],{"class":156},[62,1442,1443],{"class":72},"\"Public API\"",[62,1445,1446],{"class":156},"), ",[62,1448,1449],{"class":685},"docs_url",[62,1451,160],{"class":152},[62,1453,1454],{"class":76},"None",[62,1456,396],{"class":156},[62,1458,1459],{"class":685},"openapi_url",[62,1461,160],{"class":152},[62,1463,1454],{"class":76},[62,1465,703],{"class":156},[62,1467,1468],{"class":64,"line":855},[62,1469,302],{"emptyLinePlaceholder":301},[62,1471,1472,1475,1477,1480,1483,1485,1488],{"class":64,"line":884},[62,1473,1474],{"class":156},"    v1 ",[62,1476,160],{"class":152},[62,1478,1479],{"class":156}," build_major(",[62,1481,1482],{"class":72},"\"Public API v1 (deprecated)\"",[62,1484,396],{"class":156},[62,1486,1487],{"class":72},"\"v1\"",[62,1489,703],{"class":156},[62,1491,1492],{"class":64,"line":899},[62,1493,1494],{"class":156},"    v1.include_router(v1_router)\n",[62,1496,1497,1500,1502,1504,1507,1509,1512],{"class":64,"line":906},[62,1498,1499],{"class":156},"    v2 ",[62,1501,160],{"class":152},[62,1503,1479],{"class":156},[62,1505,1506],{"class":72},"\"Public API v2\"",[62,1508,396],{"class":156},[62,1510,1511],{"class":72},"\"v2\"",[62,1513,703],{"class":156},[62,1515,1516],{"class":64,"line":915},[62,1517,1518],{"class":156},"    v2.include_router(v2_router)\n",[62,1520,1521],{"class":64,"line":920},[62,1522,302],{"emptyLinePlaceholder":301},[62,1524,1525,1528,1531],{"class":64,"line":925},[62,1526,1527],{"class":156},"    root.mount(",[62,1529,1530],{"class":72},"\"\u002Fv1\"",[62,1532,1533],{"class":156},", v1)\n",[62,1535,1536,1538,1541],{"class":64,"line":943},[62,1537,1527],{"class":156},[62,1539,1540],{"class":72},"\"\u002Fv2\"",[62,1542,1543],{"class":156},", v2)\n",[62,1545,1546],{"class":64,"line":959},[62,1547,302],{"emptyLinePlaceholder":301},[62,1549,1550,1553,1555,1558,1560,1563,1565,1568],{"class":64,"line":981},[62,1551,1552],{"class":68},"    @root.get",[62,1554,682],{"class":156},[62,1556,1557],{"class":72},"\"\u002Fhealthz\"",[62,1559,396],{"class":156},[62,1561,1562],{"class":685},"include_in_schema",[62,1564,160],{"class":152},[62,1566,1567],{"class":76},"False",[62,1569,703],{"class":156},[62,1571,1572,1575,1578,1581,1583,1585,1587,1589],{"class":64,"line":1002},[62,1573,1574],{"class":152},"    async",[62,1576,1577],{"class":152}," def",[62,1579,1580],{"class":68}," healthz",[62,1582,951],{"class":156},[62,1584,867],{"class":76},[62,1586,396],{"class":156},[62,1588,867],{"class":76},[62,1590,1591],{"class":156},"]:\n",[62,1593,1594,1596,1599,1602,1605,1608,1610,1613],{"class":64,"line":1010},[62,1595,815],{"class":152},[62,1597,1598],{"class":156}," {",[62,1600,1601],{"class":72},"\"status\"",[62,1603,1604],{"class":156},": ",[62,1606,1607],{"class":72},"\"ok\"",[62,1609,396],{"class":156},[62,1611,1612],{"class":72},"\"latest_version\"",[62,1614,1615],{"class":156},": latest_version().name}\n",[62,1617,1618],{"class":64,"line":1016},[62,1619,302],{"emptyLinePlaceholder":301},[62,1621,1622,1624],{"class":64,"line":1027},[62,1623,909],{"class":152},[62,1625,1626],{"class":156}," root\n",[62,1628,1629],{"class":64,"line":1044},[62,1630,302],{"emptyLinePlaceholder":301},[62,1632,1633],{"class":64,"line":1061},[62,1634,302],{"emptyLinePlaceholder":301},[62,1636,1637,1640,1642],{"class":64,"line":1076},[62,1638,1639],{"class":156},"app ",[62,1641,160],{"class":152},[62,1643,1644],{"class":156}," create_app()\n",[14,1646,1647,1648,1651,1652,1655],{},"The mounted apps must stay thin. Handlers translate between the wire format of their generation and a shared service layer that knows nothing about versions; all business logic, database access and caching live below that line. If you find yourself copying a pricing calculation into ",[27,1649,1650],{},"app\u002Fv2\u002F",", stop — that is the signal that the logic belongs in ",[27,1653,1654],{},"app\u002Fservices\u002F"," with the version-specific serialisation staying in the router module. Duplicated business logic across versions is how a bug fix ships to half your customers.",[407,1657,416,1662,416,1665,416,1668,416,1686,416,1689,416,1693,416,1700,416,1706,416,1711,416,1716,416,1721,416,1726,416,1730,416,1734,416,1738,416,1741,416,1746,416,1751,416,1755,416,1758,416,1762,416,1766,416,1769,416,1772,416,1778,416,1783,416,1787],{"viewBox":1658,"role":410,"ariaLabelledBy":1659,"xmlns":414,"style":415},"0 0 720 330",[1660,1661],"apiver-route-t","apiver-route-d",[418,1663,1664],{"id":1660},"Per-version routing through mounted ASGI applications",[422,1666,1667],{"id":1661},"A client request reaches a root ASGI app, passes through a version resolver, is dispatched to either the mounted v1 or v2 application, and both call a single shared service layer backed by one database.",[1669,1670,1671,1672,416],"defs",{},"\n    ",[1673,1674,1681,1682,1671],"marker",{"id":1675,"viewBox":1676,"refX":1677,"refY":1678,"markerWidth":1679,"markerHeight":1679,"orient":1680},"apiver-route-arrow","0 0 10 10","9","5","7","auto-start-reverse","\n      ",[1683,1684],"path",{"d":1685,"fill":437},"M0 0 L10 5 L0 10 z",[426,1687],{"x":428,"y":428,"width":429,"height":1688,"fill":431},"330",[433,1690,1692],{"x":435,"y":1691,"fill":437,"style":438},"26","One process, two wire contracts, one service layer",[426,1694],{"x":435,"y":1695,"width":483,"height":1696,"rx":1697,"fill":431,"stroke":1698,"style":1699},"132","58","10","var(--c-blue)","stroke-width:2;",[433,1701,1705],{"x":1702,"y":1703,"fill":454,"style":1704},"78","157","text-anchor:middle;font-size:13;font-family:var(--font-sans);","Client",[433,1707,1710],{"x":1702,"y":1708,"fill":437,"style":1709},"175","text-anchor:middle;font-size:11;font-family:var(--font-sans);","pinned date",[64,1712],{"x1":1695,"y1":1713,"x2":1714,"y2":1713,"stroke":437,"style":1715},"161","176","stroke-width:2;marker-end:url(#apiver-route-arrow);",[426,1717],{"x":1718,"y":1719,"width":1720,"height":1702,"rx":1697,"fill":431,"stroke":1698,"style":1699},"178","122","146",[433,1722,1725],{"x":1723,"y":1724,"fill":454,"style":1704},"251","150","Version resolver",[433,1727,1729],{"x":1723,"y":1728,"fill":437,"style":1709},"168","header, then account",[433,1731,1733],{"x":1723,"y":1732,"fill":437,"style":1709},"184","default, then reject",[1683,1735],{"d":1736,"fill":1737,"stroke":437,"style":1715},"M324 145 L372 145 L372 82","none",[1683,1739],{"d":1740,"fill":1737,"stroke":437,"style":1715},"M324 178 L372 178 L372 236",[426,1742],{"x":1743,"y":443,"width":1744,"height":1745,"rx":1697,"fill":431,"stroke":534,"style":1699},"374","180","66",[433,1747,1750],{"x":1748,"y":1749,"fill":454,"style":1704},"464","68","mount \u002Fv1",[433,1752,1754],{"x":1748,"y":1753,"fill":437,"style":1709},"88","deprecated, own docs",[426,1756],{"x":1743,"y":1757,"width":1744,"height":1745,"rx":1697,"fill":431,"stroke":484,"style":1699},"228",[433,1759,1761],{"x":1748,"y":1760,"fill":454,"style":1704},"254","mount \u002Fv2",[433,1763,1765],{"x":1748,"y":1764,"fill":437,"style":1709},"274","current, own docs",[1683,1767],{"d":1768,"fill":1737,"stroke":437,"style":1715},"M554 75 L600 75 L600 142",[1683,1770],{"d":1771,"fill":1737,"stroke":437,"style":1715},"M554 261 L600 261 L600 190",[426,1773],{"x":1774,"y":1775,"width":1776,"height":1777,"rx":1697,"fill":447,"stroke":448,"style":1699},"530","140","166","52",[433,1779,1782],{"x":1780,"y":1781,"fill":454,"style":1704},"613","163","Service layer",[433,1784,1786],{"x":1780,"y":1785,"fill":437,"style":1709},"181","version-agnostic",[433,1788,1790],{"x":435,"y":1789,"fill":437,"style":569},"316","Sunsetting v1 removes one mount line and one package. No logic is untangled.",[32,1792],{},[35,1794,1796],{"id":1795},"step-3-enforce-additive-only-change-with-a-contract-test","Step 3: Enforce additive-only change with a contract test",[14,1798,1799],{},"Most changes never need a version at all. Adding a field to a response, adding an optional request field, adding an endpoint, adding an enum value that clients treat as opaque — all safe, all shippable on a Tuesday afternoon. Breaking changes are a short, boring list: removing or renaming a field, tightening a type, adding a required request field, changing a default, changing an error status code, and changing the meaning of a value without changing its name. That last one is the assassin, because no schema diff catches it. Everything on the list gets a version gate; nothing else does.",[407,1801,416,1806,416,1809,416,1812,416,1819,416,1822,416,1825,416,1831,416,1836,416,1839,416,1842,416,1844,416,1847,416,1850,416,1854,416,1859,416,1862,416,1866,416,1869,416,1872,416,1875,416,1879,416,1882,416,1885,416,1888,416,1891,416,1893,416,1897,416,1901,416,1903,416,1906,416,1908,416,1910,416,1913],{"viewBox":1802,"role":410,"ariaLabelledBy":1803,"xmlns":414,"style":415},"0 0 720 350",[1804,1805],"apiver-tree-t","apiver-tree-d",[418,1807,1808],{"id":1804},"Decision tree for classifying an API change",[422,1810,1811],{"id":1805},"Three sequential questions decide whether a proposed change needs a new major version, a dated version gate, or can ship additively today.",[1669,1813,1671,1814,416],{},[1673,1815,1681,1817,1671],{"id":1816,"viewBox":1676,"refX":1677,"refY":1678,"markerWidth":1679,"markerHeight":1679,"orient":1680},"apiver-tree-arrow",[1683,1818],{"d":1685,"fill":437},[426,1820],{"x":428,"y":428,"width":429,"height":1821,"fill":431},"350",[433,1823,1824],{"x":435,"y":1691,"fill":437,"style":438},"Classify before you code",[426,1826],{"x":435,"y":1827,"width":1828,"height":1829,"rx":1830,"fill":431,"stroke":1698,"style":1699},"40","230","50","8",[433,1832,1835],{"x":1833,"y":1834,"fill":454,"style":455},"139","70","Removes or renames a field?",[426,1837],{"x":435,"y":1838,"width":1828,"height":1829,"rx":1830,"fill":431,"stroke":1698,"style":1699},"120",[433,1840,1841],{"x":1833,"y":1724,"fill":454,"style":455},"Adds a required input field?",[426,1843],{"x":435,"y":529,"width":1828,"height":1829,"rx":1830,"fill":431,"stroke":1698,"style":1699},[433,1845,1846],{"x":1833,"y":1828,"fill":454,"style":455},"Changes defaults or errors?",[426,1848],{"x":435,"y":1849,"width":1828,"height":1829,"rx":1830,"fill":431,"stroke":484,"style":1699},"280",[433,1851,1853],{"x":1833,"y":1852,"fill":454,"style":455},"310","Ship it additively today",[64,1855],{"x1":1833,"y1":1856,"x2":1833,"y2":1857,"stroke":437,"style":1858},"90","118","stroke-width:2;marker-end:url(#apiver-tree-arrow);",[433,1860,537],{"x":1724,"y":1861,"fill":437,"style":569},"110",[64,1863],{"x1":1833,"y1":1864,"x2":1833,"y2":1865,"stroke":437,"style":1858},"170","198",[433,1867,537],{"x":1724,"y":1868,"fill":437,"style":569},"190",[64,1870],{"x1":1833,"y1":1871,"x2":1833,"y2":568,"stroke":437,"style":1858},"250",[433,1873,537],{"x":1724,"y":1874,"fill":437,"style":569},"270",[64,1876],{"x1":1760,"y1":1877,"x2":1878,"y2":1877,"stroke":437,"style":1858},"65","418",[433,1880,488],{"x":430,"y":1881,"fill":437,"style":569},"56",[64,1883],{"x1":1760,"y1":1884,"x2":1878,"y2":1884,"stroke":437,"style":1858},"145",[433,1886,488],{"x":430,"y":1887,"fill":437,"style":569},"136",[64,1889],{"x1":1760,"y1":1890,"x2":1878,"y2":1890,"stroke":437,"style":1858},"225",[433,1892,488],{"x":430,"y":548,"fill":437,"style":569},[426,1894],{"x":1895,"y":1827,"width":1896,"height":1829,"rx":1830,"fill":431,"stroke":534,"style":1699},"420","276",[433,1898,1900],{"x":1899,"y":1834,"fill":454,"style":455},"558","New major version and route mount",[426,1902],{"x":1895,"y":1838,"width":1896,"height":1829,"rx":1830,"fill":431,"stroke":492,"style":1699},[433,1904,1905],{"x":1899,"y":1724,"fill":454,"style":455},"Gate behind a dated version",[426,1907],{"x":1895,"y":529,"width":1896,"height":1829,"rx":1830,"fill":431,"stroke":492,"style":1699},[433,1909,1905],{"x":1899,"y":1828,"fill":454,"style":455},[433,1911,1912],{"x":1895,"y":430,"fill":437,"style":569},"Silent meaning changes fail every schema diff.",[433,1914,1916],{"x":1895,"y":1915,"fill":437,"style":569},"318","Rename the field instead of redefining it.",[14,1918,1919,1920,1924],{},"Discipline that lives only in a review checklist erodes in about six weeks. Encode it instead: snapshot the generated OpenAPI document, commit it, and fail the build when a property disappears or a new required input appears. This is the single highest-leverage test in a commercial API repository, and it is thirty lines. It also pairs directly with ",[18,1921,1923],{"href":1922},"\u002Fgetting-started-with-python-apis-for-builders\u002Fdocumenting-apis-with-openapi\u002F","documenting APIs with OpenAPI",", since the snapshot is just the published schema on disk.",[53,1926,1928],{"className":576,"code":1927,"language":578,"meta":58,"style":58},"# tests\u002Ftest_contract.py\nimport json\nimport os\nfrom pathlib import Path\n\nimport pytest\n\nfrom app.main import create_app\n\nBASELINE = Path(os.getenv(\"OPENAPI_BASELINE_PATH\", \"contracts\u002Fopenapi-v2.json\"))\n\n\ndef _schemas(document: dict) -> dict:\n    return document.get(\"components\", {}).get(\"schemas\", {})\n\n\n@pytest.fixture(scope=\"module\")\ndef current() -> dict:\n    root = create_app()\n    v2 = next(route.app for route in root.routes if getattr(route, \"path\", \"\") == \"\u002Fv2\")\n    return v2.openapi()\n\n\ndef test_no_property_disappeared(current: dict) -> None:\n    baseline = json.loads(BASELINE.read_text(encoding=\"utf-8\"))\n    for name, schema in _schemas(baseline).items():\n        assert name in _schemas(current), f\"schema {name} was removed\"\n        gone = set(schema.get(\"properties\", {})) - set(\n            _schemas(current)[name].get(\"properties\", {})\n        )\n        assert not gone, f\"{name} lost properties: {sorted(gone)}\"\n\n\ndef test_no_new_required_input(current: dict) -> None:\n    baseline = json.loads(BASELINE.read_text(encoding=\"utf-8\"))\n    for name, schema in _schemas(baseline).items():\n        if not name.endswith(\"Request\"):\n            continue\n        added = set(_schemas(current)[name].get(\"required\", [])) - set(\n            schema.get(\"required\", [])\n        )\n        assert not added, f\"{name} now requires {sorted(added)}\"\n",[27,1929,1930,1935,1942,1948,1958,1962,1969,1973,1985,1989,2008,2012,2016,2035,2054,2058,2062,2079,2093,2101,2150,2157,2161,2165,2183,2206,2219,2248,2275,2284,2288,2321,2325,2329,2346,2366,2376,2392,2397,2421,2431,2435],{"__ignoreMap":58},[62,1931,1932],{"class":64,"line":65},[62,1933,1934],{"class":254},"# tests\u002Ftest_contract.py\n",[62,1936,1937,1939],{"class":64,"line":80},[62,1938,608],{"class":152},[62,1940,1941],{"class":156}," json\n",[62,1943,1944,1946],{"class":64,"line":88},[62,1945,608],{"class":152},[62,1947,611],{"class":156},[62,1949,1950,1952,1954,1956],{"class":64,"line":96},[62,1951,590],{"class":152},[62,1953,661],{"class":156},[62,1955,608],{"class":152},[62,1957,666],{"class":156},[62,1959,1960],{"class":64,"line":104},[62,1961,302],{"emptyLinePlaceholder":301},[62,1963,1964,1966],{"class":64,"line":112},[62,1965,608],{"class":152},[62,1967,1968],{"class":156}," pytest\n",[62,1970,1971],{"class":64,"line":120},[62,1972,302],{"emptyLinePlaceholder":301},[62,1974,1975,1977,1980,1982],{"class":64,"line":128},[62,1976,590],{"class":152},[62,1978,1979],{"class":156}," app.main ",[62,1981,608],{"class":152},[62,1983,1984],{"class":156}," create_app\n",[62,1986,1987],{"class":64,"line":136},[62,1988,302],{"emptyLinePlaceholder":301},[62,1990,1991,1994,1996,1998,2001,2003,2006],{"class":64,"line":329},[62,1992,1993],{"class":76},"BASELINE",[62,1995,742],{"class":152},[62,1997,967],{"class":156},[62,1999,2000],{"class":72},"\"OPENAPI_BASELINE_PATH\"",[62,2002,396],{"class":156},[62,2004,2005],{"class":72},"\"contracts\u002Fopenapi-v2.json\"",[62,2007,978],{"class":156},[62,2009,2010],{"class":64,"line":344},[62,2011,302],{"emptyLinePlaceholder":301},[62,2013,2014],{"class":64,"line":353},[62,2015,302],{"emptyLinePlaceholder":301},[62,2017,2018,2020,2023,2026,2029,2031,2033],{"class":64,"line":360},[62,2019,858],{"class":152},[62,2021,2022],{"class":68}," _schemas",[62,2024,2025],{"class":156},"(document: ",[62,2027,2028],{"class":76},"dict",[62,2030,787],{"class":156},[62,2032,2028],{"class":76},[62,2034,714],{"class":156},[62,2036,2037,2039,2042,2045,2048,2051],{"class":64,"line":365},[62,2038,909],{"class":152},[62,2040,2041],{"class":156}," document.get(",[62,2043,2044],{"class":72},"\"components\"",[62,2046,2047],{"class":156},", {}).get(",[62,2049,2050],{"class":72},"\"schemas\"",[62,2052,2053],{"class":156},", {})\n",[62,2055,2056],{"class":64,"line":379},[62,2057,302],{"emptyLinePlaceholder":301},[62,2059,2060],{"class":64,"line":730},[62,2061,302],{"emptyLinePlaceholder":301},[62,2063,2064,2067,2069,2072,2074,2077],{"class":64,"line":748},[62,2065,2066],{"class":68},"@pytest.fixture",[62,2068,682],{"class":156},[62,2070,2071],{"class":685},"scope",[62,2073,160],{"class":152},[62,2075,2076],{"class":72},"\"module\"",[62,2078,703],{"class":156},[62,2080,2081,2083,2086,2089,2091],{"class":64,"line":762},[62,2082,858],{"class":152},[62,2084,2085],{"class":68}," current",[62,2087,2088],{"class":156},"() -> ",[62,2090,2028],{"class":76},[62,2092,714],{"class":156},[62,2094,2095,2097,2099],{"class":64,"line":767},[62,2096,1424],{"class":156},[62,2098,160],{"class":152},[62,2100,1644],{"class":156},[62,2102,2103,2105,2107,2110,2113,2116,2119,2121,2124,2127,2130,2133,2136,2138,2140,2142,2145,2148],{"class":64,"line":795},[62,2104,1499],{"class":156},[62,2106,160],{"class":152},[62,2108,2109],{"class":76}," next",[62,2111,2112],{"class":156},"(route.app ",[62,2114,2115],{"class":152},"for",[62,2117,2118],{"class":156}," route ",[62,2120,1091],{"class":152},[62,2122,2123],{"class":156}," root.routes ",[62,2125,2126],{"class":152},"if",[62,2128,2129],{"class":76}," getattr",[62,2131,2132],{"class":156},"(route, ",[62,2134,2135],{"class":72},"\"path\"",[62,2137,396],{"class":156},[62,2139,1363],{"class":72},[62,2141,1366],{"class":156},[62,2143,2144],{"class":152},"==",[62,2146,2147],{"class":72}," \"\u002Fv2\"",[62,2149,703],{"class":156},[62,2151,2152,2154],{"class":64,"line":812},[62,2153,909],{"class":152},[62,2155,2156],{"class":156}," v2.openapi()\n",[62,2158,2159],{"class":64,"line":845},[62,2160,302],{"emptyLinePlaceholder":301},[62,2162,2163],{"class":64,"line":850},[62,2164,302],{"emptyLinePlaceholder":301},[62,2166,2167,2169,2172,2175,2177,2179,2181],{"class":64,"line":855},[62,2168,858],{"class":152},[62,2170,2171],{"class":68}," test_no_property_disappeared",[62,2173,2174],{"class":156},"(current: ",[62,2176,2028],{"class":76},[62,2178,787],{"class":156},[62,2180,1454],{"class":76},[62,2182,714],{"class":156},[62,2184,2185,2188,2190,2193,2195,2198,2200,2202,2204],{"class":64,"line":884},[62,2186,2187],{"class":156},"    baseline ",[62,2189,160],{"class":152},[62,2191,2192],{"class":156}," json.loads(",[62,2194,1993],{"class":76},[62,2196,2197],{"class":156},".read_text(",[62,2199,992],{"class":685},[62,2201,160],{"class":152},[62,2203,997],{"class":72},[62,2205,978],{"class":156},[62,2207,2208,2211,2214,2216],{"class":64,"line":899},[62,2209,2210],{"class":152},"    for",[62,2212,2213],{"class":156}," name, schema ",[62,2215,1091],{"class":152},[62,2217,2218],{"class":156}," _schemas(baseline).items():\n",[62,2220,2221,2224,2227,2229,2232,2235,2238,2240,2243,2245],{"class":64,"line":906},[62,2222,2223],{"class":152},"        assert",[62,2225,2226],{"class":156}," name ",[62,2228,1091],{"class":152},[62,2230,2231],{"class":156}," _schemas(current), ",[62,2233,2234],{"class":152},"f",[62,2236,2237],{"class":72},"\"schema ",[62,2239,1378],{"class":76},[62,2241,2242],{"class":156},"name",[62,2244,1384],{"class":76},[62,2246,2247],{"class":72}," was removed\"\n",[62,2249,2250,2253,2255,2258,2261,2264,2267,2270,2272],{"class":64,"line":915},[62,2251,2252],{"class":156},"        gone ",[62,2254,160],{"class":152},[62,2256,2257],{"class":76}," set",[62,2259,2260],{"class":156},"(schema.get(",[62,2262,2263],{"class":72},"\"properties\"",[62,2265,2266],{"class":156},", {})) ",[62,2268,2269],{"class":152},"-",[62,2271,2257],{"class":76},[62,2273,2274],{"class":156},"(\n",[62,2276,2277,2280,2282],{"class":64,"line":920},[62,2278,2279],{"class":156},"            _schemas(current)[name].get(",[62,2281,2263],{"class":72},[62,2283,2053],{"class":156},[62,2285,2286],{"class":64,"line":925},[62,2287,1079],{"class":156},[62,2289,2290,2292,2294,2297,2299,2301,2303,2305,2307,2310,2313,2316,2318],{"class":64,"line":943},[62,2291,2223],{"class":152},[62,2293,827],{"class":152},[62,2295,2296],{"class":156}," gone, ",[62,2298,2234],{"class":152},[62,2300,1387],{"class":72},[62,2302,1378],{"class":76},[62,2304,2242],{"class":156},[62,2306,1384],{"class":76},[62,2308,2309],{"class":72}," lost properties: ",[62,2311,2312],{"class":76},"{sorted",[62,2314,2315],{"class":156},"(gone)",[62,2317,1384],{"class":76},[62,2319,2320],{"class":72},"\"\n",[62,2322,2323],{"class":64,"line":959},[62,2324,302],{"emptyLinePlaceholder":301},[62,2326,2327],{"class":64,"line":981},[62,2328,302],{"emptyLinePlaceholder":301},[62,2330,2331,2333,2336,2338,2340,2342,2344],{"class":64,"line":1002},[62,2332,858],{"class":152},[62,2334,2335],{"class":68}," test_no_new_required_input",[62,2337,2174],{"class":156},[62,2339,2028],{"class":76},[62,2341,787],{"class":156},[62,2343,1454],{"class":76},[62,2345,714],{"class":156},[62,2347,2348,2350,2352,2354,2356,2358,2360,2362,2364],{"class":64,"line":1010},[62,2349,2187],{"class":156},[62,2351,160],{"class":152},[62,2353,2192],{"class":156},[62,2355,1993],{"class":76},[62,2357,2197],{"class":156},[62,2359,992],{"class":685},[62,2361,160],{"class":152},[62,2363,997],{"class":72},[62,2365,978],{"class":156},[62,2367,2368,2370,2372,2374],{"class":64,"line":1016},[62,2369,2210],{"class":152},[62,2371,2213],{"class":156},[62,2373,1091],{"class":152},[62,2375,2218],{"class":156},[62,2377,2378,2381,2383,2386,2389],{"class":64,"line":1027},[62,2379,2380],{"class":152},"        if",[62,2382,827],{"class":152},[62,2384,2385],{"class":156}," name.endswith(",[62,2387,2388],{"class":72},"\"Request\"",[62,2390,2391],{"class":156},"):\n",[62,2393,2394],{"class":64,"line":1044},[62,2395,2396],{"class":152},"            continue\n",[62,2398,2399,2402,2404,2406,2409,2412,2415,2417,2419],{"class":64,"line":1061},[62,2400,2401],{"class":156},"        added ",[62,2403,160],{"class":152},[62,2405,2257],{"class":76},[62,2407,2408],{"class":156},"(_schemas(current)[name].get(",[62,2410,2411],{"class":72},"\"required\"",[62,2413,2414],{"class":156},", [])) ",[62,2416,2269],{"class":152},[62,2418,2257],{"class":76},[62,2420,2274],{"class":156},[62,2422,2423,2426,2428],{"class":64,"line":1076},[62,2424,2425],{"class":156},"            schema.get(",[62,2427,2411],{"class":72},[62,2429,2430],{"class":156},", [])\n",[62,2432,2433],{"class":64,"line":1082},[62,2434,1079],{"class":156},[62,2436,2437,2439,2441,2444,2446,2448,2450,2452,2454,2457,2459,2462,2464],{"class":64,"line":1103},[62,2438,2223],{"class":152},[62,2440,827],{"class":152},[62,2442,2443],{"class":156}," added, ",[62,2445,2234],{"class":152},[62,2447,1387],{"class":72},[62,2449,1378],{"class":76},[62,2451,2242],{"class":156},[62,2453,1384],{"class":76},[62,2455,2456],{"class":72}," now requires ",[62,2458,2312],{"class":76},[62,2460,2461],{"class":156},"(added)",[62,2463,1384],{"class":76},[62,2465,2320],{"class":72},[14,2467,2468,2469,2473],{},"The asymmetry in that second test is deliberate: adding a required field to a request breaks callers, while adding a required field to a response does not — it is the removal that hurts there. Wire this into the same pipeline that runs your other checks, described in ",[18,2470,2472],{"href":2471},"\u002Fscaling-and-operating-production-python-apis\u002Ftesting-python-apis-with-pytest\u002F","testing Python APIs with pytest",", and regenerate the baseline in the same commit that ships an intentional break.",[32,2475],{},[35,2477,2479],{"id":2478},"step-4-resolve-the-effective-version-on-every-request","Step 4: Resolve the effective version on every request",[14,2481,2482],{},"Precedence is the whole design. Read the explicit request header first, fall back to the version pinned on the account when they first integrated, then fall back to the configured floor — and reject anything unrecognised rather than silently serving the newest behaviour. Silently upgrading a client that sent a typo is how you turn a support ticket into an incident.",[53,2484,2486],{"className":576,"code":2485,"language":578,"meta":58,"style":58},"# app\u002Fversioning\u002Fresolver.py\nimport os\n\nfrom fastapi import Depends, HTTPException, Request, status\n\nfrom app.auth import Account, current_account\nfrom app.versioning.registry import ApiVersion, latest_version, load_versions\n\n\nasync def resolve_version(\n    request: Request,\n    account: Account = Depends(current_account),\n) -> ApiVersion:\n    versions = load_versions()\n    header_name = os.getenv(\"API_VERSION_HEADER\", \"X-Api-Version\")\n    requested = (\n        request.headers.get(header_name)\n        or account.pinned_version\n        or os.getenv(\"API_DEFAULT_VERSION\")\n    )\n\n    if requested in versions:\n        resolved = versions[requested]\n        request.state.api_version = resolved\n        return resolved\n\n    match os.getenv(\"API_UNKNOWN_VERSION_POLICY\", \"reject\"):\n        case \"reject\":\n            raise HTTPException(\n                status_code=status.HTTP_400_BAD_REQUEST,\n                detail={\n                    \"error\": \"unknown_api_version\",\n                    \"requested\": requested,\n                    \"supported\": sorted(versions),\n                },\n            )\n        case \"latest\":\n            resolved = latest_version()\n            request.state.api_version = resolved\n            return resolved\n        case unknown:\n            raise RuntimeError(f\"invalid API_UNKNOWN_VERSION_POLICY: {unknown}\")\n",[27,2487,2488,2493,2499,2503,2514,2518,2530,2541,2545,2549,2561,2566,2576,2581,2589,2609,2619,2624,2632,2643,2647,2651,2663,2673,2683,2689,2693,2710,2720,2728,2743,2753,2765,2773,2786,2791,2796,2805,2815,2824,2831,2838],{"__ignoreMap":58},[62,2489,2490],{"class":64,"line":65},[62,2491,2492],{"class":254},"# app\u002Fversioning\u002Fresolver.py\n",[62,2494,2495,2497],{"class":64,"line":80},[62,2496,608],{"class":152},[62,2498,611],{"class":156},[62,2500,2501],{"class":64,"line":88},[62,2502,302],{"emptyLinePlaceholder":301},[62,2504,2505,2507,2509,2511],{"class":64,"line":96},[62,2506,590],{"class":152},[62,2508,1207],{"class":156},[62,2510,608],{"class":152},[62,2512,2513],{"class":156}," Depends, HTTPException, Request, status\n",[62,2515,2516],{"class":64,"line":104},[62,2517,302],{"emptyLinePlaceholder":301},[62,2519,2520,2522,2525,2527],{"class":64,"line":112},[62,2521,590],{"class":152},[62,2523,2524],{"class":156}," app.auth ",[62,2526,608],{"class":152},[62,2528,2529],{"class":156}," Account, current_account\n",[62,2531,2532,2534,2536,2538],{"class":64,"line":120},[62,2533,590],{"class":152},[62,2535,1223],{"class":156},[62,2537,608],{"class":152},[62,2539,2540],{"class":156}," ApiVersion, latest_version, load_versions\n",[62,2542,2543],{"class":64,"line":128},[62,2544,302],{"emptyLinePlaceholder":301},[62,2546,2547],{"class":64,"line":136},[62,2548,302],{"emptyLinePlaceholder":301},[62,2550,2551,2554,2556,2559],{"class":64,"line":329},[62,2552,2553],{"class":152},"async",[62,2555,1577],{"class":152},[62,2557,2558],{"class":68}," resolve_version",[62,2560,2274],{"class":156},[62,2562,2563],{"class":64,"line":344},[62,2564,2565],{"class":156},"    request: Request,\n",[62,2567,2568,2571,2573],{"class":64,"line":353},[62,2569,2570],{"class":156},"    account: Account ",[62,2572,160],{"class":152},[62,2574,2575],{"class":156}," Depends(current_account),\n",[62,2577,2578],{"class":64,"line":360},[62,2579,2580],{"class":156},") -> ApiVersion:\n",[62,2582,2583,2585,2587],{"class":64,"line":365},[62,2584,1133],{"class":156},[62,2586,160],{"class":152},[62,2588,1138],{"class":156},[62,2590,2591,2594,2596,2599,2602,2604,2607],{"class":64,"line":379},[62,2592,2593],{"class":156},"    header_name ",[62,2595,160],{"class":152},[62,2597,2598],{"class":156}," os.getenv(",[62,2600,2601],{"class":72},"\"API_VERSION_HEADER\"",[62,2603,396],{"class":156},[62,2605,2606],{"class":72},"\"X-Api-Version\"",[62,2608,703],{"class":156},[62,2610,2611,2614,2616],{"class":64,"line":730},[62,2612,2613],{"class":156},"    requested ",[62,2615,160],{"class":152},[62,2617,2618],{"class":156}," (\n",[62,2620,2621],{"class":64,"line":748},[62,2622,2623],{"class":156},"        request.headers.get(header_name)\n",[62,2625,2626,2629],{"class":64,"line":762},[62,2627,2628],{"class":152},"        or",[62,2630,2631],{"class":156}," account.pinned_version\n",[62,2633,2634,2636,2638,2641],{"class":64,"line":767},[62,2635,2628],{"class":152},[62,2637,2598],{"class":156},[62,2639,2640],{"class":72},"\"API_DEFAULT_VERSION\"",[62,2642,703],{"class":156},[62,2644,2645],{"class":64,"line":795},[62,2646,1394],{"class":156},[62,2648,2649],{"class":64,"line":812},[62,2650,302],{"emptyLinePlaceholder":301},[62,2652,2653,2655,2658,2660],{"class":64,"line":845},[62,2654,887],{"class":152},[62,2656,2657],{"class":156}," requested ",[62,2659,1091],{"class":152},[62,2661,2662],{"class":156}," versions:\n",[62,2664,2665,2668,2670],{"class":64,"line":850},[62,2666,2667],{"class":156},"        resolved ",[62,2669,160],{"class":152},[62,2671,2672],{"class":156}," versions[requested]\n",[62,2674,2675,2678,2680],{"class":64,"line":855},[62,2676,2677],{"class":156},"        request.state.api_version ",[62,2679,160],{"class":152},[62,2681,2682],{"class":156}," resolved\n",[62,2684,2685,2687],{"class":64,"line":884},[62,2686,815],{"class":152},[62,2688,2682],{"class":156},[62,2690,2691],{"class":64,"line":899},[62,2692,302],{"emptyLinePlaceholder":301},[62,2694,2695,2698,2700,2703,2705,2708],{"class":64,"line":906},[62,2696,2697],{"class":152},"    match",[62,2699,2598],{"class":156},[62,2701,2702],{"class":72},"\"API_UNKNOWN_VERSION_POLICY\"",[62,2704,396],{"class":156},[62,2706,2707],{"class":72},"\"reject\"",[62,2709,2391],{"class":156},[62,2711,2712,2715,2718],{"class":64,"line":915},[62,2713,2714],{"class":152},"        case",[62,2716,2717],{"class":72}," \"reject\"",[62,2719,714],{"class":156},[62,2721,2722,2725],{"class":64,"line":920},[62,2723,2724],{"class":152},"            raise",[62,2726,2727],{"class":156}," HTTPException(\n",[62,2729,2730,2733,2735,2738,2741],{"class":64,"line":925},[62,2731,2732],{"class":685},"                status_code",[62,2734,160],{"class":152},[62,2736,2737],{"class":156},"status.",[62,2739,2740],{"class":76},"HTTP_400_BAD_REQUEST",[62,2742,1333],{"class":156},[62,2744,2745,2748,2750],{"class":64,"line":943},[62,2746,2747],{"class":685},"                detail",[62,2749,160],{"class":152},[62,2751,2752],{"class":156},"{\n",[62,2754,2755,2758,2760,2763],{"class":64,"line":959},[62,2756,2757],{"class":72},"                    \"error\"",[62,2759,1604],{"class":156},[62,2761,2762],{"class":72},"\"unknown_api_version\"",[62,2764,1333],{"class":156},[62,2766,2767,2770],{"class":64,"line":981},[62,2768,2769],{"class":72},"                    \"requested\"",[62,2771,2772],{"class":156},": requested,\n",[62,2774,2775,2778,2780,2783],{"class":64,"line":1002},[62,2776,2777],{"class":72},"                    \"supported\"",[62,2779,1604],{"class":156},[62,2781,2782],{"class":76},"sorted",[62,2784,2785],{"class":156},"(versions),\n",[62,2787,2788],{"class":64,"line":1010},[62,2789,2790],{"class":156},"                },\n",[62,2792,2793],{"class":64,"line":1016},[62,2794,2795],{"class":156},"            )\n",[62,2797,2798,2800,2803],{"class":64,"line":1027},[62,2799,2714],{"class":152},[62,2801,2802],{"class":72}," \"latest\"",[62,2804,714],{"class":156},[62,2806,2807,2810,2812],{"class":64,"line":1044},[62,2808,2809],{"class":156},"            resolved ",[62,2811,160],{"class":152},[62,2813,2814],{"class":156}," latest_version()\n",[62,2816,2817,2820,2822],{"class":64,"line":1061},[62,2818,2819],{"class":156},"            request.state.api_version ",[62,2821,160],{"class":152},[62,2823,2682],{"class":156},[62,2825,2826,2829],{"class":64,"line":1076},[62,2827,2828],{"class":152},"            return",[62,2830,2682],{"class":156},[62,2832,2833,2835],{"class":64,"line":1082},[62,2834,2714],{"class":152},[62,2836,2837],{"class":156}," unknown:\n",[62,2839,2840,2842,2845,2847,2849,2852,2854,2857,2859,2861],{"class":64,"line":1103},[62,2841,2724],{"class":152},[62,2843,2844],{"class":76}," RuntimeError",[62,2846,682],{"class":156},[62,2848,2234],{"class":152},[62,2850,2851],{"class":72},"\"invalid API_UNKNOWN_VERSION_POLICY: ",[62,2853,1378],{"class":76},[62,2855,2856],{"class":156},"unknown",[62,2858,1384],{"class":76},[62,2860,1387],{"class":72},[62,2862,703],{"class":156},[14,2864,2865,2866,266],{},"Pin the account's version on first successful call and never change it without the customer asking. Store it as a column on the account row, expose it in your dashboard, and let customers upgrade themselves with a single toggle — self-serve upgrades are dramatically cheaper than a support thread, and a customer who can roll back in one click will try the new version far sooner. That toggle belongs next to their keys in the ",[18,2867,2869],{"href":2868},"\u002Fbuilding-monetizing-api-driven-micro-saas\u002Fdeploying-apis-to-render-or-vercel\u002Fcreating-a-developer-portal-for-your-api\u002F","developer portal",[14,2871,2872,2873,2876],{},"With the version on ",[27,2874,2875],{},"request.state",", transformations become a chain applied on the way out. Each dated change ships one function that converts a current-shape payload back to the older shape, and the chain runs in reverse date order for anyone pinned behind it. New code never learns about old shapes; only the transformer does.",[53,2878,2880],{"className":576,"code":2879,"language":578,"meta":58,"style":58},"# app\u002Fversioning\u002Ftransforms.py\nfrom collections.abc import Callable\nfrom typing import Any\n\nPayload = dict[str, Any]\nTransformer = Callable[[Payload], Payload]\n\n\ndef split_customer_name(payload: Payload) -> Payload:\n    \"\"\"Before 2026-03-01 the API exposed first_name and last_name.\"\"\"\n    full = payload.pop(\"name\", \"\")\n    first, _, last = full.partition(\" \")\n    return {**payload, \"first_name\": first, \"last_name\": last}\n\n\ndef cents_to_float_amount(payload: Payload) -> Payload:\n    \"\"\"Before 2025-11-01 amounts were floats in major units.\"\"\"\n    if \"amount_cents\" in payload:\n        payload[\"amount\"] = payload.pop(\"amount_cents\") \u002F 100\n    return payload\n\n\nDOWNGRADES: list[tuple[str, Transformer]] = [\n    (\"2025-11-01\", cents_to_float_amount),\n    (\"2026-03-01\", split_customer_name),\n]\n\n\ndef downgrade(payload: Payload, target_version: str) -> Payload:\n    for introduced_at, transform in reversed(DOWNGRADES):\n        if introduced_at > target_version:\n            payload = transform(payload)\n    return payload\n",[27,2881,2882,2887,2899,2911,2915,2930,2940,2944,2948,2958,2963,2982,2997,3021,3025,3029,3038,3043,3056,3082,3089,3093,3097,3115,3126,3135,3139,3143,3147,3162,3180,3193,3203],{"__ignoreMap":58},[62,2883,2884],{"class":64,"line":65},[62,2885,2886],{"class":254},"# app\u002Fversioning\u002Ftransforms.py\n",[62,2888,2889,2891,2894,2896],{"class":64,"line":80},[62,2890,590],{"class":152},[62,2892,2893],{"class":156}," collections.abc ",[62,2895,608],{"class":152},[62,2897,2898],{"class":156}," Callable\n",[62,2900,2901,2903,2906,2908],{"class":64,"line":88},[62,2902,590],{"class":152},[62,2904,2905],{"class":156}," typing ",[62,2907,608],{"class":152},[62,2909,2910],{"class":156}," Any\n",[62,2912,2913],{"class":64,"line":96},[62,2914,302],{"emptyLinePlaceholder":301},[62,2916,2917,2920,2922,2925,2927],{"class":64,"line":104},[62,2918,2919],{"class":156},"Payload ",[62,2921,160],{"class":152},[62,2923,2924],{"class":156}," dict[",[62,2926,867],{"class":76},[62,2928,2929],{"class":156},", Any]\n",[62,2931,2932,2935,2937],{"class":64,"line":112},[62,2933,2934],{"class":156},"Transformer ",[62,2936,160],{"class":152},[62,2938,2939],{"class":156}," Callable[[Payload], Payload]\n",[62,2941,2942],{"class":64,"line":120},[62,2943,302],{"emptyLinePlaceholder":301},[62,2945,2946],{"class":64,"line":128},[62,2947,302],{"emptyLinePlaceholder":301},[62,2949,2950,2952,2955],{"class":64,"line":136},[62,2951,858],{"class":152},[62,2953,2954],{"class":68}," split_customer_name",[62,2956,2957],{"class":156},"(payload: Payload) -> Payload:\n",[62,2959,2960],{"class":64,"line":329},[62,2961,2962],{"class":72},"    \"\"\"Before 2026-03-01 the API exposed first_name and last_name.\"\"\"\n",[62,2964,2965,2968,2970,2973,2976,2978,2980],{"class":64,"line":344},[62,2966,2967],{"class":156},"    full ",[62,2969,160],{"class":152},[62,2971,2972],{"class":156}," payload.pop(",[62,2974,2975],{"class":72},"\"name\"",[62,2977,396],{"class":156},[62,2979,1363],{"class":72},[62,2981,703],{"class":156},[62,2983,2984,2987,2989,2992,2995],{"class":64,"line":353},[62,2985,2986],{"class":156},"    first, _, last ",[62,2988,160],{"class":152},[62,2990,2991],{"class":156}," full.partition(",[62,2993,2994],{"class":72},"\" \"",[62,2996,703],{"class":156},[62,2998,2999,3001,3003,3006,3009,3012,3015,3018],{"class":64,"line":360},[62,3000,909],{"class":152},[62,3002,1598],{"class":156},[62,3004,3005],{"class":152},"**",[62,3007,3008],{"class":156},"payload, ",[62,3010,3011],{"class":72},"\"first_name\"",[62,3013,3014],{"class":156},": first, ",[62,3016,3017],{"class":72},"\"last_name\"",[62,3019,3020],{"class":156},": last}\n",[62,3022,3023],{"class":64,"line":365},[62,3024,302],{"emptyLinePlaceholder":301},[62,3026,3027],{"class":64,"line":379},[62,3028,302],{"emptyLinePlaceholder":301},[62,3030,3031,3033,3036],{"class":64,"line":730},[62,3032,858],{"class":152},[62,3034,3035],{"class":68}," cents_to_float_amount",[62,3037,2957],{"class":156},[62,3039,3040],{"class":64,"line":748},[62,3041,3042],{"class":72},"    \"\"\"Before 2025-11-01 amounts were floats in major units.\"\"\"\n",[62,3044,3045,3047,3050,3053],{"class":64,"line":762},[62,3046,887],{"class":152},[62,3048,3049],{"class":72}," \"amount_cents\"",[62,3051,3052],{"class":152}," in",[62,3054,3055],{"class":156}," payload:\n",[62,3057,3058,3061,3064,3067,3069,3071,3074,3076,3079],{"class":64,"line":767},[62,3059,3060],{"class":156},"        payload[",[62,3062,3063],{"class":72},"\"amount\"",[62,3065,3066],{"class":156},"] ",[62,3068,160],{"class":152},[62,3070,2972],{"class":156},[62,3072,3073],{"class":72},"\"amount_cents\"",[62,3075,1366],{"class":156},[62,3077,3078],{"class":152},"\u002F",[62,3080,3081],{"class":76}," 100\n",[62,3083,3084,3086],{"class":64,"line":795},[62,3085,909],{"class":152},[62,3087,3088],{"class":156}," payload\n",[62,3090,3091],{"class":64,"line":812},[62,3092,302],{"emptyLinePlaceholder":301},[62,3094,3095],{"class":64,"line":845},[62,3096,302],{"emptyLinePlaceholder":301},[62,3098,3099,3102,3105,3107,3110,3112],{"class":64,"line":850},[62,3100,3101],{"class":76},"DOWNGRADES",[62,3103,3104],{"class":156},": list[tuple[",[62,3106,867],{"class":76},[62,3108,3109],{"class":156},", Transformer]] ",[62,3111,160],{"class":152},[62,3113,3114],{"class":156}," [\n",[62,3116,3117,3120,3123],{"class":64,"line":855},[62,3118,3119],{"class":156},"    (",[62,3121,3122],{"class":72},"\"2025-11-01\"",[62,3124,3125],{"class":156},", cents_to_float_amount),\n",[62,3127,3128,3130,3132],{"class":64,"line":884},[62,3129,3119],{"class":156},[62,3131,313],{"class":72},[62,3133,3134],{"class":156},", split_customer_name),\n",[62,3136,3137],{"class":64,"line":899},[62,3138,272],{"class":156},[62,3140,3141],{"class":64,"line":906},[62,3142,302],{"emptyLinePlaceholder":301},[62,3144,3145],{"class":64,"line":915},[62,3146,302],{"emptyLinePlaceholder":301},[62,3148,3149,3151,3154,3157,3159],{"class":64,"line":920},[62,3150,858],{"class":152},[62,3152,3153],{"class":68}," downgrade",[62,3155,3156],{"class":156},"(payload: Payload, target_version: ",[62,3158,867],{"class":76},[62,3160,3161],{"class":156},") -> Payload:\n",[62,3163,3164,3166,3169,3171,3174,3176,3178],{"class":64,"line":925},[62,3165,2210],{"class":152},[62,3167,3168],{"class":156}," introduced_at, transform ",[62,3170,1091],{"class":152},[62,3172,3173],{"class":76}," reversed",[62,3175,682],{"class":156},[62,3177,3101],{"class":76},[62,3179,2391],{"class":156},[62,3181,3182,3184,3187,3190],{"class":64,"line":943},[62,3183,2380],{"class":152},[62,3185,3186],{"class":156}," introduced_at ",[62,3188,3189],{"class":152},">",[62,3191,3192],{"class":156}," target_version:\n",[62,3194,3195,3198,3200],{"class":64,"line":959},[62,3196,3197],{"class":156},"            payload ",[62,3199,160],{"class":152},[62,3201,3202],{"class":156}," transform(payload)\n",[62,3204,3205,3207],{"class":64,"line":981},[62,3206,909],{"class":152},[62,3208,3088],{"class":156},[32,3210],{},[35,3212,3214],{"id":3213},"step-5-emit-deprecation-and-sunset-headers-a-machine-can-read","Step 5: Emit deprecation and sunset headers a machine can read",[14,3216,3217,3218,3221,3222,3225,3226,3229,3230,3233,3234,3237],{},"Blog posts do not reach the integrator who left the company. Headers do, because they land in the log aggregator of the team still running the integration. Use ",[27,3219,3220],{},"Deprecation"," from RFC 9745, whose value is a structured-field date — an ",[27,3223,3224],{},"@"," followed by Unix seconds — and ",[27,3227,3228],{},"Sunset"," from RFC 8594, whose value is an IMF-fixdate string. Add a ",[27,3231,3232],{},"Link"," header pointing at your migration guide with ",[27,3235,3236],{},"rel=\"sunset\"",". Three headers, no coordination meeting, and any client with basic response logging inherits an early warning system.",[53,3239,3241],{"className":576,"code":3240,"language":578,"meta":58,"style":58},"# app\u002Fversioning\u002Fmiddleware.py\nimport os\nfrom email.utils import format_datetime\n\nfrom starlette.middleware.base import BaseHTTPMiddleware\nfrom starlette.requests import Request\nfrom starlette.responses import Response\n\n\nclass DeprecationHeaderMiddleware(BaseHTTPMiddleware):\n    async def dispatch(self, request: Request, call_next) -> Response:\n        response = await call_next(request)\n        version = getattr(request.state, \"api_version\", None)\n        if version is None or not version.is_deprecated():\n            return response\n\n        response.headers[\"Deprecation\"] = f\"@{int(version.deprecated.timestamp())}\"\n        if version.sunset is not None:\n            response.headers[\"Sunset\"] = format_datetime(version.sunset, usegmt=True)\n        policy_url = os.getenv(\"API_VERSION_POLICY_URL\")\n        if policy_url:\n            response.headers[\"Link\"] = f'\u003C{policy_url}>; rel=\"sunset\"; type=\"text\u002Fhtml\"'\n        response.headers[\"Vary\"] = os.getenv(\"API_VERSION_HEADER\", \"X-Api-Version\")\n        return response\n",[27,3242,3243,3248,3254,3266,3270,3282,3294,3306,3310,3314,3328,3340,3353,3374,3393,3400,3404,3431,3446,3470,3484,3491,3517,3538],{"__ignoreMap":58},[62,3244,3245],{"class":64,"line":65},[62,3246,3247],{"class":254},"# app\u002Fversioning\u002Fmiddleware.py\n",[62,3249,3250,3252],{"class":64,"line":80},[62,3251,608],{"class":152},[62,3253,611],{"class":156},[62,3255,3256,3258,3261,3263],{"class":64,"line":88},[62,3257,590],{"class":152},[62,3259,3260],{"class":156}," email.utils ",[62,3262,608],{"class":152},[62,3264,3265],{"class":156}," format_datetime\n",[62,3267,3268],{"class":64,"line":96},[62,3269,302],{"emptyLinePlaceholder":301},[62,3271,3272,3274,3277,3279],{"class":64,"line":104},[62,3273,590],{"class":152},[62,3275,3276],{"class":156}," starlette.middleware.base ",[62,3278,608],{"class":152},[62,3280,3281],{"class":156}," BaseHTTPMiddleware\n",[62,3283,3284,3286,3289,3291],{"class":64,"line":112},[62,3285,590],{"class":152},[62,3287,3288],{"class":156}," starlette.requests ",[62,3290,608],{"class":152},[62,3292,3293],{"class":156}," Request\n",[62,3295,3296,3298,3301,3303],{"class":64,"line":120},[62,3297,590],{"class":152},[62,3299,3300],{"class":156}," starlette.responses ",[62,3302,608],{"class":152},[62,3304,3305],{"class":156}," Response\n",[62,3307,3308],{"class":64,"line":128},[62,3309,302],{"emptyLinePlaceholder":301},[62,3311,3312],{"class":64,"line":136},[62,3313,302],{"emptyLinePlaceholder":301},[62,3315,3316,3318,3321,3323,3326],{"class":64,"line":329},[62,3317,708],{"class":152},[62,3319,3320],{"class":68}," DeprecationHeaderMiddleware",[62,3322,682],{"class":156},[62,3324,3325],{"class":68},"BaseHTTPMiddleware",[62,3327,2391],{"class":156},[62,3329,3330,3332,3334,3337],{"class":64,"line":344},[62,3331,1574],{"class":152},[62,3333,1577],{"class":152},[62,3335,3336],{"class":68}," dispatch",[62,3338,3339],{"class":156},"(self, request: Request, call_next) -> Response:\n",[62,3341,3342,3345,3347,3350],{"class":64,"line":353},[62,3343,3344],{"class":156},"        response ",[62,3346,160],{"class":152},[62,3348,3349],{"class":152}," await",[62,3351,3352],{"class":156}," call_next(request)\n",[62,3354,3355,3358,3360,3362,3365,3368,3370,3372],{"class":64,"line":360},[62,3356,3357],{"class":156},"        version ",[62,3359,160],{"class":152},[62,3361,2129],{"class":76},[62,3363,3364],{"class":156},"(request.state, ",[62,3366,3367],{"class":72},"\"api_version\"",[62,3369,396],{"class":156},[62,3371,1454],{"class":76},[62,3373,703],{"class":156},[62,3375,3376,3378,3381,3383,3385,3388,3390],{"class":64,"line":365},[62,3377,2380],{"class":152},[62,3379,3380],{"class":156}," version ",[62,3382,824],{"class":152},[62,3384,739],{"class":76},[62,3386,3387],{"class":152}," or",[62,3389,827],{"class":152},[62,3391,3392],{"class":156}," version.is_deprecated():\n",[62,3394,3395,3397],{"class":64,"line":379},[62,3396,2828],{"class":152},[62,3398,3399],{"class":156}," response\n",[62,3401,3402],{"class":64,"line":730},[62,3403,302],{"emptyLinePlaceholder":301},[62,3405,3406,3409,3412,3414,3416,3418,3421,3424,3427,3429],{"class":64,"line":748},[62,3407,3408],{"class":156},"        response.headers[",[62,3410,3411],{"class":72},"\"Deprecation\"",[62,3413,3066],{"class":156},[62,3415,160],{"class":152},[62,3417,1372],{"class":152},[62,3419,3420],{"class":72},"\"@",[62,3422,3423],{"class":76},"{int",[62,3425,3426],{"class":156},"(version.deprecated.timestamp())",[62,3428,1384],{"class":76},[62,3430,2320],{"class":72},[62,3432,3433,3435,3438,3440,3442,3444],{"class":64,"line":762},[62,3434,2380],{"class":152},[62,3436,3437],{"class":156}," version.sunset ",[62,3439,824],{"class":152},[62,3441,827],{"class":152},[62,3443,739],{"class":76},[62,3445,714],{"class":156},[62,3447,3448,3451,3454,3456,3458,3461,3464,3466,3468],{"class":64,"line":767},[62,3449,3450],{"class":156},"            response.headers[",[62,3452,3453],{"class":72},"\"Sunset\"",[62,3455,3066],{"class":156},[62,3457,160],{"class":152},[62,3459,3460],{"class":156}," format_datetime(version.sunset, ",[62,3462,3463],{"class":685},"usegmt",[62,3465,160],{"class":152},[62,3467,691],{"class":76},[62,3469,703],{"class":156},[62,3471,3472,3475,3477,3479,3482],{"class":64,"line":795},[62,3473,3474],{"class":156},"        policy_url ",[62,3476,160],{"class":152},[62,3478,2598],{"class":156},[62,3480,3481],{"class":72},"\"API_VERSION_POLICY_URL\"",[62,3483,703],{"class":156},[62,3485,3486,3488],{"class":64,"line":812},[62,3487,2380],{"class":152},[62,3489,3490],{"class":156}," policy_url:\n",[62,3492,3493,3495,3498,3500,3502,3504,3507,3509,3512,3514],{"class":64,"line":845},[62,3494,3450],{"class":156},[62,3496,3497],{"class":72},"\"Link\"",[62,3499,3066],{"class":156},[62,3501,160],{"class":152},[62,3503,1372],{"class":152},[62,3505,3506],{"class":72},"'\u003C",[62,3508,1378],{"class":76},[62,3510,3511],{"class":156},"policy_url",[62,3513,1384],{"class":76},[62,3515,3516],{"class":72},">; rel=\"sunset\"; type=\"text\u002Fhtml\"'\n",[62,3518,3519,3521,3524,3526,3528,3530,3532,3534,3536],{"class":64,"line":850},[62,3520,3408],{"class":156},[62,3522,3523],{"class":72},"\"Vary\"",[62,3525,3066],{"class":156},[62,3527,160],{"class":152},[62,3529,2598],{"class":156},[62,3531,2601],{"class":72},[62,3533,396],{"class":156},[62,3535,2606],{"class":72},[62,3537,703],{"class":156},[62,3539,3540,3542],{"class":64,"line":855},[62,3541,815],{"class":152},[62,3543,3399],{"class":156},[14,3545,3546,3547,3550,3551,3553,3554,266],{},"Announce the sunset date at least twelve months out for a paid API with enterprise customers, six months for a self-serve developer tool. Then schedule brownouts: short, pre-announced windows where the deprecated version returns ",[27,3548,3549],{},"503"," with a ",[27,3552,3228],{}," header. Two brownouts — thirty minutes at three months out, two hours at one month out — flush out the integrations nobody is watching while the consequences are still recoverable. This is the single most effective migration tool available, because it converts a silent risk into a calendar event with a known blast radius. The mechanics of retiring a single route rather than a whole generation are covered in ",[18,3555,3557],{"href":3556},"\u002Fscaling-and-operating-production-python-apis\u002Fversioning-and-evolving-public-apis\u002Fdeprecating-an-api-endpoint-without-breaking-customers\u002F","deprecating an API endpoint without breaking customers",[407,3559,416,3564,416,3567,416,3570,416,3572,416,3575,416,3580,416,3583,416,3586,416,3588,416,3591,416,3594,416,3597,416,3600,416,3603,416,3605,416,3607,416,3609,416,3611,416,3615,416,3618,416,3621,416,3624,416,3628,416,3632,416,3635,416,3638,416,3642,416,3646],{"viewBox":3560,"role":410,"ariaLabelledBy":3561,"xmlns":414,"style":415},"0 0 720 250",[3562,3563],"apiver-time-t","apiver-time-d",[418,3565,3566],{"id":3562},"Twelve-month deprecation timeline with brownouts",[422,3568,3569],{"id":3563},"A timeline from announcement at month zero through email waves at months one and six, a thirty minute brownout at month nine, a two hour brownout at month eleven, and full sunset at month twelve.",[426,3571],{"x":428,"y":428,"width":429,"height":1871,"fill":431},[433,3573,3574],{"x":435,"y":1691,"fill":437,"style":438},"Sunset schedule for a paid API, months from announcement",[426,3576],{"x":3577,"y":477,"width":3578,"height":3579,"rx":1679,"fill":447,"stroke":448,"style":449},"60","600","14",[426,3581],{"x":3577,"y":477,"width":3582,"height":3579,"rx":1679,"fill":484},"550",[64,3584],{"x1":3577,"y1":3585,"x2":3577,"y2":1775,"stroke":448,"style":449},"106",[64,3587],{"x1":1861,"y1":3585,"x2":1861,"y2":1775,"stroke":448,"style":449},[64,3589],{"x1":3590,"y1":3585,"x2":3590,"y2":1775,"stroke":448,"style":449},"360",[64,3592],{"x1":3593,"y1":3585,"x2":3593,"y2":1775,"stroke":448,"style":449},"510",[64,3595],{"x1":3596,"y1":3585,"x2":3596,"y2":1775,"stroke":448,"style":449},"610",[64,3598],{"x1":3599,"y1":3585,"x2":3599,"y2":1775,"stroke":448,"style":449},"660",[480,3601],{"cx":3577,"cy":3602,"r":1679,"fill":1698},"119",[480,3604],{"cx":3590,"cy":3602,"r":1679,"fill":1698},[480,3606],{"cx":3593,"cy":3602,"r":1679,"fill":492},[480,3608],{"cx":3596,"cy":3602,"r":1679,"fill":492},[480,3610],{"cx":3599,"cy":3602,"r":1679,"fill":534},[433,3612,3614],{"x":3577,"y":3613,"fill":454,"style":438},"72","M0 announce",[433,3616,3617],{"x":3577,"y":1856,"fill":437,"style":569},"headers go live",[433,3619,3620],{"x":1688,"y":3613,"fill":454,"style":438},"M6 email wave",[433,3622,3623],{"x":1688,"y":1856,"fill":437,"style":569},"per-account usage",[433,3625,3627],{"x":3626,"y":1728,"fill":454,"style":438},"470","M9 brownout 30 min",[433,3629,3631],{"x":3626,"y":3630,"fill":454,"style":438},"204","M11 brownout 2 h",[64,3633],{"x1":3593,"y1":1775,"x2":3593,"y2":3634,"stroke":437,"style":449},"158",[64,3636],{"x1":3596,"y1":1775,"x2":3596,"y2":3637,"stroke":437,"style":449},"194",[433,3639,3641],{"x":3599,"y":3613,"fill":454,"style":3640},"text-anchor:end;font-size:12;font-family:var(--font-sans);","M12 sunset",[433,3643,3645],{"x":3599,"y":1856,"fill":437,"style":3644},"text-anchor:end;font-size:11;font-family:var(--font-sans);","410 Gone",[433,3647,3649],{"x":435,"y":3648,"fill":437,"style":569},"234","Brownouts convert silent integration risk into a scheduled, recoverable event.",[32,3651],{},[35,3653,3655],{"id":3654},"step-6-run-the-migration-as-a-campaign-not-an-announcement","Step 6: Run the migration as a campaign, not an announcement",[14,3657,3658],{},"Traffic does not move because you published a guide. It moves because you identified the twelve accounts responsible for 90% of the old version's calls and worked them individually. Query your usage table weekly, sort by volume, and treat the head of that distribution as a sales list. For everyone in the tail, an automated email with their own numbers in it — endpoints called, request counts, days remaining — outperforms a generic newsletter by a wide margin.",[53,3660,3662],{"className":576,"code":3661,"language":578,"meta":58,"style":58},"# scripts\u002Fmigration_report.py\nimport asyncio\nimport os\n\nimport httpx\nfrom sqlalchemy import text\nfrom sqlalchemy.ext.asyncio import create_async_engine\n\nQUERY = text(\n    \"\"\"\n    SELECT account_id,\n           count(*) AS calls,\n           count(DISTINCT route) AS routes,\n           max(created_at) AS last_seen\n    FROM api_usage_events\n    WHERE api_version = :old_version\n      AND created_at > now() - interval '7 days'\n    GROUP BY account_id\n    ORDER BY calls DESC\n    \"\"\"\n)\n\n\nasync def main() -> None:\n    engine = create_async_engine(os.environ[\"DATABASE_URL\"], pool_pre_ping=True)\n    async with engine.connect() as conn:\n        rows = (await conn.execute(QUERY, {\"old_version\": os.environ[\"API_OLD_VERSION\"]})).all()\n\n    endpoint = os.environ[\"NOTIFY_WEBHOOK_URL\"]\n    async with httpx.AsyncClient(timeout=10.0) as client:\n        for row in rows:\n            payload = {\n                \"account_id\": str(row.account_id),\n                \"calls_last_7d\": row.calls,\n                \"routes\": row.routes,\n                \"sunset_date\": os.environ[\"API_SUNSET_DATE\"],\n                \"guide_url\": os.environ[\"API_VERSION_POLICY_URL\"],\n            }\n            response = await client.post(endpoint, json=payload)\n            response.raise_for_status()\n    await engine.dispose()\n\n\nif __name__ == \"__main__\":\n    asyncio.run(main())\n",[27,3663,3664,3669,3676,3682,3686,3693,3705,3717,3721,3731,3736,3741,3746,3751,3756,3761,3766,3771,3776,3781,3785,3789,3793,3797,3812,3837,3852,3885,3889,3904,3928,3940,3948,3960,3968,3976,3989,4000,4005,4025,4030,4038,4042,4046,4061],{"__ignoreMap":58},[62,3665,3666],{"class":64,"line":65},[62,3667,3668],{"class":254},"# scripts\u002Fmigration_report.py\n",[62,3670,3671,3673],{"class":64,"line":80},[62,3672,608],{"class":152},[62,3674,3675],{"class":156}," asyncio\n",[62,3677,3678,3680],{"class":64,"line":88},[62,3679,608],{"class":152},[62,3681,611],{"class":156},[62,3683,3684],{"class":64,"line":96},[62,3685,302],{"emptyLinePlaceholder":301},[62,3687,3688,3690],{"class":64,"line":104},[62,3689,608],{"class":152},[62,3691,3692],{"class":156}," httpx\n",[62,3694,3695,3697,3700,3702],{"class":64,"line":112},[62,3696,590],{"class":152},[62,3698,3699],{"class":156}," sqlalchemy ",[62,3701,608],{"class":152},[62,3703,3704],{"class":156}," text\n",[62,3706,3707,3709,3712,3714],{"class":64,"line":120},[62,3708,590],{"class":152},[62,3710,3711],{"class":156}," sqlalchemy.ext.asyncio ",[62,3713,608],{"class":152},[62,3715,3716],{"class":156}," create_async_engine\n",[62,3718,3719],{"class":64,"line":128},[62,3720,302],{"emptyLinePlaceholder":301},[62,3722,3723,3726,3728],{"class":64,"line":136},[62,3724,3725],{"class":76},"QUERY",[62,3727,742],{"class":152},[62,3729,3730],{"class":156}," text(\n",[62,3732,3733],{"class":64,"line":329},[62,3734,3735],{"class":72},"    \"\"\"\n",[62,3737,3738],{"class":64,"line":344},[62,3739,3740],{"class":72},"    SELECT account_id,\n",[62,3742,3743],{"class":64,"line":353},[62,3744,3745],{"class":72},"           count(*) AS calls,\n",[62,3747,3748],{"class":64,"line":360},[62,3749,3750],{"class":72},"           count(DISTINCT route) AS routes,\n",[62,3752,3753],{"class":64,"line":365},[62,3754,3755],{"class":72},"           max(created_at) AS last_seen\n",[62,3757,3758],{"class":64,"line":379},[62,3759,3760],{"class":72},"    FROM api_usage_events\n",[62,3762,3763],{"class":64,"line":730},[62,3764,3765],{"class":72},"    WHERE api_version = :old_version\n",[62,3767,3768],{"class":64,"line":748},[62,3769,3770],{"class":72},"      AND created_at > now() - interval '7 days'\n",[62,3772,3773],{"class":64,"line":762},[62,3774,3775],{"class":72},"    GROUP BY account_id\n",[62,3777,3778],{"class":64,"line":767},[62,3779,3780],{"class":72},"    ORDER BY calls DESC\n",[62,3782,3783],{"class":64,"line":795},[62,3784,3735],{"class":72},[62,3786,3787],{"class":64,"line":812},[62,3788,703],{"class":156},[62,3790,3791],{"class":64,"line":845},[62,3792,302],{"emptyLinePlaceholder":301},[62,3794,3795],{"class":64,"line":850},[62,3796,302],{"emptyLinePlaceholder":301},[62,3798,3799,3801,3803,3806,3808,3810],{"class":64,"line":855},[62,3800,2553],{"class":152},[62,3802,1577],{"class":152},[62,3804,3805],{"class":68}," main",[62,3807,2088],{"class":156},[62,3809,1454],{"class":76},[62,3811,714],{"class":156},[62,3813,3814,3817,3819,3822,3825,3828,3831,3833,3835],{"class":64,"line":884},[62,3815,3816],{"class":156},"    engine ",[62,3818,160],{"class":152},[62,3820,3821],{"class":156}," create_async_engine(os.environ[",[62,3823,3824],{"class":72},"\"DATABASE_URL\"",[62,3826,3827],{"class":156},"], ",[62,3829,3830],{"class":685},"pool_pre_ping",[62,3832,160],{"class":152},[62,3834,691],{"class":76},[62,3836,703],{"class":156},[62,3838,3839,3841,3844,3847,3849],{"class":64,"line":899},[62,3840,1574],{"class":152},[62,3842,3843],{"class":152}," with",[62,3845,3846],{"class":156}," engine.connect() ",[62,3848,1243],{"class":152},[62,3850,3851],{"class":156}," conn:\n",[62,3853,3854,3857,3859,3862,3865,3868,3870,3873,3876,3879,3882],{"class":64,"line":906},[62,3855,3856],{"class":156},"        rows ",[62,3858,160],{"class":152},[62,3860,3861],{"class":156}," (",[62,3863,3864],{"class":152},"await",[62,3866,3867],{"class":156}," conn.execute(",[62,3869,3725],{"class":76},[62,3871,3872],{"class":156},", {",[62,3874,3875],{"class":72},"\"old_version\"",[62,3877,3878],{"class":156},": os.environ[",[62,3880,3881],{"class":72},"\"API_OLD_VERSION\"",[62,3883,3884],{"class":156},"]})).all()\n",[62,3886,3887],{"class":64,"line":915},[62,3888,302],{"emptyLinePlaceholder":301},[62,3890,3891,3894,3896,3899,3902],{"class":64,"line":920},[62,3892,3893],{"class":156},"    endpoint ",[62,3895,160],{"class":152},[62,3897,3898],{"class":156}," os.environ[",[62,3900,3901],{"class":72},"\"NOTIFY_WEBHOOK_URL\"",[62,3903,272],{"class":156},[62,3905,3906,3908,3910,3913,3916,3918,3921,3923,3925],{"class":64,"line":925},[62,3907,1574],{"class":152},[62,3909,3843],{"class":152},[62,3911,3912],{"class":156}," httpx.AsyncClient(",[62,3914,3915],{"class":685},"timeout",[62,3917,160],{"class":152},[62,3919,3920],{"class":76},"10.0",[62,3922,1366],{"class":156},[62,3924,1243],{"class":152},[62,3926,3927],{"class":156}," client:\n",[62,3929,3930,3932,3935,3937],{"class":64,"line":943},[62,3931,1085],{"class":152},[62,3933,3934],{"class":156}," row ",[62,3936,1091],{"class":152},[62,3938,3939],{"class":156}," rows:\n",[62,3941,3942,3944,3946],{"class":64,"line":959},[62,3943,3197],{"class":156},[62,3945,160],{"class":152},[62,3947,1007],{"class":156},[62,3949,3950,3953,3955,3957],{"class":64,"line":981},[62,3951,3952],{"class":72},"                \"account_id\"",[62,3954,1604],{"class":156},[62,3956,867],{"class":76},[62,3958,3959],{"class":156},"(row.account_id),\n",[62,3961,3962,3965],{"class":64,"line":1002},[62,3963,3964],{"class":72},"                \"calls_last_7d\"",[62,3966,3967],{"class":156},": row.calls,\n",[62,3969,3970,3973],{"class":64,"line":1010},[62,3971,3972],{"class":72},"                \"routes\"",[62,3974,3975],{"class":156},": row.routes,\n",[62,3977,3978,3981,3983,3986],{"class":64,"line":1016},[62,3979,3980],{"class":72},"                \"sunset_date\"",[62,3982,3878],{"class":156},[62,3984,3985],{"class":72},"\"API_SUNSET_DATE\"",[62,3987,3988],{"class":156},"],\n",[62,3990,3991,3994,3996,3998],{"class":64,"line":1027},[62,3992,3993],{"class":72},"                \"guide_url\"",[62,3995,3878],{"class":156},[62,3997,3481],{"class":72},[62,3999,3988],{"class":156},[62,4001,4002],{"class":64,"line":1044},[62,4003,4004],{"class":156},"            }\n",[62,4006,4007,4010,4012,4014,4017,4020,4022],{"class":64,"line":1061},[62,4008,4009],{"class":156},"            response ",[62,4011,160],{"class":152},[62,4013,3349],{"class":152},[62,4015,4016],{"class":156}," client.post(endpoint, ",[62,4018,4019],{"class":685},"json",[62,4021,160],{"class":152},[62,4023,4024],{"class":156},"payload)\n",[62,4026,4027],{"class":64,"line":1076},[62,4028,4029],{"class":156},"            response.raise_for_status()\n",[62,4031,4032,4035],{"class":64,"line":1082},[62,4033,4034],{"class":152},"    await",[62,4036,4037],{"class":156}," engine.dispose()\n",[62,4039,4040],{"class":64,"line":1103},[62,4041,302],{"emptyLinePlaceholder":301},[62,4043,4044],{"class":64,"line":1109},[62,4045,302],{"emptyLinePlaceholder":301},[62,4047,4048,4050,4053,4056,4059],{"class":64,"line":1114},[62,4049,2126],{"class":152},[62,4051,4052],{"class":76}," __name__",[62,4054,4055],{"class":152}," ==",[62,4057,4058],{"class":72}," \"__main__\"",[62,4060,714],{"class":156},[62,4062,4063],{"class":64,"line":1119},[62,4064,4065],{"class":156},"    asyncio.run(main())\n",[14,4067,4068],{},"Plot the weekly percentage of requests still on the old version and put it on the same dashboard as revenue. The curve below is typical of a well-run twelve-month retirement: nothing moves for the first two months because nobody acts on an email about a date a year away, the emails with real usage numbers move the bulk between months three and six, and the first brownout does more in thirty minutes than the previous quarter of writing.",[407,4070,416,4075,416,4078,416,4081,416,4084,416,4087,416,4090,416,4092,416,4096,416,4099,416,4101,416,4104,416,4109,416,4112,416,4115,416,4119,416,4123,416,4128,416,4132,416,4136,416,4140,416,4145,416,4149,416,4152,416,4156,416,4161,416,4165,416,4168,416,4171,416,4176,416,4180,416,4183,416,4187,416,4191,416,4194],{"viewBox":4071,"role":410,"ariaLabelledBy":4072,"xmlns":414,"style":415},"0 0 720 320",[4073,4074],"apiver-burn-t","apiver-burn-d",[418,4076,4077],{"id":4073},"Share of traffic still on the old version, month by month",[422,4079,4080],{"id":4074},"Bar chart of the percentage of requests still served by the deprecated version across twelve months, falling from 100 percent to 1 percent with the sharpest drops after the usage-specific email wave and the first brownout.",[426,4082],{"x":428,"y":428,"width":429,"height":4083,"fill":431},"320",[433,4085,4086],{"x":435,"y":1691,"fill":437,"style":438},"Percent of requests still on the deprecated version",[64,4088],{"x1":1881,"y1":1871,"x2":4089,"y2":1871,"stroke":448,"style":449},"700",[64,4091],{"x1":1881,"y1":3577,"x2":1881,"y2":1871,"stroke":448,"style":449},[433,4093,4095],{"x":4094,"y":453,"fill":437,"style":3644},"48","100",[433,4097,1829],{"x":4094,"y":4098,"fill":437,"style":3644},"159",[433,4100,428],{"x":4094,"y":1760,"fill":437,"style":3644},[426,4102],{"x":1745,"y":3577,"width":443,"height":1868,"rx":4103,"fill":1698},"3",[433,4105,4108],{"x":4106,"y":4107,"fill":437,"style":1709},"87","268","M0",[426,4110],{"x":1857,"y":1749,"width":443,"height":4111,"rx":4103,"fill":1698},"182",[433,4113,4114],{"x":1833,"y":4107,"fill":437,"style":1709},"M2",[426,4116],{"x":1864,"y":4117,"width":443,"height":4118,"rx":4103,"fill":1698},"83","167",[433,4120,4122],{"x":4121,"y":4107,"fill":437,"style":1709},"191","M4",[426,4124],{"x":4125,"y":4126,"width":443,"height":4127,"rx":4103,"fill":1698},"222","115","135",[433,4129,4131],{"x":4130,"y":4107,"fill":437,"style":1709},"243","M6",[426,4133],{"x":1764,"y":4134,"width":443,"height":4135,"rx":4103,"fill":1698},"151","99",[433,4137,4139],{"x":4138,"y":4107,"fill":437,"style":1709},"295","M7",[426,4141],{"x":4142,"y":4143,"width":443,"height":4144,"rx":4103,"fill":1698},"326","174","76",[433,4146,4148],{"x":4147,"y":4107,"fill":437,"style":1709},"347","M8",[426,4150],{"x":459,"y":4151,"width":443,"height":443,"rx":4103,"fill":492},"208",[433,4153,4155],{"x":4154,"y":4107,"fill":437,"style":1709},"399","M9",[426,4157],{"x":4158,"y":4159,"width":443,"height":4160,"rx":4103,"fill":492},"430","233","17",[433,4162,4164],{"x":4163,"y":4107,"fill":437,"style":1709},"451","M10",[426,4166],{"x":4167,"y":551,"width":443,"height":446,"rx":4103,"fill":492},"482",[433,4169,4170],{"x":3549,"y":4107,"fill":437,"style":1709},"M11",[426,4172],{"x":4173,"y":4174,"width":443,"height":4175,"rx":938,"fill":534},"534","248","2",[433,4177,4179],{"x":4178,"y":4107,"fill":437,"style":1709},"555","M12",[433,4181,4182],{"x":3578,"y":1861,"fill":454,"style":569},"M6: usage",[433,4184,4186],{"x":3578,"y":4185,"fill":454,"style":569},"126","emails sent",[433,4188,4190],{"x":3578,"y":4189,"fill":454,"style":569},"160","M9: first",[433,4192,4193],{"x":3578,"y":1714,"fill":454,"style":569},"brownout",[433,4195,4196],{"x":435,"y":430,"fill":437,"style":569},"Values: 100, 96, 88, 71, 52, 40, 22, 9, 3, 1 percent.",[14,4198,4199,4200,4202],{},"The last one percent never leaves voluntarily. Decide in advance what happens to it: for a self-serve tier, sunset on the published date and let the ",[27,4201,3645],{}," speak. For accounts above a revenue threshold, offer a paid extension priced high enough to cover the maintenance — quoting a real number converts most of them into migrators within a fortnight, and the ones who pay make the old version profitable instead of merely expensive.",[32,4204],{},[35,4206,4208],{"id":4207},"configuration-reference","Configuration reference",[4210,4211,4212,4228],"table",{},[4213,4214,4215],"thead",{},[4216,4217,4218,4222,4225],"tr",{},[4219,4220,4221],"th",{},"Variable",[4219,4223,4224],{},"Default",[4219,4226,4227],{},"Production recommendation",[4229,4230,4231,4247,4265,4277,4298,4310,4323,4335],"tbody",{},[4216,4232,4233,4239,4244],{},[4234,4235,4236],"td",{},[27,4237,4238],{},"API_VERSIONS_FILE",[4234,4240,4241],{},[27,4242,4243],{},"versions.toml",[4234,4245,4246],{},"Ship inside the image; never mount from a volume",[4216,4248,4249,4254,4259],{},[4234,4250,4251],{},[27,4252,4253],{},"API_VERSION_HEADER",[4234,4255,4256],{},[27,4257,4258],{},"X-Api-Version",[4234,4260,4261,4262],{},"Keep stable forever; also add to ",[27,4263,4264],{},"Vary",[4216,4266,4267,4272,4274],{},[4234,4268,4269],{},[27,4270,4271],{},"API_DEFAULT_VERSION",[4234,4273,1737],{},[4234,4275,4276],{},"Oldest supported version, not the newest",[4216,4278,4279,4284,4289],{},[4234,4280,4281],{},[27,4282,4283],{},"API_UNKNOWN_VERSION_POLICY",[4234,4285,4286],{},[27,4287,4288],{},"reject",[4234,4290,4291,4293,4294,4297],{},[27,4292,4288],{}," in production, ",[27,4295,4296],{},"latest"," in local dev",[4216,4299,4300,4305,4307],{},[4234,4301,4302],{},[27,4303,4304],{},"API_VERSION_POLICY_URL",[4234,4306,1737],{},[4234,4308,4309],{},"Public migration guide URL, always set",[4216,4311,4312,4317,4320],{},[4234,4313,4314],{},[27,4315,4316],{},"API_BROWNOUT_WINDOWS",[4234,4318,4319],{},"empty",[4234,4321,4322],{},"Populate only during a scheduled brownout",[4216,4324,4325,4330,4332],{},[4234,4326,4327],{},[27,4328,4329],{},"API_ROOT_PATH",[4234,4331,4319],{},[4234,4333,4334],{},"Set when a proxy strips a path prefix",[4216,4336,4337,4342,4347],{},[4234,4338,4339],{},[27,4340,4341],{},"OPENAPI_BASELINE_PATH",[4234,4343,4344],{},[27,4345,4346],{},"contracts\u002Fopenapi-v2.json",[4234,4348,4349],{},"Commit the file; regenerate deliberately",[14,4351,4352,4353,4355,4356,4360,4361,4363],{},"Two entries deserve emphasis. ",[27,4354,4271],{}," must point at the ",[4357,4358,4359],"em",{},"oldest"," supported version, not the newest, because the default is what an unpinned client gets and unpinned clients are exactly the ones who cannot handle surprises. And ",[27,4362,4283],{}," should differ between environments on purpose: strict rejection in production catches typos before they reach a customer's retry loop, while a lenient local default keeps you from fighting your own tooling.",[32,4365],{},[35,4367,4369],{"id":4368},"gotchas-and-failure-modes","Gotchas and failure modes",[14,4371,4372,4376,4377,4379,4380,4384,4385,4387],{},[4373,4374,4375],"strong",{},"A cached response leaks the wrong version."," The symptom is a customer pinned to the old date receiving a new-shape payload intermittently, usually right after a traffic spike. The cause is a shared cache keyed on path alone. Fix it by adding the version header to ",[27,4378,4264],{}," and, more reliably, by putting the version name into the cache key itself when you build it — the same principle covered in ",[18,4381,4383],{"href":4382},"\u002Fscaling-and-operating-production-python-apis\u002Fcaching-python-api-responses-with-redis\u002F","caching Python API responses with Redis",". Never trust ",[27,4386,4264],{}," alone across a CDN you do not control.",[14,4389,4390,4393,4394,4397],{},[4373,4391,4392],{},"Webhook payloads ignore the version entirely."," Requests get versioned because they carry a header; outbound webhooks do not, so they quietly ship the newest shape to every subscriber. The symptom is a customer whose reads work fine but whose webhook handler starts throwing ",[27,4395,4396],{},"KeyError",". Store the version on the webhook endpoint record at registration time and run the same downgrade chain before signing the body.",[14,4399,4400,4403,4404,4407,4408,4411],{},[4373,4401,4402],{},"The version resolver depends on authentication that some routes skip."," The symptom is a ",[27,4405,4406],{},"500"," on public endpoints because ",[27,4409,4410],{},"request.state.api_version"," is missing when the deprecation middleware runs. Make the middleware tolerate a missing attribute — as the code above does — and give unauthenticated routes an explicit fallback to the configured default rather than assuming a resolved account.",[14,4413,4414,4417,4418,4421],{},[4373,4415,4416],{},"Enum growth breaks strict clients."," You add ",[27,4419,4420],{},"status: \"partially_refunded\""," and a customer's Pydantic model rejects the response. This is technically additive and practically breaking. Document from day one that clients must treat unknown enum values as opaque, and when the risk is real, gate the new value behind a dated version so only clients that opted in ever see it.",[14,4423,4424,4427,4428,4430,4431,4434],{},[4373,4425,4426],{},"Sunset day arrives and nothing is deleted."," The old mount stays because removing it feels risky, and eighteen months later you are running three generations. Put the deletion in the same sprint as the sunset, delete the package rather than commenting out the mount, and keep the ",[27,4429,3645],{}," handler for one further quarter so late callers get a real answer instead of a ",[27,4432,4433],{},"404"," that reads like an outage.",[32,4436],{},[35,4438,4440],{"id":4439},"verification","Verification",[14,4442,4443],{},"Prove the whole chain with three requests. The first pins an old version and should come back with deprecation metadata; the second sends nonsense and should be rejected; the third omits the header and should land on the configured default.",[53,4445,4447],{"className":55,"code":4446,"language":57,"meta":58,"style":58},"curl -i -s \"${API_BASE_URL}\u002Fv1\u002Fcustomers\u002Fcus_123\" \\\n  -H \"Authorization: Bearer ${API_TEST_KEY}\" \\\n  -H \"X-Api-Version: 2025-06-15\" | head -n 12\n\ncurl -s -o \u002Fdev\u002Fnull -w '%{http_code}\\n' \"${API_BASE_URL}\u002Fv1\u002Fcustomers\u002Fcus_123\" \\\n  -H \"Authorization: Bearer ${API_TEST_KEY}\" \\\n  -H \"X-Api-Version: 2020-01-01\"\n",[27,4448,4449,4471,4487,4505,4509,4535,4547],{"__ignoreMap":58},[62,4450,4451,4454,4457,4460,4463,4466,4469],{"class":64,"line":65},[62,4452,4453],{"class":68},"curl",[62,4455,4456],{"class":76}," -i",[62,4458,4459],{"class":76}," -s",[62,4461,4462],{"class":72}," \"${",[62,4464,4465],{"class":156},"API_BASE_URL",[62,4467,4468],{"class":72},"}\u002Fv1\u002Fcustomers\u002Fcus_123\"",[62,4470,77],{"class":76},[62,4472,4473,4476,4479,4482,4485],{"class":64,"line":80},[62,4474,4475],{"class":76},"  -H",[62,4477,4478],{"class":72}," \"Authorization: Bearer ${",[62,4480,4481],{"class":156},"API_TEST_KEY",[62,4483,4484],{"class":72},"}\"",[62,4486,77],{"class":76},[62,4488,4489,4491,4494,4496,4499,4502],{"class":64,"line":88},[62,4490,4475],{"class":76},[62,4492,4493],{"class":72}," \"X-Api-Version: 2025-06-15\"",[62,4495,870],{"class":152},[62,4497,4498],{"class":68}," head",[62,4500,4501],{"class":76}," -n",[62,4503,4504],{"class":76}," 12\n",[62,4506,4507],{"class":64,"line":96},[62,4508,302],{"emptyLinePlaceholder":301},[62,4510,4511,4513,4515,4518,4521,4524,4527,4529,4531,4533],{"class":64,"line":104},[62,4512,4453],{"class":68},[62,4514,4459],{"class":76},[62,4516,4517],{"class":76}," -o",[62,4519,4520],{"class":72}," \u002Fdev\u002Fnull",[62,4522,4523],{"class":76}," -w",[62,4525,4526],{"class":72}," '%{http_code}\\n'",[62,4528,4462],{"class":72},[62,4530,4465],{"class":156},[62,4532,4468],{"class":72},[62,4534,77],{"class":76},[62,4536,4537,4539,4541,4543,4545],{"class":64,"line":112},[62,4538,4475],{"class":76},[62,4540,4478],{"class":72},[62,4542,4481],{"class":156},[62,4544,4484],{"class":72},[62,4546,77],{"class":76},[62,4548,4549,4551],{"class":64,"line":120},[62,4550,4475],{"class":76},[62,4552,4553],{"class":72}," \"X-Api-Version: 2020-01-01\"\n",[14,4555,4556,4557,4560],{},"The first command must show the deprecation trio, and the second must print ",[27,4558,4559],{},"400",". Anything else means the resolver is falling through to a lenient branch you did not intend.",[53,4562,4566],{"className":4563,"code":4565,"language":433,"meta":58},[4564],"language-text","HTTP\u002F2 200\ncontent-type: application\u002Fjson\ndeprecation: @1774915200\nsunset: Thu, 01 Apr 2027 00:00:00 GMT\nlink: \u003Chttps:\u002F\u002Fexample.com\u002Fdocs\u002Fversioning>; rel=\"sunset\"; type=\"text\u002Fhtml\"\nvary: X-Api-Version\n",[27,4567,4565],{"__ignoreMap":58},[14,4569,4570,4571,4574,4575,4578,4579,4583],{},"Then assert the same behaviour in the suite so it survives refactoring, and log the resolved version as a structured field on every request — ",[27,4572,4573],{},"api_version"," alongside ",[27,4576,4577],{},"account_id"," — so the migration dashboard has real data. ",[18,4580,4582],{"href":4581},"\u002Fscaling-and-operating-production-python-apis\u002Fmonitoring-and-logging-python-apis\u002Fstructured-logging-with-structlog\u002F","Structured logging with structlog"," covers the emitter; the only versioning-specific requirement is that the field is present on 100% of requests, including errors, because the accounts you most need to reach are often the ones failing.",[32,4585],{},[35,4587,4589],{"id":4588},"cost-and-performance-at-scale","Cost and performance at scale",[14,4591,4592,4593,4597],{},"The runtime cost of this pattern is close to nothing and the operational cost is real. A resolver dependency plus a header middleware adds roughly 40 to 80 microseconds per request — under 0.5% of a typical 20 ms p95, and invisible next to a single database round trip. Mounting a second FastAPI application costs memory once at startup, on the order of 15 to 30 MB for a moderate route count, which on a 512 MB instance is affordable but not free. The transformer chain is pure dictionary manipulation at a few microseconds per hop. Where the money actually goes is maintenance: every live version multiplies your test matrix, your support surface and the cognitive cost of every future change, and two versions is roughly 1.4 times the work of one rather than twice. That is why a published sunset date is a margin decision, not a courtesy — a version kept alive for three stragglers on a $29 plan can easily consume more engineering hours per month than those accounts generate in a year. Price it: multiply your hourly rate by the hours the old generation consumes, compare it against the revenue attributable to accounts still on it, and let that number set the sunset date. The same arithmetic that drives ",[18,4594,4596],{"href":4595},"\u002Fbuilding-monetizing-api-driven-micro-saas\u002Fdesigning-api-pricing-tiers\u002Fcalculating-cost-per-api-request\u002F","cost per API request"," applies to versions, just on a slower clock.",[32,4599],{},[35,4601,4603],{"id":4602},"faq","FAQ",[14,4605,4606,4609],{},[4373,4607,4608],{},"How many versions should a small API keep alive at once?","\nTwo, with a third only during a migration window. Each additional live version adds test matrix, support load and release friction — budget roughly 40% extra maintenance per extra generation, not 100%, but it compounds. If you are a team of one or two, a hard cap of two is what keeps shipping velocity intact.",[14,4611,4612,4615],{},[4373,4613,4614],{},"Does a new major version justify a price increase?","\nRarely on its own, and pitching it that way invites churn. Charge for capability instead: ship the new capacity, rate limits or endpoints only on the current generation, and let the old one remain available at the old price until sunset. Customers migrate for features far more reliably than they migrate to avoid a fee.",[14,4617,4618,4621],{},[4373,4619,4620],{},"What is the real risk of sunsetting a version on schedule?","\nConcentrated, and therefore manageable. In most APIs the last few percent of old-version traffic sits with a handful of accounts, so the exposure is a list you can read in one sitting. Call those accounts personally, offer a paid extension above a revenue threshold, and hold the date for everyone else — a slipped sunset teaches every customer that your deadlines are negotiable, which makes the next one twice as expensive.",[14,4623,4624,4627],{},[4373,4625,4626],{},"Should webhook payloads follow the same version as API responses?","\nYes, and you must store the version explicitly on the endpoint record because there is no request header to read. Run the same downgrade chain before signing, and version the signature scheme separately — rotating a signing key and changing a payload shape in the same release makes debugging a customer's failed handler nearly impossible.",[14,4629,4630,4633],{},[4373,4631,4632],{},"How do I roll a customer forward without risking their integration?","\nGive them a self-serve toggle that sets the pinned version on their account, plus a documented rollback that takes one click. Pair it with a staging key that always resolves to the newest version so they can test the new shape against real data before flipping production. Self-serve upgrades cost you nothing per customer; a support thread costs an hour each.",[32,4635],{},[35,4637,4639],{"id":4638},"related","Related",[14,4641,4642],{},[4373,4643,4644],{},"Same track:",[4646,4647,4648,4655,4661,4667],"ul",{},[4649,4650,4651,4654],"li",{},[18,4652,4653],{"href":403},"URL Versioning vs Header Versioning"," — the full trade-off analysis behind the hybrid recommended here.",[4649,4656,4657,4660],{},[18,4658,4659],{"href":3556},"Deprecating an API Endpoint Without Breaking Customers"," — retiring one route rather than a whole generation.",[4649,4662,4663,4666],{},[18,4664,4665],{"href":2471},"Testing Python APIs with pytest"," — the suite that runs the contract test above on every push.",[4649,4668,4669,4673],{},[18,4670,4672],{"href":4671},"\u002Fscaling-and-operating-production-python-apis\u002Fmonitoring-and-logging-python-apis\u002F","Monitoring and Logging Python APIs"," — where the per-request version field becomes a migration dashboard.",[14,4675,4676],{},[4373,4677,4678],{},"Other tracks:",[4646,4680,4681,4687],{},[4649,4682,4683,4686],{},[18,4684,4685],{"href":1922},"Documenting APIs with OpenAPI"," — publishing a separate schema per mounted version.",[4649,4688,4689,4693],{},[18,4690,4692],{"href":4691},"\u002Fbuilding-monetizing-api-driven-micro-saas\u002Fdeploying-apis-to-render-or-vercel\u002Fzero-downtime-deploys-for-python-apis\u002F","Zero-Downtime Deploys for Python APIs"," — shipping a new version mount without dropping requests.",[4695,4696,4697],"style",{},"html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}html pre.shiki code .s4XuR, html code.shiki .s4XuR{--shiki-default:#E36209;--shiki-dark:#FFAB70}",{"title":58,"searchDepth":80,"depth":80,"links":4699},[4700,4701,4702,4703,4704,4705,4706,4707,4708,4709,4710,4711,4712],{"id":37,"depth":80,"text":38},{"id":388,"depth":80,"text":389},{"id":1172,"depth":80,"text":1173},{"id":1795,"depth":80,"text":1796},{"id":2478,"depth":80,"text":2479},{"id":3213,"depth":80,"text":3214},{"id":3654,"depth":80,"text":3655},{"id":4207,"depth":80,"text":4208},{"id":4368,"depth":80,"text":4369},{"id":4439,"depth":80,"text":4440},{"id":4588,"depth":80,"text":4589},{"id":4602,"depth":80,"text":4603},{"id":4638,"depth":80,"text":4639},"Version a commercial Python API without losing customers: additive-change rules, per-version FastAPI routing, RFC 9745 deprecation headers, sunset timelines.","md",{"pageTitle":4716,"type":4717,"datePublished":4718,"dateModified":4718},"API Versioning in FastAPI: Ship Changes Without Breaking","guide","2026-07-23","\u002Fscaling-and-operating-production-python-apis\u002Fversioning-and-evolving-public-apis",{"title":5,"description":4713},"scaling-and-operating-production-python-apis\u002Fversioning-and-evolving-public-apis\u002Findex","tmUIdbuV1LSTtYvYJ4voFp3PdSrUpg5jcaM8ybPMYTg",{"@context":4724,"@type":4725,"mainEntity":4726},"https:\u002F\u002Fschema.org","FAQPage",[4727,4732,4735,4738,4741],{"@type":4728,"name":4608,"acceptedAnswer":4729},"Question",{"@type":4730,"text":4731},"Answer","Two, with a third only during a migration window. Each additional live version adds test matrix, support load and release friction — budget roughly 40% extra maintenance per extra generation, not 100%, but it compounds. If you are a team of one or two, a hard cap of two is what keeps shipping velocity intact.",{"@type":4728,"name":4614,"acceptedAnswer":4733},{"@type":4730,"text":4734},"Rarely on its own, and pitching it that way invites churn. Charge for capability instead: ship the new capacity, rate limits or endpoints only on the current generation, and let the old one remain available at the old price until sunset. Customers migrate for features far more reliably than they migrate to avoid a fee.",{"@type":4728,"name":4620,"acceptedAnswer":4736},{"@type":4730,"text":4737},"Concentrated, and therefore manageable. In most APIs the last few percent of old-version traffic sits with a handful of accounts, so the exposure is a list you can read in one sitting. Call those accounts personally, offer a paid extension above a revenue threshold, and hold the date for everyone else — a slipped sunset teaches every customer that your deadlines are negotiable, which makes the next one twice as expensive.",{"@type":4728,"name":4626,"acceptedAnswer":4739},{"@type":4730,"text":4740},"Yes, and you must store the version explicitly on the endpoint record because there is no request header to read. Run the same downgrade chain before signing, and version the signature scheme separately — rotating a signing key and changing a payload shape in the same release makes debugging a customer's failed handler nearly impossible.",{"@type":4728,"name":4632,"acceptedAnswer":4742},{"@type":4730,"text":4743},"Give them a self-serve toggle that sets the pinned version on their account, plus a documented rollback that takes one click. Pair it with a staging key that always resolves to the newest version so they can test the new shape against real data before flipping production. Self-serve upgrades cost you nothing per customer; a support thread costs an hour each. ---",1784887027767]