The Truth About Conditional Dependencies in Ruby Gems: They Don’t Work the Way You Think


If you’ve ever tried to add conditional logic to your gem’s dependencies based on Ruby version or platform, you’ve probably been surprised when things didn’t work as expected. Here’s the uncomfortable truth: conditional dependencies in gemspecs are evaluated at build time, not at install time. This single fact undermines most attempts to create “smart” ruby gems that adapt to their environment.

Let me explain what’s really happening under the hood, and what you should do instead.

The Tempting But Broken Approach

It’s natural to want to write something like this in your gemspec:

Gem::Specification.new do |spec|
  spec.name = "my_awesome_gem"
  spec.version = "1.0.0"
  
  # This looks reasonable, but doesn't work the way you think!
  if RUBY_VERSION >= '3.0'
    spec.add_dependency 'modern_library', '~> 2.0'
  else
    spec.add_dependency 'legacy_library', '~> 1.5'
  end
end

Since gemspecs are just Ruby code, this is perfectly valid syntax. It will even run without errors. But it won’t do what you want.

What Actually Happens: The Build-Time Trap

Here’s the fundamental issue: when you build your gem with gem build, your gemspec is evaluated once and converted into static metadata. That metadata gets packaged into your .gem file as YAML (in metadata.gz). When someone installs your gem, RubyGems extracts this YAML and reconstructs the specification—but your original conditional logic is long gone.

Let’s walk through what actually happens:

Step 1: You Build the Gem (on Ruby 3.2)

$ ruby -v
ruby 3.2.0

$ gem build my_awesome_gem.gemspec

Your gemspec runs. The condition RUBY_VERSION >= '3.0' evaluates to true. The dependency modern_library ~> 2.0 gets added. This gets frozen into the metadata.

Step 2: Someone Installs Your Gem (on Ruby 2.7)

$ ruby -v
ruby 2.7.0

$ gem install my_awesome_gem

RubyGems extracts the metadata from your gem and sees that it depends on modern_library ~> 2.0. Your conditional code is gone. It’s just static data now. So even though this user is on Ruby 2.7, they get the dependency you specified when building on Ruby 3.2.

The conditional was evaluated in YOUR environment (at build time), not in THEIR environment (at install time).

The Three Types of Gem References (And Why It Matters)

The way conditionals behave also depends on how the gem is referenced in a Gemfile. Let’s break this down:

1. Published Gem from RubyGems

gem 'my_awesome_gem', '~> 1.0'

What happens: The gem was built once on the maintainer’s machine. The resulting metadata is static and frozen. Everyone who installs gets the exact same dependencies, regardless of their Ruby version or platform. Your conditionals were evaluated at build time on the maintainer’s machine.

2. Git Repository

gem 'my_awesome_gem', git: 'https://github.com/user/my_awesome_gem'

What happens: Bundler clones the repository and evaluates the gemspec on the installer’s machine. Conditionals ARE evaluated in the installer’s environment, so they can work here—but this isn’t the typical way gems are distributed.

3. Local Path

gem 'my_awesome_gem', path: '../my_awesome_gem'

What happens: During development, Bundler evaluates the gemspec on your machine and locks the dependencies in Gemfile.lock. Your teammates get whatever was resolved on your machine, not what would be resolved on theirs. This causes the most confusion during development.

Real-World Example: The Version Mismatch

You’re building a gem that supports both Ruby 2.7 and Ruby 3.2. You write this gemspec:

# my_awesome_gem.gemspec
Gem::Specification.new do |spec|
  spec.name = "my_awesome_gem"
  
  if RUBY_VERSION >= '3.0'
    spec.add_dependency 'http', '~> 5.0'
  else
    spec.add_dependency 'http', '~> 4.0'
  end
end

The Problem

You develop and build the gem on Ruby 3.2:

$ gem build my_awesome_gem.gemspec

The condition evaluates to true, so http ~> 5.0 gets baked into the gem metadata.

Now someone on Ruby 2.7 tries to install your gem:

$ gem install my_awesome_gem

They get http ~> 5.0 even though they’re on Ruby 2.7. If version 5.0 doesn’t support Ruby 2.7, they’re stuck.

What About Development?

During development, things are even trickier. Your Gemfile:

gem 'my_awesome_gem', path: '.'

You’re on Ruby 3.2 and run bundle install. Bundler evaluates the gemspec, sees http ~> 5.0, and locks it in Gemfile.lock.

Your teammate clones the repo. They’re on Ruby 2.7. They run bundle install. Bundler reads Gemfile.lock, tries to install http ~> 5.0, and chaos ensues.

Why RubyGems Works This Way

You might wonder: why doesn’t RubyGems just evaluate the gemspec on the installer’s machine?

The reason is simple: security and reliability. Running arbitrary Ruby code from every gem during installation would be a massive security risk. What if a gem had malicious code in its gemspec? By converting the gemspec to static metadata at build time, RubyGems ensures that installation is safe and predictable.

Additionally, dependency resolution would become much more complex and unpredictable if every gem’s requirements could change based on the installer’s environment.

What Actually Works: Real Solutions

Now that you understand the problem, let’s look at solutions that actually work.

Solution 1: Use required_ruby_version and Drop Old Versions

The simplest solution is often the best: require a minimum Ruby version and use dependencies that work with that version.

Gem::Specification.new do |spec|
  spec.name = "my_awesome_gem"
  spec.required_ruby_version = '>= 3.0'
  
  # Now you can safely use modern dependencies
  spec.add_dependency 'http', '~> 5.0'
end

If you need to support older Ruby versions, maintain separate gem versions:

  • my_awesome_gem 1.x supports Ruby 2.x
  • my_awesome_gem 2.x supports Ruby 3.x

Solution 2: Use Broader Dependency Ranges

Often, you don’t need different dependencies—you just need dependencies that work across Ruby versions:

spec.add_dependency 'http', '>= 4.0', '< 6.0'

Modern gems often support wide Ruby version ranges, so you can avoid version-specific dependencies entirely.

Solution 3: Put Conditionals in Gemfile (Not Gemspec)

For development dependencies, use your Gemfile instead:

# Gemfile
source 'https://rubygems.org'

gemspec  # This pulls in your runtime dependencies

# Development dependencies with environment-aware conditionals
if RUBY_VERSION >= '3.0'
  gem 'rubocop', '~> 1.50'
  gem 'debug'
else
  gem 'rubocop', '~> 1.30'
  gem 'byebug'
end

The Gemfile IS evaluated in each developer’s environment, so conditionals work perfectly here.

Solution 4: Build Platform-Specific Gems

For truly platform-specific needs, build separate gem files:

gem build my_awesome_gem.gemspec --platform=ruby
gem build my_awesome_gem.gemspec --platform=java

You can use platform checks in your gemspec because platform is known at build time:

spec.platform = Gem::Platform::CURRENT

if spec.platform.to_s.include?('java')
  spec.add_dependency 'jdbc-sqlite3'
else
  spec.add_dependency 'sqlite3'
end

When you build with --platform=java, the platform is set before the gemspec is evaluated, so your conditionals work correctly. You just need to build and publish multiple versions.

Solution 5: Use Extensions for Install-Time Logic

There’s a hacky workaround: gemspec extensions. These DO run at install time:

# In your gemspec
spec.extensions = ['ext/mkrf_conf.rb']
# ext/mkrf_conf.rb
require 'rubygems/dependency_installer'

installer = Gem::DependencyInstaller.new

if RUBY_VERSION >= '3.0'
  installer.install 'http', '~> 5.0'
else
  installer.install 'http', '~> 4.0'
end

# Create dummy Rakefile to satisfy extension system
File.write(File.join(File.dirname(__FILE__), 'Rakefile'), "task :default\n")

But please don’t do this. It’s fragile, breaks dependency resolution tools, and is generally frowned upon by the Ruby community. Extensions are meant for compiling native code, not installing dependencies.

When Conditionals in Gemspecs DO Make Sense

There are legitimate uses for conditionals in gemspecs—situations where you’re making decisions based on the build environment, not the install environment:

Build-Time File Inclusion

spec.files = Dir['lib/**/*', 'README.md']

# Only include experimental features if environment variable is set
if ENV['INCLUDE_EXPERIMENTAL']
  spec.files += Dir['lib/experimental/**/*']
end

Build-Time Platform Detection

# When building platform-specific gems
if RUBY_PLATFORM =~ /darwin/
  spec.files += Dir['ext/mac/**/*']
elsif RUBY_PLATFORM =~ /linux/
  spec.files += Dir['ext/linux/**/*']
end

Optional Native Extensions

if File.exist?('ext/native/extconf.rb')
  spec.extensions = ['ext/native/extconf.rb']
end

These work because they’re asking questions about the build environment, not trying to adapt to future install environments.

Testing Your Gem Properly

To avoid surprises, test your gem the way users will actually consume it:

# Build the gem
gem build my_awesome_gem.gemspec

# Create a test application in a different directory
mkdir test_app && cd test_app

# Create a Gemfile that references the built gem
cat > Gemfile << EOF
source 'https://rubygems.org'
gem 'my_awesome_gem', path: '../my_awesome_gem-1.0.0.gem'
EOF

bundle install

Test on multiple Ruby versions to ensure your dependencies work everywhere you claim to support.

The Core Principle

Remember this rule: gemspecs are for build-time decisions, Gemfiles are for install-time decisions.

If you need logic that adapts to the installer’s environment, it belongs in a Gemfile, not a gemspec. If you need true install-time adaptation in a published gem, you need to rethink your approach—probably by using required_ruby_version constraints or building multiple gem versions.

Further Reading


Leave a Reply