Ruby Keyword Arguments: Why the 3.0 Migration Broke Your Code and How to Fix It
Ruby 3.0 removed implicit Hash-to-keyword conversion, breaking legacy call sites. This post explains the change, shows how to migrate wrapper patterns with double-splat, and when keywords actually improve clarity versus adding noise.
05 May 2026, 16:35 UTC

The Problem: Silent Hash-to-Keyword Conversion Is Gone
You upgrade a Rails application from Ruby 2.7 to 3.2. Tests start failing with ArgumentError: wrong number of arguments or missing keywords. The culprit: Ruby 3.0 removed the implicit conversion of a trailing Hash into keyword arguments. Code that worked for years — passing an options hash as the last positional argument — now breaks.
This isn't a bug. It's a deliberate design decision to make method signatures explicit. The trade-off is migration pain, but the payoff is clearer APIs and fewer subtle bugs.
What Changed in Ruby 3.0
Before Ruby 3.0, a method defined with keyword arguments would accept a Hash passed positionally:
# Ruby 2.7 behavior
def greet(name, greeting: "Hello")
"#{greeting}, #{name}!"
end
greet("Alice", { greeting: "Hi" }) # => "Hi, Alice!" (Hash auto-converted)
greet("Alice", greeting: "Hi") # => "Hi, Alice!"
In Ruby 3.0+, the first call raises ArgumentError: wrong number of arguments (given 2, expected 1). The Hash is no longer implicitly splatted. You must use the double-splat operator:
# Ruby 3.0+ required
greet("Alice", **{ greeting: "Hi" }) # => "Hi, Alice!"
This separation eliminates ambiguity. A method signature now clearly declares what it accepts: positional arguments, keyword arguments, or both — never a mix-up.
Required vs Optional Keywords: Enforce Contracts at Call Time
Ruby 2.1 introduced required keyword arguments (no default value). Ruby 3.0 made them stricter: omitting a required keyword raises ArgumentError: missing keyword: :api_key immediately, not later when you try to use a nil value.
def connect(host:, port: 5432, timeout: 5)
# host is required; port and timeout have defaults
"Connecting to #{host}:#{port} with timeout #{timeout}"
end
connect(host: "db.example.com") # OK
connect(port: 5432, timeout: 10) # ArgumentError: missing keyword: :host
connect("db.example.com", port: 5432) # ArgumentError in 3.0+ (positional not allowed)
This forces callers to be explicit about required dependencies. No more options[:host] || raise guards scattered through method bodies.
Worked Example: Migrating a Service Object Wrapper
Consider a typical pattern: a wrapper that forwards options to an underlying HTTP client.
# Before Ruby 3.0 — fragile
def fetch_user(id, options = {})
HttpClient.get("/users/#{id}", options)
end
# Call sites
fetch_user(42, headers: { "Authorization" => token })
fetch_user(42, { headers: { "Authorization" => token } })
After upgrading, the second call site breaks. The fix: make keywords explicit and use double-splat for forwarding.
# Ruby 3.0+ — explicit and forwardable
def fetch_user(id, headers: {}, timeout: 10, **extra)
HttpClient.get("/users/#{id}", headers: headers, timeout: timeout, **extra)
end
# Both call styles now work
fetch_user(42, headers: { "Authorization" => token })
fetch_user(42, **{ headers: { "Authorization" => token } })
The **extra captures any additional keywords (like :query, :body) and forwards them. This pattern scales: add new keywords to the signature without changing every call site.
Trade-off: Verbosity for Simple Methods
Keyword arguments add noise when a method has one or two obvious parameters. Compare:
# Positional — fine for 1-2 args
def full_name(first, last)
"#{first} #{last}"
end
# Keyword — overkill here
def full_name(first:, last:)
"#{first} #{last}"
end
Use positional arguments when order is obvious and stable. Switch to keywords when:
- Three or more parameters
- Boolean flags (avoids
true, false, trueconfusion) - You need required/optional distinction
- Call sites benefit from self-documentation
Verification Checklist for Your Codebase
- Run tests on Ruby 3.0+ with
RUBYOPT=-W:deprecatedto surface conversion warnings. - Search for trailing Hash literals in method calls:
grep -rn "\w\s*{\s*\w\s*:\" . --include="*.rb"(run from project root). - Audit method definitions using
ruby -e "puts Method.new(:method_name).parameters"to see keyword vs positional. - Add double-splat where you forward options:
def wrapper(**opts); inner(**opts); end.
Closing: Make the Signature the Documentation
Keyword arguments shift complexity from call-site comments to the method signature itself. A signature like def deploy(env:, version:, rollback: false, **hooks) tells you exactly what's required, what's optional, and that extra hooks are accepted — without reading the body. The migration cost is real, but the long-term maintenance win is a codebase where the contract is enforced by the interpreter, not convention.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.