Bundler, Gemfiles, and Dependency Management
Objective
gem install alone can't answer "which exact version of every transitive
dependency does this app run on production, and can I reproduce that on my
laptop and in CI?" — that's Bundler's entire job. Every Rails app, and most
non-trivial gems, are built around a Gemfile and a Gemfile.lock, and the
difference between them is the difference between "what we're willing to
run" and "what we're actually running." Treating the lock file as clutter, or
not understanding what bundle exec actually changes about a process, is
where experienced developers from other ecosystems (npm's package-lock.json
looks similar but the mental model isn't identical) end up debugging "works on
my machine" bugs that the tool already solved.
Use Cases
- Pinning exact dependency versions — including transitive ones — so a deploy runs the same code that passed CI, not "whatever satisfied the version constraints today."
- Running a gem's own executable (
rspec,rubocop,rails) against the versions locked for this project, not whatever happens to be the newest version installed globally on the machine. - Isolating dependencies by environment — not loading
pry/factory_botin production, not loading the production-onlypgdriver in a CI job that uses SQLite. - Developing a gem locally against another local gem or an unreleased Git
branch, without publishing anything, via
path:/git:sources. - Packaging and publishing your own gem: turning a
lib/directory into somethinggem installor aGemfileline can pull down.
Deep Dive
The Gemfile is real Ruby, evaluated top to bottom
plaintext# Gemfile source "https://rubygems.org" ruby "3.3.0" gem "rails", "~> 7.2.0" gem "pg", ">= 1.5" group :development, :test do gem "rspec-rails" gem "pry" end group :test do gem "capybara" end gem "my_internal_lib", git: "[email protected]:acme/my_internal_lib.git", branch: "main" gem "local_tool", path: "../local_tool"
Nothing here is a special config format — group do ... end is a method call
taking a block, gem "x", "~> 1.0" is a method call with a version
constraint string. That's why bundler/inline can embed an entire Gemfile
inside a single standalone script (require "bundler/inline"; gemfile do ... end) — it's just Ruby code being evaluated in a different context, no
separate parser involved.
Version operators, from loosest to strictest: >=, <=, >, <, =
(exact), and the one worth memorizing because it's everywhere, ~> — the
"pessimistic" or "twiddle-wakka" constraint. ~> 1.5.2 allows 1.5.x patch
releases but not 1.6.0; ~> 1.5 (one fewer segment) allows any 1.x minor
release but not 2.0.0. It encodes "trust patch/minor releases not to break
me, per semver, but never auto-adopt a major version."
bundle install resolves once; Gemfile.lock is the actual truth
bundle install reads every gem line, resolves a dependency graph that
satisfies all of them simultaneously (including transitive dependencies
declared by those gems), and writes the exact resolved version of every gem
in the tree — direct and transitive — to Gemfile.lock. That lock file, not
the Gemfile, is what gets committed and what every subsequent bundle install on any machine reproduces exactly, as long as the lock file exists.
bundle update re-resolves, but only within the constraints already in the
Gemfile — bundle update rails re-resolves rails and whatever depends on
it, without touching unrelated gems. Deleting Gemfile.lock and running
bundle install forces a full re-resolution from scratch, which is a
different operation from bundle update and can produce a different result
if new releases were published in the meantime.
The practical rule: Gemfile.lock belongs in version control for an
application (you want every developer and every deploy on identical
versions). For a library gem, the convention flips — the .gemspec
declares loose constraints and the lock file is typically not committed,
because a library has to work across a range of versions its consumers might
have locked, not one exact version.
bundle exec: what it actually changes about a process
Installing multiple versions of the same gem is normal — require alone
picks whichever is newest on the load path, which is not necessarily the one
this project's Gemfile.lock resolved to. bundle exec some_command runs
some_command with $LOAD_PATH (and Gem.loaded_specs) constrained to
exactly the versions in Gemfile.lock, before the command's own code ever
runs. That's why rspec run bare can silently pick up a different rspec
version than the one the project locked, while bundle exec rspec cannot.
Inside a Ruby program (rather than a shell command) the equivalent is
require "bundler/setup" at the top of the entry point — it does the same
load-path pinning, in-process. bundle binstubs some_gem generates a wrapper
script in bin/ that already calls Bundler.setup internally, so
bin/rspec behaves like bundle exec rspec without needing the prefix every
time — the mechanism Rails' bin/rails relies on.
Grouping and environment isolation
plaintextgroup :test do gem "capybara" end
Bundler.setup(:default, :production) (roughly what a production boot does)
loads only the gems in those groups — capybara never gets required in a
production process, even though it's listed in the same Gemfile. The env
vars BUNDLE_WITH/BUNDLE_WITHOUT control this at bundle install time
too — a CI job or a production image can skip installing a group entirely
(bundle install --without development test), not merely skip requiring it,
which is what actually keeps a native-extension-heavy testing gem out of a
slim production image.
Alternative sources, and developing against unreleased code
plaintextgem "my_internal_lib", github: "acme/my_internal_lib" # shorthand for git: gem "my_internal_lib", git: "...", ref: "abc123" # pin an exact commit gem "local_tool", path: "../local_tool"
git:/github: sources are re-fetched on bundle update, same as a
registry gem with a loosened constraint — the code isn't frozen until the
lock file pins a specific revision. path: is different in kind: it points
at a real local directory, and edits there are picked up on the next run
with no bundle update needed at all, because there is no fetch step —
Bundler just requires the code in place. That distinction matters when
developing two gems in tandem: path: gives instant feedback, git: (even
pinned to a branch) still requires re-fetching to see a new commit.
Shipping a gem: the shape RubyGems and Bundler both expect
bundle gem my_gem scaffolds the conventional layout:
plaintextmy_gem/ lib/my_gem.rb # top-level require_relative fan-out lib/my_gem/client.rb # MyGem::Client — one class per file lib/my_gem/version.rb exe/my_gem # CLI entry point, no .rb extension my_gem.gemspec # metadata + dependencies spec/ # or test/, depending on --test flag
The file path mirrors the constant namespace — MyGem::Client lives at
lib/my_gem/client.rb — which is also exactly what autoloaders like
Zeitwerk (the one Rails uses) require in order to require a constant just
by referencing it, no explicit require line needed.
The .gemspec is, again, plain Ruby:
plaintextGem::Specification.new do |spec| spec.name = "my_gem" spec.version = MyGem::VERSION spec.authors = ["Acme Corp"] spec.summary = "A short summary" spec.files = Dir["lib/**/*.rb"] spec.add_dependency "faraday", "~> 2.0" spec.add_development_dependency "rspec", "~> 3.13" end
gem build my_gem.gemspec packages a .gem file locally (useful to sanity
check before publishing, or to install with gem install ./my_gem-1.0.0.gem
without touching rubygems.org at all); gem push my_gem-1.0.0.gem publishes
it. Once published, gem yank can pull a bad release, though anyone who
already resolved and locked that version keeps running it until they
re-resolve.
Everyday gem commands worth knowing outside of Bundler
gem list shows what's installed; gem which some_gem prints the path to
the loaded file, the fastest way to answer "which copy of this am I actually
running"; gem open some_gem opens the installed source in $EDITOR —
genuinely useful for reading (or temporarily patching, for local debugging)
a dependency's real implementation instead of guessing from documentation;
gem pristine some_gem restores an installed gem to its original files if a
local edit or an incomplete native-extension rebuild left it broken.
Trade-offs
Gemfile.lockin git is reproducibility, not bureaucracy — the temptation to.gitignoreit because "it just causes merge conflicts" throws away the one artifact that guarantees CI, every developer's machine, and production are running identical dependency versions. Resolve the conflict; don't remove the file.~>trades safety for staying current automatically — a tight~> 1.5.2needs manual bumps for every minor release (safest, most manual); a loose~> 1.5auto-adopts minor releases on the nextbundle update(least manual, trusts the gem's semver discipline). Pick per dependency based on how much you trust its maintainers' versioning.path:gives instant local iteration but ships nothing — great for developing two gems together, useless (and actively wrong) to leave in aGemfilethat gets deployed, since the path won't exist on another machine.git:pinned to a ref is the deployable equivalent when a registry release isn't ready yet.- Committing
Gemfile.lockfor an application vs. omitting it for a library gem is the opposite convention for a reason — an app wants one exact, reproducible dependency set; a library has to remain installable alongside whatever its consumers already locked, so pinning transitive versions in the library itself would fight every app that depends on it. bundle exec(or a binstub) is not optional ceremony — skipping it works fine until two projects on the same machine have locked different versions of the same gem, at which point barerspec/railssilently picks up whichever versionrequirefinds first, and the failure looks unrelated to versioning at all.