RubyGems and Bundler

Published by Programming Line in Ruby. Last updated .

RubyGems is Ruby’s built-in package manager — the tool that lets you install, publish, and load reusable libraries called gems. Bundler is the companion tool that manages exactly which gems, and which versions of those gems, a specific project depends on, so the same code runs identically on your laptop, your teammate’s machine, and your production server. Together they answer two different questions: RubyGems answers “how do I get this library onto my machine,” and Bundler answers “how do I guarantee my whole team and every deploy use the exact same set of libraries.”

Overview / How it works

A gem is a packaged unit of Ruby code: a .gem file, which is really just a tarball containing your library’s source files plus a gemspec — metadata describing the gem’s name, version, author, and its own dependencies. When you run gem install some_gem, RubyGems downloads that .gem file from a source (by default rubygems.org), unpacks it into a gems directory on your system, and registers it in a local index. From then on, calling require "some_gem" in any Ruby script can find and load it, because RubyGems patches Ruby’s Kernel#require early in the interpreter’s boot process to also search every installed gem’s lib/ directory, not just the standard library.

Multiple versions of the same gem can be installed side by side — RubyGems does not overwrite an older version when you install a newer one. That flexibility is powerful but also the root of a real problem: if project A needs rails 6.1 and project B on the same machine needs rails 7.1, plain gem install and bare require have no idea which version a given script actually wants. Left alone, you get “works on my machine” bugs where a script accidentally picks up whichever version happened to load first.

Bundler solves this by making dependencies explicit and reproducible. Every project gets a Gemfile — plain Ruby, evaluated as a small DSL — that lists exactly which gems the project needs and, ideally, an acceptable version range for each. Running bundle install reads that file, resolves a complete dependency graph (including every gem’s own dependencies, and their dependencies, recursively), installs anything missing, and writes the exact result — every gem and its exact resolved version — into a second file, Gemfile.lock. That lock file is the reproducibility guarantee: as long as it’s present, bundle install on any other machine installs the identical versions, byte for byte, no matter what else is installed globally or what has been released on rubygems.org since. Modern Ruby (3.2+) ships with both rubygems and bundler built in, so no separate installation step is needed to get started.

Syntax

The two tools have separate command-line interfaces that work together. The most common RubyGems commands:

Command What it does
gem install NAME Downloads and installs a gem globally
gem list Lists gems installed on the system
gem uninstall NAME Removes an installed gem
gem list -r NAME Shows available remote versions of a gem

A Gemfile uses a small DSL of plain Ruby method calls:

Line Meaning
source "https://rubygems.org" Where to fetch gems from
gem "name" Depend on any version of a gem
gem "name", "~> 3.1" Depend on a version constrained by the pessimistic operator
group :test do ... end Gems only needed in a named group (e.g. loaded only in test/dev)

And the everyday Bundler commands:

Command What it does
bundle install Resolves and installs everything the Gemfile lists, writes/updates Gemfile.lock
bundle exec COMMAND Runs COMMAND with exactly the gem versions from Gemfile.lock on the load path
bundle add NAME Adds a gem to the Gemfile and installs it in one step
bundle update NAME Upgrades one gem (and only what’s necessary) to a newer allowed version
bundle outdated Lists gems that have newer versions available than what’s locked

Examples

Example 1: A gem you already have — require and use it

json ships as a default gem with Ruby itself, so you can see the require-and-use pattern without installing anything:

require "json"

data = { name: "Ada", language: "Ruby", awesome: true }
json_string = JSON.generate(data)
puts json_string

parsed = JSON.parse(json_string)
puts parsed["name"]
puts parsed.class
Output:
{"name":"Ada","language":"Ruby","awesome":true}
Ada
Hash

require "json" loads the library the same way it would for any installed gem. JSON.generate turns the hash into a JSON string, and JSON.parse turns it back into a Ruby Hash whose keys are strings (not symbols) by default.

Example 2: A Gemfile for a small project

This is a typical Gemfile for a Rails app with a database and a test suite:

source "https://rubygems.org"

ruby "3.2.2"

gem "rails", "~> 7.1.0"
gem "pg", "~> 1.5"

group :development, :test do
  gem "rspec", "~> 3.12"
  gem "pry"
end

group :production do
  gem "puma"
end

The ruby line pins the Ruby version itself. ~> 7.1.0 allows patch upgrades (7.1.1, 7.1.2, …) but not a jump to 7.2. Gems in the :development, :test group only load in those environments — running the app in production never pulls in rspec or pry at all.

Example 3: Running bundle install and bundle exec

Given the Gemfile above, a typical terminal session looks like this:

$ bundle install
Fetching gem metadata from https://rubygems.org/..........
Resolving dependencies...
Installing puma 6.4.2
Installing pg 1.5.4
Installing rspec 3.12.0
Installing rails 7.1.3
Bundle complete! 15 Gemfile dependencies, 78 gems now installed.

$ bundle exec rspec
..

Finished in 0.02 seconds
2 examples, 0 failures

bundle install only prints “Installing” lines for gems it actually had to fetch — anything already satisfied by Gemfile.lock is skipped. bundle exec rspec runs the rspec binstub with the load path restricted to exactly the versions recorded in the lock file, even if a different rspec version happens to be installed globally on the machine.

Example 4: Comparing gem versions programmatically

RubyGems’ own version-comparison classes are ordinary Ruby objects you can use directly:

require "rubygems"

v1 = Gem::Version.new("2.4.8")
v2 = Gem::Version.new("2.10.0")

puts v1 < v2
puts [v1, v2].max

requirement = Gem::Requirement.new("~> 2.4.0")
puts requirement.satisfied_by?(v1)
puts requirement.satisfied_by?(v2)
Output:
true
2.10.0
true
false

Gem::Version compares versions numerically segment by segment, so 2.10.0 is greater than 2.4.8 even though “10” would sort before “4” as plain text — this is exactly why Gem::Version.new("2.10.0") > Gem::Version.new("2.4.8") and not the reverse. Gem::Requirement.new("~> 2.4.0") — the same pessimistic operator used in a Gemfile — means “at least 2.4.0, but less than 2.5.0,” which 2.4.8 satisfies and 2.10.0 does not.

How it works step by step

When you run bundle install, Bundler:

  • Parses the Gemfile as Ruby, collecting every gem call and its version constraint.
  • If a matching Gemfile.lock already exists and the Gemfile hasn’t changed, it reuses those exact locked versions rather than re-resolving anything.
  • Otherwise, it runs a dependency resolver that finds one version of every gem — including gems your gems depend on — that simultaneously satisfies every stated constraint. This is a real constraint-satisfaction problem: two gems can each be fine on their own but have no version overlap that works together, which is what a “could not find compatible versions” error means.
  • Downloads and installs whatever isn’t already present locally.
  • Writes the fully resolved graph — every gem, its exact version, and which other gems required it — into Gemfile.lock.

When you then run bundle exec some_command, Bundler loads Gemfile.lock, and before your command’s own code runs, it restricts what require is allowed to find to precisely those locked gems and versions. That is the entire trick behind Bundler: it does not change how require works, it changes what require can see.

Common Mistakes

1. Running a script without bundle exec

If a project has a Gemfile but you run ruby app.rb directly instead of bundle exec ruby app.rb, Ruby falls back to whatever gem versions happen to be active globally — which may not match Gemfile.lock at all, and can produce confusing errors or silently different behavior on someone else’s machine.

$ ruby app.rb                 # wrong: uses whatever gems are globally active
$ bundle exec ruby app.rb     # correct: uses exactly the locked versions

2. Confusing gem with require

Kernel#gem only activates a version constraint on the load path — it does not load any code. Forgetting the actual require leaves the library’s constants undefined:

gem "json", "~> 2.6"

data = JSON.generate({ ok: true })   # NameError: uninitialized constant JSON
puts data
require "json"

data = JSON.generate({ ok: true })
puts data

The corrected version prints {"ok":true}. In practice, Bundler’s own Bundler.require (typically called once near the top of an app) does this loading step automatically for every gem in the Gemfile, so most Bundler-managed apps never call gem or require by hand for their dependencies at all.

3. Not committing Gemfile.lock for an application

For an application (as opposed to a gem library you’re publishing), Gemfile.lock should be committed to version control. Skipping it means every fresh bundle install — on a teammate’s laptop, in CI, in production — re-resolves versions independently and can silently pick up newer releases, defeating the entire point of Bundler.

4. Hand-editing Gemfile.lock

The lock file is generated output, not something to edit directly. Changing a version number by hand doesn’t install that version or verify it’s compatible with everything else in the graph — it just makes the file lie about what’s actually resolved. Change the constraint in the Gemfile instead and re-run bundle install or bundle update.

Best Practices

  • Commit both Gemfile and Gemfile.lock for applications so every environment installs identical gem versions.
  • Use the pessimistic operator (~>) for version constraints — it allows safe patch/minor updates while blocking breaking major-version jumps.
  • Always run app commands through bundle exec (or generated binstubs) rather than calling them bare, especially on shared machines or CI.
  • Group development- and test-only gems with group so production installs skip them entirely.
  • Run bundle outdated periodically and upgrade deliberately, rather than letting dependencies drift for years.
  • Avoid installing gems with elevated privileges system-wide; use a version manager (rbenv, rvm, asdf) so gems install to a user-writable directory.
  • For a gem you publish (a library, not an app), keep the .gemspec‘s own dependency constraints loose — pin tightly in the consuming application’s Gemfile instead.

Practice Exercises

  • Write a Gemfile for a small command-line tool that depends on the thor gem for argument parsing, and on rspec only for testing. Put rspec in a :test group.
  • Using Gem::Version and Gem::Requirement, write a script that checks whether the version string "1.9.3" satisfies the requirement "~> 1.8", and prints a message saying so. (Hint: with only one digit after the decimal in "~> 1.8", the allowed range is much wider than "~> 1.8.0" would be.)
  • In your own words, explain what could go wrong if a team never commits Gemfile.lock and two developers run bundle install a week apart.

Summary

  • RubyGems is Ruby’s package manager: it installs, uninstalls, and loads gems, and can hold multiple versions of the same gem side by side.
  • A gem bundles code plus a gemspec describing its name, version, and dependencies.
  • Bundler manages a project’s dependencies declaratively via a Gemfile, resolving a full dependency graph and locking the exact result into Gemfile.lock.
  • bundle install resolves and installs; bundle exec runs a command restricted to exactly the locked versions.
  • The pessimistic operator ~> is the idiomatic way to constrain gem versions — the number of digits specified controls how wide the allowed range is.
  • Commit Gemfile.lock for applications, always prefer bundle exec over bare commands, and never hand-edit the lock file.