render json, and the bytes it actually sends
A Rails action that answers JSON is one line, and the line hides four decisions. render json: @post
reaches a renderer, then to_json, then as_json, then an encoder that may or may not rewrite your
< characters depending on a config flag whose default changed in Rails 8.1. Every failure in that
chain is silent: wrong bytes, a filter that stopped filtering, a 200 where you wanted a 204.
Everything below was run in a scratch Rails app on rails 8.1.3.1, Ruby 4.0.5, puma 8.0.2 and
PostgreSQL 17.7, with config.load_defaults 8.1. Outputs are pasted from curl, bin/rails runner
and the development log. The 18 examples that hold the claims up are in one integration test,
printed at the end.
The five lines that turn an object into a body
render json: is not a special case in the controller. It is an entry in a renderer registry, and
the whole of it is here, at actionpack-8.1.3.1/lib/action_controller/metal/renderers.rb:169:
add :json do |json, options|
json_options = options.except(:callback, :content_type, :status)
json_options[:escape] ||= false if !self.class.escape_json_responses? && options[:callback].blank?
json = json.to_json(json_options) unless json.kind_of?(String)
if options[:callback].present?
if media_type.nil? || media_type == Mime[:json]
self.content_type = :js
end
"/**/#{options[:callback]}(#{json})"
else
self.content_type = :json if media_type.nil?
json
end
end
Three things in there decide most of what follows. options.except(:callback, :content_type,
:status) means every other key you passed to render is forwarded to to_json verbatim. unless
json.kind_of?(String) means a String argument is written to the socket untouched. And
self.content_type = :json if media_type.nil? means the JSON content type is a default, not a rule:
anything that set the media type first wins.
To see exactly what arrives at the model, I put a logging to_json and as_json on a subclass and
hit two actions:
SPY to_json args=[{escape: false}]
SPY as_json options=nil
SPY to_json args=[{only: [:id], escape: false}]
SPY as_json options={only: [:id]}
So render json: record becomes record.to_json(escape: false), and the :escape key is consumed
by the encoder rather than passed down: as_json receives nil in the first case and the options
hash you actually wrote in the second.
Options ride through render and die at a String
only:, except:, methods: and include: are not options render understands. They are
serializable_hash options, read in activerecord-8.1.3.1/lib/active_record/serialization.rb:13,
and they reach it only because of the options.except(...) line above. That distinction is invisible
until the argument is a String, at which point they are dropped with no warning at all.
Four actions, one record, one dev server:
def slim
render json: Post.find(params[:id]), only: [:id, :title], methods: [:price]
end
def prebuilt_with_only
render json: Post.find(params[:id]).to_json, only: [:id]
end
def nested
render json: { post: Post.find(params[:id]).to_json }
end
$ curl -s http://127.0.0.1:3098/articles/1/slim
{"id":1,"title":"Post 0","price":19.99}
$ curl -s http://127.0.0.1:3098/articles/1/prebuilt_with_only
{"id":1,"title":"Post 0","body":"body 0","price_cents":1999,"amount":"19.99","published_at":"2026-09-26T10:00:00.000Z","author_id":1,"created_at":"2026-09-27T08:27:00.528Z","updated_at":"2026-09-27T08:27:00.528Z"}
$ curl -s http://127.0.0.1:3098/articles/1/nested
{"post":"{\"id\":1,\"title\":\"Post 0\",\"body\":\"body 0\",\"price_cents\":1999,\"amount\":\"19.99\",\"published_at\":\"2026-09-26T10:00:00.000Z\",\"author_id\":1,\"created_at\":\"2026-09-27T08:27:00.528Z\",\"updated_at\":\"2026-09-27T08:27:00.528Z\"}"}
The second response is a 200 carrying every column of the row including whatever you were trying to
hide with only:. That is the shape of a leak: somebody adds a to_json while debugging, the test
asserts response.parsed_body["id"], and the filter is gone.
The third is the double encode. { post: something.to_json } puts a JSON string inside a JSON
object, so the client gets a value it has to JSON.parse a second time. The rule that avoids both:
never call to_json yourself in a controller. Hand render json: the object.
Rails 8.1 changed the bytes twice, and neither change is in your controller
Two defaults flipped in load_defaults 8.1, in railties-8.1.3.1/lib/rails/application/configuration.rb
at lines 352 and 361:
if respond_to?(:action_controller)
action_controller.escape_json_responses = false
action_controller.action_on_path_relative_redirect = :raise
end
...
if respond_to?(:active_support)
active_support.escape_js_separators_in_json = false
end
escape_json_responses is the one you will notice. ActiveSupport::JSON::Encoding.escape_html_entities_in_json
is still true, so to_json still rewrites <, > and & as \u003c, \u003e and \u0026.
But the renderer now passes escape: false, which turns the whole escaping pass off for the
response body only. Two actions returning the same Author row, one handing over the object and
one calling to_json first:
def object
render json: Author.find(params[:id])
end
def prebuilt
render json: Author.find(params[:id]).to_json
end
$ curl -s http://127.0.0.1:3098/articles/1/object
{"id":1,"name":"Ada <b>Lovelace</b>","email":"ada@example.com","created_at":"2026-09-27T08:27:00.520Z","updated_at":"2026-09-27T08:27:00.520Z"}
$ curl -s http://127.0.0.1:3098/articles/1/prebuilt
{"id":1,"name":"Ada \u003cb\u003eLovelace\u003c/b\u003e","email":"ada@example.com","created_at":"2026-09-27T08:27:00.520Z","updated_at":"2026-09-27T08:27:00.520Z"}
143 bytes against 163, same record, same controller, same Rails. Both parse to the same object, so a
test that goes through response.parsed_body cannot tell them apart. A test that asserts on
response.body, a recorded HTTP fixture, or a downstream signature over the raw bytes all can, and
this is the upgrade that breaks them.
Keeping the old behaviour is possible and is already on the clock:
$ bin/rails runner 'ActionController.deprecator.behavior = :stderr; ActionController::Base.escape_json_responses = true'
DEPRECATION WARNING: Setting action_controller.escape_json_responses = true is deprecated and will have no effect in Rails 8.2. Set it to `false`, or remove the config. (called from <main> at bin/rails:4)
The second change is not in Action Pack at all and I only found it because a test failed. Rails 8.1's schema dumper sorts columns:
$ for v in 7.2.0 8.0.4 8.1.0; do ... done
7.2.0: 190: columns.each do |column|
8.0.4: 195: columns.each do |column|
8.1.0: 195: columns.sort_by(&:name).each do |column|
render json: record emits keys in column_names order, and column_names is whatever order the
database has. A database built by db:migrate has declaration order; one built by db:test:prepare
from schema.rb has alphabetical order. Same app, same commit, two answers:
$ bin/rails runner 'puts Post.column_names.inspect'
["id", "title", "body", "price_cents", "amount", "published_at", "author_id", "created_at", "updated_at"]
$ RAILS_ENV=test bin/rails runner 'puts Post.column_names.inspect'
["id", "amount", "author_id", "body", "created_at", "price_cents", "published_at", "title", "updated_at"]
Nothing in JSON says key order is meaningful and no sane client depends on it. Snapshot tests and byte-level response assertions do depend on it, and on Rails 8.1 they will pass locally against your migrated development database and fail in CI against a schema-loaded one. The fix is to stop asserting on the body string, not to pin the column order.
What a column turns into
Two conversions surprise people every time, and both are in as_json rather than the encoder.
A decimal column comes out as a JSON string, because BigDecimal#as_json is to_s. A datetime
comes out with exactly three decimal places and a Z, because ActiveSupport::JSON::Encoding.time_precision
is 3:
$ curl -s http://127.0.0.1:3098/articles/1/show
{"id":1,"title":"Post 0","body":"body 0","price_cents":1999,"amount":"19.99","published_at":"2026-09-26T10:00:00.000Z","author_id":1,"created_at":"2026-09-27T08:27:00.528Z","updated_at":"2026-09-27T08:27:00.528Z"}
"amount":"19.99" is a string and "price_cents":1999 is a number. That is correct rather than
annoying: a float would lose the value, and a JavaScript client doing JSON.parse on a decimal
number is exactly how 19.90 becomes 19.9. Store money in an integer column of cents and the
question does not arise, which is the argument in money and
decimals.
ActiveSupport::JSON.encode and JSON.generate are not the same function, and they differ twice in
one line:
$ bin/rails runner 'h = { at: Time.utc(2026,9,26,10,0,0), amount: BigDecimal("19.99"), s: "a<b" }; puts ActiveSupport::JSON.encode(h); puts JSON.generate(h)'
{"at":"2026-09-26T10:00:00.000Z","amount":"19.99","s":"a\u003cb"}
{"at":"2026-09-26 10:00:00 UTC","amount":"19.99","s":"a<b"}
The timestamp is the as_json difference: JSON.generate calls to_s on the Time and produces
something no client can parse as a timestamp. The a<b is the encoder difference, and it is the
same escape_html_entities_in_json from two sections up, which is worth holding next to the
render json: output above. ActiveSupport::JSON.encode escapes the <. The renderer, on
load_defaults 8.1, does not. So the helper and the response body disagree about the bytes, and only
the response body changed in 8.1. Reach for render json: in a controller and
ActiveSupport::JSON.encode outside one, never the bare JSON module.
Status, content type, and the body that is not empty
status: and content_type: are stripped from the options before to_json sees them, so they are
the two keys you can pass freely without changing the payload:
def created
render json: { id: 1 }, status: :created, content_type: "application/vnd.api+json"
end
$ curl -s -o /tmp/b.txt -w "status=%{http_code} ctype=%{content_type}\n" http://127.0.0.1:3098/articles/created
status=201 ctype=application/vnd.api+json; charset=utf-8
$ cat /tmp/b.txt
{"id":1}
render json: nil is the one that catches people. It is not the same as head :no_content:
$ curl -s -o /tmp/b.txt -w "status=%{http_code} ctype=%{content_type}\n" http://127.0.0.1:3098/articles/nothing
status=200 ctype=application/json; charset=utf-8
$ cat /tmp/b.txt
null
200, application/json, four bytes. A client checking if (response.data) sees a falsy value and a
client checking the status sees success. If you mean empty, head :no_content gives a real 204 with
no content type at all, and the related trap of an action that renders nothing by accident is in
API-only mode.
Validation errors have two shapes and you want the second one
render json: post.errors is the reflex, and it serialises to field names mapped to translated
sentences. post.errors.details serialises to symbol codes a client can branch on:
$ curl -s http://127.0.0.1:3098/articles/invalid
{"errors":{"author":["must exist"],"title":["can't be blank"]},"details":{"author":[{"error":"blank"}],"title":[{"error":"blank"}]}}
Ship both. The first is what a form renders under the input, the second is what survives a locale
change, and the details codes carry their interpolation arguments too: a length validation shows
up as {"error":"too_short","count":5}.
include: is one query per record
include: reads an association per record, in as_json, after the controller has already decided
it is done querying. Nothing in the controller looks like an N+1 and it is one:
def with_comments
render json: Post.limit(3), include: :comments
end
def with_comments_preloaded
render json: Post.includes(:comments).limit(3), include: :comments
end
Three posts, four queries. The development log, with config.colorize_logging = false and the
↳ caller lines dropped:
Processing by ArticlesController#with_comments as */*
Post Load (9.2ms) SELECT "posts".* FROM "posts" LIMIT 3 /*action='with_comments',application='RailsRenderJson',controller='articles'*/
Comment Load (0.5ms) SELECT "comments".* FROM "comments" WHERE "comments"."post_id" = 1 /*action='with_comments',application='RailsRenderJson',controller='articles'*/
Comment Load (0.2ms) SELECT "comments".* FROM "comments" WHERE "comments"."post_id" = 2 /*action='with_comments',application='RailsRenderJson',controller='articles'*/
Comment Load (0.1ms) SELECT "comments".* FROM "comments" WHERE "comments"."post_id" = 3 /*action='with_comments',application='RailsRenderJson',controller='articles'*/
Completed 200 OK in 40ms (Views: 8.6ms | ActiveRecord: 18.9ms (4 queries, 0 cached) | GC: 0.0ms)
Processing by ArticlesController#with_comments_preloaded as */*
Post Load (0.3ms) SELECT "posts".* FROM "posts" LIMIT 3 /*action='with_comments_preloaded',application='RailsRenderJson',controller='articles'*/
Comment Load (0.3ms) SELECT "comments".* FROM "comments" WHERE "comments"."post_id" IN (1, 2, 3) /*action='with_comments_preloaded',application='RailsRenderJson',controller='articles'*/
Completed 200 OK in 5ms (Views: 4.0ms | ActiveRecord: 0.6ms (2 queries, 0 cached) | GC: 0.0ms)
Ignore the milliseconds, which were taken on a laptop under a load average of 13 and mean nothing. The query counts are the claim, and the integration test asserts them rather than eyeballing the log.
The includes and the include: are two different words doing two different jobs in the same line,
which is a naming accident nobody can fix now. The broader version of this problem, and the tooling
that catches it before a user does, is in N+1 queries.
The dead end: the escape option does not break a zero-arity as_json
The escape: false option the renderer appends looked like an upgrade hazard, and it is not. Having
read that line in the renderer, I expected every model in the wild carrying a zero-argument
as_json to start raising on a Rails 8.1 upgrade:
class BadPost < ApplicationRecord
self.table_name = "posts"
def as_json
{ id: id, title: title }
end
end
An as_json that takes no arguments is a common enough shortcut, and if the renderer started passing
a non-empty options hash where it used to pass none, that is an ArgumentError on every request. It
is not. The encoder strips :escape before it calls as_json, so the model still receives nil:
$ curl -s http://127.0.0.1:3098/articles/1/zero_arity
{"id":1,"title":"Post 0"}
What does break it is an option you wrote yourself, which has nothing to do with the version:
$ curl -s -o /dev/null -w "status=%{http_code}\n" http://127.0.0.1:3098/articles/1/zero_arity_with_only
status=500
ArgumentError (wrong number of arguments (given 1, expected 0)):
So the zero-arity override is a bomb with a delay fuse rather than an upgrade hazard. It works
through render json: record, through to_json with no arguments, inside an array and nested in a
hash, and it dies the day somebody adds only: four months later. Write def as_json(options = nil)
and pass the options to super, or accept that only: and except: will never work on that model
again.
That last clause is worth its own line, because it is the quieter half. An override that does
super().merge("extra" => x) with empty parens throws the caller's options away. Leaky.first.as_json(only: [:id])
returned all nine columns plus the extra key, no error, in a model I wrote to check exactly that.
callback: is still in the renderer and still answers 422
The JSONP branch of the renderer has not been removed. It is also unreachable over a plain GET,
because append_after_action :verify_same_origin_request from
ActionController::RequestForgeryProtection fires on any non-XHR response whose media type matches
%r(\A(?:text|application)/javascript), and the callback: branch sets exactly that content type:
$ curl -s -o /dev/null -w "status=%{http_code} ctype=%{content_type}\n" http://127.0.0.1:3098/articles/jsonp
status=422 ctype=text/html; charset=UTF-8
The development log for that request, with the backtrace after the first frame cut:
Processing by ArticlesController#jsonp as */*
Security warning: an embedded <script> tag on another site requested protected JavaScript. If you know what you're doing, go ahead and disable forgery protection on this action to permit cross-origin JavaScript embedding.
Completed 422 Unprocessable Content in 1ms (Views: 0.1ms | ActiveRecord: 0.0ms (0 queries, 0 cached) | GC: 0.0ms)
ActionController::InvalidCrossOriginRequest (Security warning: an embedded <script> tag on another site requested protected JavaScript. If you know what you're doing, go ahead and disable forgery protection on this action to permit cross-origin JavaScript embedding.):
actionpack (8.1.3.1) lib/action_controller/metal/request_forgery_protection.rb:441:in 'ActionController::RequestForgeryProtection#verify_same_origin_request'
The body is built and then thrown away by an after_action. If you genuinely need JSONP in 2026 you have to turn forgery protection off on that action, which is the warning telling you to use CORS instead.
Where the time in a JSON response actually goes
The escape: false change was made for speed, so the fair question is how much. I measured the three
slices of render json: Post.limit(20) separately, on a 4312-byte payload, with benchmark-ips:
$ bin/rails runner /tmp/bench_slices.rb
payload bytes=4312
ruby 4.0.5 (2026-05-20 revision 64336ffd0e) +PRISM [arm64-darwin25]
Warming up --------------------------------------
query + instantiate 338.000 i/100ms
as_json (no encode) 632.000 i/100ms
encode escaped 8.856k i/100ms
encode unescaped 21.092k i/100ms
to_json on records 621.000 i/100ms
Calculating -------------------------------------
query + instantiate 3.490k (± 4.2%) i/s (286.50 μs/i) - 17.576k in 5.035469s
as_json (no encode) 6.649k (± 7.4%) i/s (150.40 μs/i) - 33.496k in 5.037931s
encode escaped 93.013k (± 3.0%) i/s (10.75 μs/i) - 469.368k in 5.046243s
encode unescaped 207.475k (± 4.4%) i/s (4.82 μs/i) - 1.055M in 5.083021s
to_json on records 6.174k (± 4.6%) i/s (161.98 μs/i) - 31.050k in 5.029411s
Escaping is genuinely 2.2 times the cost of not escaping, and both numbers are irrelevant. The
encoder is 10.75 μs of a 448 μs job. Turning escaping off saves 5.9 μs per response, about 1.3
percent, while as_json costs 150.40 μs and the query costs 286.50 μs. If a JSON endpoint here is
slow, it is the query or the number of Active Record objects you instantiated, and no encoder swap
touches either.
I also put three endpoints behind ApacheBench (/usr/sbin/ab -n 2000 -c 5) serving byte-identical
4312-byte payloads from render json: with escaping off, with escaping on, and from a jbuilder
template, in RAILS_ENV=production on puma 8.0.2 in single mode with 5 threads. I am not reporting
those numbers. This laptop was carrying a load average of 13.4 on 12 cores while I ran them and the
same endpoint measured 624 requests per second and then 287 requests per second ten seconds apart.
The in-process benchmark above survives that noise because benchmark-ips reports its own error
margin; a throughput figure taken on a saturated machine does not, and publishing one would be
inventing a fact.
The call, and what would change it
Hand render json: the object and let the renderer call to_json. Every to_json you write in a
controller is a chance to lose your only: filter, double-encode a payload, or ship different bytes
than the action next to it. When the response shape is more than only: and methods: can express,
write a plain Ruby class with an as_json(options = nil) method and render that, rather than
overriding as_json on the model where every other caller inherits your decision.
The cost of that position: render json: @post is the whole table, and it stays the whole table
after the migration nobody told you about. The output above has nine keys because the posts table
has nine columns, and it would have ten tomorrow with no code change and no failing test.
only: [:id, :title] fixes that and is itself a list nothing validates: Post.first.as_json(only:
[:id, :titel]) returned {"id" => 1} and raised nothing, so a rename drops a key the client was
reading and the suite stays green. A serializer class is the thing that makes a response an
interface rather than a projection of the table, and render json: never asks you to
write one.
What would change it: a first-party serializer in Action Pack, or render json: refusing a bare
ActiveRecord::Base argument the way render refuses an unknown template. Until one of those
exists, the discipline is manual and the leak is one to_json away.
What this page does not cover
Alternative encoders. I did not install Oj, Oj.optimize_rails, alba, panko_serializer or
active_model_serializers, so nothing above is a claim about them. The measurement that matters for
that decision is in the benchmark: whatever an encoder does, it is competing for 10.75 μs of a 448 μs
request, and a gem that also replaces as_json is competing for a different 150.40 μs.
Jbuilder beyond confirming that a hand-written template produced a byte-identical 4312-byte body for
the same 20 rows. Streaming JSON, ActionController::Live and any response too large to build in
memory. ActionController::API and the middleware it drops, which is
API-only mode. Request-side JSON: parameter
parsing, wrap_parameters and what params does with a JSON body are a separate chain with separate
surprises. And JSON columns in PostgreSQL, which share a word with everything here and nothing else,
covered in jsonb columns in Rails.
The integration test behind every claim above is 18 examples in one file:
$ bin/rails test test/integration/render_json_test.rb
Running 18 tests in a single process (parallelization threshold is 50)
Run options: --seed 10751
# Running:
..................
Finished in 0.261915s, 68.7246 runs/s, 156.5393 assertions/s.
18 runs, 41 assertions, 0 failures, 0 errors, 0 skips
Comments
No comments yet. Be the first.