Lua's Garbage Collector Explained
What is garbage collection?
In languages like C, you allocate memory with malloc() and free it explicitly with free(). Forget to free and you leak memory. Free too early and you get use-after-free bugs. Lua’s garbage collector sidesteps this entirely: an automatic collector tracks object reachability and reclaims memory from objects no longer in use. No manual free(), no dangling pointers, no double-free bugs.
When Lua code creates a table, closure, userdata, or string, memory is allocated on the heap. The GC periodically walks from a set of root references (the global table _G, local variables on the call stack, and registry entries) and marks everything reachable. Everything else is garbage and eligible for collection on the next sweep.
For most programs this works invisibly. But in game loops, embedded systems, or long-running servers, GC pauses can become noticeable. Understanding how the collector works lets you tune its behavior and avoid patterns that cause memory pressure.
A brief history
Lua’s garbage collector has changed significantly across versions.
Lua 5.0 used a stop-the-world mark-and-sweep algorithm. The collector would freeze your program, traverse all reachable objects, then sweep everything unreachable. For large programs this pause could stretch into hundreds of milliseconds.
Lua 5.1 switched to incremental GC, splitting mark and sweep into small steps interleaved with program execution. This dramatically reduced pause times, though individual steps still consume CPU.
Lua 5.2 introduced a generational mode on top of incremental GC. The idea: most objects die young, so frequently collect only recently created (“young”) objects with a cheap minor collection, and occasionally do a full major collection. This mirrors a strategy used in JVMs and other mature runtimes.
Lua 5.3 and 5.4 refined the incremental collector further, with 5.4 adding improvements to the generational approach. The default mode remains incremental; generational is opt-in.
The tri-color algorithm
Lua’s incremental GC uses a tri-color mark-and-sweep scheme. Every collectible object is assigned one of three colors during a collection cycle:
White: not yet visited by the GC. Objects in white when the sweep phase ends are deleted.
Gray: visited but not yet scanned. The GC knows the object exists but hasn’t followed the references inside it.
Black: fully scanned. The object and everything it references have been traversed. Black objects will not be collected this cycle.
Phase Sequence
A single incremental collection cycle proceeds through these phases:
-
Mark phase. Starting from root references (
_G, thread stacks, the registry), the GC traverses reachable objects, painting them white to gray to black. -
Atomic phase. A brief pause where weak tables have their entries cleared (any entry where the key or value is white gets removed), and objects with
__gcmetamethods are queued for finalization. -
Sweep phase. The GC walks through all objects in memory. White objects are deleted. Black objects that accidentally reference white objects are turned gray again to fix what is called the color invariant.
-
Finalization.
__gcmetamethods are called for collected userdata before their memory is freed.
The key advantage of incremental GC is that each phase runs in small increments. You pay the cost of the entire cycle, but spread across many instruction cycles rather than concentrated in one stop-the-world pause.
Controlling the GC with collectgarbage()
All GC control in Lua flows through the collectgarbage([opt], [arg]) function.
Forcing a full cycle
collectgarbage("collect")
-- Same as calling collectgarbage() with no argument
Forces a complete collection cycle immediately. In incremental mode this runs the full mark and sweep sequence. Useful when you want memory reclaimed at a known point, such as between game levels or before a memory-intensive operation. In games, forcing a collection between levels prevents GC pauses from interrupting the next gameplay segment.
Single Step
collectgarbage("step")
-- Returns true if the step completed a collection cycle
collectgarbage("step", 1000) -- larger arg = more exhaustive step
Runs one incremental GC step. The optional arg value controls how much work the step does. Returns true if that step finished a cycle. In a game loop you might call this once per frame to spread GC work evenly.
Setting pause time
collectgarbage("setpause", percent)
-- Default: 200 (collector waits until memory use grows to 200% before restarting)
collectgarbage("setpause", 100) -- restart at 100% (2x current memory)
The pause time controls how long the GC waits after finishing a cycle before starting the next. The value is in percentage points. Lower values mean more frequent GC, which uses more CPU but keeps memory lower. Higher values use less CPU but allow more memory to accumulate.
For low-latency applications, setpause(100) restarts the collector at the same memory level rather than waiting for it to double, keeping memory usage more predictable.
Setting step multiplier
collectgarbage("setstepmul", percent)
-- Default: 200 (GC runs 2x faster than allocation rate)
The step multiplier controls how fast the collector runs relative to your program’s allocation rate. Higher values make the collector more aggressive, consuming more CPU but finishing cycles faster. Lower values spread the work out but allow memory to accumulate between cycles.
Generational mode (Lua 5.2+)
collectgarbage("generational", "minor")
-- Only collect young objects created since the last collection
collectgarbage("generational", "major")
-- Force a full major collection
Generational mode assumes the weak generational hypothesis: most objects die young. A minor collection traverses only young objects, making it much faster than a full incremental cycle. You should occasionally force a major collection to handle objects that survive minor collections but become unreachable.
The code pattern below shows the typical setup for a program that creates many short-lived objects. You switch to generational mode once, then periodically run a major collection to clean up any long-lived garbage that minor collections miss. If generational mode causes unexpected pauses, fall back to incremental mode with collectgarbage("collect").
-- Switch to generational mode for programs with many short-lived objects
collectgarbage("generational", "minor")
-- Periodically force a major collection
collectgarbage("generational", "major")
-- Or fall back to incremental: collectgarbage("collect")
Generational mode keeps short-lived allocations cheap. When you need to temporarily stop the collector, query its state, or check raw memory usage, the remaining collectgarbage options give you precise control over every phase of the cycle. Pausing the GC via "stop" is occasionally useful during a tight benchmark loop where collection noise would skew results.
Other Options
collectgarbage("stop") -- pause the GC entirely
collectgarbage("restart") -- resume the GC
collectgarbage("isrunning") -- returns true if GC is running (Lua 5.4+)
collectgarbage("count") -- returns total memory in KB
Weak Tables and the GC
Weak tables are tables where the keys, values, or both are weak references. A weak reference does not prevent the garbage collector from collecting the referenced object. You create a weak table by setting a __mode key in its metatable:
-- Weak value: values can be collected if no other reference exists
local cache = setmetatable({}, { __mode = "v" })
-- Weak key: keys can be collected
local map = setmetatable({}, { __mode = "k" })
-- Both weak
local cache2 = setmetatable({}, { __mode = "kv" })
Weak tables interact with the GC at the atomic phase. Before the sweep, Lua scans all weak tables and removes any entry where the key or value is white (unreachable). This means objects stored only in a weak table become eligible for collection in the next cycle.
This makes weak tables ideal for caches and memoization tables where you do not want the table itself to prevent objects from being collected. The memoization example below caches function results keyed by argument. Because the cache uses __mode = "kv", both keys and values are weak; when nothing else references a computed result or its argument, the GC removes that entry automatically. Without weak tables, every memoization cache would grow unbounded and eventually consume all available memory.
local function memoize(fn)
local cache = setmetatable({}, { __mode = "kv" })
return function(n)
if cache[n] == nil then
cache[n] = fn(n)
end
return cache[n]
end
end
Weak tables handle automatic cleanup of cached data by letting the GC decide what to keep. For resources that need explicit cleanup: file handles, network sockets, custom C structures wrapped in userdata — Lua provides the __gc metamethod. This finalizer mechanism runs a cleanup function right before an object is collected, giving you a chance to close handles, flush buffers, or log deallocation events.
Finalizers with __gc
The __gc metamethod on a userdata is a destructor. Lua calls it when that object is about to be collected:
local mt = {
__gc = function(self)
print("Closing resource")
end
}
local obj = setmetatable({}, mt)
obj = nil
collectgarbage("collect") -- triggers __gc
A few things worth knowing about finalizers:
__gcruns during the sweep phase after the object becomes unreachable.- Finalizers execute in reverse order of object creation (LIFO).
- A collected object’s
__gcmetamethod is called exactly once. - Reference cycles involving userdata with
__gcare handled correctly , the cycle is eventually broken and finalizers run when both objects are unreachable.
In Lua 5.2 and later, tables can also have __gc finalizers.
Common Pitfalls
Temporary table accumulation
-- Creates thousands of short-lived tables between GC cycles
for i = 1, 100000 do
local temp = { i, i * 2, i * 3 }
process(temp)
end
-- Better: reuse a single table
local temp = {}
for i = 1, 100000 do
temp[1], temp[2], temp[3] = i, i * 2, i * 3
process(temp)
end
Creating thousands of ephemeral tables between GC cycles generates memory churn that the collector must sweep through. Reusing a single table avoids this entirely: you pay zero allocation cost per iteration and the GC has nothing to clean up afterward.
Global table pollution
Lua variables are global by default unless declared with local. When you assign data to a global slot, that data remains reachable as long as the slot exists, which is typically forever, since globals live in _G. The fix is straightforward: use local for any value you don’t need permanently.
-- BAD: accidentally preventing collection
function process(data)
_G.tempStorage = data -- lives in _G, never collected
end
-- GOOD: use local scope
function process(data)
local temp = data
-- ... use temp ...
end
Checking for stray globals is one form of leak prevention. Closures introduce a subtler problem: they capture variables from their enclosing scope, and those captured variables remain alive as long as the closure does. Even if your closure never touches a particular upvalue, the reference still prevents collection. Explicitly nil out large data structures when your closure no longer needs them.
Closures capturing large upvalues
-- The large table stays referenced even if handler never uses it
local largeData = loadLargeTable()
function handler()
return "clicked"
end
-- largeData is still reachable because the closure captures it
-- Nil it out when done
largeData = nil
Performance Tuning
Once you understand how closures interact with the GC, the next step is tuning the collector itself for your workload. Different environments demand different GC profiles: a game needs consistent frame times, while an embedded system cares about total memory footprint.
Low-latency settings (games, real-time)
For games and real-time applications where consistent frame times matter more than peak memory, you want the GC to restart sooner rather than waiting for memory to double. This spreads collection work across more frames, making each pause shorter. Keep the step multiplier at the default since you don’t need the collector to race ahead of allocation.
-- Restart collector sooner, keep step multiplier at default
collectgarbage("setpause", 100)
collectgarbage("setstepmul", 200)
Low-latency mode trades memory for smoother frame times. Embedded systems with tight memory budgets need the opposite tradeoff: collect more aggressively to keep the heap small, even if it costs more CPU per cycle.
Low-memory settings (embedded)
On an embedded device where every kilobyte counts, push the collector to run faster relative to the allocation rate with a higher step multiplier. Set the pause threshold low so the collector kicks in sooner rather than letting memory accumulate.
-- More aggressive collection
collectgarbage("setpause", 110)
collectgarbage("setstepmul", 300)
The incremental settings above work well for steady-state applications. If your program generates bursts of short-lived objects—temporary tables in a request handler, intermediate strings in a parser loop, discarded closures in a callback pipeline—generational mode can reduce GC overhead dramatically.
Using generational mode
Generational mode exploits the observation that freshly allocated objects tend to die quickly. By collecting only the youngest generation most of the time, you avoid scanning long-lived data structures that rarely change. Just remember to occasionally trigger a major collection manually.
-- Enable for programs that create many short-lived objects
collectgarbage("generational", "minor")
-- Occasionally force a major collection
collectgarbage("generational", "major")
Once you’ve set a GC strategy, measure it. Guessing at memory pressure without data leads to premature optimization or missed problems. Lua provides two key metrics: collectgarbage("count") for current memory usage in KB, and os.clock() delta around a forced collection to measure pause duration. Run these measurements under realistic load; a collection that takes 2 ms in a test harness might take 50 ms when your game has thousands of active objects.
Measuring impact
-- Check memory in KB
local mem = collectgarbage("count")
print("Memory in use: " .. mem .. " KB")
-- Measure full collection time
local start = os.clock()
collectgarbage("collect")
local elapsed = (os.clock() - start) * 1000
print("Full GC took: " .. elapsed .. " ms")
See Also
- Metatables and Metamethods: The
__gcmetamethod and the metatable system that powers it - Weak Tables: Deep dive into weak references and their interaction with the GC