Elixir: Data & Immutability
Immutability and Rebinding
In most languages, assigning to a variable again replaces the value in a box. Elixir has no boxes: a variable is a label bound to a value, and rebinding binds the label to a different value. The old value itself is untouched — any other code holding it still sees the original.
map = %{a: 1} then Map.put(map, :b, 2) leaves both maps valid and independent.map = %{a: 1}
bigger = Map.put(map, :b, 2) # returns a NEW map
map.a #=> 1 (unchanged — safe to share)
bigger.b #=> 2
# The update syntax rebinds an existing variable; it reads like mutation
# but is a fresh structure underneath.
counter = %{hits: 0}
counter = %{counter | hits: 1} # | requires the key to already exist
counter = Map.update(counter, :hits, 0, &(&1 + 1)) #=> %{hits: 2}
Two practical consequences. First, sharing data between processes is safe with no locks — nobody can change a shared value behind your back. Second, updates stay fast: Elixir's structures are persistent data structures, so a "new" map shares most of its internals with the old one instead of copying everything.
Numbers
1_000_000 # underscores for readability — integers are arbitrary precision
0x1F # hex literals (0b binary, 0o octal too)
10 / 3 #=> 3.3333333333333335 — / ALWAYS returns a float
div(10, 3) #=> 3 — integer division
rem(10, 3) #=> 1 — remainder
0.1 + 0.2 #=> 0.30000000000000004 — floats are IEEE 754 doubles
Atoms and Booleans
An atom is a constant whose name is its value: :ok, :error,
:transient. They are never garbage-collected, so never build them dynamically
from user input. true, false, and nil are just the atoms
:true, :false, and :nil in disguise.
:ok == :ok #=> true
is_atom(true) #=> true — booleans ARE atoms
String.to_atom("dyn_" <> "suffix") # legal, but a memory leak if driven by input
Tuples vs Lists
Tuples and lists look similar in code and behave very differently in memory. Choosing correctly is one of the first habits of idiomatic Elixir.
Tuple {...} | List [...] | |
|---|---|---|
| Storage | contiguous array | singly linked cells |
| Access by index | O(1) — elem(t, 0) | O(n) |
| Prepend | O(n) — rebuilds | O(1) — [h | t] |
| Length | known at construction | O(n) to count |
| Typical use | fixed-shape returns: {:ok, value} | variable-length collections to enumerate |
status = {:ok, "written"} # tuple: a fixed shape, read as a whole
list = [3, 1, 2] # list: built for head/tail recursion
[head | tail] = list # head = 3, tail = [1, 2]
Enum.sum(list) #=> 6
Keyword Lists and Maps
A keyword list is a list of {atom, value} pairs — ordered, keys may repeat,
and it doubles as the syntax for function options. A map is the general key/value store:
unordered, unique keys, pattern-matchable, with O(log n) access for large maps.
opts = [timeout: 5_000, retries: 3] # keyword list
opts[:timeout] #=> 5000
# The bracket sugar appears in real APIs everywhere:
Req.get("https://sagecode.org", retry: :safe, max_retries: 2)
map = %{"name" => "Ada", role: :admin} # string or atom keys
map.role #=> :admin — atom-key shorthand
%{role: r} = map # pattern matching works on maps
Map.put(map, :visits, 1) # functional update returns a new map
Structs and Dates
A struct is a map with a guaranteed shape: it names a module, rejects unknown keys at compile time, and enables protocols and pattern matching on type. The standard library itself models time this way.
defmodule Booking do
defstruct [:title, :seats] # enforced shape, defaults to nil
end
b = %Booking{title: "Intro", seats: 30}
%Booking{seats: seats} = b # matches only Booking structs
seats #=> 30
~D[2026-09-17] # Date struct via sigil
Date.add(~D[2026-09-17], 3) #=> ~D[2026-09-20]
Practice
- Rebind a map three times, printing it at each step, and explain what happened to the earlier bindings.
- Write a function
summarize({:ok, value})/summarize({:error, reason})and test both clauses. - Convert a keyword list of options into a map and back; note which operations each structure makes easy.
Next: Pattern Matching