In the last post we treated Elixir like a professional would: modules, Mix, documentation, and code you pull in from outside your own project. Now we need something to put inside those modules. We need to model data. And the first real decision you make when you model data is which container holds it.
Most of the time in Elixir, that container is a map. But before we look at the syntax, look at the choice it resolves.
You have two ways to store key-value data. One is a keyword list, which gives you nice syntax for a short, ordered set of options. The other is a map, which gives you fast access to any value by its key no matter how large the collection gets. That second property is the whole reason maps exist, and the clearest way to feel it is to imagine getting it wrong.
Why not just use a list?
Key-value data in Elixir goes back to Erlang, where it was nothing more than a list of two-element tuples. That structure is still here. We call it a keyword list.
[one: 1, two: 2]
That pretty syntax is sugar. Underneath, it is a plain list of tuples with an atom in the first slot, and a list means you walk it from the front. To find a key, you check the first tuple, then the next, then the next. For three options that is nothing. For a large collection it is a problem.
“If I have a keyword dictionary that has a million elements, it’s going to take me on average a half million functions to find one. That’s just not efficient enough.” — Bruce Tate
That inefficiency is exactly what José wanted to steer people away from. Maps arrived as a first-class type just before Elixir 1.0, late enough that he could build proper semantics around them instead of bolting them on. And he was direct about the tradeoff. Elixir won’t even let you reach into a list by position:
list = [1, 2, 3]
list[2] # not allowed
As Bruce puts it, “Elixir is not going to allow me to [index a list], because this is inefficient and José did not want to encourage inefficient code.” Keyword lists survive because they are genuinely useful in one place: the last argument of a function, where they read like named options. For everything larger, you reach for a map.
🎯 Join Groxio's Newsletter
Weekly lessons on Elixir, system design, and AI-assisted development -- plus stories from our training and mentoring sessions.
We respect your privacy. No spam, unsubscribe anytime.
The syntax, and what a key can be
A map is a percent sign and a pair of braces.
%{key1: value1, key2: value2}
When every key is an atom, you get the key: value shorthand you just saw. But atoms are not a requirement. A key can be anything. Bruce once modeled Conway’s Game of Life as a board keyed by coordinate, a sheet of graph paper where each cell was alive or dead:
lifeboard = %{{1, 1} => :alive, {1, 2} => :dead}
The common case is simpler. Most maps you write describe a record, like a row in a database, where the keys stay the same from one map to the next and only the values differ.
person = %{name: "Jane", profession: "programmer"}
That regularity has a name in Bruce’s teaching: “Keys tend to be homogeneous, and the values can be heterogeneous if we want them to be.” Hold onto that idea. It is the seed of structs in the next post.
Four ways to work with a map
Once you have a map, everything you do with it falls into one of four modes. Learn the modes and you never have to memorize the Map module.
Reach for one value. There are a few ways, and they differ in how they fail.
person.name # "Jane", raises if the key is missing
person[:name] # "Jane", returns nil if the key is missing
Map.get(person, :name)
Map.fetch(person, :name) # {:ok, "Jane"}
Map.fetch(person, :age) # :error
Map.fetch!(person, :name) # "Jane", raises on a missing key
fetch/2 hands back a tagged tuple, {:ok, value} or :error, because looking up a key is an operation that can fail and Elixir wants you to say what happens when it does. fetch!/2 takes the other stance. If the key should be there and isn’t, it raises immediately. When something is wrong, you usually want to fail fast so you can see it.
Update without mutating. Elixir never changes a map in place. You describe a new map built from the old one. The update syntax uses the pipe inside the braces:
new_person = %{person | name: "John", profession: "author"}
Read that as “keep everything the same, change only these keys.” It has a quiet safety feature worth saying out loud: this syntax guards that the key already exists. Misspell name and you get an error rather than a silent new field. When you do want to add a key, Map.put/3 is the tool, and Map.update/4 is there when the new value depends on the old one.
Map.put(person, :age, 21)
Work in bulk. Sometimes you don’t want one value, you want all of them.
Map.keys(person) # [:name, :profession]
Map.values(person) # ["Jane", "programmer"]
Move between maps and lists. A map and a list of pairs are two views of the same information, and you can cross between them freely. Map.to_list/1 gives you a keyword list back. Going the other way, Map.new/1 or Enum.into/2 builds a map from a list of tuples.
Map.to_list(person) # [name: "Jane", profession: "programmer"]
Map.new([{:name, "Jane"}])
Enum.into([{:name, "Jane"}], %{age: 21})
That last one reads as “pour this list into that map,” and it works because of something the map is quietly carrying.
Why the map plays well with everything else
Run i person in IEx and look past the value. The i helper, which uses IEx.Info, reports the contracts a type honors, and a map honors two that matter here. It is Enumerable, so its elements can be counted and traversed. It is Collectible, so you can pour values into it. Those are the contracts that let Enum.into and the rest of the standard library treat your map like any other collection.
That is Bruce’s summary of the whole class:
“We can deal with things piecemeal, one at a time; we can deal with things in bulk; and we can deal with them in a way that they match up to the contracts — collectible and enumerable — that let it play with the rest of the ecosystem.”
Where this goes next
The mental model is small. When you need to reach any value by key, instantly, no matter how big the collection, you reach for a map. Keyword lists keep their narrow, pretty job as function options. Maps carry the rest.
Keep an eye on that homogeneous-keys observation. When the keys are always the same handful of names, you are describing a fixed shape, and Elixir has a tool built for exactly that. A struct is a map that fixes its keys and checks them at compile time. It also changes the contract story: where a map opts into Enumerable, a struct opts out. That is the next building block, and it stands directly on this one.
See you in the next chapter.
Want to Learn Elixir's Data Structures by Mental Model?
This post comes from Bruce's structured Elixir course, where maps, keyword lists, structs, and protocols are taught as production architecture decisions instead of syntax trivia. Build the mental models that help you choose the right shape for real-world Elixir code.
— Paulo & Bruce