Polyglot
Languages Lua metatables
Lua § metatables

Metatables

Metatables are the principal Lua metaprogramming mechanism — admit overloading operators, customising indexing, and substantial OOP. A metatable is an ordinary table (associated with another table via setmetatable) that contains metamethods — special-named functions that the runtime calls for specific operations. The principal metamethods: __index (called on missing-key access), __newindex (called on missing-key assignment), __add/__sub/__mul/__div/etc. (operator overloading), __eq/__lt/__le (comparison), __tostring (string conversion), __call (callable tables), __len (custom length), __metatable (lock/protect the metatable). The combination — setmetatable for association, the substantial metamethod surface, the __index for OOP and defaults, the operator overloading — is the substance of Lua’s metaprogramming.

Setting and getting metatables

local mt = {}                                      -- the metatable
local t = {}                                       -- the table

setmetatable(t, mt)                                -- associate
print(getmetatable(t) == mt)                       -- true

-- Or in one call:
local t = setmetatable({}, mt)

The setmetatable returns the table — admits substantial fluent setup.

__index — missing key access

The most important metamethod. When a key is not present in the table, Lua calls __index:

local defaults = {color = "red", size = "medium"}

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

print(config.color)                                -- "red" (from defaults)
print(config.size)                                 -- "medium"

config.color = "blue"
print(config.color)                                -- "blue" (own value)
print(config.size)                                 -- "medium" (still default)

The __index may be a table (look up there) or a function (called with the table and key):

local lazy = setmetatable({}, {
    __index = function(t, key)
        return "lazy: " .. key
    end
})

print(lazy.foo)                                    -- "lazy: foo"
print(lazy.anything)                               -- "lazy: anything"

The form admits substantial DSLs — every access produces a value.

__newindex — missing key assignment

Called when assigning to a missing key:

local readonly = setmetatable({a = 1, b = 2}, {
    __newindex = function(t, key, value)
        error("attempt to modify readonly table: " .. key)
    end
})

readonly.a = 10                                    -- OK (existing key)
readonly.c = 3                                     -- ERROR: attempt to modify readonly table: c

The mechanism admits substantial readonly tables, validation, lazy-init, etc.:

-- Auto-initialise sub-tables:
local groups = setmetatable({}, {
    __newindex = function(t, key, value)
        rawset(t, key, value)                      -- avoid recursion
        print("added group: " .. key)
    end
})

groups.users = {}                                  -- "added group: users"

The rawset(t, k, v) admits assignment bypassing __newindex — substantial for avoiding infinite recursion in the metamethod.

__index for OOP

The conventional Lua OOP pattern:

local Point = {}
Point.__index = Point                              -- methods accessed via __index

function Point.new(x, y)
    local self = setmetatable({}, Point)
    self.x = x
    self.y = y
    return self
end

function Point:greet()                             -- colon syntax: implicit self
    return "I am at (" .. self.x .. ", " .. self.y .. ")"
end

local p = Point.new(1, 2)
print(p:greet())                                   -- "I am at (1, 2)"

The mechanism:

  • The instance is {x = 1, y = 2} with metatable Point.
  • Method access (p.greet) is missing on the instance — falls through to __index (which is Point).
  • The method Point.greet is found and called with self = p.

Treated more substantially in OOP idioms.

rawget and rawset

To bypass __index and __newindex:

local t = setmetatable({}, {
    __index = function(_, k) return "from metatable" end
})

print(t.foo)                                       -- "from metatable"
print(rawget(t, "foo"))                            -- nil (raw access)

rawset(t, "bar", 42)                               -- bypasses __newindex

The conventional uses are avoiding recursion in metamethods and checking actual presence of keys.

Arithmetic metamethods

Operator overloading via metatables:

local Vec = {}
Vec.__index = Vec

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

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

Vec.__sub = function(a, b)
    return Vec.new(a.x - b.x, a.y - b.y)
end

Vec.__mul = function(v, scalar)
    return Vec.new(v.x * scalar, v.y * scalar)
end

Vec.__unm = function(v)                            -- unary minus
    return Vec.new(-v.x, -v.y)
end

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

Vec.__tostring = function(v)
    return "(" .. v.x .. ", " .. v.y .. ")"
end

local a = Vec.new(1, 2)
local b = Vec.new(3, 4)
print(a + b)                                       -- "(4, 6)"
print(b - a)                                       -- "(2, 2)"
print(a * 3)                                       -- "(3, 6)"
print(-a)                                          -- "(-1, -2)"
print(a == Vec.new(1, 2))                          -- true

The principal arithmetic metamethods:

MetamethodOperator
__add+
__sub- (binary)
__mul*
__div/
__idiv// (5.3+)
__mod%
__pow^
__unm- (unary)
__concat..
__len#
__band, __bor, __bxor, __shl, __shr, __bnot (5.3+)&, |, ~, <<, >>, ~

Comparison metamethods

local Point = setmetatable({}, {})
Point.__index = Point

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

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

Point.__lt = function(a, b)
    return (a.x ^ 2 + a.y ^ 2) < (b.x ^ 2 + b.y ^ 2)  -- by distance from origin
end

Point.__le = function(a, b)
    return (a.x ^ 2 + a.y ^ 2) <= (b.x ^ 2 + b.y ^ 2)
end

local a = Point.new(1, 1)
local b = Point.new(2, 2)
print(a < b)                                       -- true
print(a == Point.new(1, 1))                        -- true

The __eq is admitted only between values of the same type with the same metatable; __lt and __le admit substantial comparison.

__tostring

Called by tostring() and print():

local Animal = {}
Animal.__index = Animal

function Animal.new(name, kind)
    return setmetatable({name = name, kind = kind}, Animal)
end

Animal.__tostring = function(a)
    return a.kind .. " named " .. a.name
end

local cat = Animal.new("Whiskers", "Cat")
print(cat)                                         -- "Cat named Whiskers" (uses __tostring)
print(tostring(cat))                               -- same

__call

Make a table callable like a function:

local Counter = setmetatable({}, {
    __call = function(self, x)
        self.count = (self.count or 0) + 1
        return self.count
    end
})

print(Counter())                                   -- 1
print(Counter())                                   -- 2
print(Counter())                                   -- 3

The mechanism admits substantial functor-style patterns.

For callable instances:

local function make_adder(n)
    return setmetatable({n = n}, {
        __call = function(self, x)
            return self.n + x
        end
    })
end

local add5 = make_adder(5)
print(add5(3))                                     -- 8
print(add5(10))                                    -- 15

__len

Custom length operator:

local t = setmetatable({}, {
    __len = function() return 42 end
})

print(#t)                                          -- 42

__metatable

Protect or lock the metatable:

local t = setmetatable({}, {__metatable = "locked"})
print(getmetatable(t))                             -- "locked"

setmetatable(t, {})                                -- error: cannot change protected metatable

The __metatable field admits substantial protection — getmetatable returns the field value rather than the metatable; setmetatable raises.

Common patterns

Default values

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

local config = with_defaults({
    host = "localhost",
    port = 8080,
    timeout = 30
})

config.host = "example.com"
print(config.host)                                 -- "example.com"
print(config.port)                                 -- 8080 (default)
print(config.timeout)                              -- 30 (default)

Read-only proxy

local function readonly(t)
    return setmetatable({}, {
        __index = t,
        __newindex = function(_, k)
            error("readonly: cannot set " .. k)
        end,
        __metatable = false                        -- hide the metatable
    })
end

local CONFIG = readonly({version = "1.0", debug = false})
print(CONFIG.version)                              -- "1.0"
CONFIG.debug = true                                -- ERROR

Lazy table

local function make_lazy(compute)
    return setmetatable({}, {
        __index = function(t, key)
            local value = compute(key)
            rawset(t, key, value)                  -- cache the value
            return value
        end
    })
end

local cache = make_lazy(function(key)
    print("computing " .. key)
    return key:upper()
end)

print(cache.hello)                                 -- "computing hello"; "HELLO"
print(cache.hello)                                 -- "HELLO" (cached)
print(cache.world)                                 -- "computing world"; "WORLD"

Auto-vivification

local function autovivify()
    return setmetatable({}, {
        __index = function(t, key)
            local new = autovivify()
            rawset(t, key, new)
            return new
        end
    })
end

local nested = autovivify()
nested.a.b.c.d = "deep"                            -- creates intermediate tables automatically

Counter (callable)

local function make_counter()
    return setmetatable({count = 0}, {
        __call = function(self)
            self.count = self.count + 1
            return self.count
        end
    })
end

local c = make_counter()
print(c())                                         -- 1
print(c())                                         -- 2
print(c.count)                                     -- 2

Validation on assignment

local function validated(rules)
    return setmetatable({}, {
        __newindex = function(t, key, value)
            local validate = rules[key]
            if validate and not validate(value) then
                error("invalid value for " .. key)
            end
            rawset(t, key, value)
        end
    })
end

local user = validated({
    age = function(v) return type(v) == "number" and v >= 0 end,
    email = function(v) return type(v) == "string" and v:match("@") end
})

user.age = 30                                      -- OK
user.email = "alice@b.c"                           -- OK
user.age = -1                                      -- ERROR

Operator overloading for matrices

local Matrix = {}
Matrix.__index = Matrix

function Matrix.new(rows)
    return setmetatable({rows = rows}, Matrix)
end

function Matrix.__add(a, b)
    local result = {}
    for i = 1, #a.rows do
        result[i] = {}
        for j = 1, #a.rows[i] do
            result[i][j] = a.rows[i][j] + b.rows[i][j]
        end
    end
    return Matrix.new(result)
end

function Matrix.__tostring(m)
    local lines = {}
    for _, row in ipairs(m.rows) do
        lines[#lines + 1] = table.concat(row, " ")
    end
    return table.concat(lines, "\n")
end

local a = Matrix.new({{1, 2}, {3, 4}})
local b = Matrix.new({{5, 6}, {7, 8}})
print(a + b)
-- 6 8
-- 10 12

Class-like with __index

local Animal = {}
Animal.__index = Animal

function Animal.new(name)
    return setmetatable({name = name}, Animal)
end

function Animal:greet()
    return "Hello, I am " .. self.name
end

local a = Animal.new("Rex")
print(a:greet())                                   -- "Hello, I am Rex"

Inheritance via __index chain

local Animal = {}
Animal.__index = Animal

function Animal.new(name)
    return setmetatable({name = name}, Animal)
end

function Animal:speak()
    return "..."
end

-- Dog inherits from Animal:
local Dog = setmetatable({}, {__index = Animal})
Dog.__index = Dog

function Dog.new(name, breed)
    local self = Animal.new(name)                  -- call parent constructor
    self.breed = breed
    return setmetatable(self, Dog)
end

function Dog:speak()                               -- override
    return "Woof!"
end

local d = Dog.new("Rex", "Labrador")
print(d.name)                                      -- "Rex" (from Animal)
print(d:speak())                                   -- "Woof!"
print(d.breed)                                     -- "Labrador"

Treated in OOP idioms.

Memoization

local function memoize(f)
    return setmetatable({}, {
        __index = function(t, k)
            local v = f(k)
            rawset(t, k, v)
            return v
        end,
        __call = function(t, k)
            return t[k]                            -- triggers __index
        end
    })
end

local fib = memoize(function(n)
    if n < 2 then return n end
    return fib(n - 1) + fib(n - 2)
end)

Method missing (dynamic dispatch)

local Proxy = {}
Proxy.__index = function(t, key)
    return function(self, ...)
        print("calling " .. key .. " with", ...)
        return t.target[key](t.target, ...)
    end
end

local function wrap(target)
    return setmetatable({target = target}, Proxy)
end

local p = wrap({greet = function(self, name) return "Hello, " .. name end})
print(p:greet("Alice"))
-- "calling greet with Alice"
-- "Hello, Alice"

Self-modifying via __index

local cache = setmetatable({}, {
    __index = function(t, k)
        print("cache miss: " .. k)
        local v = expensive_compute(k)
        rawset(t, k, v)                            -- self-cache
        return v
    end
})

cache.hello                                        -- compute on first
cache.hello                                        -- cached

A note on metatable inheritance

A table’s metatable is not inherited by sub-tables — only the fields of the metatable that the runtime consults:

local mt1 = {__add = function(a, b) return "sum" end}
local mt2 = {}                                     -- empty

setmetatable(mt2, {__index = mt1})

local t = setmetatable({}, mt2)
print(t + t)                                       -- ERROR: __add not on mt2 directly

-- Metamethods are looked up directly on the metatable, not through __index of the metatable

The mechanism distinguishes metatables (consulted by the runtime for specific operations) from regular tables (consulted via __index chain).

A note on the conventional discipline

The contemporary Lua metatables advice:

  • Use __index = table for OOP and defaults.
  • Use __index = function for substantial computed access.
  • Use __newindex for read-only or validating tables.
  • Use rawget/rawset in metamethods to avoid recursion.
  • Use operator metamethods sparingly — for substantial mathematical types.
  • Use __tostring for substantial debugging output.
  • Use __call for callable objects.
  • Use __metatable to protect from external manipulation.
  • Cache metatables as locals — substantial performance.
  • Document metamethod behaviour — admit substantial non-obvious effects.

The combination — setmetatable for association, the substantial metamethod surface, the __index for OOP and defaults, the operator overloading, the rawget/rawset for direct access — is the substance of Lua’s metaprogramming. The discipline admits substantial customisation through a small core mechanism — the conventional Lua approach to metaprogramming.