The “Crash or Continue” Dilemma
When I first started diving into Lua, I treated errors like my dog treating the mailman—with absolute hostility. A nil assignment? Boom, crash. A missing function? Crash again. I’d wrap every single line in pcall, convinced I was being safe, only to realize I was actually hiding the real problems from myself.
The truth is, Lua’s error handling is elegant, but it’s subtle. It’s not just about stopping the program; it’s about knowing when to stop it, where to catch it, and how to make your code fail gracefully instead of exploding in the user’s face.
Understanding the Two Pillars: error() and pcall()
Everything in Lua boils down to two things: throwing the error and catching it. Let’s not overcomplicate it.
error(message, level) is how you scream that something is wrong. But that level parameter? That’s the secret sauce most beginners ignore. It controls which stack frame gets blamed for the error.
function check_age(age)
if age < 0 then
-- level 2 means "blame the caller of check_age", not check_age itself
error("Age cannot be negative", 2)
end
return age * 2
end
function party_planner(guest_age)
return check_age(guest_age)
end
-- If we use default level, the error trace points to check_age.
-- With level 2, it points to party_planner, which is usually more helpful.
party_planner(-5)
pcall (protected call) is your safety net. It runs a function and catches any errors, returning true plus the result, or false plus the error message.
local success, result = pcall(function()
return 10 / 0
end)
if success then
print("Math worked! Result:", result)
else
print("Uh oh, something broke:", result)
end
Notice how result holds the error string? That’s because pcall returns the exact value error() threw.
xpcall: The Detective’s Version
pcall is great for simple catches. But what if you need to know where the error happened? That’s xpcall. It works just like pcall, but it accepts a debug hook function as a second argument. This hook gets called when an error occurs, and it’s your chance to grab a traceback.
function debug_handler(err)
-- tostring(err) gives you the error message
-- debug.traceback() gives you the stack trace
return tostring(err) .. "\n" .. debug.traceback()
end
local success, trace = xpcall(function()
local function inner()
error("Something went horribly wrong in the deep end")
end
inner()
end, debug_handler)
if not success then
print(trace)
-- Output will show the exact line numbers where the error propagated through
end
This is invaluable for production logging. You don’t just want to know what broke; you want to know the journey it took to break.
Error Objects: More Than Just Strings
Lua 5.3+ introduced a nice touch: errors don’t have to be strings. You can throw any Lua value. This is a game-changer for structured error handling.
Imagine you’re building an API client. Instead of throwing "Connection failed", you throw a table with specific fields.
function handle_api_response(status_code, body)
if status_code >= 400 then
-- Throw a structured error object
error({
type = "api_error",
code = status_code,
message = body.error_message
}, 2)
end
return body
end
-- Catching it
local ok, err = pcall(handle_api_response, 404, {error_message = "Not found"})
if not ok then
-- Check the type of the error
if type(err) == "table" and err.type == "api_error" then
print("Specific API error:", err.message)
print("HTTP code:", err.code)
else
-- Fallback for unstructured errors
print("Generic error:", err)
end
end
This allows your catch blocks to be intelligent. You can handle a network timeout differently from a parsing error, even if they happen in the same function.
The assert() Shortcut
assert(condition, message) is shorthand for “if this is false, error out immediately.” It’s concise and readable.
function load_config(path)
-- File.read might return nil if the file doesn't exist
local content = file_read(path)
assert(content, "Configuration file not found at: " .. path)
return parse_json(content)
end
But a word of caution: assert calls error() with level 2 by default, which is usually correct, but if you’re wrapping it inside another function that does its own error handling, make sure the stack level makes sense.
Tail Calls and Stack Overflow: The Performance Trap
This is where Lua shines compared to other languages. Lua guarantees tail-call optimization. If your function’s last action is calling another function, it doesn’t add a new stack frame. It reuses the current one.
This matters huge for error handling because deep recursion can blow up your stack. If you write recursive logic that might fail, ensure it’s tail-recursive.
-- Not tail-recursive: builds up stack frames
function sum_recursive(n, acc)
if n == 0 then
return acc
end
-- The call to sum_recursive is NOT the last thing happening
-- because we still need to add n to the result
return sum_recursive(n - 1, acc + n)
end
-- Tail-recursive: safe for large n
function sum_tail_recursive(n, acc)
if n == 0 then
return acc
end
-- The call to sum_tail_recursive IS the last thing happening
return sum_tail_recursive(n - 1, acc + n)
end
Wait, looking at the code above, both look tail-recursive. Let me correct that. The key is that the result of the recursive call is returned directly.
-- Bad: multiplication happens AFTER the recursive call returns
function factorial_bad(n)
if n <= 1 then return 1 end
return n * factorial_bad(n - 1) -- Not tail call
end
-- Good: result is returned directly
function factorial_good(n, acc)
acc = acc or 1
if n <= 1 then return acc end
return factorial_good(n - 1, n * acc) -- Tail call
end
When you pair this with pcall, you can safely run deep recursive operations without worrying about stack overflows crashing your script unexpectedly.
Structuring Your Code for Errors
The biggest mistake I see in Lua scripts is scattering pcall everywhere. It makes code unreadable. Instead, isolate the risky operations.
The “Try-Catch” Pattern
Lua doesn’t have try-catch keywords. You simulate it. But don’t clutter your main logic with it.
-- Bad: Logic mixed with error handling
function process_data(data)
local ok, result = pcall(function()
-- 50 lines of complex logic
-- if something fails, we return nil
end)
if not ok then
log_error(result)
return nil
end
return result
end
-- Good: Separate the risky operation
local function risky_computation(data)
-- Pure logic here, no pcall
if data.type ~= "valid" then
error("Invalid data type")
end
-- ... complex processing ...
return computed_result
end
function process_data(data)
local ok, err = pcall(risky_computation, data)
if not ok then
log_error("Computation failed:", err)
return nil
end
return err
end
By separating the what (the computation) from the how (the error handling), you make your code testable and readable. You can unit test risky_computation without worrying about the wrapper.
Global Hooks: The Nuclear Option
Sometimes you want to catch everything. Maybe you’re writing a plugin system and you don’t trust the plugins. Lua gives you setwarn and debug hooks, but a more direct approach for global error interception isn’t built into the language core in a simple way. However, you can wrap the entire Lua state’s execution.
In embedded Lua scenarios (like game engines), you often wrap the call to luaL_dostring or luaL_loadfile with pcall.
-- Pseudo-code for an embedded Lua host
local function run_lua_code(lua_state, code)
local status, err = pcall(function()
luaL_dostring(lua_state, code)
end)
if not status then
-- Log the error in the host language
host_log_error(err)
-- Recover or shut down gracefully
return false
end
return true
end
If you’re doing something really exotic, like a custom REPL or a sandbox, you can use lua_atpanic. This sets a function that Lua calls if an error is uncatchable. It’s the absolute last resort.
-- In C, you'd do:
-- lua_atpanic(L, my_panic_function);
In pure Lua, you can’t easily set atpanic, but you can simulate a panic by ensuring every entry point is wrapped.
Common Pitfalls to Avoid
1. Swallowing Errors Silently
local ok, err = pcall(dangerous_func)
if not ok then
-- Don't just do nothing!
-- log_error(err) or re-throw is better
end
If you catch an error and do nothing, you’re hiding bugs. At the very least, log it.
2. Using assert in Library Code Without Context
If you’re writing a library function, use error with a clear message and level, or return nil and an error string. assert is great for internal checks and interactive scripts, but in libraries, explicit error handling is preferred because the caller might want to recover.
3. Ignoring the Return Value of pcall
pcall(some_function) -- What if it fails? Who knows!
Always check the return value. Ignoring it is a surefire way to have silent failures in production.
Real-World Example: A Robust File Parser
Let’s put it all together. Imagine you’re building a tool that parses configuration files from untrusted sources.
local config_parser = {}
-- Structured error type
local function config_error(msg, code)
local err = { message = msg, code = code or "PARSE_ERROR" }
error(err, 2)
end
-- Core parsing logic (pure, no error handling overhead)
local function parse_table(data)
local result = {}
for key, value in pairs(data) do
if type(value) == "table" then
result[key] = parse_table(value)
else
result[key] = value
end
end
return result
end
-- Public API with safety
function config_parser.load(filepath)
-- Step 1: Read file safely
local file = io.open(filepath, "r")
if not file then
-- Throw a specific error
error({ code = "FILE_NOT_FOUND", path = filepath }, 2)
end
-- Step 2: Read content
local content = file:read("*a")
file:close()
if not content or content == "" then
error({ code = "EMPTY_FILE" }, 2)
end
-- Step 3: Parse safely using pcall
local ok, parsed_data = pcall(load, "return " .. content)
if not ok then
-- Wrap the Lua parse error in our structured error
config_error("Failed to parse config: " .. tostring(parsed_data), "PARSE_ERROR")
end
-- Step 4: Validate structure
if type(parsed_data) ~= "table" then
config_error("Config root must be a table", "INVALID_STRUCTURE")
end
-- Step 5: Deep parse
local success, result = pcall(parse_table, parsed_data)
if not success then
config_error("Error during deep validation: " .. tostring(result), "VALIDATION_ERROR")
end
return result
end
-- Usage
local ok, config = pcall(config_parser.load, "settings.json")
if ok then
print("Config loaded successfully", config)
else
if type(config) == "table" and config.code then
-- Handle specific error codes
if config.code == "FILE_NOT_FOUND" then
print("Missing file:", config.path)
elseif config.code == "PARSE_ERROR" then
print("Syntax error in config:", config.message)
else
print("Config error:", config.message)
end
else
print("Unexpected error:", config)
end
end
Why This Matters
Good error handling isn’t just about preventing crashes; it’s about providing feedback. When a user runs your Lua script and it fails, do they know why? Do they know where? If the error message is just “attempt to index a nil value,” they’re clueless. But if you’ve structured your errors with codes and messages, you can guide them to the fix.
Lua’s simplicity is its strength. It doesn’t force you into complex exception hierarchies. It gives you error, pcall, and xpcall, and that’s it. Master these three, structure your code to isolate risks, and your scripts will be robust, readable, and far less likely to bite you in production.
Remember: Fail early, fail clearly, and never swallow an error without logging it. That’s the Lua way.
