Why Rails 5 to 6 Upgrades Stall on Gems, Not on Rails
When we ran this Rails 5 to 6 upgrade on a production app, we expected the hard part to be the framework changes. Breaking changes in Active Record, new routing behavior, that kind of thing. Things you can read about in the official upgrade guide, prepare for, and solve methodically.
The 5 gems and the fix for each, at a glance:
- liquid-rails — Broke: abandoned gem locked liquid to 4.x; Ruby 3.2 removed Object#tainted?. Fix: replaced with a 90-line initializer; removed the gem.
- zero_downtime_migrations — Broke: dead since 2016; broke on Ruby 3.0 kwargs separation. Fix: swapped to strong_migrations (same API, actively maintained).
- chosen-rails — Broke: Rails version check skipped engine load on Rails 6+. Fix: manually registered the gem's asset paths in an initializer.
- kaminari — Broke: internal positional hash args broke under Ruby 3.0 kwargs. Fix: bumped 1.1.1 to 1.2.2; zero application code changes.
- paperclip — Broke: Active Storage parity gap; four broken validators on Rails 6.1. Fix: wrote a compatibility shim; migration deferred to the Rails 7 upgrade.
What we didn’t expect was that the real blocker wouldn’t be Rails at all.
It was five gems that nobody talked about. Gems that had been abandoned. Gems that quietly broke under new Ruby versions. Gems that worked fine on Rails 5 but made assumptions that Rails 6 didn’t accept anymore. We didn’t discover these through the official upgrade guide. We discovered them one by one, through failing tests, cryptic error messages at runtime, and a lot of debugging.
This is what we learned.
The Real Bottleneck in a Rails 5 to 6 Upgrade
When you upgrade Rails, the framework does one job: it changes the version number and gets out of your way. But your app doesn’t run on Rails alone. It runs on a stack of gems, each with its own compatibility matrix, its own maintenance status, and its own assumptions about how Ruby and Rails should work.
Some of those gems break silently. Some break loudly. And some were already unmaintained when you started upgrading.
The five gems in this article represent the patterns we hit most. Not because they’re the only gems with compatibility issues, but because they’re the ones that teach a lesson: most of the work in a Rails upgrade isn’t about Rails. It’s about the ecosystem around it.
1. liquid-rails: The Abandoned Gem That Locked liquid Forever
When we tried updating liquid from 4.0.3 (where it was stuck) to 5.x, we discovered that liquid-rails 0.2.0 existed explicitly to prevent us from doing so. The gem, which hasn’t received updates since 2016, has a gemspec that says:
gem 'liquid', '~> 4.0.0'When we removed liquid-rails, liquid 5 installed without issue. But then 100 test errors appeared saying:
NoMethodError: undefined method 'tainted?' for "string":StringWhat happened? Liquid 4.x (written in 2015) calls Object#tainted?, a method that Ruby 3.2 removed entirely.
How we solved it: Instead of replacing the gem (as you’d normally think), we discovered that the app only used liquid-rails for one thing: registering .liquid as a template file extension. That’s 5 lines of code. We wrote a 90-line initializer (config/initializers/liquid_template_handler.rb) that replicates exactly that functionality, and removed the gem.
Lesson: Deprecated does not equal must replace. Sometimes deprecated means “we’re not maintaining this anymore, you do it,” and that’s viable if the behavior is simple.
2. zero_downtime_migrations: The Inheritance That Broke in Ruby 3.0
zero_downtime_migrations 0.0.7 does exactly what it promises: it catches dangerous migrations before they reach production. Except it’s been dead since 2016 and doesn’t care about keyword arguments.
In Ruby 3.0, they separated positional arguments from keyword arguments. If an old method uses method_missing without forwarding *kwargs, it simply breaks. Like this:
# The old method, ignorant of kwargs def method_missing(name, args) # ... no kwargs forwarding end
# What Ruby 3.0 tries to do db:schema:load # crash, no kwargs forwarding
When we tried running bundle exec rake db:schema:load on Ruby 3.0, the app failed instantly.
How we solved it: strong_migrations does exactly the same thing (catches unsafe migrations) and is actively maintained, supports Ruby 3.x, and has the same API (safety_assured for bypass). Changed the Gemfile, zero changes to application code.
Lesson: When a gem breaks beyond repair, replacing it with a maintained alternative is the right move, but only if the API is identical.
3. chosen-rails: The Gem That Silently Disappeared
chosen-rails 1.5.2 is a wrapper that adds jQuery dropdowns. It works on Rails 3/4/5, but has a surprise in its main file:
if Rails::VERSION::MAJOR <= 5 require 'chosen-rails/engine' end
On Rails 6+, this line never executes. The Engine never loads, and its vendor/assets directories never register with Sprockets. So when any view does require chosen-jquery, it simply doesn’t exist:
ActionView::Template::Error: couldn't find file 'chosen-jquery'But here’s the weird part: it didn’t fail on assets:precompile. It only failed when a real request with a real user tried to render a page that used it. In development, it depended on whether you’d cached the assets.
How we solved it: In config/initializers/assets.rb, we manually added the gem’s paths (if it was installed):
if (spec = Gem.loaded_specs['chosen-rails']) Rails.application.config.assets.paths << File.join(spec.gem_dir, 'vendor', 'assets', 'javascripts') Rails.application.config.assets.paths << File.join(spec.gem_dir, 'vendor', 'assets', 'stylesheets') end
We didn’t replace the gem. We simply made it work on Rails 6+.
Lesson: Sometimes an old gem that works is better than replacing it with something modern if the replacement is risky. Compensate for the breakage with a single line of code.
Staring down a Rails upgrade? If your team is wondering what’s actually going to hurt, our free Rails code audit surfaces these patterns in 2 to 3 days. Architecture, gem compatibility, performance, and security findings. No commitment.Start a free Rails code audit → https://jetrockets.com/offers/free-code-audit
4. kaminari: The Tricky Kwargs Change
kaminari 1.1.1 (where the app was stuck) passed hashes positionally to Paginator.new() and Tag.new(), both of which expect kwargs:
# kaminari 1.1.1 internally
Tag.new({page: 1, per_page: 20}) # Fatal on Ruby 3.0+
# kaminari 1.2.2 internally Tag.new(page: 1, per_page: 20) # Correct
Ruby 3.0 will say: “that’s a hash, not keyword args”, and fail.
How we solved it: A Gemfile bump from 1.1.1 → 1.2.2, zero changes to application code. This is what should have happened years ago.
Lesson: Some bumps are free. This one was.
5. paperclip: Technical Debt With a Roadmap
Paperclip was deprecated when Rails introduced Active Storage. The app uses it for campaign images, and the original decision was to migrate to Active Storage. The migration was written, specs were written, and then it was... reverted.
Why? Because Active Storage in 2019 didn’t have all the features Paperclip provided, especially for video and audio (Paperclip uses FFmpeg). For context on how far the framework has come since, see our rundown of the latest Rails features and updates.
Our move: For the 6.1 upgrade step, we didn’t migrate it. Instead, we wrote a compatibility shim (config/initializers/paperclip_rails61_compat.rb) that patches four validators that Rails 6.1 broke. But this wasn’t a permanent solution. The migration to Active Storage is explicitly scheduled for the 7.0 upgrade step as a dedicated effort.
Lesson: There’s a difference between “deprecated and must be replaced now” and “deprecated and should be replaced in a planned way”. Deferring wasn’t an excuse to avoid work. It was a conscious decision to handle it in the next upgrade cycle when we could dedicate proper time to it. Some tech debt is acceptable if there’s a clear roadmap and timeline.
The Four Categories of Gem Compatibility Failure
What we learned goes beyond these five gems:
- Genuinely dead gems (zero_downtime_migrations) → Replace with a maintained alternative.
- Deprecated but functional gems (paperclip) → Defer intelligently, write shims if needed.
- Gems that broke with language changes (liquid-rails, kaminari) → Bump or replace if it’s one line.
- Gems with broken version checks (chosen-rails) → Fix the path, keep the gem.
It’s not about choosing between “keep everything” and “replace everything”. It’s about choosing the minimum cost for each case.
How to Audit Your Gems Before You Touch Rails
Rails upgrades get framed as a Rails problem. The guides tell you what changed. You read them, fix the code, and move on. But that narrative misses the actual bottleneck: your gem ecosystem. Your gems aren’t managed by the Rails team. They’re managed by dozens of independent maintainers with different capacities, priorities, and time zones. Some stopped maintaining their gems three years ago. Some are actively maintained but don’t have bandwidth for every Ruby version combination. Some solved the problem differently and moved on. That’s not a failure on their part. It’s just the reality of open source.
The real work of upgrading isn’t “follow the Rails upgrade guide.” It’s “audit every gem you depend on, understand its maintenance status, make a deliberate decision about it, and own that decision.” That’s harder than running migrations and refactoring controllers. It’s less glamorous. But it’s where the work actually lives. The five gems in this article weren’t unusual edge cases. They were symptoms of a deeper pattern: most Rails upgrades fail not because Rails changed, but because the gems around Rails didn’t, or changed in incompatible ways, or were simply abandoned.
If you’re planning an upgrade, start with your gems first. Not after the Rails changes. Before. Understand what you depend on, why it’s there, what its maintenance status is, and what happens if it breaks. That’s where you’ll find the real work, and that’s where you’ll spend most of your time.
Frequently Asked Questions: Rails 5 to 6 Gem Compatibility
Should I replace every deprecated gem before a Rails upgrade?
No. Replace the dead ones, defer the deprecated-but-functional ones with a clear roadmap, and patch the ones that broke on a specific language change. The cost of a wholesale gem rip-and-replace usually exceeds the cost of the upgrade itself.
What’s the most common reason a Rails upgrade fails?
Gem incompatibility, not Rails itself. Abandoned gems, Ruby version changes (especially the 3.0 keyword arguments separation), and silent failures in version-check logic account for most stalled upgrades we have seen.
How long does a Rails upgrade actually take?
The framework changes follow the upgrade guide and are predictable. The gem audit and remediation work is where projects overrun, often by 2x to 3x the original estimate when teams have not audited their gem ecosystem first.
What does a Rails gem audit look like?
List every gem, check its last commit date, check its compatibility with your target Ruby and Rails versions, and triage each into one of the four categories at the top of this post. We do this as part of our free Rails code audit.
Share: