← History

Luerl: Lua on the BEAM

Not a new language on the VM, but an old one brought into it: a complete Lua 5.x interpreter written in pure Erlang, designed from the start to embed and sandbox untrusted scripts inside Erlang and Elixir apps.

LuerlErlang

Four of the five languages in this family - Erlang, Elixir, Gleam, and LFE - share a strategy: take your source code and lower it onto the BEAM, so the virtual machine ends up executing your logic as ordinary bytecode. Luerl is the odd one out. It doesn't compile Lua to BEAM bytecode at all. Instead, Luerl is a complete Lua interpreter, written in pure Erlang, that runs Lua programs on top of the runtime as a library.

That distinction sounds academic until you see what it buys you: a scripting language you can hand to your users - even untrusted ones - that runs entirely inside the safety envelope of the BEAM, with no C extension, no separate process, no NIF, and no way for a runaway script to take down your node. This article is about how Luerl works, why "interpreter as a library" is such a powerful shape, and where it fits relative to the rest of the BEAM family.

What Luerl actually is

Luerl is an implementation of standard Lua 5.x (currently tracking Lua 5.3, with the migration from 5.2 noted as in progress, plus a handful of 5.1 compatibility shims like loadstring and unpack). It was created by Robert Virding - one of the three co-inventors of Erlang - and is released under the Apache 2.0 license. The same person who, with LFE, put a Lisp on the BEAM, here did the inverse: brought an existing, wildly popular embedding language into the BEAM.

Crucially, Luerl is not a binding to the reference C implementation (PUC-Rio Lua). There is no liblua, no port driver, no native code. The lexer, the parser, the compiler-to-internal-form, the virtual machine, and the standard library are all Erlang modules. When your Lua script calls string.format or iterates a table, it is Erlang code doing the work.

%% erlang - the canonical Luerl "hello world" (from the project's examples)
%% Each call takes a Lua State and returns a (new) Lua State.
run() ->
    %% Execute a string of Lua against a fresh state.
    luerl:do("print(\"Hello, Robert(o)!\")", luerl:init()),

    %% Execute a file.
    luerl:dofile("./hello.lua", luerl:init()),

    %% Or separately: parse to a chunk, then call it.
    State0 = luerl:init(),
    {ok, Chunk, State1} = luerl:load("print(\"Hello, Chunk!\")", State0),
    {ok, _Ret, _NewState} = luerl:call(Chunk, [], State1),
    done.

The Lua you're running is real Lua:

-- lua - ordinary Lua 5.3, evaluated by an Erlang interpreter
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   -- handed back to the host as an Erlang term

The big idea: the Lua state is just a data structure

This is the design decision everything else flows from. In C Lua, the interpreter state (lua_State *) is an opaque, mutable handle you poke at through a stack-based API. In Luerl, the entire Lua VM state is an ordinary, immutable Erlang term that you thread through your program.

Every Luerl call follows the same shape: it takes a state and returns a new state.

%% erlang - state in, state out. Nothing is mutated in place.
State0 = luerl:init(),                              %% a fresh Lua environment
{ok, _R1, State1} = luerl:do("x = 1 + 1", State0),  %% State1 has x = 2
{ok,  R2, State2} = luerl:do("return x * 10", State1).
%% R2 == [20]  (results come back as a list - Lua can return many values)
%% State0 is still pristine; State1 still has x = 2; nothing was clobbered.

Because the state is a value, not a handle, you get capabilities that are awkward or impossible with C Lua:

Reading and writing across the Erlang ⇄ Lua boundary

Embedding is only useful if the host can push data in and pull results out. Luerl addresses Lua's global tables by key path - a list of keys drilling into nested tables - and offers both raw and "decoded" variants of each accessor.

%% erlang - table access by key path (adapted from the project's examples)
LuaScript = <<"hello = { greeting = \"world\" }; return hello">>,
{ok, [_Table], Lua0} = luerl:do(LuaScript, luerl:init()),

%% Read hello.greeting - the *_dec form returns a decoded Erlang term.
{ok, Greeting, Lua1} = luerl:get_table_keys_dec([hello, greeting], Lua0),
%% Greeting = <<"world">>

%% Write hello.greeting from Erlang.
{ok, Lua2} = luerl:set_table_keys_dec([hello, greeting], there, Lua1),

%% The non-decoded forms speak Luerl's internal encoding directly.
{ok, Lua3} = luerl:set_table_keys([<<"hello">>, <<"goodbye">>],
                                  <<"bye">>, Lua2),
{ok, Bye, _Lua4} = luerl:get_table_keys([<<"hello">>, <<"goodbye">>], Lua3).

The encode/decode split matters. Internally, Luerl represents Lua values in its own form (numbers, binaries for strings, table references, etc.), and luerl:encode/2 / luerl:decode/2 translate between that representation and plain Erlang terms. The _dec helpers just bundle the decode step in for you. This is the boundary where you decide how much marshalling you want to pay for.

Calling Erlang from Lua

The reverse direction is where embedding becomes a real extension mechanism. You can expose Erlang functions into the Lua environment so scripts call your application's capabilities - and only the capabilities you choose to grant.

%% erlang - expose an Erlang fun as a Lua global (from the examples)
%% The function takes (Args, State) and returns {Results, State}.
regular_function(Args, St) ->
    io:format("called from Lua with ~p~n", [Args]),
    {[42], St}.

run() ->
    Lua = luerl:init(),
    {ok, Lua1} = luerl:set_table_keys_dec([<<"hello_funcall">>],
                                          fun regular_function/2, Lua),
    %% Now Lua can call it:
    {ok, Res, _} = luerl:do(<<"return hello_funcall(4, 5, 6)">>, Lua1).
    %% Res = [42]

This is the heart of the "create a customised language sharing a syntactical framework" pitch: a Lua script sees a small, curated vocabulary of host functions, and the syntax stays familiar Lua. Game designers script behaviours; rule authors write business logic; operators tweak config - all without touching (or being able to reach) the rest of your system.

Sandboxing: the reason most people reach for Luerl

Here's the problem Luerl is unusually good at. Suppose you want to let users supply code - formulas, rules, plugins, test scripts, AI-agent tool logic. Running arbitrary Erlang or Elixir is a non-starter: the BEAM's own power works against you. Hot code loading means evaluated code could replace running modules; there's no built-in way to stop an infinite loop, cap memory, or revoke access to :os.cmd/1 and the filesystem. Code.eval_string/1 on untrusted input is a remote-code-execution hole.

Luerl flips this. Because Luerl is the interpreter and you control its state, untrusted Lua can only do what the state exposes. The luerl_sandbox module makes this turnkey: it strips dangerous globals (filesystem, OS, raw I/O, dynamic loading) and - critically - enforces a reduction limit, using the BEAM's own preemptive scheduler accounting to kill scripts that run too long.

%% erlang - luerl_sandbox in action (condensed from the project's examples)

%% A default sandboxed state has the dangerous libraries removed.
SbSt = luerl_sandbox:init(),

%% os.getenv is gone - this raises a Lua error rather than leaking the host env.
{lua_error, _Reason, _} =
    luerl_sandbox:run("return os.getenv(\"HOME\")", [], SbSt),

%% CPU protection: cap reductions so a runaway loop can't pin a core.
Flags = #{max_reductions => 100},
{error, {reductions, R}} =
    luerl_sandbox:run("a = {}; for i = 1, 1000000 do a[i] = 5 end",
                      Flags, SbSt),
%% R > 100 - the process was terminated once it blew the budget.

%% Even unbounded-looking loops are safe under a reduction cap.
{error, {reductions, _}} =
    luerl_sandbox:run("x = 'a'; while true do x = x .. x end",
                      #{max_reductions => 100,
                        spawn_opts     => [{priority, low}],
                        max_time       => 1000},
                      luerl:init()),

%% You can also remove specific globals from an otherwise-normal state:
%%   luerl_sandbox:init([['_G', type]])  -- now even type() is gone.

The runner executes the script in a separate, low-priority BEAM process it can monitor and kill, and combines three controls: max_reductions (CPU budget, measured in the same reductions the scheduler counts), max_time (a wall-clock timeout), and the curated global table (capability restriction). That trio - capabilities, CPU, and time - is exactly the threat model untrusted-code execution needs, and Luerl gets it almost for free from the runtime it lives on.

From Elixir: the Lua library

You don't have to write Erlang to use Luerl. The Elixir ecosystem wraps it with the Lua library (from TV Labs), which gives you idiomatic Elixir ergonomics, compile-time syntax checking via a ~LUA sigil, and a clean macro for exposing functions.

# elixir - running Lua with the `Lua` library
{[5], _state} = Lua.eval!("return 2 + 3")

# The ~LUA sigil validates syntax at compile time; the `c` modifier precompiles.
import Lua, only: [sigil_LUA: 2]
{[42], _state} = Lua.eval!(~LUA[return 6 * 7]c)

# Set variables (including nested paths) from Elixir into the Lua state.
lua =
  Lua.new()
  |> Lua.set!([:config, :database, :port], 5432)

{[5432], _state} = Lua.eval!(lua, "return config.database.port")

Exposing Elixir to Lua uses the deflua macro, optionally scoped into a Lua table:

# elixir - define a curated API and hand it to scripts
defmodule MathAPI do
  use Lua.API, scope: "math2"

  deflua add(a, b), do: a + b
  deflua multiply(a, b), do: a * b
end

lua = Lua.new() |> Lua.load_api(MathAPI)
{[20], _state} = Lua.eval!(lua, ~LUA[return math2.add(4, math2.multiply(8, 2))])

And for sandboxing, host secrets can be tucked into a private context that scripts can never read directly - only the Elixir-side API functions can:

# elixir - private context: data the Lua script can't reach, but your API can
lua =
  Lua.new()
  |> Lua.put_private(:current_user, user)
  |> Lua.load_api(UserAPI)

defmodule UserAPI do
  use Lua.API, scope: "user"

  deflua name(), state do
    user = Lua.get_private!(state, :current_user)
    {[user.name], state}
  end
end

This is the same state-threading, capability-gated model as the raw Erlang API, dressed in Elixir. (There's also a separate sandbox Hex package that wraps luerl_sandbox more directly, with Sandbox.init/0, Sandbox.unsafe_init/0 for full-stdlib mode, and a reduction limit argument to eval/3 and run/3.)

Where Luerl fits - and how it differs from the rest

It's worth being precise about how unlike the other four BEAM languages Luerl is.

It is embedded, not compiled. Erlang, Elixir, Gleam, and LFE are all application languages - you write your whole program in them and ship the compiled .beam modules. Luerl is an embedding - you write your application in (typically) Erlang or Elixir, and use Luerl to run a guest language inside it. Lua is for the scripts; the BEAM language is for the engine.

It's an interpreter, so it pays an interpreter's tax. Because Lua runs through a tree/VM in Erlang rather than as native BEAM bytecode, Luerl is slower than equivalent compiled BEAM code - and far slower than C Lua. That's a deliberate trade: you give up raw speed and get safety, embeddability, and the BEAM's concurrency model in return. For scripting workloads - config, rules, formulas, per-tenant plugins - that trade is usually the right one.

Static typing doesn't enter into it. Gleam is the statically typed member of this family: it type-checks your program up front and then lowers it to Erlang. Luerl is the opposite end of the spectrum - it runs Lua, which is dynamically typed, and the host/guest boundary is checked at runtime via encode/decode. There is no compile-time type relationship between your Erlang/Elixir host and the Lua scripts it runs.

It inherits the runtime, not the syntax. LFE gave you Lisp on the BEAM; Gleam gave you ML-style types on the BEAM; Luerl gives you Lua on the BEAM. But the reason all of them are interesting is the same: they ride the BEAM's preemptive scheduling, per-process isolation, and fault tolerance. Luerl's reduction-based sandboxing is, quite literally, the scheduler's reduction counter being turned into a security feature.

A few practical caveats worth knowing: Luerl implements most of the standard library - basic, string, table, math, os, io, package, bit32, utf8, and debug - but does not implement Lua coroutines, which is the most common surprise for people porting existing Lua. And the sandbox's safety guarantees come from the curated global table plus reduction/time limits; if you re-expose dangerous functionality through your own host functions, you've widened the sandbox yourself.

Use cases in practice

The pattern Luerl enables shows up wherever a BEAM application needs to run logic it didn't write:

Takeaway

Luerl answers a different question than the rest of the BEAM family. The others ask, "what language do I want to write my system in?" Luerl asks, "what language do I want to let other people run inside my system - safely?" By implementing Lua as a pure-Erlang library whose entire state is an immutable, threadable Erlang term, it turns the BEAM's scheduler, process isolation, and fault tolerance into a sandbox that's hard to escape and easy to embed.

It isn't the fastest way to run Lua, and it isn't a general-purpose application language. It's something rarer: a guest language that the host runtime can completely contain. On a VM built so telephone switches would never go down, that turns out to be a remarkably good place to run code you don't fully trust.