In Ruby 3.0 and later, why does `send_email(settings)` raise ArgumentError when `settings` is a Hash and `send_email` declares keyword parameters?
answer
- keywords separated from positionals
- 2.7 warned, 3.0 raises
- braces make it positional
- splat it with **
- required keyword: to
basics
~20 sSince Ruby 3.0 a Hash passed as an ordinary argument stays positional and is never converted into keywords, so a method expecting only keywords sees one surplus argument. Pass it as send_email(**settings) to splat it into keywords.
solid answer
~50 sRuby 3.0 **separated keyword arguments from positional ones**. Before that, a trailing Hash argument was implicitly treated as keywords when the method accepted keywords; 2.7 warned about it, and 3.0 made it an error or a behaviour change. Now `send_email(settings)` passes one positional argument to a method that takes none, raising `ArgumentError` such as `wrong number of arguments (given 1, expected 0; required keyword: to)`. Braces do not help: `send_email({to: x})` is also positional. The fix is to splat at the call site, `send_email(**settings)`, or pass literal keywords. The conversion survives in one direction only: a method that declares **no** keyword parameters still receives `name: value` pairs as a positional Hash. The quiet danger is a method with an optional positional *and* keywords, where the Hash now lands in the positional slot without any error.
code
ruby · 15 linesdef send_email(to:, subject: "(no subject)")
"#{to}: #{subject}"
end
settings = { to: "[email protected]", subject: "Weekly report" }
send_email(**settings)
# => "[email protected]: Weekly report"
begin
send_email(settings)
rescue ArgumentError => e
e.message
# => "wrong number of arguments (given 1, expected 0; required keyword: to)"
endgo deeper
Recall that in Ruby 3 a Hash is keywords only when the call uses name: value pairs or a ** splat, and that braces make it positional.
Explain the exact ArgumentError, which conversion direction survived and why, and the optional-positional case where the Hash lands in the wrong slot without raising.
Describe how you would migrate a codebase: surface 2.7 warnings, fix call sites with **, audit delegating wrappers, and write tests that assert values for the silent cases.
Plan a language upgrade across many services, weighing a flag day against incremental migration and deciding how long shared libraries must keep supporting both Ruby 2 and Ruby 3 callers.
## What changed in Ruby 3.0 A **keyword argument** is one the caller passes as `name: value`; a **positional argument** is matched by its place in the list. Up to Ruby 2.6 the two were blurred: if a method accepted keywords and the last positional argument was a Hash, Ruby quietly treated that Hash as the keywords. Ruby 2.7 printed deprecation warnings for every such call, and **Ruby 3.0 separated them**. Code that warned in 2.7 either raises `ArgumentError` or behaves differently from 3.0 on, and that is still the rule in Ruby 4.0. The rule now fits in one line: **a Hash is keywords only when the call site says so**, with literal `name: value` pairs or a `**hash` splat. ## The failing call, step by step ```ruby def send_email(to:, subject: "(no subject)") "#{to}: #{subject}" end settings = { to: "[email protected]", subject: "Weekly report" } send_email(**settings) # => "[email protected]: Weekly report" send_email(settings) # ArgumentError send_email({ to: "[email protected]" }) # ArgumentError, braces make it positional ``` 1. `send_email(settings)` passes **one positional argument**. 2. The method declares **zero** positional parameters, only keywords. 3. Ruby raises `ArgumentError: wrong number of arguments (given 1, expected 0; required keyword: to)`. The suffix appears because `to:` is required. With only optional keywords the message is plain `wrong number of arguments (given 1, expected 0)`. The fix is at the call site: `send_email(**settings)`. If the Hash comes from parsed JSON or YAML, its keys are Strings, which do not match Symbol keyword names; convert them first with `settings.transform_keys(&:to_sym)`. ## The silent case: an optional positional plus keywords The error is the lucky outcome. The unlucky one produces no error at all: ```ruby def deliver(message, headers = {}, retries: 3) [headers, retries] end deliver("hi", { retries: 5 }) # => [{retries: 5}, 3] deliver("hi", retries: 5) # => [{}, 5] ``` Before 3.0, the braced Hash would have become the keywords and `retries` would be 5. From 3.0 it fills the optional `headers` slot and `retries` keeps its default. Nothing raises; the retry count is simply wrong. This is why a Ruby 3 upgrade needs the 2.7 warnings fixed rather than silenced. ## What still converts, and what does not | Method signature | Call | Result in Ruby 3.0+ | |---|---|---| | `def m(opts)` | `m(to: "a")` | `opts` is `{to: "a"}`: keywords become a positional Hash | | `def m(to:)` | `m({ to: "a" })` | `ArgumentError`: a Hash is not converted into keywords | | `def m(to:)` | `m(**h)` | keywords, as intended | | `def m(arg, **kw)` | `m(to: "a")` | `ArgumentError`: keywords are not used to fill a required positional | | `def m(*args, **kw)` | `m({ to: "a" })` | `args` is `[{to: "a"}]`, `kw` is `{}` | - The **keywords to positional** direction is kept, because countless methods take an options Hash and are called with bare `name: value` pairs. - The **positional to keywords** direction is gone. - An empty splat, `m(**{})`, passes **nothing**, not an empty positional Hash. ## Finding and fixing breakage 1. Run the suite on 2.7 with deprecation warnings on, or read the `ArgumentError` traces on 3.x; each points at a call passing a Hash where keywords were meant. 2. At each call site, change `m(hash)` to `m(**hash)`, or rewrite it with literal keywords. 3. For methods that pass arguments on to other methods with `*args`, the keywords are also lost in transit; those wrappers need explicit `**kwargs` forwarding or, for code that must also run on older Rubies, `ruby2_keywords`. 4. Watch the optional-positional-plus-keywords signatures above; they will not raise, so tests must assert the values. ## Why the change was made The old conversion was ambiguous. A method such as `def deliver(message, headers = {}, retries: 3)` could not tell whether a trailing Hash was meant as headers or as keywords, and Ruby guessed from the Hash's contents. Splitting the two means the call site states the intent and the method never guesses, which is why the rule is strict in both directions that could be confused. ## Common misconceptions - "Wrapping the Hash in braces makes it keywords." Braces make it explicitly positional. - "Ruby 3 stopped accepting `name: value` for options-Hash methods." That direction still works. - "The change only affects code that raises." The silent case is the dangerous one.
- Why did Ruby keep converting keywords into a positional Hash for methods without keyword parameters?Because a vast amount of code declares `def m(options = {})` or `def m(opts)` and is called with bare `name: value` pairs. Converting those still gives the method exactly the Hash it expects and cannot be confused with real keywords, since the method declares none. Removing that direction would have broken working code for no gain; only the ambiguous positional-to-keywords direction was removed.
- A parsed JSON payload is splatted with `send_email(**payload)` and still raises ArgumentError; why?Parsed JSON has String keys, and keyword parameters are matched by Symbol name, so `"to"` never fills `to:`. A method with only explicit keywords cannot bind them, so the call raises `ArgumentError` instead. Convert the keys first with `payload.transform_keys(&:to_sym)`, and preferably whitelist them before splatting untrusted input into a call.
saying these in an interview costs you the question
- Wrapping the Hash in braces makes Ruby treat it as keywords.
- Ruby 3 no longer lets an options-Hash method be called with name: value pairs.
- The separation only matters for calls that raise an error.
- send_email(**settings) and send_email(settings) are equivalent in Ruby 3.
- An empty **{} splat passes an empty Hash as a positional argument.