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.

30sections
146snippets
5.1 to 5.5Lua versions
6Lua tools

Updated September 29, 2026, checked against Lua 5.5.1, the Lua 5.5 reference manual, LuaJIT 2.1 and Neovim 0.12. Print it for a PDF copy.

/
level

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.

01

Install & Run

installing Lua, running scripts and the REPL, luac, a Lua toolbox and builders

Lua Toolbox: Lint, Version Check, Pattern Tester, string.format, JSON and Tables

core
Ctrl+Enter lints. Which Lua version lists the newest feature your code uses, the removed ones it still relies on, and which platforms can run it: LuaJIT, Neovim, Redis, Roblox. Test a pattern also explains every pattern item. Test a pattern: put a Lua pattern on the first line, test strings below, and optionally a line starting with => as the gsub replacement. JSON ↔ Lua table converts in whichever direction fits the input. Run on godbolt opens the script with Lua 5.5 in Compiler Explorer; nothing is sent until you press it.
output

          
Paste Lua code, a pattern and test strings, a string.format call, JSON or a Lua table above, then pick an action.

Builders: Class, Module, Rockspec, Neovim Config, LÖVE Game

everyday
account.lua

          

Install Lua

core

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

core

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

everyday

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))
02

Syntax & Comments

statements, comments, keywords, blocks, semicolons and naming

The Shape of Lua Code

core

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

KeywordsNotes
and or notlogical operators, they return one of their operands
if then elseif else endconditionals
for in while do repeat until break gotoloops and jumps
function return localfunctions and scope
nil true falsethe only literal values that are keywords
globalnew 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

core

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

LevelOperators
1or
2and
3< > <= >= ~= ==
4bitwise or, the vertical bar (5.3+)
5~ (bitwise xor)
6& (bitwise and)
7<< >>
8.. (right associative)
9+ -
10* / // %
11unary not # - ~
12^ (right associative, binds tighter than unary minus)

Statements

everyday

Chained comparisons do not exist

local x = 5
print(1 < x and x < 10)
print(pcall(load("local x = 5; return 1 < x < 10")))
03

Variables & Scope

local and global variables, global declarations, multiple assignment, const and scope rules

local and global

core

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

Lua 5.5+everyday

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

everyday

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())
04

Types & Values

the eight types, type(), nil and booleans, truthiness, equality and conversions

The Eight Types

core

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)
05

Numbers & Math

integers and floats, division, overflow, the math library, random numbers and formatting

Integers and Floats

Lua 5.3+core

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

advanced

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

core

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)
06

Strings

literals, escapes, long strings, the string library, method syntax, formatting and building strings

Literals and Escapes

core

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

core

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

core

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

everyday

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"))
07

Patterns

Lua patterns, character classes, captures, find, match, gmatch, gsub and how they differ from regex

Pattern Syntax

core

Character classes

ClassMatchesClassMatches
.any character%aletters
%ddigits%l %ulower case, upper case letters
%swhite space%wletters and digits
%ppunctuation%xhexadecimal digits
%ccontrol characters%gprintable characters except space
[abc] [a-z]a set, a range[^%d]complement of a set
%A %D %Supper case class = complement%% %.an escaped magic character

Repetition, anchors and captures

ItemMeaning
*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
%1the text of capture 1 again
%bxya 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

core

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

everyday

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*)(/?[^?]*)"))
08

Tables

creating tables, fields, keys, nested tables, references and iteration

Creating and Using Tables

core

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

everyday

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

core

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)
09

Arrays & Sequences

1-based sequences, the length operator, holes, ipairs, append, remove and 2D arrays

Sequences

core

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

everyday

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)
10

The table Library

insert, remove, sort, concat, unpack, move and pack

Sorting Keys and Counting

everyday

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

core

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), " "))
11

Control Flow

if, while, repeat, numeric and generic for, break, goto and continue

Conditionals

core

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

core

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

everyday

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))
12

Functions & Varargs

defining functions, multiple results, varargs, named vararg tables, select and table.unpack

Functions

core

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

core

..., 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

everyday

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)))
13

Closures & Upvalues

closures, upvalues, counters, memoization and private state

Closures

core

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)
14

Errors & pcall

error, assert, pcall, xpcall, error objects, tracebacks, nil plus message and load

Raising and Catching

core

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

everyday

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)
15

Iterators

generic for, pairs, ipairs, next, stateless and stateful iterators, and closing iterators

The Generic for

core

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
16

Metatables

setmetatable, __index, __newindex, operators, __call, __tostring, __eq, __lt, __len, __close and rawget

Metatables

core

__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

MetamethodTriggered by
__index __newindexreading or writing a missing key
__callcalling the value like a function
__tostring __nametostring and print, error messages
__eq __lt __le== < <=
__add __sub __mul __div __idiv __mod __pow __unmarithmetic
__band __bor __bxor __shl __shr __bnotbitwise operators, 5.3+
__concat __len.. and #
__closeto-be-closed variables, 5.4+
__gc __modefinalizers and weak tables
__metatablevalue returned by getmetatable, and it blocks setmetatable

Proxies and Defaults

advanced

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, ", "))
17

OOP & Classes

classes with metatables, the colon syntax, constructors, inheritance, super calls and mixins

Classes

core

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

everyday

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)
18

Coroutines

create, resume, yield, wrap, status, generators, producer consumer and coroutine.close

Coroutines

core

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

advanced

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
19

Modules & require

writing a module, require, package.path, package.loaded, reloading and avoiding globals

Modules

core

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)
20

Memory & GC

the garbage collector, collectgarbage, weak tables, __gc finalizers and to-be-closed variables

The Garbage Collector

everyday

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))
21

Files & I/O

io.open, reading and writing files, io.lines, read formats, stdin, stdout and error handling

Reading and Writing Files

core

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

everyday

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] == "")
22

OS, Time & Env

os.time, os.date, os.clock, environment variables, files and exit codes

Dates and Times

core

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

core

os.date format codes

CodeMeaningCodeMeaning
%Yyear, 2026%Hhour, 00 to 23
%mmonth, 01 to 12%Mminute
%dday of the month%Ssecond
%jday of the year%pAM or PM
%a %Aweekday name, short and long%b %Bmonth name, short and long
%cdate and time for the locale%x %Xdate, time for the locale
*t !*ta 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))
23

UTF-8

the utf8 library, characters versus bytes, iterating code points and escapes

The utf8 Library

Lua 5.3+core

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()
24

C API & Embedding

embedding Lua in C, the stack, calling Lua from C, writing C modules and userdata

Embedding Lua in C

advanced

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

advanced

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))'
25

LuaJIT & FFI

LuaJIT compatibility, the FFI, the bit library, and code that runs on both LuaJIT and PUC Lua

LuaJIT

advanced

What LuaJIT is

QuestionLuaJIT 2.1
LanguageLua 5.1, plus goto, hex and \z escapes and a few 5.2 functions
Integersno integer subtype, numbers are doubles; 64-bit integers only through the FFI
Bitwisethe bit library: bit.band, bit.bor, bit.bxor, bit.lshift
Speeda tracing JIT, often several times faster than PUC Lua on hot loops
Used byNeovim, OpenResty and nginx, LÖVE by default, many game engines
Releasesa 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" }))
26

Neovim Lua

init.lua, options, keymaps, autocommands, user commands, plugins with vim.pack and LSP

Configuration in init.lua

everyday

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

advanced

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)
27

LÖVE & Roblox

game loops in LÖVE, delta time, input and drawing, and Roblox Luau

LÖVE

everyday

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

everyday

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)
28

LuaRocks & Tooling

LuaRocks packages, busted tests, luacheck, StyLua, the language server and debugging

LuaRocks

everyday

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

everyday

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

everyday

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)
29

Errors & Gotchas

common error messages and the surprises that catch Lua newcomers

Error Messages

core

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

MessageUsual 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 nila 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 overflowinfinite recursion, often __index calling itself
invalid order function for sortinga 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

core

. 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")
30

Lua 5.1 to 5.5

what each version added, which platforms use which version, and compatibility

What Each Version Added

core

The main changes

VersionReleasedHighlights
Lua 5.5Dec 2025, 5.5.1 Aug 2026global declarations, named vararg tables, read only for variables, table.create, compact arrays, incremental major GC
Lua 5.4Jun 2020, 5.4.9 Aug 2026<const> and <close>, generational GC, coroutine.close, warn, new random generator
Lua 5.3Jan 2015integer subtype, // and bitwise operators, utf8 library, string.pack, table.move
Lua 5.2Dec 2011_ENV instead of setfenv, goto, \z and \x escapes, table.pack and unpack, bit32
Lua 5.1Feb 2006the module system with require, # operator, varargs with ...; the base of LuaJIT and Luau

Which Lua does each platform run

PlatformLua version
NeovimLuaJIT (Lua 5.1 semantics)
LÖVELuaJIT, or Lua 5.1 where LuaJIT is unavailable
RobloxLuau, derived from Lua 5.1
OpenResty and nginxLuaJIT
Redis scriptsLua 5.1
World of Warcraft addonsLua 5.1
Pandoc filters, Nmap scriptsLua 5.4
Standalone scriptsLua 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.


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.

TaskLua
Local variablelocal name = "ann"
Constant (5.4+)local MAX <const> = 10
Comment-- line, --[[ block ]]
Not equala ~= b
Concatenate"a" .. "b"
Length#t, #s
Tablelocal t = { 1, 2, key = "v" }
Appendt[#t + 1] = v
Loop over an arrayfor i, v in ipairs(t) do end
Loop over keysfor k, v in pairs(t) do end
Numeric loopfor i = 1, 10, 2 do end
Functionlocal function add(a, b) return a + b end
Anonymous functionfunction(x) return x * 2 end
Default valuex = x or 10
Ternary idiomlocal v = cond and a or b
Method callobj:method(arg)
Classlocal C = {}; C.__index = C
New objectsetmetatable({}, C)
Protected calllocal ok, err = pcall(f, x)
Format a string("%s: %d"):format(name, n)
Find with a patterns:match("(%d+)-(%d+)")
Replaces:gsub("%s+", " ")
Split a stringfor w in s:gmatch("[^,]+") do end
Load a modulelocal m = require("m")
Read a filelocal f = assert(io.open(p)); local s = f:read("a")
Skip to the next iterationgoto 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.

  1. 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.
  2. 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.
  3. The chunk runs as a function: statements execute top to bottom, locals live in registers, and globals are looked up in the _ENV table.
  4. require loads other chunks the same way, once each, and caches what they return in package.loaded.
  5. 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.

QuestionLuaPythonJavaScript
First index100
Not equal~=!=!==
False valuesonly nil and false0, "", empty containers, None0, "", null, undefined, NaN
Variablesglobal unless declared locallocal to the functionblock scoped with let and const
Data structuresone: the tablelist, dict, set, tuplearray, object, Map, Set
Text matchingLua patternsregular expressionsregular expressions
Runtime sizea few hundred KBtens of MBtens 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.