Lua 5.1 โ Luau: Hands-On Tutorials
Companion to luau_vs_lua51 (the complete difference reference) and part of the AntOS Luau docs. Each tutorial takes a real Lua 5.1 pattern, shows what breaks or what's now better, and rewrites it using current Luau (0.730-era features).
Status markers: โ shipped ยท ๐ง RFC accepted, implementation landing in 0.72x โ check your build.
Contents
- Running Luau in AntOS
- Turning the type checker on without breaking everything
- Loops:
pairs,continue, and iterators that yield - Strings: interpolation instead of
..andstring.format - OOP: from metatables to
class - Modules: from
require "name"to string requires andexport - Immutability:
const,table.freeze, read-only types - Binary data: from
string.bytesoup tobuffer - 64-bit integers: from hi/lo pairs to the
integertype - Math and vectors: stop allocating tables
- Things that will actually break: a debugging session
- Making it fast: attributes, native codegen, preallocation
- Embedding: porting a C host from Lua 5.1
0. Running Luau in AntOS
There is no lua or luau executable in AntOS, and none is needed. Scripts are Luau by
default โ the runtime is built into the system, so a script is not "passed to an interpreter",
it just runs.
Running a script
To run blah.lua, type its name without the extension and press Enter:
> blah
Note the extension: AntOS scripts are named .lua but the contents are Luau. Everything in
this document applies โ type annotations, continue, string interpolation, buffer, and the rest
โ regardless of the file extension. Ignore any upstream advice about .luau vs .lua; in AntOS
the extension carries no meaning about the dialect.
Arguments follow the name as usual:
> blah --verbose input.txt
Running a one-liner
Prefix the command line with a semicolon to evaluate Luau inline instead of looking up a script:
> ;print(`{1 + 2}`)
3
Useful for checking a language feature before you commit it to a script โ for instance, confirming
whether the build you're on has integer or class:
> ;print(type(0i))
> ;print(pcall(function() return class end))
Anatomy of an AntOS command script
Terminal scripts follow a fixed shape. Two conventions matter enough to state up front:
term.init()after therequire, andterm.shutdown()before everyreturnor exit path โ including error paths. Missing one leaves the terminal in a bad state.- The help header:
desc,longandusagelocals plus anargs[1] == "?"check, soblah ?prints usage.
local term = require("terminal")
local desc = "Does the thing"
local long = "Longer explanation of the thing, shown by `blah ?`."
local usage = "blah [--verbose] <input>"
local args = arg or {}
if args[1] == "?" then
print(desc)
print(long)
print(usage)
return
end
term.init()
local ok, err = pcall(function()
-- real work here
end)
term.shutdown()
if not ok then
print(`error: {err}`)
end
Argument parsing uses the declarative os.arguments parser rather than hand-rolled loops. Two
things to remember about it: it errors on undeclared flags, so anything that needs to forward
unknown arguments (compilers, wrappers) must keep a manual parser; and os.run / os.spawn return
a table, not multiple values. The Script Developer Quickstart documents this
command shape in full โ the parser's three argument kinds, the terminal lifecycle, and the console
glyph set.
A note on the rest of this document
Sections below occasionally show upstream tooling invocations โ luau-analyze src/, luau file.luau,
luau_compile in the embedding chapter. Those describe the standalone Luau distribution, not AntOS.
Read them as "wherever your build surfaces type checking and linting"; the language content around
them is unaffected.
1. Turning the type checker on without breaking everything
The mistake is flipping a 20k-line codebase to --!strict on day one and drowning in errors. Do it
file by file.
Step 1 โ audit with the default mode. Luau defaults to --!nonstrict, so just run:
luau-analyze src/
Fix whatever it reports. nonstrict only complains when it's sure.
Step 2 โ pick a leaf module and add the directive.
-- before: util.lua (Lua 5.1)
local function clamp(x, lo, hi)
if x < lo then return lo end
if x > hi then return hi end
return x
end
-- after: util.luau
--!strict
local function clamp(x: number, lo: number, hi: number): number
return math.clamp(x, lo, hi) -- Luau builtin, errors if lo > hi
end
return { clamp = clamp }
Step 3 โ annotate the boundaries, let inference do the middle. You rarely need annotations on locals; you almost always want them on function parameters and on empty table literals.
--!strict
-- inference can't guess what goes in here:
local names = {} -- inferred {unknown} in older builds
local names = {} :: { string } -- say it once
-- but this is fine unannotated:
local total = 0
for _, n in scores do
total += n
end
Step 4 โ set the mode project-wide once the files are clean.
// .luaurc
{
"languageMode": "strict",
"lint": { "*": true }
}
Step 5 โ escape hatches, in order of preference.
local x = value :: SomeType -- cast: checked, one side must be a subtype of the other
local y = value :: any -- nuclear option
--!nocheck -- whole file, for generated or vendored code
If a cast is rejected ("neither is a subtype of the other"), go through any:
value :: any :: Target. That's a smell โ but a visible one, which is the point.
Tip: if you're on 0.721+, luau-analyze --solver=new is the default. If you're evaluating an
older codebase against an older Luau and the inference looks dumb, check whether you're on the old
solver: --solver=old behaves quite differently around generics and table literals.
2. Loops: pairs, continue, and iterators that yield
2.1 Drop pairs and ipairs
-- Lua 5.1
for k, v in pairs(config) do print(k, v) end
for i, v in ipairs(list) do print(i, v) end
-- Luau: generalized iteration โ
for k, v in config do print(k, v) end
for i, v in list do print(i, v) end
Same ordering guarantees as pairs (1..#t consecutive, rest unspecified), and it's faster because
the VM handles it directly instead of calling a Lua-visible iterator.
One extra guarantee worth knowing: in a dense array, elements you append at #t+1 during the
loop are guaranteed to be visited in the same loop. Handy for worklists:
local queue = { startNode }
for _, node in queue do
for _, child in node.children do
table.insert(queue, child) -- will be visited
end
end
2.2 continue instead of inverted if
-- Lua 5.1: no continue, no goto in Luau either
for _, item in ipairs(items) do
if item.enabled and not item.hidden then
if item.count > 0 then
process(item)
end
end
end
-- Luau
for _, item in items do
if not item.enabled then continue end
if item.hidden then continue end
if item.count <= 0 then continue end
process(item)
end
continue must be the last statement in its block. The one trap is repeat..until:
repeat
continue
local done = check() -- ERROR: continue would skip a local used in the condition
until done
Hoist the local above the continue, or restructure as a while.
2.3 Custom iteration with __iter
Lua 5.1 makes you return the (generator, state, control) triple. Luau lets the object iterate itself.
-- Lua 5.1
local function rangeIter(r, i)
i = i + 1
if i <= r.to then return i end
end
local function range(r) return rangeIter, r, r.from - 1 end
for n in range(r) do ... end
-- Luau โ
local Range = {}
Range.__index = Range
function Range.__iter(self)
local i = self.from - 1
return function()
i += 1
if i <= self.to then return i end
end
end
local function range(from: number, to: number)
return setmetatable({ from = from, to = to }, Range)
end
for n in range(1, 5) do print(n) end
Note __pairs / __ipairs from Lua 5.2 do not exist in Luau and never will โ __iter
replaces both.
2.4 Iterators that yield โ (new in 0.722)
Before 0.722 you could not coroutine.yield from inside a generalized-iteration generator. Now you
can, which makes streaming/IO loops read like ordinary loops:
-- the generator blocks on IO and yields the coroutine; the loop body just runs
for request in server:incoming() do
request:respond(`echo: {request.body}`)
end
Still forbidden: yielding from a metamethod, including __iter itself. So the setup call can't
yield โ only the iterator function it returns.
3. Strings: interpolation instead of .. and string.format
-- Lua 5.1
print("Player " .. name .. " scored " .. tostring(score) .. " points")
print(string.format("%s: %d/%d (%.1f%%)", label, done, total, done/total*100))
-- Luau โ
print(`Player {name} scored {score} points`)
print(`{label}: {done}/{total} ({string.format("%.1f", done/total*100)}%)`)
Interpolation calls tostring on each value, so no manual conversion. Any expression fits inside
{}, including an if..then..else expression โ which is how you do pluralisation inline:
print(`{n} item{if n == 1 then "" else "s"} remaining`)
Keep string.format for numeric formatting (%.2f, %x, padding). Interpolation isn't a
replacement for that; it's a replacement for concatenation.
Escaping rules that catch people out:
print(`literal brace: \{`) -- backslash-escape a brace
print(`backtick: \``)
print(`{{ }}`) -- PARSE ERROR: {{ is deliberately rejected
And backtick strings need parentheses as a call argument:
print `hi` -- invalid
print(`hi`) -- correct
The and/or ternary trap
-- Lua 5.1 idiom, silently wrong when the middle value is false/nil
local visible = isEnabled and false or true -- always true
-- Luau โ
local visible = if isEnabled then false else true
Since if..then..else is an expression, it also cleans up table literals and returns:
return {
mode = if debugBuild then "debug" else "release",
level = if n < 10 then "low" elseif n < 100 then "mid" else "high",
}
else is mandatory โ there is no one-armed if-expression.
4. OOP: from metatables to class
This is the biggest ergonomic jump. Three stages, depending on how far you want to go.
Stage 1 โ the Lua 5.1 original
local Point = {}
Point.__index = Point
function Point.new(x, y)
return setmetatable({ x = x, y = y }, Point)
end
function Point:length()
return math.sqrt(self.x * self.x + self.y * self.y)
end
function Point.__add(a, b)
return Point.new(a.x + b.x, a.y + b.y)
end
function Point.__tostring(p)
return "Point(" .. p.x .. ", " .. p.y .. ")"
end
Stage 2 โ typed metatables (works on every Luau build)
The typeof(setmetatable(...)) trick is the standard idiom before classes land:
--!strict
local Point = {}
Point.__index = Point
export type Point = typeof(setmetatable(
{} :: { x: number, y: number },
Point
))
function Point.new(x: number, y: number): Point
return setmetatable({ x = x, y = y }, Point)
end
function Point.length(self: Point): number
return math.sqrt(self.x * self.x + self.y * self.y)
end
function Point.__add(a: Point, b: Point): Point
return Point.new(a.x + b.x, a.y + b.y)
end
function Point.__tostring(p: Point): string
return `Point({p.x}, {p.y})`
end
return Point
Two things to note: writing function Point.length(self: Point) rather than function Point:length()
lets you annotate self, and export type Point makes the type usable from other modules.
This works, but the Luau team's own stated reason for adding classes is that inferring
setmetatable accurately has proven very hard โ autocomplete on this pattern is mediocre.
Stage 3 โ class ๐ง (0.721+, RFC accepted)
class Point
public x: number
public y: number
function length(self) -- first arg named self โ method
return math.sqrt(self.x * self.x + self.y * self.y)
end
function __add(self, other: Point)
return Point { x = self.x + other.x, y = self.y + other.y }
end
function __tostring(self)
return `Point \{ x = {self.x}, y = {self.y} \}`
end
function new(x, y) -- no self โ static
return Point { x = x, y = y }
end
end
local p = Point.new(3, 4)
print(`{p} has length {p:length()}`)
Migration notes:
- Construction is a call on the class object taking a table of initial field values:
Point { x = 1, y = 2 }.newis just a conventional static helper, not special. - Class definitions are top-level only.
export class Pointis allowed. Two classes with the same name in one module is an error. - The name is hoisted, so mutually recursive classes work, but the class object doesn't exist
until the statement executes โ calling a factory before the
classblock runs fails at runtime. - Instances are not tables. No array part, no
pairs/ipairs, nogetmetatable/setmetatable. Anything in your codebase that generically walks objects withpairsneeds an__iteror a different approach. - Reading or writing an unknown property raises, instead of returning
nil. This is a feature (typo protection) but it will surface latent bugs in ported code. type(inst)is"object",type(Point)is"class"โ not"table". Any dispatch code doingif type(v) == "table"needs updating.- Un-annotated fields are
any. Annotate them. - Class types are nominal, unlike Luau's structural table types. Two identical classes are different types.
- Only the listed metamethods are allowed (
__add __sub __mul __div __mod __pow __idiv __unm __concat __len __eq __lt __le __call __iter __tostring). Any other__-prefixed method is a syntax error. - No inheritance yet, and no generic classes. If your hierarchy is deep, stay on Stage 2 for now.
Type refinement works through class.isinstance:
local function describe(v: unknown)
if class.isinstance(v, Point) then
return `point at {v.x},{v.y}` -- v is narrowed to Point
end
return "unknown"
end
Recommendation: move to Stage 2 now, across the board. It's pure win and requires no new runtime. Move to Stage 3 per-class once classes are stable in your pinned build and you don't need inheritance.
5. Modules: from require "name" to string requires and export
5.1 Path-based requires
-- Lua 5.1
require("mylib.util") -- searched via package.path
local json = require("json")
-- Luau โ
โ package/package.path/module() do not exist
local util = require("./util")
local shared = require("../shared/config")
local json = require("@modules/json") -- alias from .luaurc
Rules:
- A path must start with
./,../or@. Barerequire("foo")is an error. (AntOS exception: its built-in libraries are required by bare name โrequire("fs"),require("terminal"),require("net")โ because the host registers them as built-in modules, not files. This.//@rule governs user modules onD:. See AntOS libraries.) - Aliases live in
.luaurcand are looked up by walking up the directory tree:
{
"aliases": {
"modules": "./src/modules",
"test": "./tests/helpers"
}
}
- A directory with an
init.luauis a module:require("./widgets")findswidgets/init.luau.
โ ๏ธ The init.luau gotcha. Inside init.luau, relative paths resolve from the parent
directory, not from the init file:
-- widgets/init.luau
local foo = require("./foo") -- resolves to <parent>/foo โ probably not what you want
local foo = require("@self/foo") -- resolves to widgets/foo โ
@self is reserved and cannot be overridden by an alias.
5.2 Returning a table vs export โ
(0.723)
The classic pattern still works:
-- lib.luau (classic)
local M = {}
M.version = "1.0"
function M.init() end
return M
But Luau now has a language-level export:
-- lib.luau (modern)
export local version = "1.0"
export const TAU = math.pi * 2
export function init()
-- exported functions are always const
end
export type Config = { retries: number }
-- consumer.luau
local lib = require("./lib")
print(lib.version, lib.TAU)
lib.init()
local c: lib.Config = { retries = 3 }
What you get for it: the module's export table is frozen, and because bindings are guaranteed stable the compiler can do cross-module inlining and constant folding โ which is not possible with a dynamically-returned table.
Constraints: top level only (export inside an if is a syntax error), exports count toward the
200-local limit, and exported functions are implicitly const.
5.3 export type has always existed
Independent of value exports, and available on every Luau build:
-- types.luau
export type Vec2 = { x: number, y: number }
export type Handler = (Vec2) -> ()
return {}
local T = require("./types")
local v: T.Vec2 = { x = 0, y = 0 }
6. Immutability: const, table.freeze, read-only types
Three distinct mechanisms, easy to confuse.
6.1 const โ immutable binding โ
-- Lua 5.1: convention only
local MAX_RETRIES = 3
MAX_RETRIES = 5 -- nothing stops you
-- Lua 5.4 syntax, rejected by Luau:
local MAX <const> = 3 -- not valid Luau
-- Luau โ
const MAX_RETRIES = 3
MAX_RETRIES = 5 -- error: cannot reassign a const binding
MAX_RETRIES += 1 -- also an error, including in nested scopes
Works everywhere local works, including annotations, multi-assignment and functions:
const width: number, height: number = 1920, 1080
const function clamp01(x: number) return math.clamp(x, 0, 1) end
Because it's a contextual keyword, existing code using const as a variable name still compiles.
6.2 table.freeze โ immutable value โ
const does not freeze what the binding points at:
const cfg = { debug = false }
cfg.debug = true -- allowed! the table is still mutable
cfg = {} -- error
const frozen = table.freeze({ debug = false })
frozen.debug = true -- error: attempt to modify a readonly table
print(table.isfrozen(frozen)) --> true
Freeze is shallow and in-place โ nested tables need freezing separately. table.clone gives you a
mutable shallow copy of a frozen table, preserving the metatable:
const defaults = table.freeze({ retries = 3, timeout = 30 })
local function withOverrides(over: { [string]: any })
local c = table.clone(defaults) -- not frozen
for k, v in over do c[k] = v end
return table.freeze(c)
end
This pairing โ freeze the canonical value, clone to modify โ is the standard Luau idiom and has no Lua 5.1 equivalent.
6.3 Read-only types โ immutability in the type system โ
type ReadOnlyPoint = { read x: number, read y: number }
type WriteOnlySink = { write value: number }
And read-only indexers (new in 0.721), which fix a real variance problem:
local function sumOld(xs: { number }): number -- invariant
local function sumNew(xs: { read number }): number -- covariant โ
local ints: { number } = { 1, 2, 3 }
sumNew(ints) -- accepted: sumNew cannot write into xs
Rule of thumb: any function that only reads an array parameter should take { read T }.
It costs nothing and makes the function accept strictly more call sites.
7. Binary data: from string.byte soup to buffer
Lua 5.1 has no mutable byte storage. Everything is immutable strings, so building a packet means allocating garbage.
-- Lua 5.1
local function encodeHeader(id, flags, len)
return string.char(
math.floor(id / 256), id % 256,
flags,
math.floor(len / 256), len % 256
)
end
local function decodeU16(s, i)
local hi, lo = s:byte(i, i + 1)
return hi * 256 + lo
end
-- Luau โ
local function encodeHeader(id: number, flags: number, len: number): buffer
local b = buffer.create(5)
buffer.writeu16(b, 0, id) -- little-endian
buffer.writeu8(b, 2, flags)
buffer.writeu16(b, 3, len)
return b
end
local function decodeHeader(b: buffer)
return buffer.readu16(b, 0), buffer.readu8(b, 2), buffer.readu16(b, 3)
end
Key points for someone coming from Lua 5.1:
- Fixed size, set at
buffer.create(n). Max 1 GB. Out-of-range access throws โ no silent truncation. - Little-endian always. If your wire format is big-endian you swap explicitly
(
bit32.byteswapfor 32-bit, or read bytes individually). - Types:
readi8/u8/i16/u16/i32/u32/f32/f64and matchingwrite*. ๐งreadinteger/writeintegerarrive with theintegertype. - Bridge to strings with
buffer.fromstring/buffer.tostring, and copy withbuffer.copy(overlap-safe, memmove semantics). buffer.fill(b, offset, value, count?)for memset.
Bit-level packing โ this has no Lua 5.1 analogue at all:
-- pack a 12-bit field, a 3-bit field and a flag into 2 bytes
local b = buffer.create(2)
buffer.writebits(b, 0, 12, 0xABC)
buffer.writebits(b, 12, 3, 5)
buffer.writebits(b, 15, 1, 1)
print(buffer.readbits(b, 0, 12)) --> 2748
bitCount must be 0โ32.
When to keep string.pack. Luau has Lua 5.3's string.pack/unpack/packsize, with fixed
sizes (short 16, int 32, long 64, size_t 32 โ no platform variance). It's the right tool for
declarative formats:
local packed = string.pack("<I4I2z", timestamp, port, name)
local ts, port, name = string.unpack("<I4I2z", packed)
Use string.pack for a fixed record layout; use buffer when you're mutating in place, streaming,
or doing bit-level work.
8. 64-bit integers: from hi/lo pairs to the integer type
๐ง New type; RFC merged and the library appears in 0.728 release notes. Verify against your build before depending on it.
-- Lua 5.1: hi/lo pairs, 16-24 bytes per value, error-prone
local function add64(ahi, alo, bhi, blo)
local lo = alo + blo
local carry = if lo >= 2^32 then 1 else 0
return ahi + bhi + carry, lo % 2^32
end
-- Luau ๐ง
local a = 123i
local b = 0xFFFF_FFFF_FFFFi
local sum = integer.add(a, b) -- wraps, two's complement
The thing to internalise: there are no operators. This is deliberate โ Luau's primary numeric
type is still number, and monomorphic library functions optimize better than polymorphic
operators.
local a, b = 10i, 3i
integer.add(a, b) integer.sub(a, b) integer.mul(a, b)
integer.div(a, b) -- truncated; errors on /0 and on minsigned / -1
integer.idiv(a, b) -- floored
integer.rem(a, b) integer.mod(a, b) -- truncated vs floored remainder
integer.udiv(a, b) integer.urem(a, b) -- 'u' prefix = unsigned interpretation
integer.lt(a, b) integer.le(a, b) -- signed comparison
integer.ult(a, b) integer.ule(a, b) -- unsigned comparison
a == b -- the ONE operator that works
Conversions are explicit and fallible in one direction:
local x = integer.create(42) -- integer? โ nil if fractional, out of range, or NaN
local y = integer.fromstring("ff", 16) -- integer? โ base 2..36
local n = integer.tonumber(x) -- number, may lose precision above 2^53
โ ๏ธ No implicit conversion, either way. Passing an integer to something expecting a number
is a type error, and 1 + 1i is not a thing. If you're threading 64-bit IDs through existing code
that takes number, you convert explicitly at the boundary or change the signature.
Literals:
local a = 123i
local b = 1_000i
local c = 0xABABi
local d = 0b1000_1000i
local e = 0xFFFF_FFFF_FFFF_FFFFi -- == -1i, sign bit included
local f = -123i -- OK: unary minus directly on the literal
local g = -(123i) -- NOT OK
local h = 99999999999999999999i -- parse error: not exactly representable
Bit manipulation mirrors bit32 but 64-wide: band, bor, bxor, bnot, btest, lshift,
rshift, arshift, lrotate, rrotate, extract, replace, countlz, countrz, bswap,
plus integer.maxsigned / integer.minsigned.
Formatting:
print(tostring(a)) -- signed, no 'i' suffix
print(string.format("%d", a)) -- signed 64-bit
print(string.format("%x", a)) -- unsigned 64-bit
print(string.format("%f", a)) -- ERROR: float specifiers reject integers
string.pack/unpack are not extended to accept integers โ use buffer.writeinteger /
buffer.readinteger.
9. Math and vectors: stop allocating tables
-- Lua 5.1: every vector is a heap table
local function add(a, b) return { x = a.x + b.x, y = a.y + b.y, z = a.z + b.z } end
local function length(v) return math.sqrt(v.x^2 + v.y^2 + v.z^2) end
-- Luau โ
โ native value type, stored inline, no allocation, full operators
local a = vector.create(1, 2, 3)
local b = vector.one
local c = a + b * 2
local len = vector.magnitude(a)
local n = vector.normalize(a)
local d = vector.dot(a, b)
local x = vector.cross(a, b)
Components are read via .x/.y/.z (or .X/.Y/.Z) and are immutable โ there's no v.x = 1.
Build a new vector instead. In a build with LUA_VECTOR_SIZE=4 you also get .w.
Component-wise helpers: vector.floor, ceil, abs, sign, clamp, min, max, angle.
And v // n floors each component.
Math functions Lua 5.1 doesn't have:
math.clamp(x, 0, 1) -- errors if min > max
math.sign(x) -- -1, 0, 1 (0 for NaN)
math.round(x) -- halfway rounds away from zero
math.lerp(a, b, t) -- guarantees lerp(a, b, 1) == b exactly
math.map(x, 0, 10, 0, 100) -- remap between ranges
math.isnan(x) math.isinf(x) math.isfinite(x)
math.noise(x, y, z) -- 3D Perlin, [-1, 1]
math.log(x, base) -- 5.2's optional base
math.lerp's endpoint guarantee is worth calling out โ the naive a + (b - a) * t does not
reliably return exactly b at t == 1 in floating point, which causes visible artefacts in
animation code.
10. Things that will actually break: a debugging session
Run this checklist over a ported codebase. Each item is a real difference, not a style preference.
10.1 Deep tail recursion overflows
Luau has no tail calls. This is the single most likely silent breakage.
-- Lua 5.1: constant stack, runs forever
local function loop(n)
if n == 0 then return "done" end
return loop(n - 1) -- tail call, optimised away in 5.1
end
loop(1e6) -- Luau: stack overflow (limit is 20000 Lua frames)
-- Luau: rewrite as a loop
local function loop(n: number): string
while n > 0 do n -= 1 end
return "done"
end
State machines written as mutually tail-calling functions are the usual victim.
10.2 __eq fires on identical objects
local mt = {}
mt.__eq = function(a, b)
print("comparing")
return rawequal(a, b)
end
local t = setmetatable({}, mt)
print(t == t)
-- Lua 5.1: true, no print (short-circuits on rawequal)
-- Luau: prints "comparing", then true
If your __eq has side effects, logs, or is expensive, add your own rawequal fast path.
10.3 os.time(table) is UTC
local ts = os.time({ year = 2026, month = 1, day = 1, hour = 0 })
-- Lua 5.1: interpreted as local time
-- Luau: interpreted as UTC
Any date arithmetic ported from a Lua 5.1 server will be off by your timezone offset.
10.4 Closure identity isn't stable
local function make()
return function() return 1 end
end
print(make() == make())
-- Lua 5.1: false (fresh closure each time)
-- Luau: may be true โ closures with identical captured upvalues can be reused
Breaks code using closures as unique table keys or identity tokens. Use a table or a counter.
10.5 Table literal assignment order
local log = {}
local t = {
[1] = record(1),
name = record("name"),
record(2), -- array part
}
Luau assigns in program order; Lua 5.x sometimes does array elements first. Only matters if your value expressions have side effects โ but when it matters, it's baffling.
10.6 The removed-API sweep
grep -rnE 'loadstring|loadfile|dofile|string\.dump|\bio\.|package\.|\bmodule\(|os\.(execute|exit|getenv|remove|rename|tmpname)|debug\.(getinfo|sethook|getlocal|setlocal|getupvalue|setupvalue)|__gc' src/
Every hit needs a decision:
| Removed | Replacement |
|---|---|
loadstring (bytecode), string.dump |
precompile with luau_compile on the host |
loadfile, dofile, io.* |
host-provided API |
package.*, module() |
string requires + .luaurc aliases (ยง5) |
os.getenv, os.execute |
host-provided API |
debug.getinfo(...) |
debug.info(level, "sln") |
__gc |
lua_newuserdatadtor on the host side |
Note getfenv/setfenv are still there โ Luau kept them for compatibility even though 5.2
deprecated them. Don't rip them out on a false alarm.
10.7 No goto
Luau rejects goto outright and has no plans for it. The common goto continue pattern is
replaced by continue (ยง2.2); the "break out of nested loops" pattern needs a flag, a function
with an early return, or restructuring.
11. Making it fast: attributes, native codegen, preallocation
Order these by payoff.
11.1 Preallocate
-- Lua 5.1: repeated rehashing as the array grows
local t = {}
for i = 1, 10000 do t[i] = compute(i) end
-- Luau โ
local t = table.create(10000) -- capacity only, #t == 0
for i = 1, 10000 do t[i] = compute(i) end
local zeros = table.create(10000, 0) -- 10000 actual zeros
Only useful for the array part โ table.create on a dictionary is counter-productive.
Related: table.clear(t) empties a table but keeps its capacity, which is what you want for a
reused scratch buffer in a hot loop.
11.2 Let the sandbox optimise builtins
If you're embedding, call luaL_sandbox on the global state and luaL_sandboxthread per script.
Beyond the security benefit, marking the library tables read-only ("safeenv") is what lets the VM
inline builtin calls like math.floor instead of doing a global lookup through a table someone
might have monkey-patched. This is a genuinely large win and is easy to leave off by accident.
11.3 Native codegen with @native
@native
local function mandelbrotRow(y: number, width: number, out: buffer)
for x = 0, width - 1 do
-- tight float math
end
end
Or --!native at the top of a file for everything in it.
โ ๏ธ @native does not apply recursively to nested functions โ mark inner functions separately:
@native
local function outer()
@native
local function inner() end -- needs its own attribute
return inner
end
Native codegen suits compute-heavy numeric code. It doesn't help code dominated by table lookups, string building, or host calls.
11.4 Mark deprecated APIs during migration
Useful as a migration tool, not just documentation โ the linter warns at every call site:
@[deprecated { use = "buffer.writeu16", reason = "String packing allocates per call." }]
local function writeU16(s: string, v: number): string
return s .. string.char(math.floor(v / 256), v % 256)
end
$ luau-analyze src/
Function 'writeU16' is deprecated, use 'buffer.writeu16' instead. String packing allocates per call.
Since 0.730 the deprecation also shows up in autocomplete via the LSP. This is a good way to drive a gradual codebase migration: annotate the old function, then work through the warning list.
11.5 Free wins from export and const
Because exported and const bindings can never be rebound, the compiler can constant-fold them
and inline across module boundaries โ impossible with a dynamically returned module table. Using
export const for your configuration constants is not just tidier, it's faster.
12. Embedding: porting a C host from Lua 5.1
The API is "mostly Lua 5.1", which is true enough that bindings port, but these five differences will each cost you an afternoon if you hit them cold.
1. Compile and load are separate. There is no runtime luaL_loadstring taking source. You
compile to bytecode, then load:
size_t bytecodeSize = 0;
char* bytecode = luau_compile(source, sourceLen, NULL, &bytecodeSize);
int result = luau_load(L, "=chunkname", bytecode, bytecodeSize, 0);
free(bytecode);
This is deliberate: you can ship a VM with no compiler linked in, and only ever load bytecode you
produced at build time. That's also why loadstring and string.dump are gone from the script side.
2. Sandbox explicitly.
luaL_openlibs(L);
luaL_sandbox(L); /* library tables become read-only */
/* per script: */
lua_State* T = lua_newthread(L);
luaL_sandboxthread(T); /* fresh globals chained via __index */
3. No __gc. Use lua_newuserdatadtor(L, size, dtor) for userdata destructors. If your Lua 5.1
binding used __gc on a proxy table, that pattern doesn't exist.
4. Stack management got easier (0.725). The C API now auto-reserves required stack slots, so
most lua_checkstack calls in ported code are redundant. Leave them if you like โ they're just
no-ops in practice now.
5. Yieldable protected C calls (0.725). luaL_pcallyieldable lets a custom library run a
protected call that can yield โ previously a hard boundary.
Also newly public and useful when building tooling: lua_atbreakpoint, lua_callhook,
lua_allocationrate (for pacing GC externally), LUA_GCISPAUSED, lua_memorydump (JSON heap dump),
lua_setpointerencodekey.
Build knobs: LUA_VECTOR_SIZE=4 for 4-wide vectors, and a double-precision vector mode (vectors
become heap-allocated in that configuration).
Quick reference: idiom translation table
| Lua 5.1 | Luau |
|---|---|
for k,v in pairs(t) do |
for k, v in t do |
for i,v in ipairs(t) do |
for i, v in t do |
if cond then ... end wrapping loop body |
if not cond then continue end |
a and b or c |
if a then b else c |
"x = " .. tostring(x) |
`x = {x}` |
x = x + 1 |
x += 1 |
math.floor(a / b) |
a // b |
local MAX = 3 (by convention) |
const MAX = 3 |
local M = {} ... return M |
export local / export const / export function |
require("pkg.mod") |
require("./mod") or require("@alias/mod") |
setmetatable({}, Class) + __index |
class block ๐ง (or typed typeof(setmetatable(...))) |
string.char/string.byte packing |
buffer + string.pack |
| hi/lo integer pairs | integer type ๐ง |
{ x = 0, y = 0, z = 0 } |
vector.create(0, 0, 0) |
debug.getinfo(2, "Sl") |
debug.info(2, "sl") |
t = {} to clear |
table.clear(t) (keeps capacity) |
| growing an array in a loop | table.create(n) first |
-- no equivalent |
--!strict, @native, @deprecated, table.freeze |
Written 28 August 2026 against Luau 0.730. Items marked ๐ง are landing across the 0.72x series โ
check luau-lang/luau releases before relying on them in production.