The debug Library: Inspection and Hooks
Lua ships with a standard debug library that gives you a window into the runtime behaviour of your program. Unlike the other standard libraries, this one is not about data processing or I/O—it is about introspection: reading the call stack, peeking at local variables, and intercepting execution events through hooks. The debug library is deliberately low-level, exposing raw primitives that let you build debuggers, profilers, and coverage tools from scratch.
This library is deliberately low-level. It exposes raw primitives that let you build debuggers, profilers, and coverage tools from scratch. It also breaks normal encapsulation rules, which is why you should never ship it in production code. This guide covers both introspection functions and the hook system using Lua 5.4.
Introspection: reading the stack
The most fundamental introspective operation is asking “where am I right now?” debug.getinfo answers that and more.
debug.getinfo(fn, [what])
Pass a function or a stack level number and get back a table of information. Stack levels start at 1, which is the function that called getinfo itself. Level 2 is the caller of that function, and so on.
local function greet()
local info = debug.getinfo(1, "Sl")
print(string.format("%s:%d", info.short_src, info.currentline))
end
greet()
-- Output: stdin:3
The second argument is a mask of option letters. It controls which fields are returned, and leaving it off returns everything. The common letters are:
"S": source, short_src, what, linedefined"l": currentline"n": name and namewhat"f": func (the function object itself)"u": nups (number of upvalues)
Getting only the fields you need is significantly faster when you call getinfo frequently, such as inside a hook.
The what field in the returned table tells you what kind of chunk the function is: "Lua", "C", or "main". The name field is a best-effort name found by consulting the call site. If a function has no discoverable name, namewhat will be empty.
Reading and writing local variables
Once you know which function is running, you can read its local variables with debug.getlocal.
function foo(a, b)
local x = 10
for i = 1, 10 do
local name, val = debug.getlocal(1, i)
if not name then break end
print(name, val)
end
end
foo("hello", "world")
-- a hello
-- b world
-- x 10
Index 1 is the first local in declaration order, counting only variables currently in scope. When a block exits, its locals drop out of the numbering, so you will not see loop variables after the loop ends.
To modify a local, use debug.setlocal(thread, level, index, value). It returns the variable name or nil if the index is out of range. The index follows the same convention as getlocal: parameter names come first in left-to-right order, followed by explicitly declared locals in declaration order. Setting a local overwrites the current value in the target function’s stack frame, and the change takes effect immediately for any subsequent code in that function—the modified value persists for the remainder of the call.
function demo(n)
local name, val = debug.setlocal(1, 1, 999) -- try to set param n
print(name, val) --> n 999
end
demo(5)
Both functions accept a thread as the first argument. Pass 0 or omit it for the current thread. This thread-awareness makes getlocal and setlocal safe to use in coroutine-heavy programs: you can inspect or mutate the locals of one coroutine from another without corrupting the caller’s own stack. The thread parameter is especially useful in debugger implementations where the inspecting code runs in a separate coroutine from the code being debugged.
Accessing Upvalues
Closures capture variables from their enclosing scope. These captured variables are called upvalues. The debug.getupvalue function reads them directly from the closure object without needing the enclosing function to still be on the call stack—once a closure exists, its upvalues are accessible for the lifetime of the closure.
function outer(msg)
local function inner()
return msg
end
return inner
end
local closure = outer("secret")
local name, val = debug.getupvalue(closure, 1)
print(name, val) --> msg secret
debug.setupvalue modifies the stored value. It returns the upvalue name or nil if the index is out of range. The index follows the same numbering as getupvalue: upvalues are indexed starting at 1 in the order they first appear in the function body. Modifying an upvalue through the debug API changes what the closure sees on its next invocation—it is exactly equivalent to the closure having captured a different variable. This is why debug hooks can intercept and rewrite closure state without modifying the original source code, enabling powerful patterns like dynamic patching, mock injection during tests, and live configuration reloading in long-running applications.
debug.setupvalue(closure, 1, "changed")
print(closure()) --> changed
Unlike locals, upvalues exist as long as the closure exists, even when the enclosing function has returned. That is what makes closures useful.
Sharing upvalue storage with upvaluejoin
Lua 5.2 introduced two functions that manipulate upvalue identity at a deeper level.
debug.upvalueid(func, n) returns a light userdata that uniquely identifies the nth upvalue of a function. If two upvalues share the same storage slot, they return the same id.
local id1 = debug.upvalueid(foo, 1)
local id2 = debug.upvalueid(bar, 2)
if id1 == id2 then
print("these two upvalues share storage")
end
debug.upvaluejoin(f1, n1, f2, n2) makes the nth upvalue of f1 and the nth upvalue of f2 point to the same storage slot. This is permanent and one-directional.
A practical use is memoization: share a cache table between your wrapper and the original function so that writes from the wrapper are visible to the original.
local cache = {}
local function original(n)
return cache[n] or (cache[n] = heavy_computation(n))
end
-- Force original's first upvalue to share with our cache getter
debug.upvaluejoin(original, 1, function() return cache end, 1)
The call to upvaluejoin is irreversible. You cannot unshare an upvalue slot once it has been joined. This permanence means you should only use upvaluejoin when you fully understand the lifetime implications: both functions will share the same storage until both are garbage-collected, which could be far later than you expect if either function is stored in a long-lived table or global variable. For most debugging and instrumentation tasks, getupvalue and setupvalue provide enough access without the permanence risk.
The registry and traceback
debug.getregistry() returns the registry table: a hidden table used by the C API to store per-global state. From Lua it behaves like a normal table. Most Lua code never needs it directly, but C extensions and embedded Lua applications use it to pass data between C and Lua.
debug.traceback(thread, [msg], [level]) builds a stack traceback as a string. It accepts an optional message prepended to the output and a level at which to start the trace.
local function level3()
return debug.traceback(nil, "Stack trace:", 1)
end
local trace = level3()
print(trace)
-- Stack trace:
-- Stack traceback:
-- [C]: in function 'traceback'
-- stdin:2: in function 'level3'
-- stdin:5: in main chunk
Hooks: intercepting execution
The hook system lets you register a callback that runs when specific execution events occur. Hooks transform Lua from a passive runtime into an observable one: instead of guessing what your program is doing, you can watch it happen in real time. You can hook on function calls, function returns, line changes, or instruction counts. Each event type serves a different diagnostic purpose, and you can combine them to build profilers, coverage tools, step debuggers, and sandbox monitors—all from pure Lua without modifying the VM. The trade-off is performance: every hook invocation runs your callback in the same thread as the executing code, so a slow hook directly slows down the program being observed.
Setting a Hook with debug.sethook
debug.sethook([thread], hook, mask, [count])
The hook is a function that receives two arguments: the event name and, for line events, the line number.
The mask is a string combining any of these characters:
| Mask | Event | Trigger |
|---|---|---|
"c" | call | Function entry |
"r" | return | Function exit |
"l" | line | Before a new source line |
| (count) | count | Every n VM instructions |
Pass nil as the hook to remove the current hook entirely.
Tracing line execution
The most common hook is "l" for line-by-line tracing. This is useful for understanding execution flow without a debugger.
local function trace(event, line)
local info = debug.getinfo(2, "Sl")
if info then
print(string.format("[%s]:%d", info.short_src, line))
end
end
debug.sethook(trace, "l")
-- Example code
local sum = 0
for i = 1, 3 do
sum = sum + i
end
print("Total:", sum)
debug.sethook(nil) -- stop tracing
The output shows every line before it executes, letting you trace the path of your program from start to finish without setting breakpoints or single-stepping manually. This kind of tracing is particularly useful when you inherit unfamiliar code and need to understand the actual execution order—it reveals which branches are taken, how many iterations a loop executes, and whether functions you expect to be called are actually being called. The trace function above uses stack level 2 in its getinfo call because the hook itself runs at level 1, and you want information about the code that triggered the hook, not the hook callback.
Counting function calls
Use the "c" mask to count how many times each function runs. Unlike line tracing which produces a firehose of events, call-counting gives you a higher-level picture: which functions are hot, which are never reached, and whether the call distribution matches your expectations. The "Sn" flags passed to getinfo request only the source file name and the function name—the minimum needed to build a unique key for each function—keeping the profiling overhead as low as possible.
local counts = {}
local function profile(event)
local info = debug.getinfo(3, "Sn")
if info then
local key = info.short_src .. ":" .. info.linedefined
counts[key] = (counts[key] or 0) + 1
end
end
debug.sethook(profile, "c")
require("something") -- or any code to profile
debug.sethook(nil)
for k, v in pairs(counts) do
print(v, k)
end
Combine masks by concatenating: "cr" monitors both calls and returns, giving you a complete call tree where you can calculate the time spent inside each function by tracking when it was entered and when it returned. "crl" adds line events to the mix, which is powerful but expensive—every source line in your program now triggers a Lua callback, so use it only for short diagnostic runs on isolated code paths. For most profiling needs, "c" alone with a fast hash-key lookup like the example above gives you useful data without crippling performance.
Instruction Counting
The third form uses the count parameter without a mask character. The hook fires after every count virtual machine instructions executed, regardless of which function is running or which line is being processed. This mechanism is useful for enforcing execution budgets—think sandboxing user-submitted scripts or preventing runaway loops in a game’s AI code. The instruction count itself is internal to the Lua VM and does not correspond directly to CPU cycles or wall-clock time, but it is a consistent and portable measure of computational work across different hardware.
local instruction_budget = 10000
local count = 0
local function step(event)
count = count + 1
if count >= instruction_budget then
debug.sethook(nil)
error("instruction limit exceeded")
end
end
debug.sethook(step, "", 1000) -- check every 1000 instructions
Saving and restoring hooks
debug.gethook() returns the current hook function, its mask, and the count as three separate values. Call it before replacing a hook so you can restore it later. This is essential in any library or middleware that temporarily sets a hook for its own instrumentation—without saving and restoring, you would permanently clobber any hook the host application had already installed. The return values are designed to be passed directly back to sethook, making the save-and-restore pattern a one-liner.
local old_hook, old_mask, old_count = debug.gethook()
debug.sethook(my_new_hook, "l")
-- ... run some code ...
debug.sethook(old_hook, old_mask, old_count)
This pattern matters in library code. A library that silently replaces the hook breaks any debugging or profiling the host application already has running. The save-and-restore idiom should be applied any time you call sethook from code that might be loaded as a dependency, not just at the top level of a standalone script.
Hooks and Coroutines
Hooks are set per-thread, and each coroutine is its own thread from the debug library’s perspective. When you pass a coroutine thread to sethook, you instrument only that coroutine—other coroutines and the main thread continue running with their own hooks (or none at all). This thread-level isolation is critical for async Lua programs: a web server using coroutine-based request handling can hook each request independently, or a game engine can profile AI coroutines separately from rendering coroutines. Setting a hook on a coroutine that is not currently running is perfectly valid: the hook will fire as soon as that coroutine is next resumed.
local co = coroutine.create(function()
for i = 1, 3 do
print("coroutine", i)
coroutine.yield()
end
end)
local function co_hook(event, line)
print("hook:", event, line)
end
debug.sethook(co, co_hook, "l")
coroutine.resume(co) -- hook fires inside the coroutine
debug.sethook(co, nil) -- remove hook for this thread
This isolation makes it possible to debug concurrent programs without hooks interfering with each other. Each coroutine maintains its own hook state independently, and removing a hook from one coroutine does not affect any other thread’s instrumentation. This design also means you can attach different types of hooks to different coroutines—a line tracer on one, a call counter on another—to gather targeted diagnostic data without slowing down the parts of your program you are not currently investigating.
Practical Applications
Breakpoints
You can implement a simple breakpoint by setting a line hook that checks a table of breakpoint lines. This requires no external debugger protocol—just a Lua table keyed by source file and line number. When the hook fires and the current line matches a breakpoint entry, you call debug.debug() to drop into an interactive Lua prompt where you can inspect the call stack, examine local variables, and evaluate expressions before continuing execution.
local breakpoints = {["myscript.lua"] = {12 = true}}
local function breakpoint_hook(event, line)
local info = debug.getinfo(2, "Sl")
if info and breakpoints[info.short_src] and breakpoints[info.short_src][line] then
print(string.format("Breakpoint at %s:%d", info.short_src, line))
debug.debug()
end
end
debug.sethook(breakpoint_hook, "l")
Calling debug.debug() drops you into an interactive Lua prompt where you can inspect locals, examine the call stack with debug.traceback(), and resume execution by typing return. This is a lightweight alternative to attaching an external debugger, and it works anywhere Lua runs—no IDE, no debug protocol, just the standard library. The interactive prompt recognises all the debug library functions, so you can call debug.getlocal or debug.getupvalue directly from the prompt to inspect the program state at the breakpoint.
Coverage Measurement
A line hook that records every line visited gives you per-line coverage data without needing LuaCov or any external tool. The approach is straightforward: set a "l" hook, record each line number for each source file in a table, and compare the recorded lines against the known source lines after your test run completes. Lines that never appear in the coverage table represent code paths your tests did not exercise.
local covered = {}
local function coverage_hook(event, line)
local info = debug.getinfo(2, "Sl")
if info then
covered[info.short_src] = covered[info.short_src] or {}
covered[info.short_src][line] = true
end
end
After running your test suite, compare the covered lines against the lines in your source files. Any uncovered line is a potential gap in your tests.
Security and Performance
The debug library violates normal scoping rules. Any Lua code can read locals from any active stack frame, modify upvalues, and intercept execution. This is intentional for development purposes, but it means the library must not exist in production.
The common idiom to strip it is:
debug = nil
package.loaded.debug = nil
Or in Lua 5.4+, you can simply assign nil to the global.
Hooks carry a severe performance penalty. A line hook can slow a program by 10 to 100 times depending on how much work the hook does. Instruction-count hooks are similarly expensive. Profile the overhead before using hooks in performance-critical paths.
See Also
- Metatables and Metamethods in Lua: understand the table behaviour that hooks often inspect
- Weak Tables in Lua: tables with garbage-collection semantics, useful for attaching debug metadata without preventing collection
- Profiling Lua Applications: higher-level profiling techniques that build on the debug library’s hook primitives
- Error Handling Patterns: using
xpcallwith debug tracebacks to capture stack information during failures - Environments in Lua: understanding
_ENVandgetfenv/setfenvwhich complement the debug library’s scope inspection