Version Your API Endpoints

Learn how to version your API endpoints properly with this hands-on Python web development tutorial. Step-by-step guidance, best practices, and common pitfalls.

Focus: version your api endpoints properly

Sponsored

Picture this: you've shipped a REST API, thousands of clients depend on it, and then product asks for a breaking change. One innocent tweak — renaming a field, changing a status code, altering validation rules — and suddenly every mobile app, dashboard, and third-party integration starts failing in production. This is the pain every API developer eventually hits. The solution isn't to freeze your API forever or to make changes without warning; it's to version your API endpoints properly from the start. In this lesson, you'll learn practical, battle-tested strategies to introduce changes without breaking your consumers, using Python and FastAPI as the concrete example.

The Problem This Lesson Solves

APIs evolve. New requirements emerge, bugs surface, and business logic changes. The core problem is that APIs are contracts — once a client starts using an endpoint, they implicitly rely on its exact behavior. If you change that behavior, you break the contract and the client.

Common failure modes:

  • Silent breaking changes: You rename a JSON field and no one notices until Monday morning.
  • Data model drift: Internal database changes leak into the API response shape.
  • Versioning anarchy: You have v1, v2, vFinal, and /latest, and nobody knows what's live.
  • Unversioned endpoints: You simply edit the same URL, and every client gets the new behavior — whether they're ready or not.

Without a deliberate strategy, you'll face downtime, angry users, and a mountain of support tickets. The goal of versioning is to allow the API to evolve while giving clients a stable contract.

Pro tip: Versioning isn't just about URLs — it's about expectation management. Your versioning strategy defines how you communicate change to your consumers.

Core Concept / Mental Model

Think of an API version like a software release. When you install a major version of a library, you expect backward-incompatible changes. When you patch, you expect bug fixes with no breaking changes. The same idea applies to HTTP APIs.

A versioned API endpoint is an immutable contract: once you release /v1/users, its response format and behavior stay the same forever. When you need breaking changes, you create /v2/users. Clients choose when to migrate.

There are three primary ways to communicate the version:

  1. URI path versioning: https://api.example.com/v1/users — simple, explicit, and cache-friendly.
  2. Query parameter versioning: https://api.example.com/users?version=1 — easy to implement, but easy to forget and less RESTful.
  3. Header versioning: X-API-Version: 1 — keeps URLs clean but adds complexity.

The mental model is simple: the version is part of the address. Changing the version creates a new address, not a change to the existing one. This way, the old version keeps working for clients that haven't migrated.

Key insight: Versioning is not about code duplication — it's about backward compatibility. You're managing the lifecycle of your contract, not just the route.

How It Works Step by Step

Here’s how proper endpoint versioning works in practice:

  1. Decide on a versioning scheme (URI path is recommended for simplicity).
  2. Create versioned route modules in your web framework (FastAPI, Flask, Django).
  3. Define version-specific response schemas — older versions keep the old serialization.
  4. Explicitly route requests to the right version handler.
  5. Maintain old versions until you deprecate them (with a plan and grace period).
  6. Document the versioning policy in your API docs so clients know what to expect.

The key is to isolate version logic — each version acts as its own mini-API. This prevents accidental cross-contamination of breaking changes.

Hands-On Walkthrough

Let’s implement URI path versioning in FastAPI. We’ll create two versions of a /users endpoint.

1. Project Setup

First, install FastAPI and uvicorn if you haven't already:

pip install fastapi uvicorn

2. Build Two Versions of the Same Resource

Create a file main.py:

from fastapi import FastAPI
from pydantic import BaseModel
from typing import Optional

app = FastAPI()

# Shared data model (internal representation)
users_db = {
    1: {"name": "Alice", "age": 30, "email": "alice@example.com"},
    2: {"name": "Bob", "age": 25, "email": "bob@example.com"},
}

# -------- V1 response schema (original) --------
class UserV1(BaseModel):
    id: int
    name: str
    age: int

# -------- V2 response schema (adds email, keeps compatibility) --------
class UserV2(BaseModel):
    id: int
    name: str
    age: int
    email: Optional[str] = None

# -------- V1 endpoint --------
@app.get("/v1/users/{user_id}", response_model=UserV1)
def get_user_v1(user_id: int):
    user = users_db.get(user_id)
    if not user:
        return {"error": "not found"}
    return {"id": user_id, "name": user["name"], "age": user["age"]}

# -------- V2 endpoint --------
@app.get("/v2/users/{user_id}", response_model=UserV2)
def get_user_v2(user_id: int):
    user = users_db.get(user_id)
    if not user:
        return {"error": "not found"}
    return {"id": user_id, "name": user["name"], "age": user["age"], "email": user["email"]}

Run with:

uvicorn main:app --reload

Now test the endpoints:

curl http://127.0.0.1:8000/v1/users/1
curl http://127.0.0.1:8000/v2/users/1

Expected output:

  • /v1/users/1 returns {"id":1,"name":"Alice","age":30} (no email — backward compatible with old clients).
  • /v2/users/1 returns {"id":1,"name":"Alice","age":30,"email":"alice@example.com"} (new field, no impact on v1).

This demonstrates the core idea: each version is a separate contract. You can evolve freely without breaking anyone.

3. Using a Router for Cleaner Organization

For a real project, you'd organize versions into separate modules:

# routers/v1.py
from fastapi import APIRouter

router = APIRouter(prefix="/v1")

@router.get("/users/{user_id}")
def get_user(user_id: int):
    # original implementation
    return {"id": user_id, "name": "Legacy User"}

# main.py
from fastapi import FastAPI
from routers.v1 import router as v1_router

app = FastAPI()
app.include_router(v1_router)

This scales well and keeps versioned code isolated.

Compare Options / When to Choose What

Strategy URL Example Pros Cons Best For
URI path /v1/products Explicit, cache-friendly, easy SEO Requires URL changes per version Most REST APIs
Query param /products?version=1 Quick to implement Non-canonical, easy to forget Internal tools, legacy systems
Header X-API-Version: 1 Clean URLs Harder to debug, less transparent Enterprise APIs with strict URL contracts
Accept header (content negotiation) Accept: application/vnd.api.v1+json Standards-compliant Complex setup Public-facing hypermedia APIs

Rule of thumb: Start with URI path versioning. It's the simplest to reason about and the most widely understood. Add query-param or header versioning only if you have a specific need (like a stable URL for marketing or a custom HTTP client).

Pro tip: Avoid versioning the entire API with a global v1 prefix unless you truly need it. It's fine to version only the resources that change — but consistency is key.

Troubleshooting & Edge Cases

Even with clear versioning, you'll hit predictable pitfalls. Here’s how to handle them:

  • Symptom: Your clients are hitting the wrong version because they cached the old URL.
  • Fix: Use Cache-Control headers to prevent caching of versioned responses, or include the version in the cache key.

  • Symptom: You accidentally break v1 because you changed a shared model.

  • Fix: Never share response Pydantic models across versions. Each version gets its own schema class, even if it's a copy.

  • Symptom: You deprecate v1 after 1 month and users complain.

  • Fix: Set a clear deprecation policy: announce 6-12 months ahead, log warnings, and provide migration docs. Use a Deprecation-Warning header.

  • Symptom: You’re using query-param versioning, and clients forget to pass ?version=1.

  • Fix: Default to the latest version, but log a warning if version is missing to encourage adoption.

  • Symptom: Versioning only some routes leads to confusion.

  • Fix: Document every versioned endpoint in your API docs (e.g., with FastAPI's built-in Swagger UI).

What You Learned & What's Next

In this lesson, you learned the core strategies to version your API endpoints properly: you now understand why versioning matters, how to implement URI-based versioning in FastAPI, and how to compare it with query-param and header approaches. You know that each version is an immutable contract, and you’re equipped with troubleshooting tactics for real-world edge cases.

Next, you'll learn how to document your API effectively — providing clear, self-service documentation so your clients can discover endpoints and migrate smoothly between versions. Until then, practice by applying versioning to your own API and testing both old and new versions side-by-side.

Practice recap

Create a small FastAPI project with two versions of a /products endpoint. In v1, return only id and name. In v2, add price and stock. Test both endpoints with curl and verify that v1 output remains unchanged even after adding features to v2. Then, try adding a Deprecation-Warning header to v1 to practice deprecation signaling.

Common mistakes

  • Using the same response model across versions — a small change in a shared Pydantic class silently breaks v1 responses.
  • Forgetting to include the version in cache keys — a CDN or browser caches v1 response and serves it for v2 URLs.
  • Deprecating a version without a migration plan or warning headers, leading to angry consumers.
  • Mixing versioning strategies inconsistently (e.g., path for some endpoints, query param for others), confusing clients.

Variations

  1. Query parameter versioning: use ?version=1 on a stable URL — easy but less explicit.
  2. Header versioning with a custom X-API-Version header — keeps URLs clean but requires client cooperation.
  3. Content negotiation via Accept: application/vnd.api.v1+json — standards-based but more complex to implement.

Real-world use cases

  • A SaaS platform releases a breaking change to its invoice endpoint, keeping v1 live for six months while clients migrate.
  • A mobile app backend adds new fields to user profiles, but older app versions continue using v1 endpoints unchanged.
  • An enterprise integration API uses header versioning to meet strict URL contract requirements from legacy clients.

Key takeaways

  • Version your API endpoints properly by treating each version as an immutable contract — never mutate a released endpoint.
  • URI path versioning (/v1/...) is the simplest and most transparent choice for most REST APIs.
  • Isolate versioned schemas and logic; never share mutable state or response models across versions.
  • Always plan a deprecation strategy: warn clients via headers and provide a migration path.
  • Choose a versioning strategy that matches your API's audience and infrastructure, and document it clearly.
  • Test old and new versions in CI to ensure backward compatibility after every change.

Sponsored

Sponsored

Discussion

Questions, corrections, and tips help everyone reading this page.

0 comments

Add a comment

Shown publicly with your comment.

Be constructive · max 4,000 characters

No comments yet — start the thread.

Related tutorials, quizzes, and articles for this topic.