Metatables and Metamethods

Reviewed & published by Brayan K

By the end of this lesson you'll use tables as both arrays and dictionaries, customise how they behave with metatables and metamethods, and build your own classes with inheritance — the foundation of every serious Lua program.

Part of the free Lua course at LearnCodingFast — hands-on lessons with examples you run in your browser, plus practice exercises and a quick quiz.

What You'll Learn

💡 Real-World Analogy

Think of a table as a sheet of paper holding your data, and a metatable as a sticky note of instructions clipped to it. Normally Lua just reads the paper. But when something unusual happens — you ask for a row that isn't written down, or you try to add two sheets together — Lua reads the sticky note to find out what to do. The sticky note doesn't store data; it stores behaviour. That single idea — "look at the manual when you hit something unexpected" — is how Lua delivers default values, operator overloading, and full object-oriented programming, all from one humble data type.

1. Tables: Lua's One Data Structure

Lua has exactly one built-in data structure: the table. Remarkably, the same table is both an array (a numbered list) and a dictionary (named key → value pairs). The one rule that trips up newcomers: Lua arrays start at index 1, not 0. You add and remove with table.insert and table.remove, and you measure length with the # operator. Read this worked example and run it before moving on.

-- A table is Lua's ONLY data structure. It is both an
-- array (numbered list) AND a dictionary (key -> value) at once.

-- ARRAY part: a list. Lua arrays start at index 1, NOT 0.
local fruits = { "apple", "banana", "cherry" }
print(fruits[1])        -- prints: apple   (first item is [1])
print(fruits[3])        -- prints: cherry
print(#fruits)          -- prints: 3       (# is the length operator)

-- table.insert adds to the end; table.remove takes from the end.
table.insert(fruits, "date")
print(#fruits)          -- prints: 4
table.remove(fruits, 1) -- remove index 1 ("apple"); rest shift down
print(fruits[1])        -- prints: banana

-- DICTIONARY part: string keys instead of numbers.
local player = { name = "Hero", level = 5, alive = true }
print(player.name)      -- prints: Hero    (dot syntax)
print(player["level"])  -- prints: 5       (bracket syntax — same thing)

-- One table can hold BOTH parts at the same time.
local mixed = { "first", "second", color = "red" }
print(mixed[1])         -- prints: first   (array part)
print(mixed.color)      -- prints: red     (hash part)

Your turn. The snippet below is almost complete — fill in the three ___ blanks using the hints, then run it and check it against the ✅ Expected output comments.

-- 🎯 YOUR TURN — replace each ___, then run this on onecompiler.com.

local scores = { 10, 20, 30 }

-- 1) Print the FIRST score (remember: Lua arrays start at 1)
print(scores[___])      -- 👉 the index of the first item
-- ✅ Expected output: 10

-- 2) Add 40 to the end of the list
table.insert(scores, ___)   -- 👉 the number to add

-- 3) Print how many scores there are now
print(___scores)        -- 👉 the length operator goes before the table
-- ✅ Expected output: 4

2. Metatables & Metamethods

A metatable is an ordinary table whose special keys — called metamethods — tell Lua what to do in situations it doesn't otherwise handle. You attach one with setmetatable(t, mt) and read it back with getmetatable(t). The most important metamethod is __index: when you read a key the table doesn't have, Lua consults __index instead of giving you nil — perfect for defaults and inheritance. Others let you intercept writes (__newindex) or redefine operators like + (__add), == (__eq), and printing (__tostring).

Key Metamethods

MetamethodTriggered when
__indexYou read a missing key
__newindexYou set a new (missing) key
__add / __sub / __mulThe + / - / * operators
__eq / __lt / __leThe == / < / <= operators
__tostringtostring() or print()
-- A metatable is a table that customises how ANOTHER table behaves.
-- setmetatable(t, mt) attaches the metatable mt to the table t.

-- __index supplies DEFAULTS: when a key is missing on the table,
-- Lua looks it up in __index instead of returning nil.
local defaults = { color = "red", speed = 10, health = 100 }
local player = setmetatable({ name = "Hero", speed = 25 }, { __index = defaults })
print(player.name)    -- prints: Hero   (own field)
print(player.speed)   -- prints: 25     (own field wins over the default)
print(player.color)   -- prints: red    (missing -> falls back via __index)
print(player.health)  -- prints: 100    (from defaults)

-- __newindex intercepts SETTING a new key. Here we block writes
-- to a "read-only" config by raising an error.
local config = setmetatable({}, {
  __newindex = function(t, key, value)
    error("config is read-only: cannot set " .. key)
  end
})
-- config.volume = 11  -- would raise: config is read-only: cannot set volume

-- __add overloads +, __eq overloads ==, __tostring customises printing.
local Vector = {}
Vector.__index = Vector
Vector.__add = function(a, b)
  return setmetatable({ x = a.x + b.x, y = a.y + b.y }, Vector)
end
Vector.__eq = function(a, b)
  return a.x == b.x and a.y == b.y
end
Vector.__tostring = function(v)
  return "(" .. v.x .. ", " .. v.y .. ")"
end

local v1 = setmetatable({ x = 3, y = 4 }, Vector)
local v2 = setmetatable({ x = 1, y = 2 }, Vector)
print(tostring(v1 + v2))      -- prints: (4, 6)   (uses __add then __tostring)
print(v1 == v1)               -- prints: true     (uses __eq)
print(v1 == v2)               -- prints: false

Now you try overloading operators. Finish the Money type below so that + adds two amounts and print shows a £ sign. Fill in the two blanks:

-- 🎯 YOUR TURN — finish the Money type so + adds amounts.

local Money = {}
Money.__index = Money

-- 1) __add should return a new Money whose amount is the sum
Money.__add = function(a, b)
  return setmetatable({ amount = a.amount ___ b.amount }, Money)
  -- 👉 the arithmetic operator that adds two numbers
end

-- 2) __tostring should print the amount with a £ sign in front
Money.__tostring = function(m)
  return "£" .. ___           -- 👉 the field that holds the number
end

local wallet  = setmetatable({ amount = 30 }, Money)
local payment = setmetatable({ amount = 12 }, Money)
print(tostring(wallet + payment))   -- ✅ Expected output: £42

Deep Dive: __index as a table vs a function

__index can be a table (the usual case — Lua looks the missing key up inside it) or a function that Lua calls as __index(table, key) to compute a value on demand.

-- function form: compute or log on a missing key
local t = setmetatable({}, {
  __index = function(tbl, key)
    return "no value for " .. key   -- instead of nil
  end
})
print(t.banana)   -- prints: no value for banana

Use the table form for fixed defaults and class methods; reach for the function form when the fallback must be computed each time.

3. Building OOP with Metatables

Lua has no class keyword, yet it has full object-oriented programming — you assemble it from tables and metatables. The recipe: make a table for the class, set Class.__index = Class so instances inherit its methods, and write a constructor (by convention called new) that returns a table whose metatable is the class. Define methods with the colon (function Animal:speak()), which quietly adds a first parameter called self — the instance the method was called on. Inheritance is just one more metatable: point a subclass's __index at its parent.

-- Lua has no 'class' keyword. You build classes from
-- tables + metatables. Setting Class.__index = Class makes
-- instances look up missing keys (their methods) on the class.

local Animal = {}
Animal.__index = Animal          -- methods live on Animal; instances inherit them

-- A constructor is just a function that returns a table whose
-- metatable is the class. 'new' is a convention, not a keyword.
function Animal.new(name, sound)
  return setmetatable({ name = name, sound = sound }, Animal)
end

-- 'function Animal:speak()' is sugar for 'function Animal.speak(self)'.
-- The colon ':' silently adds a first parameter called 'self'.
function Animal:speak()
  return self.name .. " says " .. self.sound .. "!"
end

local cat = Animal.new("Cat", "Meow")
print(cat:speak())     -- prints: Cat says Meow!   (cat:speak() passes cat as self)

-- INHERITANCE: make Dog fall back to Animal via __index.
local Dog = setmetatable({}, { __index = Animal })  -- Dog inherits Animal's methods
Dog.__index = Dog                                   -- Dog instances look up on Dog

function Dog.new(name)
  local obj = Animal.new(name, "Woof")  -- reuse the parent constructor
  obj.tricks = {}
  return setmetatable(obj, Dog)
end

function Dog:learnTrick(trick)
  table.insert(self.tricks, trick)      -- 'self' is the Dog instance
end

local rex = Dog.new("Rex")
print(rex:speak())     -- prints: Rex says Woof!   (inherited from Animal)
rex:learnTrick("sit")
rex:learnTrick("roll")
print(#rex.tricks)     -- prints: 2

Common Errors (and the fix)

📋 Quick Reference

TaskCodeResult
First / last itemt[1] / t[#t]1-based
Add / removetable.insert(t, x) / table.remove(t)end of list
Length#titem count
Attach metatablesetmetatable(t, mt)returns t
Defaults / inheritancemt.__index = fallbackon missing key
Overload +mt.__add = function(a,b) enda + b
Define methodfunction T:method() endself passed in
Call methodobj:method()= obj.method(obj)

Mini-Challenge: a Counter class

No blanks this time — just a brief and an outline. Build the class yourself, run it, and check your output against the comment. This is the exact shape of objects you'll write in real Lua programs and games.

-- 🎯 MINI-CHALLENGE: a Counter class
-- 1. Make a table Counter and set Counter.__index = Counter
-- 2. Counter.new() returns a new object with { count = 0 }
--    whose metatable is Counter
-- 3. Add a method Counter:increment() that does self.count = self.count + 1
-- 4. Add a method Counter:value() that returns self.count
--
-- Then test it:
--   local c = Counter.new()
--   c:increment()
--   c:increment()
--   print(c:value())     -- ✅ Expected output: 2

-- your code here

🎉 Lesson Complete!

Practice quiz

At what index does a Lua array start?

  • 0
  • -1
  • Any number
  • 1

Answer: 1. Lua tables are 1-based; the first element is t[1].

What is Lua's only built-in data structure?

  • The table
  • The array
  • The list
  • The struct

Answer: The table. The table serves as both array and dictionary in Lua.

Which function attaches a metatable to a table?

  • addmeta(t, mt)
  • setmetatable(t, mt)
  • meta(t, mt)
  • withmeta(t, mt)

Answer: setmetatable(t, mt). setmetatable(t, mt) attaches mt to t and returns t.

Which metamethod supplies a value for a missing key?

  • __newindex
  • __get
  • __index
  • __missing

Answer: __index. __index is consulted when a read finds no key on the table.

Which metamethod overloads the + operator?

  • __plus
  • __sum
  • __concat
  • __add

Answer: __add. __add defines what a + b does for tables.

What does dog:speak() pass as a hidden first argument?

  • self (the dog)
  • nil
  • the class table
  • the method name

Answer: self (the dog). The colon passes the receiver as self automatically.

Which line lets instances find their methods on the class?

  • Class.methods = true
  • Class.__index = Class
  • setmetatable(Class)
  • Class.self = Class

Answer: Class.__index = Class. Setting Class.__index = Class makes instances look up methods on the class.

Which metamethod intercepts SETTING a new key?

  • __set
  • __index
  • __newindex
  • __write

Answer: __newindex. __newindex runs when you assign to a key the table doesn't already have.

What does the # operator measure on { "a", color = "red" } ?

  • 1 (only the array part)
  • 2
  • 0
  • The number of keys

Answer: 1 (only the array part). # counts only the consecutive integer (array) part, ignoring string keys.

Which metamethod customises how a table is printed?

  • __print
  • __show
  • __display
  • __tostring

Answer: __tostring. __tostring controls the output of tostring() and print().

Continue this course

Frequently asked questions

Why do Lua arrays start at 1 instead of 0?

It is a deliberate design choice from Lua's authors to read more like everyday counting (the 1st item is at index 1). It catches out programmers coming from C, Java, or Python. The length operator # and table.insert/table.remove all assume 1-based indexing, so embrace it: the first element is t[1] and the last is t[#t].

What is the difference between dog.speak() and dog:speak()?

The colon is shorthand that passes the table itself as a hidden first argument called self. dog:speak() is exactly the same as dog.speak(dog). Define methods with a colon (function Dog:speak()) and call them with a colon (dog:speak()) so self is filled in automatically. Mixing them — calling dog.speak() on a colon-defined method — leaves self as nil and crashes.

What does Class.__index = Class actually do?

It makes the class its own fallback table. When you read a key that an instance does not have (like a method name), Lua follows __index to the class and finds the method there. Without that line, instances cannot see their own methods and you get attempt to call a nil value.

When should __index be a function instead of a table?

Use a table for fixed defaults or class methods (the common case). Use a function — __index = function(t, key) ... end — when the fallback value should be computed on the fly, for example logging every missing-key access, or generating a value lazily the first time it is requested.

Can one table be both an array and a dictionary at the same time?

Yes. A single table has an array part (consecutive integer keys 1, 2, 3...) and a hash part (everything else) living together. { "a", "b", color = "red" } stores "a" at [1], "b" at [2], and "red" at key "color". Just be careful: the # length operator only measures the array part and ignores string keys.