← Back to blog

slice and except in Ruby: Keep or Drop Hash Keys Cleanly

· Lachlan Young

Half the hash manipulation in a Rails app is really one of two operations: keep these keys, or drop those keys. Filtering a params hash down to what you trust. Stripping created_at and updated_at before comparing two records. Pulling a handful of fields out of a config to pass along. Most codebases still do this the long way, with select, reject, delete, or a merge dance, and every one of those long ways has a sharp edge that slice and except file off.

Here is the whole idea in four lines:

user = { id: 7, name: "Ada", email: "ada@example.test", role: "admin" }

user.slice(:id, :name)      # => {id: 7, name: "Ada"}
user.except(:email, :role)  # => {id: 7, name: "Ada"}

slice returns a new hash containing only the keys you name. except returns a new hash with those keys removed. Both leave the original untouched, both ignore keys that are not present, and both are plain Ruby now, not Rails extensions.

They are not both Ruby, actually, and the history matters

This trips people up, so it is worth being precise. Hash#slice has been in core Ruby since 2.5. Hash#except landed later, in Ruby 3.0. Before that, except lived only in ActiveSupport, which is why you will still see codebases that assume it is a Rails thing.

The practical consequence: if you are writing a plain Ruby gem that targets 2.7, slice is safe but except is not, and you either bump your minimum to 3.0 or pull in ActiveSupport. Inside a modern Rails app you never have to think about this, both are always there. But when a NoMethodError: undefined method 'except' shows up in a library’s CI on an old Ruby, this is usually why.

Why slice beats select

The pre-2.5 way to keep a few keys was select with a block:

user.select { |k, _| [:id, :name].include?(k) }

That works, but read it again. You are describing the keys you want as a filter condition, building an array literal inside the block, and calling include? on every single pair in the hash. The intent, “give me id and name”, is buried under mechanics. slice(:id, :name) says exactly what you mean and, because it looks the keys up directly rather than iterating the whole hash, it does less work on a large hash. On a hash with two keys none of this matters. On a request payload with sixty, it is both faster and clearer.

The select version also invites a subtle bug. If you write the condition as k == :id || k == :name and later add a third key to keep, it is easy to bolt on another || and get the precedence wrong. slice has no logic to get wrong. You list the keys.

Why except beats delete and reject

The mirror image is dropping keys. The old ways were reject:

user.reject { |k, _| [:email, :role].include?(k) }

or, worse, mutating in place with delete:

copy = user.dup
copy.delete(:email)
copy.delete(:role)
copy

The delete version is the one I actively hunt for in reviews. It mutates, so you need the dup or you corrupt the caller’s hash, and forgetting the dup is a genuinely nasty bug because the code looks correct and passes the test that added it, then quietly breaks a caller three files away. It also does not compose. You cannot chain it. except(:email, :role) returns a fresh hash, takes every key at once, and never touches the original. There is nothing to forget.

The place this earns its keep: test comparisons

Here is where I reach for except almost daily. You want to assert that a record has the attributes you expect, but attributes comes back cluttered with fields you do not care about and cannot predict:

record = User.create!(name: "Ada", role: "admin")

record.attributes
# => {
#   "id" => 42,
#   "name" => "Ada",
#   "role" => "admin",
#   "created_at" => 2026-08-13 09:14:22 UTC,
#   "updated_at" => 2026-08-13 09:14:22 UTC
# }

You cannot assert against that hash directly because id, created_at, and updated_at change every run. So you carve them off:

assert_equal(
  { "name" => "Ada", "role" => "admin" },
  record.attributes.except("id", "created_at", "updated_at")
)

Now the assertion says what you actually mean: the name and role are correct, and nothing else is my concern. When it fails, it fails on a real difference instead of on a timestamp.

slice does the same job from the other direction when you only care about a couple of fields out of many:

assert_equal(
  { "name" => "Ada", "role" => "admin" },
  record.attributes.slice("name", "role")
)

Pick slice when the keys you care about are few and stable, and except when the keys you want to ignore are few and the rest is your assertion. The rule of thumb: name the shorter list.

Two things that will bite you

First, key type. slice and except match keys with eql?, which means :id and "id" are different keys. If your hash has string keys and you slice with symbols, you get an empty hash and no error:

{ "id" => 7, "name" => "Ada" }.slice(:id, :name)  # => {}

This is the single most common slice and except mistake, and it is silent. record.attributes returns string keys, so slice and except it with strings. A params hash is HashWithIndifferentAccess, so either works. Plain symbol-keyed hashes want symbols. When a slice comes back unexpectedly empty, check the key type first.

Second, ActiveSupport adds bang versions, slice! and except!, that mutate the receiver, and slice! returns the removed keys rather than the kept ones. They exist for the rare hot path where you genuinely want to avoid the allocation. Reach for the non-bang versions by default and only mutate when you have a measured reason. A surprising number of “why did this hash change under me” bugs come from a stray slice!.

When you still want a block

slice and except take literal keys, not conditions. If your rule is dynamic, for example drop every key that starts with an underscore, you are back to reject:

payload.reject { |k, _| k.to_s.start_with?("_") }

That is correct and fine. The point of slice and except is not to abolish the block form, it is to stop using the block form for the ninety percent of cases where you already know the exact keys. Reserve the block for the actual predicates.

Once you internalize the split, a lot of hash code gets shorter. Whitelisting inputs becomes a slice. Stripping noise before a comparison becomes an except. And the two operations that used to hide behind select, reject, dup, and delete start reading like what they are.

When you do carve a record down with except and the assertion still fails on two hashes that look identical, the difference is usually a value type or a key type rather than the keys themselves. Try RubyHash to paste both hashes and see exactly which value moved, right down to a nil that became an empty string or an integer that became a float.

Enjoyed this post?

Subscribe to get notified when we publish more Ruby and Rails content.