Part of Learn Lua with NeoVim

Lua for Beginners: Error Handling — Handling Things That Go Wrong with pcall & assert | Episode 27

Sandy LaneSandy Lane

Video: Lua for Beginners: Error Handling — Handling Things That Go Wrong with pcall & assert | Episode 27 by Taught by Celeste AI - AI Coding Coach

Take the quiz on the full lesson page
Test what you've read · interactive walkthrough

Lua Error Handling: pcall, assert, and the ok/err Pattern

error("message") to throw. pcall(fn) for "protected call" — returns ok, err tuple. assert(cond, msg) for invariants. xpcall(fn, handler) for custom handlers. The local x, err = fn() convention.

Errors in Lua are raised values (usually strings). Code that might fail uses one of two strategies: protected call (catch errors and turn them into return values) or the result, err return convention (return both, caller checks). Today we cover both.

Throwing an error

error("something went wrong!")

Output:

lua: errors.lua:N: something went wrong!
stack traceback: ...

error(msg) raises msg as the error and unwinds the stack. By default, the script halts and the error prints to stderr.

pcall: protected call

local ok, err = pcall(function()
  error("something went wrong!")
end)

print("ok: " .. tostring(ok))   -- "ok: false"
print("err: " .. err)

pcall(fn, args...) calls fn(args...) in protected mode. Two return values:

  • oktrue if the call succeeded, false if an error was raised.
  • err (or the function's return value) — the error message if ok is false; otherwise the function's first return value.

The script doesn't crashpcall catches the error and turns it into a return value.

pcall on real errors

local ok, err = pcall(function()
  local x = 10 + "hello"
end)

print("ok: " .. tostring(ok))
print("err: " .. err)

10 + "hello" raises a runtime error ("attempt to perform arithmetic on a string value"). pcall catches it.

This is the canonical "try" — wrap risky code in a pcall and inspect the result.

assert: shortcut for invariants

function safe_divide(a, b)
  assert(b ~= 0, "cannot divide by zero!")
  return a / b
end

print(safe_divide(10, 3))   -- 3.333...

local ok, err = pcall(safe_divide, 10, 0)
print(err)   -- "errors.lua:N: cannot divide by zero!"

assert(cond, msg) is sugar for:

if not cond then error(msg or "assertion failed!") end

If cond is truthy, returns it (and any extra args). If falsy, raises an error with msg.

Use assert for invariants — conditions that should always be true. If they're not, something is fundamentally broken.

pcall passes args

local ok, err = pcall(safe_divide, 10, 0)

Equivalent to:

local ok, err = pcall(function() return safe_divide(10, 0) end)

The first form is shorter when you have an existing function reference. The second form is needed when you have a complex call.

xpcall: custom error handler

local function error_handler(err)
  return "CAUGHT: " .. err
end

local ok, msg = xpcall(function()
  error("boom!")
end, error_handler)

print("ok: " .. tostring(ok))
print("msg: " .. msg)

xpcall(fn, handler) lets you transform the error before it's returned. The handler runs while the stack is still intact, so you can capture a stack trace via debug.traceback:

local ok, err = xpcall(risky_fn, debug.traceback)
print(err)   -- includes a stack trace

This is the standard "log error with stack trace" pattern.

The ok/err return convention

Many Lua APIs don't throw — they return nil, errmsg instead:

function read_file(path)
  local file = io.open(path, "r")
  if not file then
    return nil, "could not open " .. path
  end
  local content = file:read("a")
  file:close()
  return content, nil
end

local content, err = read_file("missing.txt")
if not content then
  print("Error: " .. err)
end

This avoids the cost of stack-unwinding and makes the error path explicit. The standard library uses this convention extensively — io.open, os.rename, etc.

The caller pattern:

local result, err = some_func(...)
if not result then
  -- handle error using err
end

When to use which

Use error/pcall when: - The error is exceptional (programming bug, external service down). - Multiple call levels are involved and you don't want to thread the error through every return. - You want a stack trace.

Use the ok, err convention when: - Failure is expected and recoverable (file not found, parse error). - Performance matters in the failure path. - The API is small and the caller naturally handles each call's result.

In practice: file/network/parse APIs use nil, err; assertions and bugs use error.

Errors don't have to be strings

error({ code = 404, msg = "not found" })

local ok, err = pcall(...)
if not ok then
  print(err.code, err.msg)
end

error accepts any value. Tables let you attach structured information. The downside: the default error message ("script.lua:N: ..." prefix) only works for strings.

Common stumbles

Calling error outside a function. error("bad") at the top level halts the script with a traceback. That's the same as letting an exception propagate.

pcall swallows the original line number. The error message has the pcall's call site, not the original. Use xpcall(fn, debug.traceback) for full stack info.

Forgetting to validate pcall's ok. If ok is false, the second value is the error, not the function's result. Treat them differently.

Treating false returns as errors. A function returning false, "reason" is unusual; most APIs use nil, "reason". Different semantics — nil means "no value at all," false is a real boolean.

Using assert for input validation. assert(x > 0) throws an error — fine for "this should be impossible" cases. For user input validation, return nil, "x must be positive" instead — let the caller decide how to react.

What's next

Episode 28: type conversion. Lua's automatic coercion rules ("10" + 5 works!), tonumber and tostring, the safe-default pattern, and string.format for controlled number formatting.

Recap

error("msg") to raise. pcall(fn, args...) returns ok, err_or_result — catches the error. xpcall(fn, handler) lets you transform the error (often debug.traceback for stack traces). assert(cond, msg) shortcut for "raise if not cond." Many APIs use the nil, errmsg return convention instead of throwing — pattern: local result, err = fn(...) if not result then ... end. Use exceptions for bugs; use nil, err for expected failures.

Next episode: type conversion.

Ready? Take the quiz on the full lesson page →
Test what you've learned. Watch the lesson and try the interactive quiz on the same page.