Understanding Ruby 3.0's Keyword Argument Separation and How to Migrate Safely
Ruby 3.0 stopped treating a trailing hash as keyword arguments automatically. Learn why the change was made, how it affects calls, and practical steps to update your code while keeping backward compatibility.
16 Nov 2025, 10:25 UTC

The Problem: Silent Hash Conversion
Before Ruby 3.0, a method that accepted keyword arguments could also be called with a plain hash as its last positional argument. The interpreter would treat that hash as if it were keyword arguments. This behavior worked in many cases but introduced ambiguity: the same call could be interpreted differently depending on the method signature, making it hard to reason about code and limiting future optimizations.
Thesis: Embracing the Explicit Split Improves Predictability
Ruby 3.0 removed the automatic conversion. A method defined with **kw now raises ArgumentError if you pass a positional hash unless you explicitly splat it with **. The change forces developers to be explicit about intent, eliminates surprising bugs, and enables better static analysis and performance.
What the Change Looks Like in Practice
Consider a simple method that expects keyword arguments:
# file: example.rb
def greet(**info)
"Hello, #{info[:name]}!"
end
In Ruby 2.7 the following call works because the hash is silently converted:
greet({ name: "Ada" }) # => "Hello, Ada!" (2.7)
In Ruby 3.0 the same line raises:
greet({ name: "Ada" }) # => ArgumentError: unknown keyword: name (3.0)
To make the call valid you must splat the hash:
greet(**{ name: "Ada" }) # => "Hello, Ada!" (3.0)
Alternatively, you can change the method to accept a positional hash:
def greet(info)
"Hello, #{info[:name]}!"
end
but then you lose the ability to pass true keyword arguments like greet(name: "Ada").
Worked Example: Updating a Real‑World Call
Suppose you maintain a small gem that wraps an HTTP client and has a method:
def request(options = {})
# options may contain :headers, :timeout, etc.
Faraday.get(url, **options)
end
Existing code might call it like:
client.request({ headers: { "Authorization" => "Bearer token" } })
Under Ruby 3.0 this triggers an ArgumentError because request expects keyword arguments but receives a positional hash. The fix is to splat the hash at the call site:
client.request(**{ headers: { "Authorization" => "Bearer token" } })
If you control the method definition and want to keep the old calling style for compatibility, you can accept a positional parameter and manually convert it:
def request(options = {})
# options is already a hash; treat it as keyword‑like
Faraday.get(url, **options)
end
This approach works on both Ruby 2.x and 3.x but sacrifices the ability to detect accidental keyword misuse.
Trade‑offs and Limitations
- Explicitness vs. Brevity – Adding
**at every call site makes the intent clear but adds noise, especially in metaprogramming where methods are built dynamically. - Backward Compatibility – Libraries that must support Ruby 2.7 and 3.0 often rely on the
ruby2_keywordsgem or the built‑inModule#ruby2_keywordsmethod to retain the old behavior without changing every caller. This adds a dependency and can produce false positives when static analysis tools see the hash as a positional argument. - Performance – The new semantics eliminate the need for the runtime to check whether a trailing hash should be treated as keywords, which can improve call‑site performance marginally.
Actionable Steps for a Safe Migration
- Detect problematic calls – Run
rubocop --railswith theLint/AmbiguousBlockAssociationandStyle/KeywordArgumentscops, or use theruby2_keywordsgem’sruby2_keywords_checkscript to list calls that need updating. - Apply the splat fix – For each flagged call, change
foo(hash)tofoo(**hash). If the method is under your control, consider whether accepting a positional hash is preferable. - Use conditional syntax for dual support – When maintaining a gem, wrap calls in a version check:
if RUBY_VERSION >= '3.0' foo(**hash) else foo(hash) end - Test thoroughly – Run your test suite against both Ruby 2.7 and 3.0 (using tools like
rvmorrbenv) and verify that no ArgumentError appears. If you useruby2_keywords, confirm that the gem’s warning flag (-w) does not emit deprecation messages for your codepath. - Document the change** – Add a note in your project’s
UPGRADING.mdexplaining that keyword arguments are now explicit and that callers must splat hashes when upgrading to Ruby 3.0.
Conclusion
Ruby 3.0’s keyword argument separation removes a source of hidden bugs and makes method contracts clearer. While the migration requires adding ** in places where a hash was previously passed positionally, the benefit is more predictable code and better tooling support. By detecting problematic calls, applying the splat operator or using compatibility helpers, and testing across versions, you can adopt the change confidently without breaking existing functionality.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.