← Back to DevBytes

Memory Management in Lua: A Deep Dive

Understanding Lua's Automatic Memory Management

Lua handles memory for you — no manual malloc/free, no dangling pointers, no double-free bugs. But this convenience comes with responsibility. Understanding how Lua's garbage collector works, how to interact with it, and how to avoid common pitfalls is essential for writing robust, high-performance applications. This deep dive covers everything from the basics of the collector to advanced tuning and best practices.

What Is Memory Management in Lua?

In Lua, all objects — strings, tables, functions, userdata, threads (coroutines) — are allocated dynamically and managed by an automatic garbage collector (GC). You create objects, use them, and when they become unreachable (no references pointing to them), the collector reclaims their memory. This is a form of tracing garbage collection, specifically an incremental, generational collector in Lua 5.4 (and a classic incremental mark-and-sweep in earlier versions).

The GC runs periodically during allocation, stepping through a cycle of marking reachable objects, sweeping dead ones, and finalizing objects that have __gc metamethods. You don't need to call collectgarbage() in most programs, but knowing how to do so gives you control over memory usage and pause times.

Why It Matters

Automatic memory management is not a silver bullet. It matters because:

Ignoring the GC is fine for short scripts, but for anything long-running or memory-intensive, you need to understand the knobs and dials.

The Garbage Collector Under the Hood

Lua’s collector works in two modes (Lua 5.4+): incremental (default) and generational. In incremental mode, the collector interleaves its work with normal execution, running small “steps” when new memory is allocated. Each step does a bit of marking or sweeping. This avoids long stops but adds some overhead to every allocation.

In generational mode (introduced in Lua 5.4), objects are divided into “young” and “old” generations. New objects start young, and if they survive a collection cycle they are promoted to old. Most objects die young, so the collector focuses on the young generation, doing minor collections quickly. Old generation collections happen less frequently. This reduces overall GC overhead significantly, especially for programs that create many short-lived tables or strings.

The GC recognizes these fundamental types:

The collector also handles weak references (weak tables) and finalizers (__gc metamethod). Weak tables are special: their keys and/or values are held weakly, meaning if a weak reference is the only reference to an object, the object can be collected. This is crucial for caches, observer patterns, and avoiding circular reference leaks.

How to Work with the Garbage Collector

All interaction goes through the collectgarbage function. It accepts various string commands and optional arguments. Here are the most important ones, with practical examples.

Inspecting Memory Usage

Use collectgarbage("count") to get the total memory (in kilobytes) currently used by Lua. This is your main tool for measuring memory footprint before and after operations.


-- Before a heavy operation
local mem_before = collectgarbage("count")
-- Create a large table
local t = {}
for i = 1, 100000 do
  t[i] = { x = i, y = i * 2 }
end
local mem_after = collectgarbage("count")
print(string.format("Memory delta: %.2f KB", mem_after - mem_before))
-- Clean up: remove reference and force collection
t = nil
collectgarbage("collect")
local mem_final = collectgarbage("count")
print(string.format("After GC: %.2f KB", mem_final))

This prints the memory used by the new table array and its subtables. Notice that after setting t = nil and forcing a full collection, memory drops back near the original level.

Forcing Garbage Collection

Sometimes you want an immediate full collection — for example, before a performance-critical section or after a massive deallocation. Use collectgarbage("collect"). It performs a complete GC cycle (mark, sweep, finalize) and returns the amount of memory freed in kilobytes (Lua 5.4; earlier versions may return other values).


-- Force a full GC cycle
local freed = collectgarbage("collect")
print("Freed memory: " .. tostring(freed) .. " KB")

Be careful: a full collection can take significant time if there are many objects. In a real-time loop, prefer incremental steps.

Performing Incremental Steps

collectgarbage("step", size) runs a single incremental step, where size is the amount of work to do (in bytes of allocation to process). The collector will run until that amount of allocation has been “paid for” by GC work. It returns true if the cycle finished, false otherwise. This lets you spread GC work across multiple frames.


-- Game loop example: do a small GC step each frame
while not collectgarbage("step", 100) do
  -- 100 bytes of allocation work per step
  -- You can do other work here, like processing input
end

You can tune the step size to balance GC pauses vs. memory growth. Larger steps finish cycles faster but cause longer single pauses.

Tuning the GC for Responsiveness

The GC has two internal parameters: pause and step multiplier. The pause controls when the collector starts a new cycle (after total allocated memory has grown by a certain percentage relative to the memory in use at the start of the cycle). The step multiplier controls the aggressiveness of incremental steps relative to actual allocations.

Use collectgarbage("setpause", p) and collectgarbage("setstepmul", m) to adjust them. Defaults: pause = 200 (200% growth triggers a cycle), stepmul = 200 (collector runs twice as fast as allocations). For a memory-tight environment, lower the pause; for a CPU-tight environment, lower the stepmul.


-- Example: tune for minimal memory overhead
collectgarbage("setpause", 100)   -- start GC when memory doubles
collectgarbage("setstepmul", 500) -- collector works 5x faster than allocations

-- For low CPU impact, slow down collector
collectgarbage("setpause", 300)
collectgarbage("setstepmul", 100) -- collector pace equals allocations

In Lua 5.4, you can also switch to generational mode:


-- Switch to generational collector (Lua 5.4+)
collectgarbage("generational", "minor")
-- Parameters: mode ("generational"/"incremental") and optional sub-mode
-- "minor" runs minor collections; "major" forces a major cycle.

Generational mode often requires no tuning and yields better performance for most applications, especially those creating many temporary objects.

Weak Tables and Ephemeron Tables

A table can have weak keys, weak values, or both. A weak reference does not prevent garbage collection. This is declared via the __mode field in a table’s metatable: "k" for weak keys, "v" for weak values, "kv" for both. Weak tables are essential for caching and preventing circular reference leaks.


-- Weak-value cache: values are collected if only the cache holds them
local cache = {}
setmetatable(cache, { __mode = "v" })

-- Store some expensive computation result
local key = "player_data"
cache[key] = { health = 100 }  -- large table
-- Now if 'key' is the only reference to the value,
-- the value becomes eligible for GC.
-- The key itself is strong, so it stays.

-- Weak-key table: keys are collected if no strong references elsewhere
local weak_key_map = {}
setmetatable(weak_key_map, { __mode = "k" })

local obj = {}
weak_key_map[obj] = "metadata"
obj = nil  -- Now the key (old obj) becomes unreachable strongly,
           -- so the entry will be removed from weak_key_map at next GC.

Lua 5.4 introduces ephemeron tables: weak-key tables where a value is only kept alive if the corresponding key is alive. This is useful for associating auxiliary data with objects without preventing their collection.


-- Ephemeron behavior (Lua 5.4): values accessible only if key is alive
-- A weak-key table with weak values is implicitly ephemeron.
-- Example: attach a callback to an object, but don't keep callback alive
-- if the object dies.
local obj_events = {}
setmetatable(obj_events, { __mode = "kv" }) -- ephemeron-like
-- (In Lua 5.4, weak-key tables are ephemeron by default for values.)

Use weak tables liberally in caches, listener registries, and any place where you don’t want to artificially extend object lifetimes.

Finalizers: The __gc Metamethod

Full userdata (and in Lua 5.4, some tables via the __gc metamethod) can have a finalizer — a function called when the object is about to be collected. This is your chance to release external resources, close file handles, free native memory, or remove references from global registries.


-- Creating a userdata with a finalizer in C is common, but Lua 5.4
-- allows finalizers on regular tables too (if set via setmetatable).
-- Example: simulating a managed file handle
local ManagedFile = {}
ManagedFile.__index = ManagedFile

function ManagedFile.new(filename)
  local f = io.open(filename, "w")
  local obj = { handle = f }
  setmetatable(obj, ManagedFile)
  return obj
end

function ManagedFile:close()
  if self.handle then
    self.handle:close()
    self.handle = nil
  end
end

-- In Lua 5.4, you can set a finalizer:
setmetatable(ManagedFile, {
  __gc = function(mt)
    -- This is called when a table with this metatable is collected.
    -- We can iterate? No, it's just the metatable finalizer.
    -- Actually, __gc is called on the *object*, not the metatable.
  }
})

Wait — the above is slightly off. In Lua 5.4, you set a __gc metamethod directly on the object’s metatable. When the object (a table) is collected, the __gc function is called with the object as argument. Let’s show a correct example:


-- Lua 5.4 table finalizer example
local mt = {
  __gc = function(obj)
    print("Finalizing object, closing handle")
    if obj.handle then
      obj.handle:close()
      obj.handle = nil
    end
  end
}

function createResource(filename)
  local f = io.open(filename, "w")
  local obj = { handle = f }
  setmetatable(obj, mt)
  return obj
end

local res = createResource("test.txt")
res = nil
collectgarbage("collect")  -- triggers finalizer, closes file

Finalizers run during the sweep phase, after the object is marked as dead. They execute in reverse order of creation (to handle dependencies). Avoid complex logic or long operations in finalizers; they can delay GC. Also, beware that an object may be resurrected if a finalizer stores it in a global reachable location — the collector handles this, but it’s messy.

Best Practices for Memory-Efficient Lua

Here are concrete guidelines to keep your Lua memory footprint small and GC overhead low:

Remember: Lua’s GC is efficient, but it’s not magic. Understanding object lifetimes and actively managing references pays off in performance and reliability.

Conclusion

Lua’s automatic memory management frees you from manual memory juggling, but it demands awareness. By learning the garbage collector’s workings, mastering collectgarbage, leveraging weak tables and finalizers, and applying best practices, you can write Lua programs that are both memory-friendly and responsive. Whether you’re building a game engine, a scripting layer for a large application, or a high-throughput network service, the time you invest in understanding Lua’s memory model will reward you with smoother performance and fewer surprises.

🚀 Need a reliable AI agent for your project?

Deploy Hermes Agent in 10 minutes. Managed hosting, zero DevOps.

Get Started — $23.99/mo
← Back to all articles