RubyGems and Bundler
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
Gemfileas Ruby, collecting everygemcall and its version constraint. - If a matching
Gemfile.lockalready 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
GemfileandGemfile.lockfor 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
groupso production installs skip them entirely. - Run
bundle outdatedperiodically 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
Gemfilefor a small command-line tool that depends on thethorgem for argument parsing, and onrspeconly for testing. Putrspecin a:testgroup. - Using
Gem::VersionandGem::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.lockand two developers runbundle installa 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 intoGemfile.lock. bundle installresolves and installs;bundle execruns 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.lockfor applications, always preferbundle execover bare commands, and never hand-edit the lock file.
