luaguides

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 sidesteps this entirely with an automatic garbage collector (GC) that tracks object reachability and reclaims memory from objects no longer in use.

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 — 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:

  1. Mark phase — Starting from root references (_G, thread stacks, the registry), the GC traverses reachable objects, painting them white to gray to black.

  2. 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 __gc metamethods are queued for finalization.

  3. 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.

  4. Finalization__gc metamethods 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.

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.

-- 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")

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:

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

Finalizers with __gc

The __gc metamethod on a userdata acts as 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:

  • __gc runs during the sweep phase after the object becomes unreachable.
  • Finalizers execute in reverse order of object creation (LIFO).
  • A collected object’s __gc metamethod is called exactly once.
  • Reference cycles involving userdata with __gc are 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

Global Table Pollution

-- 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

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

Low-Latency Settings (Games, Real-Time)

-- Restart collector sooner, keep step multiplier at default
collectgarbage("setpause", 100)
collectgarbage("setstepmul", 200)

Low-Memory Settings (Embedded)

-- More aggressive collection
collectgarbage("setpause", 110)
collectgarbage("setstepmul", 300)

Using Generational Mode

-- Enable for programs that create many short-lived objects
collectgarbage("generational", "minor")

-- Occasionally force a major collection
collectgarbage("generational", "major")

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