Mastering API Versioning in Ruby on Rails: 7 Essential Patterns for Seamless Evolution
Learn 7 practical Ruby on Rails API versioning strategies: URL paths, headers, content negotiation & more. Master API evolution without breaking existing clients.
When I first started building APIs with Ruby on Rails, I thought I could just build one version and be done. I quickly learned that APIs, like the applications they serve, must grow and change. The moment you release your API to the world, you make a promise. Other software, sometimes written by people you’ll never meet, begins to depend on that promise. Changing the terms of that promise carelessly breaks things. So, you need a plan for change. You need versioning.
Versioning is simply a way to introduce new features, fix design flaws, or improve performance without breaking the applications that already rely on your work. Over the years, I’ve seen and used several methods. Each has its own strengths and fits different situations. Let’s talk about seven practical patterns you can use.
Placing the version number directly in the URL path is the most straightforward method. It’s visible, clear, and easy to debug. When a client calls /api/v1/products, there is no ambiguity about what they are asking for. The routing in Rails handles this naturally with namespaces.
# config/routes.rb
Rails.application.routes.draw do
namespace :api do
namespace :v1 do
resources :products, only: [:index]
end
namespace :v2 do
resources :products, only: [:index] do
get 'inventory', on: :member
end
end
end
end
Your controller structure mirrors this separation. The Api::V1::ProductsController and Api::V2::ProductsController are completely independent. This is a major benefit. You can rewrite the logic for version two from scratch without touching version one. The downside is the proliferation of files. You will have duplicate controllers, serializers, and test files. For a large API, this can become heavy to manage.
I like this method for its honesty. The URL tells the whole story. It’s also very easy for a developer to test different versions just by changing the address in their browser or curl command. It’s my default choice for public APIs where clarity is paramount.
Sometimes, you want to keep your URLs clean and stable. The idea here is that the path to a resource, like /api/products, should be permanent. The version is instead communicated through an HTTP header, like X-API-Version: 2. This keeps the address simple.
Implementing this typically involves a piece of middleware. This middleware inspects the incoming request, finds the version in the header, and then internally reroutes the request to the correct set of controllers.
# app/middleware/api_version_middleware.rb
class ApiVersionMiddleware
def initialize(app)
@app = app
end
def call(env)
request = ActionDispatch::Request.new(env)
version = request.headers['X-API-Version'] || '1'
# Store it for the controller to use
env['api_version'] = version
# Internally route to a namespaced path
if env['PATH_INFO'].start_with?('/api/')
env['PATH_INFO'] = "/api/v#{version}#{env['PATH_INFO'][4..]}"
end
@app.call(env)
end
end
# config/application.rb
config.middleware.use ApiVersionMiddleware
Your controllers can then live in the api/v1 and api/v2 namespaces as before, but the client never sees that in the URL. The challenge is that it’s less transparent. You can’t just look at a URL and know its version. Debugging might require checking logs to see what version header was sent. However, for APIs consumed by mobile apps where you control the client, this can be a very elegant solution.
This pattern takes the header idea a step further by using the standard HTTP Accept header. Clients specify exactly what format and version they want using a custom media type, like application/vnd.myapp.v2+json. This is a very pure, RESTful approach.
You start by registering these custom MIME types in Rails.
# config/initializers/mime_types.rb
Mime::Type.register "application/vnd.company.v1+json", :v1
Mime::Type.register "application/vnd.company.v2+json", :v2
Then, in your controller, you use respond_to blocks to handle each version. All versions are handled by a single controller action, which can get complex.
class ProductsController < ApplicationController
def index
@products = Product.all
respond_to do |format|
format.v1 do
render json: @products, each_serializer: ProductV1Serializer
end
format.v2 do
render json: @products, each_serializer: ProductV2Serializer
end
format.json do
# Default to latest version
render json: @products, each_serializer: ProductV2Serializer
end
end
end
end
The beauty is strict adherence to HTTP standards. The drawback is complexity. Your single controller action now contains the logic for every version of that endpoint. The respond_to block can become a tangled mess if you have many versions or complex differences. I use this when building hypermedia APIs where content negotiation is a core principle.
This is perhaps the simplest method to implement. The client just adds a parameter like ?version=2 or ?v=2 to the query string. It’s incredibly easy for testing and ad-hoc exploration.
You can manage this with route constraints in Rails. The constraint checks the parameter and directs the request accordingly.
# lib/api_version_constraint.rb
class ApiVersionConstraint
def initialize(version:, default: false)
@version = version
@default = default
end
def matches?(request)
requested_version = request.params[:version] || '1'
@default || requested_version == @version
end
end
# config/routes.rb
Rails.application.routes.draw do
scope defaults: { format: :json } do
scope module: :v1, constraints: ApiVersionConstraint.new(version: '1', default: true) do
resources :products, only: [:index]
end
scope module: :v2, constraints: ApiVersionConstraint.new(version: '2') do
resources :products, only: [:index]
end
end
end
While easy, I’m cautious with this pattern. Query parameters can get cached by proxies, logged in plain text more often, and don’t feel as “clean” as path or header methods for a permanent API contract. It’s excellent for internal APIs, prototypes, or for giving clients an easy override switch.
In the real world, clients are messy. One might use headers, another might use query parameters. A robust API can try to detect the version from multiple places in a specific order. This is the “kitchen sink” approach designed for maximum compatibility.
You create a resolver service that checks all the possible sources.
class ApiVersionResolver
SOURCES = [
{ type: :header, key: 'HTTP_X_API_VERSION' },
{ type: :accept, key: 'HTTP_ACCEPT' },
{ type: :param, key: 'version' },
{ type: :default, value: '1' }
]
def self.resolve(request)
SOURCES.each do |source|
value = extract(source, request)
return value.to_s if value.present?
end
end
def self.extract(source, request)
case source[:type]
when :header
request.env[source[:key]]
when :accept
accept = request.env[source[:key]]
accept&.match(/vnd\.myapp\.v(\d+)/)&.captures&.first
when :param
request.params[source[:key]]
when :default
source[:value]
end
end
end
Then, a middleware or a before_action in your base controller calls this resolver and sets the version for the request. The main advantage is that you accommodate almost any client behavior. The disadvantage is increased complexity in your request-handling layer. You have to document the resolution order clearly.
Your API will evolve. Endpoints become outdated. When you introduce a new version, you should clearly signal when the old one will stop working. This is about communication and respect for your clients’ development time.
HTTP has standards for this. You can use the Deprecation and Sunset headers.
# app/controllers/api/base_controller.rb
class Api::BaseController < ApplicationController
after_action :add_deprecation_warnings
private
def add_deprecation_warnings
# Example: Deprecate all v1 endpoints
if request.path.include?('/v1/')
response.headers['Deprecation'] = 'true'
response.headers['Sunset'] = (Time.now + 6.months).httpdate
response.headers['Link'] = '<https://docs.example.com/migrate-to-v2>; rel="deprecation"'
end
end
end
The Deprecation: true header is a warning. The Sunset header gives a precise date and time after which the endpoint will be removed. The Link header can point to migration documentation. This pattern isn’t a versioning strategy itself, but it’s an essential companion to any strategy. It turns a breaking change from a surprise into a planned transition.
Instead of versioning entire endpoints, you can version features. You define which capabilities are available in which API versions. This allows for more granular evolution.
Imagine a Feature module that checks the request’s API version.
module ApiFeatures
FEATURES = {
expanded_product_object: { introduced_in: '2' },
bulk_operations: { introduced_in: '2' },
real_time_notifications: { introduced_in: '3' }
}
def self.enabled?(feature_name, api_version)
feature = FEATURES[feature_name]
return false unless feature
feature[:introduced_in] <= api_version
end
end
# In your controller
class ProductsController < Api::BaseController
def show
product = Product.find(params[:id])
render_data = { id: product.id, name: product.name }
# Add extra data only if the client's version supports it
if ApiFeatures.enabled?(:expanded_product_object, @api_version)
render_data[:inventory] = product.stock_count
render_data[:supplier] = product.supplier.name
end
render json: render_data
end
end
This approach can reduce duplication. You have one controller, but its output changes based on the client’s version. The risk is that the controller logic can become a series of conditional checks, making it harder to maintain. It works well for small, incremental additions rather than large, structural changes.
Choosing a pattern depends on who your clients are and how much control you have. For a public API with many external consumers, I usually recommend URL Path Versioning for its clarity. For a mobile backend where I control the client app, I might lean towards Request Header Versioning for cleaner URLs. Content Negotiation is ideal for strict REST adherents, and Query Parameter Versioning is perfect for internal tools.
No matter which you choose, remember to plan for deprecation from the start. Use feature flags or version checks to make incremental changes possible. And most importantly, document your versioning strategy clearly for the people who build on your work. Your API is a contract; good versioning is how you amend that contract without going back on your word.