Elixir & Erlang/OTP Module 2 – Values and types
Atoms, booleans, nil and comparison
Elixir atoms as named constants, true, false and nil, truthiness, and/or against &&/||, == against === and the term ordering that compares any two values.
What you will learn
- Explain atoms as named constants, and why true, false, nil and module names are atoms
- Predict which values are truthy, knowing that only false and nil are falsy
- Choose between the strict and, or and not and the relaxed &&, || and !
- Compare values with == and ===, and sort values of different types by the term ordering
- Avoid creating atoms from outside text, because the atom table is never emptied
Before you start
What you’ll learn · Before you start
What you will learn
- Explain atoms as named constants, and why true, false, nil and module names are atoms
- Predict which values are truthy, knowing that only false and nil are falsy
- Choose between the strict and, or and not and the relaxed &&, || and !
- Compare values with == and ===, and sort values of different types by the term ordering
- Avoid creating atoms from outside text, because the atom table is never emptied
Before you start
On this page
:ok, true and String are three spellings of one kind of value, the atom, and an Elixir condition treats every
value but two as true: if 0 and if "" both run their do block. Get either fact wrong and a check that looks right
lets bad data through, while one String.to_atom/1 call on user input can fill a table that the VM never empties.
Below, you will use atoms as names and tags, predict what counts as true, choose between the strict operators and,
or and not and the relaxed &&, || and !, tell == from ===, sort values of different types, and keep the
atom table safe from input. Erlang/OTP 29.1.1 with Elixir 1.20.4 printed each result you will see.
An atom is a constant whose value is its own name
An atom: A constant whose value is its own name, such as :ok or :error. true, false and nil are atoms, and so is every module name: String is the atom :"Elixir.String". is a name that stands for itself. The Elixir guide calls it “a constant whose value is
its own name”: :ok is the atom ok, and two atoms are equal when their names are. Try a few in IEx:
from session import shell
# Atoms in IEx: names that stand for themselves, the booleans and nil among them, and module names.
shell([
":ok == :ok",
"{:error, :timeout}",
"is_atom(true) and is_atom(nil)",
"true == :true",
'String == :"Elixir.String"',
":lists.reverse([1, 2, 3])",
':"hello world"',
"Atom.to_string(:ok)",
"System.halt()",
]) Output
$ iex
Erlang/OTP 29 [erts-17.1] [source] [64-bit] [smp:18:18] [ds:18:18:10] [async-threads:1] [jit]
Interactive Elixir (1.20.4) - press Ctrl+C to exit (type h() ENTER for help)
iex(1)> :ok == :ok
true
iex(2)> {:error, :timeout}
{:error, :timeout}
iex(3)> is_atom(true) and is_atom(nil)
true
iex(4)> true == :true
true
iex(5)> String == :"Elixir.String"
true
iex(6)> :lists.reverse([1, 2, 3])
[3, 2, 1]
iex(7)> :"hello world"
:"hello world"
iex(8)> Atom.to_string(:ok)
"ok"
iex(9)> System.halt()
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 atoms_session.py
Atoms are how Elixir code labels things. A function that can fail returns a tuple tagged with an atom, such as
{:ok, value} or {:error, :timeout}, and the code that receives it reads the tag first; the pattern-matching module
of this track shows how. The booleans are atoms too: true is the atom :true, as the fourth line showed, and false
and nil are atoms as well, which is why is_atom(true) and is_atom(nil) returned true. Elixir lets you leave out the
colon for these three.
Module names are atoms. String is a shorter way to write the atom :"Elixir.String", and an Erlang module is a plain
lowercase atom, which is why :lists.reverse([1, 2, 3]) called Erlang’s lists module directly. Quotes allow any
characters in a name, as in :"hello world", and Atom.to_string/1 turns an atom back into text.
Testing two atoms for equality is fast however long their names are, because the VM stores each name once and compares the entries, not the letters. The last section of this lesson shows where those names are kept, and why that matters for the safety of a server.
Only false and nil are falsy
Elixir conditions do not need booleans. if, &&, || and ! accept any value and treat exactly two values as
false: false and nil, the two falsy: Counting as false in a condition. Elixir has exactly two falsy values, false and nil. values. This script puts ten values through if:
# Ten values in a condition: only false and nil count as false.
values = [false, nil, true, 0, 0.0, "", [], %{}, :ok, "false"]
for value <- values do
verdict = if value, do: "truthy", else: "falsy"
IO.puts(String.pad_trailing(inspect(value), 10) <> verdict)
end from session import run
run("""
elixir truthiness.exs
""") Output
$ elixir truthiness.exs
false falsy
nil falsy
true truthy
0 truthy
0.0 truthy
"" truthy
[] truthy
%{} truthy
:ok truthy
"false" truthy
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 truthiness_session.py
Only the first two lines say falsy. 0, 0.0, the empty string, the empty list, the empty map and even the string
“false” are truthy: Counting as true in a condition. In Elixir every value is truthy except false and nil, so 0, the empty string and the empty list are all truthy.. If you come from Python or JavaScript, where 0 and “” count as false, this
is the line to remember: a missing value is nil, and an empty value is still a value, so it is true.
Testing a collection for emptiness with if
if items do … end runs its do block for [], because an empty list is truthy. Ask the question you mean
instead: items == [] or Enum.empty?(items) for a list, and text == "" for a string.
The rule makes one idiom common: value || default. || returns its left side when that is truthy and its right side
otherwise, so a lookup that may give nil gets its fallback in one expression. The next section shows that idiom at
work, and the one value it gets wrong.
Key idea
Only false and nil are falsy; every other value, 0, "" and [] included, is truthy.
Checkpoint
Check: Atoms as named constants · Truthy and falsy values
The strict operators want booleans; the others take any value
Elixir has two sets of boolean operators. and, or and not are strict and expect booleans; &&, || and ! are
relaxed and go by truthiness. Both sets short-circuit: they evaluate their right side only when the left side has not
already decided the answer. Watch both sets in IEx:
from session import shell
# The strict boolean operators and the ones that take any value, side by side.
shell([
"true and false",
"1 and true",
"true and 1",
'false and raise("never evaluated")',
"nil || :default",
"0 || :never",
"nil && :never",
"!nil",
"not nil",
"System.halt()",
]) Output
$ iex
Erlang/OTP 29 [erts-17.1] [source] [64-bit] [smp:18:18] [ds:18:18:10] [async-threads:1] [jit]
Interactive Elixir (1.20.4) - press Ctrl+C to exit (type h() ENTER for help)
iex(1)> true and false
false
iex(2)> 1 and true
** (BadBooleanError) expected a boolean on left-side of "and", got:
1
iex:2: (file)
iex(2)> true and 1
1
iex(3)> false and raise("never evaluated")
false
iex(4)> nil || :default
:default
iex(5)> 0 || :never
0
iex(6)> nil && :never
nil
iex(7)> !nil
true
iex(8)> not nil
** (ArgumentError) argument error
:erlang.not(nil)
iex:8: (file)
iex(8)> System.halt()
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 operators_session.py
1 and true raised BadBooleanError, because the left side of and must be a boolean. But true and 1 returned 1:
the documentation of and/2 says it “Requires only the left operand to be a boolean since it short-circuits”, so
and checks its left side and hands back its right side as it is. false and raise("never evaluated") returned
false without raising, because the right side never ran. not nil raised ArgumentError, while !nil returned true.
The relaxed operators return one of their operands, not necessarily a boolean: nil || :default gave :default,
0 || :never gave 0, because 0 is truthy, and nil && :never gave nil. The two sets compare like this:
| Rule | The strict three | The relaxed three |
|---|---|---|
| The left side | must be true or false | may be any value |
| Counts as false | false |
false and nil |
| Returns | a boolean, or the right side as it is | the operand that decided the answer |
| In guards | allowed | not allowed |
| Use for | conditions that are booleans | defaults, and values that may be nil |
The Elixir guide’s rule of thumb fits the table: use and, or and not when you expect booleans, and &&, ||
and ! when an argument may be something else. Only the strict ones work in guards, the tests on function clauses
that a later module teaches; the Kernel documentation marks each relaxed operator “Not allowed in guard clauses”.
A default with || that replaces false
show_tips || true is meant to default a missing setting to true, but when the setting is false, a real answer,
|| treats it as missing and returns true. When false is a value the code must keep, test for nil itself, or use
a lookup with a default, such as Keyword.get(options, :show_tips, true).
Checkpoint
Check: Strict and relaxed boolean operators
Two kinds of equality: by value and by type
== compares numbers by value, so 1 == 1.0 is true. === is strict equality: Comparison with === or !==, which counts two values as equal only when they also have the same type: 1 == 1.0 is true, but 1 === 1.0 is false. Map keys are compared this way.: in
the words of the Kernel documentation, two terms are “only considered to be exactly equal if they have the same value
and are of the same type”, so 1 === 1.0 is false. != and !== are their opposites. The two disagree only about
numbers: an integer and a float of the same value, also inside lists, tuples and the values of maps, and the two zeros
of floats that the last lines show.
from session import shell
# Equal by value or equal and of the same type: == and ===, and what maps use for their keys.
shell([
"1 == 1.0",
"1 === 1.0",
"1 !== 1.0",
"%{1 => :integer, 1.0 => :float}",
"Map.get(%{1 => :found}, 1.0)",
"0.0 == -0.0",
"0.0 === -0.0",
"System.halt()",
]) Output
$ iex
Erlang/OTP 29 [erts-17.1] [source] [64-bit] [smp:18:18] [ds:18:18:10] [async-threads:1] [jit]
Interactive Elixir (1.20.4) - press Ctrl+C to exit (type h() ENTER for help)
iex(1)> 1 == 1.0
true
iex(2)> 1 === 1.0
false
iex(3)> 1 !== 1.0
true
iex(4)> %{1 => :integer, 1.0 => :float}
%{1 => :integer, 1.0 => :float}
iex(5)> Map.get(%{1 => :found}, 1.0)
nil
iex(6)> 0.0 == -0.0
true
iex(7)> 0.0 === -0.0
false
iex(8)> System.halt()
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 equality_session.py
The difference matters most in maps. The Map documentation says that keys “are compared using the exact-equality
operator”, ===, so %{1 => :integer, 1.0 => :float} holds two keys, and Map.get(%{1 => :found}, 1.0) found
nothing: an amount stored under the key 1 cannot be read back with 1.0. The last two lines show a corner of floats:
there are two zeros, and since Erlang/OTP 27 the strict operators tell them apart, so 0.0 == -0.0 is true while
0.0 === -0.0 is false, as Erlang’s reference manual describes.
The same difference hides bugs in tests. 5.0 == 5 is true, so a test that compares with == passes when a function
returns the float 5.0 where it should return the integer 5. Assert with === when the type is part of the answer.
Key idea
== asks whether two numbers have the same value and === whether they are the same term, value and type, and map
keys always use ===.
Any two values can be compared, in the term ordering
Elixir lets you compare any two values, whatever their types: 1 < :an_atom is a valid comparison, and true. The order
between types is fixed, the term ordering: The fixed order in which Elixir compares values of different types: number < atom < reference < function < port < pid < tuple < map < list < bitstring. It lets any two values be compared and sorted., so a list of mixed values can always be
sorted. This program sorts one value of every kind and prints the kinds in the order the sort left them:
# One value of every kind of term, sorted: the sort follows the term ordering.
values = ["text", [1, 2], %{a: 1}, {:ok, 1}, self(), hd(Port.list()), &String.upcase/1, make_ref(), :atom, 3.5, 42]
# kind names the kind of a value: the first clause whose test is true gives the name.
kind = fn
v when is_number(v) -> "number"
v when is_atom(v) -> "atom"
v when is_reference(v) -> "reference"
v when is_function(v) -> "function"
v when is_port(v) -> "port"
v when is_pid(v) -> "pid"
v when is_tuple(v) -> "tuple"
v when is_map(v) -> "map"
v when is_list(v) -> "list"
v when is_bitstring(v) -> "bitstring"
end
kinds = Enum.dedup(Enum.map(Enum.sort(values), kind))
IO.puts(Enum.join(kinds, " < "))
# Inside one kind, each has its own rule.
IO.inspect(Enum.sort([{1, 2, 0}, {9}, {1, 2}]), label: "tuples")
IO.inspect(Enum.sort([[5], [1, 1], [1]]), label: "lists")
IO.inspect(Enum.sort([:pear, :apple, :Apple, :banana]), label: "atoms")
IO.inspect(Enum.sort(["office", "álien", "alien"]), label: "strings")
# Structs are maps, so dates compare field by field too: day before month.
march = ~D[2026-03-31]
april = ~D[2026-04-01]
IO.inspect(march > april, label: "~D[2026-03-31] > ~D[2026-04-01]")
IO.inspect(Enum.sort([april, march]), label: "Enum.sort/1")
IO.inspect(Date.compare(march, april), label: "Date.compare/2")
IO.inspect(Enum.sort([april, march], Date), label: "Enum.sort/2 with Date") from session import run
run("""
elixir term_order.exs
""") Output
$ elixir term_order.exs
number < atom < reference < function < port < pid < tuple < map < list < bitstring
tuples: [{9}, {1, 2}, {1, 2, 0}]
lists: [[1], [1, 1], [5]]
atoms: [:Apple, :apple, :banana, :pear]
strings: ["alien", "office", "álien"]
~D[2026-03-31] > ~D[2026-04-01]: true
Enum.sort/1: [~D[2026-04-01], ~D[2026-03-31]]
Date.compare/2: :lt
Enum.sort/2 with Date: [~D[2026-03-31], ~D[2026-04-01]]
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 term_order_session.py
The first line is the order that the Kernel documentation gives: number < atom < reference < function < port < pid < tuple < map < list < bitstring. References, functions, ports and pids are kinds of value that later modules meet, and strings are bitstrings, so they come last. Inside each kind there is a rule of its own, which the next four lines show:
- Tuples compare by size first, then element by element, so
{9}comes before{1, 2}. - Lists compare element by element:
[1], then[1, 1], then[5]. - Atoms compare by their text, code point by code point, so capitals sort before lowercase letters:
:Applefirst. - Strings compare byte by byte, so “álien” sorts after “office”: the first byte of
áis larger than that of any unaccented letter.
This is a structural comparison: it compares how values are built, not what they mean. The Kernel documentation’s
example is dates. A Date is a struct, which is a map underneath, and maps of the same size compare by their keys and
then by their values in key order, so a date’s :day field is compared before its :month and :year. That is why
~D[2026-03-31] > ~D[2026-04-01] was true, and why Enum.sort/1 put the April date first. To compare by meaning, use
the type’s own function, Date.compare/2, which returned :lt, or pass the module to Enum.sort/2:
Enum.sort(dates, Date) put the two dates in calendar order.
Sorting dates and times with Enum.sort/1
Enum.sort/1 and the operators < and > compare dates structurally, day first. Sort dates with
Enum.sort(dates, Date) and compare them with Date.compare/2; DateTime, NaiveDateTime and Time have a
compare/2 of their own for the same reason.
Checkpoint
Check: Equality by value and by type · The term ordering
The atom table never shrinks, so never fill it from input
Every atom name lives in one place, the VM’s atom table: The VM's table of every atom name in use. Each name is stored once, entries are never removed, and by default it holds at most 1,048,576 atoms.. The table is what makes atoms cheap to compare, and its weakness is that it never shrinks. This script counts the atoms in the table, turns 1,000 strings into atoms twice, and then asks for an atom that does not exist yet:
# The atom table: how many atoms exist, what String.to_atom/1 adds, and what String.to_existing_atom/1 refuses.
defmodule Main do
def count, do: :erlang.system_info(:atom_count)
def run do
IO.puts("atom limit: #{:erlang.system_info(:atom_limit)}")
IO.puts("atoms in the table now: #{count()}")
# Warm up the code below once, so that loading it adds no atoms to the counts.
_ = String.to_atom("warm_up")
before = count()
Enum.each(1..1000, fn i -> String.to_atom("user_" <> Integer.to_string(i)) end)
IO.puts("String.to_atom/1 on 1,000 new strings: #{count() - before} new atoms")
before = count()
Enum.each(1..1000, fn i -> String.to_atom("user_" <> Integer.to_string(i)) end)
IO.puts("the same 1,000 strings again: #{count() - before} new atoms")
IO.inspect(String.to_existing_atom("user_1"), label: "String.to_existing_atom(\"user_1\")")
String.to_existing_atom("never_seen_before")
end
end
Main.run() from session import run
# The script ends with the error that String.to_existing_atom/1 raises for a name that is not an atom yet.
run("""
!elixir atom_table.exs
""") Output
$ elixir atom_table.exs
atom limit: 1048576
atoms in the table now: 20582
String.to_atom/1 on 1,000 new strings: 1000 new atoms
the same 1,000 strings again: 0 new atoms
String.to_existing_atom("user_1"): :user_1
** (ArgumentError) errors were found at the given arguments:
* 1st argument: not an already existing atom
:erlang.binary_to_existing_atom("never_seen_before")
atom_table.exs:25: (file)
[exit status 1]
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 atom_table_session.py
The table held 20,582 names when the script started: the atoms of Elixir, of OTP and of the script itself.
String.to_atom/1 added 1,000 more, one for each new string, and the same strings a second time added none, because
each name has one entry. Erlang’s Efficiency Guide says it plainly: “The atom table is not garbage-collected.” Those
1,000 names stay until the VM stops, and the System Limits page puts the default limit at 1,048,576 atoms, which the
+t flag of erl changes. Step through the run:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- Atoms in code. Every atom that code mentions, such as :ok, :error or a module name like String, is stored once in the VM's atom table when the code is loaded. The script found 20,582 names there before it did anything.
- Comparing atoms. :ok == :ok compares two entries of the table rather than two strings of letters, so it takes the same time however long the name is. That is what makes atoms good tags, as in {:ok, value}.
- Text becomes atoms. String.to_atom/1 turns a text into an atom, and when the name is new it adds an entry. The script turned the 1,000 strings "user_1" to "user_1000" into 1,000 new atoms.
- Nothing is removed. The table is never garbage-collected, so the 1,000 names stay until the VM stops, used or not. The same strings a second time added nothing, because each name already had its entry.
- The limit. The table holds at most 1,048,576 names by default, and the +t flag of erl changes the limit. Code that turns outside text into atoms lets anyone who sends text fill it, and OTP's secure coding guidelines warn that the system might then crash.
- Looking names up. String.to_existing_atom/1 only looks names up. It returned :user_1, which existed, and raised ArgumentError for never_seen_before instead of adding it. Comparing the text with the few values you accept is safer still.
That turns a convenience into a risk. Code that calls String.to_atom/1 on text from outside, such as a query
parameter, a JSON key or a header, lets anyone who can send text add names to the table, and OTP’s secure coding
guidelines (DSG-003, “Do Not Abuse Atoms”) warn that converting text to atoms without care “might cause the system to
crash”. String.to_existing_atom/1 only looks names up: it returned :user_1, which existed, and raised
ArgumentError for “never_seen_before”. The same guideline adds that when the valid values are few, it is better to
translate the text yourself and reject everything else, which is how the practice below works.
Never make atoms from outside text
String.to_atom/1 on text from a request, a file or a message adds names to a table that is never emptied. Compare
the text with the values you accept, as in word in ["asc", "desc"], and reject the rest; use
String.to_existing_atom/1 only when any atom that already exists is an acceptable answer.
Checkpoint
Check: The atom table and outside text
Practise: read yes-or-no settings safely
Write Flags.parse/1, which reads a setting that a person typed. “on” and “yes” give true, “off” and “no” give
false, in any mix of upper and lower case, and any other text gives {:error, :unknown}. It must never create an
atom from the text: the last test parses 10,000 new strings and checks that the atom table grew by fewer than
1,000. Copy the two files into an empty folder:
defmodule Flags do
@moduledoc "Reads yes-or-no settings typed by people: on, off, yes and no, in any case."
@doc """
Returns true for "on" and "yes", false for "off" and "no", in any mix of upper and lower case,
and {:error, :unknown} for any other text. It never creates an atom from the text.
"""
def parse(_text) do
# Drop the underscore once you use the argument.
{:error, :unknown}
end
end Code.require_file("flags.ex", __DIR__)
ExUnit.start(seed: 0)
defmodule FlagsTest do
use ExUnit.Case
test "on and yes mean true, in any case" do
assert Flags.parse("on") == true
assert Flags.parse("YES") == true
assert Flags.parse("On") == true
end
test "off and no mean false, in any case" do
assert Flags.parse("off") == false
assert Flags.parse("No") == false
end
test "any other text is an error" do
assert Flags.parse("maybe") == {:error, :unknown}
assert Flags.parse("") == {:error, :unknown}
assert Flags.parse("true") == {:error, :unknown}
end
test "unknown text creates no atoms" do
texts = for i <- 1..10_000, do: "flag-" <> Integer.to_string(i)
before = :erlang.system_info(:atom_count)
Enum.each(texts, &Flags.parse/1)
# Turning the texts into atoms would add 10,000. Other code that runs meanwhile, such as
# the test reporter, may add a few dozen, so the test allows fewer than 1,000.
assert :erlang.system_info(:atom_count) - before < 1_000
end
end Run elixir flags_test.exs. The starter calls every text unknown, so the two tests of known words fail and the other
two pass:
from session import run
# The tests against the starter, whose parse/1 calls every text unknown.
run("""
cd practice
!elixir flags_test.exs
""") Output
$ cd practice
$ elixir flags_test.exs
Running ExUnit with seed: 0, max_cases: 36
1) test on and yes mean true, in any case (FlagsTest)
flags_test.exs:7
Assertion with == failed
code: assert Flags.parse("on") == true
left: {:error, :unknown}
right: true
stacktrace:
flags_test.exs:8: (test)
2) test off and no mean false, in any case (FlagsTest)
flags_test.exs:13
Assertion with == failed
code: assert Flags.parse("off") == false
left: {:error, :unknown}
right: false
stacktrace:
flags_test.exs:14: (test)
..
Finished in 0.03 seconds (0.03s on load, 0.00s async, 0.00s sync)
Result: 2/4 passed
Failed: 2 tests
[exit status 2]
This output changes from run to run: ExUnit prints how long the tests took, and max_cases follows the computer's processors
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 practice_session.py
String.downcase/1 makes the case of the text irrelevant, word in ["on", "yes"] checks whether a word is one of a
list, and if … do … else … end picks the result. The last test guards against the risk of the previous section.
Here is a version that compares atoms instead of strings, so it turns the text into an atom first:
defmodule Flags do
@moduledoc "A version that turns every text it reads into an atom first: it passes three tests of four."
def parse(text) do
word = String.to_atom(String.downcase(text))
if word in [:on, :yes] do
true
else
if word in [:off, :no], do: false, else: {:error, :unknown}
end
end
end It passes the first three tests and fails the last, which counted 10,000 new atoms for 10,000 new texts:
from session import run
# The tests against a version that makes an atom of the text before it compares it.
run("""
cd to_atom
cp ../practice/flags_test.exs .
!elixir flags_test.exs
""") Output
$ cd to_atom
$ cp ../practice/flags_test.exs .
$ elixir flags_test.exs
Running ExUnit with seed: 0, max_cases: 36
...
1) test unknown text creates no atoms (FlagsTest)
flags_test.exs:24
Assertion with < failed
code: assert :erlang.system_info(:atom_count) - before < 1000
left: 10000
right: 1000
stacktrace:
flags_test.exs:30: (test)
Finished in 0.1 seconds (0.1s on load, 0.00s async, 0.02s sync)
Result: 3/4 passed
Failed: 1 test
[exit status 2]
This output changes from run to run: ExUnit prints how long the tests took, max_cases follows the computer's processors, and other code may add a few atoms while the test counts
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 to_atom_session.py
Comparing the text with the strings you accept, as the finished version does, creates nothing, and it passes all four:
from session import run
# The same tests against a finished parse/1.
run("""
cd finished
cp ../practice/flags_test.exs .
elixir flags_test.exs
""") Output
$ cd finished $ cp ../practice/flags_test.exs . $ elixir flags_test.exs Running ExUnit with seed: 0, max_cases: 36 .... Finished in 0.02 seconds (0.02s on load, 0.00s async, 0.00s sync) Result: 4 passed
This output changes from run to run: ExUnit prints how long the tests took, and max_cases follows the computer's processors
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 finished_session.py
To finish, the lesson’s self-check: one question at a time on atoms, truthiness, the two sets of operators, equality, ordering and the atom table, beginning with the topic your answers show you know least, each choice explained:
Practice that adapts to you
Practise this lesson
15 questions about 6 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Warm-up: what is an atom in Elixir, and why are true, false and nil atoms?
An atom is a constant whose value is its own name, such as :ok or :error. Two atoms are equal when their names
are, and the VM stores each name once in its atom table, so checking two atoms for equality is as cheap as comparing
two small integers. That makes atoms the usual tags of results, as in {:ok, value} and {:error, :timeout}. The booleans are
not a separate type: true and false are the atoms :true and :false, as in Erlang, which has no boolean data
type at all, and nil is the atom :nil, which Elixir uses for “no value”. Module names are atoms too: String is
:"Elixir.String". Because the atom table is never garbage-collected, atoms are for names known in advance, never for
text that comes from users.
Two questions from code that takes input and sorts data:
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Fresher to mid
An HTTP handler turns the query parameter sort=name into an atom with String.to_atom/1 to choose the sort field. Explain the risk, rewrite the lookup safely, and say what the handler should answer when the value is unknown.
-
Fresher to mid
A report sorts Date values with Enum.sort/1 and marks a task late when today > due. It lists the last day of March after the first day of April, and on the last day of April it marks a task due the next day as late. Explain both bugs, fix them, and say which tests would have caught them.
Key takeaways
- An atom is a constant whose value is its own name;
true,falseandnilare atoms, and so is every module name,Stringbeing:"Elixir.String". - Only
falseandnilare falsy, so 0,"",[]and%{}are truthy, and an empty collection needs an explicit test. and,orandnotneed a boolean on the left and work in guards;&&,||and!take any value, return the operand that decided, and turnfalseinto a default when used asvalue || default.==compares numbers by value and===also by type, and map keys use===, so 1 and 1.0 are two different keys.- Any two values compare by the term ordering, number < atom < … < list < bitstring, and the comparison is structural,
so dates need
Date.compare/2orEnum.sort(dates, Date). - The atom table held 20,582 names at the start, is never garbage-collected and stops at 1,048,576 by default, so never
call
String.to_atom/1on outside text.
Terms in this lesson
- atom
- A constant whose value is its own name, such as :ok or :error. true, false and nil are atoms, and so is every module name: String is the atom :"Elixir.String".
- atom table
- The VM's table of every atom name in use. Each name is stored once, entries are never removed, and by default it holds at most 1,048,576 atoms.
- falsy
- Counting as false in a condition. Elixir has exactly two falsy values, false and nil.
- strict equality
- Comparison with === or !==, which counts two values as equal only when they also have the same type: 1 == 1.0 is true, but 1 === 1.0 is false. Map keys are compared this way.
- term ordering
- The fixed order in which Elixir compares values of different types: number < atom < reference < function < port < pid < tuple < map < list < bitstring. It lets any two values be compared and sorted.
- truthy
- Counting as true in a condition. In Elixir every value is truthy except false and nil, so 0, the empty string and the empty list are all truthy.
References
- Elixir guide: Basic types (atoms, booleans and nil) (The Elixir Team (hexdocs.pm))
- Kernel (Elixir), boolean operators, == and ===, and structural comparison (The Elixir Team (hexdocs.pm))
- Atom (Elixir) (The Elixir Team (hexdocs.pm))
- String.to_existing_atom/1 (Elixir) (The Elixir Team (hexdocs.pm))
- Map (Elixir), keys compared with === (The Elixir Team (hexdocs.pm))
- Date.compare/2 (Elixir) (The Elixir Team (hexdocs.pm))
- Erlang Reference Manual: Expressions, term comparisons (Erlang/OTP (erlang.org))
- Erlang Reference Manual: Data Types (booleans are atoms; the two zeros) (Erlang/OTP (erlang.org))
- Efficiency Guide: Memory Usage (the atom table) (Erlang/OTP (erlang.org))
- System Limits (the number of atoms) (Erlang/OTP (erlang.org))
- Secure Coding and Deployment Hardening Guidelines: DSG-003, Do Not Abuse Atoms (Erlang/OTP (erlang.org))
- Input Validation Cheat Sheet (allowlists) (OWASP Foundation)
Report a problem with this lesson
Kept only in this browser. Your Learn progress