I agree that this is unexpected behavior at first, but it actually makes some good sense.
Consider, for example, what you would expect this to do:
output = {'foo' => 'bar'}.to_json
render :json => output
Even though the to_json is kinda redundant, you expect the result to be {foo: "bar"}. However, note that the result of {'foo' => 'bar'}.to_json is actually a string. So, the above code block is equivalent to:
render :json => '{foo: "bar"}'
If render were to JSON-encode strings passed to :json, you would get "{foo: \"bar\"}", which is definitely not expected behavior.
So here’s the deal: render checks to see if the :json argument is a string. If so, it assumes that it’s a JSON string and you already ran to_json, and passes the string along. If not, it runs to_json on the object.
I think the documentation should probably clarify that, but there you have it. Though it’s not exactly intuitive at first glance, I would be surprised if it worked any other way.