Lua is one of the most widely deployed programming languages you’ve probably never written production code in directly. It runs inside nginx via OpenResty (powering Kong API Gateway, Cloudflare’s edge, and countless WAFs), inside Redis as an atomic scripting engine, inside Neovim replacing Vimscript, inside World of Warcraft’s entire addon system, inside Roblox serving 70+ million daily active users, and inside dozens of game engines from small indie frameworks to AAA titles. It does all of this from a codebase that compiles to under 300KB.
This is not a beginner’s guide. You know at least one language. You understand pointers, closures, or at minimum the difference between pass-by-value and pass-by-reference. The goal here is to get you to a working mental model of Lua fast, cover its genuinely interesting design choices in depth, and give you the practical knowledge to use it wherever it appears — which is everywhere.
1. What Lua Is and Why It Exists
Lua was created in 1993 at PUC-Rio (Pontifical Catholic University of Rio de Janeiro) by Roberto Ierusalimschy, Luiz Henrique de Figueiredo, and Waldemar Celes. The immediate motivation was a contract with Petrobras (the Brazilian state oil company) that required a configurable data description language for their engineering simulation software. An earlier in-house language called SOL (Simple Object Language) wasn’t cutting it. Lua — Portuguese for “moon” — was what came next.
The design constraints that shaped Lua from day one:
- Must embed cleanly in C. Host applications are written in C or C++. The language has to live inside them as a library, not the other way around.
- Must be portable. ANSI C only. No platform-specific extensions in the core.
- Must be small. The entire runtime as a shared library. The target was kilobytes, not megabytes.
- Must be fast enough for real-time use. Game engines can’t afford a slow scripting layer.
The result is a language with a deliberately minimal core. Lua does not have: a standard HTTP library, a standard JSON library, a standard filesystem abstraction, a standard datetime library, threads, or many other things you’d expect from a general-purpose language. This is not an oversight. It is the design. The host application provides whatever the script needs. Lua provides the mechanism; the host provides the policy.
Lua 5.4: The Current Stable
Lua 5.4 (released June 2020) is the current stable release as of 2026 and what you should be using for new PUC Lua work. The headline changes from 5.3:
Integer subtype. Lua now has a distinct integer type alongside floats. Prior to 5.3, all numbers were doubles. Since 5.3, 1 is an integer and 1.0 is a float, and they do not automatically coerce in arithmetic. This matters for bit operations and for code that interfaces with C integers.
1
2
3
4
5
6
7
8
|
-- Lua 5.4
print(type(1)) -- number
print(type(1.0)) -- number
print(math.type(1)) -- integer
print(math.type(1.0)) -- float
print(1 == 1.0) -- true (value equality)
print(1 // 1) -- 1 (integer floor division)
print(1.0 // 1) -- 1.0 (float floor division)
|
Generational GC. The garbage collector gained a generational mode alongside the existing incremental mode. Short-lived objects (most objects in most programs) get collected cheaply. Long-lived objects pay the full mark cost less frequently. You can tune this:
1
2
3
4
5
6
|
-- Switch to generational GC (default in 5.4)
collectgarbage("generational")
-- Or back to incremental
collectgarbage("incremental", 100, 200, 10)
-- args: pause (%), step multiplier (%), step size
|
To-be-closed variables (<close>). RAII-style resource cleanup via __close metamethod. Think Go’s defer but scoped to the variable’s lifetime:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
|
local function open_resource(name)
local r = { name = name }
-- __close metamethod called when 'r' goes out of scope
setmetatable(r, {
__close = function(self, err)
print("closing " .. self.name)
if err then
print("due to error: " .. tostring(err))
end
end
})
return r
end
do
local r <close> = open_resource("db_connection")
-- do work
-- "closing db_connection" prints here when 'do' block exits
end
|
LuaJIT vs PUC Lua
LuaJIT is a Just-In-Time compiler for Lua written by Mike Pall. It implements Lua 5.1 semantics (not 5.4) and delivers performance that can match or beat C for certain workloads. LuaJIT is what OpenResty, Kong, and most game engines that claim “Lua scripting” actually run.
The performance story: PUC Lua 5.4 is a fast interpreter — roughly 2-5x faster than CPython 3 for typical scripting tasks. LuaJIT in JIT mode is roughly 10-50x faster than PUC Lua for hot code paths. The FFI library (covered later) lets LuaJIT call C functions with essentially zero overhead.
The maintenance situation is complicated. Mike Pall reduced involvement around 2018-2020. The OpenResty project maintains their own fork (OpenResty’s LuaJIT fork adds patches for newer platforms, ARM improvements, etc.). There is no LuaJIT 3.0 on any near horizon. For new embedding work where you control the toolchain, PUC Lua 5.4 is safer. For OpenResty/nginx work, you’re using LuaJIT 2.1 (OpenResty fork) whether you like it or not, and that’s fine — it’s battle-tested at enormous scale.
The key practical differences:
| Feature |
PUC Lua 5.4 |
LuaJIT 2.1 |
| Lua version |
5.4 |
5.1 + some 5.2 compat |
<close> variables |
Yes |
No |
| Integer subtype |
Yes |
No (all numbers are doubles) |
goto |
Yes |
Yes |
bit library |
via bit32 (deprecated) |
bit (C-style bitops) |
| FFI |
No |
Yes |
| Performance |
Fast interpreter |
JIT, dramatically faster |
math.type() |
Yes |
No |
| Generational GC |
Yes |
No |
2. Core Language
Tables: The One Data Structure
Every compound data structure in Lua is a table. There are no arrays, no maps, no sets, no structs, no classes in the language — just tables. A table is an associative array that maps keys to values, where keys can be any value except nil and NaN.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
|
-- Array-style table (keys are integers starting at 1)
local fruits = {"apple", "banana", "cherry"}
print(fruits[1]) -- apple (1-indexed, not 0)
print(#fruits) -- 3
-- Dictionary-style table
local config = {
host = "localhost",
port = 5432,
database = "myapp",
}
print(config.host) -- localhost
print(config["port"]) -- 5432 (same thing)
-- Mixed table
local mixed = {
"first", -- key: 1
"second", -- key: 2
name = "mixed", -- key: "name"
[100] = "sparse", -- key: 100
}
-- Nested tables
local servers = {
{ host = "web-01", port = 80 },
{ host = "web-02", port = 80 },
{ host = "db-01", port = 5432 },
}
for i, server in ipairs(servers) do
print(i, server.host, server.port)
end
|
Tables are the module system. When you require("json"), you get back a table of functions. Tables are the object system — methods are just functions stored in table fields. Tables are namespaces. This is not a limitation; it is what allows Lua to be so small while being so expressive.
Mutating tables:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
|
local t = {}
t.foo = "bar" -- add field
t.foo = nil -- remove field (sets to nil)
t[#t + 1] = "item" -- append to array portion
-- table library for array operations
local arr = {3, 1, 4, 1, 5, 9, 2, 6}
table.sort(arr)
print(table.concat(arr, ", ")) -- 1, 1, 2, 3, 4, 5, 6, 9
table.insert(arr, 3, 99) -- insert 99 at position 3
table.remove(arr, 3) -- remove element at position 3
-- table.move (5.3+): efficient array slice/copy
local src = {10, 20, 30, 40, 50}
local dst = {}
table.move(src, 2, 4, 1, dst) -- copy src[2..4] to dst[1..]
-- dst is now {20, 30, 40}
|
The # Length Operator Gotcha
The # operator returns the “border” of a sequence — an index i where t[i] is not nil and t[i+1] is nil. For a contiguous sequence starting at 1, this is what you want. For sparse tables, the result is undefined and can be any border:
1
2
3
4
5
6
7
8
9
10
11
12
13
|
local t = {10, 20, nil, 40}
print(#t) -- could be 2 or 4, implementation-defined
-- This is safe:
local arr = {}
for i = 1, 10 do arr[i] = i * 2 end
print(#arr) -- always 10, no holes
-- This is NOT safe:
local sparse = {}
sparse[1] = "a"
sparse[3] = "c" -- gap at index 2
print(#sparse) -- undefined behavior, could be 1 or 3
|
If you need a reliable length for a non-sequence table (or you’re tracking count explicitly), maintain the count yourself or use select('#', ...) for varargs.
First-Class Functions and Closures
Functions are values. They have no name — names are just variables that happen to hold function values.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
|
-- These are equivalent:
local function add(a, b) return a + b end
local add = function(a, b) return a + b end
-- Higher-order functions
local function map(t, fn)
local result = {}
for i, v in ipairs(t) do
result[i] = fn(v)
end
return result
end
local doubled = map({1, 2, 3, 4, 5}, function(x) return x * 2 end)
-- {2, 4, 6, 8, 10}
-- Closures capture variables by reference
local function make_counter(start)
local count = start or 0
return {
inc = function() count = count + 1 end,
dec = function() count = count - 1 end,
get = function() return count end,
}
end
local c = make_counter(10)
c.inc()
c.inc()
c.dec()
print(c.get()) -- 11
|
Multiple Return Values
Lua functions can return multiple values natively. No tuples, no arrays, just multiple values on the stack:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
|
local function divmod(a, b)
return a // b, a % b
end
local q, r = divmod(17, 5)
print(q, r) -- 3 2
-- Multiple returns get truncated/extended in most contexts
local x = divmod(17, 5) -- x gets only the first value: 3
-- table.pack captures all returns
local results = table.pack(divmod(17, 5))
-- results = {3, 2, n=2}
print(results.n) -- 2 (count)
-- In a table constructor, only the last call expands
local t = {divmod(17, 5)}
-- t = {3, 2}
local t2 = {divmod(17, 5), "extra"}
-- t2 = {3, "extra"} -- divmod truncated to 1 value!
-- Parentheses force single value
print((divmod(17, 5))) -- 3 only
|
This multiple-return mechanism is what makes the ok, err pattern idiomatic in Lua, much like Go:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
|
local function read_file(path)
local f, err = io.open(path, "r")
if not f then
return nil, "cannot open " .. path .. ": " .. err
end
local content = f:read("*a")
f:close()
return content, nil
end
local content, err = read_file("/etc/hosts")
if err then
print("Error: " .. err)
else
print(content)
end
|
Varargs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
|
local function sum(...)
local total = 0
local args = {...} -- pack into table
for _, v in ipairs(args) do
total = total + v
end
return total
end
print(sum(1, 2, 3, 4, 5)) -- 15
-- select('#', ...) counts arguments including nils
local function count_args(...)
return select('#', ...)
end
print(count_args(1, nil, 3)) -- 3 (not 1)
-- select(n, ...) returns arguments from position n onward
local function from_second(...)
return select(2, ...)
end
print(from_second(10, 20, 30)) -- 20 30
-- Variadic wrapper pattern
local function logged(fn, ...)
print("calling with " .. select('#', ...) .. " args")
return fn(...)
end
|
String Library
Lua strings are immutable byte sequences. The standard string library handles most needs:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
|
local s = "Hello, World!"
-- Basic operations
print(#s) -- 13
print(s:upper()) -- HELLO, WORLD!
print(s:lower()) -- hello, world!
print(s:len()) -- 13
print(s:sub(1, 5)) -- Hello (1-indexed, inclusive)
print(s:sub(-6)) -- orld! (negative = from end)
print(s:find("World")) -- 8 12 (start, end positions)
print(s:gsub("o", "0")) -- Hell0, W0rld! 2
-- Pattern matching (not full regex, simpler)
-- %a=letter, %d=digit, %s=space, %w=alphanumeric, %p=punctuation
-- Quantifiers: * (0+), + (1+), - (0+, lazy), ? (0 or 1)
-- ^ anchors start, $ anchors end
local date = "2026-04-11"
local y, m, d = date:match("(%d%d%d%d)-(%d%d)-(%d%d)")
print(y, m, d) -- 2026 04 11
-- gmatch for iteration
local text = "the quick brown fox"
for word in text:gmatch("%a+") do
io.write(word .. " ")
end
-- the quick brown fox
-- String formatting (printf-style)
local msg = string.format("Server %s listening on port %d (%.2f%% capacity)",
"web-01", 8080, 73.456)
print(msg) -- Server web-01 listening on port 8080 (73.46% capacity)
-- String rep and reverse
print(("ab"):rep(4)) -- abababab
print(("hello"):reverse()) -- olleh
-- byte/char conversion
print(string.byte("A")) -- 65
print(string.char(65, 66, 67)) -- ABC
|
Control Flow
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
|
-- if/elseif/else (no switch/case)
local x = 42
if x < 0 then
print("negative")
elseif x == 0 then
print("zero")
elseif x < 100 then
print("small positive")
else
print("large positive")
end
-- while
local i = 0
while i < 5 do
i = i + 1
end
-- repeat/until (condition checked AFTER body, like do-while)
local n = 0
repeat
n = n + 1
until n >= 5
-- numeric for
for i = 1, 10 do print(i) end -- 1 to 10
for i = 10, 1, -2 do print(i) end -- 10, 8, 6, 4, 2
for i = 0.0, 1.0, 0.25 do print(i) end -- float step
-- generic for with iterators
local t = {a=1, b=2, c=3}
for k, v in pairs(t) do -- unordered key-value iteration
print(k, v)
end
for i, v in ipairs({10,20,30}) do -- sequential integer keys
print(i, v)
end
-- goto (yes, Lua has goto — useful for breaking nested loops)
for i = 1, 3 do
for j = 1, 3 do
if i == 2 and j == 2 then
goto continue -- Lua idiom for "continue"
end
print(i, j)
::continue::
end
end
|
Metatables are Lua’s extension mechanism. Every table (and userdata) can have a metatable — another table whose fields control how operations on the original table behave. This is how Lua implements operator overloading, prototypal inheritance, and object-oriented programming without any OOP syntax in the language itself.
1
2
3
4
5
|
-- Attach a metatable
local t = {}
local mt = {}
setmetatable(t, mt)
print(getmetatable(t) == mt) -- true
|
__index: The Prototype Chain
__index is the most important metamethod. It fires when you access a key that doesn’t exist in a table. It can be either a table (prototype lookup) or a function (computed lookup):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
|
-- __index as a table: prototype inheritance
local Animal = {
sound = "...",
speak = function(self)
print(self.name .. " says " .. self.sound)
end,
}
Animal.__index = Animal -- convention: point __index to self
local function new_animal(name, sound)
local a = { name = name, sound = sound }
setmetatable(a, Animal)
return a
end
local dog = new_animal("Rex", "woof")
dog:speak() -- Rex says woof (found via __index chain)
-- __index as a function: computed/dynamic lookup
local defaults = setmetatable({}, {
__index = function(t, k)
-- called whenever t[k] is nil
return "default_" .. k
end
})
print(defaults.foo) -- default_foo
print(defaults.bar) -- default_bar
defaults.baz = "real"
print(defaults.baz) -- real (found in table, __index not called)
|
__newindex: Intercepting Writes
__newindex fires when you write to a key that doesn’t exist in the table. Combined with rawset, you can build read-only tables, schema-enforcing tables, or write-through proxies:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
|
-- Read-only table
local function readonly(t)
return setmetatable({}, {
__index = t,
__newindex = function(_, k, v)
error("attempt to update a read-only table key: " .. tostring(k), 2)
end,
})
end
local config = readonly({ host = "localhost", port = 5432 })
print(config.host) -- localhost
config.host = "other" -- error: attempt to update a read-only table key: host
-- Tracking writes (audit log)
local function audited(t)
local log = {}
return setmetatable({}, {
__index = t,
__newindex = function(_, k, v)
log[#log+1] = string.format("SET %s = %s", tostring(k), tostring(v))
rawset(t, k, v) -- rawset bypasses metamethods
end,
__index = function(_, k) return t[k] end,
get_log = function() return log end,
})
end
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
|
local Vector = {}
Vector.__index = Vector
function Vector.new(x, y)
return setmetatable({ x = x, y = y }, Vector)
end
-- __tostring: called by tostring() and print()
function Vector:__tostring()
return string.format("Vector(%g, %g)", self.x, self.y)
end
-- __add: + operator
function Vector:__add(other)
return Vector.new(self.x + other.x, self.y + other.y)
end
-- __sub: - operator
function Vector:__sub(other)
return Vector.new(self.x - other.x, self.y - other.y)
end
-- __mul: * operator (scalar multiplication)
function Vector:__mul(scalar)
if type(scalar) == "number" then
return Vector.new(self.x * scalar, self.y * scalar)
elseif type(self) == "number" then
-- handle 3 * vec (scalar on left side)
return Vector.new(scalar * other.x, scalar * other.y)
end
end
-- __unm: unary minus
function Vector:__unm()
return Vector.new(-self.x, -self.y)
end
-- __eq: == operator (only called when both have same metatable)
function Vector:__eq(other)
return self.x == other.x and self.y == other.y
end
-- __len: # operator
function Vector:__len()
return math.sqrt(self.x^2 + self.y^2)
end
-- __call: call the table as a function
Vector.__call = function(self, t)
-- vec(t) moves along direction for time t
return Vector.new(self.x * t, self.y * t)
end
local v1 = Vector.new(3, 4)
local v2 = Vector.new(1, 2)
print(tostring(v1)) -- Vector(3, 4)
print(tostring(v1 + v2)) -- Vector(4, 6)
print(tostring(v1 - v2)) -- Vector(2, 2)
print(tostring(v1 * 2)) -- Vector(6, 8)
print(tostring(-v1)) -- Vector(-3, -4)
print(v1 == Vector.new(3, 4)) -- true
print(#v1) -- 5.0 (magnitude)
print(tostring(v1(0.5))) -- Vector(1.5, 2)
|
Building OOP: Class-Based Pattern
The standard Lua OOP idiom — you’ll see this in nearly every Lua codebase:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
|
-- A reusable class constructor
local function class(base)
local cls = {}
cls.__index = cls
if base then
setmetatable(cls, { __index = base })
end
cls.new = function(...)
local instance = setmetatable({}, cls)
if instance.init then
instance:init(...)
end
return instance
end
cls.is_a = function(self, klass)
local mt = getmetatable(self)
while mt do
if mt == klass then return true end
mt = getmetatable(mt)
end
return false
end
return cls
end
-- Define classes
local Shape = class()
function Shape:init(color)
self.color = color or "black"
end
function Shape:area()
return 0
end
function Shape:describe()
return string.format("%s shape (color=%s, area=%.2f)",
self:type_name(), self.color, self:area())
end
function Shape:type_name()
return "unknown"
end
-- Inheritance
local Circle = class(Shape)
function Circle:init(radius, color)
Shape.init(self, color) -- call super
self.radius = radius
end
function Circle:area()
return math.pi * self.radius ^ 2
end
function Circle:type_name()
return "circle"
end
local Rectangle = class(Shape)
function Rectangle:init(w, h, color)
Shape.init(self, color)
self.w, self.h = w, h
end
function Rectangle:area()
return self.w * self.h
end
function Rectangle:type_name()
return "rectangle"
end
local c = Circle.new(5, "red")
local r = Rectangle.new(4, 6, "blue")
print(c:describe()) -- circle shape (color=red, area=78.54)
print(r:describe()) -- rectangle shape (color=blue, area=24.00)
print(c:is_a(Circle)) -- true
print(c:is_a(Shape)) -- true
print(c:is_a(Rectangle)) -- false
|
__gc Finalizers
__gc on userdata (and tables in Lua 5.4) runs when the GC collects the object. Useful for wrapping C resources:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
|
-- In Lua 5.4, __gc works on tables directly
local function managed_resource(name)
local r = { name = name, open = true }
setmetatable(r, {
__gc = function(self)
if self.open then
print("GC finalizing: closing " .. self.name)
self.open = false
end
end
})
return r
end
-- Prefer <close> for deterministic cleanup in 5.4
-- __gc is a fallback for non-deterministic cleanup
local function wrap_fd(fd)
local obj = { fd = fd }
setmetatable(obj, {
__gc = function(self)
if self.fd then
-- close(self.fd) via C API in real code
print("closing fd " .. self.fd)
self.fd = nil
end
end
})
return obj
end
|
4. Coroutines
Coroutines are cooperative multitasking primitives. Lua’s are stackful coroutines — meaning a coroutine can yield from any depth in the call stack, not just from the function that called yield. This is more powerful than Python’s generators (which can only yield from the generator function itself) and more predictable than preemptive threads.
The mental model: a coroutine is a thread of execution that you manually schedule. You resume it to run, and it either yields (suspending itself and returning a value to the resumer) or returns (terminating).
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
|
-- Basic coroutine lifecycle
local co = coroutine.create(function(a, b)
print("coroutine started with", a, b)
local c = coroutine.yield(a + b) -- suspend, return a+b to resumer
print("resumed with", c)
return "done"
end)
print(coroutine.status(co)) -- suspended
local ok, val = coroutine.resume(co, 10, 20)
-- prints: coroutine started with 10 20
print(ok, val) -- true 30 (ok=true, val is yielded value)
print(coroutine.status(co)) -- suspended
local ok2, val2 = coroutine.resume(co, 99)
-- prints: resumed with 99
print(ok2, val2) -- true done (val2 is return value)
print(coroutine.status(co)) -- dead
-- Resuming a dead coroutine is an error
local ok3, err = coroutine.resume(co)
print(ok3, err) -- false cannot resume dead coroutine
|
Producer-Consumer Pattern
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
|
-- Producer generates values, consumer processes them
-- Neither needs to know about the other's control flow
local function producer()
local items = {"alpha", "beta", "gamma", "delta"}
for _, item in ipairs(items) do
print("producing: " .. item)
coroutine.yield(item)
end
-- returning nil signals end-of-stream
end
local function consumer(prod)
while true do
local ok, item = coroutine.resume(prod)
if not ok or item == nil then break end
print("consuming: " .. item)
end
end
local prod = coroutine.create(producer)
consumer(prod)
-- producing: alpha
-- consuming: alpha
-- producing: beta
-- consuming: beta
-- ... etc
|
coroutine.wrap: The Generator Interface
coroutine.wrap creates a coroutine and returns a function that resumes it each call, raising an error instead of returning false, err:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
|
-- Custom iterator using coroutines
local function range(from, to, step)
step = step or 1
return coroutine.wrap(function()
local i = from
while i <= to do
coroutine.yield(i)
i = i + step
end
end)
end
for i in range(1, 10, 2) do
io.write(i .. " ")
end
-- 1 3 5 7 9
-- Recursive tree traversal via coroutine
local function tree_iter(node)
return coroutine.wrap(function()
local function walk(n)
if n == nil then return end
walk(n.left)
coroutine.yield(n.value)
walk(n.right)
end
walk(node)
end)
end
-- Build a simple BST
local tree = {
value = 5,
left = {
value = 3,
left = { value = 1, left = nil, right = nil },
right = { value = 4, left = nil, right = nil },
},
right = {
value = 8,
left = { value = 7, left = nil, right = nil },
right = { value = 9, left = nil, right = nil },
},
}
for v in tree_iter(tree) do
io.write(v .. " ")
end
-- 1 3 4 5 7 8 9
|
Coroutines as Async/Await
Before async/await was fashionable, OpenResty was doing it with coroutines. The pattern: an event loop resumes coroutines when I/O completes, coroutines yield when they initiate I/O. From the coroutine’s perspective, calls look synchronous:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
|
-- Simplified illustration of the async-over-coroutines pattern
-- (This is what libraries like copas and the OpenResty scheduler do)
local scheduler = {
ready = {}, -- coroutines ready to run
waiting = {}, -- coroutines waiting on I/O fd
}
function scheduler.spawn(fn, ...)
local co = coroutine.create(fn)
-- pass initial args on first resume
local args = {...}
table.insert(scheduler.ready, function()
coroutine.resume(co, table.unpack(args))
end)
return co
end
-- In practice, ngx_lua does this transparently:
-- ngx.sleep(0.1) yields the coroutine to the nginx event loop
-- ngx.socket.tcp():connect() yields until the TCP handshake completes
-- From your Lua code it reads as blocking; it isn't.
-- Comparison with Python generators:
-- Python generators can only yield from the top-level generator function.
-- If you call a helper function from a generator, the helper cannot yield.
-- Lua stackful coroutines CAN yield from any call depth.
local function deep_io_simulation()
-- This could be three levels deep in a call stack
-- and it still works fine
coroutine.yield("waiting for I/O")
end
local function middleware()
deep_io_simulation() -- yield happens inside here, no problem
return "result"
end
local co = coroutine.create(function()
local result = middleware()
print("got:", result)
end)
coroutine.resume(co) -- runs until yield inside deep_io_simulation
coroutine.resume(co) -- resumes, middleware returns, prints "got: result"
|
The stackful nature is why OpenResty can offer a synchronous-looking API for non-blocking I/O. Python’s asyncio requires the async/await syntax to propagate through the entire call stack. Lua coroutines don’t.
5. The C API
The C API is what makes Lua worth understanding even if you never write a line of Lua yourself. Every embedding environment — nginx, Redis, Neovim, game engines — uses this API to expose functionality to Lua scripts. Understanding it gives you the mental model for why Lua works the way it does.
The Virtual Stack
Lua communicates between C and Lua through a virtual stack. C pushes values onto the stack before calling Lua. Lua pushes return values onto the stack for C to read. Positive indices count from the bottom (1 = bottom), negative indices from the top (-1 = top):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
|
#include <lua.h>
#include <lualib.h>
#include <lauxlib.h>
#include <stdio.h>
// Call a Lua function from C
void call_lua_function(lua_State *L) {
// Stack before: []
lua_getglobal(L, "my_function"); // stack: [function]
lua_pushinteger(L, 42); // stack: [function, 42]
lua_pushstring(L, "hello"); // stack: [function, 42, "hello"]
// Call with 2 args, expect 1 return value
// lua_pcall pops the function and args, pushes results
if (lua_pcall(L, 2, 1, 0) != LUA_OK) {
fprintf(stderr, "Error: %s\n", lua_tostring(L, -1));
lua_pop(L, 1); // pop error message
return;
}
// Stack after successful call: [result]
// Read the result
if (lua_isstring(L, -1)) {
printf("Result: %s\n", lua_tostring(L, -1));
}
lua_pop(L, 1); // always clean up the stack
}
// Register a C function callable from Lua
// Signature: all Lua-callable C functions take lua_State* and return int (# of return values)
static int c_add(lua_State *L) {
// lua_check* functions raise an error if type is wrong
lua_Integer a = luaL_checkinteger(L, 1); // first arg
lua_Integer b = luaL_checkinteger(L, 2); // second arg
lua_pushinteger(L, a + b); // push result
return 1; // number of return values
}
static int c_divmod(lua_State *L) {
lua_Integer a = luaL_checkinteger(L, 1);
lua_Integer b = luaL_checkinteger(L, 2);
if (b == 0) {
return luaL_error(L, "division by zero");
}
lua_pushinteger(L, a / b); // quotient
lua_pushinteger(L, a % b); // remainder
return 2; // two return values!
}
// Register a table of functions as a Lua module
static const luaL_Reg mylib[] = {
{"add", c_add},
{"divmod", c_divmod},
{NULL, NULL} // sentinel
};
// Called when Lua does require("mylib")
int luaopen_mylib(lua_State *L) {
luaL_newlib(L, mylib); // creates a table with the functions
return 1; // return the table
}
// Embedding Lua in a host application
int main(void) {
lua_State *L = luaL_newstate(); // create interpreter
luaL_openlibs(L); // load standard libraries
// Preload our module
luaL_requiref(L, "mylib", luaopen_mylib, 1);
lua_pop(L, 1);
// Execute a Lua script
const char *script =
"local mylib = require('mylib')\n"
"print(mylib.add(3, 4))\n" // 7
"local q, r = mylib.divmod(17, 5)\n"
"print(q, r)\n"; // 3 2
if (luaL_dostring(L, script) != LUA_OK) {
fprintf(stderr, "Script error: %s\n", lua_tostring(L, -1));
}
lua_close(L);
return 0;
}
|
Why This API Design Is Clever
The stack-based API avoids memory management ownership problems. When C pushes a string, Lua owns the copy. When Lua returns a string, C gets a pointer that is valid until the next GC cycle (or until the string is popped). The stack is the contract boundary. No shared heap pointers, no complex ownership rules, no reference counting across the boundary.
Contrast this with Python’s C API, which requires meticulous reference counting (Py_INCREF/Py_DECREF) — get it wrong and you have a memory leak or a crash. The Lua C API eliminates this class of bugs entirely for the common case.
6. LuaJIT
LuaJIT is not just “fast Lua.” It’s a different implementation with a different compilation strategy and a critically important feature: the FFI.
The FFI: Calling C Without Bindings
LuaJIT’s ffi library lets you call C functions and access C data structures directly from Lua, using C declarations:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
|
-- Only available in LuaJIT
local ffi = require("ffi")
-- Declare the C functions you want to call
ffi.cdef[[
typedef unsigned long size_t;
void* malloc(size_t size);
void free(void* ptr);
char* strcpy(char* dest, const char* src);
int printf(const char* fmt, ...);
int getpid(void);
// A struct
typedef struct {
int x;
int y;
int z;
} Point3D;
]]
-- Call C functions directly
local pid = ffi.C.getpid()
print("PID:", pid)
ffi.C.printf("Hello from C via LuaJIT FFI!\n")
-- Allocate C memory and work with structs
local p = ffi.new("Point3D")
p.x = 10
p.y = 20
p.z = 30
print(p.x, p.y, p.z) -- 10 20 30
-- Cast and pointer arithmetic
local buf = ffi.new("char[256]")
ffi.C.strcpy(buf, "hello world")
print(ffi.string(buf)) -- hello world
-- Load a shared library
local lib = ffi.load("m") -- libm (math library)
ffi.cdef[[
double sin(double x);
double cos(double x);
double sqrt(double x);
]]
print(lib.sin(math.pi / 6)) -- ~0.5
print(lib.sqrt(2.0)) -- ~1.4142
-- C arrays are zero-indexed in FFI (unlike Lua tables!)
local arr = ffi.new("int[5]", {10, 20, 30, 40, 50})
for i = 0, 4 do
io.write(arr[i] .. " ")
end
-- 10 20 30 40 50
|
The performance implication is significant: calling a C function through the FFI in JIT-compiled LuaJIT code has essentially the same overhead as calling it from C. There is no marshaling layer. This is why LuaJIT-based systems like OpenResty can push millions of requests per second.
LuaJIT traces hot code paths and compiles them to machine code. It is particularly effective on:
- Tight numeric loops (can match hand-written C)
- Table operations on small, same-shape tables
- FFI calls in hot paths
It is less effective on:
- Code with many different types flowing through the same variable
- Deep call chains with many upvalues
- Code that frequently uses
pairs() on tables with many different key types
The JIT can deoptimize (“abort trace”) and fall back to interpretation. You can check what’s happening with -jv or -jdump:
1
|
luajit -jv mycode.lua 2>&1 | grep -E "^(TRACE|ABORT)"
|
The Maintenance Situation
Mike Pall maintained LuaJIT largely alone for over a decade. He stepped back significantly around 2019-2020. The project is technically “maintained” but major new development has largely stopped. There are three forks worth knowing:
- OpenResty’s LuaJIT fork: Active, focuses on ARM64 improvements, security patches, and OpenResty-specific fixes. This is what you get when you use OpenResty.
- MoonJIT: An attempt at a community fork; less active.
- LJIT2: Experimental rewrite attempts.
For practical purposes: if you’re using OpenResty, you’re on the OpenResty fork and it’s fine. If you’re starting a new project and don’t need the FFI or extreme performance, use PUC Lua 5.4.
7. Neovim
Neovim chose Lua as its primary extension language starting around version 0.5 (2021). The decision was driven by three factors: Vimscript’s terrible performance for complex plugins, the lack of a good Vimscript debugger or type system, and the availability of LuaJIT in Neovim’s codebase already (used internally). Neovim uses PUC Lua 5.1 (specifically, the version bundled with LuaJIT) — not 5.4.
The vim.* API
Neovim exposes a rich Lua API via the global vim table:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
|
-- vim.api: direct bindings to the Neovim API (nvim_* functions)
-- vim.fn: calls Vimscript functions
-- vim.opt: sets options (replaces :set)
-- vim.keymap: manages keymaps
-- vim.cmd: executes Vimscript commands
-- vim.loop (vim.uv in recent versions): libuv event loop access
-- Options
vim.opt.number = true
vim.opt.relativenumber = true
vim.opt.tabstop = 4
vim.opt.shiftwidth = 4
vim.opt.expandtab = true
vim.opt.wrap = false
vim.opt.signcolumn = "yes"
vim.opt.cursorline = true
vim.opt.scrolloff = 8
-- Complex option types
vim.opt.listchars = { tab = "→ ", trail = "·", nbsp = "␣" }
vim.opt.wildignore:append({ "*.pyc", "node_modules", ".git" })
-- Keymaps
local opts = { noremap = true, silent = true }
vim.keymap.set("n", "<leader>ff", "<cmd>Telescope find_files<cr>", opts)
vim.keymap.set("n", "<leader>fg", "<cmd>Telescope live_grep<cr>", opts)
vim.keymap.set("v", "<", "<gv", opts) -- keep selection after indent
vim.keymap.set("v", ">", ">gv", opts)
-- Buffer and window manipulation via vim.api
local buf = vim.api.nvim_create_buf(false, true) -- scratch buffer
vim.api.nvim_buf_set_lines(buf, 0, -1, false, {
"Hello from Lua!",
"This is a scratch buffer.",
})
-- Autocommands
local augroup = vim.api.nvim_create_augroup("MyConfig", { clear = true })
vim.api.nvim_create_autocmd("BufWritePre", {
group = augroup,
pattern = "*.lua",
callback = function()
-- strip trailing whitespace on save
vim.cmd([[%s/\s\+$//e]])
end,
})
vim.api.nvim_create_autocmd("TextYankPost", {
group = augroup,
callback = function()
vim.highlight.on_yank({ higroup = "IncSearch", timeout = 200 })
end,
})
-- Diagnostics config
vim.diagnostic.config({
virtual_text = { prefix = "●" },
signs = true,
underline = true,
update_in_insert = false,
severity_sort = true,
})
|
Writing a Neovim Plugin
Neovim plugins follow a directory structure: lua/plugin-name/ for modules, plugin/ for auto-loaded init files. Here’s a simple plugin that adds a command to count words in a buffer:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
|
-- File: lua/wordcount/init.lua
-- A minimal Neovim plugin
local M = {}
function M.count_words()
local bufnr = vim.api.nvim_get_current_buf()
local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)
local word_count = 0
local char_count = 0
local line_count = #lines
for _, line in ipairs(lines) do
char_count = char_count + #line
for _ in line:gmatch("%S+") do
word_count = word_count + 1
end
end
vim.notify(
string.format("Lines: %d | Words: %d | Chars: %d",
line_count, word_count, char_count),
vim.log.levels.INFO
)
end
function M.setup(opts)
opts = opts or {}
-- Create user command
vim.api.nvim_create_user_command("WordCount", function()
M.count_words()
end, { desc = "Count words in current buffer" })
-- Optional keymap
if opts.keymap then
vim.keymap.set("n", opts.keymap, M.count_words,
{ desc = "Count words" })
end
end
return M
-- File: plugin/wordcount.lua (auto-loaded by Neovim)
-- require("wordcount").setup({ keymap = "<leader>wc" })
|
Lazy.nvim: The Plugin Manager
Lazy.nvim is the modern standard for Neovim plugin management, written entirely in Lua. It provides lazy-loading (plugins load only when needed), a lock file, and a clean UI. A typical ~/.config/nvim/lua/plugins/ setup:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
|
-- ~/.config/nvim/lua/plugins/init.lua
return {
-- LSP configuration
{
"neovim/nvim-lspconfig",
event = { "BufReadPre", "BufNewFile" },
dependencies = {
"hrsh7th/cmp-nvim-lsp",
},
config = function()
local lspconfig = require("lspconfig")
local capabilities = require("cmp_nvim_lsp").default_capabilities()
lspconfig.lua_ls.setup({
capabilities = capabilities,
settings = {
Lua = {
runtime = { version = "LuaJIT" },
diagnostics = { globals = { "vim" } },
workspace = {
library = vim.api.nvim_get_runtime_file("", true),
checkThirdParty = false,
},
},
},
})
lspconfig.gopls.setup({ capabilities = capabilities })
lspconfig.rust_analyzer.setup({ capabilities = capabilities })
-- Key mappings that only activate with an LSP attached
vim.api.nvim_create_autocmd("LspAttach", {
callback = function(ev)
local buf = ev.buf
local map = function(mode, lhs, rhs, desc)
vim.keymap.set(mode, lhs, rhs,
{ buffer = buf, desc = desc })
end
map("n", "gd", vim.lsp.buf.definition, "Go to definition")
map("n", "gr", vim.lsp.buf.references, "Find references")
map("n", "K", vim.lsp.buf.hover, "Hover documentation")
map("n", "<leader>rn", vim.lsp.buf.rename, "Rename symbol")
map("n", "<leader>ca", vim.lsp.buf.code_action, "Code action")
end,
})
end,
},
-- Fuzzy finder
{
"nvim-telescope/telescope.nvim",
cmd = "Telescope",
keys = {
{ "<leader>ff", "<cmd>Telescope find_files<cr>", desc = "Find files" },
{ "<leader>fg", "<cmd>Telescope live_grep<cr>", desc = "Live grep" },
{ "<leader>fb", "<cmd>Telescope buffers<cr>", desc = "Buffers" },
},
dependencies = { "nvim-lua/plenary.nvim" },
config = function()
require("telescope").setup({
defaults = {
layout_config = { horizontal = { preview_width = 0.6 } },
file_ignore_patterns = { "node_modules", ".git/" },
},
})
end,
},
}
|
Key Neovim plugins worth knowing, all written in Lua: nvim-treesitter (AST-based syntax highlighting and text objects), telescope.nvim (extensible fuzzy finder), nvim-cmp (completion engine), null-ls/none-ls (non-LSP diagnostics and formatting), lualine.nvim (statusline), neo-tree.nvim (file explorer).
8. OpenResty / nginx
OpenResty is nginx bundled with LuaJIT and the ngx_lua module. It turns nginx from a static config-driven proxy into a fully programmable application server where request handling logic is written in Lua. This is not a scripting bolt-on — the Lua code runs inside nginx’s event loop with non-blocking I/O. Kong API Gateway, the Cloudflare WAF, and countless API gateways are built on it.
The Request Lifecycle
OpenResty exposes the nginx request lifecycle as named phases, each with a corresponding Lua directive:
init_by_lua_block -- master process init (load shared resources)
init_worker_by_lua_block -- worker process init
access_by_lua_block -- authentication, rate limiting, routing
rewrite_by_lua_block -- URI rewriting
content_by_lua_block -- generate the response (like a handler)
header_filter_by_lua_block -- modify response headers
body_filter_by_lua_block -- transform response body
log_by_lua_block -- async logging after response sent
A simple nginx.conf showing the phase structure:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
|
# nginx.conf (OpenResty)
worker_processes auto;
events {
worker_connections 1024;
}
http {
# Shared memory dictionary: available across all workers
lua_shared_dict rate_limit 10m;
lua_shared_dict cache 50m;
# Load Lua code once at startup (all workers share via fork)
init_by_lua_block {
-- Pre-load modules, warm caches
local cjson = require("cjson")
local router = require("app.router")
router.build()
-- Variables set here are read-only in workers
}
server {
listen 8080;
location /api/ {
access_by_lua_block {
-- Rate limiting using shared dict
local limit = ngx.shared.rate_limit
local key = ngx.var.remote_addr
local count, err = limit:incr(key, 1, 0, 60) -- incr, init=0, TTL=60s
if not count then
ngx.log(ngx.ERR, "rate limit incr error: ", err)
return ngx.exit(500)
end
if count > 100 then -- 100 req/minute
ngx.header["Retry-After"] = "60"
return ngx.exit(429)
end
}
content_by_lua_block {
ngx.header.content_type = "application/json"
local body = ngx.req.get_body_data()
local args = ngx.req.get_uri_args()
-- Respond
local response = {
path = ngx.var.uri,
method = ngx.req.get_method(),
query = args,
}
ngx.say(require("cjson").encode(response))
}
log_by_lua_block {
-- Non-blocking log after response is sent
local latency = ngx.now() - ngx.req.start_time()
ngx.log(ngx.INFO, string.format(
"request completed: method=%s uri=%s status=%d latency=%.3fs",
ngx.req.get_method(),
ngx.var.uri,
ngx.status,
latency
))
}
}
}
}
|
Cosockets: Non-Blocking I/O
The cosocket API makes TCP/UDP connections appear synchronous in your Lua code while being non-blocking at the nginx event loop level. This is the stackful coroutine trick in production:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
|
-- content_by_lua_block
local tcp = ngx.socket.tcp()
tcp:settimeout(3000) -- 3 second connect timeout
-- This LOOKS blocking, but the coroutine yields to the event loop
local ok, err = tcp:connect("10.0.0.1", 6379)
if not ok then
ngx.log(ngx.ERR, "connect failed: ", err)
return ngx.exit(503)
end
-- Send a Redis command manually (in practice, use lua-resty-redis)
tcp:send("PING\r\n")
local line, err = tcp:receive() -- also yields to event loop
if line == "+PONG" then
ngx.say("Redis is alive")
end
tcp:close()
|
ngx.shared.DICT
Shared memory dictionaries are process-wide (across all nginx workers, via shared mmap). They are the canonical way to store shared state without Redis for lower-stakes use cases:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
|
-- Declare in nginx.conf: lua_shared_dict my_cache 10m;
-- In Lua:
local cache = ngx.shared.my_cache
-- set(key, value, exptime_seconds, flags)
cache:set("user:123", '{"name":"alice"}', 300)
local val, flags = cache:get("user:123")
if val then
ngx.say(val)
else
-- fetch from DB, store in cache
end
-- Atomic increment (for counters, rate limiting)
local newval, err = cache:incr("counter", 1, 0) -- init=0
-- get_keys() is O(n) and should not be called in hot paths
-- flush_expired() removes expired items but is also not free
cache:flush_expired() -- call this periodically, not per-request
|
lua-resty-* Ecosystem
The lua-resty-* namespace is the OpenResty ecosystem’s convention for libraries. Key ones:
- lua-resty-redis: Redis client using cosockets
- lua-resty-mysql: MySQL client
- lua-resty-http: HTTP client (non-blocking)
- lua-resty-jwt: JWT encode/decode/verify
- lua-resty-lrucache: LRU cache in pure Lua
- lua-resty-upstream-healthcheck: Active health checks for upstreams
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
|
-- lua-resty-redis example
local redis = require("resty.redis")
local function get_from_cache(key)
local red = redis:new()
red:set_timeouts(1000, 1000, 1000) -- connect, send, read
local ok, err = red:connect("127.0.0.1", 6379)
if not ok then
return nil, "connect: " .. err
end
local val, err = red:get(key)
if err then
return nil, "get: " .. err
end
-- Return connection to pool instead of closing
-- Reuses TCP connection for the next request in this worker
local ok, err = red:set_keepalive(30000, 100)
-- 30s max idle, pool size 100
if val == ngx.null then
return nil, nil -- key not found
end
return val, nil
end
|
9. Redis Scripting
Redis has supported Lua scripting since version 2.6. The integration is tight: the script runs inside Redis’s single-threaded event loop, making it atomic with respect to all other Redis operations.
EVAL and EVALSHA
EVAL script numkeys key [key ...] arg [arg ...]
From the Redis CLI:
1
2
3
4
5
6
7
8
9
|
# Simple atomic get-then-set
redis-cli EVAL "
local current = redis.call('GET', KEYS[1])
if current == false then
redis.call('SET', KEYS[1], ARGV[1])
return 1
end
return 0
" 1 mykey myvalue
|
From a Go client (using go-redis):
1
2
3
4
5
6
7
8
9
10
|
// Atomic compare-and-swap
const casScript = `
local current = redis.call('GET', KEYS[1])
if current == ARGV[1] then
redis.call('SET', KEYS[1], ARGV[2])
return 1
end
return 0
`
result, err := rdb.Eval(ctx, casScript, []string{"mykey"}, "oldval", "newval").Int()
|
Atomicity and Constraints
Lua scripts in Redis are fully atomic. No other command executes while a script is running. This makes them perfect for:
- Check-then-act operations (avoiding TOCTOU races)
- Multi-key operations that must succeed or fail together
- Implementing data structures Redis doesn’t natively support
Constraints you must respect:
- Scripts must not use global state that persists between calls (no
KEYS global, use the function parameter)
redis.call() raises a Lua error on Redis errors; redis.pcall() returns them as Lua error tables
- Scripts cannot perform blocking operations, sleep, or I/O
- Execution time is not bounded (beware long-running scripts blocking Redis)
Real-World Atomic Script: Distributed Rate Limiter
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
|
-- rate_limiter.lua
-- KEYS[1]: the rate limit key (e.g., "ratelimit:user:123")
-- ARGV[1]: limit (max requests)
-- ARGV[2]: window (seconds)
-- Returns: {current_count, remaining, reset_at}
local key = KEYS[1]
local limit = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local now = tonumber(redis.call('TIME')[1]) -- Unix timestamp
local count = redis.call('INCR', key)
if count == 1 then
-- First request in window, set expiry
redis.call('EXPIRE', key, window)
end
local ttl = redis.call('TTL', key)
local reset_at = now + ttl
if count > limit then
return {count, 0, reset_at, 0} -- 0 = rejected
end
return {count, limit - count, reset_at, 1} -- 1 = allowed
|
1
2
3
4
|
# Load script and cache its SHA
SHA=$(redis-cli SCRIPT LOAD "$(cat rate_limiter.lua)")
# EVALSHA is faster than EVAL (no script parsing)
redis-cli EVALSHA $SHA 1 "ratelimit:user:123" 100 60
|
Redis 7.x Functions: Replacing EVAL
Redis 7.0 introduced Redis Functions, a better alternative to EVAL. Scripts are stored persistently in Redis (unlike SCRIPT LOAD which is volatile), support libraries, and have named functions:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
|
-- Load a library into Redis (via redis-cli -x FUNCTION LOAD)
#!lua name=mylib
local function check_and_set(keys, args)
local key = keys[1]
local expected = args[1]
local new_val = args[2]
local current = redis.call('GET', key)
if current == expected then
redis.call('SET', key, new_val)
return 1
end
return 0
end
redis.register_function('check_and_set', check_and_set)
-- With ratelimiter:
local function rate_limit(keys, args)
local key = keys[1]
local limit = tonumber(args[1])
local window = tonumber(args[2])
local count = redis.call('INCR', key)
if count == 1 then
redis.call('EXPIRE', key, window)
end
return count <= limit and 1 or 0
end
redis.register_function('rate_limit', rate_limit)
|
1
2
3
4
5
6
|
# Load the library
redis-cli -x FUNCTION LOAD REPLACE < mylib.lua
# Call a named function
redis-cli FCALL check_and_set 1 mykey oldval newval
redis-cli FCALL rate_limit 1 "rl:user:123" 100 60
|
Redis Functions persist across restarts (unlike EVALSHA cache) and are replicated to replicas. They should be your default for new Redis scripting work on Redis 7+.
10. Game Engines
Lua’s presence in game engines predates most of its other deployments. The combination of fast startup, tiny footprint, easy embedding, and forgiving syntax made it the scripting language of choice for game engines in the late 1990s and early 2000s.
LÖVE2D: Indie Games in Pure Lua
LÖVE (Love2D) is a 2D game framework where the entire game logic is written in Lua. It handles the game loop, rendering, audio, and input; you write the game:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
|
-- main.lua (LÖVE2D entry point)
-- Game state
local player = {
x = 400, y = 300,
vx = 0, vy = 0,
speed = 200,
radius = 20,
color = {0.2, 0.6, 1.0},
}
local bullets = {}
local enemies = {}
function love.load()
love.window.setTitle("Space Shooter")
love.window.setMode(800, 600)
love.graphics.setBackgroundColor(0.05, 0.05, 0.1)
math.randomseed(os.time())
spawn_enemies(5)
end
function spawn_enemies(n)
for _ = 1, n do
enemies[#enemies+1] = {
x = math.random(50, 750),
y = math.random(50, 200),
radius = 15,
hp = 2,
color = {1.0, 0.3, 0.2},
}
end
end
function love.update(dt)
-- Player movement
player.vx, player.vy = 0, 0
if love.keyboard.isDown("left", "a") then player.vx = -player.speed end
if love.keyboard.isDown("right", "d") then player.vx = player.speed end
if love.keyboard.isDown("up", "w") then player.vy = -player.speed end
if love.keyboard.isDown("down", "s") then player.vy = player.speed end
player.x = math.max(player.radius,
math.min(800 - player.radius, player.x + player.vx * dt))
player.y = math.max(player.radius,
math.min(600 - player.radius, player.y + player.vy * dt))
-- Update bullets
for i = #bullets, 1, -1 do
local b = bullets[i]
b.y = b.y - 400 * dt
if b.y < 0 then
table.remove(bullets, i)
end
end
-- Collision detection (O(n*m) — fine for small counts)
for bi = #bullets, 1, -1 do
for ei = #enemies, 1, -1 do
local b, e = bullets[bi], enemies[ei]
if b and e then
local dx = b.x - e.x
local dy = b.y - e.y
if dx*dx + dy*dy < (b.radius + e.radius)^2 then
e.hp = e.hp - 1
table.remove(bullets, bi)
if e.hp <= 0 then
table.remove(enemies, ei)
end
break
end
end
end
end
if #enemies == 0 then spawn_enemies(8) end
end
function love.draw()
-- Draw player
love.graphics.setColor(player.color)
love.graphics.circle("fill", player.x, player.y, player.radius)
-- Draw bullets
love.graphics.setColor(1, 1, 0.5)
for _, b in ipairs(bullets) do
love.graphics.circle("fill", b.x, b.y, b.radius)
end
-- Draw enemies
for _, e in ipairs(enemies) do
love.graphics.setColor(e.color)
love.graphics.circle("fill", e.x, e.y, e.radius)
-- HP bar
love.graphics.setColor(0.2, 0.8, 0.2)
love.graphics.rectangle("fill",
e.x - e.radius, e.y - e.radius - 8,
(e.radius * 2) * (e.hp / 2), 4)
end
love.graphics.setColor(1, 1, 1)
love.graphics.print(string.format("Enemies: %d | Bullets: %d",
#enemies, #bullets), 10, 10)
end
function love.keypressed(key)
if key == "space" then
bullets[#bullets+1] = {
x = player.x, y = player.y - player.radius,
radius = 5,
}
end
if key == "escape" then love.event.quit() end
end
|
Luau: Roblox’s Typed Lua
Roblox developed Luau (pronounced “loo-ow”) as a typed, performance-optimized fork of Lua 5.1 specifically for their platform. Luau adds:
- Optional gradual typing with inline annotations
- Type inference
- Improved performance (their own bytecode and VM)
- String interpolation
- Generalized iteration (no need for
pairs/ipairs)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
|
-- Luau: typed Lua for Roblox
-- Type annotations
type Player = {
name: string,
health: number,
position: Vector3,
}
local function create_player(name: string, health: number): Player
return {
name = name,
health = health,
position = Vector3.new(0, 0, 0),
}
end
-- Luau generalized iteration (works on tables without pairs/ipairs)
local inventory = { sword = 1, shield = 1, potion = 5 }
for item, count in inventory do -- no pairs() needed in Luau
print(item, count)
end
-- String interpolation (Luau only, not standard Lua)
local name = "World"
local greeting = `Hello, {name}!` -- backtick syntax
print(greeting) -- Hello, World!
-- Roblox API usage pattern
local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
Players.PlayerAdded:Connect(function(player)
player.CharacterAdded:Connect(function(character)
local humanoid = character:WaitForChild("Humanoid")
humanoid.Died:Connect(function()
print(player.Name .. " has died")
end)
end)
end)
|
World of Warcraft Addon Scripting
WoW’s addon system is one of the most mature Lua embedding environments in existence. Addons have been written in Lua since WoW’s launch in 2004:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
|
-- WoW Addon: simple combat log parser
-- File: MyCombatLog/MyCombatLog.lua
local ADDON_NAME, addon = ... -- addon name and private namespace
local frame = CreateFrame("Frame")
-- Register for events
frame:RegisterEvent("COMBAT_LOG_EVENT_UNFILTERED")
frame:RegisterEvent("PLAYER_LOGIN")
local damage_totals = {}
frame:SetScript("OnEvent", function(self, event, ...)
if event == "PLAYER_LOGIN" then
print("|cff00ff00MyCombatLog|r loaded.")
elseif event == "COMBAT_LOG_EVENT_UNFILTERED" then
local timestamp, subevent, _, sourceGUID, sourceName,
_, _, destGUID, destName, _, _, amount = CombatLogGetCurrentEventInfo()
if subevent == "SPELL_DAMAGE" or subevent == "SWING_DAMAGE" then
if sourceGUID == UnitGUID("player") then
damage_totals[subevent] = (damage_totals[subevent] or 0) + (amount or 0)
end
end
end
end)
-- Slash command to report totals
SLASH_MYCOMBATLOG1 = "/mcl"
SlashCmdList["MYCOMBATLOG"] = function(msg)
if msg == "reset" then
damage_totals = {}
print("Combat log reset.")
return
end
for event, total in pairs(damage_totals) do
print(string.format("%s: %d total damage", event, total))
end
end
|
Why Game Engines Love Lua
The reasons are consistent across the industry:
Hot-reloading. Lua scripts can be reloaded at runtime without restarting the engine. Artists and designers change scripts while the game is running and see results immediately.
Moddability. Exposing a Lua interface lets users extend the game without access to the C++ source. Factorio’s entire item/recipe/entity system is Lua tables. WoW’s addon API is how addons like WeakAuras exist.
Designer-friendly. Game designers who are not C++ programmers can write AI behaviors, quest scripts, and UI logic in Lua. The syntax is forgiving and the error messages (via pcall) are recoverable.
Minimal footprint. Adding a full scripting language to a game engine adds under 300KB to the binary. In console game development where binary size matters, this is significant.
LuaRocks
LuaRocks is Lua’s package manager. It manages packages called “rocks,” which can be pure Lua or include C extensions compiled for the local platform:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
|
# Install
luarocks install luasocket # TCP/UDP sockets
luarocks install inspect # pretty-print tables (debugging)
luarocks install penlight # comprehensive utility library
luarocks install busted # testing framework
luarocks install luacheck # static analyzer
# Install to local tree (project-scoped, like npm --prefix)
luarocks install --tree ./lua_modules luasocket
# List installed
luarocks list
# Search
luarocks search json
# Show info
luarocks show luasocket
|
The Module System
Lua’s require is the module loader. It searches package.path for .lua files and package.cpath for C extension shared libraries:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
|
-- require searches package.path (semicolon-separated, ? is the module name)
-- Default: ./?.lua;/usr/share/lua/5.4/?.lua;etc.
-- Module file: myapp/utils.lua
local M = {} -- convention: M is the module table
function M.trim(s)
return s:match("^%s*(.-)%s*$")
end
function M.split(s, sep)
sep = sep or "%s"
local parts = {}
for part in s:gmatch("[^" .. sep .. "]+") do
parts[#parts+1] = part
end
return parts
end
-- Private functions (not in M, not exported)
local function internal_helper(x)
return x * 2
end
function M.double(x)
return internal_helper(x)
end
return M
-- Usage in another file:
local utils = require("myapp.utils") -- maps to myapp/utils.lua
print(utils.trim(" hello ")) -- "hello"
print(utils.split("a,b,c", ",")[2]) -- "b"
|
require caches modules in package.loaded. Calling require("foo") twice returns the same table. To reload (e.g., during development):
1
2
|
package.loaded["myapp.utils"] = nil -- invalidate cache
local utils = require("myapp.utils") -- re-execute module file
|
lua-language-server (sumneko LSP)
The lua-language-server (formerly by sumneko, now the official LuaLS) is the de facto LSP for Lua. It provides completion, diagnostics, go-to-definition, and type checking via EmmyLua annotations:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
|
-- EmmyLua annotations for type checking and documentation
---@class Config
---@field host string The database host
---@field port integer The database port (default: 5432)
---@field max_connections integer Maximum connection pool size
local Config = {}
---Create a new Config with defaults
---@param opts? table Optional partial config
---@return Config
function Config.new(opts)
opts = opts or {}
return setmetatable({
host = opts.host or "localhost",
port = opts.port or 5432,
max_connections = opts.max_connections or 10,
}, { __index = Config })
end
---@param key string The config key to get
---@return any
function Config:get(key)
return self[key]
end
-- Generic type annotations
---@generic T
---@param list T[]
---@param fn fun(item: T): boolean
---@return T[]
local function filter(list, fn)
local result = {}
for _, v in ipairs(list) do
if fn(v) then result[#result+1] = v end
end
return result
end
|
Configure .luarc.json in your project root:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
{
"runtime": {
"version": "LuaJIT"
},
"diagnostics": {
"globals": ["ngx", "redis", "vim"]
},
"workspace": {
"library": [
"/usr/share/lua/5.1",
"${3rd}/luv/library"
]
}
}
|
busted: Testing Framework
busted is the standard Lua testing framework, BDD-style:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
|
-- spec/vector_spec.lua
local Vector = require("vector")
describe("Vector", function()
describe("creation", function()
it("creates a vector with x and y", function()
local v = Vector.new(3, 4)
assert.equal(3, v.x)
assert.equal(4, v.y)
end)
end)
describe("arithmetic", function()
local v1 = Vector.new(1, 2)
local v2 = Vector.new(3, 4)
it("adds two vectors", function()
local result = v1 + v2
assert.equal(4, result.x)
assert.equal(6, result.y)
end)
it("computes magnitude via # operator", function()
local v = Vector.new(3, 4)
assert.are.near(5.0, #v, 0.001)
end)
it("supports unary minus", function()
local v = -Vector.new(1, 2)
assert.equal(-1, v.x)
assert.equal(-2, v.y)
end)
end)
describe("equality", function()
it("considers vectors equal when components match", function()
assert.equal(Vector.new(1, 2), Vector.new(1, 2))
assert.not_equal(Vector.new(1, 2), Vector.new(1, 3))
end)
end)
end)
|
1
2
3
4
5
6
7
8
|
# Run tests
busted spec/
# With coverage
busted --coverage spec/
# Filter by pattern
busted --filter "arithmetic" spec/
|
luacheck: Static Analysis
1
|
luacheck *.lua --globals vim ngx redis --max-line-length 100
|
.luacheckrc configuration:
1
2
3
4
5
|
-- .luacheckrc
std = "lua54"
max_line_length = 120
globals = { "vim", "ngx", "redis", "KEYS", "ARGV" }
ignore = { "211", "212" } -- unused variable/argument warnings
|
12. Honest Assessment
Lua is not a general-purpose language trying to be everything. Understanding where it belongs — and where it doesn’t — is the key to using it well.
Where Lua Wins
Embedding in C/C++ applications. This is Lua’s founding use case and it remains unmatched. The C API is small, clean, and eliminates most memory safety issues at the boundary. You can embed Lua in an iOS app, a router firmware, a database engine, or a game without adding meaningfully to your binary size or startup time. No other language offers this combination of capabilities.
High-performance scripting in OpenResty. Running Lua inside nginx’s event loop with LuaJIT, cosockets, and shared memory gives you a request handling environment that can process hundreds of thousands of requests per second per core while remaining programmable at the application level. Python/Ruby/Node in the same role require a separate process pool and pay IPC costs on every request. This is not a close comparison.
Game scripting and modding. Thirty years of industry convergence on Lua for this use case is not an accident. The combination of hot reloading, safe error recovery via pcall, first-class functions for event callbacks, and tiny footprint hits the exact requirements of game scripting.
Redis automation. Atomic multi-step Redis operations via EVAL/Functions are genuinely useful and Lua’s simplicity makes the scripts readable even to engineers who don’t know Lua.
Neovim configuration. If you use Neovim, writing your config and plugins in Lua rather than Vimscript gives you better tooling (LSP, debugging), better performance, and a real programming language for complex logic.
Where Lua Loses
Standard library coverage. The Lua standard library is intentionally minimal. There is no JSON in the standard library. No HTTP client. No datetime manipulation beyond os.time(). No cryptography. No regex (Lua patterns are not PCRE). Every real project immediately needs LuaRocks dependencies, and that’s where fragmentation starts.
The async story. In base Lua (outside LuaJIT FFI or specific environments like OpenResty), there is no standard non-blocking I/O story. You can write coroutine schedulers (copas, luv), but there is no standard event loop. Python’s asyncio, JavaScript’s event loop, and Go’s goroutines all have a clear standard answer for async code. Lua does not, unless you’re inside a host application that provides it (OpenResty, Neovim’s libuv).
LuaRocks fragmentation. LuaRocks has namespace collisions, incompatible versions between Lua 5.1/5.2/5.3/5.4, and a much smaller ecosystem than npm/PyPI. Many important libraries only support specific Lua versions. LuaJIT-specific libraries won’t work on PUC Lua. Finding maintained, well-tested libraries for less common domains is genuinely difficult.
The version fragmentation. Lua 5.1 (LuaJIT), 5.2, 5.3, 5.4 have incompatible semantics in several areas (table.pack, integer division, bitwise operators, goto, <close> variables). Code written for 5.4 won’t necessarily run on LuaJIT without changes. This is a real friction point when consuming third-party code.
Error messages. Lua’s runtime errors are often terse and lose context when you’re inside a coroutine or callback chain. The debug library helps, but the default error propagation story is worse than Python’s tracebacks or Go’s goroutine dumps.
Lua vs Alternatives for Embedding
vs Tcl: Tcl was the original embeddable scripting language. It has a simpler C API in some ways but a bizarre “everything is a string” type system that makes complex data manipulation painful. Lua replaced Tcl in most new embedding use cases by 2000. Tcl is still strong in EDA tooling (Vivado, Cadence) but rarely chosen for new projects.
vs Python (via embedded CPython or PyPy): Embedding CPython is theoretically possible but painful in practice. The GIL, complex initialization, large runtime size, and reference-counted C API make it poor for embedding. Python’s advantage is ecosystem. If your use case needs numpy/scipy/machine learning libraries from embedded scripts, Python has no competition. If you just need application logic scripting, Lua is cleaner.
vs JavaScript (V8, QuickJS, Duktape): V8 is enormous (tens of MB), powerful, and has the full web ecosystem. QuickJS and Duktape are small, embeddable JS runtimes. JavaScript has much better async tooling and a vastly larger ecosystem. The trade-off: Lua’s C API is cleaner and the language footprint is genuinely smaller. For constrained environments (IoT, embedded systems, router firmware) Lua still wins. For anything web-adjacent, JavaScript embeddings are increasingly competitive.
vs Wren, Squirrel, AngelScript: These are Lua alternatives targeting specifically the game engine scripting market. Wren has a cleaner class-based OOP syntax. Squirrel is syntactically closer to C++ (curly braces, 0-indexed). None of them approach Lua’s deployment scale or tooling maturity.
The Verdict
If you’re writing an application that needs user-extensible scripting — a game engine, a WAF, an API gateway, a test harness, a network device OS — Lua is likely the right choice. Its constraints are features in that context: small, fast, embeddable, no large runtime dependencies.
If you’re writing a standalone application and considering Lua because you like the language, reconsider. The stdlib gaps, LuaRocks fragmentation, and version incompatibilities are real costs. Python or Go serve standalone application development better.
Use Lua where it was designed to be used: inside something else.
Further Reading
Comments