Range Basics: .. and ..<
rangeTo ยท inclusive ยท exclusive endA range is a genuine object โ an instance of IntRange, CharRange, and so on โ not just loop syntax. 1..5 is sugar for 1.rangeTo(5), an operator function (see Functions & Lambdas) that constructs the range object; you can hold it in a variable, pass it to a function, or query it, entirely separately from ever looping over it.
// .. is inclusive on both ends: 1, 2, 3, 4, 5 val inclusive = 1..5 println(inclusive.toList()) // [1, 2, 3, 4, 5] // ..< excludes the end (Kotlin 1.7+) โ reads like a half-open interval val exclusive = 0..<5 println(exclusive.toList()) // [0, 1, 2, 3, 4] // The range is a real object: query it without iterating at all println(inclusive.first) // 1 println(inclusive.last) // 5 println(inclusive.isEmpty()) // false
0..<n over the older 0 until n in new Kotlin 2.x code. Both behave identically โ until is still available and still idiomatic in existing codebases โ but ..< is shorter and visually mirrors the [0, n) half-open notation directly.downTo & step
reverse order ยท custom increment.. and ..< only ever count upward by 1. downTo builds a descending range, and step โ an infix function, same family as to from Functions & Lambdas โ changes the increment on any range, ascending or descending.
// downTo: descending range for (i in 5 downTo 1) print("$i ") // 5 4 3 2 1 println() // step: changes the increment on an ascending range for (i in 0..10 step 2) print("$i ") // 0 2 4 6 8 10 println() // step also works combined with downTo for (i in 10 downTo 0 step 3) print("$i ") // 10 7 4 1
step always requires a positive value, even when used with downTo โ the direction comes entirely from whether you wrote ../..< or downTo, not from the sign of the step. 10 downTo 0 step -3 throws IllegalArgumentException at runtime, not a compile error, so it's easy to miss until it's actually executed.in Checks
contains ยท no iterationTesting whether a value falls inside a range doesn't iterate anything โ x in range desugars to range.contains(x), which for a numeric range is a single pair of comparisons (x >= first && x <= last), regardless of how large the range is. This is what makes ranges a natural fit for when branches and bounds checks, covered from the control-flow side in Basics.
val validAges = 0..120 val hugeRange = 1..1_000_000_000 println(45 in validAges) // true println(150 in validAges) // false println(999_999_999 in hugeRange) // true โ instant, not a billion-step scan // Reads naturally as a when branch condition fun classify(age: Int) = when (age) { in 0..12 -> "child" in 13..19 -> "teenager" else -> "adult" }
Ranges over Chars & Comparables
CharRange ยท ClosedRange<T>Ranges aren't limited to numbers. 'a'..'z' builds a CharRange, iterable exactly like an IntRange. More generally, .. works for any type implementing Comparable<T> โ the result is a ClosedRange<T> that supports membership checks and bounds, though only types with a defined "next value" (like Int or Char) can actually be iterated with a for loop.
// CharRange: iterable, just like IntRange for (c in 'a'..'e') print(c) // abcde println() println('m' in 'a'..'z') // true // Any Comparablecan form a ClosedRange, even without iteration support data class Version(val major: Int, val minor: Int) : Comparable<Version> { override fun compareTo(other: Version): Int = compareValuesBy(this, other, { it.major }, { it.minor }) } val supportedRange = Version(1, 0)..Version(2, 5) println(Version(1, 8) in supportedRange) // true: membership works, no loop needed // for (v in supportedRange) { } // ERROR: Version has no defined "next" step
Comparable<T> gets range construction and membership checks for free, but only types the standard library specifically supports for stepping (Int, Long, Char) can be iterated with for. A custom type's range is genuinely useful for bounds checking even without ever being looped over.Progressions
IntProgression ยท first/last/step ยท IterableUnder the hood, 1..10, 10 downTo 1, and 1..10 step 2 are all instances of IntProgression โ a range is really a special case of a progression with a step of exactly 1. Every progression implements Iterable<Int>, which is the whole reason for (i in 1..10) works at all: it's the same iteration mechanism covered generally in Collections, just specialized for a lazily-computed sequence of numbers instead of a stored list.
val progression = 1..11 step 3 println(progression.first) // 1 println(progression.last) // 10 โ NOT 11: last is the final value the step actually lands // on (1, 4, 7, 10), even though the range's written end is 11 println(progression.step) // 3 println(progression.toList()) // [1, 4, 7, 10] // A progression allocates no backing array โ each element is // computed on demand from first + step, exactly like generateSequence
1..1_000_000_000 in a for loop allocates essentially nothing โ no billion-element array exists anywhere. This is what makes ranges safe to use freely for loop bounds no matter how large, unlike materializing the same span as a List with .toList() would be.Quick Reference
Syntax cheat-sheet| Concept | Syntax | Notes |
|---|---|---|
| Inclusive range | 1..5 | 1, 2, 3, 4, 5 |
| Exclusive end | 0..<5 | 0, 1, 2, 3, 4 โ Kotlin 1.7+, prefer over `until` |
| Descending range | 5 downTo 1 | 5, 4, 3, 2, 1 |
| Custom increment | 0..10 step 2 | step is always positive, even with downTo |
| Membership check | x in range | Constant time for numeric ranges, no iteration |
| Char range | 'a'..'z' | Iterable, same as IntRange |
| Comparable range | a..b for any Comparable<T> | Supports membership; iterable only if T supports stepping |
| Underlying type | IntProgression / CharProgression | Defined by first, last, step; range is step = 1 |
| No backing storage | for (i in 1..1_000_000_000) | Elements computed on demand, not materialized |