DEV Community

Mustafa ERBAY
Mustafa ERBAY

Posted on • Originally published at mustafaerbay.com.tr

API Versioning: Current Approaches and Choices in the Ecosystem

The moment you launch an API, one of the biggest nightmares associated with it is versioning decisions. Especially when you consider that this API will be used by different clients or is expected to have a long lifespan, things become even more complex. Looking back at the trouble I got myself into by underestimating this topic in the past, my pragmatic approach today is rooted in those painful experiences.

API versioning is one of the key ways an application can evolve over time while ensuring that existing clients are not disrupted. In a production ERP system I worked on, the painful lesson of 20 different operator screens suddenly throwing errors due to a minor change in a JSON field name taught me how critical this issue is. In this post, I will discuss API versioning approaches, the experiences I've gained in my projects, and the trade-offs these choices bring.

Why is API Versioning Necessary? My First Wrong Choices

When developing an API, everything initially goes through a single version, and it's easy. However, over time, business requirements change, new features are added, and old features are updated or removed. These changes directly affect the applications consuming the API (mobile apps, web frontends, other services) and are called breaking changes. A breaking change causes an application using the API to stop working without code modifications.

In my early projects, I overlooked this. While developing the backend for a financial calculator side project, I didn't implement versioning with the thought, "I'm the only one using it, so it's not necessary." A few months later, while adding a new feature, I had to change the data format my existing mobile app received from the backend. The result: I had to deploy both the backend and the mobile app simultaneously and wait for users to update their applications. This was unacceptable for a system with zero downtime expectations.

⚠️ Proven by Experience: Versioning is Essential

Versioning is critical not only when your API is exposed externally but also in internal inter-service communication. If one service uses another service's API, a breaking change can cause the other service to break, leading to hours of downtime in the production environment.

These early mistakes taught me that versioning is not just a "technical detail" but also a matter of product strategy and operational flexibility. It is essential to define a versioning strategy from the outset to extend the life of your API and avoid inconveniencing your clients. Otherwise, we face a significant regression risk with every change.

Main API Versioning Approaches: Pros and Cons

There are three main approaches at the core of API versioning: URL Path, Query Parameter, and Header (Content Negotiation). Each has its own advantages and disadvantages, and choosing the right one depends on the project's needs. I've also experienced different approaches in various projects and seen when each works and when it causes headaches.

Approach Pros Cons When I Preferred It
URL Path - Simple, understandable, discoverable - Can lead to URL bloat - Public APIs, simple projects (my own side projects)
Query Parameter - Keeps URLs relatively clean - Open to misuse, low discoverability - Rarely, usually as a "fallback"
Header (Custom) - Keeps URLs cleanest - Low discoverability, requires client support - Internal APIs, places with strict control (ERP)
Header (Accept) - Compliant with HTTP, Content Negotiation - Client implementation more complex - When different output formats are required

As a general rule, for external and public APIs, I've usually preferred the URL Path method because its simplicity and understandability provide the least friction for external developers. For internal APIs, I can opt for Header-based approaches that are more flexible and keep URLs clean.

URL Path Versioning: Direct and Understandable

URL Path versioning is perhaps the most common and understandable method. You include the API's version number directly in the URL path, for example, /v1/users or /api/v2/products. This method can be easily tested even in a browser, and its documentation is straightforward.

When developing a production ERP system, I used this method for a few integration APIs exposed externally. Our clients could clearly see which version they were calling from the URL. However, when I kept two different versions live simultaneously, I started seeing if version == "v1" blocks in the backend code, which was not a pleasant situation in terms of code quality. We can make this situation a bit more manageable with a reverse proxy like Nginx.

# Nginx config example
server {
    listen 80;
    server_name api.example.com;

    location ~ ^/(v1|v2)/ {
        # Routing for v1 or v2 paths
        proxy_pass http://backend_api_cluster/$request_uri;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
    }

    location /v1/ {
        # Specific backend or logic for v1
        proxy_pass http://v1_backend_cluster;
        proxy_set_header Host $host;
    }

    location /v2/ {
        # Specific backend or logic for v2
        proxy_pass http://v2_backend_cluster;
        proxy_set_header Host $host;
    }
}
Enter fullscreen mode Exit fullscreen mode

In the Nginx example above, you can route different versions to different backends. This gives me the flexibility to deploy different versions independently and allows me to develop new versions independently while supporting older versions. However, this approach leads to longer URLs and the need to define a new path for each new version. It's inevitable that URLs will "bloat" as they go from v3, v4, and so on.

Header and Content Negotiation Versioning: More Flexible Methods

Header-based versioning specifies the API version via HTTP Request Headers. This keeps URLs clean and ensures that the API's base resource URI does not change. For example, we can use a custom header like X-API-Version: 2.

Versioning using Content Negotiation is a more standard HTTP approach. By using the Accept header, the client indicates that it is requesting a specific media type or API version. For instance, with a value like Accept: application/vnd.myapi.v2+json, the client is saying "I want the 2nd version of myapi in JSON format." I used this approach in a client project where the same endpoint needed to work with different data models.

# FastAPI example: Versioning with Accept header
from fastapi import FastAPI, Request, HTTPException
from fastapi.responses import JSONResponse

app = FastAPI()

@app.get("/items")
async def get_items(request: Request):
    accept_header = request.headers.get("Accept")

    if "application/vnd.myapi.v1+json" in accept_header:
        # Return data according to v1 model
        return JSONResponse({"version": "v1", "data": [{"id": 1, "name": "Item A"}]})
    elif "application/vnd.myapi.v2+json" in accept_header:
        # Return data according to v2 model (e.g., with an additional field)
        return JSONResponse({"version": "v2", "data": [{"uuid": "abc", "item_name": "Item A", "price": 100}]})
    else:
        raise HTTPException(status_code=406, detail="Unsupported Accept header")

Enter fullscreen mode Exit fullscreen mode

This approach has been useful, especially in internal service-to-service communication or with tightly controlled clients like mobile applications. When developers correctly set the Accept header, it was clear which version of the API they were using. However, it is less discoverable than URL Path; for someone using the API for the first time, setting the header correctly requires reading a bit more documentation. Also, some client libraries or tools may not automatically support such custom headers or Content Negotiation, which means additional development costs.

My Versioning Strategy Choices in Projects

The choice of API versioning strategy varies depending on the nature of the project and its target audience. In my experience, there is no "single right way"; there are always trade-offs. The decisions I've made in my own side projects or in internal platforms of banks I've consulted for differ from those made in a production ERP system where I gained experience.

For example, for the public API of my financial calculator side project, I preferred URL Path versioning. The reason was simple: to enable external developers to use and integrate with the API easily. A simple URL like /v1/calculate speeds up the onboarding process, compared to a complex header structure. Currently, I maintain both v1 and v2 versions live and ensure that older clients using v1 continue to work without issues. However, this comes with the burden of maintaining the code for both versions in the backend.

On the other hand, for services I developed for a bank's internal platform, I versioned the APIs via the Accept header. On this platform, since the clients were typically enterprise applications, developers had a higher ability to manage HTTP headers correctly. Additionally, keeping the URLs clean provided a more organized view for the bank's security and audit teams. In this choice, the idea that "keeping URLs clean and unchanging simplifies firewall rules and monitoring configurations" was influential. Especially when a service has more than 20 APIs, going with /v1/users, /v2/users, /v3/users can be a more elegant solution than Accept: application/vnd.bank.users.v3+json.

ℹ️ Which Method When?

When making a choice, consider your target audience and the lifespan of your API. Simplicity (URL Path) for public APIs and flexibility (Header) for closed systems or custom integrations generally yield better results. Remember, the ultimate goal of API versioning is to be able to evolve your API without breaking your clients.

In these choices, I always asked myself, "How much flexibility will I need in the future?" and "What will be the operational cost of this approach?" Sometimes I accepted technical debt for the sake of simplicity, and sometimes I opted for a slightly more complex start for long-term sustainability. The important thing is to be aware of these trade-offs and make informed decisions.

Zero Downtime and Deprecation Management During Version Transitions

One of the most challenging parts of API versioning is ensuring a smooth transition from older versions to newer ones. When working with the goal of "zero downtime," both old and new versions need to be live simultaneously for a period. This is usually managed through a process called "graceful deprecation."

During the transition from v1 API to v2 in an ERP system for a manufacturing firm, I observed that the old operator screens continued to use v1, while the newly developed mobile applications started using v2. To manage this transition process, we set a deprecation period of approximately 6 months. During this period, by monitoring calls to the v1 API, we identified which clients were still using the old version. I notified clients by adding a warning header (X-API-Deprecated: true; Deprecation-Date: 2026-12-31) to the v1 endpoints.

HTTP/1.1 200 OK
Content-Type: application/json
X-API-Deprecated: true; Deprecation-Date: 2026-12-31
Link: <https://api.example.com/v2/docs>; rel="sunset"; type="text/html"

{
  "message": "This API version (v1) will be deprecated on December 31, 2026. Please migrate to v2."
  // ... v1 response data
}
Enter fullscreen mode Exit fullscreen mode

This type of deprecation notice gives client-side developers sufficient time and helps them plan the transition. Seeing the number of requests to v1 endpoints decrease over the deprecation period was a key metric indicating that we could safely shut down v1. If I hadn't done this monitoring, I might have shut down v1 and put a customer still using it in a difficult situation. I previously wrote about [related: observability strategies], and this is part of that.

I haven't shied away from making mistakes either. Once, while performing final checks before shutting down v1, I decided to shut it down within an hour with the thought, "no one is using it anyway." However, a reporting tool that was overlooked was still using v1, and that tool's reports suddenly started coming back "empty." This taught me the lesson: "Never assume, always look at the data." In such situations, having rollback mechanisms ready is a lifesaver. My [related: post on CI/CD reliability] also touches upon these topics.

Development and Operational Cost of API Versioning

API versioning brings significant development and operational costs with it. Defining a strategy without considering these costs can lead to major problems down the line. In my experience, these costs are generally categorized under three main headings: documentation, testing, and deployment.

Documentation: Each new API version requires an updated or new set of documentation. While tools like Swagger/OpenAPI simplify this process, maintaining separate documentation for two or more active versions requires continuous effort. Especially in cases where a field in v1 changes or is removed in v2, clear and up-to-date documentation is vital to avoid confusing client developers. I know I spend a few hours a week keeping the API documentation for my side project up to date.

Testing: Each new version means expanding the existing test suite. The tests you wrote for v1 may not be valid for v2, or you may need to add new scenarios specific to v2. In an ERP project, we updated our CI/CD pipeline to test v1 and v2 APIs simultaneously. This increased test time by 30%, but it was necessary to catch regressions. I especially had to write separate integration tests for each version in automated tests.

# Example CI/CD test step (pseudo-code)
# Similar steps can be used in Gitlab CI/CD or Github Actions

test_api_versions:
  stage: test
  script:
    - echo "Running tests for API v1"
    - docker-compose -f docker-compose.v1.yml up -d
    - pytest tests/v1/
    - docker-compose -f docker-compose.v1.yml down

    - echo "Running tests for API v2"
    - docker-compose -f docker-compose.v2.yml up -d
    - pytest tests/v2/
    - docker-compose -f docker-compose.v2.yml down
Enter fullscreen mode Exit fullscreen mode

Deployment and Operations: Keeping multiple API versions live complicates deployment strategies. Having different versions in different codebases (e.g., separate Git branches) or containing conditional logic within the same codebase increases the maintenance burden. In a client project, I ran two different API versions in separate Docker containers and routed traffic using Nginx. This meant allocating separate resources (CPU, RAM) for each version, which increased operational costs. I previously shared my experiences with [related: Nginx reverse proxy configurations].

To reduce these costs, it's important to avoid unnecessary versioning and include non-breaking changes (e.g., adding a new field) in the current version. Furthermore, setting long deprecation periods encourages clients to migrate to newer versions, helping to reduce the maintenance burden of older versions.

Conclusion: Versioning is a Strategy, Not a Feature

API versioning is more of a product and operational strategy than a technical feature. One of the most important lessons I've learned in my 20 years of field experience is not to postpone this topic by saying "we'll handle it later." Before launching an API, having a clear versioning strategy and ensuring your team is in agreement on it will prevent many future headaches.

The right versioning approach varies depending on the nature of your project, your target audience, and your operational capacity. The simplicity of URL Path, the flexibility of Header-based versioning, or the rare use cases of Query Parameter... I've experienced them all and seen that each has its own pros and cons. The important thing is to be aware of these trade-offs and make an informed decision.

Remember, as your API evolves, your clients will also have to evolve. Versioning is one of the most powerful tools we have to make this transition as smooth and transparent as possible. My clear position is: Start early, monitor, communicate, and never assume.

Top comments (0)