Learn Luerl
Lua 5.x implemented in Erlang - sandboxed Lua running on the BEAM, embeddable in any Erlang or Elixir application.
What Luerl Is (and Isn't)
Lua 5.x written in Erlang, run it from the shell, and meet the threaded state.
Luerl is an implementation of Lua 5.x (currently tracking Lua 5.3) written entirely in Erlang/OTP. It was created by Robert Virding - one of the original co-inventors of Erlang and the author of LFE - so it is Lua, but Lua that runs on the BEAM. There is no C interpreter, no FFI, and no separate OS process: Luerl is just an Erlang library you add to your project.
That makes Luerl different from a normal Lua install. Standard Lua is an extension language with no main of its own; it expects to be embedded in a host program written in C. Luerl keeps that embedding model but the host is your Erlang or Elixir application. You hand Luerl some Lua source, it runs on the same scheduler as everything else, and you get the result back as an Erlang term.
The fastest way to try it is from the Erlang shell. Add Luerl as a rebar3 dependency, start rebar3 shell, and run a snippet:
1> luerl:do(<<"return 1 + 2 * 3">>, luerl:init()).
{ok,[7],{luerl, ...}}
Three things are worth noticing already. First, luerl:init/0 builds a fresh Lua state - a single Erlang data structure that holds all of Lua's globals, tables, and standard library. Second, luerl:do/2 returns a three-element tuple {ok, Results, NewState}: Lua functions can return many values, so results always come back as a list (here [7]). Third, that NewState at the end is the heart of how Luerl works.
Because Luerl is pure Erlang, the Lua state is immutable. Running code never mutates the state in place; instead each call returns a new state that you thread into the next call. If you have used Erlang before this will feel completely natural - it is the same single-assignment, explicit-state style as the rest of the language. If you are coming from Lua, the surprise is that there is no hidden global VM: the entire interpreter's world is a value you hold in a variable.
The source you pass is an Erlang binary (<<"...">>), which is the efficient way to carry text on the BEAM. Strings written "..." in Erlang are lists of character codes; Luerl accepts both, but binaries are idiomatic.
Running Lua: do, eval, and decoding results
The four ways to run scripts and why results sometimes look strange.
Luerl gives you a small family of entry points, and the differences between them matter.
The do functions run a chunk and return the result plus a new state; the eval functions run a chunk and return just an ok/error tuple, throwing away the new state. Use do when you want to keep going from where the script left off, and eval for a one-shot calculation.
%% do/2 -> {ok, Results, NewState}
{ok, [Sum], St1} = luerl:do(<<"return 40 + 2">>, luerl:init()).
%% Sum = 42, St1 is the updated state
%% eval/2 -> {ok, Results} | {error, Reason, _}
{ok, [Sum]} = luerl:eval(<<"return 40 + 2">>, luerl:init()).
There is a second axis: decoding. Lua values like tables are stored inside the state in Luerl's own internal representation. The plain functions return that raw representation, which can look cryptic. The _dec variants automatically decode the result into ordinary Erlang terms, which is almost always what you want:
%% Without decoding, a Lua table comes back as an opaque reference:
1> luerl:do(<<"return {1, 2, 3}">>, luerl:init()).
{ok,[{tref,12}], ...}
%% do_dec decodes it into a friendly Erlang shape:
2> luerl:do_dec(<<"return {1, 2, 3}">>, luerl:init()).
{ok,[[{1,1.0},{2,2.0},{3,3.0}]], ...}
That second result reveals how Luerl maps Lua data onto Erlang. A Lua table decodes to a list of {Key, Value} pairs (it is the only data structure Lua has, used for both arrays and dictionaries). Lua numbers historically decode to Erlang floats - notice 1.0 rather than 1 - which trips up newcomers, though Lua 5.3's integer subtype is increasingly preserved. Lua strings decode to Erlang binaries, nil to the atom nil, and booleans to true/false.
For larger programs you will load from a file. luerl:dofile/2 reads and runs a .lua file directly, while luerl:loadfile/2 compiles it once into a reusable form you can run many times against different states:
{ok, Form, St1} = luerl:loadfile("config.lua", luerl:init()),
{ok, Result, St2} = luerl:eval(Form, St1).
Compiling once with loadfile and evaluating the form repeatedly is the efficient pattern when the same script runs against many inputs.
A Quick Tour of the Lua Language
Tables, functions, control flow, and the bits Luerl supports.
Even though Luerl runs on the BEAM, the language you write is just Lua. If you know Lua you can skip ahead; if not, here is the essential core, all of which Luerl executes.
Variables are dynamically typed and global by default - you add local to scope them. Lua has the usual numbers, strings, booleans, nil, functions, and one compound type: the table.
local name = "Ada"
local count = 3
local ok = true
-- string concatenation uses '..'
print("hello, " .. name)
The table is Lua's single data structure, doubling as array, dictionary, and object. Array indices conventionally start at 1, not 0:
local fruit = {"apple", "pear", "plum"}
print(fruit[1]) -- apple (1-based!)
print(#fruit) -- 3 (# is length)
local user = { name = "Ada", age = 36 }
print(user.name) -- Ada
print(user["age"]) -- 36
Functions are first-class values and can return multiple results, which is exactly why Luerl gives you a list back:
local function minmax(t)
local lo, hi = t[1], t[1]
for _, v in ipairs(t) do
if v < lo then lo = v end
if v > hi then hi = v end
end
return lo, hi -- two return values
end
local a, b = minmax({4, 1, 7, 3}) -- a = 1, b = 7
Control flow is conventional: if/elseif/else/end, numeric and generic for, while, and repeat/until. The two iteration helpers you will use constantly are ipairs (walks an array part in order) and pairs (walks every key in a table):
for i = 1, 5 do print(i) end -- 1 2 3 4 5
for key, value in pairs(user) do
print(key, value)
end
Luerl ships much of the standard library - string, table, math, os (a safe subset), and basics like print, tostring, type, and pcall for protected calls. A few odd corners of full Lua (some io operations, certain debug features, coroutines historically) are partial or absent, but the language you reach for day to day is all there.
Crossing the Border: Erlang and Lua Together
Set globals, read results, and call Lua functions from Erlang.
The reason to embed Luerl is to move data and control back and forth between your Erlang/Elixir code and a Lua script. Luerl gives you a clean API for that, and the unifying idea is key paths: you address any value in Lua's nested global tables by a list of keys.
Pushing data in. Use luerl:set_table_keys/3 to write into Lua's globals before you run a script. The key path [<<"config">>, <<"limit">>] means "the limit field of the global table config":
St0 = luerl:init(),
%% create config.limit = 100 (creates config if needed)
{ok, St1} = luerl:set_table_keys([<<"config">>, <<"limit">>], 100, St0),
{ok, [V], St2} = luerl:do(<<"return config.limit * 2">>, St1).
%% V = 200
Pulling data out. After a script runs, luerl:get_table_keys/2 reads any global back into Erlang. There is a get_table_keys_dec/2 that decodes for you, mirroring the do/do_dec pairing from earlier:
{ok, [], St3} = luerl:do(<<"answer = 6 * 7">>, luerl:init()),
{ok, Answer, _St4} = luerl:get_table_keys_dec([<<"answer">>], St3).
%% Answer = 42
Calling a Lua function from Erlang. Define a function in Lua, grab a reference to it with get_table_keys, then invoke it with call_function/3. Arguments and results are lists, since Lua is multi-value on both ends:
{ok, [], St} = luerl:do(<<"function add(a, b) return a + b end">>, luerl:init()),
{ok, AddRef, St2} = luerl:get_table_keys([<<"add">>], St),
{ok, [Sum], _St3} = luerl:call_function(AddRef, [10, 20], St2).
%% Sum = 30
When you need to hand-build Lua values - say, pass an Erlang map into a Lua function as a table - use luerl:encode/2, which turns an Erlang term into Luerl's internal form and returns it with a new state; luerl:decode/2 goes the other way. Every one of these calls threads the state through, so a typical Erlang function that drives a script is a chain of pattern matches binding St0, St1, St2, and so on - the same explicit-state discipline you would use writing any other Erlang.
The Sandbox: Safe Scripting and Custom Erlang Builtins
Why Luerl is safe to run untrusted scripts, and how to extend it.
Luerl's standout feature is safety, and it comes from two properties of running Lua as Erlang data.
First, Luerl is sandboxed by construction. The interpreter only has access to the standard-library functions that are actually implemented in Erlang, and the genuinely dangerous ones simply aren't there. A script cannot shell out, read your filesystem, or open sockets unless you deliberately add a function that does so. Calls like os.execute or arbitrary io file access are absent from the default state, so untrusted Lua can compute and transform data but cannot touch the host:
%% A hostile script has nothing dangerous to call:
luerl:do(<<"return os.execute('rm -rf /')">>, luerl:init()).
%% errors out -- os.execute is not provided in the default sandbox
Second, because the whole interpreter is immutable Erlang data running inside an isolated BEAM process, you get a second ring of protection for free. Run each script in its own process and you can enforce timeouts (kill a process that loops forever) and memory bounds, and a misbehaving script can never corrupt your application's memory - processes share nothing. This is Erlang's "let it crash" fault isolation applied to scripting: an evil or buggy script just takes down its own short-lived process.
That same immutability enables a neat trick: build one state with your libraries loaded, then fan it out. Since running a script returns a new state and never mutates the original, you can use a single prepared state as the starting point for thousands of parallel, independent evaluations - perfect for per-request scripting on a server.
When you do want to give scripts extra power, you expose your own Erlang functions as Lua builtins. An Erlang function callable from Lua receives an argument list and the current state, and returns {Results, NewState} - exactly the threaded-state shape you have seen throughout. You wire it in with set_table_keys using the erl_func form:
%% An Erlang function exposed to Lua as double(x)
Double = fun([N], St) -> {[N * 2], St} end,
St0 = luerl:init(),
{ok, St1} = luerl:set_table_keys([<<"double">>], {erl_func, Double}, St0),
{ok, [R], _St2} = luerl:do(<<"return double(21)">>, St1).
%% R = 42 -- Lua called straight into Erlang
By choosing exactly which Erlang functions to expose, you define the precise capabilities a script is allowed - a domain-specific API with no escape hatch. This is why Luerl is a favourite for user-supplied configuration, game logic, business rules, and plugins inside Erlang and Elixir systems: it offers Lua's friendly, ubiquitous syntax with the BEAM's concurrency, fault isolation, and a sandbox you control down to the individual function.
Luerl from Elixir, and Where It Fits
Call Luerl from Elixir and see when to reach for it.
Because Luerl is an ordinary BEAM library, Elixir can use it directly - Erlang modules are just atoms with a leading colon. The API is identical; only the syntax changes:
state = :luerl.init()
{:ok, [result], _new_state} =
:luerl.do("return 6 * 7", state)
IO.inspect(result) # 42
Setting globals and calling Lua functions works the same way, with Erlang binaries written as Elixir strings and key paths as lists:
state = :luerl.init()
{:ok, state} = :luerl.set_table_keys(["name"], "Ada", state)
{:ok, [greeting], _} =
:luerl.do(~s|return "hello, " .. name|, state)
# greeting is the binary "hello, Ada"
Several Elixir wrapper libraries put a more idiomatic, pipe-friendly face on Luerl (handling encoding, decoding, and per-call sandboxing for you), but they all sit on the same engine you have been learning.
When should you reach for Luerl? It shines whenever you want to let people - or other parts of your system - supply behaviour rather than just data, without trusting them with your machine:
- Configuration as code: a config file that can compute values and branch, far more expressive than static formats.
- Business rules and pricing/feature logic that non-deployers can edit safely at runtime.
- Game scripting and modding, Lua's classic home, now with BEAM concurrency underneath.
- Plugins and user automations in a SaaS product, each script sandboxed in its own supervised process.
The whole point of the BEAM family is that these languages share one runtime. A Luerl state runs inside a process supervised by the same OTP supervision trees that guard your Erlang and Elixir code; it interoperates with everything else on the node; and it benefits from the scheduler, distribution, and fault tolerance you get for free. Erlang gives you the actor model, Elixir adds ergonomics and macros, Gleam adds a sound static type system, LFE adds Lisp macros - and Luerl adds the world's most popular embeddable scripting language, made safe by turning it into immutable Erlang data. When your application needs to run code it didn't write, that combination is hard to beat.