Ranges are the first Scala collection most people use, usually inside a for loop, and the one they think about least. That is mostly fine, because a Range is small, fast and predictable. It stops being fine at the edges: a step that does not divide the span, a range that silently comes out empty, a Long range that turns out to be a different class, a decimal range that drifts, a range with more than two billion elements, or a map that quietly allocates a boxed Vector inside a hot loop.

This page explains how Range is built (three integers and a flag), the arithmetic that decides its length and last element, which operations keep it a Range and which materialise a new collection, how NumericRange covers Long, BigInt, BigDecimal and Char, and the failure modes worth testing for. Examples use Scala 2.13 and Scala 3, which share the same collections library. The surrounding collection hierarchy is covered in Scala collections.

What a Range is

A Range stores a start, an end, a step and whether the end is inclusive. It never stores its elements. Element i is computed as start + i * step on demand, so 0 until 1000000000 takes the same few bytes as 0 until 3. Because it can compute any element by index, Range is an IndexedSeq[Int]: apply, length, head and last are constant time.

Two concrete subclasses exist, Range.Inclusive and Range.Exclusive, and you rarely name them. The construction syntax comes from RichInt: to builds an inclusive range, until an exclusive one, and by replaces the step and returns a new Range.

val a = 1 to 5                  // Range 1 to 5: 1, 2, 3, 4, 5
val b = 1 until 5               // 1, 2, 3, 4
val c = 0 to 20 by 5            // 0, 5, 10, 15, 20
val d = 10 to 1 by -3           // 10, 7, 4, 1
val e = Range(0, 10, 4)         // exclusive: 0, 4, 8
val f = Range.inclusive(0, 8, 4)// inclusive: 0, 4, 8
val g = (1 to 5).reverse        // 5, 4, 3, 2, 1  -- still a Range
val h = 5 to 1                  // empty: the default step is +1

The arithmetic: length, last, contains and sum

Most range bugs are arithmetic bugs, so it pays to know the rule. For a positive step, the elements are start, start + step, start + 2 * step and so on, while the value stays at or below the end (inclusive) or strictly below it (exclusive). The last element is therefore the largest grid point that the end allows, which is not always the end itself.

01234567891011start = 1end = 10, inclusive1 to 10 by 3: last = 10 (10 - 1 is divisible by 3)012345678910111 until 10 by 3: last = 7; 10 is excluded012345678910111 to 9 by 3: last = 7; the end 9 is never reachedlength = (end - start) / step + 1, adjusted when the end is excluded or not on the grid
Three ranges over the same numbers. The end is a limit, not a guaranteed element: only grid points start + k * step that the end allows are included.
(1 to 10 by 3).last      // 10
(1 until 10 by 3).last   // 7
(1 to 9 by 3).last       // 7
(1 to 9 by 3).length     // 3
(0 until 10).length      // 10
(0 to 10).length         // 11

(1 to 10 by 3).contains(7)    // true  -- constant time, no scan
(1 to 10 by 3).contains(8)    // false
(1 to 100).sum                // 5050  -- arithmetic series, no iteration

contains on an Int range checks the bounds and whether the distance from start is a multiple of the step, so it costs the same for ten elements or a billion. sum is overridden for the standard Int numeric to use the arithmetic-series formula rather than a loop. Neither needs to touch the elements. Contrast that with (1 to 100).toList.contains(7), which allocates a list and scans it.

What stays a Range and what allocates

Whether an operation returns a Range or a fresh collection decides whether it is free or allocates. Operations that can be described as another start, end and step return a Range: take, drop, reverse and by all do. Operations whose result is not an arithmetic progression cannot, so they build a new collection.

ExpressionResult typeCost
(0 until 1000).take(10)RangeConstant, no allocation of elements
(0 until 1000).drop(990).reverseRangeConstant
(0 until 1000) by 7RangeConstant
(0 until 1000).map(_ * 2)IndexedSeq[Int] (a Vector)1000 elements, boxed
(0 until 1000).filter(_ % 2 == 0)IndexedSeq[Int]Scans and allocates
(0 until 1000).view.map(_ * 2)a viewNothing until forced

Two practical consequences follow. First, if a filter is really a stride, write it as a stride: 0 until 1000 by 2 is a Range, while (0 until 1000).filter(_ % 2 == 0) is a materialised Vector of 500 boxed integers. Second, map over a large range followed by a single fold or find builds the whole intermediate collection; use a view or an iterator when only one pass is needed.

Worked example: batch boundaries without materialising ids

A common real use: a job must process row ids 0 to 1,000,002 in batches of 250,000, and each batch is handed to a worker as an id range for a SQL query. The naive version groups the ids, which materialises them. The range version computes the boundaries and never touches individual ids.

def batches(total: Int, size: Int): IndexedSeq[Range] = {
  require(size > 0, "size must be positive")
  (0 until total by size).map(start => start until math.min(start + size, total))
}

val bs = batches(1000003, 250000)
// Vector(Range 0 until 250000, Range 250000 until 500000,
//        Range 500000 until 750000, Range 750000 until 1000000,
//        Range 1000000 until 1000003)

bs.map(_.length).sum == 1000003            // true: no gaps, no overlaps
bs.map(r => s"id >= ${r.start} AND id < ${r.end}")

The outer 0 until total by size yields the start of each batch, and math.min clamps the last batch so it does not run past the total. Because each batch is a Range, r.start and r.end translate directly into an SQL predicate, and the check that the lengths add up is constant time per batch. The outer map does allocate, but only one small Range per batch.

The same shape works for time windows, but epoch milliseconds overflow Int, so the boundaries need a Long range, which is a different class with different behaviour. That is the next section.

val day = 86400000L
val windows = (startMs until endMs by day).map(s => (s, math.min(s + day, endMs)))
// startMs until endMs by day is a NumericRange.Exclusive[Long]

NumericRange: Long, BigInt, BigDecimal and Char

The to, until and by syntax also works on Long, BigInt, BigDecimal and Char, but the result is a NumericRange, not a Range. NumericRange is generic over an Integral type class, so it works for any integral type, at the cost of going through that type class for arithmetic. Each element is a boxed value of the element type. The companion object also offers explicit constructors: Range.Long, Range.BigInt and Range.BigDecimal.

val longs = 1L to 10000000000L by 1000000000L   // NumericRange.Inclusive[Long], 10 elements
val chars = 'a' to 'e'                          // NumericRange.Inclusive[Char]: a, b, c, d, e
val bigs  = BigInt(0) until BigInt(10).pow(30) by BigInt(10).pow(29)

val dec = BigDecimal("0.0") to BigDecimal("1.0") by BigDecimal("0.1")
dec.length   // 11
dec.last     // 1.0 exactly, because BigDecimal is exact in base 10

Floating-point ranges are deliberately discouraged. Building a range from Double with to or until has been deprecated since Scala 2.12.6, and you should treat it as unavailable, because a step such as 0.1 has no exact binary representation and the count of elements and the last element would depend on rounding. If you need decimal steps, use BigDecimal as above, or iterate over an Int range and scale: (0 to 10).map(_ / 10.0) has exactly 11 elements, each computed from an exact integer.

Use a Long range whenever the values can exceed Int: epoch timestamps, file offsets, row counts in large tables. An Int range over such values does not warn you; the arithmetic wraps and you get an empty range or the wrong one.

Failure modes

These are the failures to recognise in a stack trace or a code review.

  • Empty by direction. 10 to 1 is empty because the default step is +1. The fix is 10 to 1 by -1 or (1 to 10).reverse. Code that takes a start and end from data should check the direction explicitly rather than relying on a non-empty range.
  • Off by one at the end. 0 to arr.length includes arr.length and throws when used as an index. Prefer arr.indices, which is exactly 0 until arr.length.
  • Step zero. 1 to 10 by 0 throws IllegalArgumentException with the message "step cannot be 0." A step computed from data, such as a batch size read from configuration, needs a guard like the require in the worked example.
  • Too many elements. 0 to Int.MaxValue has Int.MaxValue + 1 elements. The range is constructed, but length and other operations that need the size throw IllegalArgumentException ending in "seqs cannot contain more than Int.MaxValue elements." Use a Long NumericRange or an iterator for spans that large.
  • Int overflow in the bounds. now.toInt until (now + day).toInt with millisecond timestamps wraps around. Keep Long values in a Long range.
  • Equality surprises. Ranges compare equal to any sequence with the same elements, and all empty ranges are equal to each other and to Nil. (1 to 3) == List(1, 2, 3) is true, which is usually what you want, but a set of ranges keyed by start and end is not a set of distinct ranges if some are empty.
  • Accidental materialisation. (1 to n).toList, toArray or a map in the middle of a pipeline allocate n elements. In a loop that runs per request, that is garbage the collector has to clean up.

Performance of range loops

A for loop over a range desugars to a foreach call with a closure (the rules are in for-comprehensions). Range's foreach is implemented as a plain while loop, so there is no iterator allocation, and once the JIT has inlined the closure most range loops run as fast as hand-written ones. That inlining is not guaranteed: a loop body that is large, megamorphic, or called from many sites can stay a real function call per element.

Rules of thumb: write the range loop first, because it is clearer and correct. If a profiler shows the loop as hot, measure it with JMH against a while loop before rewriting, and keep the rewrite local. Avoid map and filter chains over large ranges in hot code, since they box every Int into an IndexedSeq; a fold, a view or an explicit loop avoids the allocation. Scala collections performance covers boxing and allocation in more depth.

Choosing between Range and its alternatives

NeedUseWhy
Index loop over an array or seqxs.indicesCannot be off by one
Integer progression, any size up to Int.MaxValueRangeConstant memory, constant-time apply, contains and sum
Values beyond Int, or CharNumericRange (Long, BigInt, Char)Correct arithmetic; boxed elements
Decimal stepsBigDecimal range, or scale an Int rangeExact count and last element
One pass, possibly unboundedIterator.range or Iterator.fromNo collection at all; single use
Unbounded, re-traversable and memoisedLazyListKeeps computed elements; watch memory
The hottest inner loop after profilingwhile loopNo closure; fully under your control

What to do next

  1. Search your code for 0 to used with .length or .size and replace it with .indices or until.
  2. Find ranges built from configuration or data and add a require that the step is non-zero and the direction is the one you expect.
  3. Replace filter calls that implement a stride with by.
  4. Move any range over timestamps, offsets or large counts to Long, and add a test at a value above Int.MaxValue.
  5. Replace Double stepping with BigDecimal or a scaled Int range, and assert the expected element count in a test.
  6. Use the batch-boundary pattern for chunked work instead of grouping materialised ids.
  7. Profile before converting range loops to while loops, and convert only the hot ones.
Key takeaway: A Range is a start, an end, a step and an inclusive flag, so it costs constant memory and answers apply, length, contains and sum without touching its elements. take, drop, reverse and by return new Ranges; map and filter allocate boxed collections. Long, BigInt, BigDecimal and Char ranges are NumericRanges, Double ranges are deprecated in favour of BigDecimal, and the classic bugs are empty ranges from the wrong direction, off-by-one ends, zero steps, Int overflow and spans beyond Int.MaxValue elements. Use indices for index loops and profile before trading a range loop for a while loop.