Skip to content

Gems are versioned; Bundler pins them

Level: 201 · a Python programmer who knows pip, venv and pyproject.toml and wants the Ruby names for the same jobs

One line: A gem is a versioned package that RubyGems can activate by name (gem "x"), some ship inside Ruby (default gems), some ship beside it (bundled gems), and Bundler's Gemfile plus Gemfile.lock pin the whole set — while Python has the stdlib, pip, and one pyproject.toml playing the parts of Gemfile and gemspec at once.

Three layers matter. Core classes (Array, Set since 4.0) need no require at all. Default gems (json, bundler) are installed with Ruby and require finds them at once; they are gems, so Gem.loaded_specs knows their name after a require. Bundled gems (minitest, csv since 3.4) are installed alongside Ruby by the installer and are ordinary gems that a Gemfile must list once you use Bundler — Gem::BUNDLED_GEMS::SINCE records the release in which a former default gem became one, which is the source of the "csv was loaded from the standard library, but will no longer be part of the default gems" warning people met in 3.3. Gem::Version compares versions as numbers, not strings, and understands prereleases; Gem::Requirement is the ~> 1.2 pessimistic operator (>= 1.2, < 2.0). A Gemfile is Ruby code evaluated by Bundler's DSL, and a gemspec is a Ruby object too — both are built and read below, in-process, with nothing installed and no network. bundle install, bundle exec and the lockfile are described after the output (Not machine-checked here.).

Verified output of gems_bundler_and_gemfile_rb.rb — regenerated by tools/run_examples.py, never hand-typed.

 1. json: part of the standard install?    default gem: true
 2. csv: part of the standard install?     default gem: false   bundled gem since 3.4.0
 3. minitest:                              default gem: false   in the since-list: false (bundled since before 3.0)
 4. set:                                   no gem at all (Gem::MissingSpecError) -- a core class in 4.0
 5. what require "json" activated          Gem.loaded_specs["json"].name = json; a default gem has a spec too
 6. gem "minitest" in code (activate)      first: true   second: false
 7. gem "nope"                             Gem::MissingSpecError   is a LoadError: true   e.name: "nope"
 8. versions compare as numbers            Gem::Version 1.10 > 1.9: true   as strings: false   2.0.0.pre < 2.0.0: true
 9. a requirement, ~> 1.2                  satisfied by 1.9: true   by 2.0: false
10. a Gemfile, evaluated by Bundler        minitest (~> 5.0) [:default], csv (>= 0) [:default], rake (>= 0) [:development]
11. a gemspec object                       demo-0.1.0   runtime: json (~> 2.0)   development: minitest (>= 5, development)

Reading the output

Rows 1–4 sort four familiar names into the three layers: json is a default gem, csv and minitest are bundled gems (csv since 3.4.0, minitest for so long that the since-list does not even mention it), and Set is a core class in 4.0 — asking RubyGems for a set gem raises Gem::MissingSpecError. Row 5 shows that a require of a default gem activates it: Gem.loaded_specs["json"] holds a specification afterwards. Row 6 is the explicit form, gem "minitest", which puts the newest installed version of that gem on $LOAD_PATH without requiring anything: true when it activated something, false when it was already active. Row 7 is what a missing gem raises — Gem::MissingSpecError, a LoadError, so a bare rescue will not catch it; its message names the number of installed gems, so only the class and name are printed. Row 8 is why version strings must never be compared as strings: "1.10" > "1.9" is false as text and true as Gem::Version, and 2.0.0.pre sorts before 2.0.0. Row 9 reads ~> 1.2 as "1.2 or newer, but below 2.0". Row 10 evaluates a five-line Gemfile with Bundler::Dsl.evaluate and lists what it declared, including the :development group; row 11 builds a Gem::Specification and prints the same three facts a gemspec file holds — name, version, dependencies.

A project's Gemfile looks like this (Not machine-checked here.):

source "https://rubygems.org"

gem "minitest", "~> 5.0"
gem "csv"
gem "rake", group: :development

bundle install resolves the requirements against the gem server, installs what is missing, and writes Gemfile.lock — the exact versions chosen, which is committed so that every machine and CI run gets the same set. bundle exec ruby app.rb (or a require "bundler/setup" at the top of the program) restricts $LOAD_PATH to exactly the locked versions, so a newer gem installed system-wide cannot leak in. A gem you publish has a demo.gemspec, the file form of row 11 (Not machine-checked here.):

Gem::Specification.new do |s|
  s.name    = "demo"
  s.version = "0.1.0"
  s.summary = "A demo gem"
  s.authors = ["A. Reader"]
  s.files   = Dir["lib/**/*.rb"]
  s.add_dependency "json", "~> 2.0"
  s.add_development_dependency "minitest", ">= 5"
end

gem build demo.gemspec makes demo-0.1.0.gem; gem push publishes it; gem list, gem env and gem which json answer the questions this page asked from code.

Compared with Python

Python's layers are two, not three: the stdlib (sys.stdlib_module_names says whether a name belongs to it — rows 1–3 — and set is a builtin type, row 4) and installed distributions in site-packages, which pip install puts there and importlib.metadata describes. A stdlib module has no package metadata at all (row 5 raises PackageNotFoundError), whereas Ruby's default gems do have a specification. There is no gem "x" activation step: import takes the first match on sys.path, so choosing a version means choosing an environment — a venv per project, or uv managing both the interpreter and the environment (Not machine-checked here.). Row 7's PackageNotFoundError is a ModuleNotFoundError, the same family as Ruby's LoadError. Rows 8 and 9 show a real gap: the stdlib has no Version or Requirement class — the twin compares tuples of integers and writes ~= 1.2 by hand; the packaging library on PyPI provides both (Not machine-checked here.). Rows 10 and 11 read one pyproject.toml with tomllib (3.11+): its [project] table is at once the gemspec (name, version) and the Gemfile (dependencies, optional-dependencies), and the lockfile role falls to uv.lock, poetry.lock or a pinned requirements.txt. Note the operator spelling: Ruby's pessimistic ~> 1.2 is Python's compatible-release ~= 1.2. The Python library's pyproject.toml page ↗ walks that file table by table.

Verified output of gems_bundler_and_gemfile_py.py — regenerated by tools/run_examples.py, never hand-typed.

 1. json: part of the standard install?    stdlib module: True
 2. csv: part of the standard install?     stdlib module: True   (Ruby moved csv out; Python did not)
 3. minitest / unittest:                   minitest: False   unittest: True
 4. set:                                   builtin type: True -- no module at all
 5. what import json loaded                json.__spec__.name = json; no package metadata (PackageNotFoundError: the stdlib is not a package)
 6. no gem 'x' in code: import just finds  first: loads   second: cached (same object: True)
 7. importlib.metadata.version('nope')     PackageNotFoundError   is a ModuleNotFoundError: True   e.name: 'nope'
 8. versions compare as numbers            tuples (1, 10) > (1, 9): True   as strings: False   (no Version class in the stdlib)
 9. a requirement, ~= 1.2 by hand          satisfied by 1.9: True   by 2.0: False
10. a pyproject.toml, read by tomllib      dependencies: ['minitest~=5.0', 'csv']   dev: ['rake']
11. the [project] table is the gemspec     demo-0.1.0   (name, version, dependencies in one file)

Try it

  1. Take the version strings 1.10.0, 1.9.2, 1.9.10, 2.0.0.pre1 and 2.0.0. Print them sorted as strings and sorted as Gem::Version, then say for each whether ~> 1.9 accepts it and whether it is a prerelease, and finish with the newest non-prerelease.
  2. Write a Gemfile string with two gems in the default group and one in :test, evaluate it with Bundler::Dsl.evaluate, and print only the dependencies that definition.dependencies puts in the :test group. Then add platforms: :mri to one gem and print what d.platforms says.
Solution to 1

gems_bundler_and_gemfile_kata_rb.rb in full — pasted here by tools/run_examples.py from the file CI runs.

# Kata: sort version strings the way RubyGems does, next to a plain string sort,
# and check each against a pessimistic requirement.
versions = ["1.10.0", "1.9.2", "1.9.10", "2.0.0.pre1", "2.0.0"]

puts "1. as strings:      #{versions.sort.inspect}"
puts "2. as Gem::Version: #{versions.sort_by { Gem::Version.new(it) }.inspect}"

req = Gem::Requirement.new("~> 1.9")
puts "3. #{req} accepts:"
versions.each do |s|
  v = Gem::Version.new(s)
  puts format("   %-10s %-5s prerelease: %s", s, req.satisfied_by?(v), v.prerelease?)
end
puts "4. newest non-prerelease: #{versions.map { Gem::Version.new(it) }.reject(&:prerelease?).max}"

Verified output of gems_bundler_and_gemfile_kata_rb.rb — regenerated by tools/run_examples.py, never hand-typed.

1. as strings:      ["1.10.0", "1.9.10", "1.9.2", "2.0.0", "2.0.0.pre1"]
2. as Gem::Version: ["1.9.2", "1.9.10", "1.10.0", "2.0.0.pre1", "2.0.0"]
3. ~> 1.9 accepts:
   1.10.0     true  prerelease: false
   1.9.2      true  prerelease: false
   1.9.10     true  prerelease: false
   2.0.0.pre1 false prerelease: true
   2.0.0      false prerelease: false
4. newest non-prerelease: 2.0.0

See also