Gems and Bundler
Gemfile and Gemfile.lock, semantic versioning with ~>, and building a simple gem conceptually.
What is a gem?
A gem is a packaged, distributable Ruby library — the unit of reuse published to and downloaded from RubyGems.org, conceptually the same role npm packages play for JavaScript or crates play for Rust. A gem bundles Ruby code, metadata (name, version, dependencies), and optionally native extensions, all installable with one command:
gem install rails
gem list # every gem installed for the current Ruby version
gem uninstall rails
Installing gems one at a time like this works for quick experiments, but real projects need something stronger: a way to declare which gems (and which versions of them) a project depends on, so every developer — and the production server — installs the exact same set.
The Gemfile
Bundler (itself a gem, and included with modern Ruby) reads a project's Gemfile and resolves a consistent set of compatible gem versions:
# Gemfile
source 'https://rubygems.org'
gem 'rails', '~> 7.1.0'
gem 'pg', '>= 1.5'
group :test do
gem 'rspec'
end
group :development do
gem 'pry'
end
bundle install
bundle install reads the Gemfile, computes a version of every listed gem (and all of their dependencies) that satisfies every constraint simultaneously, installs them, and writes the exact resolved versions to Gemfile.lock. group blocks scope certain gems to specific environments — a pry debugging gem has no business being installed on a production server, and group :development keeps it out of that environment's install by default.
Gemfile.lock
Gemfile states acceptable ranges ("rails, something compatible with 7.1.0"); Gemfile.lock records the exact version of every gem, direct and transitive, that Bundler actually resolved. Every subsequent bundle install reuses the locked versions rather than re-resolving from scratch — which is exactly what makes builds reproducible across machines and time.
bundle update # re-resolve every gem to the newest version still allowed by the Gemfile
bundle update rails # re-resolve just one gem (and whatever it forces to change)
Running code with the exact locked versions (rather than whatever happens to be installed globally) requires prefixing commands with bundle exec:
bundle exec rspec
bundle exec rails server
Skipping bundle exec can silently run a different, globally-installed version of a gem's executable than the one pinned in Gemfile.lock — a common source of "works on my machine" bugs.
Semantic versioning constraints
| Constraint | Meaning |
|---|---|
'7.1.0' |
Exactly this version, nothing else |
'>= 7.1.0' |
This version or any newer one, with no upper bound |
'~> 7.1.0' |
>= 7.1.0 and < 7.2.0 — patch-level updates only (the "pessimistic" operator) |
'~> 7.1' |
>= 7.1 and < 8.0 — minor-level updates allowed too |
~> ("twiddle-wakka", informally) is the idiomatic default in most real Gemfiles: it accepts routine bug-fix (or, with the shorter form, minor-feature) releases automatically, while refusing a major version bump that might include breaking changes — striking a deliberate balance between "never update automatically" (too rigid) and "accept any future version" (too risky).
Creating a simple gem, conceptually
bundle gem scaffolds a new gem's structure, ready to be filled in and eventually published:
bundle gem my_gem
my_gem/
├── my_gem.gemspec # the gem's own metadata and dependencies
├── lib/
│ └── my_gem.rb # the gem's actual code
├── spec/ # RSpec tests for the gem itself
└── Gemfile
The gemspec is a gem's own metadata file, analogous to a project's Gemfile but describing the gem itself rather than an application consuming other gems:
# my_gem.gemspec
Gem::Specification.new do |spec|
spec.name = "my_gem"
spec.version = "0.1.0"
spec.summary = "A tiny example gem"
spec.authors = ["Ali"]
spec.files = Dir["lib/**/*.rb"]
spec.add_dependency "activesupport", ">= 6.0"
end
gem build my_gem.gemspec # produces my_gem-0.1.0.gem
gem push my_gem-0.1.0.gem # publishes it to rubygems.org, publicly, for anyone to `gem install`
Once a version is published to RubyGems.org, it's effectively permanent — you bump the version number for the next release rather than overwriting an already-published one, the same convention as npm and crates.io.
Common mistakes
- Hand-editing
Gemfile.lockdirectly instead of runningbundle install/bundle update— it's a generated file, and manual edits are easily undone or left inconsistent by the next realbundle install. - Running a command without
bundle execand getting a different (often older, globally-installed) gem version than the one actually pinned inGemfile.lock, leading to behavior that only reproduces "outside Bundler." - Using an unbounded
'>= x'constraint everywhere instead of~>, letting a future major version with breaking changes install silently on the nextbundle update. - Not committing
Gemfile.lockfor an application — for an app, the lock file should be committed so every environment (every developer, CI, production) resolves identical versions; for a gem/library being published for others to depend on, it's conventionally not committed, since the gem's own consumers will resolve their own compatible versions anyway.
Interview questions
Q: What's the difference between a Gemfile and a Gemfile.lock?
The Gemfile declares which gems a project depends on, as version ranges (~> 7.1.0, meaning "compatible with 7.1.0"). Gemfile.lock is generated by Bundler and records the exact resolved version of every gem — direct and transitive — actually installed, so the same versions can be reproduced on any machine or at any later time.
Q: What does the pessimistic version constraint ~> mean, and why is it the conventional default in a Gemfile?
~> 7.1.0 allows patch-level updates only (>= 7.1.0, < 7.2.0); ~> 7.1 allows minor-level updates too (>= 7.1, < 8.0). It's the conventional default because it lets routine bug fixes (and optionally minor features) flow in automatically via bundle update, while still refusing any update that crosses a major version boundary, where semantic versioning says breaking changes are allowed to happen.
Q: Why would you commit Gemfile.lock for an application but typically not for a gem you're publishing? An application has a fixed, known set of environments (developer machines, CI, production) that all need to run against identical dependency versions — committing the lock file guarantees that. A published gem, by contrast, will be installed alongside whatever other gems and versions its eventual consumers already have in their own projects, so forcing your own exact resolved versions on them via a committed lock file would be overly restrictive; consumers resolve compatible versions for their own environment instead.