Lua Programming Cheat Sheet
This Lua cheat sheet covers 30 sections: syntax, variables and the new global declarations, tables and sequences, strings and Lua patterns, functions and closures, metatables and OOP, coroutines, modules, files, the C API, LuaJIT, Neovim, LÖVE and Roblox, LuaRocks and the errors you will actually meet. Search it, filter it by level or by Lua version, copy any line with one click, and lint Lua code, test patterns or check which Lua version your code needs without leaving the page.
return nil
Nothing matches that search. Try a shorter keyword such as table, gsub or metatable. Several words are combined, so every one of them has to match. Keys: / focuses the search, Esc clears it, t switches the theme.
Install & Run
installing Lua, running scripts and the REPL, luac, a Lua toolbox and builders
Builders: Class, Module, Rockspec, Neovim Config, LÖVE Game
Install Lua
Package managers
brew install lua # macOS: the current 5.4 or 5.5 release sudo apt install lua5.4 # Debian and Ubuntu name packages by version sudo dnf install lua lua -v
Windows
winget install DEVCOM.Lua scoop install lua lua -v
Build the latest release from source in a minute
curl -LO https://www.lua.org/ftp/lua-5.5.1.tar.gz tar xzf lua-5.5.1.tar.gz cd lua-5.5.1 make all test sudo make install
Run Lua
Hello, world
print("Hello, Lua")
print(_VERSION)
The command line
lua script.lua arg1 arg2 # run a file; arguments land in the arg table lua -e 'print(2 ^ 10)' # run a string lua -i script.lua # run, then stay in the interactive prompt lua # start the REPL; type expressions directly luac -o app.luac script.lua # precompile to bytecode luac -l script.lua # list the bytecode instructions
Scripts with arguments and a shebang
#!/usr/bin/env lua
print("script:", arg[0])
for i, a in ipairs(arg) do
print(i, a)
end
print("count:", #arg, select("#", ...))
Checking an Install
What the interpreter was built with
print(_VERSION, math.maxinteger == 9223372036854775807, math.type(2^63)) print(package.config:sub(1, 1), #package.path > 0, type(jit))
Syntax & Comments
statements, comments, keywords, blocks, semicolons and naming
The Shape of Lua Code
Comments and blocks
-- a line comment
--[[ a block comment
over several lines ]]
--[==[ a block comment that may contain ]] ]==]
local total = 0
do
local x = 10 -- x exists only inside do ... end
total = total + x
end
print(total, x)
Reserved words
| Keywords | Notes |
|---|---|
and or not | logical operators, they return one of their operands |
if then elseif else end | conditionals |
for in while do repeat until break goto | loops and jumps |
function return local | functions and scope |
nil true false | the only literal values that are keywords |
global | new reserved word in Lua 5.5 for global declarations |
Names and style
local playerName = "ann" -- camelCase or snake_case, pick one per project local MAX_LIVES <const> = 3 -- constants in upper case local _unused, _G_backup -- a leading underscore marks private or unused print(playerName, MAX_LIVES)
Operators
Arithmetic, comparison, logic and concatenation
print(7 + 2, 7 - 2, 7 * 2, 7 / 2, 7 // 2, 7 % 2, 2 ^ 10)
print(7 == 7.0, "7" == 7, 1 ~= 2, "a" < "b")
print(nil or "default", false and "x", not nil, 0 and "zero is true")
print("con" .. "cat" .. 1, #"length")
Precedence, from lowest to highest
| Level | Operators |
|---|---|
| 1 | or |
| 2 | and |
| 3 | < > <= >= ~= == |
| 4 | bitwise or, the vertical bar (5.3+) |
| 5 | ~ (bitwise xor) |
| 6 | & (bitwise and) |
| 7 | << >> |
| 8 | .. (right associative) |
| 9 | + - |
| 10 | * / // % |
| 11 | unary not # - ~ |
| 12 | ^ (right associative, binds tighter than unary minus) |
Statements
Chained comparisons do not exist
local x = 5
print(1 < x and x < 10)
print(pcall(load("local x = 5; return 1 < x < 10")))
Variables & Scope
local and global variables, global declarations, multiple assignment, const and scope rules
local and global
Always use local
local count = 1 score = 99 local function show() print(count, score, _G.score, rawget(_G, "count")) end show()
Multiple assignment
local a, b, c = 1, 2 print(a, b, c) a, b = b, a print(a, b) local function two() return "x", "y" end local p, q, r = two(), "z" print(p, q, r)
Constants
local LIMIT <const> = 100
local f = load("local x <const> = 1; x = 2")
print(LIMIT, f)
print(select(2, load("local x <const> = 1; x = 2")))
Global Declarations
Declare globals explicitly
global print, string
global x = 10
global function greet(name)
return string.format("hi %s, x = %d", name, x)
end
print(greet("lua"))
A typo becomes a compile error
global print global count = 1 print(cuont)
_ENV is the global table in scope
local function sandbox(code)
local env = { print = print, tostring = tostring }
return load(code, "sandbox", "t", env)
end
local fn = sandbox("x = 42; print(x, os)")
fn()
print(x)
Scope Rules
Shadowing and block scope
local x = "outer" do local x = "inner" print(x) end print(x) local function f() local x = x .. " seen by f" return x end print(f())
Types & Values
the eight types, type(), nil and booleans, truthiness, equality and conversions
The Eight Types
type() tells you which
local values = { nil, true, 42, 3.14, "text", {}, print, coroutine.create(print), io.stdout }
for i = 1, 9 do
io.write(type(values[i]), " ")
end
print()
print(math.type(42), math.type(3.14), math.type("42"))
Truthiness: only nil and false are false
for _, v in ipairs({ 0, "", false, {}, "false" }) do
local shown = type(v) == "string" and string.format("%q", v) or type(v)
print(string.format("%-9s %s", shown, v and "truthy" or "falsy"))
end
Conversions
print(tonumber("42"), tonumber("0x1F"), tonumber("1e3"), tonumber("12abc"), tonumber("ff", 16))
print(tostring(3.0), tostring(nil), 10 .. "", "10" + 5, "3" * "4")
print(math.tointeger(3.0), math.tointeger(3.5), 7 // 1)
Tables and functions compare by identity
local a, b = { 1 }, { 1 }
print(a == b, a == a, #a == #b)
print(print == print, function() end == function() end)
Numbers & Math
integers and floats, division, overflow, the math library, random numbers and formatting
Integers and Floats
Two subtypes, one type
print(3, 3.0, 3 == 3.0, math.type(3), math.type(3.0)) print(10 / 2, 10 // 3, 10.0 // 3, -7 // 2, -7 % 3, 7 % -3) print(math.maxinteger, math.maxinteger + 1 == math.mininteger) print(2^53, 1e308 * 10, -(0/0) ~= -(0/0))
Floats print so they read back exactly
print(0.1 + 0.2, 1 / 3, 100 / 3)
print(string.format("%.2f", 1 / 3), string.format("%g", 0.1 + 0.2))
Bitwise operators
local flags = 0
flags = flags | 1 << 3
print(flags, flags & 8 ~= 0, 0xFF ~ 0x0F, ~0, 1 << 62, 256 >> 4)
print(string.format("%x %X %o", 255, 255, 8))
Integer Details
Integer and float keys are the same key
local t = {}
t[1] = "int"
t[1.0] = "float"
t["1"] = "string"
print(t[1], #t, t["1"], 2^53 == 2^53 + 1, math.ult(1, -1))
The math Library
Rounding and limits
print(math.floor(3.7), math.ceil(3.2), math.floor(-3.5), math.abs(-4)) print(math.max(3, 9, 1), math.min(3, 9, 1), math.pi) print(math.sqrt(16), math.huge, -math.huge, math.fmod(7, 3)) local function round(x) return math.floor(x + 0.5) end print(round(2.5), round(-2.5))
Random numbers
math.randomseed(42) local a = math.random() local b = math.random(6) local c = math.random(10, 20) print(a >= 0 and a < 1, b >= 1 and b <= 6, c >= 10 and c <= 20)
Strings
literals, escapes, long strings, the string library, method syntax, formatting and building strings
Literals and Escapes
Quotes, escapes and long brackets
local a = 'single'
local b = "double with \"quotes\" and a tab\there"
local c = [[long string
keeps "quotes" and \n as typed]]
local d = [==[contains ]] safely]==]
print(a, b)
print(c)
print(d, "\65\u{48}\x21", "line one\z
line two")
Strings are immutable byte arrays
local s = "héllo" print(#s, s:len(), s:byte(1, 3)) print(string.char(72, 105), s:upper(), s:sub(1, 1) .. "ey")
The string Library
sub, find, rep, reverse and friends
local s = "Hello, Lua world"
print(s:sub(1, 5), s:sub(-5), s:sub(8, -7))
print(s:find("Lua"), s:find("o", 6), s:find("xyz"))
print(s:lower(), s:rep(2, "|"), ("abc"):reverse())
print(s:find(".", 1, true), s:match("%a+", 8))
string.format
local name, qty, price = "gopher plush", 3, 19.9
print(string.format("%-14s|%3d|%8.2f|", name, qty, price))
print(("%05.1f %x %X %o %e"):format(3.14159, 255, 255, 8, 12345.678))
print(string.format("%q", 'say "hi"\n'), string.format("%s %s", nil, {} ~= nil))
print(string.format("%5.1f%%", 12.345))
Building Strings
table.concat instead of .. in a loop
local parts = {}
for i = 1, 5 do
parts[#parts + 1] = "item" .. i
end
print(table.concat(parts, ", "))
print(table.concat({ 1, 2, 3 }), table.concat({ "a", "b" }, "-", 1, 2))
Split and trim
local function split(s, sep)
local out = {}
for piece in (s .. sep):gmatch("(.-)" .. sep:gsub("%p", "%%%0")) do
out[#out + 1] = piece
end
return out
end
local function trim(s) return (s:gsub("^%s+", ""):gsub("%s+$", "")) end
local fields = split("a,b,,c", ",")
print(#fields, table.concat(fields, "|"), "[" .. trim(" padded ") .. "]")
More String Tasks
Lines, padding and case helpers
local text = "first\nsecond\r\nthird"
for line in (text .. "\n"):gmatch("(.-)\r?\n") do io.write("[", line, "] ") end
print()
local function lpad(s, n, ch) return (ch or " "):rep(n - #s) .. s end
local function capitalize(s) return (s:gsub("^%l", string.upper)) end
print(lpad("42", 5, "0"), capitalize("lua"), ("Title Case Me"):lower():gsub("%f[%a]%a", string.upper))
Binary data with string.pack
local packed = string.pack("<i4 I2 z", -2, 513, "hi")
print(#packed, (packed:gsub(".", function(c) return ("%02x "):format(c:byte()) end)))
print(string.unpack("<i4 I2 z", packed))
print(string.packsize("<i4 I2 d"))
Patterns
Lua patterns, character classes, captures, find, match, gmatch, gsub and how they differ from regex
Pattern Syntax
Character classes
| Class | Matches | Class | Matches |
|---|---|---|---|
. | any character | %a | letters |
%d | digits | %l %u | lower case, upper case letters |
%s | white space | %w | letters and digits |
%p | punctuation | %x | hexadecimal digits |
%c | control characters | %g | printable characters except space |
[abc] [a-z] | a set, a range | [^%d] | complement of a set |
%A %D %S | upper case class = complement | %% %. | an escaped magic character |
Repetition, anchors and captures
| Item | Meaning |
|---|---|
* | 0 or more, longest match |
+ | 1 or more, longest match |
- | 0 or more, shortest match (the lazy one) |
? | optional |
^ $ | anchor at the start, at the end |
(...) | capture |
() | capture the current position |
%1 | the text of capture 1 again |
%bxy | a balanced pair, such as %b() or %b{} |
%f[set] | frontier: the edge where the set starts |
Not regular expressions
-- regex Lua pattern -- \d+ %d+ -- \s*(\w+) %s*(%w+) -- [^,]+ [^,]+ -- a|b not possible: two patterns, or LPeg -- \.txt$ %.txt$
find, match, gmatch, gsub
The four functions
local s = "key = value; other = 42"
print(s:find("(%w+)%s*=%s*(%w+)"))
print(s:match("(%w+)%s*=%s*(%w+)"))
for k, v in s:gmatch("(%w+)%s*=%s*(%w+)") do
print("pair", k, v)
end
print(s:gsub("%w+", string.upper))
print(("2026-09-29"):match("^(%d+)-(%d+)-(%d+)$"))
gsub with a string, a table or a function
local tpl = "Hello $name, you have $count messages"
print((tpl:gsub("%$(%w+)", { name = "ann", count = 3 })))
print((("hello world"):gsub("(%w+)", "<%1>")))
print((("3 apples and 12 pears"):gsub("%d+", function(n) return n * 2 end)))
print((("a,b,c"):gsub(",", ";", 1)))
Lazy matching, balanced pairs and frontiers
local html = "<b>bold</b> and <i>italic</i>"
print(html:match("<(.-)>"), html:match("<(.*)>"))
print(("f(a(b)c) + g(x)"):match("%b()"))
print(("THE (quick) fox"):gsub("%f[%a]%a+", function(w) return w:lower() end))
print((" trim me "):match("^%s*(.-)%s*$"))
Match a literal string
local path = "notes_v2.txt"
print(path:find(".v2"))
print(path:find(".v2", 1, true))
local function escape(s) return (s:gsub("%p", "%%%0")) end
print(escape("1+1=2?"), ("cost: $5.00"):gsub(escape("$5.00"), "five dollars"))
Common Pattern Recipes
Parse key=value lines, numbers and URLs
local ini = "name = demo\nport=8080\n# comment\ndebug = true"
local cfg = {}
for k, v in ini:gmatch("([%w_]+)%s*=%s*([^\n]+)") do cfg[k] = v end
print(cfg.name, cfg.port, cfg.debug)
local nums = {}
for n in ("x=3.5, y=-2, z=1e3"):gmatch("-?%d+%.?%d*e?%d*") do nums[#nums + 1] = tonumber(n) end
print(table.concat(nums, " "))
print(("https://example.com:8443/path?q=1"):match("^(%a+)://([^/:]+):?(%d*)(/?[^?]*)"))
Tables
creating tables, fields, keys, nested tables, references and iteration
Creating and Using Tables
One data structure for everything
local user = {
name = "ann",
age = 31,
["favorite color"] = "blue",
[42] = "answer",
tags = { "admin", "dev" },
}
user.email = "ann@example.com"
user.age = nil
print(user.name, user["favorite color"], user[42], user.tags[2], user.age, #user.tags)
Tables are references
local a = { x = 1 }
local b = a
b.x = 2
print(a.x, a == b)
local function shallowCopy(t)
local c = {}
for k, v in pairs(t) do c[k] = v end
return c
end
local c = shallowCopy(a)
c.x = 3
print(a.x, c.x, next({}) == nil)
Iterate with pairs
local stock = { apple = 5, pear = 3, fig = 1 }
local keys = {}
for k in pairs(stock) do keys[#keys + 1] = k end
table.sort(keys)
for _, k in ipairs(keys) do
print(k, stock[k])
end
Nested Data
Tables of tables
local config = {
server = { host = "localhost", ports = { 8080, 8443 } },
debug = true,
}
print(config.server.host, config.server.ports[2], config.debug)
local db = config.database and config.database.host
print(db)
local ok = (config.server or {}).host
print(ok)
Printing and Copying Tables
Print a table
local function dump(v, indent)
indent = indent or ""
if type(v) ~= "table" then return type(v) == "string" and ("%q"):format(v) or tostring(v) end
local keys = {}
for k in pairs(v) do keys[#keys + 1] = k end
table.sort(keys, function(a, b) return tostring(a) < tostring(b) end)
local out = {}
for _, k in ipairs(keys) do
out[#out + 1] = indent .. " " .. tostring(k) .. " = " .. dump(v[k], indent .. " ")
end
return "{\n" .. table.concat(out, ",\n") .. "\n" .. indent .. "}"
end
print(dump({ name = "ann", tags = { "a", "b" }, n = 2 }))
Deep copy
local function deepcopy(v, seen)
if type(v) ~= "table" then return v end
seen = seen or {}
if seen[v] then return seen[v] end
local c = {}
seen[v] = c
for k, val in pairs(v) do c[deepcopy(k, seen)] = deepcopy(val, seen) end
return setmetatable(c, getmetatable(v))
end
local a = { list = { 1, 2 } }
a.self = a
local b = deepcopy(a)
b.list[1] = 99
print(a.list[1], b.list[1], b.self == b)
Sets
local function Set(list) local s = {} for _, v in ipairs(list) do s[v] = true end return s end
local a, b = Set { "x", "y", "z" }, Set { "y", "z", "w" }
local inter, union = {}, {}
for k in pairs(a) do if b[k] then inter[#inter + 1] = k end; union[k] = true end
for k in pairs(b) do union[k] = true end
table.sort(inter)
local n = 0 for _ in pairs(union) do n = n + 1 end
print(table.concat(inter, ","), n, a.x == true, a.w)
Arrays & Sequences
1-based sequences, the length operator, holes, ipairs, append, remove and 2D arrays
Sequences
Arrays start at 1
local fruits = { "apple", "banana", "cherry" }
print(fruits[1], fruits[3], fruits[0], #fruits)
fruits[#fruits + 1] = "date"
for i, f in ipairs(fruits) do
io.write(i, "=", f, " ")
end
print()
for i = #fruits, 1, -1 do io.write(fruits[i], " ") end
print()
Holes break the length
local t = { 1, 2, nil, 4 }
print(#t, select("#", 1, 2, nil, 4))
local count = 0
for _ in ipairs(t) do count = count + 1 end
print(count)
local packed = table.pack(1, 2, nil, 4)
print(packed.n, packed[4])
2D arrays and preallocation
local rows, cols = 3, 4
local grid = {}
for r = 1, rows do
grid[r] = {}
for c = 1, cols do grid[r][c] = (r - 1) * cols + c end
end
print(grid[2][3], #grid, #grid[1])
local big = table.create(1000)
print(#big)
Queues and Stacks
A double ended queue with two indexes
local Queue = {}
Queue.__index = Queue
function Queue.new() return setmetatable({ first = 1, last = 0 }, Queue) end
function Queue:push(v) self.last = self.last + 1; self[self.last] = v end
function Queue:pop()
if self.first > self.last then return nil end
local v = self[self.first]
self[self.first] = nil
self.first = self.first + 1
return v
end
local q = Queue.new()
for i = 1, 3 do q:push(i * 10) end
print(q:pop(), q:pop(), q.last - q.first + 1)
The table Library
insert, remove, sort, concat, unpack, move and pack
Sorting Keys and Counting
Count words and sort by frequency
local text = "the cat and the hat and the bat"
local count = {}
for w in text:gmatch("%a+") do count[w] = (count[w] or 0) + 1 end
local words = {}
for w in pairs(count) do words[#words + 1] = w end
table.sort(words, function(a, b)
if count[a] ~= count[b] then return count[a] > count[b] end
return a < b
end)
for i = 1, 3 do io.write(words[i], "=", count[words[i]], " ") end
print()
Insert, Remove, Move
The list functions
local t = { "a", "b", "c" }
table.insert(t, "d")
table.insert(t, 1, "z")
local removed = table.remove(t, 2)
local last = table.remove(t)
print(table.concat(t, ","), removed, last)
local dest = table.move({ 1, 2, 3 }, 1, 3, 2, { 0 })
print(table.concat(dest, ","), table.unpack({ 10, 20, 30 }, 2))
Sorting
local people = { { name = "cid", age = 31 }, { name = "ann", age = 25 }, { name = "bob", age = 31 } }
table.sort(people, function(a, b)
if a.age ~= b.age then return a.age > b.age end
return a.name < b.name
end)
for _, p in ipairs(people) do io.write(p.name, ":", p.age, " ") end
print()
local nums = { 5, 2, 8, 1 }
table.sort(nums)
print(table.concat(nums, " "))
Map, filter and reverse by hand
local function map(t, f) local r = {} for i, v in ipairs(t) do r[i] = f(v) end return r end
local function filter(t, f) local r = {} for _, v in ipairs(t) do if f(v) then r[#r + 1] = v end end return r end
local function reverse(t) local n = #t for i = 1, n // 2 do t[i], t[n - i + 1] = t[n - i + 1], t[i] end return t end
local nums = { 1, 2, 3, 4, 5, 6 }
local evens = filter(nums, function(n) return n % 2 == 0 end)
print(table.concat(map(evens, function(n) return n * n end), " "), table.concat(reverse(nums), " "))
Control Flow
if, while, repeat, numeric and generic for, break, goto and continue
Conditionals
if, elseif, else
local function grade(score)
if score >= 90 then
return "A"
elseif score >= 75 then
return "B"
else
return "C"
end
end
print(grade(95), grade(80), grade(10))
The and/or idiom instead of a ternary
local n = 7 local kind = n % 2 == 0 and "even" or "odd" local name = nil print(kind, name or "anonymous") local flag = true print(flag and false or "oops")
A lookup table instead of switch
local handlers = {
add = function(a, b) return a + b end,
mul = function(a, b) return a * b end,
}
local function run(op, a, b)
local h = handlers[op]
if not h then return nil, "unknown op " .. op end
return h(a, b)
end
print(run("add", 2, 3), run("mul", 4, 5), run("pow", 1, 1))
Loops
while, repeat and numeric for
local i = 1 while i <= 3 do io.write(i, " "); i = i + 1 end repeat io.write(i, " "); i = i - 1 until i < 1 print() for n = 10, 1, -3 do io.write(n, " ") end for x = 0, 1, 0.25 do io.write(x, " ") end print()
The loop variable is read only
for i = 1, 3 do i = i * 10 print(i) end
continue with goto
for i = 1, 6 do
if i % 2 == 0 then goto continue end
io.write(i, " ")
::continue::
end
print()
for _, row in ipairs({ { 1, 2 }, { 3, -1 }, { 5 } }) do
for _, v in ipairs(row) do
if v < 0 then goto done end
io.write(v, " ")
end
end
::done::
print("stopped")
Numeric for Details
Float steps and large ranges
local n = 0 for x = 0, 1, 0.1 do n = n + 1 end print(n) for i = math.maxinteger - 2, math.maxinteger do io.write(i % 1000, " ") end print() print(pcall(function() for i = 1, 10, 0 do end end))
Functions & Varargs
defining functions, multiple results, varargs, named vararg tables, select and table.unpack
Functions
Define, return several values
local function divmod(a, b)
return a // b, a % b
end
local q, r = divmod(17, 5)
print(q, r, divmod(17, 5))
print((divmod(17, 5)))
local t = { divmod(9, 4) }
print(#t)
Default and named arguments
local function greet(name, greeting)
greeting = greeting or "Hello"
return greeting .. ", " .. (name or "world")
end
local function box(opts)
local w, h = opts.w or 1, opts.h or 1
return w * h
end
print(greet(), greet("ann", "Hi"), box { w = 3, h = 4 }, box {})
local function for recursion
local function fact(n) if n <= 1 then return 1 end return n * fact(n - 1) end local function countdown(n) if n == 0 then return "liftoff" end return countdown(n - 1) end print(fact(20), countdown(1000000))
Varargs
..., select and table.pack
local function stats(...)
local n = select("#", ...)
local args = table.pack(...)
local sum = 0
for i = 1, args.n do sum = sum + (args[i] or 0) end
return n, sum, (select(2, ...))
end
print(stats(4, nil, 6))
Named vararg tables
local function sum(...nums) local total = 0 for i = 1, nums.n do total = total + nums[i] end return total, nums.n end print(sum(1, 2, 3, 4))
Function Tricks
Functions as arguments, and partial application
local function compose(f, g) return function(...) return f(g(...)) end end
local function partial(f, a) return function(...) return f(a, ...) end end
local add = function(a, b) return a + b end
local inc = partial(add, 1)
local double = function(x) return x * 2 end
print(inc(41), compose(double, inc)(4), select("#", compose(print, inc)))
Closures & Upvalues
closures, upvalues, counters, memoization and private state
Closures
Functions capture variables, not values
local function counter()
local n = 0
return function()
n = n + 1
return n
end
end
local c1, c2 = counter(), counter()
print(c1(), c1(), c1(), c2())
Each loop iteration gets a new variable
local fns = {}
for i = 1, 3 do
fns[i] = function() return i end
end
print(fns[1](), fns[2](), fns[3]())
Memoize a function
local function memoize(f)
local cache = {}
return function(n)
local v = cache[n]
if v == nil then v = f(n); cache[n] = v end
return v
end
end
local calls = 0
local fib
fib = memoize(function(n)
calls = calls + 1
if n < 2 then return n end
return fib(n - 1) + fib(n - 2)
end)
print(fib(80), calls)
Private state without classes
local function newAccount(balance)
local self = {}
function self.deposit(v) balance = balance + v end
function self.balance() return balance end
return self
end
local acc = newAccount(100)
acc.deposit(50)
print(acc.balance(), acc.balanceValue)
Errors & pcall
error, assert, pcall, xpcall, error objects, tracebacks, nil plus message and load
Raising and Catching
pcall catches errors
local function divide(a, b)
if b == 0 then error("division by zero") end
return a / b
end
print(pcall(divide, 10, 2))
print(pcall(divide, 1, 0))
local ok, err = pcall(function() local t = nil; return t.x end)
print(ok, err)
Two error styles
local function parsePort(s)
local n = tonumber(s)
if not n then return nil, "not a number: " .. s end
return n
end
print(parsePort("8080"))
print(parsePort("http"))
local port = assert(parsePort("443"))
print(port, pcall(assert, parsePort("x")))
Error objects and xpcall with a traceback
local function fail()
error({ code = 404, msg = "not found" })
end
local ok, e = pcall(fail)
print(ok, type(e), e.code, e.msg)
local ok2, trace = xpcall(function() error("boom") end, debug.traceback)
print(ok2, trace:match("^[^\n]+"), trace:find("stack traceback") ~= nil)
load runs code from a string
local f, err = load("return 2 + 3 * x", "calc", "t", { x = 4 })
print(f and f(), err)
print(load("return +"))
print(pcall(load("error('inside')")))
Cleanup on Error
A finally block with pcall
local function withFile(path, fn)
local f = assert(io.open(path, "w"))
local ok, err = pcall(fn, f)
f:close()
if not ok then error(err, 0) end
return true
end
local path = os.tmpname()
print(pcall(withFile, path, function(f) f:write("x"); error("write failed", 0) end))
print(withFile(path, function(f) f:write("ok") end))
os.remove(path)
Iterators
generic for, pairs, ipairs, next, stateless and stateful iterators, and closing iterators
The Generic for
pairs, ipairs and next
local t = { 10, 20, 30, x = "a" }
for i, v in ipairs(t) do io.write(i, ":", v, " ") end
print()
local count = 0
for k, v in pairs(t) do count = count + 1 end
print(count, next(t))
Write your own iterator with a closure
local function range(from, to, step)
step = step or 1
local i = from - step
return function()
i = i + step
if (step > 0 and i <= to) or (step < 0 and i >= to) then return i end
end
end
for n in range(1, 10, 3) do io.write(n, " ") end
print()
Stateless iterators
local function evens(t, i)
repeat i = i + 1 until t[i] == nil or t[i] % 2 == 0
if t[i] ~= nil then return i, t[i] end
end
local function each_even(t) return evens, t, 0 end
for i, v in each_even({ 1, 2, 3, 4, 6 }) do io.write(i, "=", v, " ") end
print()
Close resources when the loop ends
local function lines(list)
local i = 0
local closer = setmetatable({}, { __close = function() print("closed") end })
return function()
i = i + 1
return list[i]
end, nil, nil, closer
end
for line in lines({ "a", "b", "c" }) do
if line == "b" then break end
print(line)
end
Metatables
setmetatable, __index, __newindex, operators, __call, __tostring, __eq, __lt, __len, __close and rawget
Metatables
__index supplies missing keys
local defaults = { color = "blue", size = 10 }
local item = setmetatable({ size = 3 }, { __index = defaults })
print(item.size, item.color, rawget(item, "color"))
local counter = setmetatable({}, { __index = function(t, k) return 0 end })
counter.apples = counter.apples + 1
print(counter.apples, counter.pears)
Operators and tostring
local V = {}
V.__index = V
local function vec(x, y) return setmetatable({ x = x, y = y }, V) end
V.__add = function(a, b) return vec(a.x + b.x, a.y + b.y) end
V.__eq = function(a, b) return a.x == b.x and a.y == b.y end
V.__tostring = function(v) return "(" .. v.x .. ", " .. v.y .. ")" end
V.__len = function(v) return math.sqrt(v.x ^ 2 + v.y ^ 2) end
local a, b = vec(1, 2), vec(3, 4)
print(tostring(a + b), a + b == vec(4, 6), #b)
__newindex, __call and read only tables
local function readonly(t)
return setmetatable({}, {
__index = t,
__newindex = function(_, k) error("attempt to change " .. tostring(k), 2) end,
__len = function() return #t end,
})
end
local days = readonly({ "mon", "tue" })
print(days[1], #days, pcall(function() days[3] = "wed" end))
local Adder = setmetatable({}, { __call = function(self, a, b) return a + b end })
print(Adder(2, 3))
The metamethods
| Metamethod | Triggered by |
|---|---|
__index __newindex | reading or writing a missing key |
__call | calling the value like a function |
__tostring __name | tostring and print, error messages |
__eq __lt __le | == < <= |
__add __sub __mul __div __idiv __mod __pow __unm | arithmetic |
__band __bor __bxor __shl __shr __bnot | bitwise operators, 5.3+ |
__concat __len | .. and # |
__close | to-be-closed variables, 5.4+ |
__gc __mode | finalizers and weak tables |
__metatable | value returned by getmetatable, and it blocks setmetatable |
Proxies and Defaults
Track every access with a proxy
local function tracked(t, log)
return setmetatable({}, {
__index = function(_, k) log[#log + 1] = "get " .. tostring(k); return t[k] end,
__newindex = function(_, k, v) log[#log + 1] = "set " .. tostring(k); t[k] = v end,
__pairs = function() return next, t, nil end,
})
end
local log = {}
local p = tracked({ a = 1 }, log)
p.b = p.a + 1
for k, v in pairs(p) do io.write(k, "=", v, " ") end
print()
print(table.concat(log, ", "))
OOP & Classes
classes with metatables, the colon syntax, constructors, inheritance, super calls and mixins
Classes
A class is a table used as a metatable
local Account = {}
Account.__index = Account
function Account.new(owner, balance)
return setmetatable({ owner = owner, balance = balance or 0 }, Account)
end
function Account:deposit(v)
self.balance = self.balance + v
return self
end
function Account:__tostring()
return ("%s: %d"):format(self.owner, self.balance)
end
local a = Account.new("ann"):deposit(50):deposit(25)
print(tostring(a), getmetatable(a) == Account)
Inheritance
local Animal = {}
Animal.__index = Animal
function Animal.new(name) return setmetatable({ name = name }, Animal) end
function Animal:speak() return self.name .. " makes a sound" end
function Animal:describe() return "I am " .. self.name .. ". " .. self:speak() end
local Dog = setmetatable({}, { __index = Animal })
Dog.__index = Dog
function Dog.new(name, breed)
local self = Animal.new(name)
self.breed = breed
return setmetatable(self, Dog)
end
function Dog:speak() return self.name .. " barks (" .. Animal.speak(self) .. ")" end
local d = Dog.new("Rex", "collie")
print(d:describe())
print(d.breed, getmetatable(d) == Dog)
A tiny class helper
local function class(parent)
local c = {}
c.__index = c
if parent then setmetatable(c, { __index = parent }) end
c.new = function(...)
local o = setmetatable({}, c)
if o.init then o:init(...) end
return o
end
return c
end
local Point = class()
function Point:init(x, y) self.x, self.y = x, y end
local Point3 = class(Point)
function Point3:init(x, y, z) Point.init(self, x, y); self.z = z end
local p = Point3.new(1, 2, 3)
print(p.x, p.y, p.z)
Class Extras
Sorting objects with __lt, and type checks
local Version = {}
Version.__index = Version
function Version.new(s)
local a, b, c = s:match("^(%d+)%.(%d+)%.(%d+)$")
return setmetatable({ tonumber(a), tonumber(b), tonumber(c), text = s }, Version)
end
Version.__lt = function(x, y)
for i = 1, 3 do if x[i] ~= y[i] then return x[i] < y[i] end end
return false
end
Version.__tostring = function(v) return v.text end
local list = { Version.new("5.4.9"), Version.new("5.10.0"), Version.new("5.5.1") }
table.sort(list)
print(tostring(list[1]), tostring(list[2]), tostring(list[3]), getmetatable(list[1]) == Version)
Coroutines
create, resume, yield, wrap, status, generators, producer consumer and coroutine.close
Coroutines
resume and yield pass values both ways
local co = coroutine.create(function(a, b)
print("start", a, b)
local c = coroutine.yield(a + b)
print("got", c)
local d, e = coroutine.yield(c * 2)
return d + e
end)
print(coroutine.resume(co, 1, 2))
print(coroutine.resume(co, 10))
print(coroutine.resume(co, 3, 4))
print(coroutine.status(co), coroutine.resume(co))
Generators with coroutine.wrap
local function fib(n)
return coroutine.wrap(function()
local a, b = 0, 1
for _ = 1, n do
coroutine.yield(a)
a, b = b, a + b
end
end)
end
for v in fib(10) do io.write(v, " ") end
print()
Errors, status and close
local co = coroutine.create(function() coroutine.yield(1); error("broke") end)
print(coroutine.resume(co))
print(coroutine.resume(co))
print(coroutine.status(co))
local idle = coroutine.create(function() coroutine.yield() end)
coroutine.resume(idle)
print(coroutine.status(idle), coroutine.close(idle), coroutine.status(idle))
A round robin scheduler
local tasks = {}
local function spawn(name, steps)
tasks[#tasks + 1] = coroutine.wrap(function()
for i = 1, steps do
io.write(name, i, " ")
coroutine.yield(true)
end
return false
end)
end
spawn("a", 3); spawn("b", 2)
while #tasks > 0 do
for i = #tasks, 1, -1 do
if not tasks[i]() then table.remove(tasks, i) end
end
end
print()
Coroutine Pipelines
Producer, filter, consumer
local function producer()
return coroutine.wrap(function()
for _, line in ipairs({ "ok 1", "error 2", "ok 3", "error 4" }) do coroutine.yield(line) end
end)
end
local function filter(src, pattern)
return coroutine.wrap(function()
for line in src do if line:find(pattern) then coroutine.yield(line:upper()) end end
end)
end
for line in filter(producer(), "^error") do print(line) end
Modules & require
writing a module, require, package.path, package.loaded, reloading and avoiding globals
Modules
A module returns a table
-- file: mathx.lua
local M = {}
local function clamp(x, lo, hi) return math.max(lo, math.min(hi, x)) end
function M.lerp(a, b, t)
return a + (b - a) * clamp(t, 0, 1)
end
return M
-- file: main.lua
local mathx = require("mathx")
print(mathx.lerp(0, 10, 0.25))
How require finds files
print(package.path:gsub(";", "\n"))
package.path = "./lib/?.lua;./lib/?/init.lua;" .. package.path
print(package.loaded.string == string, package.loaded["os"] ~= nil)
Preload and reload
package.preload["greet"] = function(name)
print("loading", name)
return { hi = function(who) return "hi " .. who end }
end
local g1 = require("greet")
local g2 = require("greet")
print(g1 == g2, g1.hi("ann"))
package.loaded["greet"] = nil
local g3 = require("greet")
print(g1 == g3)
Memory & GC
the garbage collector, collectgarbage, weak tables, __gc finalizers and to-be-closed variables
The Garbage Collector
Measure and control it
local before = collectgarbage("count")
local big = {}
for i = 1, 100000 do big[i] = i end
local during = collectgarbage("count")
big = nil
collectgarbage()
local after = collectgarbage("count")
print(during > before, after < during, math.type(collectgarbage("count")))
print(collectgarbage("incremental"), collectgarbage("generational"))
Weak tables for caches
local cache = setmetatable({}, { __mode = "k" })
local key = {}
cache[key] = "data"
local n = 0
for _ in pairs(cache) do n = n + 1 end
key = nil
collectgarbage()
local m = 0
for _ in pairs(cache) do m = m + 1 end
print(n, m)
<close> releases resources on scope exit
local function resource(name)
return setmetatable({ name = name }, {
__close = function(self, err) print("closing " .. self.name, err) end,
})
end
do
local a <close> = resource("a")
local b <close> = resource("b")
print("working")
end
print(pcall(function()
local c <close> = resource("c")
error("failed", 0)
end))
Files & I/O
io.open, reading and writing files, io.lines, read formats, stdin, stdout and error handling
Reading and Writing Files
Write, append and read a whole file
local path = os.tmpname()
local f = assert(io.open(path, "w"))
f:write("line one\n", "line two\n", 42, "\n")
f:close()
local a = assert(io.open(path, "a"))
a:write("appended\n")
a:close()
local r = assert(io.open(path, "r"))
local all = r:read("a")
r:close()
io.write(all)
print(#all, io.open("/no/such/file"))
os.remove(path)
Line by line and by format
local path = os.tmpname()
local f = assert(io.open(path, "w"))
f:write("alpha\nbeta\n10 20 3.5\n")
f:close()
for line in io.lines(path) do io.write("[", line, "] ") end
print()
local h = assert(io.open(path))
print(h:read("l"), h:read("L"), h:read("n", "n", "n"))
h:close()
os.remove(path)
stdin, stdout, stderr and seek
io.write("no newline, ")
io.stdout:write("explicit handle\n")
io.stderr:write("warnings go to stderr\n")
local f = io.tmpfile()
f:write("0123456789")
f:seek("set", 3)
print(f:read(4), f:seek("cur"), f:seek("end"))
f:close()
Run a command and read its output
local p = assert(io.popen("ls -1 /", "r"))
local count = 0
for _ in p:lines() do count = count + 1 end
print(p:close())
print(os.execute("exit 3"))
Reading CSV
Simple CSV with quoted fields
local function parseCSVLine(line)
local fields, pos = {}, 1
while pos <= #line + 1 do
local c = line:sub(pos, pos)
if c == '"' then
local value, i = "", pos + 1
while true do
local s, e = line:find('"', i, true)
value = value .. line:sub(i, (s or #line + 1) - 1)
if not s then pos = #line + 2 break end
if line:sub(e + 1, e + 1) == '"' then value = value .. '"'; i = e + 2
else fields[#fields + 1] = value; pos = e + 2 break end
end
else
local s = line:find(",", pos, true) or #line + 1
fields[#fields + 1] = line:sub(pos, s - 1)
pos = s + 1
end
end
return fields
end
local f = parseCSVLine('42,"Smith, Ann","said ""hi""",')
print(#f, f[2], f[3], f[4] == "")
OS, Time & Env
os.time, os.date, os.clock, environment variables, files and exit codes
Dates and Times
os.time, os.date and os.clock
local t = os.time({ year = 2026, month = 9, day = 29, hour = 14, min = 5, sec = 9 })
print(os.date("%Y-%m-%d %H:%M:%S", t))
print(os.date("!%Y-%m-%dT%H:%M:%SZ", 0))
local d = os.date("*t", t)
print(d.year, d.month, d.day, d.wday, d.yday, d.isdst)
local later = os.time({ year = 2026, month = 12, day = 25, hour = 0 })
print(math.floor(os.difftime(later, t) / 86400), "days")
local c = os.clock()
for i = 1, 1e6 do end
print(os.clock() - c >= 0)
Environment and exit
print(os.getenv("HOME") ~= nil, os.getenv("NOT_SET_ANYWHERE"))
local mode = os.getenv("APP_MODE") or "development"
print(mode)
local old, new = os.tmpname(), os.tmpname()
local f = assert(io.open(old, "w")); f:close()
print(os.rename(old, new), os.remove(new), (os.remove(new)))
Date Formats
os.date format codes
| Code | Meaning | Code | Meaning |
|---|---|---|---|
%Y | year, 2026 | %H | hour, 00 to 23 |
%m | month, 01 to 12 | %M | minute |
%d | day of the month | %S | second |
%j | day of the year | %p | AM or PM |
%a %A | weekday name, short and long | %b %B | month name, short and long |
%c | date and time for the locale | %x %X | date, time for the locale |
*t !*t | a date table, local or UTC | %% | a percent sign |
Durations and ISO dates
local function iso(t) return os.date("!%Y-%m-%dT%H:%M:%SZ", t) end
local start = os.time({ year = 2026, month = 1, day = 31, hour = 12 })
local d = os.date("*t", start)
d.month = d.month + 1
local nextMonth = os.time(d)
print(os.date("%Y-%m-%d", nextMonth), iso(86400 * 365))
local secs = 3725
print(("%dh %02dm %02ds"):format(secs // 3600, secs % 3600 // 60, secs % 60))
UTF-8
the utf8 library, characters versus bytes, iterating code points and escapes
The utf8 Library
Characters, not bytes
local s = "naïve café ☕"
print(#s, utf8.len(s))
for pos, cp in utf8.codes("é☕") do io.write(pos, ":", cp, " ") end
print()
print(utf8.char(72, 228, 8364, 128512), "\u{1F600}")
print(utf8.codepoint("€"), utf8.len("\xff"))
Substrings by character
local function usub(s, i, j)
local first = utf8.offset(s, i)
local last = j and utf8.offset(s, j + 1) or #s + 1
return s:sub(first, last - 1)
end
print(usub("héllo wörld", 2, 4), usub("日本語テキスト", 3))
for ch in ("añb"):gmatch(utf8.charpattern) do io.write("[", ch, "]") end
print()
C API & Embedding
embedding Lua in C, the stack, calling Lua from C, writing C modules and userdata
Embedding Lua in C
Run a script from C
#include <lua.h>
#include <lauxlib.h>
#include <lualib.h>
int main(void) {
lua_State *L = luaL_newstate();
luaL_openlibs(L);
if (luaL_dostring(L, "return 6 * 7") != LUA_OK) {
fprintf(stderr, "%s\n", lua_tostring(L, -1));
return 1;
}
printf("%lld\n", (long long)lua_tointeger(L, -1));
lua_close(L);
return 0;
}
cc main.c -o app $(pkg-config --cflags --libs lua5.4)
Call a Lua function from C
lua_getglobal(L, "add");
lua_pushinteger(L, 2);
lua_pushinteger(L, 3);
if (lua_pcall(L, 2, 1, 0) == LUA_OK) {
printf("%lld\n", (long long)lua_tointeger(L, -1));
lua_pop(L, 1);
}
Writing a C Module
A module that Lua can require
#include <lua.h>
#include <lauxlib.h>
static int l_add(lua_State *L) {
lua_Integer a = luaL_checkinteger(L, 1);
lua_Integer b = luaL_checkinteger(L, 2);
lua_pushinteger(L, a + b);
return 1;
}
static const luaL_Reg funcs[] = {
{"add", l_add},
{NULL, NULL}
};
int luaopen_fastmath(lua_State *L) {
luaL_newlib(L, funcs);
return 1;
}
cc -shared -fPIC -o fastmath.so fastmath.c $(pkg-config --cflags lua5.4)
lua -e 'print(require("fastmath").add(2, 3))'
LuaJIT & FFI
LuaJIT compatibility, the FFI, the bit library, and code that runs on both LuaJIT and PUC Lua
LuaJIT
What LuaJIT is
| Question | LuaJIT 2.1 |
|---|---|
| Language | Lua 5.1, plus goto, hex and \z escapes and a few 5.2 functions |
| Integers | no integer subtype, numbers are doubles; 64-bit integers only through the FFI |
| Bitwise | the bit library: bit.band, bit.bor, bit.bxor, bit.lshift |
| Speed | a tracing JIT, often several times faster than PUC Lua on hot loops |
| Used by | Neovim, OpenResty and nginx, LÖVE by default, many game engines |
| Releases | a rolling release from the v2.1 branch, no numbered tags |
Call C directly with the FFI
local ffi = require("ffi")
ffi.cdef[[
int printf(const char *fmt, ...);
typedef struct { double x, y; } point_t;
]]
ffi.C.printf("Hello from %s\n", "the FFI")
local p = ffi.new("point_t", 3, 4)
print(math.sqrt(p.x ^ 2 + p.y ^ 2))
local buf = ffi.new("uint8_t[?]", 16)
print(ffi.sizeof(buf), jit.version)
Write code that runs on both
local unpack = table.unpack or unpack
local bit = bit32 or (jit and require("bit"))
local function band(a, b)
if bit then return bit.band(a, b) end
return load("local a, b = ... return a & b")(a, b)
end
print(unpack({ 1, 2 }), band(12, 10))
The old ways that LuaJIT code still uses
local env = { print = print }
local f = function() x = 10; print("x in sandbox:", x) end
setfenv(f, env)
f()
print("x outside:", x, table.getn({ 1, 2, 3 }), unpack({ "a", "b" }))
Neovim Lua
init.lua, options, keymaps, autocommands, user commands, plugins with vim.pack and LSP
Configuration in init.lua
Options, leader and key mappings
vim.g.mapleader = " "
vim.o.number = true
vim.o.relativenumber = true
vim.o.expandtab = true
vim.o.shiftwidth = 2
vim.o.undofile = true
vim.keymap.set("n", "<leader>w", "<cmd>write<cr>", { desc = "Save file" })
vim.keymap.set({ "n", "v" }, "<leader>y", '"+y', { desc = "Copy to clipboard" })
vim.keymap.set("n", "<leader>e", function()
vim.diagnostic.open_float()
end, { desc = "Show diagnostics" })
Autocommands and user commands
local group = vim.api.nvim_create_augroup("MyConfig", { clear = true })
vim.api.nvim_create_autocmd("TextYankPost", {
group = group,
callback = function() vim.hl.on_yank({ timeout = 150 }) end,
})
vim.api.nvim_create_autocmd("FileType", {
group = group,
pattern = { "lua", "go" },
callback = function(args) vim.bo[args.buf].shiftwidth = 4 end,
})
vim.api.nvim_create_user_command("Scratch", function()
vim.cmd("enew | setlocal buftype=nofile")
end, { desc = "Open a scratch buffer" })
Plugins with the built-in vim.pack
vim.pack.add({
"https://github.com/nvim-treesitter/nvim-treesitter",
{ src = "https://github.com/nvim-lua/plenary.nvim" },
{ src = "https://github.com/lewis6991/gitsigns.nvim", version = vim.version.range("1.0") },
})
require("gitsigns").setup()
-- :lua vim.pack.update() updates plugins
Language servers
vim.lsp.config("lua_ls", {
settings = {
Lua = {
runtime = { version = "LuaJIT" },
diagnostics = { globals = { "vim" } },
},
},
})
vim.lsp.enable({ "lua_ls", "gopls" })
Neovim Lua API
Buffers, notifications and inspecting values
local buf = vim.api.nvim_get_current_buf()
local lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false)
vim.notify(("%d lines in %s"):format(#lines, vim.api.nvim_buf_get_name(buf)))
print(vim.inspect({ mode = vim.fn.mode(), ft = vim.bo.filetype }))
vim.system({ "git", "status", "--short" }, { text = true }, function(res)
vim.schedule(function() vim.notify(res.stdout) end)
end)
LÖVE & Roblox
game loops in LÖVE, delta time, input and drawing, and Roblox Luau
LÖVE
The game loop
local player = { x = 400, y = 300, speed = 220 }
function love.load()
love.window.setTitle("Dodge")
end
function love.update(dt)
if love.keyboard.isDown("left") then player.x = player.x - player.speed * dt end
if love.keyboard.isDown("right") then player.x = player.x + player.speed * dt end
end
function love.draw()
love.graphics.circle("fill", player.x, player.y, 16)
love.graphics.print(("FPS %d"):format(love.timer.getFPS()), 10, 10)
end
Roblox Luau
Luau adds types and a task library
--!strict
local Players = game:GetService("Players")
local function greet(player: Player): string
return "Welcome, " .. player.Name
end
Players.PlayerAdded:Connect(function(player)
print(greet(player))
task.wait(1)
player:LoadCharacter()
end)
LuaRocks & Tooling
LuaRocks packages, busted tests, luacheck, StyLua, the language server and debugging
LuaRocks
Install and publish packages
luarocks install lpeg # a package, system wide or with sudo luarocks install --local busted # into ~/.luarocks, no sudo luarocks --lua-version=5.1 install luasocket luarocks list luarocks path >> ~/.bashrc # set LUA_PATH and LUA_CPATH for --local luarocks make # build the rockspec in this folder
Testing and Linting
Unit tests with busted
-- spec/strutil_spec.lua
local strutil = require("strutil")
describe("strutil.trim", function()
it("removes surrounding spaces", function()
assert.are.equal("hi", strutil.trim(" hi "))
end)
it("keeps inner spaces", function()
assert.are.equal("a b", strutil.trim(" a b "))
end)
end)
busted spec/
Lint, format and annotate
luacheck src/ --std=lua54 --globals vim stylua --check src/ stylua src/ ---@param name string ---@param times? integer ---@return string local function shout(name, times) return (name:upper() .. "!"):rep(times or 1) end
Debug and profile
print(debug.traceback("where am I", 1))
local info = debug.getinfo(1, "Sl")
print(info.short_src, info.currentline)
local t0 = os.clock()
local s = 0
for i = 1, 3e6 do s = s + i end
print(("loop took %.0f ms"):format((os.clock() - t0) * 1000) ~= nil)
Portable Scripts
Detect the platform and path separator
local sep = package.config:sub(1, 1)
local isWindows = sep == "\\"
local function join(...) return table.concat({ ... }, sep) end
print(isWindows, join("data", "logs", "app.log"))
print(os.getenv("OS") == "Windows_NT" or io.popen ~= nil)
Errors & Gotchas
common error messages and the surprises that catch Lua newcomers
Error Messages
attempt to index a nil value
local config = {}
print(config.server.port)
attempt to call a nil value
local obj = { name = "ann" }
print(obj:getName())
attempt to concatenate or do arithmetic on a nil value
local user = { first = "ann" }
print("Hi " .. user.last)
What the messages mean
| Message | Usual cause |
|---|---|
| attempt to index a nil value (field 'x') | a missing field or failed require |
| attempt to call a nil value (method 'm') | a typo, or . and : mixed up |
| attempt to compare number with nil | a missing value in a condition or sort function |
| bad argument #1 to 'insert' (table expected, got nil) | the table variable is nil |
| number has no integer representation | %d or an integer function given 2.5 |
| stack overflow | infinite recursion, often __index calling itself |
| invalid order function for sorting | a comparator that returns true for equal items |
| unexpected symbol near 'x' | a syntax error just before x, such as a missing then or end |
Surprises
. versus :
local Stack = { items = {} }
function Stack:push(v) table.insert(self.items, v) end
Stack:push(1)
Stack.push(Stack, 2)
print(#Stack.items, pcall(Stack.push, 3))
Globals by default, 1-based indexes and ~=
local function f()
helper = "oops"
end
f()
local t = { "a", "b" }
print(helper, t[0], t[1], 1 ~= 2, 0 and "0 is true")
Lua 5.1 to 5.5
what each version added, which platforms use which version, and compatibility
What Each Version Added
The main changes
| Version | Released | Highlights |
|---|---|---|
| Lua 5.5 | Dec 2025, 5.5.1 Aug 2026 | global declarations, named vararg tables, read only for variables, table.create, compact arrays, incremental major GC |
| Lua 5.4 | Jun 2020, 5.4.9 Aug 2026 | <const> and <close>, generational GC, coroutine.close, warn, new random generator |
| Lua 5.3 | Jan 2015 | integer subtype, // and bitwise operators, utf8 library, string.pack, table.move |
| Lua 5.2 | Dec 2011 | _ENV instead of setfenv, goto, \z and \x escapes, table.pack and unpack, bit32 |
| Lua 5.1 | Feb 2006 | the module system with require, # operator, varargs with ...; the base of LuaJIT and Luau |
Which Lua does each platform run
| Platform | Lua version |
|---|---|
| Neovim | LuaJIT (Lua 5.1 semantics) |
| LÖVE | LuaJIT, or Lua 5.1 where LuaJIT is unavailable |
| Roblox | Luau, derived from Lua 5.1 |
| OpenResty and nginx | LuaJIT |
| Redis scripts | Lua 5.1 |
| World of Warcraft addons | Lua 5.1 |
| Pandoc filters, Nmap scripts | Lua 5.4 |
| Standalone scripts | Lua 5.4 or 5.5 |
Check the running version
print(_VERSION)
print(math.type ~= nil and "5.3+ integers" or "no integer subtype")
print(jit and jit.version or "not LuaJIT")
local ok = load("local x <const> = 1")
print(ok and "5.4+ attributes" or "no attributes")
What Is a Lua Cheat Sheet
A Lua cheat sheet is a compact reference to the syntax, tables, string patterns, standard library and tooling of the Lua programming language, arranged so you find the right form of a metatable, a gsub call or a coroutine in seconds. People also look for a Lua programming cheat sheet or a Lua quick reference; they mean the same thing, usually while writing a Neovim config, a game script or a plugin for an application that embeds Lua.
This Lua cheat sheet covers 30 sections and 146 snippets for Lua 5.1 and LuaJIT through Lua 5.5, the current version. Every snippet that needs a newer version carries a chip, rows that rely on features removed in later versions carry a removed chip, and the version picker in the toolbar hides what your Lua cannot run. Rows that only apply to Neovim, LÖVE, Roblox, LuaJIT or LuaRocks modules carry a platform chip, and core Lua mode hides them. The output under the example scripts is what they printed when run on Compiler Explorer with Lua 5.5.0, or Lua 5.1.5 for rows about removed features.
The toolbox at the top lints Lua code for the classic mistakes, works out the minimum Lua version a script needs and whether LuaJIT can run it, tests Lua patterns with find, match, gmatch and gsub using a port of Lua's own matcher, checks string.format calls against the Lua 5.4 rules, converts JSON to Lua tables and back, and writes any text as a Lua string literal. The builders write class skeletons, modules, LuaRocks rockspecs, Neovim configuration and LÖVE game skeletons. Everything runs in your browser.
How It Differs From the Reference Manual
The Lua reference manual defines the language precisely; this page shows the shortest working form of each idiom next to the mistake that usually comes with it: the accidental global, the dot instead of a colon, the hole in an array, the pattern that treats a dot as any character. For the regular expressions Lua patterns are often confused with, see the regex cheat sheet; for the C side of embedding Lua, the C cheat sheet.
What This Lua Cheat Sheet Covers
The 30 sections go from the first script to embedding Lua in C, and each link jumps to that section above.
- Foundations: installing and running Lua with the toolbox and builders, syntax and operators, variables, scope and global declarations, types and truthiness, and integers, floats and math.
- Strings and tables: strings and string.format, Lua patterns, tables, arrays and sequences, and the table library.
- Code: control flow and goto, functions and varargs, closures, errors and pcall, and iterators.
- Advanced: metatables, classes and inheritance, coroutines, modules and require, and memory, weak tables and <close>.
- Libraries: files and I/O, dates, time and the environment, UTF-8, and the C API.
- Ecosystem: LuaJIT and the FFI, Neovim configuration, LÖVE and Roblox, and LuaRocks, testing and linting.
- Help: error messages and gotchas, and what changed from Lua 5.1 to Lua 5.5.
Lua Syntax Quick Reference
The forms people look up most often, one line each. The sections above show each of them in a full example with its output.
| Task | Lua |
|---|---|
| Local variable | local name = "ann" |
| Constant (5.4+) | local MAX <const> = 10 |
| Comment | -- line, --[[ block ]] |
| Not equal | a ~= b |
| Concatenate | "a" .. "b" |
| Length | #t, #s |
| Table | local t = { 1, 2, key = "v" } |
| Append | t[#t + 1] = v |
| Loop over an array | for i, v in ipairs(t) do end |
| Loop over keys | for k, v in pairs(t) do end |
| Numeric loop | for i = 1, 10, 2 do end |
| Function | local function add(a, b) return a + b end |
| Anonymous function | function(x) return x * 2 end |
| Default value | x = x or 10 |
| Ternary idiom | local v = cond and a or b |
| Method call | obj:method(arg) |
| Class | local C = {}; C.__index = C |
| New object | setmetatable({}, C) |
| Protected call | local ok, err = pcall(f, x) |
| Format a string | ("%s: %d"):format(name, n) |
| Find with a pattern | s:match("(%d+)-(%d+)") |
| Replace | s:gsub("%s+", " ") |
| Split a string | for w in s:gmatch("[^,]+") do end |
| Load a module | local m = require("m") |
| Read a file | local f = assert(io.open(p)); local s = f:read("a") |
| Skip to the next iteration | goto continue with ::continue:: |
What Lua Is
Lua is a small, fast, embeddable scripting language created in 1993 by Roberto Ierusalimschy, Luiz Henrique de Figueiredo and Waldemar Celes at PUC-Rio in Brazil; the name is Portuguese for moon. The whole interpreter is about 30,000 lines of portable C, starts in a few milliseconds and fits in a few hundred kilobytes, which is why so many programs embed it as their extension language.
The language has one data structure, the table, which serves as array, dictionary, object, module and namespace, and metatables that let tables define their own behavior for indexing, operators and calls. Lua 5.5, released on December 22, 2025, is the current version, with Lua 5.5.1 from August 3, 2026 as the latest release; Lua 5.4.9 closed the 5.4 series.
You meet Lua in game engines and games such as Roblox, LÖVE and World of Warcraft, in editors such as Neovim, in network software such as nginx with OpenResty and Redis scripts, and in tools such as Pandoc, Nmap and Wireshark. It is also a common choice for configuration that needs real logic instead of static files.
Where It Fits
Choose Lua when you need a scripting layer inside a larger program, a tiny interpreter for devices, or a fast language for game logic. For standalone applications, Python has a much larger library ecosystem, which the Python cheat sheet covers, and JavaScript runs everywhere a browser does, see the JavaScript cheat sheet. Lua's strength is doing one job small and fast inside something else.
How a Lua Script Runs
Knowing the steps between source text and running code explains most errors and performance questions.
- The host, either the standalone lua program or an application such as Neovim, creates a Lua state and opens the standard libraries it wants to expose.
- The compiler parses the chunk in a single pass and turns it into bytecode for Lua's register-based virtual machine; syntax errors are reported here with a line number.
- The chunk runs as a function: statements execute top to bottom, locals live in registers, and globals are looked up in the _ENV table.
- require loads other chunks the same way, once each, and caches what they return in package.loaded.
- The garbage collector reclaims unreachable tables, strings and closures, in incremental or generational mode, and runs __gc finalizers and __close handlers.
LuaJIT follows the same steps with its own bytecode, then traces hot loops and compiles them to machine code, which is why it is so much faster on numeric code.
Lua, Python and JavaScript Compared
The differences that surprise people moving between the three scripting languages.
| Question | Lua | Python | JavaScript |
|---|---|---|---|
| First index | 1 | 0 | 0 |
| Not equal | ~= | != | !== |
| False values | only nil and false | 0, "", empty containers, None | 0, "", null, undefined, NaN |
| Variables | global unless declared local | local to the function | block scoped with let and const |
| Data structures | one: the table | list, dict, set, tuple | array, object, Map, Set |
| Text matching | Lua patterns | regular expressions | regular expressions |
| Runtime size | a few hundred KB | tens of MB | tens of MB |
Moving data between them is easiest through JSON; the JSON to Lua table tool above and the JSON cheat sheet help with that.
Common Lua Mistakes
The same handful of problems shows up in most Lua code reviews.
In Code
- Forgetting local, which creates a global that leaks between modules.
- Calling a method with a dot instead of a colon, which leaves self wrong.
- Starting loops at 0, or putting nil into an array and trusting # afterwards.
- Writing != or += from other languages.
- Using . or - in a pattern when a literal character was meant.
- Relying on a and b or c when b can be false.
In Projects
- Assuming one Lua version: Neovim and LÖVE run LuaJIT, most new scripts run 5.4 or 5.5.
- Not checking the nil plus message returned by io.open, tonumber and require of optional modules.
- Concatenating strings in a loop instead of using table.concat.
- Running untrusted code with load and the full global table.
- Skipping a linter; luacheck catches most of the above automatically.
How to Learn Lua Well
Start With Tables and Functions
Read Programming in Lua by Roberto Ierusalimschy, then write small scripts that parse text with patterns and build tables. Master tables, closures and metatables first: almost everything else in Lua, from objects to modules, is built from those three. Run your code through the lint tool and luacheck as you go, a habit worth keeping for any language, as the guide to linting in programming explains.
Then Pick a Platform
Lua is most rewarding inside something: configure Neovim, make a small game in LÖVE, script Roblox, or write an OpenResty handler. Set up the Lua language server in your editor, following the VS Code workflow cheat sheet or your IDE of choice, keep your scripts in version control with the git cheat sheet, and test them with busted.
How This Lua Cheat Sheet Is Maintained
This page is written and maintained by Bogdan Sandu for TMS Outsource, a software development company. It was checked against the Lua 5.5 reference manual, the Lua 5.5.1 release, LuaJIT 2.1 and the Neovim 0.12 documentation on September 29, 2026, and the output of the example scripts was produced by running them on Compiler Explorer. If a snippet is wrong or out of date, the date in the byline shows when it was last reviewed.
Lua Programming Questions People Actually Ask
The questions developers search for most about Lua and about this cheat sheet, answered in a few sentences each.
What is the latest version of Lua?
Lua 5.5, released on December 22, 2025, with Lua 5.5.1 from August 3, 2026 as the latest release. It added global variable declarations, named vararg tables, read only for loop variables, table.create, more compact arrays and incremental major garbage collections. Lua 5.4.9 is the last release of the 5.4 series.
Why do Lua arrays start at 1?
Lua was designed in 1993 for engineers who were not professional programmers, and counting from 1 matched the notation they used. Technically a Lua array is just a table with integer keys, so t[0] works, but # , ipairs, table.insert, table.concat and string positions all assume the sequence starts at 1.
Is Lua hard to learn?
No. Lua 5.5 has 23 reserved words, one data structure and a small standard library, so most programmers can read it within a day. The concepts that take longer are metatables, closures and coroutines, and the differences from other languages: indexes from 1, ~= for not equal, and variables that are global unless declared local.
What is the difference between Lua and LuaJIT?
Lua, also called PUC Lua, is the reference implementation from PUC-Rio. LuaJIT is an independent implementation of Lua 5.1 with a just-in-time compiler that is often several times faster, plus an FFI for calling C directly. LuaJIT lacks the language features added since 5.1, such as integers, // and bitwise operators, <const> and global declarations.
What is a metatable in Lua?
A metatable is an ordinary table attached to another table with setmetatable. Its fields with special names, the metamethods such as __index, __add and __tostring, define what happens when the table is indexed with a missing key, used with an operator or printed. Metatables are how Lua implements classes, inheritance, default values and operator overloading.
Does Lua have classes?
Not as a keyword. A class is a table of methods that is set as the metatable of its instances with __index pointing back to itself, and inheritance chains those tables. The colon syntax, obj:method(), passes the object as self. Libraries such as middleclass add syntax on top, and the class builder on this page writes the standard pattern.
What are Lua patterns and how are they different from regex?
Lua patterns are Lua's own matching language, used by string.find, match, gmatch and gsub. They use % instead of backslash for escapes and classes such as %d and %s, and they add balanced matches (%b) and frontiers (%f), but they have no alternation, no {n,m} counts and no quantifiers on groups. For complex grammars, use the LPeg library.
Which Lua version does Neovim use?
Neovim embeds LuaJIT, so configuration and plugins are written in Lua 5.1 with LuaJIT extensions: no integer division operator, no bitwise operators (use the bit library), no <const> and no global declarations. Neovim 0.12, the current release, adds the built-in vim.pack plugin manager on top of the vim.* Lua API.
Is this Lua cheat sheet up to date?
Yes. It covers Lua 5.1 and LuaJIT through Lua 5.5 and was checked against the Lua 5.5 reference manual, the Lua 5.5.1 release, LuaJIT 2.1 and Neovim 0.12 on September 29, 2026. Every snippet that needs a newer version carries a chip, the version picker hides what your Lua cannot run, and the example output was produced by real Lua runs.
Can I download this Lua cheat sheet as a PDF?
Yes. The page has a print stylesheet, so your browser's print command with Save as PDF produces a clean copy: the dark theme switches to a print palette, every output and explanation is expanded, and the search box, navigation and footer are left out. The print core button prints only the core snippets for a short reference, and you can collapse sections or run a search first to print only what is still visible.