Error Handling with pcall and xpcall
Why Error Handling Matters
In Lua, errors are inevitable. A file might not exist, a network call might fail, or a user might pass invalid input. Without error handling, any error crashes your entire program. That’s where pcall and xpcall come in—they let you catch and handle errors gracefully instead of letting them propagate.
The Problem with Regular Function Calls
When you call a function that errors, Lua doesn’t return to your code—it throws the error up the call stack:
function divide(a, b)
return a / b -- This will error if b is 0
end
divide(10, 0) -- stdin:1: attempt to divide by zero
The program crashes. In a long-running server or game, this is unacceptable. You need a way to attempt an operation and handle failures without bringing down everything else.
Protected Execution with pcall
pcall stands for “protected call.” It runs a function in protected mode—instead of erroring, it returns a status code telling you whether the call succeeded:
local success, result = pcall(divide, 10, 0)
if success then
print("Result:", result)
else
print("Error:", result) -- "attempt to divide by zero"
end
pcall always returns at least two values. The first is a boolean: true if the function ran without error, false if an error occurred. The second is either the function’s return value (if successful) or the error message (if it failed.
This pattern lets you write defensive code:
local function read_config(filename)
local file = io.open(filename, "r")
if not file then
return nil, "Could not open file"
end
local content = file:read("*a")
file:close()
return content
end
local success, result = pcall(read_config, "config.txt")
if success then
print("Config loaded:", result)
else
print("Failed to load config:", result)
end
Understanding pcall’s Return Values
When the protected function succeeds, pcall returns true followed by whatever the function returns:
local success, sum = pcall(function() return 1 + 2 end)
-- success = true, sum = 3
When it fails, pcall returns false followed by the error message:
local success, err = pcall(function() error("oops") end)
-- success = false, err = "oops"
Multiple return values pass through cleanly:
local function stats()
return 10, 20, 30
end
local ok, a, b, c = pcall(stats)
-- ok = true, a = 10, b = 20, c = 30
This makes pcall flexible for any function signature.
Error Handling with xpcall
xpcall is similar to pcall but adds a second argument: an error handler function. Instead of getting the raw error message, you can process it through your handler first:
local function my_error_handler(err)
print("Caught error:", err)
return "Handled: " .. err -- Can return a modified message
end
local success, result = xpcall(function() error("fail!") end, my_error_handler)
-- Prints: Caught error: fail!
-- success = false, result = "Handled: fail!"
The error handler receives the error message and can do anything—log it, modify it, inspect a stack trace. This is invaluable for debugging in production.
A common pattern is to get a stack traceback:
local function debug_handler(err)
local trace = debug.traceback("", 2) -- Get stack trace
return err .. "\n" .. trace
end
xpcall(dangerous_function, debug_handler)
The debug.traceback call captures where the error actually occurred, not just the error message.
When to Use pcall vs xpcall
Choose pcall when you just need to know if something worked:
-- Simple yes/no check
local ok, data = pcall(json.decode, input_string)
if not ok then
return nil -- Silent failure is fine here
end
Choose xpcall when you need to handle the error with custom logic:
-- Need to log or transform the error
xpcall(api_call, function(err)
logger:error("API call failed: " .. err)
metrics:increment("api.failures")
end)
In practice, pcall covers most cases. Reach for xpcall when your error handler needs to do more than just check success or failure.
Practical Examples
Wrapping File Operations
Files can fail to open, read, or write. Wrap risky operations:
local function safe_read(path)
local ok, result = pcall(function()
local f = assert(io.open(path, "r")) -- assert still throws!
local content = f:read("*a")
f:close()
return content
end)
if ok then
return result
else
return nil, result
end
end
Note the assert inside the protected call—it will still trigger the error handler because pcall catches all Lua errors.
Validating User Input
local function parse_age(input)
local ok, age = pcall(function()
local n = tonumber(input)
assert(n, "Not a number")
assert(n > 0, "Must be positive")
assert(n < 150, "Implausible age")
return n
end)
if ok then
return age
else
return nil, "Invalid age: " .. age
end
end
Safe Module Loading
Loading modules can fail if the module has errors:
local ok, module = pcall(require, "optional_module")
if ok then
-- Use module safely
else
print("Module not available, using fallback")
-- Fallback logic
end
Common Pitfalls
Don’t forget the status check. Always check the first return value:
-- Wrong
local result = pcall(dangerous) -- Misses the success flag!
-- Right
local ok, result = pcall(dangerous)
Remember pcall catches all errors. This includes errors you might want to propagate:
local ok, err = pcall(function()
if something_wrong then
error("Intentional abort") -- Caught by pcall
end
end)
If you need to distinguish between recoverable and unrecoverable errors, use a custom error mechanism instead of Lua errors.
Error handlers can’t prevent the error. They only handle it:
xpcall(function() error("boom") end, function(err)
-- This runs AFTER the error
-- Can't prevent the error, only respond to it
end)
Summary
Error handling in Lua uses protected execution to prevent crashes:
pcallwraps a function call and returns success/failure plus results or errorxpcalladds an error handler for custom processing, logging, or stack traces- Use
pcallfor simple yes/no checks - Use
xpcallwhen you need to do something with the error - Always check the first return value—never ignore it
These two functions are your foundation for writing robust Lua applications that handle failures gracefully.