đ The Polyglot and the Crocodile

Elixir looks like three languages you already know. Then it bites.
Steven B Miers ¡ November 5, 2026 ¡ v1.15
đ Introduction
You open an Elixir file and relax a little.
do ⌠endblocks, so this is Ruby.- Keyword arguments and a
forcomprehension, so maybe Python with better manners. - Everything lives in a module with a capitalized name, so Javaâs in here somewhere too.
- Thereâs a web framework called Phoenix, with a router, controllers and a generator that scaffolds a CRUD screen, so Rails is definitely in here.
- The creator came from the Rails core team in 2011 [1], and it shows.
Then you write x = 1 and it works, but not for the reason you think. Three things you were sure of are now false:
=is a question, not a command. It asks whether the value on the right fits the shape on the left. A barexfits anything, soxis bound to1. Put a literal, a pattern, or a pinned^xon the left instead, and if the answer is no, it says so, loudly.- There are no objects. Modules hold functions. Data is data, and it never learns any methods.
- Nothing is mutable. You can give a name a new value. You cannot change the old one.
The thing you took for a log has teeth. Elixir-isms has the bite marks.
Elixir borrowed its clothes from languages you know and its body from one you probably donât.
This is an introduction, not a sermon. It covers where the body came from, the everyday syntax and where it differs, the constructs with no cousin in Ruby, Python or Java, and where the crocodile loses. Part 2 covers what the ecosystem builds out of them; a one-page cheat sheet rides along separately, for interviews.
đď¸ Whatâs under the clothes
Eight names, and people use the first four interchangeably, which is how you can tell whoâs read the docs. From the bottom up:
- Erlang is the language. Ericsson built it in the late 1980s to run telephone switches, which is why it cares about uptime the way a bank cares about decimals. Open-sourced in 1998 [2]
- BEAM is the virtual machine Erlang runs on, âBogdanâs Erlang Abstract Machine,â after its original author. A register machine, not a stack machine like the JVM [3]
- ERTS is the runtime system the BEAM lives inside: schedulers, memory, I/O. It has its own version number that does not match the OTP release. OTP 29 ships erts-17, and writing âERTS 29â is a tell [4]
- OTP is the standard library of behaviours for building things that stay up: supervisors, generic servers, applications. A library shelf, not a layer
- Phoenix is the web framework, two floors up. Rails-shaped on the outside, process-shaped inside, and the reason most people are reading this
- LiveView is Phoenixâs party trick: the pageâs state lives in a server process and the browser gets diffs. See more in: Part 2
- Oban is background jobs, stored in the database you already have, so enqueueing a job and saving the row itâs about is one transaction. Like Railsâ
Hotwire, but better:LiveViewis stateful, one process per visitor holding the page, whereHotwireis stateless and rebuilds from the request every time. See more in Part 2 - Commanded is CQRS and event sourcing with every aggregate as its own process, which is the patternâs natural habitat. See more in Part 2.
Elixir, which JosĂŠ Valim started in 2011, is none of the first four. Itâs a second language aimed at the same machine, and it gets there with less ceremony than youâd guess: it doesnât compile to Erlang source. It compiles to what Erlang source becomes after parsing, the Erlang Abstract Format, and hands that to the Erlang compiler with a note that says you take it from here [5].

Fig. 1: Two front doors, one building. Peers that meet at the Abstract Format; the Erlang compiler takes it from there to .beam bytecode. (The crossed-out box is real [6].)
So âElixir is fast because the BEAM is fastâ is true the way âScala has a great garbage collectorâ is true. It does, and Scala didnât write a line of it; the JVM did. Elixir gets the scheduler, the memory model and the fault model the same way, for free, because it never left the building. What it adds is the part you type.
See also: Getting started ¡ Erlang libraries from Elixir ¡ Mix
đ§ą Elixir-isms
The constructs youâll type before lunch on day one, all in one place. Every one has a cousin in Ruby, Python or Java, and every cousin will mislead you in some small, specific way. The first one misleads the most.
= is a question, not a statement
In Ruby, Python and Java, = means put this there. In Elixir it means does this fit? Most of the time the answer is yes, and it looks exactly like binding, because it is:
x = 1 # a bare name fits anything, so x is now 1. Binding, as advertised
1 = x # also legal: does x fit 1? It does, so nothing happens
2 = x # MatchError: x is 1, and the answer is no
x = 2 # a bare name always fits, so this rebinds x. The 1 itself never changed
^x = 3 # the pin makes x a wall: compare with the current x. MatchError
Binding vs. assignment, and why the word matters
This article says binding where the Ruby, Python and Java manuals say assignment, and it isnât pedantry; the two words make different promises. In those three languages a variable is a slot.
x = 2puts something new in the slot, and any code that reads the slot afterwards, including a closure created earlier, sees the new value. Thatâs assignment. In Elixir, a variable is a label stuck on a value, and the label never moves.x = 2doesnât touch the oldx; it makes a newxthat hides the old one from that line on. Thatâs binding, and doing it again to the same name is rebinding. A closure shows the difference:x = 1 get_x = fn -> x end x = 2 get_x.() # 1: the closure still holds the x it sawThe same lines in Python or Ruby give you
2, because the lambda reads the slot when it runs. (Java refuses to compile them: a lambda can only capture a local thatâs effectively final.) Erlang, underneath Elixir, doesnât allow rebinding at all:X = 2afterX = 1is just a failed match. Elixir lets you reuse the name for convenience, and the compiler gives each rebinding its own fresh variable underneath. The payoff is that no other code can change what yourxmeans behind your back.
The question gets interesting when the left side has a shape. Hereâs a fetch/1 that answers the way most Elixir functions do, with {:ok, value} or {:error, reason}:
defmodule Users do
@users %{1 => "Ada", 2 => "Grace"}
def fetch(id) do
case @users[id] do
nil -> {:error, :not_found}
user -> {:ok, user}
end
end
end
{:ok, user} = Users.fetch(1) # fits, so user binds to "Ada"
{:ok, user} = Users.fetch(99) # {:error, :not_found} won't fit: MatchError
The left side is a stencil. A hole (user) binds whatever arrives; a wall (:ok) has to agree, or the line raises. A bare x is nothing but hole, which is why x = 1 passes for assignment, and the pin ^ turns a hole into a wall: âcompare against what x already is; donât rebind it.â On a miss you get no nil and no warning: an exception, on the line where the shape was wrong. Function heads, case and with below are the same question, asked in different places.

Fig. 2: The match operator as a shape test. Hole binds, wall must agree.
The other three have all picked up pattern matching lately, and the gap is narrower than Elixirâs fans let on:
- Python 3.10 has
match, as a statement. When no case fits it does nothing. Silently [7]. - Java 21 has record patterns, final, with nesting and exhaustiveness over sealed types. âJava canât do thisâ expired in 2023 [8].
- Ruby 3âs
case ⌠inraisesNoMatchingPatternErroron a miss, same as Elixir, and Rubyâs pin can do more than Elixirâs [9]. - Scalaâs extractors are more extensible than any of the above.
So Elixirâs claim isnât expressiveness. Itâs that matching is the default and failure is loud. You donât opt in. You canât opt out.
Modules: where the classes went
Elixir has no classes, and everyone spends the first week looking for them anyway. What it has instead is three smaller things: modules for code, structs for data, behaviours for contracts. A module is a named bag of functions:
defmodule Pond do
@moduledoc "Functions about ponds. No state, no instances, no self."
@pi 3.14159 # a compile-time constant, not an instance variable
# public: Pond.area(2) returns 12.56636
def area(radius), do: @pi * square(radius)
# private: Pond.square(2) raises UndefinedFunctionError
defp square(x), do: x * x
end
- No
new, noself: a module is a namespace.Pond.area(2)reads like a Java static method because thatâs all there is. defanddefp: public and private. Thatâs the whole access-modifier menu.@pi: a module attribute, baked in at compile time. Ruby readers, this is the one that bites:@isnât an instance variable, because thereâs no instance to have one.
Structs: data with a name tag
A struct is a map with a name tag sewn in. It gets fields and defaults. It never gets methods.
defmodule Croc do
@enforce_keys [:name] # required at construction
defstruct [:name, length_m: 4.0, hungry?: true] # fields, with defaults
end
gus = %Croc{name: "Gus"} # %Croc{name: "Gus", length_m: 4.0, hungry?: true}
fed = %{gus | hungry?: false} # a new struct; gus is still hungry
%Croc{name: name} = fed # structs match like anything else: name is "Gus"
gus.name # "Gus"
gus[:name] # UndefinedFunctionError: no [] access on structs
%Croc{length_m: 5.0} # ArgumentError: :name must be given
defstruct: fields and defaults, checked when the struct is built;@enforce_keysmakes some of them mandatory.- Updating:
%{gus | hungry?: false}builds a new struct. The old one stays hungry, which is immutability doing its job. - Underneath: a map with a
__struct__: Crockey, which is why it pattern-matches like one and whyis_map(gus)istrue.
Behaviours: interfaces, British spelling
A behaviour is a contract a module promises to keep: a list of @callbacks. Itâs the nearest thing to a Java interface, with one difference youâll find out the hard way.
defmodule Notifier do
@callback deliver(to :: String.t(), msg :: String.t()) :: :ok | {:error, term()}
end
defmodule SmsNotifier do
@behaviour Notifier # the British spelling; @behavior won't compile
@impl Notifier # "this is a callback": the compiler checks the claim
def deliver(to, msg), do: IO.puts("SMS to #{to}: #{msg}")
end
notifier = SmsNotifier # modules are values, so this is dependency injection
notifier.deliver("555-0100", "the crocodile is awake")
- A missing callback is a warning, not an error. Java wonât compile a class that forgets a method; Elixir warns and carries on, and the call fails at runtime. Run CI with
mix compile --warnings-as-errorsand the two languages agree. - Modules are values: passing
SmsNotifierwhere aNotifieris expected is how Elixir does dependency injection, test doubles included. - Behaviours vs protocols: a behaviour is a contract for modules. A protocol dispatches on the dataâs type, like Pythonâs
functools.singledispatchwith better manners.
(@behavior doesnât merely fail. The compiler raises â@behavior attribute is not supported, please use @behaviour instead,â which is as close as a compiler gets to correcting your accent.)
Functions: overloading by count and by shape
A functionâs name is really name and arity. hello/0 and hello/1 are as unrelated as hello and goodbye, which is how Elixir overloads without types:
defmodule Greet do
def hello, do: hello("world") # hello/0...
def hello(name), do: "hello, #{name}" # ...and hello/1: two functions, one name
# a default argument defines both bye/1 and bye/2
def bye(name, word \\ "goodbye"), do: "#{word}, #{name}"
end
The other kind of overloading is by shape: same name, same arity, several clauses. The runtime takes the first one whose pattern fits, and a when guard narrows a clause without touching the body:
def http(200, body), do: {:ok, body}
def http(code, _) when code in 500..599, do: {:error, :server}
def http(code, _), do: {:error, code}
Three conditions, zero ifs. The nesting problem never shows up because thereâs nothing to nest. Anonymous functions are values, and they get called with a dot:
square = fn x -> x * x end # an anonymous function, bound to a name
square.(4) # 16: the dot marks an anonymous call
Enum.map(["Ada", "Grace"], &Greet.hello/1) # & captures a function by name/arity
- No
return: the last expression is the value. A sibling article, There Is No Return, is about exactly that. - Clauses stick together: clauses of one name/arity sit next to each other and are tried top to bottom. Split them up and the compiler warns.
- The dot is deliberate:
square.(4)andGreet.hello("Ada")look different so you always know which kind youâre holding.
Conditionals: everything returns something
if exists, and itâs an expression. It returns a value, so you bind its result to a name instead of binding inside it:
mood = if gus.hungry?, do: "stand back", else: "probably fine" # if returns a value
size =
cond do # no else-if: cond takes the first branch that's true
gus.length_m > 5 -> :large
gus.length_m > 3 -> :medium
true -> :small # the catch-all
end
case Users.fetch(2) do # the same shape test as =, several shapes
{:ok, user} -> "hello, #{user}"
{:error, :not_found} -> "who?"
end
- Truthiness: only
falseandnilare falsy.0,""and[]are all true, which Python readers find out exactly once. - Nothing leaks out: a variable bound inside an
ifdoesnât exist after it, and using it later is a compile error. Bind theifâs result instead, asmooddoes. caseis loud too: no clause fits, and it raisesCaseClauseError, the same as=.
Loops: there arenât any
Thereâs no while, and for isnât a loop. With nothing mutable thereâs no counter to bump, so iteration is a function call:
Enum.map([1, 2, 3], fn n -> n * 2 end) # [2, 4, 6]
Enum.reduce([1, 2, 3], 0, fn n, acc -> acc + n end) # 6: sum += n, minus the +=
for n <- 1..10, rem(n, 2) == 0, do: n * n # [4, 16, 36, 64, 100]
defmodule Countdown do
def run(0), do: :liftoff # the base case is its own clause
def run(n) when n > 0 do
IO.puts(n)
run(n - 1) # a tail call: a jump, not a new stack frame
end
end
And the trap every newcomer walks into once:
total = 0
for n <- [1, 2, 3], do: total = total + n # [1, 2, 3], plus an "unused" warning
total # still 0
Enum:map,filter,reduceand the rest of the family. Most of the loops youâd write elsewhere are one of these.for: a comprehension that returns a list, with generators and filters. It canât change anything outside itself, which is the trap above.- Recursion: the base case is a function head, and a tail call doesnât grow the stack. A loop that runs forever (a server, say) is a function that calls itself last.
with: the happy path, written once
= raises when the shape is wrong. Sometimes youâd rather get the wrong shape back and decide what to do about it. Thatâs with: a chain of matches, written <- instead of =, that stops at the first one that doesnât fit:
defmodule Door do
def open(params) do
with {:ok, id} when is_integer(id) <- Map.fetch(params, :id),
{:ok, name} <- Users.fetch(id) do
"come in, #{name}"
else
:error -> "no id given"
{:error, :not_found} -> "never heard of them"
end
end
end
Door.open(%{id: 1}) # "come in, Ada"
Door.open(%{}) # "no id given": Map.fetch returned :error
Door.open(%{id: 99}) # "never heard of them"
Door.open(%{id: "1"}) # WithClauseError: failed the guard, and no else fits
- No
else: the value that didnât fit comes straight back. Often thatâs the{:error, _}you meant to return anyway. - With
else: every possible misfit needs a clause, or you get aWithClauseError. The last line above is that, live. - What it replaces: a ladder of early returns in Python, an
Optionalchain in Java, and&.plus hope in Ruby.
See also: alias, require, import and use ¡ Keyword lists and maps ¡ Optional syntax sheet
đ§Ź Concurrency is grammar, not a library
In Java you import java.util.concurrent. In Python you import asyncio. In Elixir you import nothing, because the words are in the language:
pid = spawn(fn -> receive do {:ping, from} -> send(from, :pong) end end)
send(pid, {:ping, self()})
receive do :pong -> "pong" end
Thatâs the whole kit: make a process, put a message in its mailbox, wait for one in yours. A process isnât a thread, a green thread or a coroutine. It costs about 2.7 KB, brings its own heap, mailbox and garbage collector, and shares nothing, so it can be collected, crashed or killed and nobody else notices. (The exact bill, and the blog posts that got it wrong by 9Ă, open Part 2.) Everything else is built from two more primitives and one pattern:
- A link ties two processes together. If one crashes, the other gets the exit signal and, unless it has opted to trap exits, dies too. A clean exit doesnât spread.
- A monitor is the one-way version. âTell me if that one dies. Donât take me with it.â
- A supervisor is a process whose only job is to link to its children and restart them according to a policy you declared.

Fig. 3: A supervision tree, mid-incident. Worker B crashes; the exit signal rides the link up; the supervisor restarts B under a :one_for_one strategy, and A and C never find out.
This is what âlet it crashâ means, and candidates get it wrong in interviews often enough to be precise about it. It does not mean âdonât handle errors.â It means errors come in two kinds, with a tool for each:
- Expected failures (file missing, user not found) get a tagged tuple and a pattern match.
{:ok, _}or{:error, _}, right here, same as always. - Unexpected failures (the bug you didnât know you had) crash the process. The recovery policy lives in a supervisor somewhere else, not in a
rescueblock here.
Joe Armstrongâs 2003 thesis is titled Making reliable distributed systems in the presence of software errors [10]. In the presence of. The whole philosophy is in the preposition.
Java 21âs virtual threads made a thread as cheap as a BEAM process. They still share one heap, and nobody supervises them [11].
Loom makes concurrency cheap. The BEAM makes failure cheap. Those are different problems.
See also: Task ¡ Agent ¡ GenServer, by example
đŚ The parts with no cousin
Constructs with no equivalent in the three languages, each with its footgun, since a loaded tool should ship with the warning on the box:
- Atoms (
:ok,:error,MyModule) are interned constants in a global, never-collected table capped at about a million entries [12]. Ruby had the same design until 2.2 made symbols collectable [13]. SoString.to_atom(user_input)is a denial-of-service with a friendly name, andString.to_existing_atom/1exists. - The pipe
|>feeds the left value into the first argument of the right call, which is why the standard library puts the data first everywhere. People say âlike F#,â and F# pipes into the last argument. Different convention, same relief. - Bitstrings are the one with no analogue at all. You can pattern-match on bits, and widths that donât add up are a match failure, not a silent truncation. In Java thatâs a shift, a mask, and a code review comment.
- Macros operate on the abstract syntax tree, the AST: the tree the compiler builds from your source code, which Elixir exposes as plain three-element tuples. Much of the language (
if,def,defmodule) is macros written in Elixir [14]. The official anti-patterns guide then spends a chapter telling you not to write them. Both are right: good for extending the language, wrong for reusing code.
See also: Sigils ¡ Enumerables and streams
" Hello, World " |> String.trim() |> String.downcase() |> String.split(", ")
<<version::4, header_len::4, rest::binary>> = packet # first byte of an IPv4 header
âď¸ Where the crocodile loses
Three fights it doesnât win. The crocodile would like you to know itâs not sulking about it:
- Raw compute. Immutability has a bill, and on a big, hot, mutable data structure it comes due asymptotically. Discord measured it (640 Âľs for their best pure-Elixir sorted set, 3.68 Âľs as a Rust NIF, and the gap grew with size), kept Elixir for the coordination layer, and rewrote one data structure in Rust [15]. Thatâs the right lesson: not âleave,â but âknow which loop is the hot one.â
- Hiring. You will not hire Elixir developers (2.6% usage in the 2026 Stack Overflow survey, against Pythonâs 58% [16]). You will convert them. And the documented way Elixir adoptions die is when the one who converted everyone else leaves.
- Types. Elixir 1.20 shipped in June as ânow a gradually typed language.â The compiler infers types for everything with zero annotations, and there is also zero way to write a type signature yet. Itâs a bug finder, not a contract system; the release notes say it reports only verified bugs and that signatures come later [17].

Fig. 4: Four languages, six questions. The rows where Elixirâs cell is lonely are the rows this article was about.
đď¸ The log that bites
The resemblance was never the point. Elixir looks like Ruby so that Rails refugees would read past the first line, and it reads like Python so that nobody flinches at a for. Underneath is a 1990s telephone-switch runtime that gives every process its own heap, interrupts it every 4,000 calls [18], and treats a crash as a message with a return address. The syntax is the bait. The process is the animal.
Try this Monday: take one function in whatever youâre writing now that starts with three if-guards and a return. Rewrite it in your head as three function heads with the condition in the signature. If the third head is the one that handles the case you forgot, thatâs your finding.
Not a telecom engineer, though a very interested one. This is Part 1 of two. Part 2, Four Hats for One Process, starts with what a process actually costs (with receipts) and then what Phoenix, LiveView, Oban and Commanded build out of it. Thereâs also a sibling, There Is No Return, on why Elixir has no return keyword, and a one-page cheat sheet for the day it comes up in an interview.
đ References
- Neto, A. âWhy did JosĂŠ Valim create Elixir?â (adapted translation of a 2011 interview). https://adolfont.medium.com/why-did-jos%C3%A9-valim-create-elixir-a6e210b7f2a1
- Armstrong, J. âA History of Erlang.â HOPL III, ACM, 2007. https://doi.org/10.1145/1238844.1238850
- Gustavsson, B. âA Brief History of the BEAM Compiler.â erlang.org blog. https://www.erlang.org/blog/beam-compiler-history/
- Erlang/OTP 29.1.2 patch notes (erts-17.1.1). https://www.erlang.org/patches/otp-29.1.2
elixir_erl.erl, âCompiler backend to Erlang.â elixir-lang/elixir. https://github.com/elixir-lang/elixir/blob/main/lib/elixir/src/elixir_erl.erl- erlang/otp commit 83d41bbe, âcompiler: Eliminate the Kernel Erlang intermediate representation,â 2023. https://github.com/erlang/otp/commit/83d41bbebc7281f72d91eb78c24831c631a92a88
- PEP 634, Structural Pattern Matching: Specification. https://peps.python.org/pep-0634/
- JEP 440, Record Patterns. OpenJDK, final in Java 21. https://openjdk.org/jeps/440
- Ruby documentation, âPattern matching.â https://docs.ruby-lang.org/en/master/syntax/pattern_matching_rdoc.html
- Armstrong, J. Making reliable distributed systems in the presence of software errors. PhD thesis, KTH, 2003. https://erlang.org/download/armstrong_thesis_2003.pdf
- JEP 444, Virtual Threads. OpenJDK, final in Java 21. https://openjdk.org/jeps/444
- Erlang System Limits. https://www.erlang.org/doc/system/system_limits.html
- âRuby 2.2.0 Released,â ruby-lang.org, 25 Dec 2014. https://www.ruby-lang.org/en/news/2014/12/25/ruby-2-2-0-released/
- Elixir, âMacro anti-patterns.â https://elixir.hexdocs.pm/macro-anti-patterns.html
- Discord Engineering, âUsing Rust to Scale Elixir for 11 Million Concurrent Users,â 2019. https://medium.com/discord-engineering/using-rust-to-scale-elixir-for-11-million-concurrent-users-c6f19fc029d3
- Stack Overflow Developer Survey 2026, Technology â Languages. https://survey.stackoverflow.co/2026/technology/data/languages
- âElixir v1.20 released: now a gradually typed language,â 3 June 2026. https://elixir-lang.org/blog/2026/06/03/elixir-v1-20-0-released/
CONTEXT_REDSinerl_vm.h, erlang/otp. https://github.com/erlang/otp/blob/master/erts/emulator/beam/erl_vm.h
Version 1.15 ¡ November 2026