Let’s talk about something every Rails developer building an API will eventually face: change. Your API starts simple. It returns a user’s name and email. Then a client asks for the sign-up date. Another needs the user’s timezone. Later, you realize the email field should be nested under a contact object for clarity. But what about the apps already using the old, flat structure? They’ll break. This is the core problem of API versioning. How do you improve your API without breaking the applications that depend on it?
I’ve spent years building and maintaining Rails APIs, and I can tell you there’s no single perfect answer. There are strategies, each with trade-offs. It’s about picking the right tool for your specific situation—considering your team, your clients, and how fast you need to move. Let’s walk through several practical ways to handle this, from the most straightforward to the more nuanced.
The first method is often the easiest to understand: putting the version right in the URL path. You’ve probably seen APIs like https://api.example.com/v1/users. This is path-based versioning. It’s wonderfully explicit. Both humans and machines can look at a URL and know exactly which version of the API they’re talking to.
In a Rails application, setting this up is clean. You define separate routing namespaces for each version. This creates a clear, physical separation in your codebase. The controllers for V1 and V2 are completely distinct. This separation is the biggest advantage. You can rewrite V2 from the ground up without touching a line of V1 code. It’s safe and isolated.
Let me show you how this looks in your routes.rb file. You use the scope method to prefix routes and direct them to different module directories.
# config/routes.rb
Rails.application.routes.draw do
# All routes under /v1 are handled by controllers in the V1 module
scope module: :v1, path: 'v1' do
resources :products, only: [:index, :show]
resources :orders, only: [:create, :show, :update]
end
# All routes under /v2 are handled by controllers in the V2 module
scope module: :v2, path: :v2 do
# Maybe V2 adds a new nested action for product recommendations
resources :products, only: [:index, :show] do
get 'recommendations', on: :member
end
# And allows order deletion, which V1 did not
resources :orders, only: [:create, :show, :update, :destroy] do
post 'cancel', on: :member
end
end
end
Your controller organization follows this. You’ll have an app/controllers/v1/ directory and an app/controllers/v2/ directory. The controllers inside are independent.
# app/controllers/v1/products_controller.rb
class V1::ProductsController < ApplicationController
def index
# Simple, initial implementation
products = Product.active
render json: products.map { |p| { id: p.id, name: p.name, price: p.price } }
end
end
# app/controllers/v2/products_controller.rb
class V2::ProductsController < ApplicationController
def index
# V2 uses a more complex serializer and eager loads data
products = Product.active.includes(:reviews, :inventory)
render json: ProductSerializerV2.new(products).as_json
end
# This new action only exists in V2
def recommendations
product = Product.find(params[:id])
recommendations = ProductRecommender.new(product).suggestions
render json: recommendations
end
end
The clarity here is a major benefit. Debugging is easier. You know that any request to /v1/products is locked to the old logic. The downside is that your URLs change from version to version. Some argue this isn’t very “RESTful,” as the resource’s identity (the user) is now tied to a version. But in practice, its simplicity makes it a popular and robust choice, especially for public APIs where clarity is paramount.
What if you want to keep the URLs stable forever? You don’t want /v1/users to become /v2/users. You want the path to just be /users, and let the client specify which version they need elsewhere. This is where request headers come in.
The idea is simple. The client sends a custom header, like X-API-Version: 2024-01-01, with every request. Your application reads this header and decides how to process the request. The URL stays clean and constant.
I usually implement this with a piece of Rack middleware. Middleware sits between the web server and your Rails application, letting you inspect and modify the request early in the cycle.
# lib/api_version_middleware.rb
class ApiVersionMiddleware
# Define our custom header key and a default version
VERSION_HEADER = 'X-API-Version'
DEFAULT_VERSION = '2024-01-01'
def initialize(app)
@app = app
end
def call(env)
# Wrap the env in a request object for easier access
request = ActionDispatch::Request.new(env)
# Extract the version from the header, or use the default
api_version = request.headers[VERSION_HEADER] || DEFAULT_VERSION
# Store this information in the `env` hash so controllers can find it
env['api.version'] = api_version
# Parse it to a date for easy comparison later
env['api.version_date'] = Date.parse(api_version) rescue Date.today
# Pass the request on to the next piece of the chain (usually Rails)
@app.call(env)
end
end
You need to register this middleware in config/application.rb:
config.middleware.use ApiVersionMiddleware
Now, in a controller, you can access this version and change behavior accordingly. A common pattern is to select a different serializer.
class ProductsController < ApplicationController
# A before_action to set the version for this request
before_action :set_api_version
def index
products = Product.active
# Use a method to pick the right serializer based on the version
render json: serializer_class.new(products).as_json
end
private
def set_api_version
@api_version = request.env['api.version']
@version_date = request.env['api.version_date']
end
def serializer_class
# Logic to choose a serializer. Date comparisons are clear.
if @version_date >= Date.new(2024, 3, 1)
ProductSerializerV3
elsif @version_date >= Date.new(2024, 1, 1)
ProductSerializerV2
else
# A very old client, or one with no header
ProductSerializerV1
end
end
end
This method keeps URLs pristine. It’s elegant. The challenge is that it’s less visible. You can’t just look at a URL in a browser address bar or a log file and know the version; you need to inspect the headers. Tools like curl need an extra -H flag. It’s a trade-off between cleanliness and transparency.
This next strategy takes the header idea and formalizes it using a standard HTTP mechanism: content negotiation. Clients use the Accept header to say what format of data they want. We can extend this to specify a version.
Instead of Accept: application/json, a client sends Accept: application/vnd.mycompany.v2+json. This is a custom media type. The vnd. means “vendor-specific,” mycompany is your identifier, v2 is the version, and +json says it’s JSON flavored.
In your Rails controller, you can parse this header and respond accordingly.
class ApiVersionParser
# A regex to pluck the version number from the Accept header
MEDIA_TYPE_REGEX = /application\/vnd\.company\.v(\d+)\+json/
def self.extract_version(request)
accept_header = request.headers['Accept'] || ''
match = accept_header.match(MEDIA_TYPE_REGEX)
return match[1].to_i if match
# A very important fallback for clients that don't use the custom header
request.params[:api_version]&.to_i || 1
end
end
class ProductsController < ApplicationController
def index
products = Product.active
api_version = ApiVersionParser.extract_version(request)
# The respond_to block lets you handle different formats elegantly
respond_to do |format|
# Default JSON request (no custom Accept header)
format.json do
render json: versioned_response(products, api_version)
end
# Explicit request for V1 format
format.any('application/vnd.company.v1+json') do
render json: ProductSerializerV1.new(products).as_json
end
# Explicit request for V2 format
format.any('application/vnd.company.v2+json') do
render json: ProductSerializerV2.new(products).as_json
end
end
end
private
def versioned_response(products, version)
# Centralized logic to choose a serializer
case version
when 1
ProductSerializerV1.new(products).as_json
when 2
ProductSerializerV2.new(products).as_json
when 3
ProductSerializerV3.new(products).as_json
else
# Handle unsupported versions gracefully
raise UnsupportedVersionError, "API version #{version} is not supported."
end
end
end
This is perhaps the most academically “correct” RESTful approach. It uses a standard HTTP feature for its intended purpose. However, it can be more complex for clients to implement initially, and server-side logic can become more intricate as you manage the respond_to blocks.
Sometimes you need something simple for testing, quick scripts, or situations where setting headers is cumbersome. That’s where a query parameter shines. The request looks like GET /products?api_version=2. It’s incredibly easy for anyone to use.
You can combine this with Rails routing constraints to make certain routes only available for specific versions.
# config/routes.rb
Rails.application.routes.draw do
# The 'recommendations' route only matches if the ApiVersionConstraint says so
resources :products, only: [:index, :show] do
get 'recommendations', on: :member, constraints: ApiVersionConstraint.new(version: 2)
end
end
# lib/api_version_constraint.rb
class ApiVersionConstraint
def initialize(version:, default: false)
@version = version
@default = default
end
# This method is called by Rails routing to see if the route matches
def matches?(request)
# If this is the default route, it always matches
return true if @default
# Otherwise, check if the request's version matches this constraint's version
extract_version(request) == @version
end
private
def extract_version(request)
# Look for version in query params first, with a couple of common key names
version = request.params[:api_version] || request.params[:v]
# Fall back to a header for consistency
version ||= request.headers['X-API-Version']
# Convert to integer for comparison
version.to_i
end
end
In the controller, you need to validate that the requested action is available for the client’s version.
class ProductsController < ApplicationController
# A map defining which actions exist in which API versions
VERSION_ACTIONS = {
1 => [:index, :show],
2 => [:index, :show, :recommendations],
3 => [:index, :show, :recommendations, :similar]
}
# Check this before every action
before_action :validate_version_for_action
def index
case api_version
when 1
render_v1_index
when 2
render_v2_index
when 3
render_v3_index
end
end
def recommendations
# This action will only be called if it passed the validation check
product = Product.find(params[:id])
recommender = versioned_recommender(product)
render json: recommender.suggestions
end
private
def validate_version_for_action
action = action_name.to_sym
allowed_actions = VERSION_ACTIONS[api_version] || []
unless allowed_actions.include?(action)
# Send a clear error message if the action isn't in their version
render json: { error: "The '#{action}' action is not available in API version #{api_version}." },
status: :bad_request
end
end
def api_version
@api_version ||= extract_api_version
end
def extract_api_version
version = params[:api_version] || params[:v] || request.headers['X-API-Version']
(version || 1).to_i # Default to version 1
end
end
The query parameter method is developer-friendly and flexible. The downside is that it clutters the URL for what is arguably metadata about the request, not the resource itself. It also can be cached differently by intermediaries if the parameter changes. But for internal APIs or early-stage projects, its simplicity is a huge win.
In the real world, you might need to support multiple methods. A legacy mobile app might use a query parameter, a new web app might use headers, and a partner integration might use the media type. You need a robust detector that checks sources in a specific order.
This class looks in several places, falling back from the most specific to the least.
class ApiVersionDetector
# Define an order of precedence for version sources
VERSION_SOURCES = [
{ source: :header, header: 'X-API-Version' }, # First, check our custom header
{ source: :accept, header: 'Accept', pattern: /vnd\.company\.v(\d+)/ }, # Then, the Accept header
{ source: :param, param: :api_version }, # Then, a query param
{ source: :param, param: :v }, # Then, another possible query param
{ source: :default, value: 1 } # Finally, a hard default
]
def self.detect(request)
VERSION_SOURCES.each do |config|
version = extract_from_source(request, config)
# Return the first non-nil, non-empty value we find
return version.to_i if version.present?
end
1 # This line should never be reached due to the :default source, but it's a safety net.
end
def self.extract_from_source(request, config)
case config[:source]
when :header
request.headers[config[:header]]
when :accept
accept_header = request.headers['Accept'] || ''
match = accept_header.match(config[:pattern])
match[1] if match
when :param
request.params[config[:param]]
when :default
config[:value]
end
end
end
You can then build a responder object that uses this detector to format the response correctly, whether it’s a plain JSON request or a specific media type request.
class VersionedResponder
def initialize(controller, resource, options = {})
@controller = controller
@resource = resource
@options = options
# Use the detector to get the version for this request
@api_version = ApiVersionDetector.detect(controller.request)
end
def respond
# If it's a basic JSON request, render with the versioned serializer
if @controller.request.format.json?
render_json_response
else
# If they sent a specific Accept header, use Rails' format negotiation
render_versioned_response
end
end
private
def render_json_response
serializer = versioned_serializer
@controller.render json: serializer.new(@resource).as_json
end
def render_versioned_response
@controller.respond_to do |format|
format.any('application/vnd.company.v1+json') do
@controller.render json: V1::ProductSerializer.new(@resource).as_json
end
format.any('application/vnd.company.v2+json') do
@controller.render json: V2::ProductSerializer.new(@resource).as_json
end
# A catch-all for any other non-JSON format, or unexpected media types
format.any { render_json_response }
end
end
def versioned_serializer
case @api_version
when 1 then V1::ProductSerializer
when 2 then V2::ProductSerializer
when 3 then V3::ProductSerializer
else V1::ProductSerializer # Fallback
end
end
end
# In your controller, it becomes very clean:
def index
products = Product.active
VersionedResponder.new(self, products).respond
end
This combined approach is powerful because it meets clients where they are. It requires more upfront code but results in a very flexible and tolerant API.
Versioning isn’t just about supporting the new and the old. It’s about responsibly retiring the old. You need to communicate changes to your clients clearly and well in advance. This is where deprecation warnings come in.
HTTP has official headers for this: Deprecation and Sunset. You can add these to responses for old endpoints.
class ApiDeprecationManager
def initialize(current_version, sunset_period: 90.days)
@current_version = current_version
@sunset_period = sunset_period
@deprecations = {} # Store deprecation notices here
end
# Call this method to declare an endpoint deprecated
def deprecate(endpoint, version_removed: nil, replacement: nil, migration_guide: nil)
@deprecations[endpoint] = {
deprecated_at: Time.current,
removed_at: version_removed || calculate_removal_date,
replacement: replacement,
migration_guide: migration_guide
}
end
# Generate the headers for a given request
def deprecation_headers(request)
version = ApiVersionDetector.detect(request)
endpoint = "#{request.method} #{request.path}"
deprecation = @deprecations[endpoint]
# Only send headers if this endpoint is deprecated AND the request is using an old version
return {} unless deprecation && version < @current_version
{
'Deprecation' => deprecation[:deprecated_at].httpdate,
'Sunset' => deprecation[:removed_at].httpdate, # The date it will be turned off
'Link' => deprecation_links(deprecation) # Links to helpful docs
}
end
private
def calculate_removal_date
Time.current + @sunset_period
end
def deprecation_links(deprecation)
links = []
# Link to the new endpoint
links << "<#{deprecation[:replacement]}>; rel=\"successor-version\"" if deprecation[:replacement]
# Link to a guide on how to change
links << "<#{deprecation[:migration_guide]}>; rel=\"deprecation\"" if deprecation[:migration_guide]
links.join(', ')
end
end
You configure this in an initializer and then add the headers in a controller after_action.
# config/initializers/api_deprecation.rb
Rails.application.config.api_deprecation_manager = ApiDeprecationManager.new(3)
Rails.application.config.api_deprecation_manager.deprecate(
'GET /api/v1/products',
version_removed: Time.current + 180.days, # 6 months from now
replacement: '/api/v2/products',
migration_guide: 'https://api.example.com/docs/migration/v1-to-v2'
)
# app/controllers/application_controller.rb
class ApiController < ApplicationController
after_action :add_deprecation_headers
private
def add_deprecation_headers
manager = Rails.application.config.api_deprecation_manager
headers = manager.deprecation_headers(request)
headers.each do |name, value|
response.headers[name] = value
end
end
end
This approach is professional and respectful of your API consumers. It gives them the information they need in a machine-readable way (their monitoring can alert on Sunset headers) and a human-readable way (the Link to documentation).
Finally, let’s consider a more granular approach. Instead of switching everything at once with a version number, what if you could toggle individual features on or off for certain clients? This is feature flagging for APIs.
You can roll out a new response format to 10% of your clients, see how it performs, and then increase the percentage. You can enable a beta feature for specific partner IDs.
class ApiFeatureManager
def initialize
@features = Concurrent::Map.new # Thread-safe storage
@client_overrides = Concurrent::Map.new
end
def enable_feature(feature, version:, percentage: 0, clients: [])
@features[feature] = {
version: version,
percentage: percentage,
clients: Set.new(clients),
enabled_at: Time.current
}
end
def feature_enabled?(feature, request)
config = @features[feature]
return false unless config
client_id = extract_client_id(request)
# Explicit override for this client/feature combo
return true if @client_overrides[[feature, client_id]]
# Gradual percentage-based rollout
if config[:percentage] > 0
return rollout_percentage(client_id) <= config[:percentage]
end
# Standard version check
request_version = ApiVersionDetector.detect(request)
request_version >= config[:version]
end
def extract_client_id(request)
# This could be an API key, a token, or derived from the User-Agent
request.headers['X-Client-Id'] ||
request.params[:client_id] ||
Digest::SHA256.hexdigest(request.user_agent.to_s)[0..15] # A fallback fingerprint
end
# Creates a deterministic "rollout number" between 1-100 for a client ID
def rollout_percentage(client_id)
hash = Digest::SHA256.hexdigest(client_id).to_i(16)
(hash % 100) + 1
end
end
In your controller, you check for features.
class ProductsController < ApplicationController
def index
products = Product.active
if api_features.enabled?(:extended_product_data, request)
render json: ExtendedProductSerializer.new(products).as_json
elsif api_features.enabled?(:product_relationships, request)
render json: ProductWithRelationshipsSerializer.new(products).as_json
else
render json: BasicProductSerializer.new(products).as_json
end
end
private
def api_features
Rails.application.config.api_feature_manager
end
end
You configure features in an initializer. This is incredibly powerful for managing change.
# Enable new serializer for 20% of clients, and for all v3 requests
Rails.configuration.api_feature_manager.enable_feature(
:extended_product_data,
version: 3,
percentage: 20
)
# Enable a beta feature only for specific, trusted client IDs
Rails.configuration.api_feature_manager.enable_feature(
:experimental_search,
version: 3,
clients: ['trusted_mobile_app_v1.2', 'partner_dashboard_alpha']
)
This method decouples deployment from release. You can deploy code for a new feature but keep it hidden until you flip the flag. It reduces risk and allows for sophisticated rollout strategies.
So, which one should you choose? Start by asking questions. Who are your clients? Can they easily update their code to use headers? Is URL clarity more important for your use case? Are you building an internal tool where query parameters are easiest?
For public APIs, path-based versioning (/v1/) is often the safest due to its clarity. For internal services where you control the clients, header-based or feature-flag-based approaches offer more flexibility. For large, complex systems, a combination strategy with a smart ApiVersionDetector and a strong deprecation process is usually the end goal.
Remember, the strategy isn’t set in stone. You can start simple, perhaps with path versioning, and evolve your approach as your API and its consumer base grow. The most important thing is to think about versioning from day one. Having a plan, even a simple one, is infinitely better than trying to retrofit versioning onto a live, changing API with no strategy at all. It’s the difference between guiding your clients through a planned renovation and asking them to navigate a construction site with no warnings. Choose your tools, communicate changes clearly, and build an API that can evolve gracefully over time.