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:

Bash
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:

Ruby
# 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
Bash
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.

Bash
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:

Bash
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:

Bash
bundle gem my_gem
Plaintext
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:

Ruby
# 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
Bash
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.lock directly instead of running bundle install/bundle update — it's a generated file, and manual edits are easily undone or left inconsistent by the next real bundle install.
  • Running a command without bundle exec and getting a different (often older, globally-installed) gem version than the one actually pinned in Gemfile.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 next bundle update.
  • Not committing Gemfile.lock for 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.