luaguides

Metatables: What They Are and Why They Matter

Every table in Lua is just a collection of key-value pairs. Nothing special about it — until you attach a metatable. A metatable is a table that sits behind another table and intercepts operations you wouldn’t normally think twice about: reading a missing key, assigning to a missing key, adding two tables, or even calling a table like a function.

This interception happens through metamethods — special keys like __index and __add that Lua recognizes and calls automatically under specific conditions. Metatables are the mechanism; metamethods are the hooks. Together they let you extend Lua with powerful, built-in-feeling patterns.

Attaching a Metatable

You attach a metatable to a table with setmetatable. It takes two arguments: the table and the metatable. It returns the table (with the metatable now attached).

local point = { x = 10, y = 20 }
local meta = {}
setmetatable(point, meta)

That’s it. Right now point has meta as its metatable, but meta is empty so nothing interesting happens. The moment you add metamethods to meta, things change.

You can confirm a table’s metatable with getmetatable:

print(getmetatable(point))  -- table: 0x55f8b8c3e2a0

Both setmetatable and getmetatable only work on tables. If you try setmetatable on a non-table (like a string or number), Lua raises an error.

The __index Metamethod

developers reach for __index most often. Lua calls __index whenever you read a key that doesn’t exist in a table.

local defaults = { health = 100, speed = 10, name = "Unnamed" }

local player = setmetatable({}, {
    __index = defaults
})

print(player.health)  -- Output: 100 (from defaults)
print(player.speed)   -- Output: 10 (from defaults)
print(player.name)    -- Output: Unnamed (from defaults)
print(player.armor)   -- Output: nil (no fallback)

When you access player.health, Lua looks in player, finds nothing, then checks the metatable. The __index value is defaults, so Lua looks there and finds health = 100.

This pattern gives you default values for any table. It’s clean, reusable, and doesn’t require copying values.

__index as a Function

__index doesn’t have to be a table — it can be a function. The function receives the table and the key as arguments:

local player = setmetatable({}, {
    __index = function(self, key)
        -- You could log, compute, or fall back to another table
        return "unknown:" .. key
    end
})

print(player.health)     -- Output: unknown:health
print(player.weapon)    -- Output: unknown:weapon

The function version gives you full control. You can implement caching, compute values on the fly, or delegate to multiple tables.

The __newindex Metamethod

Where __index handles reads, __newindex handles assignments to keys that don’t exist:

local frozen = {}

setmetatable(frozen, {
    __newindex = function(self, key, value)
        error("Cannot set " .. key .. " — table is read-only")
    end
})

frozen.name = "test"  -- Error: Cannot set name — table is read-only

Every assignment to a missing key calls __newindex. If you don’t define it, the assignment happens normally.

You can combine both to make a fully read-only table:

local read_only = {}
local meta = {
    __index = function(self, key) return nil end,
    __newindex = function(self, key, value)
        error("Cannot modify read-only table")
    end
}
setmetatable(read_only, meta)

read_only.score = 10    -- Error
print(read_only.score)  -- nil (no error, just missing)

Operators: __add, __eq, __lt, and More

Metatables let you define behavior for operators. Lua has a full set of arithmetic and comparison metamethods:

MetamethodOperatorCalled When
__add+Adding two tables
__sub-Subtracting
__mul*Multiplying
__div/Dividing
__eq==Equality comparison
__lt<Less than
__le<=Less or equal
__concat..Concatenation
__call()Calling a table as a function
__tostringprint()Converting to string
__len#Length operator

Here’s a vector class using __add and __eq:

local Vector = {}
Vector.__index = Vector

function Vector.new(x, y)
    return setmetatable({ x = x, y = y }, Vector)
end

function Vector.__add(a, b)
    return Vector.new(a.x + b.x, a.y + b.y)
end

function Vector.__eq(a, b)
    return a.x == b.x and a.y == b.y
end

function Vector:__tostring()
    return "(" .. self.x .. ", " .. self.y .. ")"
end

local a = Vector.new(1, 2)
local b = Vector.new(3, 4)
print(a + b)             -- Output: (4, 6)
print(a == Vector.new(1, 2))  -- Output: true

Lua calls Vector.__add when it encounters a + b. Note that metamethods in the metatable are looked up on the metatable itself, not on the table. That’s why Vector is set as the metatable of its instances — so lookups find __add in Vector.

Making Tables Callable: __call

The __call metamethod lets you invoke a table like a function:

local multiply = setmetatable({}, {
    __call = function(self, a, b)
        return a * b
    end
})

print(multiply(3, 4))  -- Output: 12

This is handy for factories, cached computations, or wrapping expensive operations behind a clean interface.

Raw Access: rawget and rawset

Sometimes you want to bypass the metatable entirely. Lua provides rawget and rawset for this:

local defaults = { theme = "dark" }
local config = setmetatable({}, { __index = defaults })

print(rawget(config, "theme"))    -- Output: nil (not in config itself)
rawset(config, "theme", "light") -- Sets directly, skips __newindex
print(rawget(config, "theme"))   -- Output: light

rawget returns a value without triggering __index. rawset assigns without triggering __newindex. You’ll reach for these when building meta-level helpers or when you explicitly need to avoid recursion.

Common Patterns

Default Values

The __index = defaults pattern covers most default-value needs:

local defaults = { debug = false, timeout = 30 }

local function with_defaults(t)
    return setmetatable(t or {}, { __index = defaults })
end

local opts = with_defaults({ timeout = 60 })
print(opts.debug)   -- Output: false (from defaults)
print(opts.timeout) -- Output: 60 (from opts)

Pass an existing table to with_defaults to override defaults with specific values while keeping the rest.

Read-Only Tables

For constants, configuration, or anything that shouldn’t change mid-execution:

local function readonly(t)
    local proxy = {}
    setmetatable(proxy, {
        __index = t,
        __newindex = function() error("readonly") end,
        __len = function() return #t end,
        __pairs = function() return pairs(t) end
    })
    return proxy
end

local COLORS = readonly({ red = 0xff0000, green = 0x00ff00 })
print(COLORS.red)        -- Output: 16711680
COLORS.blue = 0x0000ff   -- Error

This creates a proxy table. The original t is kept private and never exposed. All access goes through the proxy and all mutation is blocked.

Module-Like Namespacing

Metatables let you build encapsulated modules where internal functions are hidden:

local counter = (function()
    local count = 0

    return setmetatable({}, {
        __call = function(self, n)
            count = count + (n or 1)
            return count
        end,
        reset = function(self)
            count = 0
        end
    })
end)()

print(counter())   -- Output: 1
print(counter(5))  -- Output: 6
counter:reset()
print(counter())   -- Output: 1

The count variable lives in the closure and is inaccessible from outside. Only the exposed methods in the metatable are callable.

See Also