luaguides

Weak Tables and Their Uses

Weak tables let you hold references to objects without preventing the garbage collector from reclaiming them. Without them, any object stored in a regular table stays alive for as long as the table exists — even if nothing else in your program needs that object. Weak tables break that link, giving you a way to cache, track, and observe objects without creating permanent memory anchors.

This guide covers all three weak table modes, explains how the garbage collector handles them, and walks through practical use cases where they solve real problems.

How Weak Tables Work

A weak table is just an ordinary table with a special flag in its metatable. You create one with setmetatable, setting the __mode key to control which references are weak:

-- Weak values: objects stored in the table can be GC'd
local weakValues = setmetatable({}, { __mode = "v" })

-- Weak keys: objects used as keys can be GC'd
local weakKeys = setmetatable({}, { __mode = "k" })

-- Both keys and values are weak
local weakBoth = setmetatable({}, { __mode = "kv" })

The __mode string tells Lua which direction of reference is weak. The letters are concatenated, so "kv" means both keys and values are weak. Without __mode, a table has normal strong semantics — everything it holds stays alive.

After creation, you use a weak table exactly like any other table. The difference is invisible to normal reads and writes; it only shows up during garbage collection.

The Three Weak Table Modes

Weak Values (__mode = "v")

When you set __mode = "v", the table values are weak references. The keys remain strong. This means if a value is no longer reachable from anywhere else in your program, the garbage collector can reclaim it — and the entry vanishes from the table.

local cache = setmetatable({}, { __mode = "v" })

local function fetch(url)
    if cache[url] then
        return cache[url]
    end
    -- simulate a network request
    local data = "content of " .. url
    cache[url] = data
    return data
end

print(fetch("http://example.com"))  -- fetches, stores
print(fetch("http://example.com"))  -- cache hit, returns stored value

Here, the string stored in cache["http://example.com"] is a weak reference. If nothing else holds that string and memory pressure builds, the GC can collect it and the entry disappears. New calls with the same URL simply recompute the result — the cache is a performance boost, not a permanent store.

Weak Keys (__mode = "k")

When you set __mode = "k", the table keys are weak references. Values remain strong. Use this when you want the table to track objects (the keys) without preventing those objects from being garbage collected. If a key becomes unreachable from anywhere else in your program, the entire entry — both key and its associated value — is removed from the table on the next GC pass.

local live = setmetatable({}, { __mode = "k" })

local function register(obj)
    live[obj] = true
end

local function countLive()
    local n = 0
    for _ in pairs(live) do n = n + 1 end
    return n
end

local a = { name = "object_a" }
local b = { name = "object_b" }
register(a)
register(b)
print(countLive())  -- 2

a = nil
collectgarbage()
print(countLive())  -- 1

a is registered in live as a key, but a itself is a weak reference. When you set a = nil, the original table object has no remaining strong references, so the GC reclaims it and removes the entry from live automatically.

Weak Keys and Values (__mode = "kv")

Both keys and values are weak. An entry survives only if both its key and value are reachable from strong references elsewhere.

local registry = setmetatable({}, { __mode = "kv" })

local function register(key, value)
    registry[key] = value
end

local function lookup(key)
    return registry[key]
end

local obj = setmetatable({}, { __mode = "kv" })
register(obj, { payload = "data" })

print(lookup(obj))  -- table: 0x...
obj = nil
collectgarbage()
print(lookup(obj))  -- nil

This mode is the most permissive. If either the key or the value loses all strong references, the entire entry is eligible for collection.

Garbage Collection Behavior

Understanding how weak tables interact with the GC helps you predict when entries disappear and why.

Strong references block collection. An object is only collected if it is unreachable from any root — globals, the stack, or any chain of strong references. Storing an object in a weak table does not prevent collection if nothing else holds it. But if another part of your program still holds a reference, the object stays alive regardless of the weak table.

A table with only weak references gets collected entirely. If every entry in a weak table depends entirely on weak references, and no outside code holds a strong reference to any key or value, the table itself becomes unreachable and Lua collects it. This is usually desirable — an empty cache costs nothing.

Strings and numbers are always strong. Lua’s garbage collector treats primitive values differently. Numbers, booleans, and strings are never collected — they live as long as the table that holds them. Setting __mode = "v" on a table that stores numbers has no effect; those values are always kept.

Ephemeron semantics (Lua 5.2+). In Lua 5.2 and later, weak tables have ephemeron semantics: if a value is reachable only through its key, and the key is only reachable through the weak table, both are collected together. In Lua 5.1 this transitive relationship did not exist — a reachable key would keep its value alive even if the value had no other strong references. LuaGuides targets Lua 5.4, so ephemeron semantics apply.

Entries are not removed immediately. When a key or value becomes unreachable, the entry is not deleted right away. Lua removes it on the next garbage collection cycle. Calling collectgarbage() forces a collection if you need deterministic cleanup in tests or short-lived scripts.

Building a Memoization Cache

Memoization caches expensive function results so repeated calls with the same arguments return the stored value instead of recomputing. Without weak tables, a memoization cache grows without bound — every computed result stays in memory forever. Weak values solve this cleanly.

local memo = setmetatable({}, { __mode = "v" })

local function memoize(fn)
    return function(n)
        if memo[n] then
            return memo[n]
        end
        local result = fn(n)
        memo[n] = result
        return result
    end
end

local function factorial(n)
    if n <= 1 then return 1 end
    return n * factorial(n - 1)
end

local fastFactorial = memoize(factorial)

print(fastFactorial(10))  -- 3628800
print(fastFactorial(10))  -- cache hit, no recursion
print(fastFactorial(9))   -- cache miss, computes from scratch

Each computed factorial result lives in memo as a weak value. If nothing else references a particular result and memory pressure rises, the GC reclaims it. The next call with the same argument recomputes it. The cache is a performance amplifier that degrades gracefully under memory pressure.

Tracking Live Objects with an Identity Map

Sometimes you want to keep track of every object of a certain type that currently exists — for debugging, profiling, or managing resources. Weak keys let you build an identity map that automatically shrinks as objects are freed.

local liveObjects = setmetatable({}, { __mode = "k" })

local ObjectRegistry = {}
ObjectRegistry.__index = ObjectRegistry

function ObjectRegistry.new(name)
    local instance = setmetatable({}, ObjectRegistry)
    instance.name = name
    liveObjects[instance] = true
    return instance
end

function ObjectRegistry.count()
    local n = 0
    for _ in pairs(liveObjects) do n = n + 1 end
    return n
end

local obj1 = ObjectRegistry.new("first")
local obj2 = ObjectRegistry.new("second")
print(ObjectRegistry.count())  -- 2

obj1 = nil
collectgarbage()
print(ObjectRegistry.count())  -- 1

The registry uses weak keys, so objects registered in it do not stay alive simply by being in the registry. When all other references to an object disappear and it goes out of scope, the next GC pass removes it from liveObjects automatically. No manual cleanup required.

An Observer Pattern with Automatic Unsubscribe

Event systems and observer patterns often suffer from a registration problem: listeners are registered but never unregistered, causing memory leaks that grow over time. Weak keys solve this elegantly — store each callback as a weak key with a strong true value. The callback stays alive as long as something else holds a reference to it; the moment all external references disappear, the GC reclaims it and the entry vanishes from the observer table automatically.

local observers = setmetatable({}, { __mode = "k" })

local function on(event, callback)
    if not observers[event] then
        observers[event] = {}
    end
    table.insert(observers[event], callback)
end

local function emit(event, ...)
    local callbacks = observers[event]
    if not callbacks then return end
    for i = #callbacks, 1, -1 do
        local cb = callbacks[i]
        if cb then cb(...) end
    end
end

on("chat", function(msg)
    print("[chat]", msg)
end)

on("chat", function(msg)
    print("[log]", msg)
end)

emit("chat", "hello, observers!")
-- [chat] hello, observers!
-- [log] hello, observers!

Each callback is stored as a weak key in observers[event], while the corresponding true value keeps it alive. If a callback loses all external strong references, the GC removes the entry automatically — no explicit unsubscribe needed.

Inspecting a Table’s Weak Mode

You can check whether a table has weak semantics by reading its metatable:

local wt = setmetatable({}, { __mode = "kv" })
local mt = getmetatable(wt)
print(mt.__mode)  -- kv

This is useful for debugging and for library code that needs to introspect table behavior. Remember that __mode lives in the metatable, not in the table itself — setting a new metatable replaces the weak mode entirely.

A Complete Example: Caching Fibonacci

Here is a full runnable example combining memoization with weak values to cache Fibonacci numbers efficiently:

local fibCache = setmetatable({}, { __mode = "v" })

local function fib(n)
    if n <= 1 then return n end

    if fibCache[n] then
        return fibCache[n]
    end

    local result = fib(n - 1) + fib(n - 2)
    fibCache[n] = result
    return result
end

-- First call: fills cache
print(fib(20))   -- 6765

-- Second call: reads from cache
print(fib(20))   -- 6765 (from cache)

-- Force GC and check
collectgarbage()
print(fibCache[20])  -- 6765 (still cached while fib is in scope)

The cache grows as you ask for higher numbers. When fibCache itself goes out of scope or is replaced, and no other code holds references to the cached numbers, the entire cache and all its entries are eligible for collection.

See Also

  • lua-metatables — weak tables are implemented through metatables; this guide covers the broader metatable system that powers them

Summary

ModeWeak partStrong partUse case
__mode = "v"ValuesKeysMemoization caches, result storage
__mode = "k"KeysValuesObject registries, identity maps
__mode = "kv"BothNeitherEphemeron relationships, debug tracking

Weak tables are not a replacement for deliberate memory management — they are a tool that makes certain patterns safer and more automatic. Use weak values for caches where stale entries should evaporate. Use weak keys for registries where tracked objects should not be kept alive by the registry itself. Use both when you want entries that survive only through mutual strong references elsewhere in the program.