curayious Language Lab

Elixir — The Same VM, Different Ergonomics

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.

Q1 · Origin and niche

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:

Q2 · Hello, build

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:

Q3 · Type and memory model

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.

Gradual typing, as of three months ago

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:

Q4 · The unit of abstraction

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:

Q5 · The error model

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:

Q6 · The concurrency model

Actors: isolated processes, asynchronous messages, no shared memory. The properties that distinguish the BEAM from every other runtime in this series:

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:

Q7 · The idiom test — logagg

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

Q8 · How it fails

Ranked by cost to a working system, not by tutorial frequency.

  1. Atoms are never garbage collected. The atom table has a fixed limit — 1,048,576 by default — and reaching it crashes the VM. 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.
  2. Unbounded mailboxes. Covered in Q6. A consumer slower than its producer has no back-pressure unless you build it; GenStage and Broadway exist for exactly this, and Task.async_stream's max_concurrency is the small version.
  3. Large messages copy. Passing a big term between processes copies it. The fix is usually to send a reference to an ETS table or a refcounted binary, and ETS reintroduces shared mutable state with none of the guarantees the process model was giving you.
  4. 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.
  5. Rebinding is not mutation. 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.
  6. Distribution is trust-by-cookie. Connected nodes share a cookie and then have full mutual access, including remote code execution. Erlang distribution on an untrusted network needs TLS distribution configured; the default is not a security boundary.

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

Q9 · Read real code

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:

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.

Q10 · What transfers


Status

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.