The Ruby Upgrade Guide You Wish You'd Read Years Ago

A comprehensive mental model of how Ruby, RubyGems, Bundler, and Rails fit together — and why upgrading Ruby is far less scary than it seems. Covers the five-layer architecture of a Rails app's dependency stack, how version managers work, the difference between bundle install and bundle update, and a step-by-step walkthrough of a real Ruby upgrade.

Ruby RubyGems Bundler Rails version management asdf dependency resolution Gemfile Gemfile.lock Ruby upgrades by piyushagarwal5050

You've been building Rails apps for years. You can scaffold a resource, write a migration, debug an N+1 query. But then someone says "we need to upgrade Ruby" and a little knot forms in your stomach.

What if it breaks everything? What even happens when you "upgrade Ruby"? Why do I need to run bundle install again? Didn't I already install these gems?

If that's you, this tutorial is going to untangle all of it. Not by giving you a checklist to follow blindly, but by building a mental model of how the pieces fit together — so that the next time you face an upgrade, you'll actually understand what's happening.

The Problem: You're Driving a Car You've Never Looked Under the Hood Of

Here's something that happens to almost every Rails developer at some point. You clone a project, run bundle install, and 147 gems get installed. You didn't ask for 147 gems. You asked for maybe 20 in your Gemfile. Where did the rest come from? Why does changing one line in your Gemfile sometimes trigger a cascade of updates? And why, when you install a new Ruby version, do all your gems just... vanish?

These aren't trivia questions. Not understanding this stuff is the root of most upgrade anxiety. So let's fix that.

The Shape of the Thing: Five Layers, Each Doing One Job

Picture your Rails app as a building. It has a foundation, walls, plumbing, wiring, and furniture. Each layer depends on the one below it, and each can (mostly) be swapped out independently.

  ┌─────────────────────────────┐
  │     Your Rails Application   │  ← The furniture. What users see.
  ├─────────────────────────────┤
  │   Rails + Other Gems         │  ← The wiring and plumbing. Functionality.
  ├─────────────────────────────┤
  │        Bundler                │  ← The foreman. Makes sure the right
  │                               │    materials are used for THIS building.
  ├─────────────────────────────┤
  │       RubyGems                │  ← The hardware store. Where you get parts.
  ├─────────────────────────────┤
  │         Ruby                  │  ← The foundation. Everything sits on this.
  └─────────────────────────────┘

The critical insight is this: each Ruby installation is a completely isolated world. When you "upgrade Ruby," you're not modifying your existing Ruby — you're building a brand new foundation next to the old one. The old one is still there, untouched. This is the single most important thing to internalize, and it dissolves most upgrade fear instantly.

Let's go layer by layer.

Layer 1: Ruby — The Foundation That Contains Everything

Ruby is the programming language interpreter. It reads your .rb files and executes them. That's it. But the way it's installed on your system has huge implications for everything above it.

Every Ruby Version Is Its Own Universe

When you install Ruby 3.2.3 via a version manager like asdf, here's what actually lands on your disk:

~/.asdf/installs/ruby/3.2.3/
├── bin/           # The ruby executable, irb, gem, etc.
├── lib/           # Ruby's standard library
└── lib/ruby/gems/ # A dedicated directory for gems

And if you also have Ruby 3.1.4 installed:

~/.asdf/installs/ruby/3.1.4/
├── bin/
├── lib/
└── lib/ruby/gems/  # Completely separate gem directory

These two Rubies know nothing about each other. They share nothing. The gems you installed under 3.1.4 don't exist under 3.2.3. This is by design — it's what makes it safe to experiment with new versions without nuking your working setup.

Why This Matters Right Now

This is why, after switching Ruby versions, you get errors like Could not find 'rails'. It's not broken — you just moved into a new house and haven't unpacked the boxes yet. Running bundle install unpacks those boxes.

Version Managers: The Switchboard

Tools like asdf, rbenv, or rvm do something deceptively simple: they change which Ruby your terminal points to. That's fundamentally all they do.

asdf install ruby 3.2.3     # Build a new Ruby universe
asdf local ruby 3.2.3       # Point THIS project at it
asdf global ruby 3.2.3      # Make it the default everywhere

When you run asdf local ruby 3.2.3, it writes 3.2.3 to a .ruby-version file in your project directory. Next time you cd into that folder, asdf sees the file and adjusts your PATH so that ruby, gem, bundle, and everything else point to the 3.2.3 installation. No magic. Just PATH manipulation.

Layer 2: RubyGems — The Hardware Store

RubyGems is Ruby's built-in package manager. It ships with every Ruby installation. Its job is straightforward: download gems from rubygems.org and install them into the current Ruby's gem directory.

gem install rails          # Grab the latest Rails
gem install rails -v 7.2.0 # Grab a specific version
gem list                   # What's installed in THIS Ruby?
gem env                    # Where is everything? (Very useful for debugging)

The Detail That Trips People Up: Multiple Versions Coexist

When you gem install rails twice — once for 7.1.0 and once for 7.2.0 — both versions sit side by side:

gems/
├── rails-7.1.0/
├── rails-7.2.0/
└── rails-7.2.2.1/

By default, RubyGems loads the latest version. So rails -v would show 7.2.2.1. But you can be explicit:

rails _7.1.0_ new myapp       # Use the underscore trick for a specific version
gem exec -v 7.2.0 rails new myapp  # Or use gem exec

This is fine for one-off commands. But for a real project with dozens of gems, each needing specific versions? That's where you need something smarter.

Layer 3: Bundler — The Foreman Who Keeps Everything in Line

Here's the problem Bundler solves. Imagine your app needs:
- rails ~> 7.2.0 (any 7.2.x)
- devise ~> 4.9 (any 4.9.x)
- pg ~> 1.5

Sounds simple. But Rails itself depends on 40+ other gems, each with their own version constraints. Devise depends on bcrypt, warden, orm_adapter. Some of these sub-dependencies overlap. What if Rails wants activesupport 7.2.2.1 but some other gem wants activesupport >= 6.0, < 7.1?

Resolving this web of constraints is Bundler's entire reason for existing. And it does it using two files that work as a team.

The Two Files: A Wishlist and a Receipt

Gemfile — your wishlist. What you want, with some flexibility:

source "https://rubygems.org"
ruby "3.2.3"

gem "rails", "~> 7.2.2"     # Any 7.2.x, at least 7.2.2
gem "pg", "~> 1.1"           # Any 1.x, at least 1.1
gem "devise"                 # Whatever works

Gemfile.lock — the receipt. What Bundler actually resolved:

rails (7.2.2.1)
  actioncable (= 7.2.2.1)
  actionmailbox (= 7.2.2.1)
  actionmailer (= 7.2.2.1)
  ...
pg (1.5.4)
devise (4.9.3)
  bcrypt (~> 3.0)
  orm_adapter (~> 0.1)
  ...

The Gemfile.lock is how your entire team (and your production server) ends up with the exact same gem versions. Without it, "works on my machine" becomes a daily occurrence.

Always commit both files. The Gemfile expresses your intent. The Gemfile.lock guarantees reproducibility.

The Commands That Actually Matter

Here's where a lot of confusion lives. bundle install and bundle update sound similar but behave very differently:

bundle install — "Give me what the lockfile says."
It reads Gemfile.lock and installs those exact versions. If there's no lockfile, it resolves everything fresh and creates one. This is your everyday command. It's safe and predictable.

bundle update — "Re-resolve everything from scratch."
It ignores the lockfile, looks at your Gemfile constraints, and finds the newest versions that satisfy all constraints. Then it writes a new lockfile. This is how you get newer gem versions — but it can change a lot of things at once.

bundle update rails — "Re-resolve just Rails and its dependencies."
A safer middle ground. Only Rails and the gems that directly depend on it get re-resolved.

bundle update rails --conservative — "Re-resolve only Rails. Leave its dependencies alone."
The most surgical option. Bundler will only bump the named gem's version, keeping all its transitive dependencies at their current lockfile versions unless a change is strictly required to satisfy the new version's constraints. Reach for this when you want a patch bump without risking a cascade of downstream changes. You can also name multiple gems — bundle update rails devise --conservative — to bump a few specific gems while still keeping the blast radius minimal.

bundle exec rails server — "Run this command using the exact gems from my lockfile."
This is crucial. Without bundle exec, Ruby might load whatever gem version it finds first. With it, you're guaranteed to use the versions your project expects.

Other commands worth knowing:

bundle check              # "Are all my lockfile gems already installed?"
bundle outdated           # "Which gems have newer versions available?"
bundle show devise        # "Where on disk is this gem installed?"
bundle doctor             # "Is anything obviously broken?"

Layer 4: Rails — A Gem, But a Very Big One

Here's something that surprises people: Rails isn't one thing. It's a meta-gem — a gem whose only job is to depend on other gems:

rails
├── actioncable        (WebSockets)
├── actionmailer       (Email)
├── actionpack         (Routing, controllers)
├── actionview         (Templates)
├── activejob          (Background jobs)
├── activemodel        (Model behavior without a database)
├── activerecord       (ORM)
├── activestorage      (File uploads)
├── activesupport      (Utilities used everywhere)
├── railties           (Generators, CLI, glue)
└── ... and more

This is why bundle install on a fresh Rails project installs 50+ gems even though your Gemfile only lists a handful. Each Rails component pulls in its own dependencies, which pull in their dependencies. It's dependencies all the way down.

The practical takeaway: when you "upgrade Rails," you're really upgrading a whole constellation of gems in lockstep.

The Independence Question: What Can You Upgrade Separately?

This is where the mental model pays off. Not everything is tangled together. Some things can be upgraded freely; others have constraints.

Fully Independent

Ruby can be upgraded without touching anything else. Install a new version, switch to it, run bundle install, and your existing gem versions (from the lockfile) get reinstalled under the new Ruby. Your old Ruby is still there if you need to switch back.

RubyGems can be upgraded within any Ruby installation:
bash
gem update --system

Newer versions are backward compatible. This is almost always safe.

Bundler is just a gem:
bash
gem install bundler
bundle update --bundler # Update the Bundler version recorded in Gemfile.lock

Also extremely backward compatible.

Constrained by Relationships

Rails has minimum Ruby version requirements:
- Rails 7.0+ needs Ruby 2.7+
- Rails 7.2+ needs Ruby 3.1+

So you can't always upgrade Rails without also upgrading Ruby. Check the Rails release notes before attempting.

Individual gems may have their own Ruby or Rails version constraints. A gem that uses Ruby 3.1 syntax won't work on Ruby 3.0. A gem that hooks into Rails internals might break with a new Rails version.

The Rule of Thumb

Upgrades flow upward through the stack. You can always upgrade the foundation (Ruby) without touching anything above it — you just need to reinstall. But upgrading something in the middle (like Rails) might require a newer foundation.

The Upgrade: What Actually Happens, Step by Step

Let's walk through a Ruby upgrade from 3.1.4 to 3.2.3 and demystify every step.

Step 1: Install the new Ruby

asdf install ruby 3.2.3

A new, empty Ruby universe appears at ~/.asdf/installs/ruby/3.2.3/. Your current Ruby (3.1.4) is completely unaffected. Nothing in your project has changed yet.

Step 2: Point your project at it

asdf local ruby 3.2.3

This writes 3.2.3 to .ruby-version. Now when you type ruby, you get the new version. But you have zero gems installed (except the handful that ship with Ruby).

Step 3: Install Bundler

gem install bundler

This installs Bundler into the new Ruby's gem directory. Each Ruby needs its own Bundler.

Step 4: Tell your Gemfile

# Gemfile
ruby "3.2.3"

This isn't strictly required at runtime, but it's a signal to Bundler (and to your hosting platform) about which Ruby this project expects.

Step 5: Install your gems

bundle install

Bundler reads your existing Gemfile.lock and installs those exact same versions — but compiled for Ruby 3.2.3. Gems with C extensions (like pg, nokogiri, ffi) get recompiled. Pure Ruby gems just get copied.

Step 6: Verify

ruby --version           # 3.2.3 ✓
bundle exec rails --version  # Same Rails version as before ✓
bundle exec rspec        # Run your tests

The Safety Net

If anything goes wrong:

asdf local ruby 3.1.4   # Switch back in one command

Your old Ruby, with all its gems still installed, is right where you left it. This is why isolated Ruby installations matter — rollback is instantaneous.

When Things Go Wrong: Debugging With Understanding

Instead of memorizing error messages, let's understand the patterns behind them.

"Your Gemfile.lock was generated with Bundler 2.3.11..."

What's happening: The Bundler version recorded in your lockfile doesn't match what's installed.

Why it happens: Different team members or CI environments might have different Bundler versions.

The fix:
```bash
bundle update --bundler # Update the lockfile's Bundler version

OR

gem install bundler -v 2.3.11 # Install the version the lockfile expects
```

Native extension build failures after Ruby upgrade

What's happening: Gems with C code (like pg, nokogiri, mysql2) need to be compiled against your specific Ruby version. The old compiled versions from your previous Ruby don't work.

Why it happens: C extensions link against Ruby's internal headers, which change between versions.

The fix:
bash
bundle install # Usually sufficient — recompiles automatically
gem pristine --all # Nuclear option: forces recompilation of everything

"Could not find gem X" after switching Ruby versions

What's happening: You switched to a Ruby version that doesn't have your gems installed yet.

Why it happens: Each Ruby has its own gem directory. New Ruby = empty gem directory.

The fix:
bash
gem install bundler && bundle install

Different gem versions loading than expected

What's happening: You're running a command without bundle exec, so RubyGems loads the latest installed version instead of the one your project needs.

The fix: Always use bundle exec:
bash
bundle exec rails server
bundle exec rspec
bundle exec rake db:migrate

Or use binstubs (bin/rails, bin/rspec) which do this automatically.

The Diagnostic Toolkit

When something feels wrong, these four commands tell you the state of your world:

ruby --version       # Which Ruby am I running?
which ruby           # Where is it installed? (Reveals if version manager is working)
gem env              # Where are gems going? What paths are configured?
bundle --version     # Which Bundler am I using?
bundle check         # Are all gems from my lockfile installed?

gem env is especially underrated. It shows you exactly where gems are being installed, what Ruby version they're targeting, and what paths are in play. When something is installing to the wrong place, this command reveals it immediately.

The Fear Factor: Why Upgrades Are Safer Than You Think

Most upgrade anxiety comes from four misconceptions. Let's retire them:

Misconception 1: "Upgrading Ruby might break my current setup."
It can't. New Ruby versions are installed alongside old ones. Your old Ruby and all its gems remain untouched. You're adding a new option, not replacing the existing one.

Misconception 2: "All my gems will disappear."
They don't disappear — they just live in the old Ruby's directory. bundle install under the new Ruby reads your lockfile and installs the same versions. You get the same gems; they're just compiled fresh.

Misconception 3: "I need to understand C compilation to upgrade."
Bundler and RubyGems handle compilation automatically for almost every gem. The rare failures are usually missing system libraries (libpq-dev, libxml2-dev), not Ruby problems.

Misconception 4: "If something goes wrong, I'm stuck."
Switching back is one command: asdf local ruby [old-version]. Your old environment is exactly as you left it.

A Healthy Upgrade Habit

Here's the process that makes upgrades boring (in the best way):

  1. Branch. Create a git branch for the upgrade.
  2. Install. Install the new Ruby version alongside your current one.
  3. Switch. Point your project at the new version.
  4. Bundle. Run bundle install and fix any compilation issues.
  5. Test. Run your full test suite.
  6. Stage. Deploy to a staging environment.
  7. Ship. Merge and deploy to production.
  8. Clean up (optional). Remove the old Ruby version once you're confident: bash asdf uninstall ruby 3.1.4

The key insight: steps 1-4 are completely reversible with zero risk. You're not committing to anything until you merge the branch.

Challenge

Here's something to try right now that will make all of this concrete:

  1. Run gem env and find the GEM PATHS section. Can you trace how they correspond to your current Ruby version?
  2. Run asdf list ruby (or rbenv versions). How many Ruby universes do you have on your machine?
  3. Pick any gem in your Gemfile.lock. Run bundle show [gemname]. Look at the path — can you see the Ruby version embedded in it?
  4. If you're feeling bold: install a different Ruby version with asdf install ruby [version], switch to it with asdf shell ruby [version] (temporary, just for this terminal session), and run gem list. Notice how it's a completely different set of gems. Then close the terminal and everything's back to normal.
  5. Run bundle open [gemname] on a gem you use daily (like devise or rails). This opens the gem's actual source code in your editor. Poke around. You'll realize that gems aren't magic black boxes — they're just Ruby files organized in directories, written by people like you. Knowing you can read (and even temporarily edit) gem source code is a superpower for debugging.

These five steps take ten minutes and will do more for your confidence than any article ever could.