Error handling
Lua’s error handling admits two principal mechanisms: exception-style via error and pcall (or xpcall), and value-style via return-on-failure with nil, message or false-and-error idioms. The conventional Lua discipline depends on context: return-on-failure for expected failures (admit substantial fluent API design); error for substantial bugs and unrecoverable conditions. The pcall (protected call) admits running a function with error catching — analogous to try in other languages. assert admits compact precondition checks. The combination — explicit error-as-value returns, error/pcall for exception-style, assert for preconditions, the absence of native try/catch syntax — is the substance of Lua’s error-handling surface.
error
The error function raises an error:
error("something went wrong")
error("error: " .. detail, 2) -- level 2 (caller's location)
The second argument is the level — admit substantial control over error location reporting:
1(default) — error reported at theerrorcall site.2— at the caller’s location.0— no location info.
The error propagates up the stack; if uncaught, it terminates the program (with a stack trace).
Error objects
The error “message” may be any value — typically a string but admit tables for structured errors:
error({code = 404, message = "not found"})
local ok, err = pcall(function()
error({code = 404, message = "not found"})
end)
print(err.code) -- 404
print(err.message) -- "not found"
The conventional contemporary discipline favours string messages for substantial debug-readability; structured errors admit substantial type-based dispatch.
pcall (protected call)
The pcall admits catching errors:
local ok, result = pcall(risky_function, arg1, arg2)
if ok then
print("succeeded:", result)
else
print("error:", result) -- result is the error message
end
The pcall returns:
- On success —
true, <return values of f>. - On error —
false, <error message>.
The pcall is similar to try/catch:
local function risky()
-- ...
if condition then
error("oops")
end
return "success"
end
local ok, result = pcall(risky)
if not ok then
print("caught:", result)
end
xpcall (extended protected call)
The xpcall admits an error handler — called with the error before unwinding:
local function err_handler(err)
return debug.traceback(err, 2)
end
local ok, result = xpcall(risky_function, err_handler)
if not ok then
print("traceback:", result)
end
The mechanism admits substantial substrate-aware error handling — particularly for adding stack traces.
In Lua 5.2+, xpcall admits passing additional arguments:
local ok, result = xpcall(f, handler, arg1, arg2)
assert
The assert admits compact precondition checks:
local function divide(a, b)
assert(b ~= 0, "division by zero")
return a / b
end
local n = assert(tonumber("42"), "not a number")
local f = assert(io.open(path, "r"), "file not found: " .. path)
The assert(v, msg):
- If
vis truthy — returnsv(and any other arguments). - If
vis falsy — callserror(msg).
The mechanism admits substantial conciseness for value-or-error patterns.
Return-on-failure pattern
The conventional Lua pattern for expected failures:
local function divide(a, b)
if b == 0 then return nil, "division by zero" end
return a / b
end
local result, err = divide(10, 0)
if not result then
print("error:", err)
end
The mechanism is conventional in the Lua standard library:
local f, err = io.open("nonexistent.txt", "r")
if not f then
print("error:", err) -- e.g., "nonexistent.txt: No such file"
return
end
local n = tonumber("not numeric") -- returns nil on failure (no message)
When to use which
The conventional discipline:
- Use
error— for substantial programmer errors and invariant violations. - Use return-on-failure — for expected, recoverable failures (file open, parse, network).
- Use
assert— for compact preconditions. - Use
pcall— at the boundary of substantial subsystems.
-- Programmer error (use error/assert):
local function set_volume(v)
assert(type(v) == "number", "expected number")
assert(v >= 0 and v <= 100, "out of range")
-- ...
end
-- Recoverable failure (use return-on-failure):
local function read_config(path)
local f = io.open(path)
if not f then return nil, "config not found" end
local content = f:read("*a")
f:close()
local config, err = parse(content)
if not config then return nil, "parse error: " .. err end
return config
end
Common patterns
Wrap risky operation
local function safe_divide(a, b)
local ok, result = pcall(divide, a, b)
if ok then return result end
return 0 -- default on error
end
Resource cleanup
Lua does not include a try/finally syntax; the conventional pattern uses pcall and explicit cleanup:
local function with_file(path, action)
local f, err = io.open(path)
if not f then return nil, err end
local ok, result = pcall(action, f)
f:close()
if not ok then return nil, result end
return result
end
local data = with_file("data.txt", function(f)
return f:read("*a")
end)
For Lua 5.4+, the <close> attribute admits substantial automatic cleanup:
do
local f <close> = io.open("data.txt", "r")
-- f:close() called automatically when scope exits, even on error
if f then process(f:read("*a")) end
end
Validation chain
local function validate_user(user)
if type(user) ~= "table" then
return nil, "user must be a table"
end
if not user.name or user.name == "" then
return nil, "name is required"
end
if user.age and (user.age < 0 or user.age > 150) then
return nil, "invalid age"
end
if user.email and not user.email:match("@") then
return nil, "invalid email"
end
return user
end
local valid, err = validate_user(input)
if not valid then
print("validation error:", err)
end
Custom error types via tables
local function NetworkError(message, code)
return {type = "NetworkError", message = message, code = code}
end
local function ValidationError(field, message)
return {type = "ValidationError", field = field, message = message}
end
local function dispatch_error(err)
if type(err) == "table" then
if err.type == "NetworkError" then
print("network error:", err.code, err.message)
elseif err.type == "ValidationError" then
print("invalid", err.field .. ":", err.message)
else
print("unknown error:", err.type)
end
else
print("error:", err)
end
end
-- Usage:
local ok, err = pcall(function()
error(NetworkError("timeout", 408))
end)
if not ok then dispatch_error(err) end
Retry with pcall
local function retry(attempts, fn, ...)
for i = 1, attempts do
local results = {pcall(fn, ...)}
if results[1] then
-- Success; return non-status return values
return select(2, table.unpack(results))
end
if i == attempts then
error(results[2]) -- last attempt; re-raise
end
end
end
local result = retry(3, function() return fetch_data() end)
Error with traceback
local function trace_handler(err)
return debug.traceback(err, 2)
end
local ok, err = xpcall(risky, trace_handler)
if not ok then
print(err) -- includes stack trace
end
Result-style return
local function parse_int(s)
local n = tonumber(s)
if n and n == math.floor(n) then
return n
end
return nil, "not an integer: " .. s
end
local n, err = parse_int(input)
if n then
process(n)
else
handle_error(err)
end
Asserting types
local function check_type(value, expected, name)
if type(value) ~= expected then
error(string.format("expected %s for %s, got %s", expected, name, type(value)), 3)
end
return value
end
local function process(name, age)
check_type(name, "string", "name")
check_type(age, "number", "age")
-- ...
end
The level = 3 in error admits the error being reported at the caller’s caller — admit substantial usability.
pcall for boundary
-- At the boundary of a request handler:
local function handle_request(request)
local ok, response = pcall(process_request, request)
if ok then
return {status = 200, body = response}
else
log_error(response)
return {status = 500, body = "internal error"}
end
end
Multiple returns with pcall
The pcall returns true followed by all of the function’s return values:
local function foo()
return 1, 2, 3
end
local ok, a, b, c = pcall(foo)
print(ok, a, b, c) -- true, 1, 2, 3
For collecting:
local results = {pcall(foo)}
local ok = results[1]
if ok then
-- results[2], results[3], ... are the values
end
Default value or error
local n = tonumber(input) or error("not numeric")
-- Or with assert:
local n = assert(tonumber(input), "not numeric")
Custom assert_eq
local function assert_eq(actual, expected, msg)
if actual ~= expected then
error(string.format("%s: expected %s, got %s",
msg or "assertion failed",
tostring(expected),
tostring(actual)
), 2)
end
end
assert_eq(2 + 2, 4, "addition")
assert_eq(divide(10, 2), 5, "division")
Wrapping standard library errors
local function read_json(path)
local f, err = io.open(path)
if not f then return nil, "open: " .. err end
local content = f:read("*a")
f:close()
local ok, result = pcall(json.decode, content)
if not ok then return nil, "decode: " .. result end
return result
end
<close> for RAII (5.4+)
local function process_file(path)
local file <close> = setmetatable({}, {
__index = io.open(path, "r"),
__close = function(self)
-- cleanup
print("closing")
end
})
-- use file
end
The <close> attribute (5.4+) admits substantial deterministic cleanup; the variable’s __close metamethod is called when the scope exits.
Chaining error returns
local function process(input)
local parsed, err = parse(input)
if not parsed then return nil, "parse: " .. err end
local validated, err = validate(parsed)
if not validated then return nil, "validate: " .. err end
return transform(validated)
end
Error via coroutine
local co = coroutine.create(function()
error("inside coroutine")
end)
local ok, err = coroutine.resume(co)
print(ok, err) -- false, "...: inside coroutine"
The coroutine.resume is pcall-like — errors are caught and returned.
A note on the absence of try/catch
Lua does not admit try/catch syntax — pcall is the conventional substitute:
-- Other languages:
-- try { risky() } catch (e) { handle(e) }
-- Lua:
local ok, err = pcall(risky)
if not ok then handle(err) end
The mechanism admits substantial flexibility — pcall is a function, not syntax — but is conventionally less readable for substantial multi-statement guarded blocks.
A note on the conventional discipline
The contemporary Lua error-handling advice:
- Use return-on-failure (
nil, message) for expected failures. - Use
errorfor substantial programmer errors. - Use
assertfor compact preconditions. - Use
pcallat boundaries of substantial subsystems. - Use
xpcallwithdebug.tracebackfor substantial debug logging. - Use
<close>(5.4+) for substantial RAII cleanup. - Document error returns — admit substantial caller awareness.
- Don’t catch errors blindly — admit substantial debug visibility.
- Use error level argument (
error(msg, 2)) for substantial caller-side reporting.
The combination — error/pcall for exception-style, assert for preconditions, return-on-failure for expected failures, xpcall for substantial error handlers, the <close> attribute (5.4+) for RAII — is the substance of Lua’s error-handling surface. The discipline produces explicit, traceable error flows with substantial flexibility through the value-style returns and pcall boundary handling.