Tables, metatables and object orientation

How __index and __newindex turn plain tables into classes, proxies and read-only views.

References and copying

Tables are compared and assigned by reference. Two names can point at one table, and passing a table to a function passes the reference, so mutations are visible to the caller.

local a = { 1, 2 }
local b = a                 -- the same table, not a copy
b[3] = 3
print(#a)                   -- 3

local shallow = {}
for k, v in pairs(a) do shallow[k] = v end   -- copy the top level only
shallow[1] = 99
print(a[1])                 -- 1  (numbers are copied by value)

-- table.clone is standard in 5.4; a hand-written deep copy needs recursion
local function deepcopy(t, seen)
  if type(t) ~= "table" then return t end
  seen = seen or {}
  if seen[t] then return seen[t] end
  local out = {}
  seen[t] = out
  for k, v in pairs(t) do out[deepcopy(k, seen)] = deepcopy(v, seen) end
  return out
end
  • Cyclic tables need the seen map above, or a self-referential table loops forever.
  • The length operator #t is only reliable for a sequence with no holes.
  • Deleting a key is t[k] = nil; there is no separate delete statement.

Metatables: __index and __newindex

A metatable is an ordinary table attached to another table. When a lookup misses, Lua consults __index; when an assignment targets a key that is not already present, Lua consults __newindex. Everything object-oriented in Lua is built from these two hooks.

local Stack = {}
Stack.__index = Stack               -- instance lookups fall back to the class

function Stack.new()
  return setmetatable({ items = {} }, Stack)
end

function Stack:push(v)              -- ':' passes self implicitly
  self.items[#self.items + 1] = v
end

function Stack:pop()
  local v = self.items[#self.items]
  self.items[#self.items] = nil
  return v
end

function Stack:size() return #self.items end

local s = Stack.new()
s:push("a")
s:push("b")
print(s:pop(), s:size())            -- b  1

-- __index may also be a function: computed values and defaults
local defaults = setmetatable({}, {
  __index = function(_, key)
    return key == "timeout" and 30 or ("unknown: " .. key)
  end,
})
print(defaults.timeout, defaults.host)   -- 30  unknown: host

-- __newindex intercepts writes, which is how proxies are written
local log = {}
local proxy = setmetatable({}, {
  __index = log,
  __newindex = function(_, k, v) print("set", k); rawset(log, k, v) end,
})
proxy.x = 1                          -- prints "set x", stores into log
print(log.x)                         -- 1
print(rawget(proxy, "x"))            -- nil: the metamethod was bypassed
💡
Metamethods are a one-time cost at lookup, not a per-call one, but a function __index defeats the fast path. Prefer a table (the class) as __index and reserve functions for computed values and proxies.

Operator metamethods

MetamethodFires whenExtra rule
__indexReading a missing keyTable or function
__newindexAssigning a missing keyTable or function; rawset bypasses it
__callThe table is called like a functionReceives the table as the first argument
__tostringtostring/printMust return a string, otherwise the tostring behaviour is skipped
__lenThe # operatorLua 5.2+ only, and not used by table.insert
__eq== between two tablesOnly when both operands share the same metamethod
__lt, __le< and <=Used by table.sort with a default comparator
__add, __sub, __mul, __concatArithmetic and ..Either operand's metatable may supply it
__gc, __closeCollection or scope exitRequires setmetatable at creation time
local Vec = {}
Vec.__index = Vec
Vec.__add = function(a, b) return Vec.new(a.x + b.x, a.y + b.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
Vec.__len = function(v) return math.sqrt(v.x ^ 2 + v.y ^ 2) end

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

local p = Vec.new(1, 2) + Vec.new(3, 4)
print(p, #p)                 -- (4, 6)  7.211102550928
print(Vec.new(1, 1) == Vec.new(1, 1))   -- true

-- tostring only works because __tostring returned a string; assert that
local ok, text = pcall(tostring, setmetatable({}, { __tostring = function() return 0 end }))
print(ok, text ~= nil)       -- false  true  (raises instead of looping)

__index chains give inheritance: point one class's metatable at another class. Keep the chain shallow — every level that misses adds another table lookup, and deep chains make errors hard to trace.

FAQ

How do I make a read-only table?
Wrap it: setmetatable({}, { __index = data, __newindex = function() error("read-only", 2) end }). Writes fail while reads pass through. It is a guard against mistakes, not against hostile code, since rawset on the original table still works.
Is there a class or new keyword?
No. A class is a table with an __index back-reference, and setmetatable is the constructor's last step. Libraries such as middleclass add inheritance sugar, but the mechanism is always metatables.

Lua syntax and types Embedding Lua in a host program

Last refreshed 2026-09-18.