Skip to content

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.

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 1
print(math.sqrt(2)) -- => 1.4142135623731
print(math.exp(1), math.log(math.exp(1))) -- => 2.718281828459 1.0
print(math.log(8, 2)) -- => 3.0 (log with a base)
print(math.pi, math.huge) -- => 3.1415926535898 inf
print(math.maxinteger, math.mininteger) -- => 9223372036854775807 -9223372036854775808
print(math.fmod(-7, 2)) -- => -1 truncates toward zero (C semantics)
print(-7 % 2) -- => 1 the % operator floors
print(math.modf(3.7)) -- integral part then fractional part: 3 and 0.7
print(math.tointeger(8.0), math.type(8.0)) -- => 8 float

Random 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 string
print(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 -> epoch
print(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 + message
os.rename("a.txt", "b.txt")
os.exit(0) -- optional second arg: close the Lua state first

os.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) end
local contents = f:read("a") -- "a" = all
f: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) end
end
-- Write.
local out = assert(io.open("/tmp/report.txt", "w"))
out:write("count: ", 42, "\n") -- numbers are converted; no separators added
out: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 bytes
f:flush()
print(io.type(f)) -- => "file", "closed file", or nil for a non-file
io.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 stdout
for name in p:lines() do print(name) end
p: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() raises

Lua has no try/catch. It has error to raise and pcall/xpcall to catch.

error("something broke") -- prepends "file:line: "
error("something broke", 2) -- blames the CALLER's line instead
error("raw message", 0) -- no position info at all
error({ code = 404, msg = "not found" }) -- error objects can be any value

The 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)
end
end

assert(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 message
local n = assert(tonumber(input), "not a number")

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 404

xpcall(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) end
app.lua:1: deep failure
stack 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]
end

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

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.lua
  1. If package.loaded["name"] exists, return it. The file body runs exactly once per Lua state.
  2. Otherwise try each function in package.searchers in 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.so when you require a.b.
  3. 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.lua
print(package.cpath)
-- => /usr/local/lib/lua/5.4/?.so;/usr/local/lib/lua/5.4/loadall.so;./?.so

So 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:

Terminal window
LUA_PATH="./src/?.lua;;" lua main.lua

Or from Lua, before the require:

package.path = "./vendor/?.lua;" .. package.path

To force a reload during development:

package.loaded["mylib.strings"] = nil
local 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.

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)) -- => suspended
print(coroutine.resume(co, 1, 2)) -- => true 3
print(coroutine.status(co)) -- => suspended
print(coroutine.resume(co, 10)) -- => true done
print(coroutine.status(co)) -- => dead
print(coroutine.resume(co)) -- => false cannot resume dead coroutine

The 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: 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)
end
end
consume(filter(producer{ "alpha", "beta", "gamma" }))
-- => 1 ALPHA
-- => 2 BETA
-- => 3 GAMMA

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

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)

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, #t is the last valid index.
  • Assignment without local creates a process-wide global. Prefix everything with local. A typo in a name silently reads nil instead of failing.
  • Only nil and false are falsy. 0 and "" are true.
  • nil in a list breaks # and ipairs. Track the count yourself or use table.pack. Never t[i] = nil in the middle of a sequence you still intend to iterate — use table.remove.
  • pairs order 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 of h’s values but only one of g’s.
  • gsub returns two values. Wrap in parentheses: return (s:gsub(...)).
  • Integer vs float (5.3+). / and ^ always give floats; 3 and 3.0 are equal but print differently and format differently. Use // for integer division and math.floor/math.tointeger to convert.
  • : vs .obj:m(x) passes obj as self; obj.m(x) does not. Mismatches produce attempt to index a nil value (local 'self').
  • Metamethods are not inherited. Copy __tostring, __eq and friends into every subclass.
  • Strings are immutable. Concatenating in a loop is quadratic; use table.concat.
  • Shadowing on redeclaration: local x = x captures the outer x, which is a deliberate and useful idiom for caching globals into locals.
  • tostring(nil) is "nil", but .. nil is 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: 99999

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

  • The standard library is deliberately tiny: string, table, math, os, io, coroutine, utf8, debug. Everything else comes from the host or LuaRocks.
  • error/assert raise; pcall catches; xpcall catches 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.
  • require caches in package.loaded and searches package.path/package.cpath with ? substitution; the failure message lists every path tried.
  • Coroutines are stackful, cooperative, single-threaded. wrap for iterators, create/resume when you need to handle errors yourself.
  • Return nil, message for expected failures and error for bugs — it is what the standard library does.