[{"data":1,"prerenderedAt":2910},["ShallowReactive",2],{"page-\u002Fscaling-and-operating-production-python-apis\u002Fversioning-and-evolving-public-apis\u002Furl-versioning-vs-header-versioning\u002F":3,"faq-schema-\u002Fscaling-and-operating-production-python-apis\u002Fversioning-and-evolving-public-apis\u002Furl-versioning-vs-header-versioning\u002F":2889},{"id":4,"title":5,"body":6,"description":2879,"extension":2880,"meta":2881,"navigation":273,"path":2885,"seo":2886,"stem":2887,"__hash__":2888},"content\u002Fscaling-and-operating-production-python-apis\u002Fversioning-and-evolving-public-apis\u002Furl-versioning-vs-header-versioning\u002Findex.md","URL Versioning vs Header Versioning: What to Ship on a Paid API",{"type":7,"value":8,"toc":2868},"minimark",[9,13,32,35,40,51,66,224,232,705,712,1267,1274,1278,1294,1314,1450,1453,1759,1767,1771,1774,1785,1796,1799,2096,2107,2111,2118,2130,2247,2258,2262,2275,2281,2421,2427,2431,2438,2469,2729,2746,2750,2756,2762,2766,2777,2783,2791,2797,2809,2813,2818,2845,2850,2864],[10,11,5],"h1",{"id":12},"url-versioning-vs-header-versioning-what-to-ship-on-a-paid-api",[14,15,16,17,21,22,25,26,31],"p",{},"You have one public endpoint, a handful of paying integrators, and a response shape you now regret. The next decision is where the version number lives: in the path as ",[18,19,20],"code",{},"\u002Fv1\u002Freports",", or in a content-negotiation header as ",[18,23,24],{},"Accept: application\u002Fvnd.acme.v1+json",". The internet will argue about REST purity for a thousand comments without mentioning the two things that decide it for a small commercial API — what each does to your cache hit rate, and how much support time it burns. This page resolves that. Part of the ",[27,28,30],"a",{"href":29},"\u002Fscaling-and-operating-production-python-apis\u002Fversioning-and-evolving-public-apis\u002F","Versioning and Evolving Public APIs"," guide.",[14,33,34],{},"The headline: put the major version in the path, and reserve header-based negotiation for the small dated changes that do not deserve a whole new major version. That hybrid is what most APIs that survive a decade converge on, and it costs you almost nothing to set up on day one.",[36,37,39],"h2",{"id":38},"the-two-schemes-dimension-by-dimension","The two schemes, dimension by dimension",[14,41,42,43,46,47,50],{},"Path versioning encodes the contract in the resource identifier. ",[18,44,45],{},"GET \u002Fv2\u002Freports\u002F42"," is a different URL from ",[18,48,49],{},"GET \u002Fv1\u002Freports\u002F42",", and every layer of the stack — router, CDN, proxy log, browser address bar, someone's bookmarked curl command — treats them as two distinct things without being told anything.",[14,52,53,54,57,58,61,62,65],{},"Header versioning keeps one URL and asks the client to state which representation it wants. The purist argument is correct: ",[18,55,56],{},"\u002Freports\u002F42"," identifies a report, and ",[18,59,60],{},"v1"," or ",[18,63,64],{},"v2"," is a serialization of that same report, so the version belongs in content negotiation. The catch is that the internet's plumbing was built to key on URLs, and moving the discriminator into a header makes you responsible for teaching every one of those layers about it.",[67,68,76,77,76,81,76,85,76,92,76,100,76,110,76,115,76,119,76,125,76,129,76,133,76,136,76,138,76,140,76,145,76,148,76,151,76,154,76,156,76,158,76,162,76,165,76,168,76,171,76,173,76,175,76,179,76,182,76,185,76,188,76,190,76,192,76,196,76,199,76,202,76,205,76,207,76,209,76,213,76,216,76,219],"svg",{"viewBox":69,"role":70,"ariaLabelledBy":71,"xmlns":74,"style":75},"0 0 760 320","img",[72,73],"uvh-matrix-t","uvh-matrix-d","http:\u002F\u002Fwww.w3.org\u002F2000\u002Fsvg","width:100%;height:auto;margin:1.5rem 0;font-family:var(--font-sans);","\n  ",[78,79,80],"title",{"id":72},"Path versioning versus header versioning by dimension",[82,83,84],"desc",{"id":73},"A five-row comparison matrix covering cache keys, manual testing, generated docs, log triage and the cost of adding a second version under each scheme.",[86,87],"rect",{"x":88,"y":88,"width":89,"height":90,"fill":91},"0","760","320","var(--c-surface)",[93,94,99],"text",{"x":95,"y":96,"fill":97,"style":98},"380","24","var(--c-text)","text-anchor:middle;font-size:14;font-family:var(--font-sans);","Where the version number actually shows up",[86,101],{"x":102,"y":103,"width":104,"height":105,"rx":106,"fill":107,"stroke":108,"style":109},"20","40","240","34","6","var(--c-surface-alt)","var(--c-border)","stroke-width:2;",[86,111],{"x":112,"y":103,"width":113,"height":105,"rx":106,"fill":107,"stroke":114,"style":109},"270","230","var(--c-blue)",[86,116],{"x":117,"y":103,"width":113,"height":105,"rx":106,"fill":107,"stroke":118,"style":109},"510","var(--c-coral)",[93,120,124],{"x":121,"y":122,"fill":97,"style":123},"140","62","text-anchor:middle;font-size:13;font-family:var(--font-sans);","Dimension",[93,126,128],{"x":127,"y":122,"fill":97,"style":123},"385","Path: \u002Fv1\u002Freports",[93,130,132],{"x":131,"y":122,"fill":97,"style":123},"625","Header: Accept vnd",[86,134],{"x":102,"y":135,"width":104,"height":105,"rx":106,"fill":91,"stroke":108,"style":109},"82",[86,137],{"x":112,"y":135,"width":113,"height":105,"rx":106,"fill":91,"stroke":108,"style":109},[86,139],{"x":117,"y":135,"width":113,"height":105,"rx":106,"fill":91,"stroke":108,"style":109},[93,141,144],{"x":121,"y":142,"fill":97,"style":143},"104","text-anchor:middle;font-size:12;font-family:var(--font-sans);","CDN cache key",[93,146,147],{"x":127,"y":142,"fill":97,"style":143},"Free, in the URL",[93,149,150],{"x":131,"y":142,"fill":97,"style":143},"Needs Vary: Accept",[86,152],{"x":102,"y":153,"width":104,"height":105,"rx":106,"fill":91,"stroke":108,"style":109},"122",[86,155],{"x":112,"y":153,"width":113,"height":105,"rx":106,"fill":91,"stroke":108,"style":109},[86,157],{"x":117,"y":153,"width":113,"height":105,"rx":106,"fill":91,"stroke":108,"style":109},[93,159,161],{"x":121,"y":160,"fill":97,"style":143},"144","Manual curl test",[93,163,164],{"x":127,"y":160,"fill":97,"style":143},"Paste URL and go",[93,166,167],{"x":131,"y":160,"fill":97,"style":143},"Extra -H every time",[86,169],{"x":102,"y":170,"width":104,"height":105,"rx":106,"fill":91,"stroke":108,"style":109},"162",[86,172],{"x":112,"y":170,"width":113,"height":105,"rx":106,"fill":91,"stroke":108,"style":109},[86,174],{"x":117,"y":170,"width":113,"height":105,"rx":106,"fill":91,"stroke":108,"style":109},[93,176,178],{"x":121,"y":177,"fill":97,"style":143},"184","Generated docs",[93,180,181],{"x":127,"y":177,"fill":97,"style":143},"One schema each",[93,183,184],{"x":131,"y":177,"fill":97,"style":143},"Hand-built branches",[86,186],{"x":102,"y":187,"width":104,"height":105,"rx":106,"fill":91,"stroke":108,"style":109},"202",[86,189],{"x":112,"y":187,"width":113,"height":105,"rx":106,"fill":91,"stroke":108,"style":109},[86,191],{"x":117,"y":187,"width":113,"height":105,"rx":106,"fill":91,"stroke":108,"style":109},[93,193,195],{"x":121,"y":194,"fill":97,"style":143},"224","Access-log triage",[93,197,198],{"x":127,"y":194,"fill":97,"style":143},"Version is visible",[93,200,201],{"x":131,"y":194,"fill":97,"style":143},"Log the header first",[86,203],{"x":102,"y":204,"width":104,"height":105,"rx":106,"fill":91,"stroke":108,"style":109},"242",[86,206],{"x":112,"y":204,"width":113,"height":105,"rx":106,"fill":91,"stroke":108,"style":109},[86,208],{"x":117,"y":204,"width":113,"height":105,"rx":106,"fill":91,"stroke":108,"style":109},[93,210,212],{"x":121,"y":211,"fill":97,"style":143},"264","Cost of adding v2",[93,214,215],{"x":127,"y":211,"fill":97,"style":143},"Copy one router",[93,217,218],{"x":131,"y":211,"fill":97,"style":143},"Branch in handlers",[93,220,223],{"x":95,"y":221,"fill":222,"style":143},"300","var(--c-text-muted)","Header versioning wins on purity; path versioning wins on plumbing",[14,225,226,227,231],{},"In FastAPI the path scheme is almost embarrassingly cheap. Two routers, two prefixes, one line each to mount them — and because each router owns its own response models, your ",[27,228,230],{"href":229},"\u002Fgetting-started-with-python-apis-for-builders\u002Fdocumenting-apis-with-openapi\u002F","OpenAPI documentation"," stays truthful for free.",[233,234,239],"pre",{"className":235,"code":236,"language":237,"meta":238,"style":238},"language-python shiki shiki-themes github-light github-dark","import os\nfrom fastapi import APIRouter, FastAPI\n\napp = FastAPI(title=os.getenv(\"API_TITLE\", \"Reports API\"))\n\nv1 = APIRouter(prefix=\"\u002Fv1\", tags=[\"v1\"])\nv2 = APIRouter(prefix=\"\u002Fv2\", tags=[\"v2\"])\n\n\nasync def load_report(report_id: str) -> dict[str, object]:\n    \"\"\"Stand-in for your real query layer.\"\"\"\n    return {\"id\": report_id, \"total_cents\": 1299, \"currency\": \"usd\"}\n\n\n@v1.get(\"\u002Freports\u002F{report_id}\")\nasync def get_report_v1(report_id: str) -> dict[str, object]:\n    row = await load_report(report_id)\n    return {\"id\": row[\"id\"], \"total\": row[\"total_cents\"] \u002F 100}\n\n\n@v2.get(\"\u002Freports\u002F{report_id}\")\nasync def get_report_v2(report_id: str) -> dict[str, object]:\n    row = await load_report(report_id)\n    return {\n        \"id\": row[\"id\"],\n        \"total\": {\"amount\": row[\"total_cents\"], \"currency\": row[\"currency\"]},\n    }\n\n\napp.include_router(v1)\napp.include_router(v2)\n","python","",[18,240,241,254,268,275,308,313,348,378,383,388,421,427,464,469,474,495,519,533,568,573,578,594,618,629,637,650,677,683,688,693,699],{"__ignoreMap":238},[242,243,246,250],"span",{"class":244,"line":245},"line",1,[242,247,249],{"class":248},"szBVR","import",[242,251,253],{"class":252},"sVt8B"," os\n",[242,255,257,260,263,265],{"class":244,"line":256},2,[242,258,259],{"class":248},"from",[242,261,262],{"class":252}," fastapi ",[242,264,249],{"class":248},[242,266,267],{"class":252}," APIRouter, FastAPI\n",[242,269,271],{"class":244,"line":270},3,[242,272,274],{"emptyLinePlaceholder":273},true,"\n",[242,276,278,281,284,287,290,292,295,299,302,305],{"class":244,"line":277},4,[242,279,280],{"class":252},"app ",[242,282,283],{"class":248},"=",[242,285,286],{"class":252}," FastAPI(",[242,288,78],{"class":289},"s4XuR",[242,291,283],{"class":248},[242,293,294],{"class":252},"os.getenv(",[242,296,298],{"class":297},"sZZnC","\"API_TITLE\"",[242,300,301],{"class":252},", ",[242,303,304],{"class":297},"\"Reports API\"",[242,306,307],{"class":252},"))\n",[242,309,311],{"class":244,"line":310},5,[242,312,274],{"emptyLinePlaceholder":273},[242,314,316,319,321,324,327,329,332,334,337,339,342,345],{"class":244,"line":315},6,[242,317,318],{"class":252},"v1 ",[242,320,283],{"class":248},[242,322,323],{"class":252}," APIRouter(",[242,325,326],{"class":289},"prefix",[242,328,283],{"class":248},[242,330,331],{"class":297},"\"\u002Fv1\"",[242,333,301],{"class":252},[242,335,336],{"class":289},"tags",[242,338,283],{"class":248},[242,340,341],{"class":252},"[",[242,343,344],{"class":297},"\"v1\"",[242,346,347],{"class":252},"])\n",[242,349,351,354,356,358,360,362,365,367,369,371,373,376],{"class":244,"line":350},7,[242,352,353],{"class":252},"v2 ",[242,355,283],{"class":248},[242,357,323],{"class":252},[242,359,326],{"class":289},[242,361,283],{"class":248},[242,363,364],{"class":297},"\"\u002Fv2\"",[242,366,301],{"class":252},[242,368,336],{"class":289},[242,370,283],{"class":248},[242,372,341],{"class":252},[242,374,375],{"class":297},"\"v2\"",[242,377,347],{"class":252},[242,379,381],{"class":244,"line":380},8,[242,382,274],{"emptyLinePlaceholder":273},[242,384,386],{"class":244,"line":385},9,[242,387,274],{"emptyLinePlaceholder":273},[242,389,391,394,397,401,404,408,411,413,415,418],{"class":244,"line":390},10,[242,392,393],{"class":248},"async",[242,395,396],{"class":248}," def",[242,398,400],{"class":399},"sScJk"," load_report",[242,402,403],{"class":252},"(report_id: ",[242,405,407],{"class":406},"sj4cs","str",[242,409,410],{"class":252},") -> dict[",[242,412,407],{"class":406},[242,414,301],{"class":252},[242,416,417],{"class":406},"object",[242,419,420],{"class":252},"]:\n",[242,422,424],{"class":244,"line":423},11,[242,425,426],{"class":297},"    \"\"\"Stand-in for your real query layer.\"\"\"\n",[242,428,430,433,436,439,442,445,448,451,453,456,458,461],{"class":244,"line":429},12,[242,431,432],{"class":248},"    return",[242,434,435],{"class":252}," {",[242,437,438],{"class":297},"\"id\"",[242,440,441],{"class":252},": report_id, ",[242,443,444],{"class":297},"\"total_cents\"",[242,446,447],{"class":252},": ",[242,449,450],{"class":406},"1299",[242,452,301],{"class":252},[242,454,455],{"class":297},"\"currency\"",[242,457,447],{"class":252},[242,459,460],{"class":297},"\"usd\"",[242,462,463],{"class":252},"}\n",[242,465,467],{"class":244,"line":466},13,[242,468,274],{"emptyLinePlaceholder":273},[242,470,472],{"class":244,"line":471},14,[242,473,274],{"emptyLinePlaceholder":273},[242,475,477,480,483,486,489,492],{"class":244,"line":476},15,[242,478,479],{"class":399},"@v1.get",[242,481,482],{"class":252},"(",[242,484,485],{"class":297},"\"\u002Freports\u002F",[242,487,488],{"class":406},"{report_id}",[242,490,491],{"class":297},"\"",[242,493,494],{"class":252},")\n",[242,496,498,500,502,505,507,509,511,513,515,517],{"class":244,"line":497},16,[242,499,393],{"class":248},[242,501,396],{"class":248},[242,503,504],{"class":399}," get_report_v1",[242,506,403],{"class":252},[242,508,407],{"class":406},[242,510,410],{"class":252},[242,512,407],{"class":406},[242,514,301],{"class":252},[242,516,417],{"class":406},[242,518,420],{"class":252},[242,520,522,525,527,530],{"class":244,"line":521},17,[242,523,524],{"class":252},"    row ",[242,526,283],{"class":248},[242,528,529],{"class":248}," await",[242,531,532],{"class":252}," load_report(report_id)\n",[242,534,536,538,540,542,545,547,550,553,555,557,560,563,566],{"class":244,"line":535},18,[242,537,432],{"class":248},[242,539,435],{"class":252},[242,541,438],{"class":297},[242,543,544],{"class":252},": row[",[242,546,438],{"class":297},[242,548,549],{"class":252},"], ",[242,551,552],{"class":297},"\"total\"",[242,554,544],{"class":252},[242,556,444],{"class":297},[242,558,559],{"class":252},"] ",[242,561,562],{"class":248},"\u002F",[242,564,565],{"class":406}," 100",[242,567,463],{"class":252},[242,569,571],{"class":244,"line":570},19,[242,572,274],{"emptyLinePlaceholder":273},[242,574,576],{"class":244,"line":575},20,[242,577,274],{"emptyLinePlaceholder":273},[242,579,581,584,586,588,590,592],{"class":244,"line":580},21,[242,582,583],{"class":399},"@v2.get",[242,585,482],{"class":252},[242,587,485],{"class":297},[242,589,488],{"class":406},[242,591,491],{"class":297},[242,593,494],{"class":252},[242,595,597,599,601,604,606,608,610,612,614,616],{"class":244,"line":596},22,[242,598,393],{"class":248},[242,600,396],{"class":248},[242,602,603],{"class":399}," get_report_v2",[242,605,403],{"class":252},[242,607,407],{"class":406},[242,609,410],{"class":252},[242,611,407],{"class":406},[242,613,301],{"class":252},[242,615,417],{"class":406},[242,617,420],{"class":252},[242,619,621,623,625,627],{"class":244,"line":620},23,[242,622,524],{"class":252},[242,624,283],{"class":248},[242,626,529],{"class":248},[242,628,532],{"class":252},[242,630,632,634],{"class":244,"line":631},24,[242,633,432],{"class":248},[242,635,636],{"class":252}," {\n",[242,638,640,643,645,647],{"class":244,"line":639},25,[242,641,642],{"class":297},"        \"id\"",[242,644,544],{"class":252},[242,646,438],{"class":297},[242,648,649],{"class":252},"],\n",[242,651,653,656,659,662,664,666,668,670,672,674],{"class":244,"line":652},26,[242,654,655],{"class":297},"        \"total\"",[242,657,658],{"class":252},": {",[242,660,661],{"class":297},"\"amount\"",[242,663,544],{"class":252},[242,665,444],{"class":297},[242,667,549],{"class":252},[242,669,455],{"class":297},[242,671,544],{"class":252},[242,673,455],{"class":297},[242,675,676],{"class":252},"]},\n",[242,678,680],{"class":244,"line":679},27,[242,681,682],{"class":252},"    }\n",[242,684,686],{"class":244,"line":685},28,[242,687,274],{"emptyLinePlaceholder":273},[242,689,691],{"class":244,"line":690},29,[242,692,274],{"emptyLinePlaceholder":273},[242,694,696],{"class":244,"line":695},30,[242,697,698],{"class":252},"app.include_router(v1)\n",[242,700,702],{"class":244,"line":701},31,[242,703,704],{"class":252},"app.include_router(v2)\n",[14,706,707,708,711],{},"The header scheme costs more code before it does anything useful, because you have to parse the negotiation yourself, reject unknown versions with a ",[18,709,710],{},"406",", and remember to advertise what you did:",[233,713,715],{"className":235,"code":714,"language":237,"meta":238,"style":238},"import os\nimport re\nfrom fastapi import Depends, FastAPI, Header, HTTPException, Response\n\nVENDOR = os.getenv(\"API_VENDOR\", \"acme\")\nDEFAULT_VERSION = int(os.getenv(\"API_DEFAULT_VERSION\", \"1\"))\nSUPPORTED = {1, 2}\nACCEPT_RE = re.compile(rf\"application\u002Fvnd\\.{re.escape(VENDOR)}\\.v(\\d+)\\+json\")\n\napp = FastAPI(title=os.getenv(\"API_TITLE\", \"Reports API\"))\n\n\nasync def negotiated_version(accept: str | None = Header(default=None)) -> int:\n    if not accept:\n        return DEFAULT_VERSION\n    for candidate in accept.split(\",\"):\n        found = ACCEPT_RE.search(candidate.strip())\n        if not found:\n            continue\n        version = int(found.group(1))\n        if version not in SUPPORTED:\n            raise HTTPException(406, f\"unsupported version v{version}\")\n        return version\n    return DEFAULT_VERSION\n\n\n@app.get(\"\u002Freports\u002F{report_id}\")\nasync def get_report(\n    report_id: str,\n    response: Response,\n    version: int = Depends(negotiated_version),\n) -> dict[str, object]:\n    response.headers[\"Vary\"] = \"Accept\"\n    response.headers[\"Content-Type\"] = f\"application\u002Fvnd.{VENDOR}.v{version}+json\"\n    match version:\n        case 1:\n            return {\"id\": report_id, \"total\": 12.99}\n        case _:\n            return {\"id\": report_id, \"total\": {\"amount\": 1299, \"currency\": \"usd\"}}\n",[18,716,717,723,730,741,745,766,789,808,843,847,869,873,877,919,930,938,958,971,981,986,1002,1020,1049,1056,1062,1066,1070,1085,1097,1107,1112,1124,1137,1153,1186,1195,1206,1227,1235],{"__ignoreMap":238},[242,718,719,721],{"class":244,"line":245},[242,720,249],{"class":248},[242,722,253],{"class":252},[242,724,725,727],{"class":244,"line":256},[242,726,249],{"class":248},[242,728,729],{"class":252}," re\n",[242,731,732,734,736,738],{"class":244,"line":270},[242,733,259],{"class":248},[242,735,262],{"class":252},[242,737,249],{"class":248},[242,739,740],{"class":252}," Depends, FastAPI, Header, HTTPException, Response\n",[242,742,743],{"class":244,"line":277},[242,744,274],{"emptyLinePlaceholder":273},[242,746,747,750,753,756,759,761,764],{"class":244,"line":310},[242,748,749],{"class":406},"VENDOR",[242,751,752],{"class":248}," =",[242,754,755],{"class":252}," os.getenv(",[242,757,758],{"class":297},"\"API_VENDOR\"",[242,760,301],{"class":252},[242,762,763],{"class":297},"\"acme\"",[242,765,494],{"class":252},[242,767,768,771,773,776,779,782,784,787],{"class":244,"line":315},[242,769,770],{"class":406},"DEFAULT_VERSION",[242,772,752],{"class":248},[242,774,775],{"class":406}," int",[242,777,778],{"class":252},"(os.getenv(",[242,780,781],{"class":297},"\"API_DEFAULT_VERSION\"",[242,783,301],{"class":252},[242,785,786],{"class":297},"\"1\"",[242,788,307],{"class":252},[242,790,791,794,796,798,801,803,806],{"class":244,"line":350},[242,792,793],{"class":406},"SUPPORTED",[242,795,752],{"class":248},[242,797,435],{"class":252},[242,799,800],{"class":406},"1",[242,802,301],{"class":252},[242,804,805],{"class":406},"2",[242,807,463],{"class":252},[242,809,810,813,815,818,821,824,827,830,832,835,838,841],{"class":244,"line":380},[242,811,812],{"class":406},"ACCEPT_RE",[242,814,752],{"class":248},[242,816,817],{"class":252}," re.compile(",[242,819,820],{"class":248},"rf",[242,822,823],{"class":297},"\"application\u002Fvnd\\.",[242,825,826],{"class":406},"{",[242,828,829],{"class":252},"re.escape(",[242,831,749],{"class":406},[242,833,834],{"class":252},")",[242,836,837],{"class":406},"}",[242,839,840],{"class":297},"\\.v(\\d+)\\+json\"",[242,842,494],{"class":252},[242,844,845],{"class":244,"line":385},[242,846,274],{"emptyLinePlaceholder":273},[242,848,849,851,853,855,857,859,861,863,865,867],{"class":244,"line":390},[242,850,280],{"class":252},[242,852,283],{"class":248},[242,854,286],{"class":252},[242,856,78],{"class":289},[242,858,283],{"class":248},[242,860,294],{"class":252},[242,862,298],{"class":297},[242,864,301],{"class":252},[242,866,304],{"class":297},[242,868,307],{"class":252},[242,870,871],{"class":244,"line":423},[242,872,274],{"emptyLinePlaceholder":273},[242,874,875],{"class":244,"line":429},[242,876,274],{"emptyLinePlaceholder":273},[242,878,879,881,883,886,889,891,894,897,899,902,905,907,910,913,916],{"class":244,"line":466},[242,880,393],{"class":248},[242,882,396],{"class":248},[242,884,885],{"class":399}," negotiated_version",[242,887,888],{"class":252},"(accept: ",[242,890,407],{"class":406},[242,892,893],{"class":248}," |",[242,895,896],{"class":406}," None",[242,898,752],{"class":248},[242,900,901],{"class":252}," Header(",[242,903,904],{"class":289},"default",[242,906,283],{"class":248},[242,908,909],{"class":406},"None",[242,911,912],{"class":252},")) -> ",[242,914,915],{"class":406},"int",[242,917,918],{"class":252},":\n",[242,920,921,924,927],{"class":244,"line":471},[242,922,923],{"class":248},"    if",[242,925,926],{"class":248}," not",[242,928,929],{"class":252}," accept:\n",[242,931,932,935],{"class":244,"line":476},[242,933,934],{"class":248},"        return",[242,936,937],{"class":406}," DEFAULT_VERSION\n",[242,939,940,943,946,949,952,955],{"class":244,"line":497},[242,941,942],{"class":248},"    for",[242,944,945],{"class":252}," candidate ",[242,947,948],{"class":248},"in",[242,950,951],{"class":252}," accept.split(",[242,953,954],{"class":297},"\",\"",[242,956,957],{"class":252},"):\n",[242,959,960,963,965,968],{"class":244,"line":521},[242,961,962],{"class":252},"        found ",[242,964,283],{"class":248},[242,966,967],{"class":406}," ACCEPT_RE",[242,969,970],{"class":252},".search(candidate.strip())\n",[242,972,973,976,978],{"class":244,"line":535},[242,974,975],{"class":248},"        if",[242,977,926],{"class":248},[242,979,980],{"class":252}," found:\n",[242,982,983],{"class":244,"line":570},[242,984,985],{"class":248},"            continue\n",[242,987,988,991,993,995,998,1000],{"class":244,"line":575},[242,989,990],{"class":252},"        version ",[242,992,283],{"class":248},[242,994,775],{"class":406},[242,996,997],{"class":252},"(found.group(",[242,999,800],{"class":406},[242,1001,307],{"class":252},[242,1003,1004,1006,1009,1012,1015,1018],{"class":244,"line":580},[242,1005,975],{"class":248},[242,1007,1008],{"class":252}," version ",[242,1010,1011],{"class":248},"not",[242,1013,1014],{"class":248}," in",[242,1016,1017],{"class":406}," SUPPORTED",[242,1019,918],{"class":252},[242,1021,1022,1025,1028,1030,1032,1035,1038,1040,1043,1045,1047],{"class":244,"line":596},[242,1023,1024],{"class":248},"            raise",[242,1026,1027],{"class":252}," HTTPException(",[242,1029,710],{"class":406},[242,1031,301],{"class":252},[242,1033,1034],{"class":248},"f",[242,1036,1037],{"class":297},"\"unsupported version v",[242,1039,826],{"class":406},[242,1041,1042],{"class":252},"version",[242,1044,837],{"class":406},[242,1046,491],{"class":297},[242,1048,494],{"class":252},[242,1050,1051,1053],{"class":244,"line":620},[242,1052,934],{"class":248},[242,1054,1055],{"class":252}," version\n",[242,1057,1058,1060],{"class":244,"line":631},[242,1059,432],{"class":248},[242,1061,937],{"class":406},[242,1063,1064],{"class":244,"line":639},[242,1065,274],{"emptyLinePlaceholder":273},[242,1067,1068],{"class":244,"line":652},[242,1069,274],{"emptyLinePlaceholder":273},[242,1071,1072,1075,1077,1079,1081,1083],{"class":244,"line":679},[242,1073,1074],{"class":399},"@app.get",[242,1076,482],{"class":252},[242,1078,485],{"class":297},[242,1080,488],{"class":406},[242,1082,491],{"class":297},[242,1084,494],{"class":252},[242,1086,1087,1089,1091,1094],{"class":244,"line":685},[242,1088,393],{"class":248},[242,1090,396],{"class":248},[242,1092,1093],{"class":399}," get_report",[242,1095,1096],{"class":252},"(\n",[242,1098,1099,1102,1104],{"class":244,"line":690},[242,1100,1101],{"class":252},"    report_id: ",[242,1103,407],{"class":406},[242,1105,1106],{"class":252},",\n",[242,1108,1109],{"class":244,"line":695},[242,1110,1111],{"class":252},"    response: Response,\n",[242,1113,1114,1117,1119,1121],{"class":244,"line":701},[242,1115,1116],{"class":252},"    version: ",[242,1118,915],{"class":406},[242,1120,752],{"class":248},[242,1122,1123],{"class":252}," Depends(negotiated_version),\n",[242,1125,1127,1129,1131,1133,1135],{"class":244,"line":1126},32,[242,1128,410],{"class":252},[242,1130,407],{"class":406},[242,1132,301],{"class":252},[242,1134,417],{"class":406},[242,1136,420],{"class":252},[242,1138,1140,1143,1146,1148,1150],{"class":244,"line":1139},33,[242,1141,1142],{"class":252},"    response.headers[",[242,1144,1145],{"class":297},"\"Vary\"",[242,1147,559],{"class":252},[242,1149,283],{"class":248},[242,1151,1152],{"class":297}," \"Accept\"\n",[242,1154,1156,1158,1161,1163,1165,1168,1171,1174,1177,1179,1181,1183],{"class":244,"line":1155},34,[242,1157,1142],{"class":252},[242,1159,1160],{"class":297},"\"Content-Type\"",[242,1162,559],{"class":252},[242,1164,283],{"class":248},[242,1166,1167],{"class":248}," f",[242,1169,1170],{"class":297},"\"application\u002Fvnd.",[242,1172,1173],{"class":406},"{VENDOR}",[242,1175,1176],{"class":297},".v",[242,1178,826],{"class":406},[242,1180,1042],{"class":252},[242,1182,837],{"class":406},[242,1184,1185],{"class":297},"+json\"\n",[242,1187,1189,1192],{"class":244,"line":1188},35,[242,1190,1191],{"class":248},"    match",[242,1193,1194],{"class":252}," version:\n",[242,1196,1198,1201,1204],{"class":244,"line":1197},36,[242,1199,1200],{"class":248},"        case",[242,1202,1203],{"class":406}," 1",[242,1205,918],{"class":252},[242,1207,1209,1212,1214,1216,1218,1220,1222,1225],{"class":244,"line":1208},37,[242,1210,1211],{"class":248},"            return",[242,1213,435],{"class":252},[242,1215,438],{"class":297},[242,1217,441],{"class":252},[242,1219,552],{"class":297},[242,1221,447],{"class":252},[242,1223,1224],{"class":406},"12.99",[242,1226,463],{"class":252},[242,1228,1230,1232],{"class":244,"line":1229},38,[242,1231,1200],{"class":248},[242,1233,1234],{"class":252}," _:\n",[242,1236,1238,1240,1242,1244,1246,1248,1250,1252,1254,1256,1258,1260,1262,1264],{"class":244,"line":1237},39,[242,1239,1211],{"class":248},[242,1241,435],{"class":252},[242,1243,438],{"class":297},[242,1245,441],{"class":252},[242,1247,552],{"class":297},[242,1249,658],{"class":252},[242,1251,661],{"class":297},[242,1253,447],{"class":252},[242,1255,450],{"class":406},[242,1257,301],{"class":252},[242,1259,455],{"class":297},[242,1261,447],{"class":252},[242,1263,460],{"class":297},[242,1265,1266],{"class":252},"}}\n",[14,1268,1269,1270,1273],{},"That ",[18,1271,1272],{},"Vary: Accept"," line is not optional garnish. Forget it and every caching layer between you and your customer will serve a v1 body to a v2 client, which is the single nastiest failure mode in this entire debate because it only happens to some users, only under load, and never on your laptop.",[36,1275,1277],{"id":1276},"cacheability-the-dimension-that-costs-real-money","Cacheability: the dimension that costs real money",[14,1279,1280,1281,1284,1285,1288,1289,1293],{},"A path-versioned response is trivially cacheable at the edge. ",[18,1282,1283],{},"\u002Fv1\u002Freports\u002F42"," and ",[18,1286,1287],{},"\u002Fv2\u002Freports\u002F42"," are separate cache objects with separate TTLs, and a CDN or reverse proxy stores and serves them without configuration. If you already run ",[27,1290,1292],{"href":1291},"\u002Fscaling-and-operating-production-python-apis\u002Fcaching-python-api-responses-with-redis\u002F","Redis-backed response caching",", the version simply becomes part of the key you were building anyway.",[14,1295,1296,1297,1300,1301,1303,1304,301,1307,301,1310,1313],{},"Header versioning forces you onto ",[18,1298,1299],{},"Vary",", and ",[18,1302,1299],{}," is a blunt instrument: it stores one variant per distinct header value seen, not per version you support. Clients send ",[18,1305,1306],{},"Accept: *\u002F*",[18,1308,1309],{},"Accept: application\u002Fjson",[18,1311,1312],{},"Accept: application\u002Fvnd.acme.v2+json, application\u002Fjson;q=0.9",", and every SDK's slightly different ordering of the same list. Each spelling becomes a separate cache entry even when three of them resolve to the same version. Hit rate collapses, origin traffic climbs, and your compute bill rises for a reason nobody will find in a profiler.",[67,1315,76,1320,76,1323,76,1326,76,1329,76,1332,76,1338,76,1344,76,1349,76,1354,76,1357,76,1361,76,1365,76,1371,76,1376,76,1379,76,1383,76,1387,76,1390,76,1394,76,1396,76,1398,76,1401,76,1403,76,1406,76,1409,76,1411,76,1414,76,1418,76,1421,76,1424,76,1427,76,1431,76,1435,76,1439,76,1443,76,1446],{"viewBox":1316,"role":70,"ariaLabelledBy":1317,"xmlns":74,"style":75},"0 0 760 372",[1318,1319],"uvh-cache-t","uvh-cache-d",[78,1321,1322],{"id":1318},"Edge cache behaviour under each versioning scheme",[82,1324,1325],{"id":1319},"Path versioning produces two clean cache objects keyed by URL, while Accept-header versioning fragments one URL into many Vary variants and drops the hit rate.",[86,1327],{"x":88,"y":88,"width":89,"height":1328,"fill":91},"372",[93,1330,1331],{"x":95,"y":96,"fill":97,"style":98},"What the edge stores for the same report",[93,1333,1337],{"x":1334,"y":1335,"fill":114,"style":1336},"30","56","font-size:12;font-family:var(--font-sans);","PATH SCHEME",[86,1339],{"x":1334,"y":1340,"width":1341,"height":1342,"rx":1343,"fill":107,"stroke":108,"style":109},"66","120","46","8",[93,1345,1348],{"x":1346,"y":1347,"fill":97,"style":143},"90","94","Clients",[244,1350],{"x1":1351,"y1":1352,"x2":1353,"y2":1352,"stroke":114,"style":109},"150","89","210",[86,1355],{"x":1353,"y":1340,"width":1356,"height":1342,"rx":1343,"fill":107,"stroke":114,"style":109},"130",[93,1358,1360],{"x":1359,"y":1347,"fill":97,"style":143},"275","Edge cache",[244,1362],{"x1":1363,"y1":1352,"x2":1364,"y2":1352,"stroke":114,"style":109},"340","400",[86,1366],{"x":1364,"y":1367,"width":1368,"height":1369,"rx":106,"fill":91,"stroke":1370,"style":109},"60","330","26","var(--c-teal)",[93,1372,1375],{"x":1373,"y":1374,"fill":97,"style":143},"565","78","key \u002Fv1\u002Freports\u002F42 — hit",[86,1377],{"x":1364,"y":1378,"width":1368,"height":1369,"rx":106,"fill":91,"stroke":1370,"style":109},"92",[93,1380,1382],{"x":1373,"y":1381,"fill":97,"style":143},"110","key \u002Fv2\u002Freports\u002F42 — hit",[93,1384,1386],{"x":95,"y":1385,"fill":222,"style":143},"142","2 objects, roughly 90 percent hit rate",[244,1388],{"x1":1334,"y1":170,"x2":1389,"y2":170,"stroke":108,"style":109},"730",[93,1391,1393],{"x":1334,"y":1392,"fill":118,"style":1336},"192","HEADER SCHEME",[86,1395],{"x":1334,"y":187,"width":1341,"height":1342,"rx":1343,"fill":107,"stroke":108,"style":109},[93,1397,1348],{"x":1346,"y":113,"fill":97,"style":143},[244,1399],{"x1":1351,"y1":1400,"x2":1353,"y2":1400,"stroke":118,"style":109},"225",[86,1402],{"x":1353,"y":187,"width":1356,"height":1342,"rx":1343,"fill":107,"stroke":118,"style":109},[93,1404,1360],{"x":1359,"y":1405,"fill":97,"style":143},"223",[93,1407,1272],{"x":1359,"y":104,"fill":222,"style":1408},"text-anchor:middle;font-size:11;font-family:var(--font-sans);",[244,1410],{"x1":1363,"y1":1400,"x2":1364,"y2":1400,"stroke":118,"style":109},[86,1412],{"x":1364,"y":1413,"width":1368,"height":96,"rx":106,"fill":91,"stroke":108,"style":109},"176",[93,1415,1417],{"x":1373,"y":1416,"fill":97,"style":143},"193","\u002Freports\u002F42 + vnd.acme.v1+json",[86,1419],{"x":1364,"y":1420,"width":1368,"height":96,"rx":106,"fill":91,"stroke":108,"style":109},"206",[93,1422,1423],{"x":1373,"y":1405,"fill":97,"style":143},"\u002Freports\u002F42 + application\u002Fjson",[86,1425],{"x":1364,"y":1426,"width":1368,"height":96,"rx":106,"fill":91,"stroke":108,"style":109},"236",[93,1428,1430],{"x":1373,"y":1429,"fill":97,"style":143},"253","\u002Freports\u002F42 + star slash star",[86,1432],{"x":1364,"y":1433,"width":1368,"height":96,"rx":106,"fill":91,"stroke":1434,"style":109},"266","var(--c-yellow)",[93,1436,1438],{"x":1373,"y":1437,"fill":97,"style":143},"283","\u002Freports\u002F42 + v2, json q=0.9",[93,1440,1442],{"x":95,"y":1441,"fill":222,"style":143},"316","4 objects for 2 versions, hit rate falls toward 50 percent",[86,1444],{"x":1334,"y":1368,"width":1445,"height":1334,"rx":106,"fill":107,"stroke":108,"style":109},"700",[93,1447,1449],{"x":95,"y":1448,"fill":97,"style":143},"350","Drop Vary by accident and the edge serves a v1 body to a v2 client",[14,1451,1452],{},"If you go the header route anyway, normalise before you cache: resolve the header to an integer at the edge of your application and build the key from that integer, never from raw header text. That keeps your Redis layer sane even when the CDN in front of you is not.",[233,1454,1456],{"className":235,"code":1455,"language":237,"meta":238,"style":238},"import hashlib\nimport os\nimport redis.asyncio as redis\n\nclient = redis.from_url(os.environ[\"REDIS_URL\"], decode_responses=True)\nNAMESPACE = os.getenv(\"API_CACHE_NAMESPACE\", \"reports\")\nTTL_SECONDS = int(os.getenv(\"API_CACHE_TTL\", \"60\"))\n\n\ndef cache_key(path: str, version: int, tier: str) -> str:\n    \"\"\"One key per (resource, resolved version, plan tier) — never per raw header.\"\"\"\n    raw = f\"{NAMESPACE}:v{version}:{tier}:{path}\"\n    return hashlib.sha256(raw.encode()).hexdigest()[:32]\n\n\nasync def cached_body(path: str, version: int, tier: str) -> str | None:\n    return await client.get(cache_key(path, version, tier))\n\n\nasync def store_body(path: str, version: int, tier: str, body: str) -> None:\n    await client.set(cache_key(path, version, tier), body, ex=TTL_SECONDS)\n",[18,1457,1458,1465,1471,1484,1488,1513,1532,1553,1557,1561,1591,1596,1641,1654,1658,1662,1693,1702,1706,1710,1742],{"__ignoreMap":238},[242,1459,1460,1462],{"class":244,"line":245},[242,1461,249],{"class":248},[242,1463,1464],{"class":252}," hashlib\n",[242,1466,1467,1469],{"class":244,"line":256},[242,1468,249],{"class":248},[242,1470,253],{"class":252},[242,1472,1473,1475,1478,1481],{"class":244,"line":270},[242,1474,249],{"class":248},[242,1476,1477],{"class":252}," redis.asyncio ",[242,1479,1480],{"class":248},"as",[242,1482,1483],{"class":252}," redis\n",[242,1485,1486],{"class":244,"line":277},[242,1487,274],{"emptyLinePlaceholder":273},[242,1489,1490,1493,1495,1498,1501,1503,1506,1508,1511],{"class":244,"line":310},[242,1491,1492],{"class":252},"client ",[242,1494,283],{"class":248},[242,1496,1497],{"class":252}," redis.from_url(os.environ[",[242,1499,1500],{"class":297},"\"REDIS_URL\"",[242,1502,549],{"class":252},[242,1504,1505],{"class":289},"decode_responses",[242,1507,283],{"class":248},[242,1509,1510],{"class":406},"True",[242,1512,494],{"class":252},[242,1514,1515,1518,1520,1522,1525,1527,1530],{"class":244,"line":315},[242,1516,1517],{"class":406},"NAMESPACE",[242,1519,752],{"class":248},[242,1521,755],{"class":252},[242,1523,1524],{"class":297},"\"API_CACHE_NAMESPACE\"",[242,1526,301],{"class":252},[242,1528,1529],{"class":297},"\"reports\"",[242,1531,494],{"class":252},[242,1533,1534,1537,1539,1541,1543,1546,1548,1551],{"class":244,"line":350},[242,1535,1536],{"class":406},"TTL_SECONDS",[242,1538,752],{"class":248},[242,1540,775],{"class":406},[242,1542,778],{"class":252},[242,1544,1545],{"class":297},"\"API_CACHE_TTL\"",[242,1547,301],{"class":252},[242,1549,1550],{"class":297},"\"60\"",[242,1552,307],{"class":252},[242,1554,1555],{"class":244,"line":380},[242,1556,274],{"emptyLinePlaceholder":273},[242,1558,1559],{"class":244,"line":385},[242,1560,274],{"emptyLinePlaceholder":273},[242,1562,1563,1566,1569,1572,1574,1577,1579,1582,1584,1587,1589],{"class":244,"line":390},[242,1564,1565],{"class":248},"def",[242,1567,1568],{"class":399}," cache_key",[242,1570,1571],{"class":252},"(path: ",[242,1573,407],{"class":406},[242,1575,1576],{"class":252},", version: ",[242,1578,915],{"class":406},[242,1580,1581],{"class":252},", tier: ",[242,1583,407],{"class":406},[242,1585,1586],{"class":252},") -> ",[242,1588,407],{"class":406},[242,1590,918],{"class":252},[242,1592,1593],{"class":244,"line":423},[242,1594,1595],{"class":297},"    \"\"\"One key per (resource, resolved version, plan tier) — never per raw header.\"\"\"\n",[242,1597,1598,1601,1603,1605,1607,1610,1613,1615,1617,1619,1622,1624,1627,1629,1631,1633,1636,1638],{"class":244,"line":429},[242,1599,1600],{"class":252},"    raw ",[242,1602,283],{"class":248},[242,1604,1167],{"class":248},[242,1606,491],{"class":297},[242,1608,1609],{"class":406},"{NAMESPACE}",[242,1611,1612],{"class":297},":v",[242,1614,826],{"class":406},[242,1616,1042],{"class":252},[242,1618,837],{"class":406},[242,1620,1621],{"class":297},":",[242,1623,826],{"class":406},[242,1625,1626],{"class":252},"tier",[242,1628,837],{"class":406},[242,1630,1621],{"class":297},[242,1632,826],{"class":406},[242,1634,1635],{"class":252},"path",[242,1637,837],{"class":406},[242,1639,1640],{"class":297},"\"\n",[242,1642,1643,1645,1648,1651],{"class":244,"line":466},[242,1644,432],{"class":248},[242,1646,1647],{"class":252}," hashlib.sha256(raw.encode()).hexdigest()[:",[242,1649,1650],{"class":406},"32",[242,1652,1653],{"class":252},"]\n",[242,1655,1656],{"class":244,"line":471},[242,1657,274],{"emptyLinePlaceholder":273},[242,1659,1660],{"class":244,"line":476},[242,1661,274],{"emptyLinePlaceholder":273},[242,1663,1664,1666,1668,1671,1673,1675,1677,1679,1681,1683,1685,1687,1689,1691],{"class":244,"line":497},[242,1665,393],{"class":248},[242,1667,396],{"class":248},[242,1669,1670],{"class":399}," cached_body",[242,1672,1571],{"class":252},[242,1674,407],{"class":406},[242,1676,1576],{"class":252},[242,1678,915],{"class":406},[242,1680,1581],{"class":252},[242,1682,407],{"class":406},[242,1684,1586],{"class":252},[242,1686,407],{"class":406},[242,1688,893],{"class":248},[242,1690,896],{"class":406},[242,1692,918],{"class":252},[242,1694,1695,1697,1699],{"class":244,"line":521},[242,1696,432],{"class":248},[242,1698,529],{"class":248},[242,1700,1701],{"class":252}," client.get(cache_key(path, version, tier))\n",[242,1703,1704],{"class":244,"line":535},[242,1705,274],{"emptyLinePlaceholder":273},[242,1707,1708],{"class":244,"line":570},[242,1709,274],{"emptyLinePlaceholder":273},[242,1711,1712,1714,1716,1719,1721,1723,1725,1727,1729,1731,1734,1736,1738,1740],{"class":244,"line":575},[242,1713,393],{"class":248},[242,1715,396],{"class":248},[242,1717,1718],{"class":399}," store_body",[242,1720,1571],{"class":252},[242,1722,407],{"class":406},[242,1724,1576],{"class":252},[242,1726,915],{"class":406},[242,1728,1581],{"class":252},[242,1730,407],{"class":406},[242,1732,1733],{"class":252},", body: ",[242,1735,407],{"class":406},[242,1737,1586],{"class":252},[242,1739,909],{"class":406},[242,1741,918],{"class":252},[242,1743,1744,1747,1750,1753,1755,1757],{"class":244,"line":580},[242,1745,1746],{"class":248},"    await",[242,1748,1749],{"class":252}," client.set(cache_key(path, version, tier), body, ",[242,1751,1752],{"class":289},"ex",[242,1754,283],{"class":248},[242,1756,1536],{"class":406},[242,1758,494],{"class":252},[14,1760,1761,1762,1766],{},"That is the same discipline that keeps ",[27,1763,1765],{"href":1764},"\u002Fscaling-and-operating-production-python-apis\u002Fcaching-python-api-responses-with-redis\u002Fcache-invalidation-strategies\u002F","cache invalidation strategies"," tractable: one canonical key shape, computed in one place.",[36,1768,1770],{"id":1769},"client-ergonomics-and-the-support-load-they-generate","Client ergonomics and the support load they generate",[14,1772,1773],{},"Your integrators are not a standards committee. They are a solo founder wiring your API into a Django admin at midnight, an agency developer copying whatever is in your quickstart, and an ops person debugging a broken sync from a terminal. All three benefit from a version that survives being copied and pasted.",[14,1775,1776,1777,1780,1781,1784],{},"With path versioning, a support conversation is a URL. Someone sends you ",[18,1778,1779],{},"https:\u002F\u002Fapi.example.com\u002Fv1\u002Freports\u002F42",", you paste it into your own terminal, and you see exactly what they saw. With header versioning the URL alone is ambiguous: you have to ask what they sent in ",[18,1782,1783],{},"Accept",", they may not know, their HTTP client may rewrite it, and a proxy in between may have stripped or reordered it. That is a five-message exchange instead of one, and at indie scale support time is your most expensive input.",[14,1786,1787,1788,1790,1791,1795],{},"Header versioning also breaks the trivial cases. You cannot open a versioned endpoint in a browser or hand a customer a link. Webhook receivers, spreadsheet connectors and no-code tools mostly provide no way to set a custom ",[18,1789,1783],{}," value. If any part of your product is discoverable by pasting a URL — and it should be, because that is how a ",[27,1792,1794],{"href":1793},"\u002Fbuilding-monetizing-api-driven-micro-saas\u002Fdeploying-apis-to-render-or-vercel\u002Fcreating-a-developer-portal-for-your-api\u002F","developer portal"," converts a curious visitor into a trial — the path scheme is the one that works.",[14,1797,1798],{},"Where header versioning genuinely shines is in a well-built SDK, because the version becomes a client-level default nobody has to think about per call:",[233,1800,1802],{"className":235,"code":1801,"language":237,"meta":238,"style":238},"import os\nimport httpx\n\nBASE_URL = os.environ[\"API_BASE_URL\"]\nVENDOR = os.getenv(\"API_VENDOR\", \"acme\")\nVERSION = os.getenv(\"API_VERSION\", \"2\")\n\n\ndef build_client() -> httpx.AsyncClient:\n    \"\"\"Pin the version once; every request inherits it.\"\"\"\n    return httpx.AsyncClient(\n        base_url=BASE_URL,\n        timeout=httpx.Timeout(10.0, connect=5.0),\n        headers={\n            \"Accept\": f\"application\u002Fvnd.{VENDOR}.v{VERSION}+json\",\n            \"Authorization\": f\"Bearer {os.environ['API_TOKEN']}\",\n        },\n    )\n\n\nasync def fetch_report(report_id: str) -> dict[str, object]:\n    async with build_client() as client:\n        response = await client.get(f\"\u002Freports\u002F{report_id}\")\n        response.raise_for_status()\n        return response.json()\n",[18,1803,1804,1810,1817,1821,1836,1852,1871,1875,1879,1889,1894,1901,1912,1938,1948,1971,2000,2005,2010,2014,2018,2041,2057,2084,2089],{"__ignoreMap":238},[242,1805,1806,1808],{"class":244,"line":245},[242,1807,249],{"class":248},[242,1809,253],{"class":252},[242,1811,1812,1814],{"class":244,"line":256},[242,1813,249],{"class":248},[242,1815,1816],{"class":252}," httpx\n",[242,1818,1819],{"class":244,"line":270},[242,1820,274],{"emptyLinePlaceholder":273},[242,1822,1823,1826,1828,1831,1834],{"class":244,"line":277},[242,1824,1825],{"class":406},"BASE_URL",[242,1827,752],{"class":248},[242,1829,1830],{"class":252}," os.environ[",[242,1832,1833],{"class":297},"\"API_BASE_URL\"",[242,1835,1653],{"class":252},[242,1837,1838,1840,1842,1844,1846,1848,1850],{"class":244,"line":310},[242,1839,749],{"class":406},[242,1841,752],{"class":248},[242,1843,755],{"class":252},[242,1845,758],{"class":297},[242,1847,301],{"class":252},[242,1849,763],{"class":297},[242,1851,494],{"class":252},[242,1853,1854,1857,1859,1861,1864,1866,1869],{"class":244,"line":315},[242,1855,1856],{"class":406},"VERSION",[242,1858,752],{"class":248},[242,1860,755],{"class":252},[242,1862,1863],{"class":297},"\"API_VERSION\"",[242,1865,301],{"class":252},[242,1867,1868],{"class":297},"\"2\"",[242,1870,494],{"class":252},[242,1872,1873],{"class":244,"line":350},[242,1874,274],{"emptyLinePlaceholder":273},[242,1876,1877],{"class":244,"line":380},[242,1878,274],{"emptyLinePlaceholder":273},[242,1880,1881,1883,1886],{"class":244,"line":385},[242,1882,1565],{"class":248},[242,1884,1885],{"class":399}," build_client",[242,1887,1888],{"class":252},"() -> httpx.AsyncClient:\n",[242,1890,1891],{"class":244,"line":390},[242,1892,1893],{"class":297},"    \"\"\"Pin the version once; every request inherits it.\"\"\"\n",[242,1895,1896,1898],{"class":244,"line":423},[242,1897,432],{"class":248},[242,1899,1900],{"class":252}," httpx.AsyncClient(\n",[242,1902,1903,1906,1908,1910],{"class":244,"line":429},[242,1904,1905],{"class":289},"        base_url",[242,1907,283],{"class":248},[242,1909,1825],{"class":406},[242,1911,1106],{"class":252},[242,1913,1914,1917,1919,1922,1925,1927,1930,1932,1935],{"class":244,"line":466},[242,1915,1916],{"class":289},"        timeout",[242,1918,283],{"class":248},[242,1920,1921],{"class":252},"httpx.Timeout(",[242,1923,1924],{"class":406},"10.0",[242,1926,301],{"class":252},[242,1928,1929],{"class":289},"connect",[242,1931,283],{"class":248},[242,1933,1934],{"class":406},"5.0",[242,1936,1937],{"class":252},"),\n",[242,1939,1940,1943,1945],{"class":244,"line":471},[242,1941,1942],{"class":289},"        headers",[242,1944,283],{"class":248},[242,1946,1947],{"class":252},"{\n",[242,1949,1950,1953,1955,1957,1959,1961,1963,1966,1969],{"class":244,"line":476},[242,1951,1952],{"class":297},"            \"Accept\"",[242,1954,447],{"class":252},[242,1956,1034],{"class":248},[242,1958,1170],{"class":297},[242,1960,1173],{"class":406},[242,1962,1176],{"class":297},[242,1964,1965],{"class":406},"{VERSION}",[242,1967,1968],{"class":297},"+json\"",[242,1970,1106],{"class":252},[242,1972,1973,1976,1978,1980,1983,1985,1988,1991,1994,1996,1998],{"class":244,"line":497},[242,1974,1975],{"class":297},"            \"Authorization\"",[242,1977,447],{"class":252},[242,1979,1034],{"class":248},[242,1981,1982],{"class":297},"\"Bearer ",[242,1984,826],{"class":406},[242,1986,1987],{"class":252},"os.environ[",[242,1989,1990],{"class":297},"'API_TOKEN'",[242,1992,1993],{"class":252},"]",[242,1995,837],{"class":406},[242,1997,491],{"class":297},[242,1999,1106],{"class":252},[242,2001,2002],{"class":244,"line":521},[242,2003,2004],{"class":252},"        },\n",[242,2006,2007],{"class":244,"line":535},[242,2008,2009],{"class":252},"    )\n",[242,2011,2012],{"class":244,"line":570},[242,2013,274],{"emptyLinePlaceholder":273},[242,2015,2016],{"class":244,"line":575},[242,2017,274],{"emptyLinePlaceholder":273},[242,2019,2020,2022,2024,2027,2029,2031,2033,2035,2037,2039],{"class":244,"line":580},[242,2021,393],{"class":248},[242,2023,396],{"class":248},[242,2025,2026],{"class":399}," fetch_report",[242,2028,403],{"class":252},[242,2030,407],{"class":406},[242,2032,410],{"class":252},[242,2034,407],{"class":406},[242,2036,301],{"class":252},[242,2038,417],{"class":406},[242,2040,420],{"class":252},[242,2042,2043,2046,2049,2052,2054],{"class":244,"line":596},[242,2044,2045],{"class":248},"    async",[242,2047,2048],{"class":248}," with",[242,2050,2051],{"class":252}," build_client() ",[242,2053,1480],{"class":248},[242,2055,2056],{"class":252}," client:\n",[242,2058,2059,2062,2064,2066,2069,2071,2073,2075,2078,2080,2082],{"class":244,"line":620},[242,2060,2061],{"class":252},"        response ",[242,2063,283],{"class":248},[242,2065,529],{"class":248},[242,2067,2068],{"class":252}," client.get(",[242,2070,1034],{"class":248},[242,2072,485],{"class":297},[242,2074,826],{"class":406},[242,2076,2077],{"class":252},"report_id",[242,2079,837],{"class":406},[242,2081,491],{"class":297},[242,2083,494],{"class":252},[242,2085,2086],{"class":244,"line":631},[242,2087,2088],{"class":252},"        response.raise_for_status()\n",[242,2090,2091,2093],{"class":244,"line":639},[242,2092,934],{"class":248},[242,2094,2095],{"class":252}," response.json()\n",[14,2097,2098,2099,2102,2103,2106],{},"The same one-line default works for a path-versioned API — you put ",[18,2100,2101],{},"\u002Fv2"," in ",[18,2104,2105],{},"base_url"," instead. So the SDK argument is a wash, and the difference only shows up the moment someone works outside the SDK. On a small API that happens constantly.",[36,2108,2110],{"id":2109},"routing-and-documentation-cost-inside-fastapi","Routing and documentation cost inside FastAPI",[14,2112,2113,2114,2117],{},"Path versioning maps onto FastAPI's structure directly. Each version is a router, or better a whole sub-application mounted under its prefix, so v1 pins an old Pydantic model and v2 pins the new one without either importing the other. When you finally delete v1, you delete a directory and one ",[18,2115,2116],{},"include_router"," line. That deletability is worth more than it sounds: old versions linger for years mostly because nobody can tell which code belongs to them.",[14,2119,2120,2121,2124,2125,2129],{},"Header versioning puts the branch inside the handler. That is fine for two versions of one field. It stops being fine when v2 renames a query parameter, drops an endpoint and changes the pagination envelope, because now every handler carries a ",[18,2122,2123],{},"match"," statement, your response model is a union, and the ",[27,2126,2128],{"href":2127},"\u002Fgetting-started-with-python-apis-for-builders\u002Fdocumenting-apis-with-openapi\u002Fcustomizing-fastapi-openapi-schema\u002F","generated OpenAPI schema"," can only describe one branch honestly. You end up hand-maintaining docs — the exact chore FastAPI was supposed to delete from your week.",[67,2131,76,2136,76,2139,76,2142,76,2145,76,2148,76,2152,76,2157,76,2159,76,2162,76,2164,76,2168,76,2171,76,2174,76,2178,76,2180,76,2182,76,2186,76,2188,76,2191,76,2193,76,2195,76,2199,76,2202,76,2205,76,2207,76,2210,76,2212,76,2214,76,2216,76,2219,76,2221,76,2224,76,2227,76,2231,76,2235,76,2237,76,2240,76,2243],{"viewBox":2132,"role":70,"ariaLabelledBy":2133,"xmlns":74,"style":75},"0 0 760 356",[2134,2135],"uvh-route-t","uvh-route-d",[78,2137,2138],{"id":2134},"How a request reaches handler code under each scheme",[82,2140,2141],{"id":2135},"Path versioning splits at the router with one OpenAPI schema per version, while header versioning routes to a single handler that branches internally on the negotiated version.",[86,2143],{"x":88,"y":88,"width":89,"height":2144,"fill":91},"356",[93,2146,2147],{"x":95,"y":96,"fill":97,"style":98},"Where the branch lives in your code",[86,2149],{"x":1334,"y":2150,"width":1368,"height":2151,"rx":106,"fill":107,"stroke":114,"style":109},"44","28",[93,2153,2156],{"x":2154,"y":2155,"fill":97,"style":143},"195","63","Path scheme: branch at the router",[86,2158],{"x":1364,"y":2150,"width":1368,"height":2151,"rx":106,"fill":107,"stroke":118,"style":109},[93,2160,2161],{"x":1373,"y":2155,"fill":97,"style":143},"Header scheme: branch in the handler",[86,2163],{"x":1341,"y":1346,"width":1351,"height":105,"rx":1343,"fill":91,"stroke":108,"style":109},[93,2165,2167],{"x":2154,"y":2166,"fill":97,"style":143},"112","GET \u002Fv2\u002Freports",[86,2169],{"x":2170,"y":1346,"width":1351,"height":105,"rx":1343,"fill":91,"stroke":108,"style":109},"490",[93,2172,2173],{"x":1373,"y":2166,"fill":97,"style":143},"GET \u002Freports",[244,2175],{"x1":2154,"y1":2176,"x2":2154,"y2":2177,"stroke":114,"style":109},"124","146",[244,2179],{"x1":1373,"y1":2176,"x2":1373,"y2":2177,"stroke":118,"style":109},[86,2181],{"x":1367,"y":2177,"width":1341,"height":105,"rx":1343,"fill":91,"stroke":114,"style":109},[93,2183,2185],{"x":1341,"y":2184,"fill":97,"style":143},"168","v1 router",[86,2187],{"x":1353,"y":2177,"width":1341,"height":105,"rx":1343,"fill":91,"stroke":114,"style":109},[93,2189,2190],{"x":112,"y":2184,"fill":97,"style":143},"v2 router",[244,2192],{"x1":2154,"y1":2177,"x2":1341,"y2":2177,"stroke":114,"style":109},[244,2194],{"x1":2154,"y1":2177,"x2":112,"y2":2177,"stroke":114,"style":109},[86,2196],{"x":2197,"y":2177,"width":2198,"height":105,"rx":1343,"fill":91,"stroke":118,"style":109},"470","190",[93,2200,2201],{"x":1373,"y":2184,"fill":97,"style":143},"Accept dependency",[244,2203],{"x1":1373,"y1":2204,"x2":1373,"y2":187,"stroke":118,"style":109},"180",[86,2206],{"x":2197,"y":187,"width":2198,"height":105,"rx":1343,"fill":91,"stroke":118,"style":109},[93,2208,2209],{"x":1373,"y":194,"fill":97,"style":143},"one handler, match v",[244,2211],{"x1":1341,"y1":2204,"x2":1341,"y2":187,"stroke":114,"style":109},[244,2213],{"x1":112,"y1":2204,"x2":112,"y2":187,"stroke":114,"style":109},[86,2215],{"x":1367,"y":187,"width":1341,"height":105,"rx":1343,"fill":91,"stroke":108,"style":109},[93,2217,2218],{"x":1341,"y":194,"fill":97,"style":143},"v1 models",[86,2220],{"x":1353,"y":187,"width":1341,"height":105,"rx":1343,"fill":91,"stroke":108,"style":109},[93,2222,2223],{"x":112,"y":194,"fill":97,"style":143},"v2 models",[86,2225],{"x":1334,"y":2226,"width":1368,"height":1367,"rx":1343,"fill":107,"stroke":1370,"style":109},"262",[93,2228,2230],{"x":2154,"y":2229,"fill":97,"style":143},"285","Two honest OpenAPI documents",[93,2232,2234],{"x":2154,"y":2233,"fill":222,"style":1408},"305","delete a folder to retire v1",[86,2236],{"x":1364,"y":2226,"width":1368,"height":1367,"rx":1343,"fill":107,"stroke":1434,"style":109},[93,2238,2239],{"x":1373,"y":2229,"fill":97,"style":143},"One document, union responses",[93,2241,2242],{"x":1373,"y":2233,"fill":222,"style":1408},"retiring v1 means editing handlers",[93,2244,2246],{"x":95,"y":2245,"fill":222,"style":143},"342","Deletability is the difference that shows up two years later",[14,2248,2249,2250,2253,2254,2257],{},"There is a middle road. Keep the major version in the path and use a dated header — ",[18,2251,2252],{},"X-Api-Version: 2026-07-23",", the shape Stripe popularised — for the tiny behaviour changes that would otherwise force a major bump. Existing accounts pin the date they signed up on, new accounts get today's, and you ship small breaking tweaks continuously without ever minting ",[18,2255,2256],{},"\u002Fv3",". It costs you per-account version storage and one compatibility transform per dated change, but it is the only scheme that scales past a handful of majors.",[36,2259,2261],{"id":2260},"when-to-choose-each","When to choose each",[14,2263,2264,2265,2269,2270,2274],{},"Choose ",[2266,2267,2268],"strong",{},"path versioning"," when your API is public, sold to strangers, cached at the edge, documented with generated OpenAPI, or consumed by anything that cannot set custom headers. That covers essentially every commercial API under a few million requests a month. It is also the right default for a team of one or two, because it makes the version legible in logs, dashboards and ",[27,2271,2273],{"href":2272},"\u002Fbuilding-monetizing-api-driven-micro-saas\u002Ftracking-api-usage-and-analytics\u002F","usage analytics"," with no extra instrumentation.",[14,2276,2264,2277,2280],{},[2266,2278,2279],{},"header versioning"," when the clients are exclusively your own SDKs, the responses are uncacheable and user-specific anyway, and someone owns hand-written documentation. Internal service-to-service APIs fit this well, as does an API whose changes are frequent and small rather than rare and structural — precisely the dated-header case.",[67,2282,76,2287,76,2290,76,2293,76,2296,76,2299,76,2302,76,2306,76,2309,76,2313,76,2316,76,2318,76,2322,76,2326,76,2329,76,2332,76,2334,76,2338,76,2341,76,2344,76,2347,76,2349,76,2351,76,2353,76,2355,76,2360,76,2364,76,2368,76,2372,76,2375,76,2378,76,2380,76,2384,76,2387,76,2389,76,2392,76,2394,76,2397,76,2400,76,2403,76,2407,76,2410,76,2414,76,2417],{"viewBox":2283,"role":70,"ariaLabelledBy":2284,"xmlns":74,"style":75},"0 0 760 392",[2285,2286],"uvh-tree-t","uvh-tree-d",[78,2288,2289],{"id":2285},"Decision tree for picking a versioning scheme",[82,2291,2292],{"id":2286},"Four questions about public clients, edge caching, generated docs and change frequency lead to path versioning, header versioning, or the hybrid of path majors plus a dated header.",[86,2294],{"x":88,"y":88,"width":89,"height":2295,"fill":91},"392",[93,2297,2298],{"x":95,"y":96,"fill":97,"style":98},"Answer four questions, then stop deliberating",[86,2300],{"x":113,"y":103,"width":221,"height":2301,"rx":1343,"fill":107,"stroke":108,"style":109},"38",[93,2303,2305],{"x":95,"y":2304,"fill":97,"style":143},"64","Do strangers call this API directly?",[244,2307],{"x1":95,"y1":1374,"x2":95,"y2":2308,"stroke":108,"style":109},"96",[244,2310],{"x1":2311,"y1":2308,"x2":2312,"y2":2308,"stroke":108,"style":109},"170","620",[244,2314],{"x1":2311,"y1":2308,"x2":2311,"y2":2315,"stroke":118,"style":109},"116",[244,2317],{"x1":2312,"y1":2308,"x2":2312,"y2":2315,"stroke":114,"style":109},[93,2319,2321],{"x":1413,"y":2166,"fill":222,"style":2320},"font-size:11;font-family:var(--font-sans);","no",[93,2323,2325],{"x":2324,"y":2166,"fill":222,"style":2320},"594","yes",[86,2327],{"x":103,"y":2315,"width":2328,"height":2301,"rx":1343,"fill":91,"stroke":118,"style":109},"260",[93,2330,2331],{"x":2311,"y":121,"fill":97,"style":143},"Is anything cached at the edge?",[86,2333],{"x":2197,"y":2315,"width":2328,"height":2301,"rx":1343,"fill":91,"stroke":114,"style":109},[93,2335,2337],{"x":2336,"y":121,"fill":97,"style":143},"600","Ship \u002Fv1 in the path",[244,2339],{"x1":2311,"y1":2340,"x2":2311,"y2":1413,"stroke":108,"style":109},"154",[244,2342],{"x1":1346,"y1":1413,"x2":2343,"y2":1413,"stroke":108,"style":109},"250",[244,2345],{"x1":1346,"y1":1413,"x2":1346,"y2":2346,"stroke":118,"style":109},"196",[244,2348],{"x1":2343,"y1":1413,"x2":2343,"y2":2346,"stroke":114,"style":109},[93,2350,2321],{"x":2308,"y":1392,"fill":222,"style":2320},[93,2352,2325],{"x":194,"y":1392,"fill":222,"style":2320},[86,2354],{"x":1334,"y":2346,"width":113,"height":2301,"rx":1343,"fill":91,"stroke":118,"style":109},[93,2356,2359],{"x":2357,"y":2358,"fill":97,"style":143},"145","220","Docs written by hand?",[86,2361],{"x":2362,"y":2346,"width":2363,"height":2301,"rx":1343,"fill":91,"stroke":114,"style":109},"290","200",[93,2365,2367],{"x":2366,"y":2358,"fill":97,"style":143},"390","Path wins on cache",[244,2369],{"x1":2357,"y1":2370,"x2":2357,"y2":2371,"stroke":108,"style":109},"234","256",[244,2373],{"x1":2374,"y1":2371,"x2":2358,"y2":2371,"stroke":108,"style":109},"80",[244,2376],{"x1":2374,"y1":2371,"x2":2374,"y2":2377,"stroke":114,"style":109},"276",[244,2379],{"x1":2358,"y1":2371,"x2":2358,"y2":2377,"stroke":1370,"style":109},[93,2381,2321],{"x":2382,"y":2383,"fill":222,"style":2320},"86","272",[93,2385,2325],{"x":2386,"y":2383,"fill":222,"style":2320},"194",[86,2388],{"x":102,"y":2377,"width":2358,"height":2301,"rx":1343,"fill":91,"stroke":114,"style":109},[93,2390,2391],{"x":1356,"y":221,"fill":97,"style":143},"Path, keep docs honest",[86,2393],{"x":2328,"y":2377,"width":104,"height":2301,"rx":1343,"fill":91,"stroke":1370,"style":109},[93,2395,2396],{"x":95,"y":221,"fill":97,"style":143},"Accept header is viable",[86,2398],{"x":2197,"y":2346,"width":2328,"height":2399,"rx":1343,"fill":107,"stroke":1434,"style":109},"118",[93,2401,2402],{"x":2336,"y":194,"fill":97,"style":143},"Changing shape monthly?",[93,2404,2406],{"x":2336,"y":2405,"fill":222,"style":1408},"248","Then add the hybrid:",[93,2408,2409],{"x":2336,"y":112,"fill":97,"style":143},"major in path",[93,2411,2413],{"x":2336,"y":2412,"fill":97,"style":143},"292","date in a header",[86,2415],{"x":1334,"y":2416,"width":1445,"height":105,"rx":106,"fill":107,"stroke":108,"style":109},"336",[93,2418,2420],{"x":95,"y":2419,"fill":97,"style":143},"358","Most paid APIs under a few million requests a month land on the path branch",[14,2422,2423,2424,2426],{},"Avoid header versioning if you cannot guarantee ",[18,2425,1299],{}," is set everywhere, if any CDN or platform router sits in front of you with default caching rules you do not fully control, or if you sell to non-technical buyers who will paste URLs into tools you have never heard of. Avoid path versioning only if minting a new major version is something you expect to do more than about once a year — at that cadence, the copy-a-router move stops being cheap and the maintenance surface multiplies.",[36,2428,2430],{"id":2429},"migrating-an-unversioned-api-to-v1","Migrating an unversioned API to \u002Fv1",[14,2432,2433,2434,2437],{},"You shipped ",[18,2435,2436],{},"\u002Freports"," with no version at all. Here is the smallest safe path to a versioned contract without breaking the customers you already have.",[2439,2440,2441,2449,2452,2455,2458,2466],"ol",{},[2442,2443,2444,2445,2448],"li",{},"Mount your existing routes under ",[18,2446,2447],{},"\u002Fv1"," while leaving the unversioned paths working. Nothing changes for anyone yet.",[2442,2450,2451],{},"Add middleware that rewrites unversioned requests to the default version and stamps a header on the response, so you can measure who is still on the legacy path.",[2442,2453,2454],{},"Ship it, then watch your logs for a full billing cycle. You want unversioned request counts per account, not a global total.",[2442,2456,2457],{},"Update your quickstart, SDK defaults and portal examples to the versioned URLs. Most traffic moves on its own as clients upgrade.",[2442,2459,2460,2461,2465],{},"Email the remaining accounts the exact URL change and attach a sunset date. ",[27,2462,2464],{"href":2463},"\u002Fscaling-and-operating-production-python-apis\u002Fversioning-and-evolving-public-apis\u002Fdeprecating-an-api-endpoint-without-breaking-customers\u002F","Deprecating an endpoint without breaking customers"," covers the headers and timeline that make it stick.",[2442,2467,2468],{},"Keep the rewrite middleware forever. It costs microseconds, and it means an old integration never 404s for a reason the customer cannot understand.",[233,2470,2472],{"className":235,"code":2471,"language":237,"meta":238,"style":238},"import os\nfrom fastapi import FastAPI, Request\n\napp = FastAPI(title=os.getenv(\"API_TITLE\", \"Reports API\"))\nDEFAULT_PREFIX = os.getenv(\"API_DEFAULT_PREFIX\", \"\u002Fv1\")\nKNOWN_PREFIXES = tuple(os.getenv(\"API_PREFIXES\", \"\u002Fv1,\u002Fv2\").split(\",\"))\n\n\n@app.middleware(\"http\")\nasync def pin_legacy_requests(request: Request, call_next):\n    \"\"\"Route unversioned paths to the default version, and mark them.\"\"\"\n    path = request.url.path\n    legacy = not path.startswith(KNOWN_PREFIXES) and not path.startswith(\"\u002Fdocs\")\n    if legacy:\n        request.scope[\"path\"] = f\"{DEFAULT_PREFIX}{path}\"\n        request.scope[\"raw_path\"] = request.scope[\"path\"].encode()\n    response = await call_next(request)\n    if legacy:\n        response.headers[\"X-Api-Version-Source\"] = \"implicit-default\"\n    return response\n",[18,2473,2474,2480,2491,2495,2517,2535,2562,2566,2570,2582,2594,2599,2609,2638,2645,2670,2689,2701,2707,2722],{"__ignoreMap":238},[242,2475,2476,2478],{"class":244,"line":245},[242,2477,249],{"class":248},[242,2479,253],{"class":252},[242,2481,2482,2484,2486,2488],{"class":244,"line":256},[242,2483,259],{"class":248},[242,2485,262],{"class":252},[242,2487,249],{"class":248},[242,2489,2490],{"class":252}," FastAPI, Request\n",[242,2492,2493],{"class":244,"line":270},[242,2494,274],{"emptyLinePlaceholder":273},[242,2496,2497,2499,2501,2503,2505,2507,2509,2511,2513,2515],{"class":244,"line":277},[242,2498,280],{"class":252},[242,2500,283],{"class":248},[242,2502,286],{"class":252},[242,2504,78],{"class":289},[242,2506,283],{"class":248},[242,2508,294],{"class":252},[242,2510,298],{"class":297},[242,2512,301],{"class":252},[242,2514,304],{"class":297},[242,2516,307],{"class":252},[242,2518,2519,2522,2524,2526,2529,2531,2533],{"class":244,"line":310},[242,2520,2521],{"class":406},"DEFAULT_PREFIX",[242,2523,752],{"class":248},[242,2525,755],{"class":252},[242,2527,2528],{"class":297},"\"API_DEFAULT_PREFIX\"",[242,2530,301],{"class":252},[242,2532,331],{"class":297},[242,2534,494],{"class":252},[242,2536,2537,2540,2542,2545,2547,2550,2552,2555,2558,2560],{"class":244,"line":315},[242,2538,2539],{"class":406},"KNOWN_PREFIXES",[242,2541,752],{"class":248},[242,2543,2544],{"class":406}," tuple",[242,2546,778],{"class":252},[242,2548,2549],{"class":297},"\"API_PREFIXES\"",[242,2551,301],{"class":252},[242,2553,2554],{"class":297},"\"\u002Fv1,\u002Fv2\"",[242,2556,2557],{"class":252},").split(",[242,2559,954],{"class":297},[242,2561,307],{"class":252},[242,2563,2564],{"class":244,"line":350},[242,2565,274],{"emptyLinePlaceholder":273},[242,2567,2568],{"class":244,"line":380},[242,2569,274],{"emptyLinePlaceholder":273},[242,2571,2572,2575,2577,2580],{"class":244,"line":385},[242,2573,2574],{"class":399},"@app.middleware",[242,2576,482],{"class":252},[242,2578,2579],{"class":297},"\"http\"",[242,2581,494],{"class":252},[242,2583,2584,2586,2588,2591],{"class":244,"line":390},[242,2585,393],{"class":248},[242,2587,396],{"class":248},[242,2589,2590],{"class":399}," pin_legacy_requests",[242,2592,2593],{"class":252},"(request: Request, call_next):\n",[242,2595,2596],{"class":244,"line":423},[242,2597,2598],{"class":297},"    \"\"\"Route unversioned paths to the default version, and mark them.\"\"\"\n",[242,2600,2601,2604,2606],{"class":244,"line":429},[242,2602,2603],{"class":252},"    path ",[242,2605,283],{"class":248},[242,2607,2608],{"class":252}," request.url.path\n",[242,2610,2611,2614,2616,2618,2621,2623,2626,2629,2631,2633,2636],{"class":244,"line":466},[242,2612,2613],{"class":252},"    legacy ",[242,2615,283],{"class":248},[242,2617,926],{"class":248},[242,2619,2620],{"class":252}," path.startswith(",[242,2622,2539],{"class":406},[242,2624,2625],{"class":252},") ",[242,2627,2628],{"class":248},"and",[242,2630,926],{"class":248},[242,2632,2620],{"class":252},[242,2634,2635],{"class":297},"\"\u002Fdocs\"",[242,2637,494],{"class":252},[242,2639,2640,2642],{"class":244,"line":471},[242,2641,923],{"class":248},[242,2643,2644],{"class":252}," legacy:\n",[242,2646,2647,2650,2653,2655,2657,2659,2661,2664,2666,2668],{"class":244,"line":476},[242,2648,2649],{"class":252},"        request.scope[",[242,2651,2652],{"class":297},"\"path\"",[242,2654,559],{"class":252},[242,2656,283],{"class":248},[242,2658,1167],{"class":248},[242,2660,491],{"class":297},[242,2662,2663],{"class":406},"{DEFAULT_PREFIX}{",[242,2665,1635],{"class":252},[242,2667,837],{"class":406},[242,2669,1640],{"class":297},[242,2671,2672,2674,2677,2679,2681,2684,2686],{"class":244,"line":497},[242,2673,2649],{"class":252},[242,2675,2676],{"class":297},"\"raw_path\"",[242,2678,559],{"class":252},[242,2680,283],{"class":248},[242,2682,2683],{"class":252}," request.scope[",[242,2685,2652],{"class":297},[242,2687,2688],{"class":252},"].encode()\n",[242,2690,2691,2694,2696,2698],{"class":244,"line":521},[242,2692,2693],{"class":252},"    response ",[242,2695,283],{"class":248},[242,2697,529],{"class":248},[242,2699,2700],{"class":252}," call_next(request)\n",[242,2702,2703,2705],{"class":244,"line":535},[242,2704,923],{"class":248},[242,2706,2644],{"class":252},[242,2708,2709,2712,2715,2717,2719],{"class":244,"line":570},[242,2710,2711],{"class":252},"        response.headers[",[242,2713,2714],{"class":297},"\"X-Api-Version-Source\"",[242,2716,559],{"class":252},[242,2718,283],{"class":248},[242,2720,2721],{"class":297}," \"implicit-default\"\n",[242,2723,2724,2726],{"class":244,"line":575},[242,2725,432],{"class":248},[242,2727,2728],{"class":252}," response\n",[14,2730,2731,2732,2735,2736,1284,2738,2740,2741,2745],{},"Rewriting ",[18,2733,2734],{},"request.scope[\"path\"]"," before routing is the trick that keeps this to eight lines. Cover it with a test that asserts both ",[18,2737,56],{},[18,2739,1283],{}," return identical bodies — ",[27,2742,2744],{"href":2743},"\u002Fscaling-and-operating-production-python-apis\u002Ftesting-python-apis-with-pytest\u002F","pytest against the real app"," makes that a five-line case, and it is the test that stops a future refactor silently 404ing your oldest customers.",[36,2747,2749],{"id":2748},"builder-verdict","Builder verdict",[14,2751,2752,2753,2755],{},"Ship path versioning. ",[18,2754,2447],{}," in the URL wins on every axis that costs a small commercial API real money: the edge caches it for free, support conversations resolve in one message instead of five, generated docs stay accurate without hand-editing, retiring a version is a directory deletion, and every log line tells you which contract a request used without extra instrumentation. Header versioning is the more elegant reading of HTTP, and if correctness were the only thing at stake it would win — but the elegance is invisible to your customers and the plumbing tax is not. A collapsed cache hit rate on an unremarkable Tuesday costs more than resource-identifier purity is worth, and it is a number you will struggle to attribute.",[14,2757,2758,2759,2761],{},"The refinement worth adopting is the hybrid: majors in the path, small dated changes behind a version header pinned per account. Start with the path only, because it takes ten minutes. Add the dated header the first time you catch yourself wanting ",[18,2760,2101],{}," for a single field rename — that is the signal you have outgrown majors alone, and it usually arrives around the time you have enough revenue to justify the compatibility layer it demands.",[36,2763,2765],{"id":2764},"faq","FAQ",[14,2767,2768,2771,2772,2776],{},[2266,2769,2770],{},"Does path versioning actually reduce my hosting bill?","\nIndirectly but measurably: edge and Redis caching work with no configuration, so a cacheable endpoint at 90 percent hit rate reaches origin a tenth as often. On a small API that is the difference between one container and three — tens of dollars a month, plus the p95 improvement that stops customers opening tickets. Put a number on it with ",[27,2773,2775],{"href":2774},"\u002Fbuilding-monetizing-api-driven-micro-saas\u002Fdesigning-api-pricing-tiers\u002Fcalculating-cost-per-api-request\u002F","cost per API request",".",[14,2778,2779,2782],{},[2266,2780,2781],{},"How risky is switching schemes after launch?","\nSwitching from headers to paths is low risk, because you can serve both simultaneously — mount the routers under prefixes and keep the negotiation dependency as a fallback for the old clients. Going the other way is worse: URLs live in customers' code, cron jobs and no-code tools you cannot see, so you can never fully retire a path. Decide before your second paying integrator and you avoid the question entirely.",[14,2784,2785,2788,2789,2776],{},[2266,2786,2787],{},"Do I need a new major version to add a field?","\nNo. Adding an optional field to a response is additive and safe for any client that parses JSON properly, and a version bump for it is a self-inflicted maintenance cost. Reserve majors for removals, renames, type changes and semantic changes to existing fields. If your customers break on new fields, the fix is a note in your docs telling them to ignore unknown keys, not a ",[18,2790,2101],{},[14,2792,2793,2796],{},[2266,2794,2795],{},"How many versions can a one-person API afford to run?","\nTwo, plus a sunset date on the older one. Every live version multiplies your test matrix, your support surface and the odds a security fix gets backported by hand. If you are running three, pick the least-used one, publish a ninety-day sunset, and spend the reclaimed hours on what customers actually pay for.",[14,2798,2799,2802,2803,2805,2806,2808],{},[2266,2800,2801],{},"What breaks if I forget the Vary header?","\nCross-contamination: a shared cache stores the first response it sees for ",[18,2804,56],{}," and serves it to everyone, so a v2 client receives a v1 body. It fails intermittently, only under real traffic, and only for some users — the hardest possible bug to reproduce. If you use header versioning, add a test that asserts ",[18,2807,1272],{}," appears on every cacheable response, and treat a missing one as a release blocker.",[36,2810,2812],{"id":2811},"related","Related",[14,2814,2815],{},[2266,2816,2817],{},"Same track:",[2819,2820,2821,2826,2832,2838],"ul",{},[2442,2822,2823,2825],{},[27,2824,30],{"href":29}," — the parent guide, with per-version routing, additive-change tests and sunset headers end to end.",[2442,2827,2828,2831],{},[27,2829,2830],{"href":2463},"Deprecating an API Endpoint Without Breaking Customers"," — the timeline and headers for retiring the version you just replaced.",[2442,2833,2834,2837],{},[27,2835,2836],{"href":1291},"Caching Python API Responses with Redis"," — how to build the cache keys that make path versioning pay off.",[2442,2839,2840,2844],{},[27,2841,2843],{"href":2842},"\u002Fscaling-and-operating-production-python-apis\u002Fcaching-python-api-responses-with-redis\u002Fredis-vs-in-memory-caching-for-fastapi\u002F","Redis vs In-Memory Caching for FastAPI"," — where to put the cache once you know what the key looks like.",[14,2846,2847],{},[2266,2848,2849],{},"Other tracks:",[2819,2851,2852,2858],{},[2442,2853,2854,2857],{},[27,2855,2856],{"href":229},"Documenting APIs with OpenAPI"," — keeping one accurate schema per live version instead of hand-editing docs.",[2442,2859,2860,2863],{},[27,2861,2862],{"href":2272},"Tracking API Usage and Analytics"," — per-account version usage, which is what tells you when a migration is safe to finish.",[2865,2866,2867],"style",{},"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 .s4XuR, html code.shiki .s4XuR{--shiki-default:#E36209;--shiki-dark:#FFAB70}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}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);}",{"title":238,"searchDepth":256,"depth":256,"links":2869},[2870,2871,2872,2873,2874,2875,2876,2877,2878],{"id":38,"depth":256,"text":39},{"id":1276,"depth":256,"text":1277},{"id":1769,"depth":256,"text":1770},{"id":2109,"depth":256,"text":2110},{"id":2260,"depth":256,"text":2261},{"id":2429,"depth":256,"text":2430},{"id":2748,"depth":256,"text":2749},{"id":2764,"depth":256,"text":2765},{"id":2811,"depth":256,"text":2812},"Path \u002Fv1 or an Accept header? Compare CDN cacheability, client ergonomics, FastAPI routing and docs cost, then ship the scheme a small paid API can afford.","md",{"pageTitle":2882,"type":2883,"datePublished":2884,"dateModified":2884},"URL Versioning vs Header Versioning for Commercial APIs","article","2026-07-23","\u002Fscaling-and-operating-production-python-apis\u002Fversioning-and-evolving-public-apis\u002Furl-versioning-vs-header-versioning",{"title":5,"description":2879},"scaling-and-operating-production-python-apis\u002Fversioning-and-evolving-public-apis\u002Furl-versioning-vs-header-versioning\u002Findex","HzOY-2pMj_drKgV1EULWQNA9DHsjuXYXCRkOUROV8Bw",{"@context":2890,"@type":2891,"mainEntity":2892},"https:\u002F\u002Fschema.org","FAQPage",[2893,2898,2901,2904,2907],{"@type":2894,"name":2770,"acceptedAnswer":2895},"Question",{"@type":2896,"text":2897},"Answer","Indirectly but measurably: edge and Redis caching work with no configuration, so a cacheable endpoint at 90 percent hit rate reaches origin a tenth as often. On a small API that is the difference between one container and three — tens of dollars a month, plus the p95 improvement that stops customers opening tickets. Put a number on it with cost per API request.",{"@type":2894,"name":2781,"acceptedAnswer":2899},{"@type":2896,"text":2900},"Switching from headers to paths is low risk, because you can serve both simultaneously — mount the routers under prefixes and keep the negotiation dependency as a fallback for the old clients. Going the other way is worse: URLs live in customers' code, cron jobs and no-code tools you cannot see, so you can never fully retire a path. Decide before your second paying integrator and you avoid the question entirely.",{"@type":2894,"name":2787,"acceptedAnswer":2902},{"@type":2896,"text":2903},"No. Adding an optional field to a response is additive and safe for any client that parses JSON properly, and a version bump for it is a self-inflicted maintenance cost. Reserve majors for removals, renames, type changes and semantic changes to existing fields. If your customers break on new fields, the fix is a note in your docs telling them to ignore unknown keys, not a \u002Fv2.",{"@type":2894,"name":2795,"acceptedAnswer":2905},{"@type":2896,"text":2906},"Two, plus a sunset date on the older one. Every live version multiplies your test matrix, your support surface and the odds a security fix gets backported by hand. If you are running three, pick the least-used one, publish a ninety-day sunset, and spend the reclaimed hours on what customers actually pay for.",{"@type":2894,"name":2801,"acceptedAnswer":2908},{"@type":2896,"text":2909},"Cross-contamination: a shared cache stores the first response it sees for \u002Freports\u002F42 and serves it to everyone, so a v2 client receives a v1 body. It fails intermittently, only under real traffic, and only for some users — the hardest possible bug to reproduce. If you use header versioning, add a test that asserts Vary: Accept appears on every cacheable response, and treat a missing one as a release blocker.",1784887028521]