The Ruby community has a collective anxiety disorder about gem versioning. Half the ecosystem pins everything to exact versions out of paranoia, the other half uses >= and hopes for the best, and meanwhile we’re all stuck resolving dependency conflicts that shouldn’t exist.
Here’s what actually works, based on where your code lives in the dependency stack.
The Operators You Actually Need
Semantic versioning uses MAJOR.MINOR.PATCH. The operators:
~> 2.3.0– Allows 2.3.x, locks major and minor~> 2.3– Allows 2.x where x >= 3, locks major>= 2.0, < 4.0– Everything from 2.0 up to (but not including) 4.0= 2.3.0– Exact version (almost always wrong)
The pessimistic operator (~>) is named that because it assumes things will break at the boundary it locks. Use it.
Low-Level Libraries: Get Out of Everyone’s Way
If you’re writing a utility gem, a parser, or anything that sits deep in dependency trees, your job is to not cause conflicts. This means loose constraints, even if it makes you nervous.
Use ranges like >= 2.0, < 4.0 instead of ~> 2.3. Yes, version 3.0 might break your code. Test against it. Fix your code. The alternative is forcing everyone who depends on you to deal with version conflicts.
Pay attention to how your dependencies actually version. If a gem is at 1.16.3 and 1.17.2 and clearly uses minor bumps for significant changes, don’t assume 1.18 will be compatible. Use >= 1.16, < 1.18 to exclude the next unreleased minor version. Not all gems follow semver strictly – some treat minor versions like majors. Adapt your constraints to their actual release pattern.
Test against the full range you claim to support. CI matrix builds are cheap. Run your tests against both the minimum and maximum versions. If you can’t be bothered to verify compatibility, don’t claim it.
Stop dropping support for old Ruby versions because you feel like it. Bumping your minimum Ruby requirement creates upgrade pressure across the entire ecosystem. Do it when you need language features or there’s a security issue, not because 2.7 feels old to you.
The json gem gets this right – it’s everywhere in dependency graphs and it doesn’t create problems because it doesn’t impose artificial constraints.
Mid-Level Gems: This Is Where It Gets Messy
You have dependencies. Other people depend on you. Welcome to the middle of the stack, where most dependency hell originates.
Use ~> with minor version ranges for your dependencies: ~> 3.2. This gives you patch updates and minor bumps while protecting against major version breaks. You’re saying “I tested this with 3.2 and I’m confident about 3.3, but 4.0 is unknown.”
Be explicit about what doesn’t work. If you know version 4.0 of something breaks your code, say < 4.0 in addition to your lower bound. Don’t rely on pessimistic constraints being enough.
Watch for gems that play fast and loose with semver. Some maintainers put breaking changes in minor releases, either through inexperience or because they’re still in 0.x territory (where semver rules don’t really apply). If a gem’s history shows this pattern, constrain it tighter than you normally would.
If you’re building Rails plugins or extensions, your Rails version constraint should be as permissive as you can honestly support. Your users already have Rails constraints – don’t make them tighter unnecessarily. Test against multiple Rails versions.
Major version bumps mean you can tighten constraints. Requiring Ruby 3.0+ when your previous version supported 2.6+? That’s a major version change. Users stuck on old Rubies stay on your old releases. This is fine.
Applications: You’re at the Top, Act Like It
Nobody depends on your Rails app. This changes everything.
Commit your Gemfile.lock. There is no debate here. The lock file is what actually runs in production. Version control it.
Your Gemfile constraints can be relatively loose because the lock file does the real pinning. gem 'rails', '~> 7.0' is fine – you’re just expressing the boundary of what you’re willing to support.
Update one gem at a time: bundle update specific_gem. Updating everything at once makes debugging breaks impossible. When something breaks, you want to know exactly which update caused it.
Pin specific versions when a gem is problematic. Don’t be religious about this – sometimes a gem has a bad release or a flaky version range, and gem 'troublesome', '3.2.1' is the right answer. Document why and review it later.
What Everyone Gets Wrong
Pinning everything in a library: Specifying = 2.3.1 for all your dependencies makes your gem unusable with any other gems. Don’t do this.
No upper bounds: Just using >= 2.0 is wishful thinking. Major version bumps break things. At minimum, exclude untested major versions: >= 2.0, < 4.0.
Trusting semver blindly: Not every gem follows semantic versioning properly. Look at the actual release history. If minor bumps consistently introduce breaking changes, write your constraints accordingly.
Forgetting transitive dependencies: Your constraints affect the entire dependency graph, not just your immediate dependencies. A tight constraint in your gem ripples outward.
Lying about Ruby compatibility: Set required_ruby_version in your gemspec based on what you actually use. Don’t claim Ruby 2.5 support when you’re using Ruby 3.0 syntax.
The Reality
Sometimes you’ll violate these guidelines. Internal tools can have tighter constraints. Long-established gems with thousands of dependents need to be even more conservative.
The point is understanding the tradeoffs. Tight constraints mean stability but create conflicts. Loose constraints mean compatibility but increase risk. Your position in the dependency graph determines which tradeoff to make.
Test broadly. Be explicit about what you support. Update dependencies regularly. And when you’re debugging dependency resolution at 2 AM, remember that Bundler is doing its best with the constraints everyone gave it. Most dependency hell is self-inflicted through either paranoia or optimism, when what’s needed is pragmatism based on actual testing.
Document your version choices. Add a comment explaining why you picked those bounds. Future you will appreciate it when you’re trying to figure out whether you can finally bump that minimum version.