5 min read
On this page

Collections

Elixir gives you four collection types you'll touch every day: lists, tuples, maps, and keyword lists. They look superficially similar, but their performance characteristics and idiomatic uses are quite different. Picking the wrong one for the job is one of the more common ways Elixir code ends up slow without anyone noticing.

The short version: lists for sequences you'll traverse, tuples for fixed-arity records, maps for keyed data, keyword lists for options.

Lists

Lists in Elixir are singly linked lists. Not arrays. This single fact drives everything about how you use them.

iex> [1, 2, 3]
[1, 2, 3]

iex> [head | tail] = [1, 2, 3]
iex> head
1
iex> tail
[2, 3]

Internally, [1, 2, 3] is [1 | [2 | [3 | []]]]. Each cons cell points to the next. This means:

  • Prepending to a list is O(1). Just allocate one new cell.
  • Appending to a list is O(n). You have to walk to the end and rebuild the chain.
  • Length is O(n). The runtime walks the list to count.
  • Random access is O(n). No indexing.
# fast
new_list = [0 | existing_list]

# slow — avoid in loops
new_list = existing_list ++ [99]

The classic mistake is appending in a recursion or Enum.reduce. Build the list backwards by prepending, then reverse once at the end.

# slow
def squares(nums) do
  Enum.reduce(nums, [], fn n, acc -> acc ++ [n * n] end)
end

# fast
def squares(nums) do
  nums
  |> Enum.reduce([], fn n, acc -> [n * n | acc] end)
  |> Enum.reverse()
end

# even better — let Enum do it
def squares(nums), do: Enum.map(nums, & &1 * &1)

Lists are immutable, so [head | tail] doesn't copy tail — it just adds one new cell pointing at the existing tail. This is why prepending is cheap and why functional list code doesn't allocate as much as you'd fear.

Common operations live in the Enum and List modules:

Enum.map([1, 2, 3], & &1 * 2)        # [2, 4, 6]
Enum.filter([1, 2, 3], & &1 > 1)     # [2, 3]
Enum.reduce([1, 2, 3], 0, &+/2)      # 6
Enum.find([1, 2, 3], & &1 > 1)       # 2
List.first([1, 2, 3])                # 1
List.last([1, 2, 3])                 # 3 — this is O(n)
length([1, 2, 3])                    # 3 — also O(n)

If you find yourself calling length/1 to compare with another length, use match?/2 against [] or [_] instead — pattern matching is constant time.

Tuples

Tuples are fixed-size, contiguous in memory, and accessed by index in constant time. They're written with curly braces.

iex> {:ok, "result"}
{:ok, "result"}

iex> tuple_size({:ok, "result"})
2

iex> elem({:ok, "result"}, 0)
:ok

Updating a tuple copies the whole thing. So they're great for small, fixed-arity data and bad for anything you'd grow over time.

The dominant use of tuples in Elixir is the tagged-tuple convention for return values:

case File.read("config.json") do
  {:ok, contents} -> parse(contents)
  {:error, reason} -> Logger.error("read failed: #{inspect(reason)}")
end

Every IO function in the standard library returns either {:ok, value} or {:error, reason}. Pattern matching on these is the bread and butter of Elixir control flow. We'll get to that in the pattern matching chapter.

You'll also see tuples used as small records in performance-sensitive code, but in modern Elixir, structs (covered in the next file) are usually a better choice.

# fine for a quick coordinate
{x, y} = {3, 4}

# better as a struct if you carry it around
%Point{x: 3, y: 4}

Maps

Maps are Elixir's general-purpose key-value structure. Keys can be any term — atoms, strings, integers, tuples, even other maps.

iex> user = %{name: "Ada", age: 36}
%{name: "Ada", age: 36}

iex> user.name
"Ada"

iex> user[:name]
"Ada"

iex> Map.get(user, :name)
"Ada"

Three ways to read a value, with subtly different behaviors:

  • user.name — works only if the key exists. Raises KeyError if missing. Atom keys only.
  • user[:name] — returns nil if missing. Works for any key type via the Access behavior.
  • Map.get(user, :name) — same as [], with optional default. Map.get(user, :name, "Unknown").

Use the dot syntax when you know the key exists (e.g., on a struct). Use [] or Map.get/2 when the key might be missing.

Maps with atom keys are extremely common in Elixir code. The shorthand %{name: "Ada"} is sugar for %{:name => "Ada"}. For non-atom keys, you have to use the arrow form:

%{"name" => "Ada", "age" => 36}
%{1 => :one, 2 => :two}

Updating a map:

iex> user = %{name: "Ada", age: 36}

iex> %{user | age: 37}
%{name: "Ada", age: 37}

iex> Map.put(user, :role, :admin)
%{name: "Ada", age: 36, role: :admin}

The | syntax requires the key to already exist. It raises if you misspell. This is good — typos in update fields become loud errors.

iex> %{user | nam: "Bob"}
** (KeyError) key :nam not found

Map.put/3 adds or updates without checking. Use it when you're inserting genuinely new keys.

For nested updates, put_in, update_in, and get_in are the idiomatic tools:

iex> user = %{profile: %{address: %{city: "Stockholm"}}}

iex> put_in(user, [:profile, :address, :city], "Oslo")
%{profile: %{address: %{city: "Oslo"}}}

iex> update_in(user, [:profile, :address, :city], &String.upcase/1)
%{profile: %{address: %{city: "STOCKHOLM"}}}

Maps are O(log n) lookup for small maps and effectively O(1) for larger ones (the runtime switches representations around 32 entries). They're fine for general-purpose use.

Keyword Lists

Keyword lists are lists of two-element tuples where the first element is an atom. They look like maps but they're lists.

iex> opts = [timeout: 5_000, retries: 3]
[timeout: 5000, retries: 3]

iex> opts == [{:timeout, 5_000}, {:retries, 3}]
true

Why have a separate construct? Three reasons:

  1. Order is preserved. Maps don't guarantee iteration order beyond what BEAM happens to do.
  2. Duplicate keys are allowed. Useful for query DSLs like Ecto.
  3. They're the convention for function options.

That last point is the main one in practice. Look at any well-designed Elixir library and the optional arguments come in as a keyword list:

HTTPoison.get("https://example.com", [], timeout: 5_000, recv_timeout: 10_000)

Repo.all(query, timeout: :infinity, log: false)

Phoenix.Channel.broadcast(socket, "event", %{}, async: true)

There's syntactic sugar that lets you drop the brackets when the keyword list is the last argument:

# these are identical
foo(arg, [timeout: 5_000])
foo(arg, timeout: 5_000)

You access keyword lists with Keyword.get/3:

iex> Keyword.get(opts, :timeout, 1_000)
5000

iex> Keyword.get(opts, :missing, :default)
:default

Don't use keyword lists for general data. They're O(n) lookup. They're for options and DSLs, not collections.

When To Use Each

A practical decision guide based on what I've seen in production code:

  • Sequence of values you'll traverse, transform, or accumulate. List.
  • Function returning success/failure or a small fixed record. Tuple.
  • Data with named fields you'll read and update by key. Map.
  • Function options or DSLs (Ecto queries, Plug pipelines). Keyword list.

If you have data with a known shape that you pass around as a thing, reach for a struct (next file). If you have arbitrary key-value bags from external sources (JSON, params), use a map.

Common Operations Cheat Sheet

# build a list
[1, 2, 3]
[head | tail]
1..100 |> Enum.to_list()

# transform
Enum.map(list, fun)
Enum.filter(list, fun)
Enum.reduce(list, acc, fun)
Enum.flat_map(list, fun)
Enum.uniq(list)
Enum.sort(list)
Enum.group_by(list, key_fun)

# build a map
%{a: 1, b: 2}
Map.new(list, fn x -> {x.id, x} end)

# read a map
map[:key]
Map.get(map, :key)
Map.fetch!(map, :key)   # raises if missing

# update a map
%{map | key: new_value}
Map.put(map, :key, value)
Map.update(map, :counter, 1, & &1 + 1)
Map.merge(map1, map2)

# read keyword
Keyword.get(opts, :key, default)
Keyword.fetch!(opts, :key)

Common Pitfalls

Appending to lists in a hot path. acc ++ [x] is O(n) every iteration. Prepend and reverse at the end, or use Enum.

Calling length/1 to check if a list is empty. Use list == [] or pattern match [] versus [_ | _]. Constant time.

Using maps for ordered data. Maps don't preserve insertion order in any guaranteed way. If order matters, use a list of tuples or a keyword list.

Mixing keyword lists and maps. They look alike but opts[:key] on a map and opts[:key] on a keyword list have different complexity. Be intentional about which you accept in a function.

Forgetting %{map | key: val} raises on missing keys. This is a feature for typo-catching, but if you're inserting new keys, use Map.put/3. The error message helps tremendously when you do typo a field name.

Treating tuples as variable-length collections. Tuples are for fixed-arity records. If your tuple is growing, you wanted a list.

Key Takeaways

  • Lists are linked lists. Prepend cheap, append expensive, no random access.
  • Tuples are fixed-size with constant-time access. Use them for tagged returns and small records.
  • Maps are the workhorse for keyed data. Atom-key shorthand for known shapes, string keys for parsed input.
  • Keyword lists are for function options and DSLs, not for general key-value storage.
  • The | syntax for map updates raises on missing keys — that's a feature.
  • Reach for Enum and Map modules before writing manual recursion. They're well-tuned and idiomatic.