Skip to content

Metatables

Lesson 7.1

A metatable changes how a table behaves: what happens when you read a missing key, add two tables, call a table like a function, or print it.

The idea

Every table can have a metatable: a second, ordinary table that holds special fields called metamethods. Their names start with two underscores, like __index or __add. When Lua meets an operation it can’t do on its own, such as adding two tables, it looks in the metatable for the matching metamethod and calls it.

setmetatable(t, mt) attaches a metatable and returns t. getmetatable(t) gives it back.

local t = {}
local mt = {}
setmetatable(t, mt)
print(getmetatable(t) == mt)  --> true

__index: fallback for missing keys

When you read a key that a table doesn’t have, Lua checks the metatable’s __index. If __index is a table, Lua looks the key up there:

local defaults = { health = 20, speed = 1.0 }
local zombie = setmetatable({ speed = 0.7 }, { __index = defaults })

print(zombie.speed)   --> 0.7
print(zombie.health)  --> 20

zombie has its own speed, but no health, so Lua falls back to defaults.health. The fallback table is not copied; it is shared. This is the base of classes and inheritance in the next lesson.

__index can also be a function. Lua calls it with the table and the missing key:

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

print(names.stone)  --> minecraft:stone
print(names.dirt)   --> minecraft:dirt

__index is only used for keys that are missing. If the table has the key, Lua never asks the metatable.

__newindex: watching new keys

__newindex runs when you assign to a key that doesn’t exist yet. You can use it to reject or redirect writes:

local function read_only(t)
  return setmetatable({}, {
    __index = t,
    __newindex = function(_, key)
      error("can't change " .. tostring(key) .. ": the table is read-only", 2)
    end,
  })
end

local config = read_only({ max_players = 20 })
print(config.max_players)                      --> 20
print(pcall(function() config.max_players = 50 end))

The last line prints false and the error message. pcall catches errors; the lesson Error handling explains it. The proxy table stays empty, so every write goes through __newindex.

rawget and rawset

Sometimes you want to skip the metatable. rawget(t, k) reads a key without __index, and rawset(t, k, v) writes without __newindex:

local zombie = setmetatable({}, { __index = { health = 20 } })
print(zombie.health)              --> 20
print(rawget(zombie, "health"))   --> nil

Inside a __newindex function, use rawset to really store the value. A plain t[k] = v there would call __newindex again, forever.

__tostring: how it prints

print and tostring use __tostring if it exists:

local pos_mt = {
  __tostring = function(p)
    return string.format("(%d, %d, %d)", p.x, p.y, p.z)
  end,
}

local spawn = setmetatable({ x = 0, y = 64, z = 0 }, pos_mt)
print(spawn)  --> (0, 64, 0)

Without it, a table prints as something like table: 0x55d0c3a8.

__call: tables you can call

__call lets you call a table like a function. The table itself comes first, then the arguments:

local counter = setmetatable({ n = 0 }, {
  __call = function(self, step)
    self.n = self.n + (step or 1)
    return self.n
  end,
})

print(counter())   --> 1
print(counter(5))  --> 6

Operators

Metamethods also define what the operators do on tables:

Metamethod Operator
__add, __sub, __mul, __div +, -, *, /
__mod, __pow, __idiv, __unm %, ^, //, unary -
__concat ..
__len #
__eq == (and ~=)
__lt, __le <, <= (and >, >=)
__band, __bor, __bxor, __shl, __shr, __bnot bitwise operators

A small vector type:

local Vec = {}
Vec.__index = Vec

local function vec(x, y, z)
  return setmetatable({ x = x, y = y, z = z }, Vec)
end

Vec.__add = function(a, b) return vec(a.x + b.x, a.y + b.y, a.z + b.z) end
Vec.__mul = function(v, n) return vec(v.x * n, v.y * n, v.z * n) end
Vec.__eq = function(a, b) return a.x == b.x and a.y == b.y and a.z == b.z end
Vec.__tostring = function(v) return "(" .. v.x .. ", " .. v.y .. ", " .. v.z .. ")" end

local pos = vec(10, 64, 5)
local step = vec(1, 0, 0)

print(pos + step)               --> (11, 64, 5)
print(step * 3)                 --> (3, 0, 0)
print(pos + step == vec(11, 64, 5))  --> true

Some details:

  • For a + b, Lua checks a’s metatable first, then b’s. So __mul above handles step * 3, but 3 * step would call __mul(3, step), with the number first. A full vector type checks the argument types.
  • __eq is only used when both sides are tables (or both userdata). Comparing a table to a number is simply false.
  • a > b is turned into b < a, and a >= b into b <= a. Define both __lt and __le if you want all four comparisons.

Celestial uses this exact idea: positions in the API are tables with a metatable, so you can write pos + offset.

Other metamethods

  • __len changes what #t returns.
  • __concat handles t .. "text".
  • __close runs when a <close> variable goes out of scope (see Error handling).
  • __gc runs when the garbage collector frees the table.
  • __mode = "k" or "v" makes a weak table, whose keys or values don’t keep their objects alive. It is useful for caches.
  • __metatable hides the metatable: getmetatable returns this field instead, and setmetatable refuses to change it.
  • __name is used by some error messages and by tostring for userdata.

Try it

Make a “safe” inventory: a table where reading an item that isn’t there gives 0 instead of nil, so inv.diamond + 1 always works. Then give it a __tostring that lists the items as name=count, sorted by name.

Show a solution
local inv_mt = {
  __index = function() return 0 end,
  __tostring = function(inv)
    local names = {}
    for name in pairs(inv) do names[#names + 1] = name end
    table.sort(names)
    local parts = {}
    for _, name in ipairs(names) do
      parts[#parts + 1] = name .. "=" .. inv[name]
    end
    return table.concat(parts, ", ")
  end,
}

local inv = setmetatable({}, inv_mt)
inv.diamond = inv.diamond + 1
inv.coal = inv.coal + 16
print(inv.emerald)  --> 0
print(inv)          --> coal=16, diamond=1

pairs only sees keys the table really has, so the __index default doesn’t add entries.

Recap: a metatable’s metamethods decide what happens on missing keys (__index), new keys (__newindex), printing, calls and operators.