LUNAROPS · OPERATIONAL UPLINK 100% UPTIME 1,247d POSTS 893 JEFF.MOON@LUNAROPS.DEV UTC --:--:--

Lua: The Embeddable Language

lualuajitopenrestyneovimgame-devembeddablescripting

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

3. Metatables and Metamethods

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

__tostring, __len, Arithmetic Metamethods

 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 Performance Characteristics

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.


11. Package Management and Tooling

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