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

  1. Running Luau in AntOS
  2. Turning the type checker on without breaking everything
  3. Loops: pairs, continue, and iterators that yield
  4. Strings: interpolation instead of .. and string.format
  5. OOP: from metatables to class
  6. Modules: from require "name" to string requires and export
  7. Immutability: const, table.freeze, read-only types
  8. Binary data: from string.byte soup to buffer
  9. 64-bit integers: from hi/lo pairs to the integer type
  10. Math and vectors: stop allocating tables
  11. Things that will actually break: a debugging session
  12. Making it fast: attributes, native codegen, preallocation
  13. 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 the require, and term.shutdown() before every return or exit path โ€” including error paths. Missing one leaves the terminal in a bad state.
  • The help header: desc, long and usage locals plus an args[1] == "?" check, so blah ? 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 }. new is just a conventional static helper, not special.
  • Class definitions are top-level only. export class Point is 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 class block runs fails at runtime.
  • Instances are not tables. No array part, no pairs/ipairs, no getmetatable/setmetatable. Anything in your codebase that generically walks objects with pairs needs an __iter or 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 doing if 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 @. Bare require("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 on D:. See AntOS libraries.)
  • Aliases live in .luaurc and are looked up by walking up the directory tree:
{
    "aliases": {
        "modules": "./src/modules",
        "test": "./tests/helpers"
    }
}
  • A directory with an init.luau is a module: require("./widgets") finds widgets/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.byteswap for 32-bit, or read bytes individually).
  • Types: readi8/u8/i16/u16/i32/u32/f32/f64 and matching write*. ๐Ÿšง readinteger/writeinteger arrive with the integer type.
  • Bridge to strings with buffer.fromstring / buffer.tostring, and copy with buffer.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.

Important: The Ant64 family of home computers are at early design/prototype stage, everything you see here is subject to change.