Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

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.

  • Beginner
  • 15 minutes
  • Examples run with elixir 1.20.4, erlang 29.1.1 and Python 3.14.8
  • By MySmartCoPilot
Free lesson, Pro answers: 15 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

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:

Atoms in IEx Python · atoms_session.py
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:

truthiness.exs: ten values in a condition Elixir · truthiness.exs
# 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
Which values count as true Python · truthiness_session.py
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

  1. Question 1 of 2 Which of these values is not an atom?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: "ok"

    An atom is a constant whose value is its own name. :ok, true, false, nil and every module name are atoms; "ok" is a string, a different type.

  2. Question 2 of 2 Which of these values make if value do … else … end take its else branch?

    Choose every answer that is right.

    Show the answer: Question 2 of 2

    Answer:

    • nil
    • false

    Exactly two values are falsy, false and nil. Every other value, 0, "", [], %{} and the string "false" included, is truthy and runs the do block.

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:

Strict and relaxed operators in IEx Python · operators_session.py
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

  1. Question 1 of 2 What does true and 0 return?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: 0

    The documentation of and/2 says it requires only the left operand to be a boolean, since it short-circuits. With true on the left it returns the right operand, so true and 0 is 0, just as true and 1 was 1.

  2. Question 2 of 2 attempts is 0 after a first try that worked. What does attempts || 3 return?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: 0

    value || default returns value whenever it is truthy, and 0 is truthy, so attempts || 3 is 0, as 0 || :never was 0 in the session. The default is used only for nil or false.

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.

== against === in IEx Python · equality_session.py
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:

term_order.exs: one value of every kind, sorted Elixir · term_order.exs
# 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")
The term ordering, and the rule inside each kind Python · term_order_session.py
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: :Apple first.
  • 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

  1. Question 1 of 2 Which of these comparisons returns false?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: 2 === 2.0

    == and === give different answers only about numbers: an integer and a float of the same value, as 2 == 2.0 is true and 2 === 2.0 is false, and the two float zeros, 0.0 and -0.0.

  2. Question 2 of 2 What does this return?

    Read the code, then choose one answer.

    Enum.sort([{2, 1}, {1, 2, 3}, {3}])
    Show the answer: Question 2 of 2

    Answer: [{3}, {2, 1}, {1, 2, 3}]

    Tuples are ordered by size, and tuples of the same size element by element. That is why {9} came before {1, 2} in the lesson's program.

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:

atom_table.exs: what String.to_atom/1 adds Elixir · atom_table.exs
# 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()
Counting the atom table Python · atom_table_session.py
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:

Interactive · step by step Where atoms live, and how text fills the atom table Step through atom_table.exs. Every name is stored once in one table, and nothing ever takes a name out again.
Where atoms live, and how text fills the atom table, step 6 of 6. Showing Atoms in your code, The atom table, :ok == :ok, Text from outside, String.to_atom/1, 1,000 more names (still there, unused), to_existing_atom/1, Limit: 1,048,576 names. In focus: to_existing_atom/1.stored oncenew entrieslooks up onlyAtoms in your code:ok, :error, StringThe atom table20,582 names at the start:ok == :okone entry against one entryText from outside"user_1" to "user_1000"String.to_atom/1adds a name when it is new1,000 more namesstill there, unusedto_existing_atom/1raises for a new nameLimit: 1,048,576 namesset with the +t flag

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. 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.
  2. 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}.
  3. 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.
  4. 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.
  5. 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.
  6. 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

  1. Question 1 of 2 A server calls String.to_atom/1 on the same 500 user names ten times a day for a week. None of the names was an atom before. How many atoms does it add to the table?

    Type a number.

    A hint

    In the script, the same 1,000 strings a second time added nothing.

    Show the answer: Question 1 of 2

    Answer: 500 atoms

    Each name gets one entry, the first time it is turned into an atom, and later calls find that entry: 500 new atoms, which stay in the table until the VM stops. A server that sees new names every day keeps adding to them.

  2. Question 2 of 2 No atom named not_an_atom_yet_42 exists. What does String.to_existing_atom("not_an_atom_yet_42") do?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: It raises ArgumentError

    String.to_existing_atom/1 never adds to the atom table. It returns the atom when the name exists and raises ArgumentError, "not an already existing atom", when it does not.

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:

practice/flags.ex: the starter Elixir · practice/flags.ex
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
practice/flags_test.exs: the tests Elixir · practice/flags_test.exs
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:

The tests against the starter Python · practice_session.py
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:

to_atom/flags.ex: a version that makes atoms Elixir · to_atom/flags.ex
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:

The tests against the version that makes atoms Python · to_atom_session.py
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:

The tests against a finished parse/1 Python · finished_session.py
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.

  1. Question 1 of 15 When are two atoms equal?

    Choose one answer.

    Show the answer: Question 1 of 15

    Answer: When their names are the same

    An atom is a constant whose value is its own name: two atoms are equal exactly when their names are equal.

  2. Question 2 of 15 What does this return?

    Read the code, then choose one answer.

    is_atom(Enum) and Enum == :"Elixir.Enum"
    Show the answer: Question 2 of 15

    Answer: true

    Elixir module names are atoms with the prefix Elixir., so Enum == :"Elixir.Enum" is true, just as String == :"Elixir.String" was in the session.

  3. Question 3 of 15 A function returns {:ok, 42} or {:error, :not_found}. Why are the tags atoms rather than the strings "ok" and "error"?

    Choose one answer.

    Show the answer: Question 3 of 15

    Answer: Atoms are fixed names that compare in one step, and the set of tags is known when the code is written

    Result tags come from a small, fixed set written in the code, which suits atoms: each is stored once and compared as one table entry. The atom table's one rule, never add names from outside text, does not apply to names written in the code.

  4. Question 4 of 15 Is 0 truthy or falsy in Elixir?

    Choose one answer.

    Show the answer: Question 4 of 15

    Answer: Truthy

    Elixir has exactly two falsy values, false and nil. 0 is truthy, as the script printed.

  5. Question 5 of 15 What does this print?

    Read the code, then choose one answer.

    IO.puts(if [], do: "has items", else: "empty")
    Show the answer: Question 5 of 15

    Answer: has items

    if [] runs its do branch because the empty list is truthy. Test for an empty list with list == [] or Enum.empty?(list).

  6. Question 6 of 15 You port if count: from Python, where 0 is false, to Elixir as if count do … end. What happens when count is 0?

    Choose one answer.

    Show the answer: Question 6 of 15

    Answer: The do block runs, unlike in Python, so the port must say if count > 0

    Code that relies on 0 or "" being false in another language must say what it means in Elixir: count > 0, text != "", list != [].

  7. Question 7 of 15 Which operator raises BadBooleanError when its left side is 1?

    Choose one answer.

    Show the answer: Question 7 of 15

    Answer: and

    The strict operators demand booleans: and and or check their left side, and not checks its one argument. The relaxed &&, || and ! accept any value.

  8. Question 8 of 15 What does this return?

    Read the code, then choose one answer.

    false and raise("boom")
    Show the answer: Question 8 of 15

    Answer: false

    Both sets of operators short-circuit: and stops at a false left side, or at a true one, && at a falsy one and || at a truthy one, so the right side never runs.

  9. Question 9 of 15 A setting may be true, false or missing, which is nil. Which expression keeps an explicit false and gives true only for a missing setting?

    Choose one answer.

    Show the answer: Question 9 of 15

    Answer: if setting == nil, do: true, else: setting

    value || default replaces both nil and false. When false is a real answer, test for nil explicitly, or read the setting with a function that takes a default for a missing key, such as Keyword.get/3.

  10. Question 10 of 15 What does === check that == does not?

    Choose one answer.

    Show the answer: Question 10 of 15

    Answer: That the two values have the same type, so an integer never equals a float

    === counts two terms as equal only when they have the same value and the same type. The difference shows only in numbers: integers against floats, and the float zeros 0.0 and -0.0.

  11. Question 11 of 15 prices = %{1 => 99}. What does prices[1.0] return?

    Choose one answer.

    Show the answer: Question 11 of 15

    Answer: nil

    Keys are compared with the exact-equality operator, so 1 and 1.0 are different keys, as Map.get(%{1 => :found}, 1.0) returned nil in the session.

  12. Question 12 of 15 Which of these comparisons is true?

    Choose one answer.

    Show the answer: Question 12 of 15

    Answer: 1000 < :a

    The order between kinds is number < atom < reference < function < port < pid < tuple < map < list < bitstring, so 1000 < :a is true whatever the number.

  13. Question 13 of 15 Enum.sort(dates) puts the first day of April before the last day of March. Which call sorts them by calendar date?

    Choose one answer.

    Show the answer: Question 13 of 15

    Answer: Enum.sort(dates, Date)

    Dates are structs, and structural comparison looks at the :day field before :month and :year. Sort them with Enum.sort(dates, Date) and compare them with Date.compare/2.

  14. Question 14 of 15 What happens to an atom that String.to_atom/1 created, once no code uses it any more?

    Choose one answer.

    Show the answer: Question 14 of 15

    Answer: It stays in the atom table until the VM stops

    Erlang's Efficiency Guide says the atom table is not garbage-collected: names created at run time stay for the life of the VM, which is why outside text must never become atoms.

  15. Question 15 of 15 An endpoint takes ?order=asc or ?order=desc and passes :asc or :desc to a sort function. Which conversion is safest?

    Choose one answer.

    Show the answer: Question 15 of 15

    Answer: Compare the text with "asc" and "desc", return the matching atom, and reject anything else

    OTP's secure coding guidelines advise translating the text yourself when the valid values are few, and rejecting the rest. An allowlist of "asc" and "desc" accepts exactly what the sort function supports.

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.

  1. 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.

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. 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.

    Model answer, Pro. First step and follow-ups

Key takeaways

  • An atom is a constant whose value is its own name; true, false and nil are atoms, and so is every module name, String being :"Elixir.String".
  • Only false and nil are falsy, so 0, "", [] and %{} are truthy, and an empty collection needs an explicit test.
  • and, or and not need a boolean on the left and work in guards; &&, || and ! take any value, return the operand that decided, and turn false into a default when used as value || 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/2 or Enum.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/1 on 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

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.