Standard Library and Idioms
Lua’s standard library is small enough to learn in an afternoon: strings, tables, math, os, io, coroutines, and a handful of globals. This page covers what is left after strings and tables, plus error handling, modules, coroutines, and the mistakes everyone makes once.
What is in the box
Section titled “What is in the box”| Library | Contents |
|---|---|
| (globals) | print, type, tostring, tonumber, pairs, ipairs, next, select, assert, error, pcall, xpcall, setmetatable, getmetatable, raw*, require, load, dofile, collectgarbage, warn (5.4), _G, _VERSION |
string |
Matching, formatting, substrings, bytes; pack/unpack (5.3+) |
table |
insert, remove, concat, sort, unpack, pack, move (5.3+) |
math |
Arithmetic, trig, random, integer helpers |
os |
Time, dates, environment, files by name, process exit |
io |
File handles, streams, reading and writing |
coroutine |
Cooperative multitasking |
utf8 |
UTF-8 encode/decode (5.3+) |
debug |
Introspection, hooks, tracebacks — for tooling, not application logic |
package |
The module system’s internals |
That is the whole thing. No networking, no JSON, no dates beyond os.date, no filesystem traversal, no regex. Anything else comes from the host application or from LuaRocks.
print(math.floor(3.7), math.ceil(3.2)) -- => 3 4 (integers in 5.3+)print(math.abs(-5), math.max(1, 9, 3), math.min(1, 9, 3)) -- => 5 9 1print(math.sqrt(2)) -- => 1.4142135623731print(math.exp(1), math.log(math.exp(1))) -- => 2.718281828459 1.0print(math.log(8, 2)) -- => 3.0 (log with a base)print(math.pi, math.huge) -- => 3.1415926535898 infprint(math.maxinteger, math.mininteger) -- => 9223372036854775807 -9223372036854775808
print(math.fmod(-7, 2)) -- => -1 truncates toward zero (C semantics)print(-7 % 2) -- => 1 the % operator floorsprint(math.modf(3.7)) -- integral part then fractional part: 3 and 0.7print(math.tointeger(8.0), math.type(8.0)) -- => 8 floatRandom numbers:
print(math.random()) -- float in [0, 1)print(math.random(6)) -- integer in [1, 6]print(math.random(10, 20)) -- integer in [10, 20]print(os.time()) -- => 1786000000 (Unix epoch seconds, integer)print(os.date("%Y-%m-%d %H:%M:%S")) -- => 2026-08-09 14:03:21 (local time)print(os.date("!%Y-%m-%dT%H:%M:%SZ")) -- => leading '!' means UTC
local t = os.date("*t") -- a table instead of a stringprint(t.year, t.month, t.day, t.hour, t.wday, t.yday, t.isdst)
print(os.time{ year = 2026, month = 8, day = 9, hour = 12 }) -- table -> epochprint(os.difftime(os.time(), 0)) -- seconds between two times
print(os.clock()) -- CPU seconds used, a float (for benchmarks)print(os.getenv("HOME"))os.remove("/tmp/scratch") -- returns true, or nil + messageos.rename("a.txt", "b.txt")os.exit(0) -- optional second arg: close the Lua state firstos.date format specifiers are C’s strftime: %Y %m %d %H %M %S %j %A %B %c %x %X %z, plus Lua’s *t / !*t table forms.
os.execute(cmd) runs a shell command; since 5.2 it returns true|nil, then "exit" or "signal", then the code. os.execute() with no argument returns whether a shell is available.
-- Read a whole file, with proper error handling.local f, err = io.open("/etc/hostname", "r")if not f then error("cannot open: " .. err) endlocal contents = f:read("a") -- "a" = allf:close()
-- Line by line; io.lines closes the file when the loop ends.for line in io.lines("/etc/passwd") do if line:match("^root") then print(line) endend
-- Write.local out = assert(io.open("/tmp/report.txt", "w"))out:write("count: ", 42, "\n") -- numbers are converted; no separators addedout:close()Read formats:
| Format | Reads |
|---|---|
"l" |
the next line, without the newline (the default) |
"L" |
the next line, with the newline (5.2+) |
"a" |
everything from the current position to EOF ("" at EOF, never nil) |
"n" |
a number, or nil if the text is not numeric |
| (a number) | that many bytes; nil at EOF |
Modes for io.open: "r", "w" (truncate), "a" (append), "r+", "w+", "a+", each optionally with b for binary (which only matters on Windows).
Other useful pieces:
f:seek("set", 0) -- rewind; whence is "set", "cur" (default) or "end"print(f:seek("end")) -- => file size in bytesf:flush()print(io.type(f)) -- => "file", "closed file", or nil for a non-fileio.write("no newline added") -- writes to the default output (stdout)local line = io.read("l") -- reads from the default input (stdin)
local p = io.popen("ls -1", "r") -- read a command's stdoutfor name in p:lines() do print(name) endp:close()In Lua 5.4 you can make a file handle close deterministically with <close>:
do local f <close> = assert(io.open("data.txt")) process(f:read("a"))end -- f is closed here, even if process() raisesError handling
Section titled “Error handling”Lua has no try/catch. It has error to raise and pcall/xpcall to catch.
Raising
Section titled “Raising”error("something broke") -- prepends "file:line: "error("something broke", 2) -- blames the CALLER's line insteaderror("raw message", 0) -- no position info at allerror({ code = 404, msg = "not found" }) -- error objects can be any valueThe level argument decides which line number is reported. Use level = 2 in library functions so the user sees their call site, not your internals — this is what assert(amount > 0, ...) inside an API method should effectively do.
local function set_port(p) if type(p) ~= "number" then error("port must be a number, got " .. type(p), 2) endendassert(v, message) returns all its arguments when v is truthy, and raises message otherwise. It composes nicely with functions that return nil, err:
local f = assert(io.open("config.lua")) -- raises with io.open's messagelocal n = assert(tonumber(input), "not a number")Catching
Section titled “Catching”pcall(f, ...) calls f in protected mode. It returns true plus f’s results, or false plus the error object.
local ok, result = pcall(function() return 1 / 0 end)print(ok, result) -- => true inf (float division is not an error)
local ok, err = pcall(function() error("boom") end)print(ok, err) -- => false file.lua:3: boom
-- Structured errors survive intact:local ok, e = pcall(function() error({ code = 404 }) end)print(ok, type(e), e.code) -- => false table 404xpcall(f, handler, ...) additionally runs handler at the point of the error, before the stack unwinds, which is the only way to capture a traceback:
local function risky() error("deep failure") end
local ok, err = xpcall(risky, debug.traceback)if not ok then print(err) endapp.lua:1: deep failurestack traceback: [C]: in function 'error' app.lua:1: in function <app.lua:1> [C]: in function 'xpcall' app.lua:3: in main chunk [C]: in ?Swap debug.traceback for pcall and you get only the first line. That difference is the entire reason xpcall exists.
A common wrapper that turns exceptions into nil, err results:
local function try(f, ...) local res = table.pack(xpcall(f, debug.traceback, ...)) if res[1] then return table.unpack(res, 2, res.n) end return nil, res[2]endwarn (Lua 5.4)
Section titled “warn (Lua 5.4)”warn(msg) emits a warning to stderr. Warnings are off by default in the standalone interpreter; the control messages "@on" and "@off" toggle them.
warn("@on")warn("deprecated: use new_api()") -- => Lua warning: deprecated: use new_api()Modules and require
Section titled “Modules and require”require("name") returns the module’s value, loading it the first time and caching it afterwards.
local json = require("dkjson")local socket = require("socket")local strings = require("mylib.strings") -- mylib/strings.luaHow the search works
Section titled “How the search works”- If
package.loaded["name"]exists, return it. The file body runs exactly once per Lua state. - Otherwise try each function in
package.searchersin order:package.preload["name"]— a function registered in code, used for bundling.- The Lua loader: substitute the name into every template in
package.path. - The C loader: same, over
package.cpath, loading a.so/.dll. - The all-in-one loader, which looks for
a.sowhen you requirea.b.
- Store the result in
package.loaded["name"]and return it.
package.path is a ;-separated list of templates where ? is replaced by the module name with dots turned into directory separators:
print(package.path)-- => /usr/local/share/lua/5.4/?.lua;/usr/local/share/lua/5.4/?/init.lua;-- /usr/local/lib/lua/5.4/?.lua;/usr/local/lib/lua/5.4/?/init.lua;-- ./?.lua;./?/init.luaprint(package.cpath)-- => /usr/local/lib/lua/5.4/?.so;/usr/local/lib/lua/5.4/loadall.so;./?.soSo require("mylib.strings") tries <dir>/mylib/strings.lua then <dir>/mylib/strings/init.lua for each directory in turn. If nothing matches, the error lists every path it tried — read it, the answer is always in there.
Override from the shell, where ;; expands to the built-in default:
LUA_PATH="./src/?.lua;;" lua main.luaOr from Lua, before the require:
package.path = "./vendor/?.lua;" .. package.pathTo force a reload during development:
package.loaded["mylib.strings"] = nillocal strings = require("mylib.strings")load(chunk [, chunkname [, mode [, env]]]) compiles a string or a reader function into a function without running it. dofile(path) loads and runs a file every time, bypassing the cache.
local f, err = load("return 1 + 1")print(f()) -- => 2
-- Sandboxing: give the chunk a restricted _ENV (5.2+).local env = { print = print }local g = load("print(os)", "sandbox", "t", env)g() -- => nil (os is not visible)LuaRocks is the package manager: luarocks install lpeg, luarocks install --local penlight. It installs into paths that package.path already covers, or run eval "$(luarocks path)" for a local tree.
Coroutines
Section titled “Coroutines”A coroutine is a separate call stack that can suspend itself mid-execution and be resumed later. It is not a thread: only one coroutine runs at a time, switching happens only at explicit yield/resume points, and there is no preemption or shared-memory race.
local co = coroutine.create(function(a, b) print("start", a, b) -- => start 1 2 local c = coroutine.yield(a + b) -- send 3 out; c comes from the next resume print("resumed with", c) -- => resumed with 10 return "done"end)
print(coroutine.status(co)) -- => suspendedprint(coroutine.resume(co, 1, 2)) -- => true 3print(coroutine.status(co)) -- => suspendedprint(coroutine.resume(co, 10)) -- => true doneprint(coroutine.status(co)) -- => deadprint(coroutine.resume(co)) -- => false cannot resume dead coroutineThe API:
| Function | Behavior |
|---|---|
coroutine.create(f) |
Returns a suspended coroutine (a thread value) |
coroutine.resume(co, ...) |
Starts/continues it. Returns true, <yielded or returned values> or false, error |
coroutine.yield(...) |
Suspends the running coroutine; its arguments become resume’s extra results |
coroutine.status(co) |
"suspended", "running", "normal" (resumed another), "dead" |
coroutine.wrap(f) |
Returns a function; calling it resumes. Errors propagate instead of being returned |
coroutine.isyieldable() |
Whether yield is legal right now |
coroutine.running() |
The running coroutine and whether it is the main one (5.2+) |
coroutine.close(co) |
Kills a suspended coroutine, running its <close> variables (5.4) |
resume never raises: errors inside the coroutine come back as false, message, exactly like pcall. wrap does the opposite — it re-raises in the caller, which is what you want for iterators.
Coroutines are stackful: you can yield from inside nested function calls, not just from the top-level body. That is the difference from Python/JavaScript generators, and it is what makes them usable as a general control-flow tool.
Producer / consumer
Section titled “Producer / consumer”-- Producer: yields one item at a time. It has no idea who consumes them.local function producer(lines) return coroutine.create(function() for _, line in ipairs(lines) do coroutine.yield(line) end end)end
-- Filter: sits between producer and consumer, itself a coroutine.local function filter(prod) return coroutine.create(function() local n = 0 while true do local ok, line = coroutine.resume(prod) if not ok or line == nil then break end n = n + 1 coroutine.yield(("%3d %s"):format(n, line:upper())) end end)end
-- Consumer: drives the pipeline.local function consume(co) while true do local ok, value = coroutine.resume(co) if not ok or value == nil then break end print(value) endend
consume(filter(producer{ "alpha", "beta", "gamma" }))-- => 1 ALPHA-- => 2 BETA-- => 3 GAMMANothing here buffers the whole input: each item is pulled through on demand. That is the point of the pattern — it gives you lazy streaming without callbacks or an event loop.
Coroutines as iterators
Section titled “Coroutines as iterators”coroutine.wrap turns any recursive traversal into a for loop, which is where coroutines pay off most in everyday code:
local function walk(t) return coroutine.wrap(function() local function visit(node, path) for k, v in pairs(node) do local p = path == "" and tostring(k) or path .. "." .. tostring(k) if type(v) == "table" then visit(v, p) else coroutine.yield(p, v) end end end visit(t, "") end)end
for path, value in walk{ db = { host = "x", port = 5432 }, debug = true } do print(path, value)end-- => db.host x-- => db.port 5432-- => debug true (order varies: pairs is unordered)Idioms and gotchas
Section titled “Idioms and gotchas”The list every Lua newcomer eventually assembles:
- Indices start at 1.
t[1]is the first element,s:sub(1, 1)is the first byte,#tis the last valid index. - Assignment without
localcreates a process-wide global. Prefix everything withlocal. A typo in a name silently readsnilinstead of failing. - Only
nilandfalseare falsy.0and""are true. nilin a list breaks#andipairs. Track the count yourself or usetable.pack. Nevert[i] = nilin the middle of a sequence you still intend to iterate — usetable.remove.pairsorder is undefined and can vary between runs. Sort the keys when output must be stable.~=, not!=..., not+, for concatenation.#, not.length. And there is no+=,++,continue, or ternary.- Multiple returns collapse to one unless the call is last in the list.
f(g(), h())passes all ofh’s values but only one ofg’s. gsubreturns two values. Wrap in parentheses:return (s:gsub(...)).- Integer vs float (5.3+).
/and^always give floats;3and3.0are equal but print differently and format differently. Use//for integer division andmath.floor/math.tointegerto convert. :vs.—obj:m(x)passesobjasself;obj.m(x)does not. Mismatches produceattempt to index a nil value (local 'self').- Metamethods are not inherited. Copy
__tostring,__eqand friends into every subclass. - Strings are immutable. Concatenating in a loop is quadratic; use
table.concat. - Shadowing on redeclaration:
local x = xcaptures the outerx, which is a deliberate and useful idiom for caching globals into locals. tostring(nil)is"nil", but.. nilis an error. Wrap interpolated values you are not sure about:"got " .. tostring(v).
Two small idioms worth adopting:
-- Cache hot library functions as locals at the top of a module.local concat, insert, format = table.concat, table.insert, string.format
-- Return nil + message for expected failures; raise only for programmer errors.local function parse_port(s) local n = tonumber(s) if not n then return nil, "not a number: " .. s end if n < 1 or n > 65535 then return nil, "out of range: " .. n end return math.tointeger(n)end
local port, err = parse_port("99999")print(port, err) -- => nil out of range: 99999That convention — nil, message for the expected, error() for the unexpected — is what the standard library itself does (io.open, tonumber, os.remove), and matching it means assert(f()) always does the right thing.
Key points
Section titled “Key points”- The standard library is deliberately tiny: string, table, math, os, io, coroutine, utf8, debug. Everything else comes from the host or LuaRocks.
error/assertraise;pcallcatches;xpcallcatches with a handler that runs before unwinding, which is the only way to get a traceback.- Error values can be any type, so structured errors work without extra machinery.
requirecaches inpackage.loadedand searchespackage.path/package.cpathwith?substitution; the failure message lists every path tried.- Coroutines are stackful, cooperative, single-threaded.
wrapfor iterators,create/resumewhen you need to handle errors yourself. - Return
nil, messagefor expected failures anderrorfor bugs — it is what the standard library does.