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.
(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 iterationcontains 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.
| Expression | Result type | Cost |
|---|---|---|
(0 until 1000).take(10) | Range | Constant, no allocation of elements |
(0 until 1000).drop(990).reverse | Range | Constant |
(0 until 1000) by 7 | Range | Constant |
(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 view | Nothing 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 10Floating-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 1is empty because the default step is +1. The fix is10 to 1 by -1or(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.lengthincludesarr.lengthand throws when used as an index. Preferarr.indices, which is exactly0 until arr.length. - Step zero.
1 to 10 by 0throwsIllegalArgumentExceptionwith the message "step cannot be 0." A step computed from data, such as a batch size read from configuration, needs a guard like therequirein the worked example. - Too many elements.
0 to Int.MaxValuehas Int.MaxValue + 1 elements. The range is constructed, butlengthand other operations that need the size throwIllegalArgumentExceptionending 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).toIntwith 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,toArrayor amapin 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
| Need | Use | Why |
|---|---|---|
| Index loop over an array or seq | xs.indices | Cannot be off by one |
| Integer progression, any size up to Int.MaxValue | Range | Constant memory, constant-time apply, contains and sum |
| Values beyond Int, or Char | NumericRange (Long, BigInt, Char) | Correct arithmetic; boxed elements |
| Decimal steps | BigDecimal range, or scale an Int range | Exact count and last element |
| One pass, possibly unbounded | Iterator.range or Iterator.from | No collection at all; single use |
| Unbounded, re-traversable and memoised | LazyList | Keeps computed elements; watch memory |
| The hottest inner loop after profiling | while loop | No closure; fully under your control |
What to do next
- Search your code for
0 toused with.lengthor.sizeand replace it with.indicesoruntil. - Find ranges built from configuration or data and add a
requirethat the step is non-zero and the direction is the one you expect. - Replace
filtercalls that implement a stride withby. - Move any range over timestamps, offsets or large counts to Long, and add a test at a value above Int.MaxValue.
- Replace Double stepping with BigDecimal or a scaled Int range, and assert the expected element count in a test.
- Use the batch-boundary pattern for chunked work instead of grouping materialised ids.
- Profile before converting range loops to while loops, and convert only the hot ones.