From Erlang to Elixir: the lineage
How Elixir inherits Erlang/OTP wholesale, then layers macros, the pipe, and modern tooling on top - and why the two interoperate with zero overhead.
Elixir is not a fork of Erlang, a transpiler to Erlang, or a syntax skin over it. It is a separate language with its own compiler that happens to target the same virtual machine: the BEAM. That single design choice - "compile to BEAM bytecode, reuse Erlang/OTP as-is" - is what makes Elixir simultaneously a brand-new language and a direct descendant of three decades of Ericsson's reliability engineering.
When José Valim started Elixir in 2011, he admired the BEAM's concurrency, distribution, and fault tolerance but wanted friendlier syntax, real metaprogramming, and better tooling. Crucially, he chose to keep the runtime rather than reinvent it. Everything below follows from that decision.
What Elixir inherits unchanged
Both languages compile to bytecode for the BEAM (Bogdan/Björn's Erlang Abstract Machine). Once compiled, an Elixir module and an Erlang module are indistinguishable to the VM - both are .beam files full of the same bytecode, loaded into the same kind of module, and scheduled by the same preemptive scheduler. That means Elixir gets, for free and with no reimplementation:
- Lightweight processes - millions of isolated, share-nothing units of execution that communicate only by asynchronous message passing.
- Preemptive, per-process scheduling and garbage collection - one process can never starve or stall another.
- Built-in distribution - processes on different nodes talk transparently across a cluster.
- Hot code loading - modules can be upgraded in a running system.
- OTP - the behaviours (
gen_server,supervisor,application), supervision trees, and the "let it crash" philosophy.
The data model is shared too. Elixir's atoms, tuples, lists, integers, floats, binaries, PIDs, and references are Erlang terms - the same in-memory representation, not a wrapper. An Elixir string is an Erlang UTF-8 binary; an Elixir :ok atom is the Erlang ok atom. This is why interop is so cheap: there is nothing to marshal.
Here is the same idea - a tiny stateful server - expressed twice. First, idiomatic Erlang using the gen_server behaviour:
-module(counter).
-behaviour(gen_server).
-export([start_link/0, increment/0, value/0]).
-export([init/1, handle_call/3, handle_cast/2]).
start_link() -> gen_server:start_link({local, ?MODULE}, ?MODULE, 0, []).
increment() -> gen_server:cast(?MODULE, increment).
value() -> gen_server:call(?MODULE, value).
init(N) -> {ok, N}.
handle_cast(increment, N) -> {noreply, N + 1}.
handle_call(value, _From, N) -> {reply, N, N}.
Now the same component in Elixir. Note that use GenServer and __MODULE__ differ syntactically, but the callbacks return the exact same tagged tuples ({:ok, n}, {:noreply, n}, {:reply, n, n}) because they are talking to the very same OTP gen_server implementation written in Erlang:
defmodule Counter do
use GenServer
# Client API
def start_link(n \\ 0), do: GenServer.start_link(__MODULE__, n, name: __MODULE__)
def increment, do: GenServer.cast(__MODULE__, :increment)
def value, do: GenServer.call(__MODULE__, :value)
# Server callbacks
@impl true
def init(n), do: {:ok, n}
@impl true
def handle_cast(:increment, n), do: {:noreply, n + 1}
@impl true
def handle_call(:value, _from, n), do: {:reply, n, n}
end
GenServer in Elixir is a thin, ergonomic layer (defaults, @impl checks, a friendlier use) over Erlang's gen_server. The supervision tree, restart strategies, and crash semantics are byte-for-byte identical because they are the same code.
How they interoperate
Because Erlang modules are just BEAM modules, Elixir calls them directly. An Erlang module named crypto is reachable from Elixir as the atom-named module :crypto, and you call its functions with normal dot syntax:
# Calling Erlang's standard library straight from Elixir - no bindings, no FFI.
:crypto.hash(:sha256, "elixir") |> Base.encode16()
:rand.uniform(100)
:lists.reverse([1, 2, 3]) # => [3, 2, 1]
:timer.sleep(50)
:queue.in(:a, :queue.new()) # :queue.in/2 takes the item first, then the queue
There is no foreign-function interface, no glue layer, and no performance penalty - it is a plain BEAM call. Elixir's own standard library leans on this constantly: :erlang.term_to_binary/1, :ets, and :gen_tcp are everyday tools in Elixir code.
The reverse works too. An Elixir module MyApp.Math is, at the VM level, the atom 'Elixir.MyApp.Math', so Erlang code can call it:
%% Calling Elixir's Counter module from Erlang.
'Elixir.Counter':start_link(0),
'Elixir.Counter':increment(),
Value = 'Elixir.Counter':value().
A couple of semantic seams are worth knowing. Erlang strings are lists of integers ("charlists"), written "abc" in Erlang but ~c"abc" (or 'abc' in old code) in Elixir; Elixir strings are binaries. And Elixir adds protocols, structs, and a different default nil/false truthiness, which are Elixir-level concepts the Erlang side does not see. But the underlying terms cross the boundary untouched, which is why mixed Erlang/Elixir codebases - and depending on Erlang libraries like cowboy, ranch, or mnesia - are routine.
What Elixir adds: the pipe operator
Erlang composes functions by nesting them, which reads inside-out. Elixir's |> operator takes the value on its left and threads it as the first argument of the call on its right, turning a nest into a top-to-bottom pipeline.
%% Erlang: read this from the inside out.
lists:sum(
lists:map(fun(N) -> N * N end,
lists:filter(fun(N) -> N rem 2 =:= 0 end,
lists:seq(1, 10)))).
# Elixir: the same data flow, read top to bottom.
1..10
|> Enum.filter(fn n -> rem(n, 2) == 0 end)
|> Enum.map(&(&1 * &1))
|> Enum.sum()
# => 220
This is more than cosmetics. Elixir's standard library is deliberately designed so that the "subject" of a function is its first argument (Enum.map(list, fun), String.split(str, sep)), precisely so pipelines compose naturally. The &(&1 * &1) shorthand is the capture operator - another small Elixir ergonomic touch absent from Erlang.
What Elixir adds: macros and homoiconicity
This is Elixir's biggest leap beyond Erlang. Like Lisp, Elixir is homoiconic: code is represented as data you can inspect and generate. Any expression's abstract syntax tree (AST) is a {operation, metadata, arguments} tuple, which you can capture with quote:
iex> quote do: 1 + 2
{:+, [context: Elixir, ...], [1, 2]}
A macro receives unevaluated AST at compile time and returns new AST to splice in at the call site. Vast swaths of Elixir itself - if, unless, def, defmodule - are macros, not keywords. This is how frameworks build DSLs that feel native:
defmodule MyMacros do
# Runs `block` only when `condition` is false. A macro, not a function,
# because it must NOT evaluate `block` unless the branch is taken.
defmacro unless(condition, do: block) do
quote do
if !unquote(condition), do: unquote(block)
end
end
end
Erlang does have compile-time facilities - the preprocessor, -define macros, and parse transforms - but they are far more limited and far less ergonomic than Elixir's hygienic, AST-level macros. Elixir's macro system is what powers Phoenix's routing DSL, Ecto's query syntax, and ExUnit's assert, all of which read like dedicated languages while compiling down to ordinary BEAM code.
Worth noting: this metaprogramming heritage runs deep on the BEAM. LFE (Lisp Flavoured Erlang) is a true Lisp on the same VM with defmacro and full homoiconicity, and Gleam takes the opposite path - a statically typed, ML-flavoured BEAM language that deliberately omits macros in favor of a sound type checker. Elixir sits in between: dynamic like Erlang and LFE, but with Lisp-grade macros.
What Elixir adds: a cohesive toolchain
Erlang's tooling grew organically (rebar3, erl, EUnit/Common Test) and is capable but fragmented. Elixir shipped one official, batteries-included tool, Mix, plus the Hex package manager, IEx shell, ExUnit test framework, and HexDocs. Project creation, dependency resolution, compilation, tasks, and tests live under a single, consistent interface:
# mix new my_app # scaffold a project (lib/, test/, mix.exs)
# mix deps.get # fetch dependencies from Hex
# mix compile # compile
# mix test # run the ExUnit suite
# iex -S mix # interactive shell with the project loaded
Dependencies - whether Elixir or Erlang packages - are declared together in mix.exs, and Mix happily compiles Erlang sources alongside Elixir ones:
defp deps do
[
{:phoenix, "~> 1.8"}, # Elixir
{:jason, "~> 1.4"}, # Elixir
{:cowboy, "~> 2.10"} # Erlang - same dependency mechanism
]
end
ExUnit tests are concise and ship with every project:
defmodule CounterTest do
use ExUnit.Case
test "increment bumps the value" do
start_supervised!({Counter, 0})
Counter.increment()
assert Counter.value() == 1
end
end
What Elixir adds: Phoenix and a thriving ecosystem
Elixir's flagship is Phoenix (current series 1.8, released August 2025), a high-throughput web framework built directly on OTP: every connection is a supervised BEAM process, so the same fault tolerance that keeps telecom switches alive keeps web requests isolated. LiveView extends this to rich, real-time UIs whose state lives in a server-side process and is diffed to the browser over a WebSocket, often with little or no custom JavaScript.
defmodule MyAppWeb.CounterLive do
use MyAppWeb, :live_view
def mount(_params, _session, socket), do: {:ok, assign(socket, count: 0)}
def handle_event("inc", _params, socket),
do: {:noreply, update(socket, :count, &(&1 + 1))}
def render(assigns) do
~H"""
<button phx-click="inc">Count: {@count}</button>
"""
end
end
Beyond the web, the Nx (Numerical Elixir) stack brought tensors, automatic differentiation, and machine learning to the platform, extending Elixir well past its web-services reputation. None of this required leaving the BEAM - it all builds on the same processes, supervisors, and message passing inherited from Erlang.
What Elixir is adding now: gradual set-theoretic types
Erlang and Elixir are both dynamically and strongly typed, with Erlang's optional Dialyzer/success-typing offering a separate, best-effort static analysis. Elixir is going further with a gradual, set-theoretic type system built into the compiler itself. The theory was published in 2023 (Castagna, Duboc, and Valim), and the checker has shipped incrementally: v1.17 (June 2024) introduced set-theoretic data types, and v1.18 (December 2024) added type checking of function calls plus inference of patterns and return types - all without requiring annotations, inferring types from existing code. The system is designed to be sound and gradual (it includes a dynamic() type), so it catches real bugs - impossible pattern matches, invalid tuple indexing, dead branches - while preserving Elixir's dynamic feel. This is the one place Elixir is meaningfully ahead of Erlang's built-in story, and it remains a moving target as later releases add user-written signatures.
The lineage in one sentence
Elixir kept everything that made Erlang legendary - the BEAM, OTP, processes, supervision, distribution, hot upgrades - and added the things Erlang lacked: a pipe, Lisp-grade macros, an integrated toolchain, a flagship web framework, and an emerging in-compiler type system. Because both languages share the same runtime and the same terms, they are not rivals but neighbors: you can mix them file-by-file, call across the boundary for free, and pick whichever syntax fits the job.