Ruby 4 Upgrade Notes — Preparing Your Codebase

Ruby 4 Upgrade Notes — Preparing Your Codebase

Diagram showing Ruby version compatibility boundaries across major library ecosystems

Ruby major version upgrades are rare enough that most developers have never done one. Ruby went from 1.8 to 1.9 in 2007, from 1.9 to 2.0 in 2013, and from 2.x to 3.0 in 2020. Each transition broke things. Each transition also made the language meaningfully better in ways that justified the pain — eventually. Ruby 4 continues that tradition, and this guide covers what you need to know before you touch your Gemfile: the key language changes, frozen string behaviour shifts, pattern matching refinements, compatibility boundaries with popular gems, how to structure your testing approach, and the deprecation triage workflow that keeps an upgrade from becoming a month-long detour. This connects to the broader Ruby performance topic, because several of Ruby 4's changes exist specifically to enable runtime optimizations that were impossible under the old semantics. I have been through every major Ruby transition since 1.8 to 1.9 — the one that broke half the ecosystem — and the lesson is always the same: the upgrade itself is mechanical. The preparation is where you either save yourself three weeks or lose them.

What actually changes in Ruby 4

Let's start with what matters. Ruby 4 is not a ground-up rewrite. It is a set of targeted changes that tighten the language's semantics, remove long-deprecated behaviours, and create room for future optimization. The major areas:

Frozen string literals become the default. This is the change with the widest blast radius. In Ruby 3.x, string literals are mutable unless you add # frozen_string_literal: true at the top of a file or freeze them explicitly. In Ruby 4, string literals are frozen by default. Every "hello" is immutable. If your code mutates string literals — str << " world" where str was assigned from a literal — it will raise a FrozenError.

Removed deprecated methods and behaviours. Methods that have carried deprecation warnings since Ruby 3.1 or 3.2 are gone. File.exists?, Dir.exists?, ENV.clone, and a handful of others. If you have been ignoring deprecation warnings in your test output, Ruby 4 turns those warnings into NoMethodError exceptions.

Pattern matching refinements. The in operator and case/in syntax gain additional capabilities, including better deconstruction of custom objects and more predictable behaviour with nil and false values. If you adopted pattern matching early, review the updated edge cases — some will change how your existing patterns match.

Stricter keyword argument handling. The Ruby 2.7-to-3.0 keyword argument separation is complete and permanent. Ruby 4 removes the last compatibility shims. Code that passed a hash as keyword arguments and relied on implicit conversion will fail hard, not just warn.

YJIT as the assumed runtime path. Ruby 4 does not remove the classic interpreter, but YJIT is now enabled by default in many build configurations and several standard library methods are optimized with the assumption that YJIT is active. This matters for performance expectations and for debugging — stack traces and profiling output look different under YJIT.

Frozen strings: the change that breaks the most code

This deserves its own section because it will cause more upgrade failures than everything else combined. Why? Because mutable string operations are everywhere in Ruby codebases, and they are invisible until they raise.

The patterns that break:

# Pattern 1: appending to a literal
msg = "Hello"
msg << ", world"  # FrozenError in Ruby 4

# Pattern 2: gsub! and sub! on literals
template = "Dear {name}"
template.gsub!("{name}", user.name)  # FrozenError

# Pattern 3: force_encoding on a literal
raw = "some bytes"
raw.force_encoding("ASCII-8BIT")  # FrozenError

The fix for all of these is the same: call .dup when you need a mutable copy of a string, or use String.new to create a mutable string from the start. The question is how to find every instance in a large codebase.

Start with the magic comment. Add # frozen_string_literal: true to every file in your project now, while you are still on Ruby 3.x. This gives you the Ruby 4 frozen behaviour on a per-file basis and lets you find breakage incrementally. Run your test suite after each batch of files. This is the single most valuable preparation step.

If you have a large codebase, use rubocop --auto-correct-all --only Style/FrozenStringLiteralComment to add the comment to every file, then run tests and fix failures in batches. Tedious? Yes. But it converts a flag-day surprise into a gradual migration.

Watch for gems that mutate strings. Your application code is only part of the problem. Gems that assume mutable strings — particularly older ones that predate the frozen string discussion — will raise in Ruby 4. You cannot fix these by adding magic comments; you need updated gem versions.

Pattern matching: what changed and why it matters

If you have not adopted pattern matching yet, Ruby 4 is a reasonable time to start. The syntax is stable, the edge cases have been ironed out, and the performance has improved.

Key updates in Ruby 4:

# Improved deconstruct_keys with default handling
case response
in { status: 200, body: String => body }
  process(body)
in { status: 404 }
  handle_not_found
in { status: (500..) }
  alert_on_call
end

The find pattern is now fully optimised. Guard clauses in in expressions handle nil more predictably — a source of subtle bugs in Ruby 3.1 and 3.2 where nil values would fall through patterns in ways that surprised even experienced developers.

For existing pattern matching code, review any pattern that matches against values that could be nil. The semantics are tighter now, which is correct, but may change which branch your code takes.

Compatibility boundaries: gems and dependencies

Every major Ruby version creates a compatibility cliff. Gems that use C extensions need to be recompiled. Gems that rely on removed methods need updates. Gems that depend on specific interpreter internals — and more of them do this than you would expect — may need significant changes.

Your approach:

1. Audit your Gemfile.lock. For every gem, check whether the current version advertises Ruby 4 compatibility. Look at the gem's CI matrix on GitHub. Does it test against Ruby 4? If there is no CI badge and no mention in the changelog, assume it is untested.

2. Identify gems with C extensions. Run bundle list | xargs -I {} gem spec {} extensions 2>/dev/null | grep -v "^$". Any gem with native extensions is a recompile risk. Most will work — the Ruby C API changes between major versions are documented — but test early.

3. Check for gems using eval, instance_variable_set, or method_missing on core classes. These are the gems most likely to break when internal class layouts change. Monkey-patching gems are high-risk in any major upgrade.

4. Pay special attention to your ORM. ActiveRecord, Sequel, ROM — whatever you use, confirm it has a Ruby 4-compatible release. An incompatible ORM blocks your entire upgrade.

Build a compatibility table. It takes an afternoon and saves weeks of surprises.

Preparing your test suite

Your tests are your upgrade safety net. If the net has holes, the upgrade is a high-wire act.

Before you start the upgrade:

Get to green. A test suite that is already failing is useless for detecting upgrade regressions. Fix existing failures first. This is not optional — it is a precondition.

Add coverage for string mutation. If your test suite does not exercise the code paths that mutate strings, frozen string errors will appear in production, not in CI. Specifically, look for any controller or model that builds strings dynamically — email templates, CSV exports, PDF generation, API response assembly.

Run tests with warnings enabled. RUBYOPT=-W:deprecated ruby -e "require 'your_app'" will surface deprecation warnings that your normal test run suppresses. Collect and address every one.

Test with Ruby 4 in CI before merging. Add Ruby 4 to your CI matrix alongside your current Ruby version. Let both run. Fix Ruby 4 failures without breaking Ruby 3.x compatibility. Only drop Ruby 3.x from the matrix after you have deployed Ruby 4 to production and confirmed stability.

The upgrade sequence

Do not change everything at once. The order matters:

  1. Add frozen string literal comments to all files. Deploy this on your current Ruby version. Fix breakage.
  2. Address all deprecation warnings. Deploy. Confirm stability.
  3. Update gems to Ruby 4-compatible versions. Deploy each gem update independently where possible.
  4. Add Ruby 4 to your CI matrix. Fix failures.
  5. Deploy Ruby 4 to staging. Run your full integration suite. Soak for at least 48 hours.
  6. Deploy Ruby 4 to production. Monitor error rates aggressively for 72 hours.

Each step is a separate deployment with its own validation window. Yes, this is slow. It is also how you avoid the "everything broke and we do not know why" failure mode that derails upgrades for weeks.

What usually goes wrong

These are the upgrade failures that actually consume engineering time, based on what I have seen across dozens of Ruby major version transitions:

  1. Frozen string errors in production, not in tests. Code paths that are only exercised under specific conditions — rare API responses, edge-case user input, background job payloads — raise FrozenError for the first time in production. The fix is better test coverage, but the real lesson is: add the frozen string comment now and shake out issues incrementally.
  2. Gem incompatibility discovered mid-upgrade. You are three days into the upgrade, half your code is modified, and you discover a critical gem does not support Ruby 4. Now you cannot go forward or backward cleanly. Audit gems first, always.
  3. YJIT behavioural differences. Code that depends on specific object allocation patterns or garbage collection timing may behave differently under YJIT. This is rare but disorienting when it happens, because the bug looks like a race condition but is actually an optimization artefact.
  4. Keyword argument breakage in metaprogrammed code. If you use define_method or method_missing with methods that accept keyword arguments, the stricter handling in Ruby 4 will surface bugs that have been latent since the Ruby 2.7 transition. These are hard to find because the method definitions are generated at runtime.
  5. Bundler version mismatches. Ruby 4 ships with a newer Bundler. If your deployment uses a different Bundler version, dependency resolution can produce different results between development and production. Pin your Bundler version explicitly.

Upgrade checklist

  • Read Ruby 4 release notes and changelog completely
  • Run full test suite — confirm green baseline on current Ruby
  • Add # frozen_string_literal: true to all application files
  • Run tests — fix all FrozenError failures
  • Enable deprecation warnings and address every one
  • Audit all gems for Ruby 4 compatibility
  • Update or replace incompatible gems (separate deploys)
  • Add Ruby 4 to CI matrix alongside current Ruby
  • Fix all Ruby 4 CI failures without breaking current Ruby
  • Deploy Ruby 4 to staging with production-like data
  • Soak test on staging for minimum 48 hours
  • Deploy to production during low-traffic window
  • Monitor error rates, memory usage and response times for 72 hours
  • Remove old Ruby version from CI matrix after one week of stability

FAQ

How long does a Ruby 4 upgrade take?

For a well-maintained application with frozen string comments already in place and up-to-date gems: a few days of active work plus monitoring time. For a large codebase with no frozen string preparation and outdated dependencies: two to four weeks, with the gem audit consuming most of that time.

Can I skip Ruby 3.3 and go straight from 3.1 to 4.0?

You can, but you will miss the incremental deprecation warnings that each minor version adds. Going through 3.2 and 3.3 first surfaces issues one version at a time instead of all at once. The extra deploys cost less than the debugging time saved.

Will my Rails application work on Ruby 4?

That depends on which Rails version you are running. Check the Rails compatibility matrix — Rails tends to support new Ruby versions in patch releases shortly after launch. If you are on a current Rails version, you should be fine. If you are on Rails 6 or earlier, you likely have bigger problems than the Ruby version.

Should I upgrade Ruby and Rails at the same time?

No. One major dependency upgrade per deployment cycle. Upgrade Ruby first, stabilise, then upgrade Rails if needed. Combining them doubles the failure surface and makes every bug ambiguous — was it a Ruby change or a Rails change?

What about Ruby 4 performance improvements?

Most applications will see modest throughput improvements from tighter YJIT integration and frozen string optimizations. The performance gains are real but not dramatic for typical web workloads. Do not upgrade for performance alone; upgrade because you need to stay on a supported runtime.