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 5 – Recursion, collections and data modelling

Comprehensions with for

Write Elixir for comprehensions with generators, filters and patterns, collect into maps, strings and totals, and take binaries apart byte by byte.

  • Intermediate
  • 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

  • Predict the order and number of the results of a comprehension with several generators
  • Combine generators, filters and patterns, and predict which elements a pattern skips
  • Build maps, strings, sets and single values from a comprehension with into, reduce and uniq
  • Iterate over binaries with bitstring generators, and detect bytes a generator leaves over

Before you start

What you’ll learn · Before you start

What you will learn

  • Predict the order and number of the results of a comprehension with several generators
  • Combine generators, filters and patterns, and predict which elements a pattern skips
  • Build maps, strings, sets and single values from a comprehension with into, reduce and uniq
  • Iterate over binaries with bitstring generators, and detect bytes a generator leaves over

Before you start

On this page

for x <- 1..3, y <- [:a, :b], do: {x, y} returns six tuples, every pairing of three numbers with two atoms, from one line that would take two nested Enum calls to write. A comprehension takes elements from one or more collections, keeps the ones that pass its filters and match its patterns, and collects a result for each, into a list or into whatever you ask for.

Below, you will predict the order of the results of several generators, filter elements and skip them by their shape, collect into maps, strings, sets and totals, and take a binary apart three bytes at a time. Elixir 1.20.4 printed every result here, on Erlang/OTP 29.1.1, whose own shell runs the Erlang session at the end of the second section.

Generators give every combination, in nested order

A comprehension: A for expression that takes elements from one or more generators, keeps those that pass its filters, and collects the value of its body for each one: into a list, unless into: or reduce: asks for something else. starts with for and one or more generator: The part of a comprehension written pattern <- collection, which hands its elements one at a time to the rest of the comprehension. Elements that do not match the pattern are skipped; several generators give every combination., each written pattern <- collection. The comprehensions guide puts the rule for several generators in one line: “Multiple generators can also be used to calculate the Cartesian product of two lists”. Try one, two and three kinds of generator:

One generator, two generators, and a generator that depends on the one before Python · generators_session.py
from session import shell

# One generator, two generators, and a generator that uses the value of the one before it.
shell([
    "for x <- [1, 2, 3], do: x * 100",
    "for x <- 1..3, y <- [:a, :b], do: {x, y}",
    'for size <- ["S", "M"], colour <- ["red", "blue"], do: size <> "-" <> colour',
    "for x <- [1, 2], y <- [x, x * 10], do: y",
    "for x <- [], y <- [:a, :b], do: {x, y}",
    "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)> for x <- [1, 2, 3], do: x * 100
[100, 200, 300]
iex(2)> for x <- 1..3, y <- [:a, :b], do: {x, y}
[{1, :a}, {1, :b}, {2, :a}, {2, :b}, {3, :a}, {3, :b}]
iex(3)> for size <- ["S", "M"], colour <- ["red", "blue"], do: size <> "-" <> colour
["S-red", "S-blue", "M-red", "M-blue"]
iex(4)> for x <- [1, 2], y <- [x, x * 10], do: y
[1, 10, 2, 20]
iex(5)> for x <- [], y <- [:a, :b], do: {x, y}
[]
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 generators_session.py

One generator gave one result per element, [100, 200, 300]. Two generators gave six tuples, and the order is the point: for each x the second generator runs from its start, so the last generator varies fastest, as the inner loop of two nested loops would. Step through the six results:

Interactive · step by step for x <- 1..3, y <- [:a, :b]: the six results in order Step through the comprehension. The left column is the first generator; the right one fills with results.
for x <- 1..3, y <- [:a, :b]: the six results in order, step 6 of 6. Showing x = 1, x = 2, x = 3, {1, :a}, {1, :b}, {2, :a}, {2, :b}, {3, :a}, {3, :b}.:a:b:a:b:a:bx = 1x = 2x = 3{1, :a}{1, :b}{2, :a}{2, :b}{3, :a}{3, :b}

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

Every step as text
  1. The first x. The first generator takes its first element: x is 1. For this x, the second generator starts from the beginning of [:a, :b].
  2. {1, :a}. y takes :a, and the body {x, y} gives the first result, {1, :a}.
  3. {1, :b}. y takes :b, the last element of its list, giving {1, :b}. The second generator has run out for x = 1.
  4. The next x. So the first generator moves on, x = 2, and the second starts again from :a: {2, :a}, then {2, :b}.
  5. The last x. x = 3 gives {3, :a} and {3, :b}. The last generator varies fastest, like the inner loop of two nested loops.
  6. Six results. Three values of x times two of y make six results, every combination once, in this order. An empty generator anywhere makes zero.

A later generator may use the variables of an earlier one: y <- [x, x * 10] gave [1, 10, 2, 20]. And an empty generator anywhere means no combination at all, so for x <- [], … returned [] without running the body.

That last point has a catch in Elixir 1.20, whose type checker infers the types of variables in code without type annotations. Tickets.print_all/2 uses ticket as an atom inside a comprehension and as a number after it:

typed.ex Elixir · typed.ex
# Prints a line for each item, labelled with the ticket, then returns the next
# ticket number. The ticket is used as an atom inside the comprehension and as a
# number after it.
defmodule Tickets do
  def print_all(ticket, items) do
    for item <- items do
      IO.puts("#{Atom.to_string(ticket)}: #{item}")
    end

    ticket + 1
  end
end
Compiled with Elixir 1.20.4, then called with no items Python · typed_session.py
from session import run

# Compiling the module shows the type checker's warning; the call with no items still runs.
run("""
elixirc typed.ex
elixir -e "IO.inspect(Tickets.print_all(7, []))"
""")

Output

$ elixirc typed.ex
    warning: incompatible types given to Kernel.+/2:

        ticket + 1

    given types:

        -dynamic(atom())-, integer()

    but expected one of:

        #1
        integer(), integer()

        #2
        integer(), float()

        #3
        float(), integer()

        #4
        float(), float()

    where "ticket" was given the type:

        # type: dynamic(atom())
        # from: typed.ex:7:23
        Atom.to_string(ticket)

    type warning found at:
    │
 10 │     ticket + 1
    │            ~
    │
    └─ typed.ex:10:12: Tickets.print_all/2
$ elixir -e "IO.inspect(Tickets.print_all(7, []))"
8

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

Elixir 1.20: the type checker assumes a comprehension runs

Compiling typed.ex warned that ticket + 1 gets an atom, yet the call with no items returned 8, because the body never ran. The type system guide lists this as a known false positive: “For comprehensions in Elixir assume they are executed at least once.” Its suggested workaround is to wrap the comprehension in if list != [] do; here, a ticket that is either an atom or a number is worth fixing anyway.

Checkpoint

Check: Generators and the order of results

  1. Question 1 of 2 What does this return?

    Read the code, then choose one answer.

    for x <- [:p, :q], y <- 1..2, do: {y, x}
    Show the answer: Question 1 of 2

    Answer: [{1, :p}, {2, :p}, {1, :q}, {2, :q}]

    Several generators give every combination, in the order of nested loops with the last generator innermost, as for x <- 1..3, y <- [:a, :b] gave {1, :a} and {1, :b} before {2, :a}.

  2. Question 2 of 2 How many results does for a <- 1..4, b <- 1..5, a < b, do: {a, b} return?

    Type a number.

    A hint

    For each a, count the b from 1 to 5 that are larger than it.

    Show the answer: Question 2 of 2

    Answer: 10 results

    a = 1 pairs with 2, 3, 4 and 5; a = 2 with 3, 4 and 5; a = 3 with 4 and 5; a = 4 with 5. That is 4 + 3 + 2 + 1 = 10 of the 20 combinations; the filter drops the other 10.

Filters keep what passes, and a pattern skips what does not match

A filter is an ordinary expression written after a generator: the for/1 documentation says “Filters must evaluate to truthy values (everything but nil and false)”, and elements for which a filter returns false or nil are dropped. A generator’s pattern filters too, by shape. Try both:

payments.ex Elixir · payments.ex
# The amount of a successful payment result; there is no clause for an error.
defmodule Payments do
  def amount({:ok, amount}), do: amount
end
A filter, patterns in generators, and the same pattern in a function Python · filters_session.py
from session import shell

# A filter, patterns in generators, and the same pattern in a function given to Enum.map/2.
shell([
    "for x <- 1..10, rem(x, 3) == 0, do: x * x",
    "IO.inspect(for(x <- 1..10, rem(x, 3) == 0, do: x * x), charlists: :as_lists)",
    "results = [{:ok, 1200}, {:error, :timeout}, {:ok, 450}, {:error, :declined}]",
    "for {:ok, amount} <- results, do: amount",
    "for {:error, reason} <- results, do: reason",
    'for {item, price} when price > 40 <- %{"tea" => 30, "toast" => 45, "idli" => 60}, do: item',
    "Enum.map(results, &Payments.amount/1)",
    "System.halt()",
], command="iex payments.ex")

Output

$ iex payments.ex
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)> for x <- 1..10, rem(x, 3) == 0, do: x * x
~c"\t$Q"
iex(2)> IO.inspect(for(x <- 1..10, rem(x, 3) == 0, do: x * x), charlists: :as_lists)
[9, 36, 81]
~c"\t$Q"
iex(3)> results = [{:ok, 1200}, {:error, :timeout}, {:ok, 450}, {:error, :declined}]
[ok: 1200, error: :timeout, ok: 450, error: :declined]
iex(4)> for {:ok, amount} <- results, do: amount
[1200, 450]
iex(5)> for {:error, reason} <- results, do: reason
[:timeout, :declined]
iex(6)> for {item, price} when price > 40 <- %{"tea" => 30, "toast" => 45, "idli" => 60}, do: item
["idli", "toast"]
iex(7)> Enum.map(results, &Payments.amount/1)
** (FunctionClauseError) no function clause matching in Payments.amount/1

    The following arguments were given to Payments.amount/1:

        # 1
        {:error, :timeout}

    payments.ex:3: Payments.amount/1
    (elixir 1.20.4) lib/enum.ex:1725: Enum."-map/2-lists^map/1-1-"/2
    (elixir 1.20.4) lib/enum.ex:1725: Enum."-map/2-lists^map/1-1-"/2
    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 filters_session.py

The first line returned the squares of 3, 6 and 9, yet IEx printed ~c"\t$Q": [9, 36, 81] is a list of printable codes, which IEx shows as a charlist, as the strings lesson explained, and charlists: :as_lists showed the numbers. results printed in keyword-list form for the same reason of display: it is a list of two-element tuples whose first elements are atoms, which is what a keyword list is.

for {:ok, amount} <- results returned [1200, 450]. The guide states the rule: “all non-matching patterns are ignored”, so the two errors were skipped without a word, and {:error, reason} <- results picked out the other two. A guard after the pattern, when price > 40, filters in the generator itself. The same pattern as the only clause of Payments.amount/1 behaved the other way: given {:error, :timeout} by Enum.map/2, it raised FunctionClauseError. Step through what the generator did:

Interactive · stage by stage for {:ok, amount} <- results: what the pattern keeps Step through the four results. Each one is matched against the generator's pattern before the body can run.
for {:ok, amount} <- results: what the pattern keeps, step 5 of 5. Showing The next result ({:error, :declined}), Match {:ok, amount} (in a function: no skipping), Body: amount (collected: [1200, 450]), Skipped (2 results skipped). In focus: Match {:ok, amount}.does not matchThe next result{:error, :declined}Match {:ok, amount}in a function: no skippingBody: amountcollected: [1200, 450]Skipped2 results skipped

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

Every step as text
  1. {:ok, 1200}. The first result, {:ok, 1200}, matches the pattern, so amount is 1200 and the body puts it into the result.
  2. {:error, :timeout}. {:error, :timeout} does not match {:ok, amount}. The comprehension skips it: no error is raised, and nothing is collected.
  3. {:ok, 450}. {:ok, 450} matches, and 450 joins the list.
  4. {:error, :declined}. The last result is skipped like the first error. The comprehension returns [1200, 450], and the two failures have left no trace.
  5. The same pattern in a function. Payments.amount/1 has the same pattern as its only clause. Given {:error, :timeout} by Enum.map/2, it raised FunctionClauseError instead of skipping.

Losing errors in a generator pattern

for {:ok, row} <- parsed, do: insert(row) drops every {:error, reason} silently, so a bad import looks like a good one. When failures matter, split first, with Enum.split_with/2 or two comprehensions, and report or count the errors; when an error must stop the work, use a function without a clause for it, as Payments.amount/1 was.

Erlang has list comprehensions too, with the pattern and the result written the other way round. Its shell on Erlang/OTP 29.1.1 shows two things Elixir’s for does not have:

Comprehensions in the Erlang shell Python · erlang_session.py
from session import shell

# Comprehensions in the Erlang shell of Erlang/OTP 29.1.1.
shell([
    "[V || {ok, V} <- [{ok, 1200}, {error, timeout}, {ok, 450}]].",
    "[V || {ok, V} <:- [{ok, 1200}, {error, timeout}, {ok, 450}]].",
    "[X, -X || X <- [1, 2, 3]].",
    "halt().",
], command="erl", kind="erl")

Output

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

Eshell V17.1 (press Ctrl+G to abort, type help(). for help)
1> [V || {ok, V} <- [{ok, 1200}, {error, timeout}, {ok, 450}]].
[1200,450]
2> [V || {ok, V} <:- [{ok, 1200}, {error, timeout}, {ok, 450}]].
** exception error: no match of right hand side value {error,timeout}
3> [X, -X || X <- [1, 2, 3]].
[1,-1,2,-2,3,-3]
4> halt().

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

With <-, Erlang skips the elements that do not match, as Elixir does. With the strict generator <:- the same comprehension raised on {error, timeout}. And [X, -X || X <- [1, 2, 3]] emitted two elements for each one it took.

Erlang/OTP 29: several elements per iteration

The OTP 29.0 release notes list multi-valued comprehensions, EEP 78, among the highlights; on Erlang/OTP 28 the same line is a syntax error. Elixir’s for collects one value per iteration, so to emit two, return a list and flatten it, or use Enum.flat_map/2.

Checkpoint

Check: Filters and patterns in generators

  1. Question 1 of 2 What does this return?

    Read the code, then choose one answer.

    for {:paid, n} <- [{:paid, 300}, {:due, 120}, {:paid, 45}], do: n
    Show the answer: Question 1 of 2

    Answer: [300, 45]

    A pattern on the left of <- is a filter by shape: matching elements bind its variables and reach the body, the others are skipped silently, as for {:ok, amount} <- results kept 1200 and 450.

  2. Question 2 of 2 The filter here is just x. What does this return?

    Read the code, then choose one answer.

    for x <- [0, nil, false, "", []], x, do: x
    Show the answer: Question 2 of 2

    Answer: [0, "", []]

    Filters must evaluate to truthy values, everything but nil and false, so a filter on the element itself drops exactly the nils and falses.

into, reduce and uniq decide what a comprehension builds

A comprehension returns a list unless told otherwise. :into takes any collectable: A value that results can be put into, because its type implements the Collectable protocol, such as a list, a map, a MapSet or a binary. The into: option of for and Enum.into/2 take one.: the for/1 documentation says it “accepts any structure as long as it implements the Collectable protocol”. :reduce turns the body into clauses that receive the accumulator, as Enum.reduce/3 does, and :uniq keeps each result only once:

A map, a string, a total, unique results and a set Python · collect_session.py
from session import shell

# What a comprehension builds: a map, a string, a single value, a list without repeats, a set.
shell([
    'menu = %{"tea" => 30, "toast" => 45, "idli" => 60}',
    "for {item, price} <- menu, into: %{}, do: {item, price + 5}",
    'for <<c <- "chai latte">>, c != ?\\s, into: "", do: <<c>>',
    "for price <- [120, 45, 300], reduce: 0, do: (total -> total + price)",
    "for x <- [3, 1, 3, 2, 1], uniq: true, do: x * 100",
    "for x <- 1..6, into: MapSet.new(), do: rem(x, 3)",
    "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)> menu = %{"tea" => 30, "toast" => 45, "idli" => 60}
%{"idli" => 60, "tea" => 30, "toast" => 45}
iex(2)> for {item, price} <- menu, into: %{}, do: {item, price + 5}
%{"idli" => 65, "tea" => 35, "toast" => 50}
iex(3)> for <<c <- "chai latte">>, c != ?\s, into: "", do: <<c>>
"chailatte"
iex(4)> for price <- [120, 45, 300], reduce: 0, do: (total -> total + price)
465
iex(5)> for x <- [3, 1, 3, 2, 1], uniq: true, do: x * 100
[300, 100, 200]
iex(6)> for x <- 1..6, into: MapSet.new(), do: rem(x, 3)
MapSet.new([0, 1, 2])
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 collect_session.py

into: %{} built a map from the {item, price} pairs, so the prices rose by 5 and the result stayed a map, the job that needed Map.new/2 in the Enum lesson. A bitstring generator with into: "" removed the space from “chai latte”, one byte at a time. reduce: 0 added up the prices to 465: the body is written as total -> total + price, and the documentation says the left side of -> “receives the accumulated value of the previous iteration”. uniq: true dropped the repeated 300 and 100, and into: MapSet.new() kept the three different remainders.

Comprehensions and Enum pipelines overlap. Answer the two questions about a loop of your own to see which fits:

Interactive · answer and compare A comprehension or an Enum pipeline? Answer the questions about your loop. The table marks which way of writing it fits, and why.
How many collections does the loop take its elements from?
Can the answer be known before the end of the collection?

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

for and Enum against five things a loop over collections may need
Written as Combines collections Skips by shape Builds a map, string, set Stops early Many steps in a row
A for comprehension Looks like: for {:ok, a} <- results, a > 0, do: a Good two generators give every combination, six for 3 × 2 Good a pattern left of <- skips what does not match Good into: %{}, into: "" and into: MapSet.new() Poor visits every element; it cannot stop at the first match Mixed one generator, filters and one body; a longer chain gets crowded
An Enum pipeline Looks like: results |> Enum.filter(…) |> Enum.map(…) Mixed needs Enum.flat_map/2 around Enum.map/2 for each extra list Mixed a pattern in fn raises FunctionClauseError; filter first Good Map.new/2, Enum.join/2, MapSet.new/2 Good find/2, any?/2 and reduce_while/3 stop at the answer Good each step a named function, read from top to bottom
How the answers decide
  • “How many collections does the loop take its elements from?” Two or more, in every combination: adds a caveat to An Enum pipeline. Every combination of two lists is one line with two generators; with Enum it is a flat_map around a map, harder to read.
  • “Can the answer be known before the end of the collection?” Yes, it can stop at the first match: rules out A for comprehension. A comprehension visits every element; find/2, any?/2 and reduce_while/3 stop as soon as they know.
  • “How many collections does the loop take its elements from?” One and “Can the answer be known before the end of the collection?” No, every element counts: Both fit. Pick for when a pattern or :into says it best, and a pipeline when the work has several named steps.

Key idea

A comprehension is generators and filters, then a body, collected into a list by default; into: changes what it builds, reduce: turns it into a single value, and uniq: removes repeats. It always visits every element.

Checkpoint

Check: Collecting with into, reduce and uniq

  1. Question 1 of 2 What does this return?

    Read the code, then choose one answer.

    for {k, v} <- [a: 1, b: 2], into: %{}, do: {v, k}
    Show the answer: Question 1 of 2

    Answer: %{1 => :a, 2 => :b}

    into: accepts any collectable. For a map, the body returns {key, value} tuples, as the menu with every price raised by 5 did.

  2. Question 2 of 2 What does this return?

    Read the code, then choose one answer.

    for x <- [4, 7, 1], reduce: 0, do: (acc -> max(acc, x))
    Show the answer: Question 2 of 2

    Answer: 7

    With reduce:, the body is written as clauses whose left side receives the accumulator, and the comprehension returns the final accumulator, as reduce: 0 added up the prices to 465.

Bitstring generators walk a binary

A bitstring generator: A generator written <<pattern <- binary>>, which takes a binary apart piece by piece, such as <<r::8, g::8, b::8 <- pixels>> for three bytes at a time. A last piece too short for the pattern is skipped without an error. takes a binary apart instead of a list, piece by piece, with a binary pattern on the left of <-. The comprehensions guide’s example is pixels, and pixels.exs decodes nine bytes of red, green and blue values into tuples and back:

pixels.exs Elixir · pixels.exs
# An image's pixels as raw bytes, three to a pixel (red, green, blue), turned
# into tuples and back with bitstring generators.
pixels = <<255, 0, 0, 0, 128, 255, 10, 20, 30>>
IO.puts("#{byte_size(pixels)} bytes")

# Each step of the generator takes the next three bytes, one 8-bit number each.
tuples = for <<r::8, g::8, b::8 <- pixels>>, do: {r, g, b}
IO.inspect(tuples, label: "pixels")

# A filter and a calculation on each pixel: the grey value of the bright ones.
grey = for {r, g, b} <- tuples, r + g + b > 100, do: div(r + g + b, 3)
IO.inspect(grey, label: "grey values of the bright pixels", charlists: :as_lists)

# into: <<>> collects binaries, so the tuples become bytes again.
back = for {r, g, b} <- tuples, into: <<>>, do: <<r, g, b>>
IO.inspect(back == pixels, label: "back to the same bytes")

# Two bytes too many: the generator takes the one whole pixel and skips the rest.
IO.inspect(for(<<r::8, g::8, b::8 <- <<255, 0, 0, 7, 8>> >>, do: {r, g, b}), label: "from 5 bytes")
Bytes to pixels and back Python · pixels_session.py
from session import run

run("""
elixir pixels.exs
""")

Output

$ elixir pixels.exs
9 bytes
pixels: [{255, 0, 0}, {0, 128, 255}, {10, 20, 30}]
grey values of the bright pixels: [85, 127]
back to the same bytes: true
from 5 bytes: [{255, 0, 0}]

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

Each step of <<r::8, g::8, b::8 <- pixels>> took the next three bytes as three 8-bit numbers, so 9 bytes became three tuples. The filter and the body then worked on tuples as in any comprehension, and into: <<>> collected the binaries <<r, g, b>> back into the same 9 bytes. Step through it, then through the binary of 5 bytes:

Interactive · step by step <<r::8, g::8, b::8 <- pixels>>: three bytes a step Step through the generator. The left column is the binary, three bytes to a box; the right one is each result.
<<r::8, g::8, b::8 <- pixels>>: three bytes a step, step 6 of 6. Showing 255, 0, 0, 0, 128, 255, 10, 20, 30, {255, 0, 0}, {0, 128, 255}, {10, 20, 30}, 255, 0, 0, 7, 8, {255, 0, 0}. In focus: 7, 8.255, 0, 0bytes 0 to 20, 128, 255bytes 3 to 510, 20, 30bytes 6 to 8{255, 0, 0}red{0, 128, 255}a light blue{10, 20, 30}almost black255, 0, 0of the 5-byte binary7, 8two bytes left over{255, 0, 0}the only result

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

Every step as text
  1. Nine bytes. pixels is a binary of 9 bytes. The pattern <<r::8, g::8, b::8>> asks for three 8-bit numbers, so each step takes the next 3 bytes.
  2. The first pixel. Bytes 0 to 2 match as r = 255, g = 0, b = 0, and the body gives {255, 0, 0}.
  3. The second and third. The next step starts where the last one ended: bytes 3 to 5 give {0, 128, 255}, and bytes 6 to 8 give {10, 20, 30}.
  4. Nothing left. After byte 8 the binary is used up, so the generator stops with three tuples. into: <<>> turns them back into the same 9 bytes.
  5. Five bytes. A binary of 5 bytes gives one whole pixel from its first 3 bytes, {255, 0, 0}.
  6. Two bytes left over. The last 2 bytes cannot fill <<r::8, g::8, b::8>>, so the generator skips them without an error. Check byte_size(data) first if the length must be a multiple of 3.

Text to Binary & Hex Converter Type "hey" and choose Decimal to see the three bytes, 104, 101 and 121, that for <<c <- "hey">> takes one at a time.

The last line of the output is the trap: from 5 bytes the generator returned one pixel and skipped the 2 bytes that could not fill the pattern, without an error. A truncated upload or a format with padding at the end of each row gives wrong pictures silently.

Trusting a bitstring generator to check the length

A bitstring generator stops when the rest of the binary does not fill its pattern, so short or padded data is never reported. Check the size first, rem(byte_size(data), 3) == 0 for whole pixels, and return an error when it does not hold.

Checkpoint

Check: Bitstring generators

  1. Question 1 of 2 What value does this return?

    Read the code, then choose one answer.

    for <<c <- "abc">>, do: c + 1
    Show the answer: Question 1 of 2

    Answer: [98, 99, 100], which IEx prints as ~c"bcd"

    <<c <- "abc">> takes one byte at a time, as the space-removing comprehension did. The result is a list, and the way IEx displays it, ~c"bcd", does not change its value.

  2. Question 2 of 2 How many results does for <<a::16 <- <<1, 2, 3, 4, 5>> >>, do: a return?

    Type a number.

    A hint

    a::16 takes two bytes each step.

    Show the answer: Question 2 of 2

    Answer: 2 results

    Each step takes 16 bits, two bytes: <<1, 2>> gives 258 and <<3, 4>> gives 772. The fifth byte cannot fill a 16-bit piece, so it is skipped without an error, as the two extra bytes of the pixels were.

Practise: the Game of Life

Conway’s Game of Life is a grid of cells, each alive or dead, that changes in steps: a live cell with 2 or 3 live neighbours stays alive, a dead cell with exactly 3 comes alive, and every other cell is dead in the next step. Here the board is a MapSet of the live cells’ {row, col} positions. Write Life.step/1 and Life.render/3 with comprehensions. Copy the two files into an empty folder:

practice/life.ex: the starter Elixir · practice/life.ex
# Conway's Game of Life on a board of live cells: a MapSet of {row, col} tuples.
# A live cell with 2 or 3 live neighbours stays alive, a dead cell with exactly 3
# comes alive, and every other cell is dead in the next generation. Write both
# functions with for comprehensions; each starter returns a placeholder.
defmodule Life do
  # The live cells of the next generation, as a MapSet.
  def step(live), do: live

  # The board as text: rows lines of cols characters, "#" for a live cell and
  # "." for a dead one, each line ending in "\n".
  def render(_live, _rows, _cols), do: ""
end
practice/life_test.exs: the tests Elixir · practice/life_test.exs
Code.require_file("life.ex", __DIR__)
ExUnit.start(seed: 0)

defmodule LifeTest do
  use ExUnit.Case

  @blinker MapSet.new([{1, 0}, {1, 1}, {1, 2}])
  @block MapSet.new([{0, 0}, {0, 1}, {1, 0}, {1, 1}])
  @glider MapSet.new([{0, 1}, {1, 2}, {2, 0}, {2, 1}, {2, 2}])

  test "render draws every row of the board" do
    assert Life.render(@blinker, 3, 3) == "...\n###\n...\n"
    assert Life.render(MapSet.new(), 2, 4) == "....\n....\n"
  end

  test "a lone cell dies, and an empty board stays empty" do
    assert Life.step(MapSet.new([{5, 5}])) == MapSet.new()
    assert Life.step(MapSet.new()) == MapSet.new()
  end

  test "a blinker turns from a row into a column" do
    assert Life.step(@blinker) == MapSet.new([{0, 1}, {1, 1}, {2, 1}])
    assert Life.step(Life.step(@blinker)) == @blinker
  end

  test "a block does not change" do
    assert Life.step(@block) == @block
  end

  test "a glider moves one cell down and one right in four steps" do
    moved = for {r, c} <- @glider, into: MapSet.new(), do: {r + 1, c + 1}
    assert Enum.reduce(1..4, @glider, fn _, live -> Life.step(live) end) == moved
  end
end

Run elixir life_test.exs. The starter returns the board unchanged and an empty drawing, so only the test of a board that does not change passes:

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

# The tests against the starter, whose functions return placeholders.
run("""
cd practice
!elixir life_test.exs
""")

Output

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



  1) test render draws every row of the board (LifeTest)
     life_test.exs:11
     Assertion with == failed
     code:  assert Life.render(@blinker, 3, 3) == "...\n###\n...\n"
     left:  ""
     right: "...\n###\n...\n"
     stacktrace:
       life_test.exs:12: (test)



  2) test a lone cell dies, and an empty board stays empty (LifeTest)
     life_test.exs:16
     Assertion with == failed
     code:  assert Life.step(MapSet.new([{5, 5}])) == MapSet.new()
     left:  MapSet.new([{5, 5}])
     right: MapSet.new([])
     stacktrace:
       life_test.exs:17: (test)



  3) test a blinker turns from a row into a column (LifeTest)
     life_test.exs:21
     Assertion with == failed
     code:  assert Life.step(@blinker) == MapSet.new([{0, 1}, {1, 1}, {2, 1}])
     left:  MapSet.new([{1, 0}, {1, 1}, {1, 2}])
     right: MapSet.new([{0, 1}, {1, 1}, {2, 1}])
     stacktrace:
       life_test.exs:22: (test)

.

  4) test a glider moves one cell down and one right in four steps (LifeTest)
     life_test.exs:30
     Assertion with == failed
     code:  assert Enum.reduce(1..4, @glider, fn _, live -> Life.step(live) end) == moved
     left:  MapSet.new([{0, 1}, {1, 2}, {2, 0}, {2, 1}, {2, 2}])
     right: MapSet.new([{1, 2}, {2, 3}, {3, 1}, {3, 2}, {3, 3}])
     stacktrace:
       life_test.exs:32: (test)


Finished in 0.3 seconds (0.3s on load, 0.00s async, 0.02s 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

Three comprehensions do the work. The eight neighbours of a cell are two generators over -1..1 with a filter that drops {0, 0}. The cells that may be alive next are the live cells and all their neighbours, collected with into: MapSet.new() so each is there once; a filter that counts each one’s live neighbours keeps the cells of the next step. render/3 is a comprehension over the rows with into: "", whose body is a comprehension over the columns, also into: "", followed by "\n". A finished version passes all five tests:

The tests against a finished Life module Python · finished_session.py
from session import run

# The same tests against a finished Life module.
run("""
cd finished
cp ../practice/life_test.exs .
elixir life_test.exs
""")

Output

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

.....
Finished in 0.04 seconds (0.04s 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 asks about generators, filters and patterns, collecting, and binaries, one question at a time and the idea you know least first, and it explains every answer the moment you give it:

Practice that adapts to you

Practise this lesson

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

  1. Question 1 of 14 In a comprehension with two generators, which one changes fastest?

    Choose one answer.

    Show the answer: Question 1 of 14

    Answer: The last one

    Several generators work like nested loops with the last one innermost, so for x <- 1..3, y <- [:a, :b] gave both y values for x = 1 before x became 2.

  2. Question 2 of 14 What does this return?

    Read the code, then choose one answer.

    for x <- 1..3, y <- x..3//1, do: {x, y}
    Show the answer: Question 2 of 14

    Answer: [{1, 1}, {1, 2}, {1, 3}, {2, 2}, {2, 3}, {3, 3}]

    Generators are read from left to right, and each sees the variables bound by those before it, so the second range depends on x and the result has 3 + 2 + 1 = 6 tuples.

  3. Question 3 of 14 elixirc warned that ticket + 1 in Tickets.print_all/2 gets an atom, yet print_all(7, []) returned 8. Why?

    Choose one answer.

    Show the answer: Question 3 of 14

    Answer: The type checker assumes a comprehension's body runs at least once, but with no items it never ran

    Elixir 1.20's type checker infers types from how variables are used. It treats a comprehension's body as if it runs, which helps it find mismatches inside and outside comprehensions and occasionally warns about code that only ever runs with an empty list.

  4. Question 4 of 14 What does a filter in a comprehension, such as rem(x, 3) == 0, do?

    Choose one answer.

    Show the answer: Question 4 of 14

    Answer: Keeps the elements for which it is truthy and drops the rest

    Filters come after the generators and are ordinary expressions; elements for which a filter returns false or nil are dropped, as rem(x, 3) == 0 kept 3, 6 and 9.

  5. Question 5 of 14 What does this return?

    Read the code, then choose one answer.

    people = [{"Asha", 19}, {"Dev", 15}, {"Meera", 22}]
    for {name, age} when age >= 18 <- people, do: name
    Show the answer: Question 5 of 14

    Answer: ["Asha", "Meera"]

    A generator's pattern may carry a guard, written with when before <-, which filters in the generator itself, as when price > 40 kept toast and idli from the menu.

  6. Question 6 of 14 An import must stop at the first row that failed to parse, instead of leaving it out. Which way of writing the loop does that?

    Choose one answer.

    Show the answer: Question 6 of 14

    Answer: Enum.map/2 with a function whose only clause matches {:ok, row}

    A comprehension's pattern skips, a function's pattern raises. Choose by what a mismatch means: a kind of value to leave out, or a bug that must stop the work.

  7. Question 7 of 14 In the Erlang shell, what did [V || {ok, V} <:- L] do when L held {error, timeout}?

    Choose one answer.

    Show the answer: Question 7 of 14

    Answer: It raised an exception, no match of right hand side value

    Erlang has both: <- skips what does not match, like Elixir's generators, and <:- raises. Elixir's for has no strict generator, so a mismatch that matters is caught with a function that has no clause for it.

  8. Question 8 of 14 What does a comprehension return when it has neither into nor reduce?

    Choose one answer.

    Show the answer: Question 8 of 14

    Answer: A list of the body's results, in order

    A comprehension collects into a list by default. into: names another collectable and reduce: a single accumulated value.

  9. Question 9 of 14 What does this return?

    Read the code, then choose one answer.

    for <<c <- "a-b-c">>, c != ?-, into: "", do: <<c>>
    Show the answer: Question 9 of 14

    Answer: "abc"

    A bitstring generator with into: "" rebuilds a string from the bytes the body returns, as "chai latte" became "chailatte".

  10. Question 10 of 14 What does this return?

    Read the code, then choose one answer.

    for x <- [1, 2, 3, 4], uniq: true, do: rem(x, 2)
    Show the answer: Question 10 of 14

    Answer: [1, 0]

    uniq: true adds each result to the collection only if it was not returned before, as the repeated 300 and 100 were dropped in the session.

  11. Question 11 of 14 A job needs the first order over 10,000 rupees among a million orders. Which do you write?

    Choose one answer.

    Show the answer: Question 11 of 14

    Answer: Enum.find/2 with the condition

    A comprehension always visits every element. When the answer can be known before the end, an Enum function that stops, find/2, any?/2 or reduce_while/3, does less work.

  12. Question 12 of 14 What does the generator <<r::8, g::8, b::8 <- pixels>> do?

    Choose one answer.

    Show the answer: Question 12 of 14

    Answer: Takes the binary pixels apart three bytes at a time, binding each byte to r, g and b

    A bitstring generator walks a binary with a binary pattern, as the comprehensions guide's pixel example does, and the rest of the comprehension works on the values it binds.

  13. Question 13 of 14 What does this return?

    Read the code, then choose one answer.

    for {r, g, b} <- [{1, 2, 3}], into: <<>>, do: <<b, g, r>>
    Show the answer: Question 13 of 14

    Answer: <<3, 2, 1>>

    into: <<>> collects binaries, which is how pixels.exs turned its tuples back into the original 9 bytes; the body decides the order of the bytes.

  14. Question 14 of 14 An upload of raw RGB pixels must be refused when it is cut short. What do you add before the comprehension?

    Choose one answer.

    Show the answer: Question 14 of 14

    Answer: A check that rem(byte_size(data), 3) == 0, returning an error when it fails

    A bitstring generator stops when the rest of the binary cannot fill its pattern. When the data must be whole, check its size first and return an error that says how many bytes were left over.

Interview questions

Warm-up: what does a pattern on the left of <- do in an Elixir comprehension?

It filters by shape. Each element of the collection is matched against the pattern; an element that matches binds the pattern’s variables and goes on to the filters and the body, and one that does not match is skipped, with no error. So for {:ok, amount} <- results, do: amount returns the amounts of the successful results only: [1200, 450] from two successes and two errors. That makes it a short way to pick one kind of value out of a list of tagged tuples, and a way to lose errors silently when they should be reported. A function with the same pattern as its only clause raises FunctionClauseError instead, which is the behaviour to choose when a mismatch is a bug.

Two questions from imports and binary data:

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

  1. Fresher to mid

    A nightly import parses a CSV of payments and inserts them with for {:ok, row} <- Enum.map(lines, &parse/1), do: insert(row). Every month about 1.2 % of the payments are missing from the database, and no error was ever logged. Explain the cause, and redesign the import so that failures are seen and handled.

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

  2. Mid to senior

    A service decodes raw images whose rows of 3-byte RGB pixels are each padded with zero bytes to a multiple of 4 bytes. It uses one comprehension, for <<r::8, g::8, b::8 <- data>>, over the whole buffer. Images 4 pixels wide look right, but 5-pixel-wide ones come out with shifted colours. Explain and fix it.

    Model answer, Pro. First step and follow-ups

Key takeaways

  • A comprehension with several generators produces every combination, with the last generator varying fastest; a later generator may use an earlier variable, and an empty generator gives an empty result.
  • Filters keep the elements for which they are truthy, and a pattern on the left of <- skips the elements that do not match it, silently, where the same pattern in a function raises FunctionClauseError.
  • into: collects into any collectable, such as a map, a string or a MapSet, reduce: threads an accumulator through acc -> … clauses, and uniq: drops repeated results.
  • A bitstring generator takes a binary apart piece by piece, <<r::8, g::8, b::8 <- pixels>> three bytes at a time, and skips a last piece that does not fill the pattern, so check the size of the data first.
  • Elixir 1.20’s type checker assumes a comprehension’s body runs at least once; Erlang’s comprehensions have a strict generator, <:-, and from Erlang/OTP 29 can emit several results per iteration, which Elixir’s for does not.

Terms in this lesson

bitstring generator
A generator written <<pattern <- binary>>, which takes a binary apart piece by piece, such as <<r::8, g::8, b::8 <- pixels>> for three bytes at a time. A last piece too short for the pattern is skipped without an error.
collectable
A value that results can be put into, because its type implements the Collectable protocol, such as a list, a map, a MapSet or a binary. The into: option of for and Enum.into/2 take one.
comprehension
A for expression that takes elements from one or more generators, keeps those that pass its filters, and collects the value of its body for each one: into a list, unless into: or reduce: asks for something else.
generator
The part of a comprehension written pattern <- collection, which hands its elements one at a time to the rest of the comprehension. Elements that do not match the pattern are skipped; several generators give every combination.

References

Related tools

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.