Lua standards (reference: August 2026)
Criteria verified as of August 2026. Re-verify on the web before committing to anything (§8).
1. Scope and triggers
Lua is almost never used on its own: it is used embedded, and the host decides almost everything —
interpreter version, available libraries, concurrency model, what can be called and what kills your
process. The first question in any Lua work is not "which version of the language" but "who is the
host". This skill is structured by host.
Triggers: .lua, .tl, .rockspec, .luacheckrc, selene.toml, stylua.toml, init.lua,
*_spec.lua (busted), content_by_lua_block/access_by_lua_file/ngx.*, vim.api/vim.uv,
EVAL/EVALSHA/FUNCTION LOAD, luarocks, ---@ annotations.
Not applicable: see caching-cdn-standards and networking-standards (nginx/OpenResty as a
platform: configuration, TLS, upstreams, cache, limits — the Lua that runs inside is ours),
data-platform-standards and nosql-standards (Redis/Valkey as an engine: memory, persistence,
eviction, clustering — the EVAL script is ours), homelab-standards (self-hosting the stack),
appsec-standards (methodology and vulnerability classes; here only Lua's specific sandboxing),
c-standards (Lua's C API, lua_State, native extensions and their memory are hers; here only
the boundary as seen from Lua), perl-standards and groovy-standards (nothing in common beyond
"it is a dynamic language"), secrets-management-standards, vulnerability-management-standards,
observability-standards, sql-standards.
2. Default decisions: version and toolchain
Verify the latest version on the web before committing to it in a real project (§8).
The ecosystem's fracture is real and is not resolved by choosing "the latest": the host imposes
the version and many LuaRocks libraries only work on one branch.
| Branch |
Status verified as of Aug 2026 |
When it is your target |
| Lua 5.1 |
End of line (5.1.5, Feb 2012). Still the most widely deployed version via LuaJIT |
Only because the host imposes it (LuaJIT, Redis, OpenResty) |
| Lua 5.2 / 5.3 |
No active maintenance; 5.3.6 is from Sep 2020 |
Never in a new project |
| Lua 5.4 |
5.4.8 (4 Jun 2025), the latest of the branch |
Default for standalone Lua if the host does not dictate |
| Lua 5.5 |
5.5.0 published on 22 Dec 2025 |
Greenfield standalone; verify your rocks' support first |
| LuaJIT |
Active: a rolling release model on the v2.1 branch, with commits in Aug 2026. No tarballs and no release tags; the version is 2.1.<commit timestamp> |
When performance or the host dictates (OpenResty, Neovim) |
LuaJIT is supportable, with explicit conditions: it is compatible with Lua 5.1 plus a subset
of 5.2/5.3 extensions — it is not "modern Lua". A new project on LuaJIT accepts writing
5.1 for ever. Upstream operational rules that must be respected: follow the git v2.1 branch
(not master, not the v2.1.ROLLING tag, not 2.1.0-beta3), do not use third-party tarballs nor
GitHub's automatic tarball (without .git it does not compile the correct version), and if the
build needs "a release", take dated snapshots of the branch.
| Component |
Choice |
Verified |
Licence (raw LICENSE) |
| Formatter |
StyLua |
v2.5.2 (May 2026) |
MPL-2.0 (not MIT) |
| Linter |
selene |
0.31.0 (May 2026), active development |
MPL-2.0 (not MIT) |
| Linter (alternative) |
luacheck (lunarmodules/luacheck) |
v1.2.0, May 2024 — no releases since |
MIT |
| Types |
lua-language-server (LuaLS) + ---@ annotations |
3.18.2 (Apr 2026) |
MIT |
| Real typing |
Teal (tl) |
v0.24.8 (Oct 2025) |
MIT |
| Tests |
busted |
v2.3.0 (Jan 2026) |
MIT |
| Packages |
LuaRocks |
3.13.x |
MIT |
Criterion: selene by default in a new project (active, fast, selene.toml with the standard
library declared per host: lua51, lua54, roblox or one of your own). luacheck is still valid
where it is already in place, but its lack of releases since 2024 is a declared risk. A committed
stylua.toml and stylua --check as a gate.
Typing: in non-trivial new code, either Teal (compiles to Lua, real types; only if you
control the host's build) or plain Lua with LuaLS ---@ annotations verified in CI
(lua-language-server --check). Without one of the two, a refactor in Lua is done blind.
LuaRocks is the fragile point of the stack: it resolves transitive versions poorly, many rocks do
not declare branch compatibility (5.1 vs 5.4) and those that carry C need the toolchain and headers
of the specific interpreter. Rule: a local rock tree per project (luarocks --tree ./.rocks),
a .rockspec with dependencies bounded by version, and for OpenResty/Neovim vendor the
dependency into the repo rather than depending on LuaRocks in the production runtime.
3. The language: what actually breaks
- Variables are global by default. It is Lua's biggest operational design flaw. A typo
creates a silent global; in a long-lived host (an nginx worker, a game server) that is a
memory leak and state contamination between requests.
local always, without exception.
Enable detection: luacheck/selene flag undeclared globals, and in hosts that allow it, load
a strict module that errors on reading/writing an undeclared global.
- The table is the only structure: array, hash, object, module and namespace. There is nothing
else. 1-indexed.
#t and ipairs are only reliable with no holes; with a nil in the middle
the result is undefined — for sparse collections, pairs and an explicit counter.
- Metatables and
__index: inheritance is a chain of __index; use it sparingly, explicitly and
flat (each level is an indirection on the hot path). Document every metatable that is not __index
— __gc, __close (5.4+) and __newindex are powerful and opaque.
- Errors are values, and
pcall is the mechanism: error() unwinds to the nearest
pcall. Convention: library functions return nil, err (checkable) and reserve
error() for programmer contract violations. xpcall with debug.traceback to
keep the trace — with pcall you lose it. A pcall whose error is discarded without a log is a
veto.
nil vs false: only nil and false are falsy; 0 and "" are truthy. Distinguish
"absent" (nil) from "present and false" (false) in every API returning flags. Arithmetic
coercion ("10" + 1) hides bugs: convert with tonumber and check for nil.
- In 5.3+ there are separate integers and floats (
3/2 is a float, // integer); in LuaJIT/5.1
everything is a double. The same code on both, doing index or money arithmetic, behaves
differently: pin it down with tests.
- Closures: cheap and the natural idiom for callbacks; careful with capturing
self or big
tables in long-lived closures (they retain memory).
- GC: incremental by default; 5.4 adds a generational mode
(
collectgarbage("generational")), which usually wins with a lot of young garbage. Do not touch
its parameters without measuring pauses before and after.
4. Criteria per host
OpenResty / nginx (lua-nginx-module)
- Hard prohibition: no blocking call in the event loop. An nginx worker serves
thousands of connections in one thread; one blocking call freezes them all. Vetoed in request
code:
os.execute, io.* over files, LuaSocket's socket.*, any library with synchronous
I/O, ngx.sleep in a busy-wait loop, and C libraries that block. Use cosockets
(ngx.socket.tcp), ngx.timer.at, resty.http, lua-resty-redis, lua-resty-mysql.
- Each request runs in a lightweight coroutine managed by the module:
ngx.thread.spawn /
ngx.thread.wait to parallelise subrequests; the cosocket object is not shared between
requests nor between coroutines.
- Life cycle phases, each with what it may do:
init_by_lua (master startup:
preloading modules and immutable data), init_worker_by_lua (timers and per-worker state),
set_by_lua (it blocks, trivial computation only), rewrite/access_by_lua (auth, routing),
content_by_lua, header_filter/body_filter_by_lua (no I/O), log_by_lua (no blocking
cosockets: use a buffer + timer). Choosing the wrong phase is the most common bug.
ngx.shared.DICT is the only state shared between workers: a fixed size declared in
nginx.conf, values only scalars/strings, and it may evict by LRU when it fills up — check
the second return value of :set() (err == "no memory") and handle the failure. It is not a
database and does not replace Redis; it is a process cache with a global lock per operation.
lua_code_cache on in production always; off only in development (it recompiles every
request).
- A
required module is cached per worker: module-level state persists between
requests. Nothing mutable per request at module level — it is the classic source of data leaks
between users.
- Prefer
*_by_lua_file over *_by_lua_block as soon as the code goes beyond a few lines: Lua
embedded in nginx.conf is not linted, not tested and not reviewed well.
Neovim
- The interpreter is LuaJIT: you write Lua 5.1 with extensions. Do not assume
goto, integer
division nor 5.4 APIs.
init.lua as the single entry point; configuration split into lua/<user>/*.lua loaded with
require. No logic in init.lua beyond bootstrapping the plugin manager.
- API:
vim.api.nvim_* (stable and typed API) by default; vim.fn.* only for Vimscript
functions with no equivalent; vim.opt/vim.o for options. vim.loop is deprecated: use
vim.uv (the same libuv binding). Cache the handle (local uv = vim.uv) on hot paths.
- Plugin structure:
lua/<plugin>/init.lua with an idempotent M.setup(opts),
plugin/<plugin>.lua only for what must run on load, doc/ with :help. No heavy work at the
top level of the module: that runs on require and is paid for in startup time.
- Manager: lazy.nvim, with
lazy-lock.json committed (it is the lockfile: without it your
config is not reproducible). Lazy loading by event/ft/cmd/keys, not lazy = false out of
convenience.
- Autocommands always in their own
augroup with clear = true — otherwise they duplicate on
reload.
- Do not block the UI: I/O with
vim.uv async or vim.system(); vim.schedule to get back to the
main thread from a callback. A synchronous vim.fn.system() in an autocommand is a frozen editor.
Redis / Valkey (EVAL / EVALSHA / Functions)
- The interpreter is Lua 5.1 with a sandbox:
os, io and system access do not exist.
- The script is atomic and blocks the whole server while it runs. Operational corollary:
scripts are short and bounded; no loops over collections of unbounded size, no
KEYS *,
no O(n) over large structures. A slow script is a global latency outage.
- Determinism: historical and still correct criteria. Today replication is by effects (write
commands are replicated, not the script) — the default since Redis 5.0 and verbatim replication
is no longer supported since Redis 7.0, which relaxes the engine's restriction. The
engineering rule does not relax: do not generate randomness nor read the time inside the script.
Pass the timestamp and any random value as an argument (
ARGV) from the client: it is
reproducible, testable and auditable. If you need the server's time, redis.call('TIME'), never
a Lua source.
- Every key accessed goes in
KEYS, never built inside the script: it is the contract
that makes it work in a cluster (all keys must fall in the same slot; use hash tags).
local on every variable: polluting Lua's global state breaks the server's consistency.
- Deployment:
SCRIPT LOAD + EVALSHA with a fallback to EVAL on NOSCRIPT (the script cache
is lost on restart and is not replicated reliably). For stable and versioned logic, Redis
Functions (FUNCTION LOAD) is preferable to a loose EVAL: it is persisted and replicated.
- Scripts are code versioned in the repo, not strings pasted into the application code.
Games and modding
- Lua embedded in an engine with the explicit purpose of letting third parties write code: that
is §5 and it is not negotiable. Mandatory sandbox, a minimal and audited API surface, and
CPU/memory budgets.
- The mod's state lives in the host, not in Lua globals; expose functions, not mutable tables of the
engine. Careful with
__gc and with retaining references to native objects: it is the usual route
to use-after-free across the C boundary.
Other hosts (bounded)
Wireshark (dissectors in Lua: analysis code that runs over untrusted traffic — treat it
as a hostile parser), HAProxy (lua-load, the same blocking veto as OpenResty over its event
loop), Kong (plugins on OpenResty: apply the OpenResty section as is, plus Kong's plugin life
cycle). For each one, the rule is identical: read which Lua version it embeds, which
libraries it exposes and which operations block its loop before writing a line.
5. Security: running user Lua is running code
Starting point, with no qualifications: if you load Lua you did not write yourself, you are
executing arbitrary code inside your process. The sandbox reduces the damage; it does not
eliminate it.
- Loading surface:
load / loadstring / loadfile / dofile / require over untrusted
content are the sink. Use load(chunk, name, "t", env) — the "t" mode (text only) is
mandatory: accepting bytecode ("b") is fatal, because Lua's bytecode verifier is not
robust and a malicious precompiled chunk corrupts the interpreter's memory and escapes the
sandbox without needing any dangerous function.
- Environment: in 5.2+ pass an explicit
_ENV as the fourth argument to load; in 5.1/LuaJIT,
setfenv on the loaded function. The environment is an allowlist built from scratch, never
a copy of _G with things deleted.
- Out of the environment, always:
os (execute, getenv, remove, rename, exit,
tmpname), the whole of io, package and require (it allows loading any .so), the
entire debug (debug breaks any sandbox: getupvalue/setupvalue/getregistry give
access to the host's state), load/loadstring/loadfile/dofile, collectgarbage,
rawset/rawget over host tables, and string.dump. From os, at most
os.time/os.clock/os.date if determinism does not matter.
- Careful with indirect references:
("").format reaches the string metatable, which is
global and shared; if the sandbox can modify it, it contaminates the host. Protect the string
metatable (debug.setmetatable in the host, __metatable to block getmetatable) and freeze the
environment's tables with __newindex = function() error(...) end.
- A sandbox in pure Lua is never enough, because it does not bound resources:
- CPU:
while true do end hangs the process. Mitigation: an instruction-counting hook
(debug.sethook(co, fn, "", N)) that aborts on exceeding the budget — installed from the
host, over a coroutine, and knowing that debug itself must not be exposed to the guest.
It is a partial mitigation: there are operations (string patterns, huge concatenations) that
burn a lot of time in few instructions.
- Memory: a table that grows without limit exhausts the host's RAM. Real mitigation: an
allocator with a limit (
lua_newstate with your own allocator that fails on exceeding the
quota) — done in C, not in Lua.
- Patterns:
string.find/gsub with user patterns over large inputs are a DoS
(backtracking). Do not accept user patterns; if you must, bound the length of the pattern and
the subject.
- Real isolation: for genuinely untrusted code, the control that cuts is the
process: an interpreter in a separate process, unprivileged, with
rlimit on CPU/memory/files,
seccomp and no network, and communication over bounded IPC. The in-process sandbox is defence in
depth, not the boundary.
- Classic injection: never build Lua code by concatenating input (
load("return "..x)) — it is
eval by another name. Never build SQL, commands or paths by concatenation from Lua.
- Secrets: outside the code and outside
nginx.conf; in OpenResty they come in via the
environment (env + os.getenv in init_by_lua) or via a fetch at startup, and never into
ngx.shared.DICT nor into logs. Careful with dumping context tables into error logs: they carry
tokens.
- SCA: LuaRocks has no auditing ecosystem comparable to npm/PyPI. Practical consequence:
minimise dependencies, pin exact versions in the
.rockspec, and review the code of rocks
with C extensions that go into production.
6. Performance and operability
- Profile before optimising; in LuaJIT, first check that your hot path compiles (
-jv,
-jdump): a bail-out to the interpreter due to an NYI construct costs more than any
micro-tuning.
- Real and measurable cost: concatenation in a loop is O(n²) (accumulate in a table and
table.concat); a global is a hash lookup (cache local ngx = ngx, local fmt = string.format
in the module).
- Explicit timeouts on all I/O:
settimeout/set_timeouts on every cosocket; the default in
many libraries is too high or infinite. Retries with backoff only on idempotent operations.
- Observability: structured logs with a correlation id (in OpenResty,
ngx.var.request_id);
per-worker metrics via ngx.shared.DICT on an internal endpoint. An error that only appears in
error.log with no level and no context is an invisible incident.
- Watch
collectgarbage("count") as a metric: in long-lived hosts, monotonic growth is the
symptom of accumulated globals or retained closures. In OpenResty, changing the Lua requires a
reload: design startup to be cheap and idempotent.
7. Sustainability and prohibitions
- Cadence: LuaJIT is updated by following the
v2.1 branch with dated snapshots and a review of
the changes, not once a year. Lua 5.4/5.5 are updated by patch without drama; the branch jump
(5.1→5.4, 5.4→5.5) is a project, not a version bump.
- Every dependency with no release in >18 months is reviewed (this applies today to
luacheck);
every C extension is audited before it goes in.
- Migrate from LuaJIT to Lua 5.4 only with a reason (you genuinely need 5.4) and with measurement:
you lose the JIT and the FFI, and that can be an order of magnitude on the hot path.
List of prohibitions (veto):
- ❌ Implicit global variables.
local or a written justification in the code.
- ❌ FORBIDDEN any blocking call in the OpenResty/HAProxy event loop
(
os.execute, io.*, LuaSocket, synchronous C libraries). It is an outage, not a code smell.
- ❌ FORBIDDEN
load/loadstring with a mode that accepts bytecode ("b"/"bt") over
untrusted input. Only "t".
- ❌ FORBIDDEN to expose
debug, package, require, os or io to untrusted code; and
FORBIDDEN to treat a pure-Lua sandbox as a security boundary without CPU/memory limits
imposed from the host.
- ❌ Building Lua code, SQL or commands by concatenating input.
- ❌ Randomness or reading the clock inside a Redis/Valkey script; keys not declared in
KEYS; scripts with unbounded loops.
- ❌ A
pcall whose error is discarded with no log and no propagation.
- ❌ Per-request mutable state at module level in OpenResty; globals that persist between requests.
- ❌ Relying on
#t/ipairs over tables with holes.
- ❌ Publishing/deploying without
lazy-lock.json (Neovim) or without pinned versions in the
.rockspec.
- ❌ Lua embedded in
nginx.conf beyond a few lines (use *_by_lua_file).
- ❌
lua_code_cache off in production.
- ❌ Third-party LuaJIT tarballs or GitHub's automatic tarball; the
v2.1.ROLLING tag as a pin.
- ❌ Choosing Lua for new code that is not embedded in a host that requires it. Lua shines as an
extension language inside somebody else's process; as a standalone application language, its
ecosystem (packages, auditing, types, minimal standard library) is a cost that almost never pays
off against Python or Go.
8. Mandatory web verification
Before committing to versions or APIs, verify online (WebSearch/WebFetch, and the Atom feeds
https://github.com/OWNER/REPO/releases.atom — api.github.com returns 403 unauthenticated):
- LuaJIT: activity on the
v2.1 branch (recent commits) and the project's status at
luajit.org/status.html.
It is the datum that decides whether a new project can rest on it. There are no release tags: do
not look for one.
- Lua:
lua.org/news.html and lua.org/versions.html — the latest of 5.4, the status and
adoption of 5.5.0 (published on 22 Dec 2025), and whether 5.5.1 or 5.4.9 have come out.
- Versions and raw licences of StyLua and selene (both MPL-2.0, not MIT), LuaLS, Teal,
busted and LuaRocks; and whether
luacheck has published a release again.
- The version of OpenResty and of
lua-nginx-module (as of Aug 2026 the 0.10.32 branch was in
release candidate: do not pin an rc in production without checking whether there is already a
final).
- Neovim: stable version (0.12.x as of Aug 2026) and the deprecation list (
:help deprecated)
before writing a plugin — vim.loop→vim.uv is not the only one.
- Redis/Valkey: the Lua version embedded in the specific version you deploy and the state of
Functions vs
EVAL; the replication rules changed in 5.0 and 7.0.
- CVEs of the interpreter and of the C extensions you use (osv.dev / GitHub Advisories).
Declared discrepancy: LuaRocks' releases Atom feed shows a feed updated
(2025-12-28) earlier than that of its most recent entry (LuaRocks 3.13.0, updated 2026-01-28).
Version 3.13.0 is taken as good; its publication date is not verified — confirm it at
luarocks.org before citing it.
If the web contradicts this document, the web wins — flag the discrepancy.
1---2name: lua-standards3description: Use when writing or reviewing Lua code - .lua files, .luacheckrc, selene.toml, stylua.toml, .rockspec and luarocks, busted spec files, LuaJIT vs Lua 5.1/5.4/5.5 targets, OpenResty content_by_lua_block/access_by_lua_file/ngx.shared.DICT/lua-nginx-module, Neovim init.lua and vim.api/vim.uv plugins with lazy.nvim, Redis or Valkey EVAL/EVALSHA/FUNCTION scripts, Teal .tl files, lua-language-server ---@ annotations, or sandboxing untrusted Lua with load/setfenv/_ENV.4---56# Lua standards (reference: August 2026)78Criteria verified as of **August 2026**. Re-verify on the web before committing to anything (§8).910## 1. Scope and triggers1112Lua is almost never used on its own: **it is used embedded, and the host decides almost everything** —13interpreter version, available libraries, concurrency model, what can be called and what kills your14process. The first question in any Lua work is not "which version of the language" but **"who is the15host"**. This skill is structured by host.1617Triggers: `.lua`, `.tl`, `.rockspec`, `.luacheckrc`, `selene.toml`, `stylua.toml`, `init.lua`,18`*_spec.lua` (busted), `content_by_lua_block`/`access_by_lua_file`/`ngx.*`, `vim.api`/`vim.uv`,19`EVAL`/`EVALSHA`/`FUNCTION LOAD`, `luarocks`, `---@` annotations.2021**Not applicable**: see `caching-cdn-standards` and `networking-standards` (nginx/OpenResty **as a22platform**: configuration, TLS, upstreams, cache, limits — **the Lua that runs inside is ours**),23`data-platform-standards` and `nosql-standards` (Redis/Valkey **as an engine**: memory, persistence,24eviction, clustering — **the `EVAL` script is ours**), `homelab-standards` (self-hosting the stack),25`appsec-standards` (methodology and vulnerability classes; here only Lua's specific sandboxing),26`c-standards` (**Lua's C API, `lua_State`, native extensions and their memory are hers**; here only27the boundary as seen from Lua), `perl-standards` and `groovy-standards` (nothing in common beyond28"it is a dynamic language"), `secrets-management-standards`, `vulnerability-management-standards`,29`observability-standards`, `sql-standards`.3031## 2. Default decisions: version and toolchain3233> Verify the latest version on the web before committing to it in a real project (§8).3435**The ecosystem's fracture is real and is not resolved by choosing "the latest"**: the host imposes36the version and many LuaRocks libraries only work on one branch.3738| Branch | Status verified as of Aug 2026 | When it is your target |39|---|---|---|40| **Lua 5.1** | End of line (5.1.5, Feb 2012). Still **the most widely deployed version** via LuaJIT | Only because the host imposes it (LuaJIT, Redis, OpenResty) |41| Lua 5.2 / 5.3 | No active maintenance; 5.3.6 is from Sep 2020 | Never in a new project |42| **Lua 5.4** | 5.4.8 (4 Jun 2025), the latest of the branch | Default for **standalone** Lua if the host does not dictate |43| **Lua 5.5** | **5.5.0 published on 22 Dec 2025** | Greenfield standalone; verify your rocks' support first |44| **LuaJIT** | **Active**: a *rolling release* model on the `v2.1` branch, with commits in Aug 2026. No tarballs and no release tags; the version is `2.1.<commit timestamp>` | When performance or the host dictates (OpenResty, Neovim) |4546**LuaJIT is supportable**, with explicit conditions: it is **compatible with Lua 5.1** plus a subset47of 5.2/5.3 extensions — it is **not** "modern Lua". A new project on LuaJIT accepts writing485.1 for ever. Upstream operational rules that must be respected: follow the git `v2.1` branch49(not `master`, not the `v2.1.ROLLING` tag, not `2.1.0-beta3`), **do not use third-party tarballs nor50GitHub's automatic tarball** (without `.git` it does not compile the correct version), and if the51build needs "a release", take dated snapshots of the branch.5253| Component | Choice | Verified | Licence (raw LICENSE) |54|---|---|---|---|55| Formatter | **StyLua** | v2.5.2 (May 2026) | **MPL-2.0** (not MIT) |56| Linter | **selene** | 0.31.0 (May 2026), active development | **MPL-2.0** (not MIT) |57| Linter (alternative) | `luacheck` (`lunarmodules/luacheck`) | v1.2.0, May 2024 — **no releases since** | MIT |58| Types | **lua-language-server** (LuaLS) + `---@` annotations | 3.18.2 (Apr 2026) | MIT |59| Real typing | **Teal** (`tl`) | v0.24.8 (Oct 2025) | MIT |60| Tests | **busted** | v2.3.0 (Jan 2026) | MIT |61| Packages | **LuaRocks** | 3.13.x | MIT |6263Criterion: **selene by default** in a new project (active, fast, `selene.toml` with the *standard64library* declared per host: `lua51`, `lua54`, `roblox` or one of your own). `luacheck` is still valid65where it is already in place, but **its lack of releases since 2024 is a declared risk**. A committed66`stylua.toml` and `stylua --check` as a gate.6768**Typing**: in non-trivial new code, either **Teal** (compiles to Lua, real types; only if you69control the host's build) or plain Lua **with LuaLS `---@` annotations** verified in CI70(`lua-language-server --check`). Without one of the two, a refactor in Lua is done blind.7172**LuaRocks is the fragile point of the stack**: it resolves transitive versions poorly, many rocks do73not declare branch compatibility (5.1 vs 5.4) and those that carry C need the toolchain and headers74of the specific interpreter. Rule: **a local rock tree per project** (`luarocks --tree ./.rocks`),75a `.rockspec` with `dependencies` bounded by version, and for OpenResty/Neovim **vendor** the76dependency into the repo rather than depending on LuaRocks in the production runtime.7778## 3. The language: what actually breaks7980- **Variables are global by default. It is Lua's biggest operational design flaw.** A typo81 creates a silent global; in a long-lived host (an nginx worker, a game server) that is a82 memory leak and state contamination between requests. **`local` always**, without exception.83 Enable detection: `luacheck`/`selene` flag undeclared globals, and in hosts that allow it, load84 a `strict` module that *errors* on reading/writing an undeclared global.85- **The table is the only structure**: array, hash, object, module and namespace. There is nothing86 else. 1-indexed. `#t` and `ipairs` **are only reliable with no holes**; with a `nil` in the middle87 the result is undefined — for sparse collections, `pairs` and an explicit counter.88- **Metatables and `__index`**: inheritance is a chain of `__index`; use it sparingly, explicitly and89 flat (each level is an indirection on the hot path). Document every metatable that is not `__index`90 — `__gc`, `__close` (5.4+) and `__newindex` are powerful and opaque.91- **Errors are values, and `pcall` is the mechanism**: `error()` unwinds to the nearest92 `pcall`. Convention: library functions return `nil, err` (checkable) and reserve93 `error()` for programmer contract violations. `xpcall` with `debug.traceback` to94 keep the trace — with `pcall` you lose it. **A `pcall` whose error is discarded without a log is a95 veto.**96- **`nil` vs `false`**: only `nil` and `false` are falsy; **`0` and `""` are truthy**. Distinguish97 "absent" (`nil`) from "present and false" (`false`) in every API returning flags. Arithmetic98 coercion (`"10" + 1`) hides bugs: convert with `tonumber` and check for `nil`.99- In 5.3+ there are **separate integers and floats** (`3/2` is a float, `//` integer); in LuaJIT/5.1100 everything is a double. The same code on both, doing index or money arithmetic, **behaves101 differently**: pin it down with tests.102- **Closures**: cheap and the natural idiom for callbacks; careful with capturing `self` or big103 tables in long-lived closures (they retain memory).104- **GC**: incremental by default; **5.4 adds a generational mode**105 (`collectgarbage("generational")`), which usually wins with a lot of young garbage. Do not touch106 its parameters without measuring pauses before and after.107108## 4. Criteria per host109110### OpenResty / nginx (`lua-nginx-module`)111- **Hard prohibition: no blocking call in the event loop.** An nginx worker serves112 thousands of connections in one thread; one blocking call freezes them all. Vetoed in request113 code: `os.execute`, `io.*` over files, LuaSocket's `socket.*`, any library with synchronous114 I/O, `ngx.sleep` in a busy-wait loop, and C libraries that block. Use **cosockets**115 (`ngx.socket.tcp`), `ngx.timer.at`, `resty.http`, `lua-resty-redis`, `lua-resty-mysql`.116- Each request runs in a **lightweight coroutine** managed by the module: `ngx.thread.spawn` /117 `ngx.thread.wait` to parallelise subrequests; the cosocket object **is not shared between118 requests nor between coroutines**.119- **Life cycle phases**, each with what it may do: `init_by_lua` (master startup:120 preloading modules and immutable data), `init_worker_by_lua` (timers and per-worker state),121 `set_by_lua` (**it blocks, trivial computation only**), `rewrite`/`access_by_lua` (auth, routing),122 `content_by_lua`, `header_filter`/`body_filter_by_lua` (**no I/O**), `log_by_lua` (**no blocking123 cosockets**: use a buffer + timer). Choosing the wrong phase is the most common bug.124- **`ngx.shared.DICT` is the only state shared between workers**: a fixed size declared in125 `nginx.conf`, values only scalars/strings, and it **may evict by LRU when it fills up** — check126 the second return value of `:set()` (`err == "no memory"`) and handle the failure. It is not a127 database and does not replace Redis; it is a process cache with a global lock per operation.128- `lua_code_cache on` in production **always**; `off` only in development (it recompiles every129 request).130- A `require`d module is cached per worker: **module-level state persists between131 requests**. Nothing mutable per request at module level — it is the classic source of data leaks132 between users.133- Prefer `*_by_lua_file` over `*_by_lua_block` as soon as the code goes beyond a few lines: Lua134 embedded in `nginx.conf` is not linted, not tested and not reviewed well.135136### Neovim137- The interpreter is **LuaJIT**: you write Lua 5.1 with extensions. Do not assume `goto`, integer138 division nor 5.4 APIs.139- `init.lua` as the single entry point; configuration split into `lua/<user>/*.lua` loaded with140 `require`. No logic in `init.lua` beyond bootstrapping the plugin manager.141- **API**: `vim.api.nvim_*` (stable and typed API) by default; `vim.fn.*` only for Vimscript142 functions with no equivalent; `vim.opt`/`vim.o` for options. **`vim.loop` is deprecated: use143 `vim.uv`** (the same libuv binding). Cache the handle (`local uv = vim.uv`) on hot paths.144- Plugin structure: `lua/<plugin>/init.lua` with an idempotent `M.setup(opts)`,145 `plugin/<plugin>.lua` only for what must run on load, `doc/` with `:help`. **No heavy work at the146 top level of the module**: that runs on `require` and is paid for in startup time.147- Manager: **lazy.nvim**, with `lazy-lock.json` **committed** (it is the lockfile: without it your148 config is not reproducible). Lazy loading by `event`/`ft`/`cmd`/`keys`, not `lazy = false` out of149 convenience.150- Autocommands always in their own `augroup` with `clear = true` — otherwise they duplicate on151 reload.152- Do not block the UI: I/O with `vim.uv` async or `vim.system()`; `vim.schedule` to get back to the153 main thread from a callback. A synchronous `vim.fn.system()` in an autocommand is a frozen editor.154155### Redis / Valkey (`EVAL` / `EVALSHA` / Functions)156- The interpreter is **Lua 5.1** with a sandbox: `os`, `io` and system access do not exist.157- **The script is atomic and blocks the whole server while it runs.** Operational corollary:158 scripts are **short and bounded**; no loops over collections of unbounded size, no `KEYS *`,159 no O(n) over large structures. A slow script is a global latency outage.160- **Determinism**: historical and still correct criteria. Today replication is **by effects** (write161 commands are replicated, not the script) — the default since Redis 5.0 and **verbatim replication162 is no longer supported since Redis 7.0**, which relaxes the engine's restriction. **The163 engineering rule does not relax**: do not generate randomness nor read the time inside the script.164 Pass the timestamp and any random value **as an argument (`ARGV`)** from the client: it is165 reproducible, testable and auditable. If you need the server's time, `redis.call('TIME')`, never166 a Lua source.167- **Every key accessed goes in `KEYS`**, never built inside the script: it is the contract168 that makes it work in a cluster (all keys must fall in the same slot; use *hash tags*).169- `local` on every variable: **polluting Lua's global state breaks the server's consistency**.170- Deployment: `SCRIPT LOAD` + `EVALSHA` with a *fallback* to `EVAL` on `NOSCRIPT` (the script cache171 is lost on restart and is not replicated reliably). For stable and versioned logic, **Redis172 Functions** (`FUNCTION LOAD`) is preferable to a loose `EVAL`: it is persisted and replicated.173- Scripts are **code versioned in the repo**, not strings pasted into the application code.174175### Games and modding176- Lua embedded in an engine with the explicit purpose of letting **third parties write code**: that177 is §5 and it is not negotiable. Mandatory sandbox, a minimal and audited API surface, and178 CPU/memory budgets.179- The mod's state lives in the host, not in Lua globals; expose functions, not mutable tables of the180 engine. Careful with `__gc` and with retaining references to native objects: it is the usual route181 to *use-after-free* across the C boundary.182183### Other hosts (bounded)184**Wireshark** (dissectors in Lua: analysis code that runs over untrusted traffic — treat it185as a hostile parser), **HAProxy** (`lua-load`, the same blocking veto as OpenResty over its event186loop), **Kong** (plugins on OpenResty: apply the OpenResty section as is, plus Kong's plugin life187cycle). For each one, the rule is identical: **read which Lua version it embeds, which188libraries it exposes and which operations block its loop** before writing a line.189190## 5. Security: running user Lua is running code191192**Starting point, with no qualifications: if you load Lua you did not write yourself, you are193executing arbitrary code inside your process.** The sandbox reduces the damage; it does not194eliminate it.195196- **Loading surface**: `load` / `loadstring` / `loadfile` / `dofile` / `require` over untrusted197 content are the sink. Use `load(chunk, name, "t", env)` — the **`"t"` mode (text only) is198 mandatory**: accepting bytecode (`"b"`) is fatal, because **Lua's bytecode verifier is not199 robust** and a malicious precompiled chunk corrupts the interpreter's memory and escapes the200 sandbox without needing any dangerous function.201- **Environment**: in 5.2+ pass an explicit `_ENV` as the fourth argument to `load`; in 5.1/LuaJIT,202 `setfenv` on the loaded function. The environment is an **allowlist** built from scratch, never203 a copy of `_G` with things deleted.204- **Out of the environment, always**: `os` (`execute`, `getenv`, `remove`, `rename`, `exit`,205 `tmpname`), the whole of `io`, `package` and `require` (it allows loading any `.so`), the206 **entire** `debug` (`debug` breaks any sandbox: `getupvalue`/`setupvalue`/`getregistry` give207 access to the host's state), `load`/`loadstring`/`loadfile`/`dofile`, `collectgarbage`,208 `rawset`/`rawget` over host tables, and `string.dump`. From `os`, at most209 `os.time`/`os.clock`/`os.date` if determinism does not matter.210- **Careful with indirect references**: `("").format` reaches the string metatable, which is211 **global and shared**; if the sandbox can modify it, it contaminates the host. Protect the string212 metatable (`debug.setmetatable` in the host, `__metatable` to block `getmetatable`) and freeze the213 environment's tables with `__newindex = function() error(...) end`.214- **A sandbox in pure Lua is never enough**, because it does not bound resources:215 - **CPU**: `while true do end` hangs the process. Mitigation: an instruction-counting hook216 (`debug.sethook(co, fn, "", N)`) that aborts on exceeding the budget — installed **from the217 host**, over a coroutine, and knowing that `debug` itself must not be exposed to the guest.218 It is a partial mitigation: there are operations (`string` patterns, huge concatenations) that219 burn a lot of time in few instructions.220 - **Memory**: a table that grows without limit exhausts the host's RAM. Real mitigation: **an221 allocator with a limit** (`lua_newstate` with your own allocator that fails on exceeding the222 quota) — done in C, not in Lua.223 - **Patterns**: `string.find`/`gsub` with user patterns over large inputs are a DoS224 (backtracking). Do not accept user patterns; if you must, bound the length of the pattern and225 the subject.226- **Real isolation**: for genuinely untrusted code, the control that cuts is **the227 process**: an interpreter in a separate process, unprivileged, with `rlimit` on CPU/memory/files,228 seccomp and no network, and communication over bounded IPC. The in-process sandbox is defence in229 depth, not the boundary.230- **Classic injection**: never build Lua code by concatenating input (`load("return "..x)`) — it is231 `eval` by another name. Never build SQL, commands or paths by concatenation from Lua.232- **Secrets**: outside the code and outside `nginx.conf`; in OpenResty they come in via the233 environment (`env` + `os.getenv` in `init_by_lua`) or via a fetch at startup, and **never into234 `ngx.shared.DICT` nor into logs**. Careful with dumping context tables into error logs: they carry235 tokens.236- **SCA**: LuaRocks has no auditing ecosystem comparable to npm/PyPI. Practical consequence:237 minimise dependencies, pin exact versions in the `.rockspec`, and **review the code** of rocks238 with C extensions that go into production.239240## 6. Performance and operability241242- Profile before optimising; in LuaJIT, first check that your hot path **compiles** (`-jv`,243 `-jdump`): a bail-out to the interpreter due to an NYI construct costs more than any244 micro-tuning.245- Real and measurable cost: concatenation in a loop is O(n²) (accumulate in a table and246 `table.concat`); a global is a hash lookup (cache `local ngx = ngx`, `local fmt = string.format`247 in the module).248- **Explicit timeouts on all I/O**: `settimeout`/`set_timeouts` on every cosocket; the default in249 many libraries is too high or infinite. Retries with backoff only on idempotent operations.250- Observability: structured logs with a correlation id (in OpenResty, `ngx.var.request_id`);251 per-worker metrics via `ngx.shared.DICT` on an internal endpoint. An error that only appears in252 `error.log` with no level and no context is an invisible incident.253- Watch `collectgarbage("count")` as a metric: in long-lived hosts, monotonic growth is the254 symptom of accumulated globals or retained closures. In OpenResty, changing the Lua requires a255 `reload`: design startup to be cheap and idempotent.256257## 7. Sustainability and prohibitions258259- **Cadence**: LuaJIT is updated by following the `v2.1` branch with dated snapshots and a review of260 the changes, not once a year. Lua 5.4/5.5 are updated by patch without drama; the branch jump261 (5.1→5.4, 5.4→5.5) is a **project**, not a version bump.262- Every dependency with no release in >18 months is reviewed (this applies today to `luacheck`);263 every C extension is audited before it goes in.264- Migrate from LuaJIT to Lua 5.4 only with a reason (you genuinely need 5.4) and with measurement:265 you lose the JIT and the FFI, and that can be an order of magnitude on the hot path.266267**List of prohibitions (veto):**268- ❌ Implicit global variables. `local` or a written justification in the code.269- ❌ **FORBIDDEN** any blocking call in the OpenResty/HAProxy event loop270 (`os.execute`, `io.*`, LuaSocket, synchronous C libraries). It is an outage, not a *code smell*.271- ❌ **FORBIDDEN** `load`/`loadstring` with a mode that accepts bytecode (`"b"`/`"bt"`) over272 untrusted input. Only `"t"`.273- ❌ **FORBIDDEN** to expose `debug`, `package`, `require`, `os` or `io` to untrusted code; and274 **FORBIDDEN** to treat a pure-Lua sandbox as a security boundary without CPU/memory limits275 imposed from the host.276- ❌ Building Lua code, SQL or commands by concatenating input.277- ❌ Randomness or reading the clock inside a Redis/Valkey script; keys not declared in278 `KEYS`; scripts with unbounded loops.279- ❌ A `pcall` whose error is discarded with no log and no propagation.280- ❌ Per-request mutable state at module level in OpenResty; globals that persist between requests.281- ❌ Relying on `#t`/`ipairs` over tables with holes.282- ❌ Publishing/deploying without `lazy-lock.json` (Neovim) or without pinned versions in the283 `.rockspec`.284- ❌ Lua embedded in `nginx.conf` beyond a few lines (use `*_by_lua_file`).285- ❌ `lua_code_cache off` in production.286- ❌ Third-party LuaJIT tarballs or GitHub's automatic tarball; the `v2.1.ROLLING` tag as a pin.287- ❌ **Choosing Lua for new code that is not embedded in a host that requires it.** Lua shines as an288 extension language inside somebody else's process; as a standalone application language, its289 ecosystem (packages, auditing, types, minimal standard library) is a cost that almost never pays290 off against Python or Go.291292## 8. Mandatory web verification293294Before committing to versions or APIs, **verify online** (WebSearch/WebFetch, and the Atom feeds295`https://github.com/OWNER/REPO/releases.atom` — `api.github.com` returns 403 unauthenticated):2961. **LuaJIT**: activity on the `v2.1` branch (recent commits) and the project's status at297 `luajit.org/status.html`.298 It is the datum that decides whether a new project can rest on it. There are no release tags: do299 not look for one.3002. **Lua**: `lua.org/news.html` and `lua.org/versions.html` — the latest of 5.4, the status and301 adoption of **5.5.0** (published on 22 Dec 2025), and whether 5.5.1 or 5.4.9 have come out.3023. Versions and **raw licences** of StyLua and selene (**both MPL-2.0**, not MIT), LuaLS, Teal,303 busted and LuaRocks; and whether `luacheck` has published a release again.3044. The version of **OpenResty** and of `lua-nginx-module` (as of Aug 2026 the 0.10.32 branch was in305 *release candidate*: do not pin an `rc` in production without checking whether there is already a306 final).3075. **Neovim**: stable version (0.12.x as of Aug 2026) and the deprecation list (`:help deprecated`)308 before writing a plugin — `vim.loop`→`vim.uv` is not the only one.3096. **Redis/Valkey**: the Lua version embedded in the specific version you deploy and the state of310 Functions vs `EVAL`; the replication rules changed in 5.0 and 7.0.3117. CVEs of the interpreter and of the C extensions you use (osv.dev / GitHub Advisories).312313**Declared discrepancy**: LuaRocks' releases Atom feed shows a feed `updated`314(2025-12-28) earlier than that of its most recent entry (LuaRocks 3.13.0, `updated` 2026-01-28).315Version 3.13.0 is taken as good; **its publication date is not verified** — confirm it at316`luarocks.org` before citing it.317318If the web contradicts this document, **the web wins** — flag the discrepancy.