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, it 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.
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.
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.
Accessing Upvalues
Closures capture variables from their enclosing scope. These captured variables are called upvalues. The debug.getupvalue function reads them.
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.
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.
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. You can hook on function calls, function returns, line changes, or instruction counts.
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.
Counting Function Calls
Use the "c" mask to count how many times each function runs.
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. "crl" adds line events.
Instruction Counting
The third form uses the count parameter without a mask character. The hook fires after every count VM instructions. This is useful for rough performance profiling, though the instruction count itself is implementation-defined.
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.
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 the host application already has running.
Hooks and Coroutines
Hooks are set per-thread. When you pass a coroutine thread to sethook, you instrument only that coroutine. Other coroutines and the main thread run unaffected.
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.
Practical Applications
Breakpoints
You can implement a simple breakpoint by setting a line hook that checks a table of breakpoint lines.
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 prompt where you can inspect locals and resume execution with return.
Coverage Measurement
A line hook that records every line visited gives you per-line coverage data.
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
Written
- File: sites/luaguides/src/content/guides/lua-debug-library.md
- Words: ~1150
- Read time: 5 min
- Topics covered: debug.getinfo, getlocal/setlocal, getupvalue/setupvalue, upvalueid, upvaluejoin, getregistry, traceback, debug.debug, sethook, gethook, line hooks, call hooks, instruction counting, coroutine hooks, breakpoints, coverage, production security
- Verified via: https://www.lua.org/pil/23.html, https://www.lua.org/pil/23.1.html, https://www.lua.org/pil/23.2.html
- Unverified items: none