Lesson 5 • Intermediate
Tables, Metatables & OOP
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.
lua command — reading and running is how this clicks.What You'll Learn
- Use a table as an array and a dictionary (1-based indexing, #, insert/remove)
- Attach a metatable with setmetatable and getmetatable
- Provide defaults and inheritance with the __index metamethod
- Intercept writes with __newindex
- Overload operators with __add, __eq, and __tostring
- Build classes with :method() colon syntax, self, and inheritance
if/for. Everything about tables and metatables we build from scratch here.💡 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: 42. 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
| Metamethod | Triggered when |
|---|---|
| __index | You read a missing key |
| __newindex | You set a new (missing) key |
| __add / __sub / __mul | The + / - / * operators |
| __eq / __lt / __le | The == / < / <= operators |
| __tostring | tostring() 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: falseNow 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 bananaUse 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: 2Common Errors (and the fix)
- Off-by-one from 0-based habits:
fruits[0]isnilin Lua — the first element isfruits[1]and the last isfruits[#fruits]. Loop withfor i = 1, #t do. - "attempt to index a nil value (local 'self')": you called a colon-method with a dot, e.g.
dog.speak(). Usedog:speak()soselfis passed automatically (it equalsdog.speak(dog)). - "attempt to call a nil value (method 'speak')": you forgot
Class.__index = Class, so instances can't find their methods. Add that line right after you create the class table. - Mixing array and hash parts:
#only counts the consecutive integer keys. In{ "a", color = "red" },#tis1— thecolorkey is invisible to it. Don't rely on#for tables used as dictionaries. - A
nilin the middle of an array: settingt[2] = nilleaves a "hole", and#tmay then return1or3unpredictably. Usetable.removeto delete so the rest shift down.
📋 Quick Reference
| Task | Code | Result |
|---|---|---|
| First / last item | t[1] / t[#t] | 1-based |
| Add / remove | table.insert(t, x) / table.remove(t) | end of list |
| Length | #t | item count |
| Attach metatable | setmetatable(t, mt) | returns t |
| Defaults / inheritance | mt.__index = fallback | on missing key |
| Overload + | mt.__add = function(a,b) end | a + b |
| Define method | function T:method() end | self passed in |
| Call method | obj:method() | = obj.method(obj) |
Frequently Asked Questions
Q: 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].
Q: 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.
Q: 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.
Q: 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.
Q: 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.
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!
- ✅ A table is Lua's only data structure — array and dictionary, indexed from 1
- ✅
table.insert,table.remove, and#manage and measure lists - ✅
setmetatableattaches behaviour;__indexsupplies defaults and inheritance - ✅
__newindexintercepts writes;__add/__eq/__tostringoverload operators - ✅ Classes are tables with
Class.__index = Class;:method()passesself - ✅ Next lesson: Lua for Game Development — put tables, metatables, and OOP to work in LÖVE2D and Roblox
Sign up for free to track which lessons you've completed and get learning reminders.