A software engineer focused on writing expressive code for humans (and computers).

January 20, 2019 ·

ActiveSupport #as_json vs #to_json

Something that comes up when writing API endpoints in Rails is whether to use #as_json or #to_json. Despite the methods sounding synonymous, they serve two distinct purposes. Yet a surprising amount of implicit calling happens making them feel more similar than they actually are.

#as_json is a lie

A natural assumption for “as json” is that it returns the object as a JSON string. False. Check out the difference between the outputs of #as_json and #to_json:

book = { id: 42, name: "The Hitchhiker's Guide to the Galaxy", published: Date.new(1979, 10, 12) }
#=> {:id=>42, :name=>"The Hitchhiker's Guide to the Galaxy", :published=>Fri, 12 Oct 1979}
book.as_json
#=> {"id"=>42, "name"=>"The Hitchhiker's Guide to the Galaxy", "published"=>"1979-10-12"}
book.to_json
#=> "{\"id\":42,\"name\":\"The Hitchhiker's Guide to the Galaxy\",\"published\":\"1979-10-12\"}"

While the result of #as_json may look like JSON, it’s actually a Ruby hash. It is #to_json that returns a serialized JSON string. So clearly not an alias: one returns a hash, the other a string.

book.as_json.class
#=> Hash
book.to_json.class
#=> String

So if we start with a hash, call #as_json on it, then get back a hash… what have we gained? First, the hash keys are converted to strings rather than symbols. Second, the :published value becomes a string representation of the date rather than a Date object. Since JSON only supports string, number, boolean, and null values, anything more complex must be simplified.

Caveat: JSON also supports array and object values, but those are just structural containers that allow for lists and hierarchy. The atomic value types are string, number, boolean, and null.

Simplify then serialize

Ruby separates the concerns of simplification and serialization. #as_json is responsible for simplification: it normalizes Ruby objects to JSON compatible primitives. #to_json is responsible for serialization: it generates a raw JSON string with proper escaping and keywords (e.g. nil to null). So it makes sense to use both methods in tandem. Which is exactly what happens in many of the #to_json definitions:

def to_json(*args)
  as_json.to_json(*args)
end

The delegation to #as_json lets #to_json focus on serialization, not simplifying objects to primitives.

NOTE: Both #as_json and #to_json are defined in the Ruby Standard library. However, you can pretty much ignore Ruby’s implementation of #as_json since it’s entirely overridden by ActiveSupport.

Adapt objects to Hash

ActiveSupport extends Object#as_json to work with all objects. The method first converts an object to a hash using #to_hash, then calls #as_json on the resulting hash. So #to_hash acts as an adapter to normalize objects to a common type: Hash. Then the single Hash#as_json definition can work on any of the adapted objects.

# lib/active_support/core_ext/object/json.rb
class Object
  def as_json(options = nil)
    if respond_to?(:to_hash)
      to_hash.as_json(options)
    else
      instance_values.as_json(options)
    end
  end

  # so basically this…
  def as_json(*)
    to_hash.as_json(*)
  end
end

So Hash#as_json is where the real work happens. A majority of the method deals with filtering down the hash using only/except, but the last line is the most interesting. It loops over each key/value pair in the hash and makes them more JSON-like, calling #to_s on the key and #as_json on the value.

# lib/active_support/core_ext/object/json.rb
class Hash
  def as_json(options = nil)
    # create a subset of the hash by applying :only or :except
    subset = if options
      # …
    else
      self
    end
    Hash[subset.map { |k, v| [k.to_s, options ? v.as_json(options.dup) : v.as_json] }]
  end

  # so basically this…
  def as_json(*)
    Hash[map { |k, v| k.to_s, v.as_json }]
  end
end

Array#as_json works similar, calling #as_json on each value in the array:

# lib/active_support/core_ext/object/json.rb
class Array
  def as_json(options = nil)
    map { |v| options ? v.as_json(options.dup) : v.as_json }
  end

  # so basically this…
  def as_json(*)
    map(&:as_json)
  end
end

So calling #as_json on a Hash or Array will recursively call #as_json on each of their values. But what happens when you call #as_json on a primitive object such as a string or number?

#as_json all the things!

ActiveSupport provides #as_json definitions for many core and standard Ruby classes. An example of this was seen earlier when #as_json simplified the Date object to a nicely formatted ISO 8601 string:

# lib/active_support/core_ext/object/json.rb
class Date
  def as_json(options = nil)
    if ActiveSupport::JSON::Encoding.use_standard_json_time_format
      strftime("%Y-%m-%d")
    else
      strftime("%Y/%m/%d")
    end
  end

  # so basically this…
  def as_json(*)
    strftime("%Y-%m-%d")
  end
end

# Example:
Date.new(1979, 10, 12).as_json
#=> "1979-10-12"

Other Ruby objects are already JSON compatible so #as_json returns the values unmodified:

# lib/active_support/core_ext/object/json.rb
class String
  def as_json(options = nil)
    self
  end
end

class Numeric
  def as_json(options = nil)
    self
  end
end

# Examples:
"book".as_json
#=> "book"
42.as_json
#=> 42

For others still, ActiveSupport uses #to_s to simplify the objects to meaningful strings:

# lib/active_support/core_ext/object/json.rb
class Symbol
  def as_json(options = nil)
    to_s
  end
end

class BigDecimal
  def as_json(options = nil)
    finite? ? to_s : nil
  end
end

class Range
  def as_json(options = nil)
    to_s
  end
end

# Examples:
:book.as_json
#=> "book"
BigDecimal(42).as_json
#=> "42.0"
(1..3).as_json
#=> "1..3"

The point is, #as_json simplifies all objects to either a single JSON atomic value type or to a hash or array with multiple values for more complex objects.

Which should I use?

#to_json is the actual serialization method that outputs a JSON string. It should be the very last modification to your JSON. And most of the time you don’t need to actually call it… your framework will do it for you. There might be times when you need to explicitly serialize to JSON, like when storing a value in Redis or PostgreSQL, but that’s not the typical case in Rails apps.

#as_json is a bit more useful. It outputs a Ruby hash with string keys and primitive values. This simplifies modifications, such as merging in an extra attribute or building out a larger response made of multiple objects. That said, you only need to call #as_json if you need to modify the structure since #to_json will implicitly call it for you.

So which method should you use? Neither. Instead, build a Hash in the shape you want the JSON response and pass it directly to render. Let your framework handle the rest. That means render json: hash in Rails or json(hash) in Sinatra. If you must have a JSON string, you can use hash.to_json assuming ActiveRecord, or JSON.generate(hash) for a non-Rails environment.