Part of Learn Lua with NeoVim

Lua with Neovim: Metatables — __tostring, __add, __eq, __index & Custom Table Behavior | Episode 29

Sandy LaneSandy Lane

Video: Lua with Neovim: Metatables — __tostring, __add, __eq, __index & Custom Table Behavior | Episode 29 by Taught by Celeste AI - AI Coding Coach

Take the quiz on the full lesson page
Test what you've read · interactive walkthrough

Lua Metatables: __tostring, __add, __eq, __index

A metatable attached to a table changes how operators and lookups work on it. __tostring for custom string conversion. __add for +. __eq for ==. __index for fallback lookups. The mechanism behind operator overloading and OOP.

Metatables are Lua's most powerful feature. They let you customise how the language operates on your tables — operator overloading, inheritance, default values, all built on the same simple mechanism.

Custom __tostring

local vec1 = { x = 3, y = 4 }

setmetatable(vec1, {
  __tostring = function(v)
    return "(" .. v.x .. ", " .. v.y .. ")"
  end,
})

print("vec1: " .. tostring(vec1))   -- "vec1: (3, 4)"

setmetatable(t, mt) attaches mt as t's metatable. When you call tostring(vec1), Lua sees the __tostring metamethod and calls it, passing vec1.

Without the metatable, tostring(vec1) would return "table: 0x...". With it, the user-defined function runs.

This is the simplest metamethod and the most common — give your custom types a sensible string form.

A vector with __add

local Vector = {}
Vector.__index = Vector

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

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

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

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

local a = Vector.new(1, 2)
local b = Vector.new(3, 4)
local c = a + b

print("a + b = " .. tostring(c))   -- "(4, 6)"
print("a == b: " .. tostring(a == b))   -- "false"

local d = Vector.new(1, 2)
print("a == d: " .. tostring(a == d))   -- "true"

Three metamethods on Vector:

  • __tostring — what tostring(v) returns.
  • __add — what a + b does (when at least one side is a Vector).
  • __eq — what == does. Both sides must be Vectors with the same metatable.

Vector.new creates an instance and sets Vector itself as its metatable. So every Vector instance picks up all the metamethods.

The full list of metamethods

Arithmetic: - __add (+), __sub (-), __mul (*), __div (/) - __mod (%), __pow (^), __unm (unary -) - __idiv (//)

Comparison: - __eq (==), __lt (<), __le (<=)

Other: - __concat (..) - __len (#) - __index — read fallback - __newindex — write fallback - __call — make table callable like a function: t(...) - __tostring

Bitwise (Lua 5.3+): - __band, __bor, __bxor, __bnot, __shl, __shr

Most code uses just __index, __newindex, __tostring, and a handful of arithmetic/comparison metamethods.

__index as fallback

local defaults = {
  color = "blue",
  size = 10,
  visible = true,
}

local button = { label = "Click me" }
setmetatable(button, { __index = defaults })

print("label: " .. button.label)      -- "Click me" (own field)
print("color: " .. button.color)      -- "blue" (from defaults!)
print("size: " .. button.size)        -- 10 (from defaults!)

When you read button.color, Lua:

  1. Looks for color in button itself. Not found.
  2. Falls back to __index from the metatable.
  3. If __index is a table, looks up color there.

So missing keys silently inherit from defaults. This is the foundation of OOP — methods live in the "class" table, instances inherit by __index.

__index doesn't trigger on writes

button.color = "red"
print("color after set: " .. button.color)   -- "red"
print("defaults.color: " .. defaults.color)  -- "blue" (unchanged)

Reads fall through to __index; writes go directly to the instance. So setting button.color = "red" adds color to button, leaving defaults untouched.

That's the expected "class-and-instance" behaviour: instance methods can override, and per-instance state lives on the instance.

__index can also be a function

setmetatable(t, {
  __index = function(table, key)
    return "default value for " .. key
  end,
})

print(t.anything)   -- "default value for anything"

A function __index runs when a missing key is accessed. Useful for lazy values, computed properties, or proxy objects.

Skipping the metatable: rawget and rawset

local t = setmetatable({}, { __index = function() return 0 end })
print(t.foo)             -- 0 (via __index)
print(rawget(t, "foo"))  -- nil (skips __index)

rawget(t, k) reads k from t without triggering __index. Useful for "is this key actually there" checks.

Same for rawset(t, k, v), rawequal, rawlen.

getmetatable and __metatable

local mt = getmetatable(t)
print(mt)

getmetatable(t) returns the metatable, or nil if there isn't one. If you set __metatable to a value, getmetatable returns that value (used for hiding the real metatable from outside code).

Method-call sugar with :

function Vector:length()
  return math.sqrt(self.x^2 + self.y^2)
end

local v = Vector.new(3, 4)
print(v:length())   -- 5.0

function Vector:length() is sugar for function Vector.length(self)self is implicit. v:length() is sugar for Vector.length(v).

This colon syntax + __index is what makes Lua look like an OOP language. We dive deeper next episode.

Common stumbles

Forgetting setmetatable. A table with no metatable doesn't trigger any metamethods, no matter what's in the candidate mt table.

Confusing the metatable with the table. setmetatable(t, mt)t is your data, mt holds the metamethods. Don't put your data fields in mt.

__index triggered for every nested access. A deeply-nested fallback chain is slow. Most OOP designs have at most one or two levels.

Modifying the metatable while in use. Adding/removing metamethods at runtime works but is hard to reason about. Set them up once.

Equality requires the same metatable on both sides. Two tables with __eq defined on different metatables compare with default reference equality. Use the same metatable (i.e., the same class).

What's next

Episode 30: OOP with metatables. Putting it all together: classes, instances, inheritance via chained __index, the :new() constructor pattern, and the colon-syntax convention.

Recap

setmetatable(t, mt) attaches a metatable. Metamethods (__name) customise operators and lookups: __tostring, __add/__sub/etc., __eq/__lt, __index for read-fallback, __newindex for write-fallback. __index as a table gives inheritance; as a function gives computed/lazy values. Writes don't trigger __index — they go straight to the table. rawget/rawset skip metamethods.

Next episode: OOP with metatables.

Ready? Take the quiz on the full lesson page →
Test what you've learned. Watch the lesson and try the interactive quiz on the same page.