DEV Community

Cover image for Learning Elixir: Ranges
João Paulo Abreu
João Paulo Abreu

Posted on

Learning Elixir: Ranges

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

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 Enum and Stream functions
  • 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]
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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!
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)   # []
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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]
Enter fullscreen mode Exit fullscreen mode

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]
Enter fullscreen mode Exit fullscreen mode

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]
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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]
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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]
Enter fullscreen mode Exit fullscreen mode

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]
Enter fullscreen mode Exit fullscreen mode

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}]
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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]
Enter fullscreen mode Exit fullscreen mode

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]
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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]
Enter fullscreen mode Exit fullscreen mode

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]
Enter fullscreen mode Exit fullscreen mode

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!"
Enter fullscreen mode Exit fullscreen mode

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!"
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Testing in IEx:

iex> 50 in 1..100
true

iex> RangeExamples.sum_direct(1..100)
5050

iex> RangeExamples.sum_range_formula(1..100)
5050
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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]
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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}
Enter fullscreen mode Exit fullscreen mode

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}]
Enter fullscreen mode Exit fullscreen mode

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 Enum and Stream
  • 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 Enum and Stream functions
  • 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

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 with construct for chaining operations that might fail
  • Using case and 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)