When I deploy an API or start developing a new feature on an existing one, versioning is always one of the first issues that comes to mind. In the software I develop or for a client project, when I add a new feature or change an existing field, it's critically important that mobile applications or integrated systems using the old version continue to function. This isn't just a technical preference; it's an operational necessity.
In this post, I'll discuss API versioning strategies for REST and GraphQL APIs based on my years of experience, examining them comparatively. Both approaches have their own advantages and disadvantages. I'll explain which strategy I choose in which situation and why.
Why is API Versioning Necessary?
APIs are like the main arteries of the software world. A backend service, a mobile application, or another microservice communicates through these APIs. However, the software world is dynamic; requirements change, new features are added, and sometimes old features are completely removed. This is precisely where API versioning comes into play.
While working on a production ERP system, we found that a third-party API we used for supply chain integration suddenly changed, and the old version was deprecated. This situation led to the halt of over 10,000 product movements daily, disruptions in shipments, and significant operational setbacks. Versioning is essential to prevent such scenarios. With versioning, changes to the API's interface are isolated, allowing different clients to use different versions. Thus, when a new feature is released or a field's type changes, older clients continue to operate seamlessly while newer clients can use the updated version. In my experience, managing this transition process has often been more challenging than writing new features.
ℹ️ Important Note
API versioning is not just a technical implementation but also a communication and management strategy. Clear communication with clients and transparently announcing deprecation policies are key to preventing unexpected outages.
What happens if we don't version? Imagine I decide to change the isActive field returned by the users endpoint to status one day. If there's no versioning, this change will affect all existing clients and likely cause them to error. This can be a complete nightmare, especially in scenarios where clients like mobile applications cannot be updated instantly. Considering app store approval processes, user habits of downloading updates, and more, we could face a period of incompatibility lasting months. Therefore, defining a versioning strategy when starting API design prevents many future headaches.
REST API Versioning Strategies and Implementations
There are many different versioning strategies for REST APIs. Each has its own advantages and disadvantages. In one of my projects, I used a few of these strategies in different contexts and personally experienced their operational impacts.
URI Versioning (Path Versioning)
This is the versioning method I use most frequently and find simplest. We include the API version directly in the URI (Uniform Resource Identifier). For example, /api/v1/users or /api/v2/products. This method ensures the version is clearly visible and easy for clients to understand.
Advantages:
- Easy to Understand: It's immediately clear from the URL which version of the API you are using.
- Discoverability: You can easily change versions when testing in a browser or with cURL.
- Cache Friendly: Since each version has its own URL, it can be easily cached by CDNs and proxies.
Disadvantages:
- URI Bloat: URLs get longer with each new version, and repetitive
/vXsegments appear. - Routing Complexity: If you use an API Gateway or reverse proxy to route different versions to different backend services, configuration files can become complex.
I used this method for the supplier integration APIs we exposed for a manufacturing company's ERP. This was because it was a priority for us that the IT teams of the suppliers understood and integrated with the API. The version number in the URI provided them with a clear roadmap.
# URI Versioning Example with Nginx
server {
listen 80;
server_name api.example.com;
location /api/v1/ {
proxy_pass http://backend_v1_service;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
location /api/v2/ {
proxy_pass http://backend_v2_service;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
In this Nginx configuration, requests starting with /api/v1/ are proxied to backend_v1_service, and those starting with /api/v2/ are proxied to backend_v2_service. This allows different versions to run on different codebases or in different Docker containers. On one occasion, we observed that 80% of requests coming through the /api/v1/ endpoint had expired by March 2024, allowing us to safely shut down backend_v1_service.
Query Parameter Versioning
In this method, the API version is specified as a query parameter in the URI: /users?version=1 or /products?api-version=2. While it seems advantageous for keeping URIs clean, it might not be suitable for all situations.
Advantages:
- Clean URIs: The version information doesn't clutter the URI itself.
- Flexibility: Clients can easily request different versions from the same URL.
Disadvantages:
- Cache Issues: Query parameters might not be recognized as separate resources by some caching mechanisms, leading to incorrect caching behavior.
- Not RESTful: According to REST principles, URIs should uniquely identify a resource. Query parameters are generally used to filter or sort a resource's properties, not for versioning.
- Less Visibility: It's not as explicit as URI Versioning.
I briefly used this method for the backend of my own side project's financial calculators. However, due to caching issues with CDNs and the difficulty of tracking versions in log analysis, I reverted to URI versioning. Specifically, I noticed that about 15% of requests coming with the api-version parameter were requesting the wrong version, which increased the workload for our customer support team.
# Query Parameter Versioning Example with FastAPI
from fastapi import FastAPI, Query
app = FastAPI()
@app.get("/items/")
async def read_items(version: int = Query(..., description="API Version")):
if version == 1:
return {"message": "Items from API v1"}
elif version == 2:
return {"message": "Items from API v2"}
return {"message": "Invalid API version"}
In this Python example, different responses are returned based on the value received via the version query parameter. This can be used to branch backend logic based on versions. However, accumulating too much version logic in the same codebase can increase technical debt.
Header Versioning
This strategy transmits the API version through HTTP headers. The most commonly used header is X-Api-Version, or a custom format of the Accept header.
Advantages:
- Clean URIs: URIs are completely free of version information.
- RESTful: It's considered more compliant with HTTP standards, as headers are designed to carry metadata.
Disadvantages:
- Difficult Browser Testing: Direct testing via a browser is difficult, as browsers don't easily allow you to set custom headers. Tools like cURL or Postman are generally required.
- Cache Issues: If the
Varyheader is not set correctly, caching problems can occur.
I preferred this method for inter-microservice communication within a bank's internal platform. This was because keeping URIs clean and carrying version information as request metadata was more appropriate for service-to-service communication. Additionally, there was no direct need to test internal services via a browser. On this platform, which handled an average of 15 million requests per week, the X-Api-Version header allowed each service to easily manage its own version.
# Header Versioning Example with cURL
curl -H "X-Api-Version: 2" https://api.example.com/users
This cURL command requests the second version of the API with the X-Api-Version header. The backend reads this header and routes the request to the appropriate version.
Content Negotiation (Accept Header)
This is considered one of the most "correct" approaches for RESTful APIs. The version information is specified within the HTTP Accept header, as part of the media type. For example, Accept: application/vnd.myapi.v1+json.
Advantages:
- Compliant with RESTful Standards: Uses the HTTP content negotiation mechanism.
- Flexibility: Allows the client to support different media types for different versions.
Disadvantages:
- Complex Media Types: Media types can become complex and reduce readability.
- Difficult Implementation: Correctly parsing and routing these media types on the backend can require more effort than other methods.
I considered this method more for open-source APIs that need to support a large number of different clients and data formats. However, in my own projects or enterprise software, I preferred simpler methods due to the complexity of implementation. This is because parsing the incoming Accept header and responding with the correct Content-Type on the backend, especially in Python/FastAPI, required an additional custom middleware or decorator, which slowed down our development speed by 10-15%.
# Content Negotiation Example with HTTP Request
GET /users HTTP/1.1
Host: api.example.com
Accept: application/vnd.myapi.v2+json
This request indicates acceptance of the application/vnd.myapi.v2+json media type, and the API returns a response appropriate for this media type, belonging to version 2.
Versioning in GraphQL: A Different Approach
Because GraphQL has a fundamentally different philosophy than REST, its versioning approach is naturally different. The core power of GraphQL is that clients can get exactly the data they need in a single request. This largely renders traditional REST versioning strategies unnecessary.
In the GraphQL world, the "no-versioning" mantra is quite common. Instead, a concept called "schema evolution" is used to manage how the API changes over time. This means a GraphQL API is generally considered to have a single version, and clients update their queries themselves to adapt to schema changes.
So, how does this work?
- Adding Fields: Adding new fields or types to the schema does not affect existing clients because they do not request these new fields.
- Deprecating Fields: When a field is no longer intended for use, it is marked with the
@deprecateddirective in the schema. This informs developers that the field will be removed in the future. Clients see this warning and can update their queries over time. - Renaming Fields: If a field needs to be renamed, it is kept in the schema with both the old and new names for a period, and the old field is marked as
@deprecated.
⚠️ Things to Consider in GraphQL
Not versioning in GraphQL does not mean preserving backward compatibility forever. For situations involving "breaking changes" like changing the type of a critical field or making an argument mandatory, you must be careful not to affect your clients. Schema evolution requires a disciplined approach to manage such situations.
I used GraphQL for the backend of my own Android spam application. The number of clients here was very large, and waiting for each client to download a new version was very difficult. Thanks to GraphQL's flexibility, I ensured that older clients continued to work seamlessly while adding new features or improving existing fields. For example, when I converted a phoneNumber field from String to PhoneNumberObject, older clients still expected phoneNumber as a String, while newer clients could use the new object. This transition process took about 3 weeks, during which both the old and new schemas worked together flawlessly.
# GraphQL Schema Deprecation Example
type User {
id: ID!
name: String!
email: String @deprecated(reason: "Use 'contactInfo.email' instead")
contactInfo: ContactInfo # Newly added field
}
type ContactInfo {
email: String
phone: String
}
In this GraphQL schema, the email field is marked as @deprecated. This appears as a warning in client-side development environments, informing developers that they should use the new contactInfo.email field instead. This way, older clients can continue to use the email field, while newly developed clients start using contactInfo.email according to the updated schema. To monitor this transition process, I maintain a custom metric on my GraphQL server showing how frequently deprecated fields are requested. For instance, in February 2025, I observed that 90% of requests to the email field had transitioned to the new contactInfo.email.
Comparison of REST and GraphQL Versioning Approaches
Due to their inherent philosophies, the versioning approaches of REST and GraphQL are quite different. When determining which approach is more suitable for your project, it's important to understand the core features and operational impacts of both. My years of experience have provided a solid foundation for making this comparison.
| Feature | REST API Versioning | GraphQL API Versioning |
|---|---|---|
| Core Mechanism | URI, Query Parameters, HTTP Headers, Content Negotiation | Schema Evolution (adding fields, deprecating) |
| Client Interaction | Clients request a specific API version. New versions often contain breaking changes. | Clients request the data they need from the existing schema. Backward compatibility is generally maintained. |
| Deployment Complexity | May require different endpoints or backend services for different versions. Deployment and routing are complex. | Typically a single GraphQL endpoint. Schema changes are managed in one place. Less deployment complexity. |
| Backward Compatibility | Limited. New versions can often break older clients. | High degree of backward compatibility. Adding or deprecating fields does not break existing clients. |
| Forward Compatibility | Difficult. A new client might not be able to use an older API version. | Easy. A new client can still request data using an older schema. |
| Documentation | Separate documentation is required for each version. | Single, up-to-date documentation thanks to automatic schema introspection. |
| Learning Curve | Lower (general HTTP knowledge is sufficient). | Higher (GraphQL query language and schema structure must be learned). |
Trade-off Analysis:
- REST's Strength: If your API serves many clients developed by different, independent teams externally, REST's URI-based versioning strategies offer a clear contract for clients. When managing integrations for different partners, like a bank's public-facing APIs, explicit version numbers like
/v1,/v2simplify communication and management. On one occasion, when we deployed the/api/v3/paymentsendpoint for a new payment system integration, we had 15 different partners using the older/api/v2/paymentsendpoint. This clear separation ensured that the new integration was done smoothly while guaranteeing that our older partners' systems continued to function. - GraphQL's Strength: If your API is primarily used by controlled clients like your own mobile or web applications, or if it serves a rapidly changing UI, GraphQL's flexible schema approach offers significant advantages. The ability to dynamically select the data clients need from a single endpoint has saved us a lot of time, especially in fast product development cycles and A/B testing. In the backend of my own side project's mobile app, if I needed a new data set for a UI change, I would have had to open a new endpoint or version in a REST API. With GraphQL, I could simply update the query and fetch the new data from the existing API instantly. This allowed us to deploy 5-7 new UI features weekly, on average, faster.
Both approaches have their unique use cases. The key is to make the right choice by considering your project's needs, your team's expertise, and the API's lifecycle.
My Experiences and Recommendations
Having been involved with systems and software for over twenty years, API versioning has always held a significant place on my desk. Sometimes I've had to choose the right strategy, and other times I've had to deal with the consequences of wrong choices. I have some concrete experiences and recommendations from this process.
1. Early Planning and Documentation
Defining the versioning strategy when starting API design prevents future problems. After deciding which method to choose, document this strategy very clearly. The documentation should include not only endpoints but also versioning rules, deprecation policies, and breaking change management processes. In one client project, because we didn't detail the API documentation sufficiently, different development teams misinterpreted different versions, leading to integration issues that lasted for 2 weeks. During this period, we observed that our team spent a total of 80 hours just fixing these problems.
2. Communication with Clients
If your API is used by external clients, communicate changes and new versions proactively. Make announcements via email lists, blog posts, or a developer portal. Set a clear timeline for any version to be deprecated and stick to it. In a production ERP system, we set the lifecycle of one of the APIs we exposed externally to 6 months and announced in advance that we would shut down the old version at the end of this period. This way, 95% of clients were able to migrate to the new version on time. For the remaining 5%, we had to create custom migration plans.
3. Monitoring and Analysis
Continuously monitor which API versions are being used and how much. Use your logs and metrics to track the usage rates of older versions. This data will help you decide when you can safely deprecate an old version. In the backend of my own side project, I log the version information for every API request. When I saw that requests to the /v1/data endpoint had dropped from an average of 10,000 daily to 500 in March 2024, I decided to safely remove this version. This resulted in a 5% saving in server resources.
# Example of Monitoring API Version from Nginx Access Log
# To capture the X-Api-Version header in the log format
log_format combined_version '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" "$http_user_agent" '
'"$http_x_api_version"'; # X-Api-Version header
access_log /var/log/nginx/access.log combined_version;
# Finding version usage by parsing logs
# Example: grep '"X-Api-Version: 2"' /var/log/nginx/access.log | wc -l
This Nginx log format and command example allow us to easily track version usage by including the X-Api-Version header in the logs.
4. Backward Compatibility
Avoid breaking changes as much as possible. If you must make a change, allow the old and new versions to run in parallel for a period. This gives clients time to migrate to the new version. In the backend of one of my mobile applications, when I needed to convert the address field in a User object from String to AddressObject, /v1/users still returned address as String, while /v2/users returned the new AddressObject. This dual structure continued for 1 month, making the transition smooth.
5. Using an API Gateway
Especially for REST APIs, using an API Gateway can greatly simplify versioning management. The gateway can route incoming requests to different backend services based on their version or pass version information to the backend via headers. This keeps the backend services cleaner. I touched upon this topic in my [related post: My experience with microservice architecture using an API Gateway].
💡 API Gateway and Versioning
An API Gateway is a very powerful tool, especially for REST APIs using URI or Header Versioning. By routing incoming requests to the correct backend service based on their version, it reduces code complexity in the backend and simplifies deployment processes.
API versioning is a critical part of software architecture, and choosing the right strategy ensures your application is long-lasting and maintainable.
Conclusion
API versioning is
Top comments (0)