APIs change. A field is renamed, a list becomes nested, a string becomes structured. If your script reads response["name"] and the API rolls out v2 with response["profile"]["name"], your script breaks at 2am on a Tuesday.
The defense is two-pronged: pin the version in your request, and handle both shapes in your code.
responses = [
{"version": "v1", "name": "alice"}, # old shape
{"version": "v2", "profile": {"name": "bob"}}, # new shape
]
for r in responses:
if "profile" in r:
name = r.get("profile", {}).get("name")
print(f"v2 path: {name}")
else:
name = r.get("name")
print(f"v1 path: {name}")Why pin a version and handle both shapes? Doesn't pinning solve the problem?
Pinning means your client requests a specific version. The server has to support that version. Most APIs deprecate older versions on a schedule — six months, a year. So pinning protects you in the short term; handling both shapes protects you across the migration window.
And how do you pin?
Usually a header. Stripe: Stripe-Version: 2024-04-10. GitHub: Accept: application/vnd.github.v3+json. Custom: X-API-Version: v2. The header name and format depends on the API, but the mechanism is universal — one header, one line, version locked.
Providers evolve their schema. Common changes:
name → display_name)name → profile.name)tags: string → tags: List[str])Without versioning, every change breaks every client. With versioning, providers can introduce new shapes while old clients keep working.
Providers document the header to send:
| Provider | Header |
|---|---|
| Stripe | Stripe-Version: 2024-04-10 (date-pinned) |
| GitHub | Accept: application/vnd.github.v3+json |
| Linear | Linear-Version: 2022-06-28 |
| Custom | X-API-Version: v2 |
requests.get(
url,
headers={"X-API-Version": "v2"},
timeout=10,
)Without the header, the provider picks a default — often the latest, sometimes the oldest. Either way you've delegated the choice. Pin it.
Even with a pinned version, your script will eventually need to migrate. During the migration window, you may receive responses in either shape. Handle both:
def extract_name(response):
# v2 shape: {"profile": {"name": ...}}
if "profile" in response:
return response.get("profile", {}).get("name")
# v1 shape: {"name": ...}
return response.get("name")The if "key" in dict check is the canonical way to detect which shape arrived. Once you no longer support v1, delete the v1 branch in a follow-up commit.
| Strategy | When to use |
|---|---|
| Lazy migration — handle both, ship the v2-friendly extractor, drop v1 later | Most cases. Lowest-risk. |
| Atomic switch — flip the version pin, redeploy, revert if broken | Small APIs you fully understand. |
| Side-by-side — run both v1 and v2 paths, compare results, switch when v2 matches | Critical paths where you can't tolerate a regression. |
Almost always: lazy migration. Atomic and side-by-side are tools for specific situations.
# fragile — assumes the v2 shape
name = response["profile"]["name"]
# robust — works on both v1 and v2, returns None on neither
name = response.get("profile", {}).get("name") or response.get("name")The second form is one line longer, an order of magnitude more durable.
APIs change. A field is renamed, a list becomes nested, a string becomes structured. If your script reads response["name"] and the API rolls out v2 with response["profile"]["name"], your script breaks at 2am on a Tuesday.
The defense is two-pronged: pin the version in your request, and handle both shapes in your code.
responses = [
{"version": "v1", "name": "alice"}, # old shape
{"version": "v2", "profile": {"name": "bob"}}, # new shape
]
for r in responses:
if "profile" in r:
name = r.get("profile", {}).get("name")
print(f"v2 path: {name}")
else:
name = r.get("name")
print(f"v1 path: {name}")Why pin a version and handle both shapes? Doesn't pinning solve the problem?
Pinning means your client requests a specific version. The server has to support that version. Most APIs deprecate older versions on a schedule — six months, a year. So pinning protects you in the short term; handling both shapes protects you across the migration window.
And how do you pin?
Usually a header. Stripe: Stripe-Version: 2024-04-10. GitHub: Accept: application/vnd.github.v3+json. Custom: X-API-Version: v2. The header name and format depends on the API, but the mechanism is universal — one header, one line, version locked.
Providers evolve their schema. Common changes:
name → display_name)name → profile.name)tags: string → tags: List[str])Without versioning, every change breaks every client. With versioning, providers can introduce new shapes while old clients keep working.
Providers document the header to send:
| Provider | Header |
|---|---|
| Stripe | Stripe-Version: 2024-04-10 (date-pinned) |
| GitHub | Accept: application/vnd.github.v3+json |
| Linear | Linear-Version: 2022-06-28 |
| Custom | X-API-Version: v2 |
requests.get(
url,
headers={"X-API-Version": "v2"},
timeout=10,
)Without the header, the provider picks a default — often the latest, sometimes the oldest. Either way you've delegated the choice. Pin it.
Even with a pinned version, your script will eventually need to migrate. During the migration window, you may receive responses in either shape. Handle both:
def extract_name(response):
# v2 shape: {"profile": {"name": ...}}
if "profile" in response:
return response.get("profile", {}).get("name")
# v1 shape: {"name": ...}
return response.get("name")The if "key" in dict check is the canonical way to detect which shape arrived. Once you no longer support v1, delete the v1 branch in a follow-up commit.
| Strategy | When to use |
|---|---|
| Lazy migration — handle both, ship the v2-friendly extractor, drop v1 later | Most cases. Lowest-risk. |
| Atomic switch — flip the version pin, redeploy, revert if broken | Small APIs you fully understand. |
| Side-by-side — run both v1 and v2 paths, compare results, switch when v2 matches | Critical paths where you can't tolerate a regression. |
Almost always: lazy migration. Atomic and side-by-side are tools for specific situations.
# fragile — assumes the v2 shape
name = response["profile"]["name"]
# robust — works on both v1 and v2, returns None on neither
name = response.get("profile", {}).get("name") or response.get("name")The second form is one line longer, an order of magnitude more durable.
Create a free account to get started. Paid plans unlock all tracks.