params.to_h Surprises: What Strong Parameters Actually Gives You
At some point every Rails developer writes something like this and watches it fail:
def create
attrs = params[:user].to_h
User.create!(attrs)
end
In development it raises ActionController::UnfilteredParameters. Or it does not raise, and returns an empty hash, and you get a User with nothing set. Or it works locally and breaks in a test because the hash you asserted against has string keys and the one you built has symbols. Strong parameters is one of the parts of Rails where the object you are holding is not the object you think you are holding, and to_h is where that gap becomes visible.
Parameters is not a Hash
This is the root of everything else. ActionController::Parameters used to inherit from HashWithIndifferentAccess. Since Rails 5 it does not inherit from Hash at all. It wraps one.
params = ActionController::Parameters.new(name: "Ada", role: "admin")
params.class # => ActionController::Parameters
params.is_a?(Hash) # => false
It quacks like a hash for the common reads, [], fetch, dig, each, key?, so most code never notices. But anything that requires a real Hash, and that includes ActiveRecord mass assignment, to_json in some paths, and a plain assert_equal against a hash literal, will either raise or quietly do the wrong thing.
That was a deliberate change. Making Parameters a Hash meant that any code path accepting a hash silently accepted unfiltered user input. Breaking the inheritance turned a security question into a type error, which is the correct trade.
The two modes of to_h
to_h behaves differently depending on whether the object has been permitted:
params = ActionController::Parameters.new(name: "Ada", role: "admin")
params.to_h
# raises ActionController::UnfilteredParameters
params.permit(:name).to_h
# => {"name" => "Ada"}
Note what happened to role. It was not permitted, so it is gone. No error, no warning in the returned value, it just is not there. If you permitted the wrong key name, or misspelled it, or the client nested the field one level deeper than you expected, to_h hands you a smaller hash than you wanted and the failure surfaces later as a validation error or a nil column.
The unpermitted keys are not entirely silent, but you have to opt in to hearing about them. In config/environments/test.rb and development.rb:
config.action_controller.action_on_unpermitted_parameters = :raise
The default in development and test is :log, which writes a line to the log that nobody reads. Setting it to :raise in test is one of the highest value config changes you can make in a Rails app. It converts an entire class of “why is this field not saving” bugs into a loud test failure at the exact line that caused it.
to_unsafe_h, and when it is fine
There is an escape hatch:
params.to_unsafe_h
# => {"name" => "Ada", "role" => "admin"}
This returns everything, permitted or not, as a HashWithIndifferentAccess. The name is doing real work, so treat it as a signal rather than a convenience. It is genuinely fine when the input did not come from a user, for example a webhook body you have already verified by signature, or params you constructed yourself in a test. It is not fine anywhere near mass assignment. If you find to_unsafe_h on the path between a request and create! or update!, that is the bug.
The key type problem in tests
Here is where this collides with hash assertions. permit(...).to_h returns a hash with string keys:
params.permit(:name, :role).to_h
# => {"name" => "Ada", "role" => "admin"}
So this assertion fails:
assert_equal({ name: "Ada", role: "admin" }, params.permit(:name, :role).to_h)
and the failure output shows you two hashes that read almost identically, because "name" => "Ada" and name: "Ada" look close enough that your eye slides right over the difference. This is the same class of problem as any symbol versus string key mismatch, except that here you did not choose the key type, strong parameters chose it for you.
Two clean fixes. Either assert with string keys, which is honest about what the method returns:
assert_equal({ "name" => "Ada", "role" => "admin" }, params.permit(:name, :role).to_h)
or normalize both sides explicitly with symbolize_keys:
assert_equal({ name: "Ada", role: "admin" }, params.permit(:name, :role).to_h.symbolize_keys)
Pick one and use it consistently across the suite. Mixing them is how you end up with a test file where half the assertions use strings and nobody remembers why.
Nested and array parameters
The other place to_h surprises people is nesting. permit does not recurse for you:
params = ActionController::Parameters.new(
user: { name: "Ada", address: { city: "Melbourne", postcode: "3000" } }
)
params.require(:user).permit(:name).to_h
# => {"name" => "Ada"}
The whole address is gone. You have to name the nested structure:
params.require(:user).permit(:name, address: [:city, :postcode]).to_h
# => {"name" => "Ada", "address" => {"city" => "Melbourne", "postcode" => "3000"}}
Arrays of scalars need the empty array form, permit(tags: []), and arrays of hashes need the shape spelled out, permit(items: [:sku, :quantity]). For a genuinely open-ended blob, for example a JSON settings column, there is permit! on that sub-hash, but understand that you are turning filtering off for that branch and you own whatever arrives there.
The reason this feels tedious is that it is meant to. Every key you permit is a key you have decided a stranger on the internet is allowed to set on your model. The tedium is the security boundary doing its job.
A short checklist
When params are not doing what you expect, work through this in order. Check whether you are holding Parameters or a Hash, because the answer changes what every subsequent method does. Check whether the key you want is actually in the permit list, spelled the same way the form sends it. Check nesting, since a key one level deeper than your permit call is dropped without comment. Check key type before comparing to a hash literal, because to_h gives you strings. And set action_on_unpermitted_parameters to :raise in test so the next one of these tells you directly instead of making you guess.
When the assertion still fails on two hashes that look the same, the difference is usually a key type or a value type rather than a missing key. Try RubyHash to paste both hashes and see exactly which key or value moved.
Enjoyed this post?
Subscribe to get notified when we publish more Ruby and Rails content.