Ranges are like bookmarks that mark the start and end of a sequence without actually writing down every number in between. I like to think of a range as a smart recipe that knows how to generate numbers on demand - 1..100 doesn't store all 100 numbers in memory, it just remembers "start at 1, end at 100, count by 1s." This lazy approach makes ranges incredibly memory-efficient, whether you're working with small sequences like 1..5 or massive ones like 1..1_000_000. Unlike lists that explicitly store every element, ranges provide a lightweight way to represent sequences, enabling iteration patterns and enumeration operations that scale effortlessly. In this article, we'll explore how ranges work, their enumeration capabilities, and practical applications in Elixir development.
Note: The examples in this article use Elixir 1.19.0-rc.2. While most operations should work across different versions, some functionality might vary.
Table of Contents
- Introduction
- Understanding How Ranges Work
- Creating and Working with Range Syntax
- Range Enumeration Patterns
- Lazy Evaluation and Memory Efficiency
- Using Ranges for Slicing and Pattern Matching
- Performance Characteristics
- Applications in Data Processing
- Conclusion
- Further Reading
- Next Steps
Introduction
Ranges are one of Elixir's elegant solutions for representing sequences of numbers. They're particularly useful when you need to work with consecutive values without the memory overhead of storing each element.
Some characteristics I've noticed about ranges:
- Memory efficient: Store only endpoints and step, not every value
- Lazy by nature: Generate values only when needed
- Always inclusive: Both endpoints are included in the range
-
Enumerable: Work seamlessly with
EnumandStreamfunctions - Pattern matchable: Can be destructured to extract components
- Directional: Support both ascending and descending sequences
Ranges are perfect for:
- Iteration:
for i <- 1..10, do: process(i) - Slicing:
Enum.slice(list, 2..5) - Number generation:
Enum.to_list(1..100) - Membership testing:
x in 1..10
Here's a simple example that shows the power of ranges:
# Traditional approach with a list - stores all 1 million numbers
million_numbers = Enum.to_list(1..1_000_000) # Uses significant memory
# Range approach - stores only start, end, step
million_range = 1..1_000_000 # Tiny memory footprint!
# Both can be enumerated the same way
Enum.take(million_range, 5) # [1, 2, 3, 4, 5]
Let's dive into how ranges work!
Understanding How Ranges Work
The Internal Structure
Unlike lists that store every element, a range stores only three pieces of information:
# A range like 1..10 internally stores:
# - first: 1
# - last: 10
# - step: 1 (default)
# When you enumerate, Elixir generates values on demand:
# Start at 1, add step (1), get 2
# Start at 2, add step (1), get 3
# ... continue until reaching 10
This is why ranges are called "lazy" - they don't do work until you ask them to generate values.
Inclusivity
One thing I learned about ranges is that they're always inclusive in Elixir - both endpoints are included:
range = 1..5
# This includes both 1 and 5
Enum.to_list(range) # [1, 2, 3, 4, 5]
# Not [1, 2, 3, 4] like in some other languages!
Testing in IEx:
iex> 1..5 |> Enum.to_list()
[1, 2, 3, 4, 5]
iex> first..last = 1..5
1..5
iex> {first, last}
{1, 5}
iex> 5 in 1..10
true
iex> 15 in 1..10
false
Empty Ranges
A range is empty when no values satisfy its conditions. This happens when the step direction doesn't match the endpoint direction:
# Empty ranges - step direction conflicts with endpoints
ascending_empty = 5..1//1 # Positive step, but first > last
descending_empty = 1..5//-1 # Negative step, but first < last
Enum.to_list(ascending_empty) # []
Enum.to_list(descending_empty) # []
The rules I've learned:
- For ascending ranges (step > 0): needs
first <= last - For descending ranges (step < 0): needs
first >= last
Note: Writing 5..1 without explicit step generates a deprecation warning in current Elixir versions. The documentation recommends always using explicit step notation like 5..1//-1 for descending ranges.
Testing in IEx:
iex> Enum.to_list(5..1//1)
[]
iex> Enum.to_list(1..5//-1)
[]
iex> Enum.empty?(5..1//1)
true
iex> Enum.empty?(5..1//-1)
false
Creating and Working with Range Syntax
Basic Range Syntax
The most common way to create ranges uses the .. operator:
# Simple ascending range
basic = 1..10
Enum.to_list(basic) # [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
# Single element range
single = 5..5
Enum.to_list(single) # [5]
# Large range (still tiny in memory!)
huge = 1..1_000_000
Enum.take(huge, 3) # [1, 2, 3]
Ranges with Steps
Use the // operator to specify a step value:
# Count by 2s
evens = 2..10//2
Enum.to_list(evens) # [2, 4, 6, 8, 10]
# Count by 3s
threes = 0..15//3
Enum.to_list(threes) # [0, 3, 6, 9, 12, 15]
# Descending ranges require explicit negative step
countdown = 10..1//-1
Enum.to_list(countdown) # [10, 9, 8, 7, 6, 5, 4, 3, 2, 1]
# Descending by 2s
odd_countdown = 9..1//-2
Enum.to_list(odd_countdown) # [9, 7, 5, 3, 1]
Important: Implicit decreasing ranges (like 10..1 without specifying step) are deprecated. Always use explicit step notation for descending ranges: 10..1//-1.
Testing in IEx:
iex> 2..10//2 |> Enum.to_list()
[2, 4, 6, 8, 10]
iex> 10..1//-1 |> Enum.to_list()
[10, 9, 8, 7, 6, 5, 4, 3, 2, 1]
iex> 0..20//5 |> Enum.to_list()
[0, 5, 10, 15, 20]
iex> 9..1//-2 |> Enum.to_list()
[9, 7, 5, 3, 1]
Using Range.new/2 and Range.new/3
You can also create ranges programmatically:
# Create range with function
range1 = Range.new(1, 10)
# Equivalent to 1..10
# Create range with step
range2 = Range.new(1, 10, 2)
# Equivalent to 1..10//2
# Dynamic range creation
defmodule RangeBuilder do
def build_range(start, finish, step \\ 1) do
Range.new(start, finish, step)
end
# Create range based on condition
def conditional_range(ascending?) do
if ascending? do
Range.new(1, 100, 1)
else
Range.new(100, 1, -1)
end
end
end
Testing in IEx:
iex> Range.new(1, 10)
1..10
iex> Range.new(1, 10, 2)
1..10//2
iex> Range.new(5, 1, -1) |> Enum.to_list()
[5, 4, 3, 2, 1]
The Full-Slice Range
The special .. notation creates the range 0..-1//1, which has special meaning when used with slicing functions:
# Full-slice notation
full_slice = ..
# This range (0..-1//1) would normally be empty
# But Enum.slice/2 and String.slice/2 treat it specially
# to return the entire collection
list = [1, 2, 3, 4, 5]
Enum.slice(list, ..) # [1, 2, 3, 4, 5] (all elements)
Testing in IEx:
iex> list = [1, 2, 3, 4, 5]
[1, 2, 3, 4, 5]
iex> Enum.slice(list, ..)
[1, 2, 3, 4, 5]
iex> Enum.slice(list, 1..3)
[2, 3, 4]
Range Enumeration Patterns
Working with Enum Functions
Ranges implement the Enumerable protocol, so they work with all Enum functions:
# Map over a range
squares = Enum.map(1..5, fn x -> x * x end)
# [1, 4, 9, 16, 25]
# Filter range values
evens = Enum.filter(1..10, fn x -> rem(x, 2) == 0 end)
# [2, 4, 6, 8, 10]
# Reduce a range
sum = Enum.reduce(1..100, 0, fn x, acc -> x + acc end)
# 5050
# Or use the simpler Enum.sum
sum_simple = Enum.sum(1..100)
# 5050
# Take first N elements
first_five = Enum.take(1..1_000_000, 5)
# [1, 2, 3, 4, 5]
Using Ranges in Comprehensions
Ranges work beautifully with list comprehensions:
# Generate squares
squares = for n <- 1..10, do: n * n
# [1, 4, 9, 16, 25, 36, 49, 64, 81, 100]
# Filter within comprehension
even_squares = for n <- 1..10, rem(n, 2) == 0, do: n * n
# [4, 16, 36, 64, 100]
# Multiple ranges (Cartesian product)
coordinates = for x <- 1..3, y <- 1..3, do: {x, y}
# [{1, 1}, {1, 2}, {1, 3}, {2, 1}, {2, 2}, {2, 3}, {3, 1}, {3, 2}, {3, 3}]
# With pattern matching
results = for x <- 1..5, rem(x, 2) == 1, do: {:odd, x}
# [{:odd, 1}, {:odd, 3}, {:odd, 5}]
Testing in IEx:
iex> for n <- 1..5, do: n * n
[1, 4, 9, 16, 25]
iex> for x <- 1..3, y <- 1..2, do: {x, y}
[{1, 1}, {1, 2}, {2, 1}, {2, 2}, {3, 1}, {3, 2}]
iex> for n <- 1..10, rem(n, 3) == 0, do: n
[3, 6, 9]
iex> Enum.sum(1..100)
5050
Streaming with Ranges
For even more efficiency, combine ranges with Stream:
# Stream allows composing operations without intermediate lists
result = 1..1_000_000
|> Stream.map(&(&1 * 2))
|> Stream.filter(&(rem(&1, 3) == 0))
|> Enum.take(10)
# [6, 12, 18, 24, 30, 36, 42, 48, 54, 60]
# Only computes the first 10 matching values!
# Doesn't process all 1 million numbers
Testing in IEx:
iex> 1..100 |> Stream.map(&(&1 * 2)) |> Stream.filter(&(rem(&1, 5) == 0)) |> Enum.take(5)
[10, 20, 30, 40, 50]
iex> 1..1_000_000 |> Stream.take(3) |> Enum.to_list()
[1, 2, 3]
Lazy Evaluation and Memory Efficiency
Understanding Lazy Evaluation
One of the most powerful features of ranges is their lazy evaluation. This means values are generated only when needed:
# Creating a range doesn't generate any values
big_range = 1..10_000_000 # Instant! No memory used for values
# Values are generated on demand during enumeration
first_ten = Enum.take(big_range, 10)
# Only generates [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
Memory Comparison
The key difference between ranges and lists in memory:
# Range: stores only 3 values (first, last, step)
range = 1..1_000_000 # Lightweight!
# List: stores every single element
list = Enum.to_list(1..1_000_000) # Much larger in memory
# Both can be enumerated the same way
Enum.sum(range) # Works with the range directly
Enum.sum(list) # Works with the list
This is why ranges are perfect for representing large sequences - they don't need to store every value.
Practical Lazy Evaluation Examples
Lazy evaluation means the range only generates values as needed:
# Take first 5 from a million - only generates 5 values
result = 1..1_000_000 |> Enum.take(5)
# Find first match - stops as soon as found
first_match = 1..100 |> Enum.find(fn x -> rem(x, 13) == 0 end)
# Filter then take - only processes until it has 3 results
filtered = 1..50 |> Enum.filter(&(rem(&1, 7) == 0)) |> Enum.take(3)
Testing in IEx:
iex> 1..1_000_000 |> Enum.take(5)
[1, 2, 3, 4, 5]
iex> 1..100 |> Enum.find(fn x -> rem(x, 13) == 0 end)
13
iex> 1..50 |> Enum.filter(&(rem(&1, 7) == 0)) |> Enum.take(3)
[7, 14, 21]
Using Ranges for Slicing and Pattern Matching
Slicing Collections with Ranges
Ranges provide an elegant syntax for extracting portions of collections:
list = [100, 200, 300, 400, 500, 600, 700, 800, 900, 1000]
# Get elements 2-5 (indices 1-4, remember 0-indexed)
Enum.slice(list, 1..4)
# [200, 300, 400, 500]
# Get last 3 elements using negative indices
Enum.slice(list, -3..-1//1)
# [800, 900, 1000]
# Skip first 2, take next 4
Enum.slice(list, 2..5)
# [300, 400, 500, 600]
# Every other element in a slice
Enum.slice(list, 0..9//2)
# [100, 300, 500, 700, 900]
String Slicing
Ranges work with binary slicing too:
text = "Hello, World!"
# Get characters 0-4
String.slice(text, 0..4)
# "Hello"
# Get last 6 characters
String.slice(text, -6..-1//1)
# "World!"
# Get every 2nd character
String.codepoints(text)
|> Enum.slice(0..12//2)
|> Enum.join()
# "Hlo ol!"
Testing in IEx:
iex> list = [100, 200, 300, 400, 500, 600, 700, 800, 900, 1000]
[100, 200, 300, 400, 500, 600, 700, 800, 900, 1000]
iex> Enum.slice(list, 1..4)
[200, 300, 400, 500]
iex> Enum.slice(list, -3..-1//1)
[800, 900, 1000]
iex> String.slice("Hello, World!", 0..4)
"Hello"
iex> String.slice("Hello, World!", -6..-1//1)
"World!"
Pattern Matching with Ranges
Ranges can be destructured to extract their components:
# Extract range components
first..last//step = 1..10//2
# first = 1, last = 10, step = 2
# Extract without step (default step is 1)
start..finish = 5..15
# start = 5, finish = 15
defmodule RangeAnalyzer do
# Analyze range properties
def analyze(range) do
first..last//step = range
%{
start: first,
end: last,
step: step,
size: Enum.count(range),
direction: if(step > 0, do: :ascending, else: :descending)
}
end
# Check if ranges overlap
def overlaps?(r1, r2) do
f1..l1 = r1
f2..l2 = r2
# Check if ranges have any common elements
max(f1, f2) <= min(l1, l2)
end
# Merge consecutive ranges
def merge_consecutive?(r1, r2) do
_f1..l1//s1 = r1
f2.._l2//s2 = r2
# Check if r2 starts right after r1 with same step
s1 == s2 && l1 + s1 == f2
end
end
Testing in IEx:
iex> first..last//step = 1..10//2
1..10//2
iex> {first, last, step}
{1, 10, 2}
iex> start..finish = 5..15
5..15
iex> {start, finish}
{5, 15}
iex> RangeAnalyzer.analyze(1..10//2)
%{start: 1, end: 10, step: 2, size: 5, direction: :ascending}
iex> RangeAnalyzer.overlaps?(1..5, 3..7)
true
iex> RangeAnalyzer.overlaps?(1..5, 6..10)
false
Using Ranges in Function Guards
defmodule RangeGuards do
# Accept values in range
def categorize_age(age) when age in 0..12, do: :child
def categorize_age(age) when age in 13..19, do: :teenager
def categorize_age(age) when age in 20..64, do: :adult
def categorize_age(age) when age >= 65, do: :senior
# Validate score range
def valid_score?(score) when score in 0..100, do: true
def valid_score?(_), do: false
# Process based on range membership
def process_value(x) when x in 1..10, do: {:small, x}
def process_value(x) when x in 11..100, do: {:medium, x}
def process_value(x) when x in 101..1000, do: {:large, x}
def process_value(x), do: {:out_of_range, x}
end
Testing in IEx:
iex> RangeGuards.categorize_age(8)
:child
iex> RangeGuards.categorize_age(17)
:teenager
iex> RangeGuards.valid_score?(85)
true
iex> RangeGuards.valid_score?(150)
false
iex> RangeGuards.process_value(5)
{:small, 5}
iex> RangeGuards.process_value(50)
{:medium, 50}
Performance Characteristics
Understanding Range Efficiency
Ranges are memory-efficient because they store only three values (first, last, step) regardless of the range size:
range = 1..1_000_000
# Creating ranges is fast - only stores endpoints and step
Range.new(1, 1000)
first..last//step = range
# Operations that generate values must enumerate
Enum.to_list(range) # Generates all 1 million values
Enum.sum(range) # Enumerates to sum
# Bounded operations only work with needed values
Enum.take(range, 10) # Only generates first 10 values
Enum.random(range) # Documented as constant time for ranges
Working Efficiently with Ranges
defmodule RangeExamples do
# Check if value is in range
def in_range?(value, range) do
value in range
end
# Use Stream for lazy evaluation
def first_n_multiples(n, count) do
Stream.iterate(n, &(&1 + n))
|> Enum.take(count)
end
# Avoid unnecessary conversions
def sum_direct(range) do
Enum.sum(range) # Works directly on range
end
# Mathematical formula alternative (for simple ranges)
def sum_range_formula(first..last) when first <= last do
n = last - first + 1
div(n * (first + last), 2)
end
end
Testing in IEx:
iex> 50 in 1..100
true
iex> RangeExamples.sum_direct(1..100)
5050
iex> RangeExamples.sum_range_formula(1..100)
5050
Memory Considerations
defmodule MemoryExamples do
# Works with range directly - no intermediate list
def count_evens_direct(max) do
1..max |> Enum.count(&(rem(&1, 2) == 0))
end
# Creates intermediate list (uses more memory)
def count_evens_with_list(max) do
1..max
|> Enum.to_list()
|> Enum.filter(&(rem(&1, 2) == 0))
|> length()
end
# Mathematical formula (no enumeration needed)
def count_evens_formula(max), do: div(max, 2)
# Lazy streaming for complex operations
def process_large_range(range) do
range
|> Stream.filter(&(rem(&1, 3) == 0))
|> Stream.map(&(&1 * &1))
|> Enum.take(100)
end
end
Testing in IEx:
iex> MemoryExamples.count_evens_direct(100)
50
iex> MemoryExamples.count_evens_formula(100)
50
iex> MemoryExamples.process_large_range(1..10_000) |> length()
100
Applications in Data Processing
Iteration Patterns
Ranges make common iteration patterns simple:
# Generate numbered items
items = for i <- 1..5, do: "Item #{i}"
# Countdown
countdown = for i <- 5..1//-1, do: "T-minus #{i}"
# Simple pagination calculation
page_size = 10
total_items = 45
total_pages = div(total_items + page_size - 1, page_size)
pages = for page <- 1..total_pages, do: page
Testing in IEx:
iex> for i <- 1..5, do: "Item #{i}"
["Item 1", "Item 2", "Item 3", "Item 4", "Item 5"]
iex> for i <- 5..1//-1, do: "T-minus #{i}"
["T-minus 5", "T-minus 4", "T-minus 3", "T-minus 2", "T-minus 1"]
iex> total_pages = div(45 + 10 - 1, 10)
5
iex> for page <- 1..total_pages, do: page
[1, 2, 3, 4, 5]
Common Algorithms
Ranges work well for implementing common algorithms:
# FizzBuzz
fizzbuzz = for i <- 1..15 do
cond do
rem(i, 15) == 0 -> "FizzBuzz"
rem(i, 3) == 0 -> "Fizz"
rem(i, 5) == 0 -> "Buzz"
true -> to_string(i)
end
end
# Multiplication table
table = for i <- 1..3, j <- 1..3, do: {i, j, i * j}
# Sum of squares
sum_of_squares = 1..10 |> Enum.map(&(&1 * &1)) |> Enum.sum()
Testing in IEx:
iex> for i <- 1..15, rem(i, 15) == 0 or rem(i, 3) == 0 or rem(i, 5) == 0, do: i
[3, 5, 6, 9, 10, 12, 15]
iex> for i <- 1..3, j <- 1..3, do: {i, j, i * j}
[{1, 1, 1}, {1, 2, 2}, {1, 3, 3}, {2, 1, 2}, {2, 2, 4}, {2, 3, 6}, {3, 1, 3}, {3, 2, 6}, {3, 3, 9}]
iex> 1..10 |> Enum.map(&(&1 * &1)) |> Enum.sum()
385
Data Generation
Ranges simplify generating test data:
# Generate user IDs
user_ids = 1..5 |> Enum.to_list()
# Generate sequential dates
dates = for day <- 0..4, do: Date.add(~D[2024-01-01], day)
# Sample points at intervals
samples = for x <- 0..100//25, do: {x, x * 2}
Testing in IEx:
iex> 1..5 |> Enum.to_list()
[1, 2, 3, 4, 5]
iex> for day <- 0..4, do: Date.add(~D[2024-01-01], day)
[~D[2024-01-01], ~D[2024-01-02], ~D[2024-01-03], ~D[2024-01-04], ~D[2024-01-05]]
iex> for x <- 0..100//25, do: {x, x * 2}
[{0, 0}, {25, 50}, {50, 100}, {75, 150}, {100, 200}]
Conclusion
Ranges are a fundamental feature of Elixir that I've found incredibly useful for working with sequences. In this article, I explored:
- How ranges work internally with their lightweight structure
- Creating ranges with various syntax options and step values
- Enumeration patterns and how ranges integrate with
EnumandStream - The power of lazy evaluation for memory efficiency
- Using ranges for slicing collections and pattern matching
- Performance characteristics and when ranges excel
- Practical applications in data processing and algorithms
Some things I learned:
- Memory efficiency: Ranges store only endpoints and step, making them perfect for large sequences
- Lazy by design: Values are generated on demand, not stored in memory
- Always inclusive: Both endpoints are included, unlike some other languages
-
Deprecation note: Implicit decreasing ranges are deprecated; always use explicit steps like
10..1//-1 -
Enumerable power: Ranges work seamlessly with all
EnumandStreamfunctions - Pattern matching: Ranges can be destructured to extract their components
Ranges complement Elixir's other data structures by providing a memory-efficient way to represent sequences. They work well when combined with lazy evaluation through Stream, allowing you to work with large ranges without storing all values in memory.
Understanding ranges has helped me write cleaner Elixir code. Whether it's simple iteration, batch processing, or algorithms, ranges provide an elegant solution for working with sequences.
Further Reading
- Elixir Official Documentation - Range
- Elixir Official Documentation - Enum
- Elixir Official Documentation - Stream
Next Steps
With a solid understanding of ranges, the next step is exploring Error Handling in Elixir. Error handling is crucial for building robust applications, and Elixir provides elegant patterns for dealing with failures.
In the next article, we'll explore:
- Understanding
{:ok, value}and{:error, reason}patterns - The
withconstruct for chaining operations that might fail - Using
caseand pattern matching for error handling - Best practices for propagating and handling errors
- Building resilient functions with proper error handling
Error handling is essential for writing production-ready Elixir code. Let's dive into how Elixir makes error handling both explicit and elegant!
Top comments (0)