Lists are like a chain of boxes where each box holds a value and points to the next box in line. Unlike arrays in other languages that let you jump directly to any position, Elixir lists work more like a treasure hunt - you start at the first box and follow the chain until you find what you're looking for. This might sound limiting, but it makes lists incredibly powerful for the functional programming style that Elixir embraces. In this article, we'll explore how lists work, why they're designed this way, and how to use them effectively in your Elixir programs.
Note: The examples in this article use Elixir 1.18.3. While most operations should work across different versions, some functionality might vary.
Table of Contents
- Introduction
- Understanding How Lists Work
- Creating Lists
- Basic List Operations
- Head and Tail Decomposition
- List Comprehensions
- Essential List Functions
- Working with Nested Lists
- Best Practices and Common Patterns
- Conclusion
- Further Reading
- Next Steps
Introduction
Lists are one of the most fundamental data structures in Elixir, and understanding them well is essential for effective functional programming.
Think of a list as a train where each car is connected to the next one. You can easily:
- Add a new car to the front (very fast)
- Look at what's in the first car (very fast)
- Get a view of the train starting from the second car (very fast)
But if you want to reach the last car, you'd need to walk through every car in front of it. This is exactly how lists work in Elixir, and understanding this concept will help you write better code.
Lists are perfect for:
- Building collections of data piece by piece
- Processing data in order from start to finish
- Representing sequences where you often work with the first element
Let's dive in and see how they work!
Understanding How Lists Work
Before we jump into using lists, let's understand what makes them special. This will help you understand why some operations are super fast while others take more time.
The Chain Structure
Imagine a list as a chain of connected boxes. Each box contains:
- A value (like the number 1, 2, or 3)
- A pointer to the next box in the chain
# When you write [1, 2, 3], Elixir creates this structure:
# Box1(1) → Box2(2) → Box3(3) → End
This is different from arrays in other languages where all values are stored side by side in memory. With lists, each box can be anywhere in memory, but they're connected by these pointers.
Head and Tail
Every list has two important parts:
- Head: The first value in the list
- Tail: Everything else (which is also a list!)
Important: Lists in Elixir are immutable. Operations like
[1 | list]
orlist ++ [5]
create new lists - the original remains unchanged.
list1 = [1, 2, 3, 4]
list2 = [2, 3, 4]
list3 = [4]
# For [1, 2, 3, 4]: head = 1, tail = [2, 3, 4]
head1 = hd(list1) # Result: 1
tail1 = tl(list1) # Result: [2, 3, 4]
# For [2, 3, 4]: head = 2, tail = [3, 4]
head2 = hd(list2) # Result: 2
tail2 = tl(list2) # Result: [3, 4]
# For [4]: head = 4, tail = [] (empty list)
head3 = hd(list3) # Result: 4
tail3 = tl(list3) # Result: []
Let's see this in action:
my_list = [1, 2, 3, 4]
# Get the first element (head) - this will be 1
first = hd(my_list)
# Get everything except the first element (tail) - this will be [2, 3, 4]
rest = tl(my_list)
Testing in IEx:
iex> my_list = [1, 2, 3, 4]
[1, 2, 3, 4]
iex> hd(my_list)
1
iex> tl(my_list)
[2, 3, 4]
Why This Structure Matters
This chain structure explains why certain operations are super fast:
# These are FAST (instant):
list = [1, 2, 3, 4, 5]
first_element = hd(list) # Just look at the first box
rest_of_list = tl(list) # Just follow the pointer
new_list = [0 | list] # Just create one new box
# These are SLOWER (have to walk the chain):
last_element = List.last(list) # Walk to the end
list_length = length(list) # Count all boxes
third_element = Enum.at(list, 2) # Walk to position 2
Testing in IEx
iex> fruits = ["apple", "banana", "cherry"]
["apple", "banana", "cherry"]
iex> hd(fruits)
"apple"
iex> tl(fruits)
["banana", "cherry"]
iex> [1 | [2, 3]]
[1, 2, 3]
Creating Lists
There are several ways to create lists in Elixir, each useful in different situations.
Literal Syntax
The most common way to create lists is using the literal syntax:
# Empty list
empty = []
# List of integers
numbers = [1, 2, 3, 4, 5]
# List of atoms
statuses = [:ok, :error, :pending]
# Mixed types (though not always recommended)
mixed = [1, :atom, "string", [1, 2]]
# Lists can contain any Elixir data type
complex = [
%{name: "Alice", age: 30},
{:tuple, "data"},
fn x -> x * 2 end
]
Testing in IEx:
iex> empty = []
[]
iex> numbers = [1, 2, 3, 4, 5]
[1, 2, 3, 4, 5]
iex> statuses = [:ok, :error, :pending]
[:ok, :error, :pending]
iex> mixed = [1, :atom, "string", [1, 2]]
[1, :atom, "string", [1, 2]]
Using the Cons Operator
The cons operator |
allows you to prepend elements:
original = [2, 3, 4]
# Add 1 to the front - result will be [1, 2, 3, 4]
with_prepended = [1 | original]
Testing in IEx:
iex> original = [2, 3, 4]
[2, 3, 4]
iex> [1 | original]
[1, 2, 3, 4]
iex> [0, 1 | original]
[0, 1, 2, 3, 4]
Range to List Conversion
You can convert ranges to lists using Enum.to_list/1
:
# Simple range - creates [1, 2, 3, 4, 5]
numbers = Enum.to_list(1..5)
# Range with steps (every 2nd number) - creates [2, 4, 6, 8, 10]
evens = Enum.to_list(2..10//2)
# Descending range (step of -1) - creates [5, 4, 3, 2, 1]
countdown = Enum.to_list(5..1//-1)
Testing in IEx:
iex> Enum.to_list(1..5)
[1, 2, 3, 4, 5]
iex> Enum.to_list(2..10//2)
[2, 4, 6, 8, 10]
iex> Enum.to_list(5..1//-1)
[5, 4, 3, 2, 1]
Using List Module Functions
The List
module provides functions for creating specialized lists:
# Duplicate elements - creates [0, 0, 0, 0, 0]
zeros = List.duplicate(0, 5)
# Creates ["hello", "hello", "hello"]
greetings = List.duplicate("hello", 3)
Testing in IEx:
iex> List.duplicate(0, 5)
[0, 0, 0, 0, 0]
iex> List.duplicate("hello", 3)
["hello", "hello", "hello"]
A Quick Note About Display in IEx
Sometimes you might see something unexpected in IEx when working with lists of numbers:
# This might surprise you!
numbers = [72, 101, 108, 108, 111]
# IEx might display this as ~c"Hello" instead of [72, 101, 108, 108, 111]
This happens because these numbers correspond to ASCII character codes, and IEx tries to be helpful by showing them as a charlist (character list). The list is still a list of numbers - it's just how IEx chooses to display it. You can force IEx to show the actual numbers:
# Force IEx to show numbers
inspect([72, 101, 108, 108, 111], charlists: :as_lists)
# Shows: [72, 101, 108, 108, 111]
Testing in IEx:
iex> [72, 101, 108, 108, 111]
~c"Hello"
iex> inspect([72, 101, 108, 108, 111], charlists: :as_lists)
"[72, 101, 108, 108, 111]"
iex> # These don't form valid ASCII, so shown as numbers
nil
iex> [1, 2, 3, 200, 255]
[1, 2, 3, 200, 255]
Don't worry - this is just a display quirk in IEx. Your list is still a list of integers!
Basic List Operations
Now that we understand how to create lists, let's explore the fundamental operations you'll use daily.
Accessing Elements
list = [1, 2, 3, 4, 5]
# Getting the first element (head) - result is 1
first = hd(list)
# Getting all but the first element (tail) - result is [2, 3, 4, 5]
rest = tl(list)
# Safe access (returns nil for empty lists) - result is 1
first_safe = List.first(list)
# This returns nil instead of crashing
first_empty = List.first([])
# Getting the last element (expensive - O(n)) - result is 5
last = List.last(list)
# Accessing by index (expensive - O(n), zero-indexed) - result is 3
third = Enum.at(list, 2) # Index 2 means third element
# ⚠️ Important: hd/1 and tl/1 crash on empty lists!
hd([]) # ** (ArgumentError) argument error
tl([]) # ** (ArgumentError) argument error
# Use List.first/1 for safe access:
List.first([]) # nil (doesn't crash)
Adding Elements
original = [2, 3, 4]
# Prepending (fast - O(1)) - result is [1, 2, 3, 4]
prepended = [1 | original]
# Appending (slow - O(n)) - result is [2, 3, 4, 5]
appended = original ++ [5]
# Multiple prepends (add multiple elements at once) - result is [0, 1, 2, 3, 4]
multiple_prepend = [0, 1 | original]
Concatenating Lists
list1 = [1, 2, 3]
list2 = [4, 5, 6]
# Concatenation
combined = list1 ++ list2
# Flatten nested lists
nested = [[1, 2], [3, 4], [5]]
flattened = List.flatten(nested)
Basic Information
list = [1, 2, 3, 4, 5]
# Length (O(n) operation)
len = length(list) # 5
# Check if empty (pattern matching against [] is idiomatic)
is_empty = list == [] # false
is_empty_alt = length(list) == 0 # false (requires traversal, less efficient)
# Check if element exists
has_three = 3 in list # true
has_ten = 10 in list # false
Testing in IEx - Basic Operations
iex> list = [1, 2, 3, 4, 5]
[1, 2, 3, 4, 5]
iex> hd(list)
1
iex> tl(list)
[2, 3, 4, 5]
iex> List.first([])
nil
iex> [0 | list]
[0, 1, 2, 3, 4, 5]
iex> [1, 2] ++ [3, 4]
[1, 2, 3, 4]
iex> 3 in [1, 2, 3, 4]
true
Head and Tail Decomposition
Head and tail decomposition is one of the most powerful techniques for working with lists in functional programming. It allows you to process lists recursively by dealing with one element at a time.
Understanding Head and Tail
# Any non-empty list can be decomposed into head and tail
list = [1, 2, 3, 4, 5]
# Head: the first element - result is 1
head = hd(list)
# Tail: everything except the first element - result is [2, 3, 4, 5]
tail = tl(list)
# The tail is itself a list (possibly empty)
single_element = [42]
head_single = hd(single_element) # Result: 42
tail_single = tl(single_element) # Result: [] (empty list)
Recursive Processing
Head and tail decomposition enables elegant recursive solutions:
defmodule ListProcessor do
# Calculate sum recursively
def sum([]), do: 0
def sum([head | tail]), do: head + sum(tail)
# Count elements recursively
def count([]), do: 0
def count([_head | tail]), do: 1 + count(tail)
# Find maximum element
def max([single]), do: single
def max([head | tail]) do
tail_max = max(tail)
if head > tail_max, do: head, else: tail_max
end
# Reverse a list (using accumulator for efficiency)
def reverse(list), do: reverse(list, [])
# Base case: empty list, return accumulator
defp reverse([], acc), do: acc
# Recursive case: move head to accumulator, process tail
defp reverse([head | tail], acc), do: reverse(tail, [head | acc])
end
Building Lists with Head and Tail
defmodule ListBuilder do
# Double all elements
def double([]), do: []
def double([head | tail]), do: [head * 2 | double(tail)]
# Filter elements (keep only those that pass the test)
def filter([], _fun), do: []
def filter([head | tail], fun) do
if fun.(head) do
[head | filter(tail, fun)] # Keep this element
else
filter(tail, fun) # Skip this element
end
end
# Map transformation
def map([], _fun), do: []
def map([head | tail], fun), do: [fun.(head) | map(tail, fun)]
end
Testing in IEx - Recursive Functions
iex> [head | tail] = [1, 2, 3, 4, 5]
[1, 2, 3, 4, 5]
iex> head
1
iex> tail
[2, 3, 4, 5]
iex> ListProcessor.sum([1, 2, 3])
6
iex> ListProcessor.count([1, 2, 3, 4])
4
iex> ListProcessor.max([3, 1, 4, 1, 5])
5
iex> ListProcessor.reverse([1, 2, 3])
[3, 2, 1]
iex> ListBuilder.double([1, 2, 3])
[2, 4, 6]
iex> ListBuilder.filter([1, 2, 3, 4, 5], &(rem(&1, 2) == 0))
[2, 4]
List Comprehensions
List comprehensions let you create new lists in a clean, readable way:
# Square numbers from 1 to 5 - creates [1, 4, 9, 16, 25]
squares = for n <- 1..5, do: n * n
# Only even numbers - creates [2, 4, 6, 8, 10]
evens = for n <- 1..10, rem(n, 2) == 0, do: n
# Extract successful results (pattern match only :ok tuples)
results = [{:ok, 1}, {:error, "bad"}, {:ok, 2}]
successes = for {:ok, value} <- results, do: value # Creates [1, 2]
Testing in IEx - Comprehensions
iex> for n <- 1..5, do: n * n
[1, 4, 9, 16, 25]
iex> for n <- 1..10, rem(n, 2) == 0, do: n
[2, 4, 6, 8, 10]
iex> for {:ok, value} <- [{:ok, 1}, {:ok, 2}], do: value * 2
[2, 4]
Working with Nested Lists
Sometimes you'll work with lists containing other lists. List comprehensions make this easy:
# Cartesian product - all combinations
coordinates = for x <- 1..2, y <- 1..2, do: {x, y}
# Result: [{1, 1}, {1, 2}, {2, 1}, {2, 2}]
# Nested list processing
matrix = [[1, 2], [3, 4], [5, 6]]
all_elements = for row <- matrix, element <- row, do: element
# Result: [1, 2, 3, 4, 5, 6]
# With filtering
greater_than_three = for row <- matrix, element <- row, element > 3, do: element
# Result: [4, 5, 6]
Pattern Matching in Comprehensions
# Extract from tuples - gets only names, result is ["Alice", "Bob"]
people = [{"Alice", 25}, {"Bob", 30}]
names = for {name, _age} <- people, do: name
# Filter by pattern - only extracts :ok values, result is [1, 2]
results = [{:ok, 1}, {:error, "bad"}, {:ok, 2}]
successes = for {:ok, value} <- results, do: value
Testing in IEx - Nested Lists
iex> people = [{"Alice", 25}, {"Bob", 30}]
[{"Alice", 25}, {"Bob", 30}]
iex> for {name, _age} <- people, do: name
["Alice", "Bob"]
iex> results = [{:ok, 1}, {:error, "bad"}, {:ok, 2}]
[{:ok, 1}, {:error, "bad"}, {:ok, 2}]
iex> for {:ok, value} <- results, do: value
[1, 2]
iex> for x <- 1..2, y <- 1..2, do: {x, y}
[{1, 1}, {1, 2}, {2, 1}, {2, 2}]
iex> for n <- 1..3, into: %{}, do: {n, n * n}
%{1 => 1, 2 => 4, 3 => 9}
Essential List Functions
The List
module and Enum
module provide a rich set of functions for working with lists. Let's explore the most essential ones you'll use in everyday Elixir programming.
List Module Functions
The List
module contains functions specific to lists:
# Basic list information
list = [1, 2, 3, 4, 5]
# First and last elements (safe)
first = List.first(list) # 1
last = List.last(list) # 5
first_empty = List.first([]) # nil
# Insert and delete
inserted = List.insert_at(list, 2, 99)
# [1, 2, 99, 3, 4, 5]
deleted = List.delete_at(list, 2)
# [1, 2, 4, 5]
delete_value = List.delete(list, 3)
# [1, 2, 4, 5]
# Replace and update
replaced = List.replace_at(list, 2, 99)
# [1, 2, 99, 4, 5]
updated = List.update_at(list, 2, fn x -> x * 10 end)
# [1, 2, 30, 4, 5]
# Duplicate elements
duplicated = List.duplicate(:ok, 3)
# [:ok, :ok, :ok]
Enum Module Functions
Most list processing is done with Enum
functions, which work with any enumerable:
numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
# Basic transformations
doubled = Enum.map(numbers, fn x -> x * 2 end)
# [2, 4, 6, 8, 10, 12, 14, 16, 18, 20]
evens = Enum.filter(numbers, fn x -> rem(x, 2) == 0 end)
# [2, 4, 6, 8, 10]
# Reducing operations
sum = Enum.sum(numbers) # 55
product = Enum.reduce(numbers, 1, &*/2) # 3628800
max_value = Enum.max(numbers) # 10
min_value = Enum.min(numbers) # 1
# Finding elements
found = Enum.find(numbers, fn x -> x > 7 end) # 8
found_index = Enum.find_index(numbers, fn x -> x > 7 end) # 7
member = Enum.member?(numbers, 5) # true
Useful Enum Functions for Lists
data = [3, 1, 4, 1, 5, 9, 2, 6, 5, 3]
# Sorting - result is [1, 1, 2, 3, 3, 4, 5, 5, 6, 9]
sorted = Enum.sort(data)
# Descending sort - result is [9, 6, 5, 5, 4, 3, 3, 2, 1, 1]
sorted_desc = Enum.sort(data, :desc)
# Unique elements - removes duplicates, result is [3, 1, 4, 5, 9, 2, 6]
unique = Enum.uniq(data)
# Frequency counting - counts how many times each appears
frequencies = Enum.frequencies(data) # %{1 => 2, 2 => 1, 3 => 2, ...}
Testing Enum Functions
iex> data = [3, 1, 4, 1, 5, 9, 2, 6, 5, 3]
[3, 1, 4, 1, 5, 9, 2, 6, 5, 3]
iex> Enum.sort(data)
[1, 1, 2, 3, 3, 4, 5, 5, 6, 9]
iex> Enum.uniq(data)
[3, 1, 4, 5, 9, 2, 6]
iex> Enum.frequencies([1, 2, 1, 3, 2])
%{1 => 2, 2 => 2, 3 => 1}
iex> Enum.take(data, 3)
[3, 1, 4]
iex> Enum.drop(data, 3)
[1, 5, 9, 2, 6, 5, 3]
Combining Multiple Operations
defmodule DataProcessor do
# Process numbers: filter evens, square them, sum the result
def process_numbers(numbers) do
numbers
|> Enum.filter(fn x -> rem(x, 2) == 0 end) # Get evens
|> Enum.map(fn x -> x * x end) # Square them
|> Enum.sum() # Sum the squares
end
# Process transactions: calculate total deposits
def total_deposits(transactions) do
transactions
|> Enum.filter(fn tx -> tx.type == :deposit end)
|> Enum.map(fn tx -> tx.amount end)
|> Enum.sum()
end
# Process transactions: calculate total withdrawals
def total_withdrawals(transactions) do
transactions
|> Enum.filter(fn tx -> tx.type == :withdrawal end)
|> Enum.map(fn tx -> tx.amount end)
|> Enum.sum()
end
end
Testing in IEx - List Functions
iex> DataProcessor.process_numbers([1, 2, 3, 4, 5, 6, 7, 8, 9, 10])
220
iex> transactions = [%{type: :deposit, amount: 100, date: ~D[2023-01-01]}, %{type: :withdrawal, amount: 50, date: ~D[2023-01-02]}, %{type: :deposit, amount: 200, date: ~D[2023-01-03]}, %{type: :withdrawal, amount: 75, date: ~D[2023-01-04]}]
[%{type: :deposit, amount: 100, date: ~D[2023-01-01]}, ...]
iex> DataProcessor.total_deposits(transactions)
300
iex> DataProcessor.total_withdrawals(transactions)
125
iex> Enum.map([1, 2, 3], &(&1 * 2))
[2, 4, 6]
iex> [1, 2, 3, 4, 5, 6] |> Enum.filter(&(rem(&1, 2) == 0)) |> Enum.sum()
12
Best Practices and Common Patterns
Here are some simple guidelines to help you write better list code:
Do's and Don'ts
✅ DO: Use head and tail pattern matching
def sum([]), do: 0
def sum([head | tail]), do: head + sum(tail)
✅ DO: Use list comprehensions for simple transformations
squares = for n <- 1..5, do: n * n
✅ DO: Use prepending (adding to front)
new_list = [new_item | existing_list] # Fast!
Testing Best Practices
# Try these examples to see the concepts in action
iex> defmodule ListExamples do; def sum([]), do: 0; def sum([head | tail]), do: head + sum(tail); end
{:module, ListExamples, ...}
iex> ListExamples.sum([1, 2, 3, 4])
10
iex> for n <- 1..5, do: n * n
[1, 4, 9, 16, 25]
iex> existing = [2, 3, 4]
[2, 3, 4]
iex> [1 | existing]
[1, 2, 3, 4]
❌ DON'T: Use appending for building lists
# Slow - avoid this pattern
new_list = existing_list ++ [new_item]
✅ DO: Use Enum functions for data processing
result = numbers
|> Enum.filter(fn x -> x > 0 end)
|> Enum.map(fn x -> x * 2 end)
When to Use Lists
Lists are perfect for:
- Processing data in order
- Building collections piece by piece
- Recursive operations
- When you mostly work with the first element
Simple Examples
# Building a list efficiently (prepending is O(1))
def build_countdown(0, acc), do: acc # Base case
def build_countdown(n, acc), do: build_countdown(n - 1, [n | acc]) # Add n to front
# Processing with pattern matching (recursive search)
def find_first_even([]), do: nil # Empty list - not found
def find_first_even([head | tail]) do
if rem(head, 2) == 0 do
head # Found even number, return it
else
find_first_even(tail) # Keep searching in tail
end
end
Conclusion
Lists are one of the most important data structures in Elixir. In this article, we've covered:
- How lists work internally (linked structure)
- Creating and manipulating lists
- Head and tail operations
- List comprehensions
- Essential List and Enum functions
- Working with nested lists
- Best practices for list code
Understanding lists well will make you much more effective at Elixir programming. They're the foundation for many functional programming patterns and you'll use them constantly in real applications.
Further Reading
- Elixir Official Documentation - Lists
- Elixir Official Documentation - Enum
- "Programming Elixir" by Dave Thomas - Chapters on Lists and Recursion
Next Steps
With a solid understanding of lists, you're ready to explore Tuples in Elixir. Tuples complement lists perfectly by providing fast random access and representing structured data with a fixed number of elements.
In the next article, we'll explore:
- When to choose tuples over lists
- Pattern matching with tuples
- Tuple-based return values and error handling
- Practical applications in real-world Elixir code
The journey through Elixir's data structures continues!
Top comments (0)