Elixir’s core data types are the building blocks of every program you’ll write. In this article we will walk through each primitive type, explore how they behave, and see how they combine to form more expressive structures. All examples are original, using domains such as a video‑streaming service, a game leaderboard, and a simple IoT sensor hub.
Even though Elixir runs on the Erlang VM and provides a dynamic type system, knowing the characteristics of each type helps you write faster, more predictable code:
- Performance‑critical paths: atoms are stored once in a global table, making them cheap to compare.
- Pattern matching: many control‑flow constructs rely on matching against literal values, so picking the right literal matters.
- Immutability guarantees: every data structure is immutable; understanding how updates work saves memory and avoids surprising bugs.
Atoms – Named Constants
Atoms are literals that start with a colon (:) or an uppercase identifier. They are globally unique – the VM creates a single entry for each distinct atom, and every occurrence points to that entry.
Defining atoms
# classic style
status = :queued
# alias style (no leading colon)
ActiveState = :online
Both :queued and ActiveState resolve to the same underlying atom (the VM stores :queued and :online only once). The alias form is just syntactic sugar that the compiler rewrites to :"Elixir.ActiveState".
Atoms as module aliases
When you write alias MyApp.Stream, as: Video, the compiler expands Video to the fully‑qualified module name MyApp.Stream and stores it as the atom :Elixir.MyApp.Stream. This dual usage (named constant vs module alias) is why the term “alias” appears in both contexts.
Booleans – Just Two Atoms
Elixir does not have a dedicated boolean type. Instead, the atoms :true and :false are used. For convenience, the language allows you to write true and false without a leading colon – they are still the same atoms.
def authorized?(user) do
user.role == :admin and user.active?
end
Notice that the logical operators (and, or, not) expect Boolean atoms. Passing any other atom raises an ArgumentError because the VM cannot interpret it as a truth value.
Nil and Truthiness
The atom :nil (written as nil) represents the absence of a value, similar to null in other languages. Together with false, it forms the set of “falsy” values. Anything else, including 0, "", and empty lists [], is considered “truthy”.
Short‑circuit operators
Elixir provides three operators that stop evaluating as soon as the result can be determined:
||– returns the first truthy operand.&&– returns the second operand only if the first is truthy.!– logical negation (works only on Booleans).
These operators are especially handy for fallback chains:
# Try to resolve a video thumbnail from three sources.
thumbnail =
cache_lookup(video.id) ||
read_from_disk(video.id) ||
fetch_from_cdn(video.id)
If cache_lookup/1 returns nil (falsy), the expression proceeds to the next function, and so on, until a truthy value appears.
Tuples – Fixed‑Size Records
Tuples group a fixed number of elements. They are stored contiguously in memory, making element access fast but immutable.
Creating and reading a tuple
player = {:player, "Alex", 1200}
# pattern‑match the tuple
{:player, name, score} = player
IO.puts("#{name} has #{score} points")
Updating a tuple
Because tuples cannot be mutated, Kernel.put_elem/3 returns a new tuple:
# Increment Alex’s score
player = put_elem(player, 2, 1300)
# ^ rebinds the variable to the new tuple
The original tuple remains unchanged, allowing the old version to be garbage‑collected if no longer referenced.
Lists – Recursive Linked Structures
Lists are singly‑linked, recursively defined structures. Under the hood each list is a {head, tail} pair, where tail is itself a list (or the empty list []).
Basic list syntax
primes = [2, 3, 5, 7]
Recursive definition using the | operator
# Explicitly show the head/tail relationship
list = [1 | [2 | [3 | []]]]
# => [1, 2, 3]
In practice you rarely write lists in this verbose form; the syntactic sugar shown above is more ergonomic.
Performance characteristics
- Prepending (
[new | existing]) isO(1)– the new list’s tail points directly to the old list. - Appending (e.g.,
list ++ [new]) isO(n)– the whole list must be traversed and copied. - Random access via
Enum.at/2is alsoO(n)because the list must be walked from the head.
Typical usage pattern
When constructing a list in a loop, you usually prepend items and reverse at the end:
def build_leaderboard(scores) do
scores
|> Enum.reduce([], fn {player, pts}, acc ->
[{player, pts} | acc]
end)
|> Enum.reverse()
end
Immutability – The Core Discipline
Every value in Elixir is immutable. Functions never change the contents of a data structure; they return a new version. This property enables:
- Pure‑function reasoning: given the same arguments, a function always yields the same result.
- Concurrent safety: multiple processes can share the same value without fear of race conditions.
- Versioning: you can keep references to old versions of a structure, which is useful for undo/redo features.
How updates are implemented internally
When you “modify” a tuple, the runtime copies the whole tuple (shallow copy). For lists, only the part up to the changed index is duplicated; the remaining tail is shared. This explains why pushing to the head of a list is cheap while appending is expensive.
Maps – Flexible Key/Value Stores
Maps are the go‑to container for associative data. Keys can be any term, but using atoms as keys yields the most ergonomic syntax.
Creating maps
# a map of sensor readings
readings = %{
temperature: 21.5,
humidity: 45,
status: :ok
}
Accessing values
# Using the [] accessor
temp = readings[:temperature] # => 21.5
# Using dot syntax (only for atom keys)
status = readings.status # => :ok
If a key does not exist, readings[:missing] returns nil. To differentiate between “absent” and “explicitly nil”, you can use Map.fetch/2 which returns {:ok, value} or :error.
Updating a map
# Update the temperature while keeping the rest intact
readings = %{readings | temperature: 22.0}
The %{map | key: new} syntax only works for existing keys; trying to add a brand‑new key this way raises KeyError. To insert a fresh entry, use Map.put/3:
readings = Map.put(readings, :pressure, 1013)
Binaries – Sequences of Bytes
Binaries are raw byte containers, created with the << >> sigil. They underpin strings, network packets, and binary protocols.
Basic binary literals
raw = <<0x41, 0x42, 0x43>> # "ABC" in ASCII
IO.inspect(raw) # prints <<65, 66, 67>>
Specifying sizes
You can declare how many bits a value occupies. This is crucial for protocol encoding:
# Encode a 12‑bit field followed by a 4‑bit flag
packet = <<1234::12, 0b1010::4>>
If the total bit count is not a multiple of 8, the result is a bitstring (a binary with a non‑byte‑aligned tail).
Concatenation
full = <<"header">> <> <<"payload">>
Strings – Binary Text
Strings in Elixir are just UTF‑8 binaries. The double‑quoted syntax ("…") creates a binary, while single‑quoted syntax creates a list of integer codepoints (a “character list”).
Binary strings
greeting = "Welcome, #{Enum.random(["Alice", "Bob", "Eve"])}!"
IO.puts(greeting)
String interpolation (#{…}) works only inside double‑quoted binaries. Escape sequences (\n, \t, etc.) behave as expected.
Sigils for strings
# ~s uses the same rules as double quotes but allows custom delimiters
path = ~s(/var/log/#{date})
# ~S disables interpolation and escaping
raw_sql = ~S(SELECT * FROM users WHERE name = '#{name}')
Character lists
Although rarely needed, a character list is a plain list of integers:
charlist = 'hello' # same as [104,101,108,108,111]
IO.inspect(charlist) # prints 'hello'
Conversion helpers: String.to_charlist/1 and List.to_string/1.
First‑Class Functions
Functions are values. You can store them in variables, pass them to other functions, and return them from functions.
Anonymous functions (lambdas)
increment = fn x -> x + 1 end
IO.puts(increment.(41)) # => 42
The dot (.) after a variable signals “invoke the function stored in this variable”. It distinguishes anonymous‑function calls from calls to named functions (which use the regular module.function() syntax).
The capture operator
If you want to turn a named function into a first‑class value, the & (capture) operator does the job:
# Capture the 1‑arity IO.inspect/1 function
printer = &IO.inspect/1
printer.(:hello) # prints :hello
You can also create concise lambdas with argument placeholders (&1, &2, …):
# Multiply two numbers without naming them
mult = &(&1 * &2)
IO.puts(mult.(6, 7)) # => 42
Closures – Capturing the environment
A lambda can “close over” variables that exist in its lexical scope:
factor = 3
scale = fn x -> x * factor end
IO.puts(scale.(10)) # => 30
Even if factor is later rebounded, the closure retains a reference to the original memory location, not the new value:
factor = 3
scale = fn x -> x * factor end
factor = 5 # rebinding
IO.puts(scale.(2)) # still => 6, not 10
Other Built‑in Types (Reference, PID, Port)
For completeness, here are three low‑level types you’ll mostly encounter when dealing with concurrency or interfacing with external resources:
- Reference – a globally unique identifier created with
make_ref(). Useful for correlating messages. - PID – a process identifier (
spawn/1returns a PID). Allows you to send messages withsend/2. - Port – an endpoint to an external OS resource (e.g., a Unix socket). Managed via
Port.open/2.
Higher‑Level Types
Elixir ships with a handful of libraries that expose richer abstractions built on top of the core types. Below we showcase the most common ones.
Ranges
# Represent a sequence of consecutive integers
hours = 9..17
Enum.each(hours, fn h -> IO.puts("Open at #{h}:00") end)
Ranges are enumerable and extremely memory‑efficient; a range of a million numbers still occupies a tiny struct with start/stop fields.
Keyword lists
A keyword list is a list of two‑element tuples where the first element is an atom. It’s handy for optional arguments.
def log(message, opts \\ []) do
level = Keyword.get(opts, :level, :info)
prefix = Keyword.get(opts, :prefix, "")
IO.puts("[#{String.upcase(to_string(level))}] #{prefix}#{message}")
end
log("User signed in", level: :debug, prefix: "[Auth] ")
# => [DEBUG] [Auth] User signed in
MapSet
A set implemented on top of a map; it guarantees uniqueness of elements.
users = MapSet.new(["alice", "bob"])
users = MapSet.put(users, "charlie")
IO.inspect(MapSet.member?(users, "bob")) # true
Date/Time Types
Elixir provides Date, Time, NaiveDateTime, and DateTime. They’re all structs built on maps, offering conversion, formatting, and timezone handling.
today = Date.utc_today()
IO.puts("Today is #{Date.to_string(today)}")
Common Patterns & Pitfalls
Pattern matching on literals
Because atoms, tuples, and maps are immutable, they make excellent pattern‑matching targets:
def handle({:error, reason}) do
IO.puts("Failed: #{reason}")
end
def handle({:ok, payload}) do
IO.puts("Success!")
end
Avoiding costly list appends
Appending to a list with ++ creates a copy of the left operand. In a loop that builds a large collection, repeatedly using ++ will degrade performance. Prefer prepending and a final Enum.reverse/1 instead.
Never rely on Map.put/3 for “update‑or‑insert” semantics without checking existence.
If you intend to add a new key only when it doesn’t exist, use Map.update/4 or Map.put_new/3 to avoid accidental overwrites.
Beware of accidental capture of mutable references in closures.
Since a closure holds a reference to the exact memory location of an outer variable, rebinding that variable does not affect the captured value. This can lead to subtle bugs when you expect the closure to see the latest value.
Summary
- Atoms are constant, globally unique literals; they can double as module aliases.
- Booleans are just
:trueand:false;nilandfalseare falsy, everything else is truthy. - Short‑circuit operators (
||,&&) enable concise fallback logic. - Tuples group a fixed number of elements; updating creates a new tuple.
- Lists are recursive, singly‑linked structures; prepend is cheap, append is costly.
- Immutability guarantees side‑effect‑free functions and safe concurrency.
- Maps provide flexible key/value storage; dot syntax works for atom keys, while
Map.fetch/2distinguishes missing keys. - Binaries are raw byte containers; strings are UTF‑8 binaries, while character lists are lists of codepoints.
- Functions are first‑class; the capture operator (
&) and argument placeholders simplify lambda creation. - Higher‑level abstractions (Ranges, Keyword lists, MapSet, Date/Time) build on these primitives to solve everyday problems.
Mastering these building blocks equips you to write clear, efficient, and idiomatic Elixir code—whether you’re manipulating data structures, defining protocols, or orchestrating concurrent processes.