Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

Elixir & Erlang/OTP  Module 2 – Values and types

Lists, tuples and ranges

How Elixir stores lists and tuples, why prepending is fast and ++ in a loop is slow, when to use each, and ranges with steps, empty and descending.

  • Beginner
  • 15 minutes
  • Examples run with elixir 1.20.4, erlang 29.1.1 and Python 3.14.8
  • By MySmartCoPilot
Free lesson, Pro answers: 15 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

What you will learn

  • Choose between a list and a tuple from what each costs to read, add to and change
  • Build a list by prepending and reversing once, and explain why ++ in a loop is slow
  • Predict the results of -- and of ++ with a value that is not a list
  • Write ranges with a step, including empty and descending ranges

Before you start

What you’ll learn · Before you start

What you will learn

  • Choose between a list and a tuple from what each costs to read, add to and change
  • Build a list by prepending and reversing once, and explain why ++ in a loop is slow
  • Predict the results of -- and of ++ with a value that is not a list
  • Write ranges with a step, including empty and descending ranges

Before you start

On this page

Adding an item to the front of an Elixir list takes the same time whether the list holds 2 items or 2 million, while adding one at the end copies the whole list. Building 20,000 items with ++ in a loop copied 199,990,000 list cells and took nearly a second on the computer that ran it, against a fraction of a millisecond for the fast way.

Below, you will see why from the way lists are stored, build lists the fast way, avoid two surprising list operators, choose between lists and tuples, and write ranges with steps, including empty and descending ones. Each output below comes from Elixir 1.20.4 with Erlang/OTP 29.1.1 underneath.

A list is a chain of cells: the front is near, the end is far

The Elixir guide says it in one line: “Lists are stored in memory as linked lists”. Each cell holds one item and a link to the rest of the list, and that one fact explains every cost below. Try the basic operations in IEx:

Lists in IEx Python · lists_session.py
from session import shell

# A list in IEx: adding at the front, joining at the end, and what happens to the original.
shell([
    "list = [2, 3]",
    "[1 | list]",
    "list ++ [4]",
    "list",
    "{hd([1, 2, 3]), tl([1, 2, 3])}",
    "length([1, 2, 3])",
    "List.last!([])",
    "System.halt()",
])

Output

$ iex
Erlang/OTP 29 [erts-17.1] [source] [64-bit] [smp:18:18] [ds:18:18:10] [async-threads:1] [jit]

Interactive Elixir (1.20.4) - press Ctrl+C to exit (type h() ENTER for help)
iex(1)> list = [2, 3]
[2, 3]
iex(2)> [1 | list]
[1, 2, 3]
iex(3)> list ++ [4]
[2, 3, 4]
iex(4)> list
[2, 3]
iex(5)> {hd([1, 2, 3]), tl([1, 2, 3])}
{1, [2, 3]}
iex(6)> length([1, 2, 3])
3
iex(7)> List.last!([])
** (ArgumentError) attempted to get the last element of an empty list
    (elixir 1.20.4) lib/list.ex:381: List.last!/1
    iex:7: (file)
iex(7)> System.halt()

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 lists_session.py

[1 | list] put 1 in front of [2, 3], and list ++ [4] added 4 at the end, yet list was still [2, 3] afterwards: list operators “never modify the existing list”, in the guide’s words, they return a new one. hd/1 gave the head and tail: The first item of a list and the list of every item after it: in [1, 2, 3] the head is 1 and the tail is [2, 3]. [x | list] builds a list from a head and a tail., the first item, and tl/1 the tail, the list of the items after it. Step through what each of those lines did to the cells:

Interactive · step by step A list as linked cells: [1 | list] shares, list ++ [4] copies Step through the lines of the IEx session. Each box is one cell of a list, an item with a link to the rest.
A list as linked cells: [1 | list] shares, list ++ [4] copies, step 6 of 6. Showing 1, 2, 3, [], 2, copied, 3, copied, 4. In focus: [].1the cell of [1 | list]2first cell of list3second cell of list[]the end of every list2, copiedfirst cell of list ++ [4]3, copiedits link now goes to 44the new last cell

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. list = [2, 3]. A list is a chain of cells. Each cell holds one item and a link to the rest of the list, and the last cell links to the empty list. [2, 3] is two cells.
  2. [1 | list]. [1 | list] makes one new cell holding 1 and links it to the cells that already exist. Nothing is copied, so it takes the same time for a list of two items or of two million.
  3. list is unchanged. Cells never change once made, so list is still [2, 3], as the fourth line showed. The new list [1, 2, 3] and the old one share the cells of 2 and 3.
  4. list ++ [4]. Appending must change the link of the last cell, and cells cannot change, so ++ copies every cell of its left list: 2 and 3 are copied, and the copy of 3 links to a new cell holding 4.
  5. The cost of ++. So ++ costs one copy per cell of its left list. Appending one item at a time copies 0 + 1 + 2 + … cells: 199,990,000 copies for a list of 20,000 items, as build_order.exs counted.
  6. Walking to the end. hd/1 and tl/1 read only the first cell. length/1 and List.last!/1 must follow every link to the end of the list, so they take longer the longer the list is.

The figure shows the two costs to remember. Prepending makes one cell and shares the rest, so it costs the same for any length. Anything that needs the end of a list walks every cell: length/1, List.last/1, and ++, which also copies every cell of its left list. The guide’s naming rule helps here: a function is called size when it takes constant time and length when it is linear, which is why lists have length/1 and tuples tuple_size/1.

New in Elixir 1.20

List.first!/1 and List.last!/1 raise ArgumentError for an empty list, as the session showed, where List.first/1 and List.last/1 return nil. Use the ! versions when an empty list is a mistake that should stop the program rather than a nil that travels on.

Build a list by prepending, then reverse it once

Because ++ copies its left list, appending items one at a time copies the list again and again. This program builds the same list of n items in two ways, prepending each item and reversing once at the end, or appending each with ++, and times both:

build_order.exs: two ways to build the same list Elixir · build_order.exs
# Building a list of n items two ways: prepending and reversing once, or appending with ++ each time.
defmodule Build do
  # Prepend each item, then reverse once at the end: n new cells, then n more for the reverse.
  def by_prepend(n), do: Enum.reverse(prepend(1, n, []))

  defp prepend(i, n, acc) when i > n, do: acc
  defp prepend(i, n, acc), do: prepend(i + 1, n, [i | acc])

  # Append each item with ++, which copies the whole list built so far, every time.
  def by_append(n), do: append(1, n, [])

  defp append(i, n, acc) when i > n, do: acc
  defp append(i, n, acc), do: append(i + 1, n, acc ++ [i])
end

defmodule Main do
  def run do
    IO.puts("      n  cells copied by ++  prepend and reverse     ++ in a loop  same list")

    for n <- [5_000, 10_000, 20_000] do
      {fast_us, fast} = :timer.tc(fn -> Build.by_prepend(n) end)
      {slow_us, slow} = :timer.tc(fn -> Build.by_append(n) end)
      # The k-th ++ copies the k - 1 cells built before it: 0 + 1 + ... + (n - 1) in all.
      copied = div(n * (n - 1), 2)

      IO.puts(
        String.pad_leading(Integer.to_string(n), 7) <>
          String.pad_leading(Integer.to_string(copied), 20) <>
          String.pad_leading(ms(fast_us), 21) <>
          String.pad_leading(ms(slow_us), 17) <>
          String.pad_leading(Atom.to_string(fast == slow), 11)
      )
    end
  end

  defp ms(us), do: :erlang.float_to_binary(us / 1000, decimals: 2) <> " ms"
end

Main.run()
Prepend and reverse, against ++ in a loop Python · build_order_session.py
from session import run

run("""
elixir build_order.exs
""")

Output

$ elixir build_order.exs
      n  cells copied by ++  prepend and reverse     ++ in a loop  same list
   5000            12497500              0.09 ms         45.91 ms       true
  10000            49995000              0.08 ms        205.88 ms       true
  20000           199990000              0.20 ms        988.65 ms       true

This output changes from run to run: the times depend on the computer and on what else it is doing; the counts of copied cells do not

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 build_order_session.py

Both ways built the same list, as the last column says. The count of copied cells explains the times: the k-th ++ copies the k − 1 cells built before it, so n appends copy 0 + 1 + … + (n − 1) = n(n − 1)/2 cells. Doubling n from 10,000 to 20,000 multiplied the copies by about four, from 49,995,000 to 199,990,000, and the time of the ++ loop grew in step, while prepending and reversing, about 2n cell operations, stayed well under a millisecond.

Key idea

Add to the front of a list, never to the end in a loop: prepending costs one cell, while ++ copies its whole left list every time.

Erlang’s Efficiency Guide gives the same advice: attach new elements to the beginning of the list, and when the order matters, “build the list in reverse order and reverse the list when you are done”. Enum.reverse/1 is one pass over the list, so the fast way costs two passes in all, however long the list is.

++ in a loop or a recursion

acc ++ [item] inside a loop copies acc on every pass, so the work grows with the square of the length. Write [item | acc] in the loop and Enum.reverse(acc) once at the end; when you only transform a list, Enum.map/2 builds the new list for you.

Checkpoint

Check: Lists as linked cells · Building a list the fast way

  1. Question 1 of 2 A list holds 1,000,000 items. Which operation takes about as long as on a list of 3 items?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: [0 | list]

    A list is a chain of cells read from the front. Only operations at the front, [x | list], hd/1 and tl/1, cost the same for every length; anything that needs the end walks the whole list.

  2. Question 2 of 2 A loop builds a list of 1,000 items with acc ++ [item], one item at a time, starting from []. How many cells do the 1,000 appends copy in all?

    Type a number.

    A hint

    The k-th append copies the k - 1 cells built before it.

    Show the answer: Question 2 of 2

    Answer: 499500 cells

    The copies are 0 + 1 + 2 + … + 999 = 1,000 × 999 / 2 = 499,500. The same formula gave 49,995,000 for 10,000 items in build_order.exs; prepending and reversing would need about 2,000 cell operations.

Subtracting removes first matches only, and a list can end in a non-list

Two list operators behave in ways that surprise people coming from other languages:

-- and an improper list Python · list_ops_session.py
from session import shell

# Two list operators that surprise: -- removes the first match only, and ++ with a non-list on the right,
# then the one place where a list that ends in a non-list is normal: IO data.
shell([
    "[1, 2, 2, 3] -- [2]",
    "[1, 2, 2, 3, 2] -- [2, 2]",
    "[1 | 2]",
    'is_list(["a", "b"] ++ "c")',
    'length(["a", "b"] ++ "c")',
    'IO.iodata_to_binary(["tea for " | "two"])',
    "System.halt()",
])

Output

$ iex
Erlang/OTP 29 [erts-17.1] [source] [64-bit] [smp:18:18] [ds:18:18:10] [async-threads:1] [jit]

Interactive Elixir (1.20.4) - press Ctrl+C to exit (type h() ENTER for help)
iex(1)> [1, 2, 2, 3] -- [2]
[1, 2, 3]
iex(2)> [1, 2, 2, 3, 2] -- [2, 2]
[1, 3, 2]
iex(3)> [1 | 2]
[1 | 2]
iex(4)> is_list(["a", "b"] ++ "c")
true
iex(5)> length(["a", "b"] ++ "c")
** (ArgumentError) errors were found at the given arguments:

  * 1st argument: not a list

    :erlang.length(["a", "b" | "c"])
    iex:5: (file)
iex(5)> IO.iodata_to_binary(["tea for " | "two"])
"tea for two"
iex(6)> System.halt()

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 list_ops_session.py

-- removes, for each item on its right, the first match on its left, and no more: [1, 2, 2, 3] -- [2] kept the second 2, and [1, 2, 2, 3, 2] -- [2, 2] removed two 2s and kept the third. To remove every copy of a value, filter the list instead, with Enum.reject/2, which the collections module of this track teaches.

The other surprise is the improper list: A list whose last link is not the empty list, such as [1 | 2]. It is legal and appears in IO data, but length/1 and most Enum functions reject it.. A list’s last cell normally links to the empty list, but [1 | 2] links to the number 2 instead, and ["a", "b"] ++ "c" made the same shape, because ++ copies its left list and attaches whatever is on its right as the last link. The result is still a list to is_list/1, yet length/1 raised ArgumentError, and most functions of Enum fail on it too. Improper lists are legal on purpose: IO data, the nested lists of strings and bytes that I/O functions accept, may end in a string instead of the empty list, and IO.iodata_to_binary(["tea for " | "two"]) joined one into “tea for two”. In ordinary code an improper list is nearly always a mistake: an item that should have been wrapped in a list, ++ ["c"].

A tuple is a fixed group: quick to read, copied to change

A tuple: A fixed-size group of values stored side by side, written {:ok, 42}. Reading a value by position is quick, but changing one builds a new tuple. keeps a fixed number of values side by side. The guide says that tuples “store elements contiguously in memory”, which makes reading by position fast and changing expensive:

Tuples in IEx Python · tuples_session.py
from session import shell

# A tuple: a fixed group of values, read by position, copied to change, and used as a tagged result.
shell([
    't = {:ok, "hello", 42}',
    "elem(t, 1)",
    'put_elem(t, 1, "world")',
    "t",
    "tuple_size(t)",
    'File.read("no_such_file.txt")',
    "System.halt()",
])

Output

$ iex
Erlang/OTP 29 [erts-17.1] [source] [64-bit] [smp:18:18] [ds:18:18:10] [async-threads:1] [jit]

Interactive Elixir (1.20.4) - press Ctrl+C to exit (type h() ENTER for help)
iex(1)> t = {:ok, "hello", 42}
{:ok, "hello", 42}
iex(2)> elem(t, 1)
"hello"
iex(3)> put_elem(t, 1, "world")
{:ok, "world", 42}
iex(4)> t
{:ok, "hello", 42}
iex(5)> tuple_size(t)
3
iex(6)> File.read("no_such_file.txt")
{:error, :enoent}
iex(7)> System.halt()

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 tuples_session.py

elem(t, 1) read the second value, counting from 0, in one step, and tuple_size/1 gave 3 just as quickly. put_elem/3 returned a new tuple and left t as it was: tuples are immutable too, so changing one value means a new tuple, although the guide notes that “all entries are shared between the old and the new tuple, except for the entry that has been replaced”. The last line shows the most common tuple of all, a tagged tuple: A tuple whose first element is an atom naming what it holds, such as {:ok, value} or {:error, reason}, the usual shape of a result that may fail.: File.read/1 returned {:error, :enoent} for a missing file, and returns {:ok, contents} when it can read one. The atom first says which case you have.

The rule of thumb follows from the costs. A list suits a collection whose number of items varies and which you go through in order; a tuple suits a fixed group of a few values read by position, such as a result, a point {x, y} or an Erlang date, {year, month, day}. Answer the two questions in the table to see the choice for your own data:

Interactive · answer and compare A list or a tuple for your data? Answer the two questions about your data. The table marks which kind fits, and why.
Does the number of values change while the program runs?
How do you mostly use the values?

Answer the questions above: the table then marks what fits your case and says why the rest does not.

Lists and tuples against the five things you do most with a group of values
Kind Read item number i Add one item Change one item Go through every item Size known in advance
List Stored as: linked cells, [1, 2, 3] Poor walks i cells from the front Good [x | list] makes one cell and shares the rest Mixed copies the cells before the item Good Enum and comprehensions read lists directly Mixed any length, which may change as the program runs
Tuple Stored as: values side by side, {1, 2, 3} Good elem/2 reads by position in one step Poor builds a new, larger tuple Mixed put_elem/3 makes a new tuple; the other entries are shared Poor not enumerable; Tuple.to_list/1 first Good fixed size, such as {:ok, value} or {x, y}
How the answers decide
  • “Does the number of values change while the program runs?” Yes, values come and go: rules out Tuple. A tuple's size is part of what it is, and adding to it builds a new tuple each time; a list grows from the front for the cost of one cell.
  • “Does the number of values change while the program runs?” No, it is always the same few: adds a caveat to List. A fixed group of a few values, such as a result {:ok, value} or a point {x, y}, reads more clearly as a tuple.
  • “How do you mostly use the values?” Go through them one by one: adds a caveat to Tuple. Tuples are not enumerable, so going through one means converting it to a list first.
  • “How do you mostly use the values?” Read a particular one by its position: adds a caveat to List. Reading item number i of a list walks i cells; if positions matter in a long list, look at a tuple or a map instead.

Most code needs both: a list of tuples, such as [{:ok, 1}, {:error, :enoent}], is a varying collection of fixed results. When the values have names rather than positions, the next lesson’s maps are the better fit.

Checkpoint

Check: List subtraction and improper lists · Tuples and tagged tuples

  1. Question 1 of 2 What does this return?

    Read the code, then choose one answer.

    [3, 1, 3, 2, 3] -- [3, 3]
    Show the answer: Question 1 of 2

    Answer: [1, 2, 3]

    For each item on its right, -- removes the first matching item on its left, as [1, 2, 2, 3, 2] -- [2, 2] kept the third 2. To remove every copy of a value, filter the list instead.

  2. Question 2 of 2 A function returns either the contents of a file or the reason it could not read it. Which shape fits best?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: A tagged tuple: {:ok, contents} or {:error, reason}

    A tagged tuple is the usual shape of a result that may succeed or fail: the atom tells the caller which case it has, and the second value carries the data or the reason, as {:error, :enoent} named a missing file.

Ranges are three numbers with a step

A range: A sequence of integers written first..last//step, such as 1..10//3. It stores only those three numbers, so 1..1_000_000 holds no list of a million values. stands for a sequence of integers without storing them. It is written first..last, or first..last//step with a step:

Ranges in IEx Python · ranges_session.py
from session import shell

# Ranges with a step, empty and descending ranges, and the warning for a range written backwards.
shell([
    "Enum.to_list(1..10//3)",
    "Enum.to_list(10..1//-3)",
    "Enum.to_list(1..0//1)",
    "5..1",
    "n = 0",
    "Enum.to_list(1..n)",
    "Enum.to_list(1..n//1)",
    "Range.size(1..10//3)",
    "5 in 1..10",
    "Enum.count(1..1_000_000)",
    "Map.from_struct(1..1_000_000)",
    "Enum.sum(1..100)",
    "System.halt()",
])

Output

$ iex
Erlang/OTP 29 [erts-17.1] [source] [64-bit] [smp:18:18] [ds:18:18:10] [async-threads:1] [jit]

Interactive Elixir (1.20.4) - press Ctrl+C to exit (type h() ENTER for help)
iex(1)> Enum.to_list(1..10//3)
[1, 4, 7, 10]
iex(2)> Enum.to_list(10..1//-3)
[10, 7, 4, 1]
iex(3)> Enum.to_list(1..0//1)
[]
iex(4)> 5..1
warning: 5..1 has a default step of -1, please write 5..1//-1 instead
  iex:4: (file)

5..1//-1
iex(5)> n = 0
0
iex(6)> Enum.to_list(1..n)
warning: Range.new/2 and first..last default to a step of -1 when last < first. Use Range.new(first, last, -1) or first..last//-1, or pass 1 if that was your intention
  (elixir 1.20.4) src/elixir.erl:382: :elixir.eval_external_handler/3
  (stdlib 8.1) erl_eval.erl:1048: :erl_eval.do_apply/7
  (stdlib 8.1) erl_eval.erl:1664: :erl_eval.expr_list/7
  (stdlib 8.1) erl_eval.erl:1660: :erl_eval.expr_list/5
  (stdlib 8.1) erl_eval.erl:754: :erl_eval.expr/6

[1, 0]
iex(7)> Enum.to_list(1..n//1)
[]
iex(8)> Range.size(1..10//3)
4
iex(9)> 5 in 1..10
true
iex(10)> Enum.count(1..1_000_000)
1000000
iex(11)> Map.from_struct(1..1_000_000)
%{first: 1, last: 1000000, step: 1}
iex(12)> Enum.sum(1..100)
5050
iex(13)> System.halt()

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 ranges_session.py

1..10//3 gave 1, 4, 7 and 10, and a negative step counts down: 10..1//-3 gave 10, 7, 4 and 1. Ranges include both ends whenever the step lands on them. 1..0//1 is empty, because no number is at least 1 and at most 0, and an empty range is a normal value. Writing 5..1 still works, but IEx printed the warning “5..1 has a default step of -1, please write 5..1//-1 instead”: the Range documentation says implicit decreasing ranges are deprecated. The next three lines show why. With n = 0, 1..n counted down and gave [1, 0], with a warning that came only as it ran, while 1..n//1 was the empty range that a loop over “1 to n” means.

Map.from_struct/1 shows what a range really is: a struct of three integers, first, last and step. So 1..1_000_000 takes no more memory than 1..3, and in, Range.size/1 and Enum.count/1 answer from the three numbers. Enum.sum(1..100) gave 5,050.

Ranges with a computed end that may come out backwards

1..n counts down when n is below 1, so for n = 0 it gave [1, 0] instead of nothing. Whenever the end is computed, write the step: 1..n//1 is empty for n = 0, and n..1//-1 counts down on purpose.

Checkpoint

Check: Ranges with a step

  1. Question 1 of 2 What does this return?

    Read the code, then choose one answer.

    Enum.to_list(2..11//3)
    Show the answer: Question 1 of 2

    Answer: [2, 5, 8, 11]

    first..last//step starts at first and adds step while the value stays within last, both ends included: 2, 5, 8 and 11, as 1..10//3 gave 1, 4, 7 and 10.

  2. Question 2 of 2 A function returns Enum.sum(1..n), the sum of 1 to n. What does it return for n = 0, and how should the range be written?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: It returns 1, because 1..0 counts down to [1, 0]; write 1..n//1, which is empty for n = 0 and sums to 0

    When the end of a range is computed, write the step: 1..n//1 is the empty range for n = 0, so the sum of nothing is 0, while 1..n counts down, with no more than a warning when it runs.

Practise: rotate a list

Write Rotate.left/2 and Rotate.right/2. left(list, n) moves the first n items to the end, so left([1, 2, 3, 4, 5], 2) is [3, 4, 5, 1, 2], and right(list, n) moves the last n items to the front. Any whole n must work: one larger than the list goes round more than once, and a negative one turns the other way. Copy the two files into an empty folder:

practice/rotate.ex: the starter Elixir · practice/rotate.ex
defmodule Rotate do
  @moduledoc "Rotates lists: the items that fall off one end come back at the other."

  @doc "Moves the first n items to the end. Any whole n works: larger than the list, or negative."
  def left(list, _n) do
    # Drop the underscore once you use the argument.
    list
  end

  @doc "Moves the last n items to the front. right(list, n) is the same as left(list, -n)."
  def right(list, _n) do
    list
  end
end
practice/rotate_test.exs: the tests Elixir · practice/rotate_test.exs
Code.require_file("rotate.ex", __DIR__)
ExUnit.start(seed: 0)

defmodule RotateTest do
  use ExUnit.Case

  test "left moves the first n items to the end" do
    assert Rotate.left([1, 2, 3, 4, 5], 2) == [3, 4, 5, 1, 2]
  end

  test "right moves the last n items to the front" do
    assert Rotate.right([1, 2, 3, 4, 5], 2) == [4, 5, 1, 2, 3]
  end

  test "n larger than the list goes round more than once" do
    assert Rotate.left([1, 2, 3], 4) == [2, 3, 1]
    assert Rotate.right([1, 2, 3], 7) == [3, 1, 2]
  end

  test "a negative n turns the other way" do
    assert Rotate.left([1, 2, 3], -1) == [3, 1, 2]
  end

  test "an empty list stays empty" do
    assert Rotate.left([], 3) == []
    assert Rotate.right([], 3) == []
  end
end

Run elixir rotate_test.exs. The starter returns every list as it was, so only the test of the empty list passes:

The tests against the starter Python · practice_session.py
from session import run

# The tests against the starter, which returns every list unchanged.
run("""
cd practice
!elixir rotate_test.exs
""")

Output

$ cd practice
$ elixir rotate_test.exs
Running ExUnit with seed: 0, max_cases: 36



  1) test left moves the first n items to the end (RotateTest)
     rotate_test.exs:7
     Assertion with == failed
     code:  assert Rotate.left([1, 2, 3, 4, 5], 2) == [3, 4, 5, 1, 2]
     left:  [1, 2, 3, 4, 5]
     right: [3, 4, 5, 1, 2]
     stacktrace:
       rotate_test.exs:8: (test)



  2) test right moves the last n items to the front (RotateTest)
     rotate_test.exs:11
     Assertion with == failed
     code:  assert Rotate.right([1, 2, 3, 4, 5], 2) == [4, 5, 1, 2, 3]
     left:  [1, 2, 3, 4, 5]
     right: [4, 5, 1, 2, 3]
     stacktrace:
       rotate_test.exs:12: (test)



  3) test n larger than the list goes round more than once (RotateTest)
     rotate_test.exs:15
     Assertion with == failed
     code:  assert Rotate.left([1, 2, 3], 4) == [2, 3, 1]
     left:  [1, 2, 3]
     right: [2, 3, 1]
     stacktrace:
       rotate_test.exs:16: (test)



  4) test a negative n turns the other way (RotateTest)
     rotate_test.exs:20
     Assertion with == failed
     code:  assert Rotate.left([1, 2, 3], -1) == [3, 1, 2]
     left:  [1, 2, 3]
     right: [3, 1, 2]
     stacktrace:
       rotate_test.exs:21: (test)

.
Finished in 0.02 seconds (0.02s on load, 0.00s async, 0.00s sync)

Result: 1/5 passed
Failed: 4 tests
[exit status 2]

This output changes from run to run: ExUnit prints how long the tests took, and max_cases follows the computer's processors

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 practice_session.py

Two functions do the work: Integer.mod/2, from the first lesson of this module, and Enum.split/2. Integer.mod(n, length(list)) turns any n, negative or too large, into a position from 0 up to the length of the list. Enum.split(list, k) returns a tuple of the first k items and the rest, {front, back}, and back ++ front joins them, once, so the whole rotation is a couple of passes over the list. An empty list needs a check of its own, because Integer.mod/2 cannot divide by a length of 0. A finished version passes all five tests:

The tests against finished left/2 and right/2 Python · finished_session.py
from session import run

# The same tests against finished left/2 and right/2.
run("""
cd finished
cp ../practice/rotate_test.exs .
elixir rotate_test.exs
""")

Output

$ cd finished
$ cp ../practice/rotate_test.exs .
$ elixir rotate_test.exs
Running ExUnit with seed: 0, max_cases: 36

.....
Finished in 0.01 seconds (0.01s on load, 0.00s async, 0.00s sync)

Result: 5 passed

This output changes from run to run: ExUnit prints how long the tests took, and max_cases follows the computer's processors

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 finished_session.py

Then try the self-check: it covers lists, building them, the two surprising operators, tuples and ranges, asks about whichever you know least first, and explains each answer as soon as you give it:

Practice that adapts to you

Practise this lesson

13 questions about 5 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.

  1. Question 1 of 13 What does this return?

    Read the code, then choose one answer.

    {hd([3, 5, 7]), tl([3, 5, 7])}
    Show the answer: Question 1 of 13

    Answer: {3, [5, 7]}

    The head is the first item of a list and the tail is the list of the remaining items: 3 and [5, 7]. Both only look at the first cell, so they take the same time for any length.

  2. Question 2 of 13 Why is the function called length/1 for lists but tuple_size/1 for tuples?

    Choose one answer.

    Show the answer: Question 2 of 13

    Answer: Elixir calls a function size when it runs in constant time and length when it walks the data

    The lists-and-tuples guide names functions size when the operation is in constant time and length when it is linear, so the name tells you the cost: length/1 for lists, tuple_size/1 and byte_size/1 for tuples and binaries.

  3. Question 3 of 13 What does List.first!([]) do in Elixir 1.20?

    Choose one answer.

    Show the answer: Question 3 of 13

    Answer: It raises ArgumentError

    List.first!/1 and List.last!/1, added in Elixir 1.20, raise ArgumentError, "attempted to get the last element of an empty list" for last!, where List.first/1 and List.last/1 return nil.

  4. Question 4 of 13 Why is acc ++ [item] slow inside a loop?

    Choose one answer.

    Show the answer: Question 4 of 13

    Answer: ++ copies its whole left list every time, and acc grows on every pass

    To add at the end, ++ copies every cell of its left list, so n appends copy n(n - 1)/2 cells: 199,990,000 for 20,000 items in build_order.exs.

  5. Question 5 of 13 Which change makes a loop that appends with acc ++ [x] fast and keeps the items in their order?

    Choose one answer.

    Show the answer: Question 5 of 13

    Answer: Prepend with [x | acc] in the loop, then call Enum.reverse/1 once at the end

    Build in reverse with [x | acc] and reverse once when the loop ends, the advice of Erlang's Efficiency Guide. For a plain transformation of a list, Enum.map/2 does this for you.

  6. Question 6 of 13 Appending 10,000 items one at a time with ++ copied 49,995,000 cells. How many cells does appending 40,000 items copy?

    Type a number.

    A hint

    Four times the items means about sixteen times the copies.

    Show the answer: Question 6 of 13

    Answer: 799980000 cells (anything from 779980000 to 819980000 counts)

    n appends copy n(n - 1)/2 cells, so 40,000 appends copy 40,000 × 39,999 / 2 = 799,980,000, about 16 times as many for 4 times the items. The work grows with the square of the length.

  7. Question 7 of 13 What is [1 | 2]?

    Choose one answer.

    Show the answer: Question 7 of 13

    Answer: An improper list, whose last link is 2 instead of the empty list

    A proper list ends with a link to []. [1 | 2] ends with 2, which makes it improper: legal, used in IO data, and rejected by length/1 and most Enum functions.

  8. Question 8 of 13 What does length(["a"] ++ "b") do?

    Choose one answer.

    Show the answer: Question 8 of 13

    Answer: It raises ArgumentError, because ++ made the improper list ["a" | "b"]

    With a non-list on its right, ++ builds an improper list, and length/1 raises "not a list". The usual fix is to wrap the item, ["a"] ++ ["b"].

  9. Question 9 of 13 What does this return?

    Read the code, then choose one answer.

    elem({:a, :b, :c}, 1)
    Show the answer: Question 9 of 13

    Answer: :b

    elem(tuple, i) reads the value at position i, counting from 0, in one step, because a tuple stores its values side by side.

  10. Question 10 of 13 t = {1, 2, 3}, then put_elem(t, 0, 9) runs. What is t afterwards?

    Choose one answer.

    Show the answer: Question 10 of 13

    Answer: {1, 2, 3}

    Every operation on a tuple returns a new tuple, as put_elem(t, 1, "world") did in the session while t kept "hello". Bind the result to keep it: t = put_elem(t, 0, 9).

  11. Question 11 of 13 A game keeps the position of each of 10,000 players and updates every position 30 times a second. A position is two numbers. Which shapes fit?

    Choose one answer.

    Show the answer: Question 11 of 13

    Answer: A tuple {x, y} for each position, in a collection of players, such as a map from player to position

    Use a tuple for a small fixed group read by position and a collection for a varying number of items. The next lesson's maps give each player's position a key.

  12. Question 12 of 13 Which range gives 5, 4, 3, 2 and 1 without a warning?

    Choose one answer.

    Show the answer: Question 12 of 13

    Answer: 5..1//-1

    A descending range needs its negative step written: 5..1//-1. Writing the step also turns a range that would count the wrong way into an empty one instead.

  13. Question 13 of 13 How much memory does the range 1..10_000_000 hold, compared with 1..10?

    Choose one answer.

    Show the answer: Question 13 of 13

    Answer: The same, because a range stores only its first, last and step

    A range is a struct of first, last and step, so in, Range.size/1 and Enum.count/1 answer from three numbers without building a list.

Interview questions

Warm-up: when do you use a tuple and when a list in Elixir?

Use a list for a collection whose number of items varies, which you go through in order or build up as you go, and a tuple for a fixed group of a few values, read by position. The costs decide it. A list is a chain of linked cells: prepending is instant and shares the rest, but length/1, reading item i and ++ walk the list. A tuple stores its values side by side: elem/2 and tuple_size/1 are instant, but changing a value with put_elem/3 builds a new tuple. That is why functions return tagged tuples, such as {:ok, contents} or {:error, :enoent} from File.read/1: always two values, the first an atom that names the case. And it is why a list of tuples, such as a list of results, is so common.

Two questions about building and queueing lists:

Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.

  1. Fresher to mid

    A function builds a report of 50,000 lines by appending each line with acc ++ [line], and it takes several seconds. Explain where the time goes, estimate how it grows with the number of lines, and rewrite it.

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. Fresher to mid

    A service keeps waiting jobs in a list, adds each new job with jobs ++ [job] and takes the next one with hd/1 and tl/1. With 100,000 jobs waiting it slows to a crawl. Explain why, and choose a structure in which adding and taking are both cheap.

    Model answer, Pro. First step and follow-ups

Key takeaways

  • A list is a chain of linked cells: [x | list] adds one cell and shares the rest, while length/1, List.last/1 and ++ walk the list, and ++ copies its whole left list.
  • Build lists by prepending and reversing once: 20,000 appends with ++ copied 199,990,000 cells, while prepending and reversing took a fraction of a millisecond.
  • -- removes only the first match for each item on its right, and ++ with a non-list on the right makes an improper list that length/1 rejects.
  • A tuple is a fixed group of values side by side, read in one step with elem/2 and copied by put_elem/3; tagged tuples such as {:ok, value} and {:error, reason} are how functions report results.
  • A range is a struct of three integers, first..last//step: it holds no list, an empty range is normal, and a descending range needs its negative step written.
  • New in 1.20, List.first!/1 and List.last!/1 raise for an empty list instead of returning nil.

Terms in this lesson

head and tail
The first item of a list and the list of every item after it: in [1, 2, 3] the head is 1 and the tail is [2, 3]. [x | list] builds a list from a head and a tail.
improper list
A list whose last link is not the empty list, such as [1 | 2]. It is legal and appears in IO data, but length/1 and most Enum functions reject it.
range
A sequence of integers written first..last//step, such as 1..10//3. It stores only those three numbers, so 1..1_000_000 holds no list of a million values.
tagged tuple
A tuple whose first element is an atom naming what it holds, such as {:ok, value} or {:error, reason}, the usual shape of a result that may fail.
tuple
A fixed-size group of values stored side by side, written {:ok, 42}. Reading a value by position is quick, but changing one builds a new tuple.

References

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.