Whether working on a production ERP or developing the backend for my own side projects, one of the most common architectural decisions I have faced is choosing an API versioning strategy. I have experienced firsthand that this decision is far more than a simple technical detail; it directly impacts the future flexibility, maintenance cost, and developer experience of the project. There was always a fundamental dilemma: Should I choose a simple path to get up and running quickly, or build a flexible structure that is resistant to change in the long run?
In this post, I will share the API versioning approaches I have tried in different projects over the years, along with their pros and cons from my own perspective. By sharing concrete examples of when each strategy worked and where it caused me headaches, I aim to help you make a more informed decision. Because I know that on-paper solutions and real-world scenarios in the field rarely align perfectly.
Why Do We Need API Versioning?
APIs are the fundamental tools that allow applications to talk to each other. But over time, business requirements change, new features are added, and data models evolve. These changes can sometimes break the behavior or structure of existing APIs. This is exactly where versioning comes into play.
If I make a breaking change in an API—meaning I modify the output, input, or behavior of an existing endpoint in a way that is not backward-compatible—all client applications (mobile, web, other services) using that API will break instantly. When I experienced this in a production ERP, the reason a delayed shipment report was incorrect for 3 days was a minor change in a data model. In a client project, older versions of the mobile app crashed due to a schema change in the backend. Versioning is a critical mechanism to prevent such disasters and allow different clients to update at their own pace.
ℹ️ Breaking Change Example
Splitting the
addressfield in a customer object returned by an API into separate fields likestreet,city, andpostalCodeis a breaking change. Since older clients expect theaddressfield, they cannot process this new structure and will throw an error.
URI Path Versioning: The Most Common Approach
Versioning via the URI (Uniform Resource Identifier) path is probably the most common and simplest approach I see in the industry. Here, the API version is specified directly as part of the URL, for example, /api/v1/users or /api/v2/products.
The biggest advantage of this approach is its clarity and discoverability. A developer can immediately tell which version they are working with just by looking at the URL. It is also highly compatible with HTTP caching mechanisms because each version has its own unique URL. This has been my first choice in many projects, especially when I needed to launch an MVP quickly. I could easily handle routing with a simple location block in Nginx. However, as I started supporting multiple versions in the long run, I noticed that the router configurations and codebase became a bit cluttered. Managing separate code blocks or separate route files for each version could become necessary.
# Nginx URI Path Versioning example
server {
listen 80;
server_name api.example.com;
location /api/v1/ {
proxy_pass http://backend_v1_service;
# Other proxy settings
}
location /api/v2/ {
proxy_pass http://backend_v2_service;
# Other proxy settings
}
}
The Nginx example above routes requests coming to /api/v1 to backend_v1_service, and those coming to /api/v2 to backend_v2_service. This structure is particularly useful when different versions are served by different microservices. However, if you are managing multiple versions within the same microservice, you might start seeing control blocks like if version == 'v1' in your code, which increases technical debt. In one client project, by the time we reached version 5, v1, v2, and v3 were still active, and the readability of the code had dropped significantly.
Header Versioning: The Cost of Flexibility
Header versioning involves passing the API version through HTTP headers. There are two common ways to do this: using a custom header (X-API-Version: 1) or using the Accept header to specify a custom media type (Accept: application/vnd.myapi.v2+json).
The most appealing aspect of this approach is that the URIs remain clean. The URL of the resources does not change; you simply request the representation you want via headers. This feels more aligned with the "resource-oriented" principle of RESTful architecture. I tried this method on a bank's internal platform when different departments needed to access the same resources with different representations for their respective applications. It was fantastic in terms of flexibility; the same /users endpoint could return the user list in the old format with X-API-Version: 1 and in the new format with X-API-Version: 2. However, this flexibility came at a cost.
⚠️ Discoverability Issue
In header versioning, the client needs to know which API versions are supported. Testing this simply from a browser or with
curlis harder compared to URI versioning. Clients generally become more dependent on documentation.
Testing and debugging are more complex compared to URI versioning. Additionally, some proxy servers or CDNs may not process Accept or custom HTTP headers correctly, which can lead to caching or routing issues. When I tried this in the backend of one of my side projects, testing different versions even with a simple curl command did not feel as practical as URIs. It took time for the development team to get used to this approach and constantly remember the correct Accept header.
# Header Versioning (Custom Header) example
curl -H "X-API-Version: 2" https://api.example.com/products/123
# Header Versioning (Media Type) example
curl -H "Accept: application/vnd.myapi.v2+json" https://api.example.com/users
Query Parameter Versioning: Quick and Dirty?
Query parameter versioning involves passing the API version as a query parameter in the URL: /api/resource?version=1 or /api/resource?v=2.
This method is one of the fastest to implement. I have used it when I needed to quickly spin up a version, especially during prototype development or in internal APIs. In a manufacturing company's ERP, I used parameters like ?format=v1 and ?format=v2 to quickly test different outputs of a temporary reporting API. It is easy to see different versions by directly changing the URL in the browser, which is advantageous for quick testing scenarios.
However, this approach has serious drawbacks. First, it can pose problems for HTTP caching mechanisms. Different query parameters can create separate cache entries even if they are different versions of the same resource, which reduces cache efficiency. Second, it violates the semantic meaning of the URI. The version of a resource should relate to how that resource is represented rather than being an attribute of the resource itself. Using ?version=X creates the perception that the resource itself is versioned.
# FastAPI Query Parameter Versioning example
from fastapi import FastAPI, Query
app = FastAPI()
@app.get("/items/")
async def read_items(version: int = Query(1, description="API version")):
if version == 1:
return {"message": "Hello from V1"}
elif version == 2:
return {"message": "Greetings from V2!"}
return {"message": "Invalid version"}
🔥 Security Risk and Confusion
Query parameter versioning can carry security risks, especially when meaningless values like
v=999are tried or when version control is not handled well. Additionally, URLs can become very long and unreadable when there are multiple query parameters. I considered using this method in the backend of my Android spam app but decided against it because I needed a more robust structure to manage different versions of mobile clients.
Content Negotiation (Media Type Versioning): The RESTful Approach
Content Negotiation relies on the principle of requesting different data formats or versions using the HTTP Accept header. This is typically done by specifying a custom media type like application/vnd.company.app.v1+json. This approach is closest to the core RESTful architecture principle of "different representations of the same resource."
The biggest advantage of this method is that the URI remains completely static. The /users resource always remains /users, but a different representation (a different data schema or version) is returned based on the media type specified by the client in the Accept header. This provides great flexibility, especially when a resource evolves according to different needs throughout its lifecycle. In a client project, we evaluated this approach when the same order resource needed to have both a detailed accounting view and a simple mobile app view. From the same /orders/{id} endpoint, Accept: application/vnd.company.erp.v1+json returned ERP-specific details, while Accept: application/vnd.company.mobile.v1+json returned mobile-specific summary data.
# Flask Content Negotiation example
from flask import Flask, request, jsonify
app = Flask(__name__)
@app.route('/products/<int:product_id>', methods=['GET'])
def get_product(product_id):
accept_header = request.headers.get('Accept', '')
if 'application/vnd.myapi.v2+json' in accept_header:
return jsonify({"id": product_id, "name": f"Product v2 {product_id}", "price_usd": 12.99})
elif 'application/vnd.myapi.v1+json' in accept_header:
return jsonify({"id": product_id, "product_name": f"Product v1 {product_id}", "current_price": "12.99 USD"})
else:
# Default or fallback case
return jsonify({"id": product_id, "name": f"Product default {product_id}"})
if __name__ == '__main__':
app.run(debug=True)
💡 Simplifying with an API Gateway
Content negotiation can seem complex, but we can abstract this logic from the client using an API Gateway. The Gateway can parse the incoming
Acceptheader and route it to the appropriate backend service or version. This reduces complexity on the client side.
However, this approach also has its own challenges. Its implementation can be more complex than others, and clients need to know and use the correct media types. There can be differences among developer tools and libraries in how they handle the Accept header. In a production ERP, even though we needed such a flexible structure, I anticipated that the cost of managing this complexity for the development team would be high, so I opted for simpler URI versioning. There is always a trade-off between simplicity and flexibility.
Forward-Looking Strategies Without Versioning: NoVersioning
The idea of "not doing versioning" might sound crazy at first, but it can make perfect sense in certain scenarios. This approach involves trying to keep the API backward-compatible at all times or opening a completely new endpoint when a breaking change occurs.
The core philosophy here is to manage the evolution of the API by keeping breaking changes to a minimum. For example, adding a new field to an existing JSON object is generally backward-compatible because older clients can simply ignore this new field. However, removing an existing field, renaming it, or changing its data type is a breaking change. When following a NoVersioning strategy, you must strictly avoid or be extremely careful with these types of breaking changes. I applied a similar approach in the backend of my financial calculators. Since my user count was low and the API was only used by my own applications, I could update all clients simultaneously when a breaking change occurred.
The advantage of this approach is that there is always a single, most up-to-date version of the API. Clients always access the latest features, and the burden of supporting older versions is eliminated. However, the biggest disadvantage is managing breaking changes. If you absolutely must make a breaking change, you have to completely deprecate an old endpoint or open a brand-new endpoint with a new name. This creates a situation similar to URI versioning.
-- Backward-compatible database schema change example
-- Adding a new column to the existing 'users' table
ALTER TABLE users
ADD COLUMN created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP;
-- Renaming a column in the existing 'products' table would be a breaking change
-- ALTER TABLE products RENAME COLUMN price TO unit_price; -- This should be avoided!
ℹ️ NoVersioning and Backward-Compatible Changes
In a NoVersioning strategy, usually only backward-compatible changes are made:
- Adding new, optional fields.
- Adding new endpoints.
- Adding new, optional query parameters.
- Making an existing field optional (if clients can tolerate this). To avoid breaking changes, I often use techniques like
feature flagsanddark launches.
This strategy can be particularly suitable for internal APIs, small teams, or fast-changing products. However, it is generally risky for public APIs with a large client base where different versions need to run concurrently. In a manufacturing firm, I used this logic for communication between internal services because all services were under my control and deployed simultaneously.
Which Strategy to Choose When? Trade-off Analysis
Choosing an API versioning strategy depends on the nature of the project, the client base, development speed, and sustainability goals. There is no "one right way"; the important thing is to find the right balance for the problems you face. In my twenty years of field experience, I have seen that the unique dynamics of each project require a different decision.
In a production ERP, since there were many external integrations, I preferred URI path versioning. Because it was simple and clear, it was easy for other companies making integrations to adapt. In the backend of my mobile apps, I mostly used header versioning like X-API-Version because mobile client update cycles can be slower compared to web, and I had to support different versions for a long time. In my simple side projects, I sometimes started with query parameter versioning to quickly spin up a prototype and then transitioned to URI versioning once things stabilized.
The table below summarizes the key features of different strategies and their use cases in my experience:
| Strategy | Advantages | Disadvantages | When I Use It |
|---|---|---|---|
| URI Path Versioning | Simple, discoverable, cache-friendly. | URI clutter, router complexity can increase. | Most public APIs, multi-client systems, microservice-based projects. |
| Header Versioning | URI remains clean, flexible, RESTful. | Hard to discover, complex testing, proxy issues. | Complex internal APIs, when different representations of the same resource are needed. |
| Query Parameter Versioning | Fast implementation, easy testing in browser. | Cache issues, semantic issues, security risks. | Quick prototypes, internal APIs, very simple apps with few clients. |
| Content Negotiation | RESTful, clean URI, high flexibility. | Complex implementation, tooling support varies. | Multi-layered, large enterprise systems, when different clients have very different needs. |
| NoVersioning | Simplest, always up-to-date API, low maintenance load. | Hard to manage breaking changes, constant backward compatibility. | Internal APIs, small teams, fast-changing products, where clients are under my control. |
When looking at this table, it is important to remember that each strategy has its own "price tag." Simplicity usually compromises flexibility, while flexibility brings complexity and development costs. For example, when we chose Content Negotiation on a bank's internal platform, we initially provided a lot of flexibility, but the onboarding process and adaptation of new developers took longer. This increased the total cost of the project.
Conclusion: Decision-Making is a Matter of Continuous Optimization
Choosing an API versioning strategy is not a one-time decision. It can evolve throughout the lifecycle of the project, and as needs change, transitioning to different approaches may be necessary. The important thing is that the strategy you choose fits the current needs and future growth plans of your project. One of the biggest mistakes I have seen in my career was trying to rigidly maintain the initial decision, whereas flexibility and pragmatism usually yield better results.
My advice is to always start with the simplest solution and transition to more complex strategies only when truly necessary. This helps you avoid over-engineering and move forward with a pragmatic mindset. Remember, APIs are like living organisms; they grow, change, and evolve over time. The key is to manage this evolution in a controlled and sustainable manner.
Top comments (0)