Read against Go, not against Ruby. The syntax borrowed from Ruby and the resemblance stops there. Both Elixir and Go answer Arc III's question — how does work get divided — and they answer it differently: Go shares memory by communicating over channels, Elixir has no shared memory to communicate about. Every process owns its heap. That one constraint is what makes supervision, hot upgrades, and "let it crash" coherent rather than reckless.
On ordering. Erlang is track 50 and is not written yet. Elixir came first here because installing it installs Erlang/OTP as well, so the Erlang track is now unblocked. Where this track says "the BEAM" or "OTP", the credit belongs to Erlang; Elixir contributes the surface language and the tooling.
Two origins, thirty years apart, and the distance between them is the point.
Erlang was built at Ericsson from 1986 by Joe Armstrong, Robert Virding and Mike Williams, for telephone switches: systems that must keep running while being upgraded, where a fault in one call must not affect another. It was open-sourced in 1998. Armstrong's 2003 thesis, Making reliable distributed systems in the presence of software errors, is the design document for everything in Q5 and Q6.
The famous number from that thesis deserves care, because most citations get it wrong. Armstrong reports the AXD301 switch achieving nine nines — 99.9999999% uptime — and then says in the same document that evidence for long-term operational stability "had also not been collected in any systematic way", the figure coming from a customer's slide deck by unclear means. He treats it as suggestive, not as measurement. Quoting it as a hard number is a misreading of the source.
Elixir was created by José Valim in 2011–12, with v1.0 in September 2014. His design goals post states three, and they explain why the language looks the way it does:
Elixir is not a dialect of Erlang and it is not a transpiler. It compiles to the same BEAM bytecode and shares the same process model, and it adds macros, protocols, a module system, Mix, Hex, ExUnit, and documentation as a first-class language construct.
Who pays for it today. Discord is the load-bearing case, and they have written it up: How Discord Scaled Elixir to 5,000,000 Concurrent Users describes a process per guild and per session; a later account puts it at 400–500 Elixir machines with a five-engineer team owning 20+ services, and the path past 5M went through Rustler to put a Rust data structure inside an Elixir process. Elsewhere: Supabase Realtime, Heroku's routing layer, Brex, Remote, PepsiCo, and the Nerves project for embedded. The pattern is many concurrent connections with independent failure domains — chat, presence, telemetry, IoT — rather than CPU-bound batch work.
The current release is Elixir 1.20.4; v1.20 shipped 3 June 2026 and is the subject of Q3. Elixir targets the last three Erlang major versions at time of release; this track runs 1.20.4 on Erlang/OTP 28.
Further reading:
This track has no build step:
elixir logagg.exs ../../bench/data/access.log
A .exs file is compiled to BEAM bytecode in memory on every run and discarded. That makes
Elixir the second interpreted track after Python, and for a different reason — the
compilation is real, complete, and type-checked; it just is not cached.
Real Elixir is a Mix project: mix new, a mix.exs manifest, dependencies from
Hex, mix test running ExUnit, and mix
release producing a self-contained release with the VM inside it. That is one command from
here, and it is deliberately not used: a mix.exs would add a lockfile, a _build directory,
and a dependency-resolution step to a program with no dependencies.
Installation is where the Windows story lives. The official route from elixir-lang.org/install is a script that fetches both runtimes into the user profile with no administrator rights:
curl.exe -fsSO https://elixir-lang.org/install.bat
.\install.bat elixir@1.20.4 otp@28.4
It pulls otp_win64_28.4.zip (170 MB) from github.com/erlang/otp and elixir-otp-28.zip
(8 MB) from github.com/elixir-lang/elixir, and unzips them under
%USERPROFILE%\.elixir-install\installs. On this machine it failed partway through the OTP
download; tools/install-elixir.ps1 does the same four
steps with a downloader that reports its errors. Details in NOTES.md.
What ships, and why Erlang's answer differs from every other track's:
logagg.exs
│
├─ elixir logagg.exs compile in memory, run, discard ← this track
│
└─ mix release
│
├─ your compiled .beam files
├─ every dependency's .beam files
└─ THE ERLANG VM ITSELF (erts-16.3)
│
└─► one directory you copy to the target.
No Erlang install required there.
Go ships a static binary; Java ships a jar and assumes a JVM; Python assumes an
interpreter. Elixir ships the runtime INSIDE the artifact -- which is what makes
hot code upgrade (Q6) a deployment story rather than a party trick.
Further reading:
mix release — what the artifact contains
and what it does not.A variable is a name bound to an immutable value, and rebinding shadows rather than
mutates. Elixir allows x = 1; x = 2, which looks like assignment and is not: the second
= is a fresh binding, and any closure that captured the first still sees 1. Erlang
forbids rebinding outright; Elixir permits it for readability and keeps the underlying
immutability.
Nothing in logagg.exs mutates. Compare directly with the track before it: OCaml made
mutability opt-in with a mutable keyword on four record fields. Elixir does not offer the
keyword.
step/2 is called 200 times. Each call RETURNS A NEW %Acc{}.
acc₀ ──► %{acc₀ | lines: 1} ──► %{acc₁ | ok: 1, services: …} ──► acc₃ ─► …
│ │ │
│ └── new struct, 5 words └── new struct + new map
│ 4 of them POINT AT │
│ acc₀'s fields └── the map shares every
│ untouched key with
└── still valid, still reachable, unchanged acc₁'s map
Copying is per-WORD, not per-structure: %{s | count: n} copies the struct's
handful of fields and shares everything they point to. Map.update/4 on a
6-key map rewrites one node of a hash-array-mapped trie. The garbage is
short-lived and dies in the process's own minor heap.
Memory is per-process. Each BEAM process gets its own heap and its own generational collector. A collection pauses that one process, not the VM — there is no global stop-the-world, which is the property Discord is buying. It also means a process that dies frees its entire heap at once with no collection at all, so short-lived processes are close to free.
Integers are arbitrary precision. SPEC 2 asks for a 64-bit accumulator; the BEAM promotes to a bignum at the word boundary rather than wrapping. Three tracks, three behaviours at the same boundary — C wraps (and signed overflow is undefined), OCaml has 63 bits and wraps, Elixir grows. Only one of the three is incapable of producing a wrong number quietly.
This is the part of Elixir that is currently changing, and most references describe the old
state. Elixir v1.20,
released 3 June 2026, type checks every Elixir program, with no type annotations
anywhere. The system is set-theoretic — types are unions, intersections and negations — and
the dynamic() type is a range that narrows as code executes rather than a hole that
discards information.
What the compiler now does without being asked: infers types across guards and pattern
matches, narrows them across case clauses, tracks tuple and map structure through size
checks and field access, and reports dead branches. It emits a violation only when the
supplied and accepted types are disjoint — Map.fetch!(x, :key) where x can only be an
integer or a binary at runtime — which the release calls a verified bug: a typing violation
guaranteed to fail if executed.
That is a narrower promise than OCaml's. OCaml's checker rejects programs that might be wrong; Elixir 1.20 flags only those it can prove are wrong. The trade is that it required no changes to any existing codebase.
Further reading:
dynamic(), inference, and what a verified bug is.The process, and then the module. Both matter and they answer different questions.
The module is the compile-time unit: named functions, defstruct, @moduledoc, and
pattern-matched function heads. logagg.exs uses three nested modules, and classify/1
shows the idiom that replaces a dispatch table:
defp classify([_ts, _svc, _meth, _path, status, latency]) do …
defp classify(_), do: {:error, :fields}
Two clauses, tried in order. The six-element list pattern is the arity check, exactly as in OCaml — the two languages inherited it from the same place.
The process is the runtime unit, and it is where Elixir stops resembling the other tracks.
A process is not a thread: it is a few hundred bytes, scheduled preemptively by the VM, with
no shared memory and a mailbox. State is held by a process looping over itself — recursion
carrying an accumulator, which is the same shape as Enum.reduce(%Acc{}, &step/2) in this
program, run forever instead of once.
OTP behaviours package the loop. GenServer is that recursion with a standard protocol
for calls, casts, timeouts and shutdown; Supervisor starts children and restarts them under
a declared strategy; Application is the unit of start-up and configuration. They are
ordinary modules implementing a callback contract, not language features.
The abstraction that has no analogue in any earlier track is the supervision tree. If
logagg were a service rather than a batch job:
LogAgg.Application
│
LogAgg.Supervisor strategy: :one_for_one
┌───────────┼───────────┐
▼ ▼ ▼
Tailer Registry ReportServer
(one per file) (holds %Acc{})
│
└── crashes on a malformed FILE (not a malformed line --
that is ordinary data, see Q5). The supervisor restarts
it with the declared initial state. The Registry and the
ReportServer never notice, because :one_for_one restarts
only the child that died.
What replaces the catch-all try/rescue in the other tracks: a declared policy
about what to restart, written once, in a different module from the code that
fails.
Further reading:
Three mechanisms, and the interesting one is the third.
Tagged tuples are the convention for expected failure: {:ok, value} and {:error,
reason}. classify/1 returns :ok or {:error, :status}. It is a convention, not a type —
nothing stops a caller from ignoring the {:error, _} branch, and until v1.20 nothing warned
either.
Exceptions (raise / rescue) are for the unexpected, and the naming enforces the split:
File.read/1 returns {:ok, binary} | {:error, posix}, while File.read!/1 raises. The !
suffix is a whole-ecosystem convention that the tagged-tuple version is available and the
bang version is the one that gives up.
Letting it crash is the third, and it is a claim about system design rather than a mechanism. A process that hits an unexpected state dies; its supervisor restarts it from a known-good initial state; the failure does not propagate into unrelated work because there is no shared memory for it to corrupt. Armstrong's argument is that defensive code for conditions you did not anticipate is code you cannot test, and that restarting from a state you did design is more reliable than continuing from one you did not.
Where SPEC 1's ordered reason lands:
classify/1
┌──────────┴──────────┐
▼ ▼
[_,_,_,_,status,latency] _ (anything else)
│ │
┌────────┴────────┐ ▼
▼ ▼ ▼ {:error, :fields}
{:error, {:error, :ok
:status} :latency}
A malformed LINE is data, and is handled: counted, reported, and the run
continues with RC 4. A malformed FILE is a fault, and raises File.Error.
That boundary -- expected bad input versus unexpected broken world -- is the
same judgement C, Java, Go, Python and OCaml all made independently. Six
tracks, six mechanisms, one design rule.
The cross-track row, for the same malformed line 31:
| Track | Returns | What stops you ignoring it |
|---|---|---|
| C | const char *, NULL = fine |
Nothing |
| Java 8 | String, null = fine |
Nothing |
| Python | str \| None |
A type checker, if run |
| Go | string, "" = fine |
Nothing |
| OCaml | reason option |
Warning 8 — non-exhaustive match |
| Elixir | :ok \| {:error, atom} |
Convention, plus v1.20's checker where the types are provably disjoint |
Elixir sits one rung below OCaml here and has been climbing since 1.17. The honest statement today is that the compiler catches the cases it can prove, and the convention carries the rest.
Further reading:
raise,
throw, exit, and when each is correct.Actors: isolated processes, asynchronous messages, no shared memory. The properties that distinguish the BEAM from every other runtime in this series:
CONTEXT_REDS was 2000 before OTP 20 and is 4000 now — the older number
is repeated widely and is stale.[smp:16:16].For --shard N (SPEC 6) the shape falls out of the model with nothing to guard:
File.stream!(path, :line)
│
Stream.chunk_every(chunk_size)
│
Task.async_stream(&aggregate_chunk/1, max_concurrency: N)
│ │ │
process process process each with its OWN heap,
%Acc{} %Acc{} %Acc{} its own GC, its own %Acc{}
│ │ │
└───────────┼───────────┘
▼
Enum.reduce(&merge/2) merge is associative,
│ so chunk order is free
▼
the same Enum.sort_by as the serial path
No lock, no mutex, no atomic. Not because the code is careful -- because
there is no shared mutable state for it to be careful ABOUT. The %Acc{}
structs are immutable values sent by copy between isolated heaps.
What the model costs, stated as mechanism rather than warning:
Further reading:
Task.async_stream/3 — the SPEC 6
primitive, with back-pressure built in.logagglogagg.exs · lab notes in NOTES.md
PASS elixir
~230 lines against C's ~330, OCaml's ~250, Go's ~250, Java's ~210, Python's ~190. The line-accounting, now across six tracks:
| C | Java 8 | Go | OCaml | Elixir | |
|---|---|---|---|---|---|
| Hash map insert-or-update | ~60 lines by hand | computeIfAbsent |
5 lines + nil check |
6 lines, find_opt |
Map.update/4 |
| Split on whitespace | ~15 lines | Pattern.split |
strings.Fields |
~12 lines | String.split/1 |
| Arity check | if (nfields != 6) |
if (f.length != 6) |
if len(f) != 6 |
the pattern | the function head |
| Sort by two keys | qsort + cmp |
Comparator chain |
sort.Slice |
List.sort + match |
sort_by {-count, name} |
| Accumulator | mutable struct | mutable fields | mutable struct | mutable fields |
a new struct per line |
Three decisions only this language forced:
The output is an iolist, not a string. render/2 returns a nested list of binaries and
never concatenates:
["logagg 1.0", "\n",
"source: ", "access.log", "\n",
…,
[ [ "api-gateway ", " 69", …], "\n" ], ← per row
[ [ "ledger ", " 45", …], "\n" ],
…]
│
└─► IO.binwrite walks the tree and hands the VM a vector of
pointers. The nesting is never flattened, so building this
report costs zero string copies.
Every other track in the series concatenates into a Buffer, StringBuilder,
strings.Builder or a list-and-join. Elixir's I/O layer accepts the tree.
IO.binwrite, not IO.write. Sixth track, sixth spelling of the Windows line-ending
fact — and the only one where the correct call is also the faster one, because binwrite
skips the Unicode encoding step entirely:
| Track | Fix |
|---|---|
| C | _setmode(_fileno(stdout), _O_BINARY) |
| Java 8 | System.out.write(s.getBytes(US_ASCII)) |
| Python | sys.stdout.buffer.write(s.encode("ascii")) |
| Go | nothing — no text mode exists |
| OCaml | set_binary_mode_out stdout true |
| Elixir | IO.binwrite(:stdio, iodata) |
\A and \z, not ^ and $. Elixir's ~r is PCRE, where ^ and $ match at line
boundaries. ~r/^[0-9]+$/ accepts "200\nanything". The anchors that mean start and end of
string are \A and \z. SPEC 1 says three digits and nothing else; the naive regex would
have passed the fixture and been wrong.
Ranked by cost to a working system, not by tutorial frequency.
String.to_atom/1 on anything derived from user
input is therefore a remote denial of service. The mitigation is
String.to_existing_atom/1,
which raises rather than growing the table. logagg.exs uses atoms only as literals
written in the source.Task.async_stream's max_concurrency is the small version.String versus charlist. "abc" is a UTF-8 binary; 'abc' is a list of code points,
and Erlang libraries return the latter. Passing one where the other is expected produces
a function-clause error at a distance. Elixir has been deprecating the single-quote form
for exactly this reason.x = 2 inside an if does not change the outer x —
blocks return values, and a closure captured earlier still sees the old binding. The
compiler warns about the unused result, which is the tell.The tooling layer — what catches these:
| Tool | What it does |
|---|---|
| The v1.20 compiler | Set-theoretic inference; reports verified bugs and dead code, no annotations |
| Dialyzer / Dialyxir | Success typing over BEAM files — the older analysis, still catches things the compiler does not |
| Credo | Lint and consistency, including String.to_atom |
| Sobelow | Security-focused static analysis for Phoenix |
mix format |
Canonical formatting, in the box, opt-in per project |
:observer |
Live process tree, mailbox lengths, memory — attachable to a production node |
:recon |
Safe production introspection; the tool for the mailbox failure in row 2 |
Read lib/elixir/lib/gen_server.ex
— the GenServer module in Elixir's own source. It is the right file for this series because
the abstraction it wraps is one you have already written by hand: Enum.reduce(%Acc{},
&step/2) in logagg.exs is a fold over state, and a GenServer is that fold running forever
with a message protocol attached.
What to look for:
@moduledoc is a long-form essay on when a process is the right answer, shipped as
part of the module. Documentation as a language construct is one of Elixir's genuine
additions over Erlang, and this is the showpiece.use GenServer is a macro that injects default callbacks — read what it generates, and the
Q1 claim about a macro-extensible core stops being abstract.handle_call / handle_cast / handle_info split is the whole synchronous versus
asynchronous versus out-of-band distinction, in three callback names.:gen_server. The Elixir module is a typed, documented, macro-
assisted front end over OTP code from the 1990s, which is the compatibility goal from Q1
made concrete.Shorter alternative:
lib/elixir/lib/enum.ex
— large, but any single function is a tidy example of multi-clause dispatch and the
Enumerable protocol. Enum.reduce/3 in particular is the function this program is built on.
--shard N
design needed no synchronisation because nothing was shared, and the reason it needed none
is that every message was copied. Take the trade — not the slogan — to Go's channels, to
Rust's Send/Sync, and to any service boundary you are tempted to make a shared
in-process cache.mix release ships the VM. It makes the
deployment question — which interpreter, which JVM, which libc — disappear, and it is why
the same system can be upgraded while running.done — PASS elixir, byte-exact against the golden file on the first run, on Elixir 1.20.4
/ Erlang-OTP 28 installed without administrator rights via
tools/install-elixir.ps1.
Outstanding: --shard N (SPEC 6) with Task.async_stream, which for this track is the
content rather than an extra. Installing Elixir also installed Erlang/OTP, so
track 50 is now unblocked and should be written next — it is the one
place the supervision material properly belongs.