Upgrading from Ruby 2.7 to 3.2 Keyword Arguments
The Structural Reality of Keyword Arguments
In the early days of the postal service, a letter could often reach its destination with a surprisingly vague address — perhaps only a name and a town. The postal workers would use their implicit knowledge of the community to route the letter correctly. Over time, however, as the volume of mail grew and automated sorting machines were introduced, the postal service required exact ZIP codes and standardized street addresses. What was once handled by implicit understanding now requires explicit, structured data. While stricter addressing demanded more upfront work from the sender, it enabled vastly faster and more predictable delivery.
We see a very similar evolution when we upgrade a Ruby application from version 2.7 to 3.2. Prior to Ruby 3.0, the Ruby interpreter would use its implicit knowledge to automatically convert a final positional hash into keyword arguments. Introduced conceptually in Ruby 2.7 via deprecation warnings, the strict separation of positional and keyword arguments became enforced in Ruby 3.0 — and remains a strict requirement in Ruby 3.2.
For a large application, this separation often represents significant technical debt. In previous versions, we frequently relied on Ruby’s implicit conversion. Ruby 3 removes this behavior entirely; attempting to bypass this during a migration will result in severe ArgumentError failures in production.
Of course, this is not a matter of a routine syntax find-and-replace. Handling these changes safely requires a structured workflow, prioritizing technical debt remediation to ensure our core application remains stable while the upgrade progresses.
Understanding the Argument Separation
Before we get into the remediation workflow, let’s take a step back and examine what actually changed between the versions. Prior to Ruby 3, passing a positional hash to a method expecting keyword arguments functioned seamlessly.
For example, if you had a deployment script running under Ruby 2.6, you might define a method and call it like this:
$ irb
irb(main):001:0> def configure_deployment(server, options: {})
irb(main):002:1> puts "Server: #{server}, Options: #{options}"
irb(main):003:1> end
=> :configure_deployment
irb(main):004:0> configure_deployment("production", { options: { secure: true } })
Server: production, Options: {:secure=>true}
The interpreter implicitly converted the final positional hash argument into the expected keyword arguments. In Ruby 3.2, however, arguments must match exactly: positional arguments for positional parameters, and keyword arguments for keyword parameters. Providing a hash where keyword arguments are expected raises an error:
$ irb
irb(main):001:0> def configure_deployment(server, options: {})
irb(main):002:1> puts "Server: #{server}, Options: #{options}"
irb(main):003:1> end
=> :configure_deployment
irb(main):004:0> configure_deployment("production", { options: { secure: true } })
ArgumentError (wrong number of arguments (given 2, expected 1))
When we need to update our code for Ruby 3.2, there are two major approaches. The first is to provide keyword arguments explicitly by removing the curly braces.
$ irb
irb(main):005:0> configure_deployment("production", options: { secure: true })
Server: production, Options: {:secure=>true}
The second option is to explicitly expand an existing hash with the double splat (**) operator.
$ irb
irb(main):006:0> config_hash = { options: { secure: true } }
=> {:options=>{:secure=>true}}
irb(main):007:0> configure_deployment("production", **config_hash)
Server: production, Options: {:secure=>true}
Generally speaking, the first option is preferable when we are writing literal hashes directly in the method call. The second option, though, will often make more sense if we are passing a variable that contains a hash constructed earlier in our program’s execution.
Diagnosing Code Complexity
One may wonder: can we write a static analysis script to find all of these implicit conversions? The answer is, unfortunately, no.
Strictly speaking, because Ruby is dynamically typed, static analysis tools cannot always determine if a variable holds a hash that will be implicitly converted at runtime. Consequently, we must find these conversions through actual execution of the code rather than static analysis.
The most effective strategy relies on the intermediate step provided by the Ruby core team. Before committing to Ruby 3.2, we must first upgrade the application to Ruby 2.7. Under Ruby 2.7, implicit conversions still function, but the interpreter emits deprecation warnings whenever they occur:
warning: Using the last argument as keyword parameters is deprecated; maybe ** should be added to the call
Capturing these warnings is critical for assessing the scope of the required technical debt remediation.
A Structured Workflow for Large Codebases
Attempting a monolithic transition across a large codebase often introduces regressions. Instead, we should break the upgrade into manageable phases.
Target Ruby 2.7 in Testing
First, configure your Continuous Integration pipelines to run your test suite under Ruby 2.7. This allows us to retain functionality while surfacing the necessary warnings.
Capture and Aggregate Deprecation Warnings
By default, Ruby 2.7 outputs a deprecation warning to stderr whenever implicit keyword argument conversion takes place. To find every conversion, we can configure our test suite to log or aggregate these warnings systematically.
For example, we might place the following configuration into our spec_helper.rb or test_helper.rb file:
# Enable deprecation warnings during test execution
Warning[:deprecated] = true
Iterative Remediation
Next, we address the deprecation warnings incrementally. This approach avoids halting ongoing product development. We can allocate specific blocks of time to update method signatures and calls, utilizing the double splat operator or explicit keyword arguments as required.
This phase is critical. We must evaluate the warnings contextually and update the code to accurately reflect the intended argument structure. For instance, if a method was designed to accept an optional positional hash, applying the double splat operator to the method call might satisfy the Ruby 3.2 keyword argument requirement, but inadvertently change the application’s internal logic.
Transitioning to Ruby 3.2
Only once the test suite executes completely green — and is entirely free of keyword argument deprecation warnings under Ruby 2.7 — should we upgrade the application to Ruby 3.2. This ensures that the migration to the new version is a predictable process rather than a speculative deployment.
Long-Term Technical Health
Addressing keyword argument separation explicitly leads to more predictable and maintainable code. The investment in resolving these warnings pays off in post-upgrade support, ensuring our application remains stable and easier to maintain.
By utilizing a structured path, we can avoid unnecessary disruption and reinforce the long-term viability of our codebase.
Sponsored by Durable Programming
Need help maintaining or upgrading your Ruby on Rails application? Durable Programming specializes in keeping Rails apps secure, performant, and up-to-date.
Hire Durable Programming