Inside Ruby’s Range: A Tour Through range.c

May 18, 2026

Most Ruby developers use ranges every day:

(1..5)
('a'..'z')
(1...)
(..10)

They feel lightweight, expressive, and almost deceptively simple.

Tokyo Topographic Map
Built for Ruby on Rails

Build Maps Without
Google APIs

Generate beautiful production-ready maps directly from your Rails backend. Fast rendering, zero external dependencies, full control.

✓ No API fees ✓ Self-hosted ✓ Rails Native ✓ Fast Rendering
Why developers switch
Replace expensive map stacks.

Stop relying on third-party map billing and bloated JS libraries. Render static or dynamic maps directly in Ruby.

Try It Now
Tokyo MapView Demo

But under the hood, Ruby’s Range implementation is one of the most optimized and feature-rich parts of CRuby.

Inside range.c, the Ruby core team handles:

  • endless ranges
  • beginless ranges
  • arithmetic sequences
  • binary search
  • Fixnum optimizations
  • Bignum overflow
  • float stepping
  • string iteration
  • symbol iteration
  • enumerator integration

All behind a syntax most developers learn on day one.

Let’s open the hood.


How Ruby Stores a Range

Internally, a Range stores only three values:

#define RANGE_SET_BEG(r, v) (RSTRUCT_SET(r, 0, v))
#define RANGE_SET_END(r, v) (RSTRUCT_SET(r, 1, v))
#define RANGE_SET_EXCL(r, v) (RSTRUCT_SET(r, 2, v))

That means Ruby ranges are essentially:

  • begin
  • end
  • exclude_end?

For example:

(1..5)

becomes conceptually:

begin: 1
end: 5
exclude_end?: false

While:

(1...5)

becomes:

begin: 1
end: 5
exclude_end?: true

The implementation is surprisingly compact considering how much behavior Ruby builds on top of it.


Ranges Are Immutable

One subtle detail hidden in range.c:

if (CLASS_OF(range) == rb_cRange) {
rb_obj_freeze(range);
}

Ruby freezes ranges automatically.

That means this:

r = (1..5)
r.frozen?
# => true

isn’t accidental it’s a deliberate design choice inside CRuby.

Why?

Because immutable objects:

  • are safer
  • simplify optimizations
  • avoid synchronization problems
  • make iteration logic predictable

This is especially important once endless and beginless ranges enter the picture.


Inclusive vs Exclusive Ranges

Ruby internally tracks exclusivity using:

#define EXCL(r) RTEST(RANGE_EXCL(r))

That tiny flag changes iteration behavior everywhere.

Inclusive:

(1..5).to_a
# => [1, 2, 3, 4, 5]

Exclusive:

(1...5).to_a
# => [1, 2, 3, 4]

Internally, iteration paths constantly branch based on this flag:

if (EXCL(range)) {

One boolean influences:

  • iteration
  • size
  • max
  • step
  • bsearch
  • arithmetic sequences
  • array slicing

Fast Paths for Integers

Ruby aggressively optimizes integer ranges.

This section is one of the most important:

else if (FIXNUM_P(b) && FIXNUM_P(e) && FIXNUM_P(step)) {
/* fixnums are special: summation is performed in C for performance */

Instead of generic Ruby method dispatch, CRuby runs tight C loops directly.

For example:

(1..1_000_000).each

does not repeatedly call Ruby methods for every iteration.

Ruby executes optimized C loops:

for (long i = FIX2LONG(beg); i < lim; i++) {
rb_yield(LONG2FIX(i));
}

This dramatically reduces overhead.

Without these fast paths, range iteration would be much slower.


Endless Ranges Are First-Class Citizens

Endless ranges aren’t syntax sugar.

Ruby deeply integrates them into the runtime.

Example:

(1..)

Internally:

if (NIL_P(RANGE_END(range))) {

Ruby literally stores nil as the end boundary.

That affects everything.

Converting to arrays becomes illegal:

(1..).to_a
# RangeError

because internally:

rb_raise(rb_eRangeError,
"cannot convert endless range to an array");

But iteration works forever:

(1..).each

using dedicated infinite loops:

for (;;)

Ruby even optimizes endless integer iteration separately.


Beginless Ranges Behave Differently

Beginless ranges:

(..10)

also store nil internally but for the beginning.

Some operations become meaningless:

(..10).first
# RangeError

because CRuby explicitly rejects it:

rb_raise(rb_eRangeError,
"cannot get the first element of beginless range");

This distinction matters:

Range TypeValid(1..) iteration✅(..10).reverse_each✅(..10).first❌(1..).last❌

Ruby carefully defines semantics for each combination.


Range#step Is Far More Advanced Than Most Developers Realize

This is where range.c becomes especially interesting.

Ruby supports:

(1..10).step(2)

negative stepping:

(10..1).step(-2)

float stepping:

(1.0..5.0).step(0.5)

time stepping:

(start_time..end_time).step(3600)

and even string stepping:

('a'..'z').step(2)

Internally, CRuby dynamically detects iteration direction:

if (unit < 0) {

and decides whether iteration should move forward or backward.

Even more interesting:

Ruby preserves backward compatibility for string ranges:

// backwards compatibility behavior for String only

Meaning old Ruby behaviors are intentionally preserved deep inside the runtime.


Arithmetic Sequences

When no block is provided:

(1..10).step(2)

Ruby doesn’t immediately iterate.

Instead, it creates:

Enumerator::ArithmeticSequence

Internally:

return rb_arith_seq_new(...)

This allows lazy-style behavior and advanced slicing tricks:

array[(0..) % 2]

which selects every second element.

Most Rubyists use this feature without realizing an entirely different internal object is being created.


Binary Search Internals Are Surprisingly Sophisticated

Range#bsearch is one of the most technically fascinating parts of range.c.

Ruby supports binary searching over:

  • integers
  • floats
  • endless ranges
  • beginless ranges

The float implementation is especially clever.

CRuby maps floats to signed 64-bit integers:

union int64_double {
int64_t i;
double d;
};

Why?

Because binary search fundamentally works best over ordered integers.

Ruby transforms floating-point values into comparable integer representations while preserving ordering semantics.

That’s an advanced systems-level optimization hidden behind:

(0.0..10.0).bsearch

Ruby Carefully Handles Overflow

Large integer ranges transition between Fixnum and Bignum logic.

Example:

/* then switch to Bignum API */

CRuby starts with fast native integer operations, then transparently switches to arbitrary precision arithmetic when limits are exceeded.

This prevents overflow while maintaining performance for common cases.

Most developers never notice the transition happening.


Strings and Symbols Have Dedicated Iteration Logic

Ruby ranges support:

('a'..'z')
(:aa..:zz)

These are not generic implementations.

CRuby has dedicated string iteration functions:

rb_str_upto_each

and symbol-specific handling:

sym_each_i

This is why string ranges behave naturally despite strings not being numeric.

Ruby internally uses succ semantics to generate subsequent values.


The Real Lesson from range.c

Ruby’s Range looks tiny on the surface:

1..5

But internally it’s:

  • highly optimized
  • deeply polymorphic
  • overflow-aware
  • enumerable-aware
  • lazy-compatible
  • infinite-range capable
  • backward-compatible

range.c is a perfect example of CRuby’s philosophy:

simple syntax backed by serious engineering.

The elegance Ruby developers experience daily exists because the runtime absorbs enormous implementation complexity behind the scenes.

And once you read the source, ranges stop looking like syntax sugar and start looking like one of Ruby’s most sophisticated core abstractions.

Article content

Leave a comment