7 min read
On this page

For Comprehensions

for in Elixir is not a loop. It looks like one, and people coming from C, Python, or JavaScript reach for it like one, but underneath it is a comprehension — a declarative description of "produce a new collection from one or more input collections, optionally filtered, with each element transformed." If that sentence sounds like Enum.map plus Enum.filter, you are right. The difference is syntactic: comprehensions read more naturally when you have multiple inputs, nested destructuring, or filters that are awkward to express as separate function calls.

Elixir does not have a for x in xs: ... style imperative loop at all. Tail recursion and Enum cover most iteration; for covers the rest. Knowing when to reach for it is a small but real productivity win.

The Basic Shape

for x <- [1, 2, 3], do: x * 2
# [2, 4, 6]

The x <- [1, 2, 3] part is called a generator. It pulls each element from the right-hand collection and binds it to x on the left. The do: block runs once per element, and the results are collected into a new list.

That is the entire contract. Generator on the left, expression on the right, list back. No loop counter, no break, no continue — none of that vocabulary applies.

Multi-line form when the body is more than a one-liner:

for user <- users do
  %{
    id: user.id,
    display: "#{user.first_name} #{user.last_name}",
    initials: "#{first_initial(user)}#{last_initial(user)}"
  }
end

Compared to the equivalent Enum.map:

Enum.map(users, fn user ->
  %{
    id: user.id,
    display: "#{user.first_name} #{user.last_name}",
    initials: "#{first_initial(user)}#{last_initial(user)}"
  }
end)

For a single transformation, Enum.map is just as clear and usually what you want, especially inside a pipeline. The case for for opens up when there is more going on.

Filters

Anything after the generator that is not another generator or option is a filter. The comprehension only includes elements for which every filter returns a truthy value.

for x <- [1, 2, 3, 4, 5, 6], rem(x, 2) == 0, do: x * 10
# [20, 40, 60]

You can chain filters:

for user <- users,
    user.active,
    user.email_verified,
    String.ends_with?(user.email, "@discord.com"),
    do: user.id

The same logic with Enum:

users
|> Enum.filter(& &1.active)
|> Enum.filter(& &1.email_verified)
|> Enum.filter(&String.ends_with?(&1.email, "@discord.com"))
|> Enum.map(& &1.id)

Both work. The for version reads top-to-bottom as "for every user where these things are true, give me the id." The Enum version reads as a pipeline of transformations. Pick whichever feels closer to how you would explain the code to a colleague.

A nice property of the comprehension form: filters short-circuit. If the first filter rejects an element, the rest are never evaluated for that element. The compiler handles this for you.

Destructuring in the Generator

The left-hand side of <- is a pattern, not just a variable. Anything you can match in case or a function head, you can match here.

pairs = [{:ok, 1}, {:error, :timeout}, {:ok, 2}, {:error, :nxdomain}, {:ok, 3}]

for {:ok, value} <- pairs, do: value
# [1, 2, 3]

This is one of the killer features. The pattern {:ok, value} does two things at once: it filters out elements that do not match (the :error tuples are silently dropped) and it destructures the ones that do. There is no equivalent in Enum that is anywhere near as concise — you would need a flat_map returning [value] or [] per element, or a filter followed by a map with another destructure.

You see this constantly when working with Ecto query results, HTTP response lists, or anything that returns tagged tuples in bulk:

responses = Enum.map(urls, &HTTPoison.get/1)

for {:ok, %HTTPoison.Response{status_code: 200, body: body}} <- responses do
  Jason.decode!(body)
end

Failed requests, non-200 responses, and anything else that does not match the exact shape gets dropped. The body of the comprehension only runs for the successes.

Real-world example from a Phoenix LiveView that wants the IDs of online users whose presence metadata includes a :typing flag:

for {user_id, %{metas: [%{typing: true} | _]}} <- Presence.list(topic) do
  user_id
end

The pattern walks several layers deep. Anything that does not match — users without metas, users not currently typing, malformed entries from an older client — just does not appear in the result. No defensive code, no case branches.

Building Lookups

A common shape: take a list of structs and turn it into a list of {key, value} pairs you can pass to Map.new/1 or Enum.into(%{}).

users = Repo.all(User)

lookup =
  for user <- users, into: %{} do
    {user.id, user}
  end

The into: %{} option (covered in detail in the next file) collects directly into a map. Without it, you would get a list of tuples and have to Enum.into(%{}) at the end.

For a filtered lookup:

admin_emails =
  for %User{role: :admin, email: email} <- users, into: MapSet.new() do
    email
  end

This is the kind of code where for starts to beat Enum. The same in Enum:

admin_emails =
  users
  |> Enum.filter(&match?(%User{role: :admin}, &1))
  |> Enum.map(& &1.email)
  |> MapSet.new()

Three operations, with match?/2 doing the same job that the comprehension's destructure does inline. Not bad, but the for reads more directly: "for every admin user, collect their email into a MapSet."

When for Wins, When Enum Wins

Use for when:

  • You have a non-trivial pattern in the generator that filters and destructures in one step.
  • You are doing a Cartesian product over multiple generators (next file).
  • You want to collect into something other than a list and the into: syntax keeps it compact.
  • You are unpacking a binary (covered in the binary comprehensions file).

Use Enum (or Stream) when:

  • You are in the middle of a pipeline and the data is already flowing through |>.
  • The transformation is a simple map or filter without destructuring.
  • You want laziness (Stream) for large or infinite collections.
  • The function you want is one of the more specific Enum helpers — group_by, chunk_every, sort_by, min_max_by.

Both have access to the same expressivity. Comprehensions compile down to a reduce under the hood, so there is no performance gap worth chasing in either direction. Pick based on which reads better in context.

A Worked Example

Suppose you are parsing a CSV of orders from a payment processor like Stripe, and you want a list of {customer_id, amount_cents} for successful charges over five dollars, paid in USD.

With for:

charges = parse_csv("stripe_charges.csv")

for %{"status" => "succeeded",
      "currency" => "usd",
      "amount" => amount_str,
      "customer" => customer_id} <- charges,
    amount = String.to_integer(amount_str),
    amount > 500 do
  {customer_id, amount}
end

The pattern in the generator filters out failed charges and non-USD charges in one shot. The amount = String.to_integer(amount_str) line is itself a filter — but every match is truthy, so it acts as a let binding, making amount available to the next filter. (This is a small trick worth remembering. Any expression in a filter position binds whatever variables it introduces.)

The same with Enum:

charges
|> Enum.filter(&match?(%{"status" => "succeeded", "currency" => "usd"}, &1))
|> Enum.map(fn %{"amount" => a, "customer" => c} ->
  {c, String.to_integer(a)}
end)
|> Enum.filter(fn {_c, amount} -> amount > 500 end)

Three passes, two of them filters, with a destructure in the middle. It works, but the comprehension expresses the intent in one block: "for every successful USD charge where the amount exceeds five dollars, give me the customer and amount."

Common Pitfalls

Treating for as an imperative loop. It is not. There is no early exit, no mutable accumulator, no side-effect-friendly body unless you are explicit about it. If you write for x <- xs, do: IO.puts(x) thinking of it as a forEach, you are also allocating a list of :ok atoms that you immediately throw away. For pure side effects, use Enum.each/2.

Forgetting that filters are not the same as guards. A filter is any expression that evaluates to truthy or falsy. It runs at runtime. It is not the same as a when guard in pattern matching — you cannot use the restricted guard subset rules to your advantage here. Anything that runs is regular code.

Mistaking the generator's destructure for an error. for {:ok, v} <- pairs silently drops non-matching elements. This is deliberate and useful, but it surprises people coming from languages where a destructure mismatch would raise. If you want to crash on mismatch, use Enum.map with an explicit case, or pattern-match outside the comprehension.

Over-nesting comprehensions. Once you have three or four generators and several filters, the comprehension can become harder to read than a tail-recursive function or a with block. Comprehensions are at their best with one or two generators and a handful of filters. Past that, refactor.

Building a list when you wanted a single value. Comprehensions always produce a collection. If you are looking for "the first matching element," use Enum.find/2. If you are folding into a single value, use Enum.reduce/3 (or for's :reduce option, covered in the next file).

Key Takeaways

  • for is a comprehension, not a loop. It produces a new collection from one or more generators, optionally filtered, with each element transformed.
  • Generators have the form pattern <- collection. The left side is a real pattern — destructure freely.
  • Anything after the generator that is not another generator or option is a filter. Non-matching destructures and falsy filters silently drop the element.
  • Filters can also bind variables (amount = String.to_integer(...)), making intermediate values available downstream.
  • Comprehensions compile to a reduce. There is no performance difference worth chasing versus Enum.map/Enum.filter.
  • Reach for for when destructuring and filtering combine cleanly. Reach for Enum when you are already piping.
  • For pure side effects, use Enum.each/2. Comprehensions always allocate a result.