← History

The Virding Thread: Erlang, LFE, and Luerl

One designer, three BEAM languages: how Robert Virding went from co-creating Erlang to building a Lisp on the VM (LFE) and then smuggling Lua into it (Luerl) - and what each move reveals about the runtime underneath.

ErlangLFELuerl

Most people are lucky to leave a mark on one programming language. Robert Virding has his fingerprints on three - and all three live on the same virtual machine. In 1986 he was one of the trio at the Ericsson Computer Science Laboratory who created Erlang. Two decades later he built LFE (Lisp Flavoured Erlang), a homoiconic Lisp that compiles down to run alongside Erlang. A few years after that he wrote Luerl, a complete Lua interpreter implemented in pure Erlang.

Read in order, those three projects are a single, coherent argument about what the BEAM is for. Erlang is the language the runtime was built to serve. LFE asks, "if the VM is this good, can I bring my own favourite language to it?" Luerl pushes the same idea one step further: "can I bring someone else's language to it, unchanged, and run it safely?" This article follows that thread.

The starting point: Erlang and the runtime Virding helped design

Erlang grew out of work begun in 1986 at Ericsson, where Joe Armstrong, Robert Virding, and Mike Williams set out to program fault-tolerant telephone exchanges. The problem domain demanded soft real-time behaviour, massive concurrency, distribution, and the ability to run for years without stopping. The answer was a language whose fundamental unit is the process: a cheap, isolated, share-nothing thread of execution that communicates only by asynchronous message passing.

Virding's contributions ran deep - he worked on much of the early standard library and the compiler - so he didn't just use the runtime, he helped shape its internals. That insider knowledge is exactly what made his later languages possible. Here is the bedrock idiom of the platform, the thing every later BEAM language is really trying to get access to:

%% erlang - spawn an isolated process, send it a message, receive a reply
-module(echo).
-export([start/0, loop/0]).

start() ->
    Pid = spawn(?MODULE, loop, []),
    Pid ! {self(), "ping"},
    receive
        {Pid, Reply} -> io:format("got: ~s~n", [Reply])
    end.

loop() ->
    receive
        {From, Msg} -> From ! {self(), Msg}, loop()
    end.

spawn, ! (send), and selective receive are the whole concurrency model. Layered on top of them is OTP - the behaviours (gen_server, supervisor, application), supervision trees, and the "let it crash" philosophy that turns process failure into a recoverable, supervised event rather than a catastrophe. None of this is library glue bolted onto a generic VM; it is the BEAM's reason to exist. Keep that in mind, because the punchline of LFE and Luerl is that they inherit all of it without re-implementing a single line.

The second thread: LFE, a Lisp that is Erlang underneath

Virding is a long-time Lisp programmer, and LFE is what happens when someone who built the BEAM decides he wants S-expressions on it. He announced the first public release on the erlang-questions mailing list in March 2008, built against Erlang/OTP R12B-0. That first cut was deliberately minimal - it couldn't yet handle recursive letrec, binaries, receive, or try, and had no Lisp shell - but the design goal was already clear: a Lisp designed for the BEAM rather than a Lisp bolted on top of it. After roughly eight years of work it reached its milestone 1.0 release in March 2016.

The crucial implementation decision is that LFE compiles to Core Erlang, the small, explicitly functional intermediate language the Erlang compiler already uses internally. Because LFE rejoins the standard compilation pipeline at that point, it emits 100% Erlang-compatible BEAM modules. There is no FFI, no marshalling, no penalty: an LFE module calling lists:foldl/3 is just one BEAM module calling another.

Here is the echo process from above, expressed in LFE. Same spawn, same !, same receive - the parentheses are the only thing that changed:

;; lfe - the same process model, in S-expressions
(defmodule echo
  (export (start 0) (loop 0)))

(defun start ()
  (let ((pid (spawn 'echo 'loop '())))
    (! pid `#(,(self) "ping"))
    (receive
      (`#(,from ,reply) (io:format "got: ~s~n" (list reply))))))

(defun loop ()
  (receive
    (`#(,from ,msg) (! from `#(,(self) ,msg)) (loop))))

Two things in that snippet are worth pausing on. First, #(...) is LFE's literal tuple syntax, and the backquote/comma combination (`#(,from ,reply)) builds and pattern-matches Erlang tuples directly - so {From, Reply} in Erlang becomes `#(,from ,reply) in LFE, mapping to the same runtime term. Second, defun supports multi-clause pattern matching exactly the way Erlang functions do:

;; lfe - multi-clause pattern matching, just like Erlang function heads
(defun ackermann
  ((0 n) (+ n 1))
  ((m 0) (ackermann (- m 1) 1))
  ((m n) (ackermann (- m 1) (ackermann m (- n 1)))))

But LFE is not just Erlang-with-parens. It is a genuine Lisp-2 (separate namespaces for functions and values, in the Common Lisp tradition) and it is homoiconic: code is the same S-expression data the language manipulates. That unlocks the one thing classic Erlang never had - real, compile-time macros:

;; lfe - a macro, expanded at compile time, that Erlang simply cannot express
(defmacro unless
  ((cons test body) `(if (not ,test) (progn ,@body))))

;; (unless (full? queue) (enqueue queue item))
;; expands to:
;; (if (not (full? queue)) (progn (enqueue queue item)))

, (unquote) splices a value into the template and ,@ (unquote-splicing) splices a list. Because the macro runs at compile time and produces ordinary code, unless costs nothing at runtime - it is an if. This is the same metaprogramming superpower that Elixir later made famous with its own defmacro, but LFE got there first on the BEAM, and got it in the full Lisp tradition. Worth noting for context: the BEAM family spans the whole spectrum here - Gleam is the statically typed outlier that deliberately omits macros in favour of a sound type checker, while LFE sits at the dynamic, maximally-metaprogrammable end.

The takeaway: LFE proves that the BEAM's reliability machinery - lightweight processes, supervision trees, hot code loading, let-it-crash - is language-agnostic. You can wrap it in any syntax you like, including a Lisp, and lose nothing.

The third thread: Luerl, bringing Lua into the BEAM

If LFE asks "can I put my favourite syntax on the VM?", Luerl asks a harder question: "can I run an existing, unmodified language on the VM - one with mutable state and totally different semantics - without compiling it to BEAM bytecode at all?"

Virding announced Luerl on the erlang-questions list on 18 February 2012, describing it as "a Lua interpreter written in Erlang which started off as a proof of concept and an experiment with handling mutable data, but it grew." And grow it did: today it is an actively maintained library tracking Lua 5.3, reaching the 1.x series (stable releases such as v1.5.1 in late 2025), distributed under the Apache License 2.0.

The architecture is the deliberate opposite of LFE's. Luerl does not compile Lua to BEAM bytecode. It is a complete Lua implementation - parser, compiler, and evaluator - written in clean Erlang/OTP. The Lua you write runs on top of the BEAM, interpreted by Erlang, rather than being lowered into it. Lua source code from anywhere on the internet runs unchanged:

-- lua (executed by Luerl) - ordinary, unmodified Lua 5.3
local function fib(n)
  if n < 2 then return n end
  return fib(n - 1) + fib(n - 2)
end

local total = 0
for i = 1, 10 do
  total = total + fib(i)
end
return total

The genuinely clever part is how Luerl reconciles Lua's mutable global state with Erlang's immutability. The entire Lua state - globals, tables, the lot - lives in a single Erlang data structure that is explicitly threaded through every operation. Nothing mutates in place; each step takes the old state and returns a new one. That is why almost every Luerl function both takes a state and hands back a new state. Here is the host-side Erlang driving the interpreter, using the v1.x API:

%% erlang - the host program embeds and drives Luerl
%% init/0 creates a fresh Lua VM; do/2 returns {ok, Results, NewState}
State0 = luerl:init(),
{ok, Result, State1} = luerl:do(<<"return 1 + 2, 'hi'">>, State0),
%% Result =:= [3, <<"hi">>]

%% Read and write Lua variables from Erlang by table path:
{ok, State2} = luerl:set_table_keys([<<"x">>], 42, State1),
{ok, Value, State3} = luerl:get_table_keys([<<"x">>], State2).
%% Value =:= 42

Notice the state-threading discipline: State0, State1, State2, State3. Each call consumes the previous state and produces the next - the Lua "mutation" is really a functional fold over an immutable structure. (luerl:do_dec/2 is the same as do/2 but decodes Lua values into plain Erlang terms for you, and luerl:encode/2 / luerl:decode/2 convert in each direction.)

Because the host fully controls that state, exposing Erlang functions to Lua is natural. A Lua-callable function is just an Erlang function with the shape fun(Args, State) -> {Results, State}, where both Args and Results are lists of Luerl-compatible terms:

%% erlang - expose an Erlang function to Lua code
%% Args and Results are lists; State is threaded through, as everywhere in Luerl.
Adder = fun([A, B], St) -> {[A + B], St} end,
{ok, St1} = luerl:set_table_keys_dec([<<"add">>], Adder, luerl:init()),
{ok, [Sum], _St2} = luerl:do(<<"return add(20, 22)">>, St1).
%% Sum =:= 42

And because Elixir is just another BEAM language, it drives Luerl through the :luerl atom-named module with zero ceremony:

# elixir - same Luerl library, called from Elixir
state = :luerl.init()
{:ok, result, _state} = :luerl.do("return 6 * 7", state)
# result == [42]

Two consequences fall straight out of the design. First, sandboxing: because there are no NIFs, no ports, and no C bindings, untrusted Lua never escapes the safety envelope of the Erlang VM interpreting it - it can only touch the state and functions the host chose to expose. That makes Luerl a natural fit for user-supplied scripts, game logic, and runtime configuration. Second, concurrency for free: a Lua state is an ordinary Erlang term, so you can hold one per BEAM process and run thousands of independent Lua interpreters concurrently, each preemptively scheduled and garbage-collected on its own, with no shared mutable state to corrupt.

The thread, pulled tight

Lay the three projects side by side and the throughline is unmistakable. Each is a different answer to the question "what is the relationship between a language and the BEAM?"

Compile into it; run on top of it; or be it. The same person explored all three relationships, and the fact that one designer could is itself the point: the BEAM's hard-won guarantees - cheap concurrency, fault isolation, predictable latency, hot upgrades - are general enough to host a telecom language, a Lisp, and a re-imagined Lua, all at once, all interoperating. Building a runtime that good is enormously hard. Robert Virding helped build it once, then spent years demonstrating just how much you can do with it.