DEV Community

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

Posted on

Learning Elixir: Lists

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

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

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] or list ++ [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: []
Enter fullscreen mode Exit fullscreen mode

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

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

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

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

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

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

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

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

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

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

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

Testing in IEx:

iex> List.duplicate(0, 5)
[0, 0, 0, 0, 0]

iex> List.duplicate("hello", 3)
["hello", "hello", "hello"]
Enter fullscreen mode Exit fullscreen mode

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

✅ DO: Use list comprehensions for simple transformations

squares = for n <- 1..5, do: n * n
Enter fullscreen mode Exit fullscreen mode

✅ DO: Use prepending (adding to front)

new_list = [new_item | existing_list]  # Fast!
Enter fullscreen mode Exit fullscreen mode

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

❌ DON'T: Use appending for building lists

# Slow - avoid this pattern
new_list = existing_list ++ [new_item]
Enter fullscreen mode Exit fullscreen mode

✅ DO: Use Enum functions for data processing

result = numbers
         |> Enum.filter(fn x -> x > 0 end)
         |> Enum.map(fn x -> x * 2 end)
Enter fullscreen mode Exit fullscreen mode

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

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

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)