Rails provides a built-in as_json method for generating JSON. Additionally, jbuilder is the default gem for handling JSON APIs, and there are other alternative gems available. This article compares the following solutions:

  1. as_json
  2. jbuilder
  3. active_model_serializers
  4. oj_serializers

Based on personal experience, the evaluation considers the following criteria:

  1. Ease of maintenance.
  2. Reusability of defined structures.
  3. Ability to generate JSON objects and use them within the code.
  4. Performance.
  5. Ease of testing.

Scenario

Assume we have two models: Post and User. The goal is to generate the following JSON outputs:

Post index

1
2
3
4
5
6
7
8
9
10
11
12
13
{
"posts": [
{
"id": 1,
"title": "Post Title",
"timeago": "19 minutes",
"user": {
"id": 1,
"name": "User Name"
}
}
]
}

Post show

1
2
3
4
5
6
7
8
9
10
11
12
13
14
{
"posts": [
{
"id": 1,
"title": "Post Title",
"timeago": "19 minutes",
"content": "Post Content...",
"user": {
"id": 1,
"name": "User Name"
}
}
]
}

User index

1
2
3
4
5
6
7
8
{
"users": [
{
"id": 1,
"name": "User Name"
}
]
}

This scenario simulates displaying partial data on an index page and showing complete data with nested structures on a detailed page.

Implementation

The following sections demonstrate how to implement the example using four different approaches.

as_json

post.rb

1
2
3
4
5
6
7
8
9
10
class Post < ApplicationRecord
# for time_ago_in_words
include ActionView::Helpers::DateHelper

belongs_to :user

def timeago
time_ago_in_words(created_at)
end
end

posts_controller.rb

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
class PostsController < ApplicationController
def index
render json: {
posts: Post.all.as_json(
only: %i[id title],
methods: %i[timeago],
include: {
user: {
only: %i[id name]
}
}
)
}
end

def show
render json: {
posts: Post.find(params[:id]).as_json(
only: %i[id title content],
methods: %i[timeago],
include: {
user: {
only: %i[id name]
}
}
)
}
end
end

users_controller.rb

1
2
3
4
5
6
7
8
9
class UsersController < ApplicationController
def index
render json: {
users: User.all.as_json(
only: %i[id title]
)
}
end
end

Using the built-in as_json function allows simple conversion of model data into JSON. However, it’s not easy to handle fields that need processing. In the example above, you can see that timeago needs to be implemented in the model. Additionally, reusing structures requires custom design; otherwise, it will result in a lot of redundant code, as seen in the example.

jbuilder

users/_user.json.jbuilder

1
json.extract! user, *%i[id name]

users/index.json.jbuilder

1
2
3
json.users do
json.array! users, partial: 'user', as: :user
end

posts/_post.json.jbuilder

1
2
3
4
5
json.extract! post, *%i[id title]
json.extract! post, *%i[content] if local_assigns[:detail]
json.timeago time_ago_in_words(post.created_at)

json.user post.user, partial: 'users/user', as: :user

posts/index.json.jbuilder

1
2
3
json.posts do
json.array! posts, partial: 'post', as: :post
end

posts/show.json.jbuilder

1
2
3
json.post do
json.partial! post, as: :post, detail: true
end

posts_controller.rb

1
2
3
4
5
6
7
8
9
class PostsController < ApplicationController
def index
render locals: { posts: Post.all }
end

def show
render locals: { post: Post.find(params[:id]) }
end
end

users_controller.rb

1
2
3
4
5
class UsersController < ApplicationController
def index
render locals: { users: User.all }
end
end

jbuilder uses a view-rendering approach for generating JSON, moving JSON construction logic into views. Partial templates allow structure reuse. However, passing parameters is necessary to control different outputs, as seen in the Post show example.

Since JSON is generated using Render View, producing JSON outside of an API becomes more complicated. Additionally, testing must be conducted through a controller, and code within the view is excluded from code coverage.

If you want to generate JSON using the existing view in your code, you can write it like this:

1
2
3
4
ActionController::Base.new.render_to_string(
partial: 'posts/post',
locals: { post: post, detail: true }
)

This generates a JSON string. If you need an object for further operations, you must first use JSON.parse.

active_model_serializers

user_serializer.rb

1
2
3
class UserSerializer < ActiveModel::Serializer
attributes :id, :name
end

post_serializer.rb

1
2
3
4
5
6
7
8
9
10
class PostSerializer < ActiveModel::Serializer
include ActionView::Helpers::DateHelper

attributes :id, :title
attribute :timeago do
time_ago_in_words(object.created_at)
end

has_one :user
end

post_detail_serializer.rb

1
2
3
class PostDetailSerializer < PostSerializer
attributes :content
end

posts_controller.rb

1
2
3
4
5
6
7
8
9
class PostsController < ApplicationController
def index
render json: Post.all
end

def show
render json: Post.find(params[:id]), serializer: PostDetailSerializer
end
end

users_controller.rb

1
2
3
4
5
class UsersController < ApplicationController
def index
render json: User.all
end
end

active_model_serializers uses the defined Serializer class to output data. Reusable parts can also be output in detail by using inheritance. Like the PostDetailSerializer above.

If you want to generate JSON in the code, you can write it like this:

1
2
3
ActiveModelSerializers::SerializableResource.new(post, {
serializer: PostDetailSerializer
}).as_json

However, there are actually quite a few issues with its usage:

nil requires special handling

When retrieving a single record, if the expectation is to return null instead of a 404, an error occurs. For example, in the following case:

1
render json: post, serializer: PostDetailSerializer

When post is nil, the expectation is to output:

1
2
3
{
"post": null
}

Actually, an error occurs.

1
undefined method `read_attribute_for_serialization' for nil

It turns out it needs to be written like this:

1
2
3
4
5
if post
render json: post, serializer: PostDetailSerializer
else
render json: { post: nil }
end

Default single root

The default behavior automatically generates a root like posts or post, for example:

1
2
3
{
"posts": [{ ... }]
}

To output additional data, for example:

1
2
3
4
{
"posts": [{ ... }],
"total_pages": 10
}

The default behavior can’t achieve this. You can either use the JSON generation method mentioned above or use the meta parameter.

1
render json: Post.all, meta: { total_pages: 10 }

However, this will add an extra level of nesting.

1
2
3
4
5
6
{
"posts": [{ ... }],
"meta": {
"total_pages": 10
}
}

oj_serializers

user_serializer.rb

1
2
3
class UserSerializer < Oj::Serializer
attributes :id, :name
end

post_serializer.rb

1
2
3
4
5
6
7
8
9
10
class PostSerializer < Oj::Serializer
include ActionView::Helpers::DateHelper

attributes :id, :title
attribute :timeago do
time_ago_in_words(post.created_at)
end

has_one :user
end

post_detail_serializer.rb

1
2
3
class PostDetailSerializer < PostSerializer
attributes :content
end

posts_controller.rb

1
2
3
4
5
6
7
8
9
class PostsController < ApplicationController
def index
render json: { posts: PostSerializer.render(Post.all) }
end

def show
render json: { post: PostDetailSerializer.render(Post.find(params[:id])) }
end
end

users_controller.rb

1
2
3
4
5
class UsersController < ApplicationController
def index
render json: { users: UserSerializer.render(User.all) }
end
end

Similar to active_model_serializers, it uses class definitions to generate output, but it’s more intuitive to use. By calling the functions defined in the Serializer, you can directly generate JSON. In the controller, you can easily assemble the JSON, and pagination or other information can be added easily. As for the nil issue with a single record, if you want to output nil, you can achieve it using the one_if function:

1
render json: { post: PostDetailSerializer.one_if(post) }

Benchmark

Next, let’s test the performance. I created a benchmark project, and the results are approximately as follows:

1
2
3
4
5
             as_json      0.116 (± 0.0%) i/s     (8.62 s/i) -      1.000 in   8.623844s
jbuilder 0.103 (± 0.0%) i/s (9.70 s/i) - 1.000 in 9.698932s
active_model_serializers 0.057 (± 0.0%) i/s (17.55 s/i) - 1.000 in 17.549307s
oj_serializer 0.155 (± 0.0%) i/s (6.45 s/i) - 1.000 in 6.453107s
oj_serializer.to_json 0.187 (± 0.0%) i/s (5.36 s/i) - 1.000 in 5.358214s

oj_serializer has the best performance, and I also happened to find that adding to_json further improves the performance. Using the example above, it would be modified like this:

1
render json: { posts: PostSerializer.render(Post.all) }.to_json

All the tests above have already included the optimization results from oj.

Conclusions

We compare the requirements in a table:

Requirementas_jsonjbuilderactive_model_serializersoj_serializer
Ease of maintenanceXOOO
ReusabilityXOOO
Generate JSON objectsOOO
PerformanceXO
Ease of testingOOO

oj_serializer is a great choice.