Rails JSON Serialization: as_json, to_json, JBuilder, and Serializers Explained
A complete guide to Rails JSON serialization — when to use as_json vs to_json, the role of JBuilder and Active Model Serializers, performance considerations, and the ActiveRecord debug problem.
Rails has multiple ways to serialize ActiveRecord models to JSON, each with different trade-offs. Choosing the wrong one leads to over-fetching, N+1 queries, or leaking sensitive attributes. This guide covers all the approaches and when to use each.
The Three Layers
Rails JSON serialization happens at three levels:
1. **Model level** — what data an object exposes (as_json, to_json, serializable_hash)
2. **View/Template level** — how a response is shaped (JBuilder, ERB with render json:)
3. **Serializer level** — dedicated objects that define how a model maps to JSON (Active Model Serializers, Blueprinter, Panko)
Understanding which layer you're working in prevents most serialization bugs.
as_json vs to_json
These are the two most commonly confused methods.
**to_json** serializes an object to a JSON string:
user.to_json
# => '{"id":1,"name":"Alice","email":"[email protected]","created_at":"2024-01-01T10:00:00.000Z"}'
**as_json** returns a Ruby Hash (the intermediate representation before serialization):
user.as_json
# => {"id"=>1, "name"=>"Alice", "email"=>"[email protected]", "created_at"=>"2024-01-01T10:00:00.000Z"}
to_json calls as_json internally, then converts the hash to a JSON string.
Why does this matter? When you want to customize the output, override as_json:
def as_json(options = {})
super(options.merge(only: [:id, :name], methods: [:full_name]))
end
The only:, except:, include:, and methods: options on as_json control what's included.
The Attribute Leak Problem
The most common security mistake in Rails APIs: forgetting to restrict which attributes serialize.
By default, ActiveRecord's as_json includes every column — including password_digest, reset_password_token, admin, stripe_customer_id, and any other sensitive attribute.
Always use only: or except: in production:
user.as_json(only: [:id, :name, :email, :created_at])
Or override as_json in the model to enforce safe defaults.
Including Associations
The include: option fetches and serializes associations:
user.as_json(
only: [:id, :name],
include: {
posts: { only: [:id, :title, :published_at] }
}
)
Warning: This triggers an N+1 query if you call it on a collection without eager loading. Always use includes() in the query:
users = User.includes(:posts).all
users.map { |u| u.as_json(include: :posts) }
The render json: Shortcut
The simplest way to render JSON in a controller:
def show
render json: @user
end
This calls to_json on @user internally. But it gives you no control over attributes unless you've overridden as_json on the model.
A slightly better pattern:
def show
render json: @user.as_json(only: [:id, :name, :email])
end
JBuilder
JBuilder (included in Rails by default) uses a DSL in .jbuilder view templates to define the JSON shape:
# app/views/api/users/show.json.jbuilder
json.id @user.id
json.name @user.name
json.email @user.email
json.created_at @user.created_at.iso8601
json.posts @user.posts do |post|
json.id post.id
json.title post.title
end
JBuilder separates JSON structure from model logic, which is clean for complex nested responses. The downside: it's slow at scale (template rendering overhead per request) and hard to test without hitting the full render stack.
Active Model Serializers
AMS (Active Model Serializers) uses dedicated serializer classes:
class UserSerializer < ActiveModel::Serializer
attributes :id, :name, :email, :created_at
has_many :posts, serializer: PostSerializer
end
In the controller:
render json: UserSerializer.new(@user)
AMS is more maintainable than overriding as_json and more testable than JBuilder. But it has been effectively abandoned by its maintainers and has known performance issues with large collections.
Modern Alternatives: Blueprinter and Panko
**Blueprinter** is a lightweight, fast alternative to AMS:
class UserBlueprint < Blueprinter::Base
identifier :id
fields :name, :email, :created_at
association :posts, blueprint: PostBlueprint
end
UserBlueprint.render(@user)
**Panko** is built for performance — it uses native extensions to serialize ActiveRecord objects much faster than AMS or JBuilder, making it suitable for endpoints returning thousands of records.
The Debug Problem: Console Output ≠ JSON
When debugging Rails, you work in the Rails console and see:
> User.first
=> #<User id: 1, name: "Alice", email: "[email protected]", role: "admin", created_at: Mon, 01 Jan 2024 10:00:00.000000000 UTC +00:00>
This is ActiveRecord's inspect output — not JSON. It's useful for reading but can't be pasted into Postman, a curl command, or a JSON schema validator.
The two common solutions in the console:
- User.first.to_json — gives JSON string
- User.first.as_json — gives a Ruby hash with string keys
But when you've copied output from a log file, a Slack message, or a system that doesn't have a running Rails console — you need a converter. That's the use case for the ActiveRecord → JSON tool.
Choosing the Right Approach
For small projects and simple APIs: render json: with explicit as_json options. Fast to implement, easy to understand.
For medium projects with consistent API shapes: Blueprinter or a simple serializer pattern. Testable, maintainable, fast enough.
For high-throughput APIs serializing large collections: Panko or database-side JSON aggregation (json_agg in PostgreSQL).
For complex, highly varied response shapes: JBuilder, but only if you're not under performance pressure.
Whatever you choose, always restrict attributes explicitly. Never let Rails serialize an entire model without specifying what's safe to expose.