Skip to content

Working with tables

Lesson 6.3

Tables are shared by reference, not copied. This lesson covers what that means, how to copy, and some patterns you will use again and again.

Tables are references

A variable doesn’t hold a table itself. It holds a reference to it, like an address. Assigning a table to another variable gives a second name for the same table:

local a = { count = 1 }
local b = a
b.count = 99
print(a.count)  --> 99

The same happens when you pass a table to a function. The function can change the caller’s table:

local function add_xp(player, amount)
  player.xp = player.xp + amount
end

local alex = { xp = 10 }
add_xp(alex, 5)
print(alex.xp)  --> 15

This is useful: functions can update data in place, and passing a big table costs nothing. Numbers, strings and booleans don’t work this way. They behave like values, so changing a copy never affects the original.

Comparing tables

== on two tables checks whether they are the same table, not whether they have the same contents:

local p1 = { x = 1, y = 2 }
local p2 = { x = 1, y = 2 }
print(p1 == p2)  --> false
print(p1 == p1)  --> true

To compare contents, compare the fields yourself, or give the tables an __eq metamethod (see Metatables).

Copying a table

To get an independent copy, make a new table and copy each entry. This is a shallow copy:

local function shallow_copy(t)
  local copy = {}
  for k, v in pairs(t) do
    copy[k] = v
  end
  return copy
end

local original = { name = "Kit", items = { "Bread" } }
local copy = shallow_copy(original)
copy.name = "Starter kit"
print(original.name)  --> Kit

A shallow copy still shares the tables inside it. copy.items and original.items are the same list. For a fully separate copy, copy nested tables too. That is a deep copy:

local function deep_copy(t)
  if type(t) ~= "table" then
    return t
  end
  local copy = {}
  for k, v in pairs(t) do
    copy[k] = deep_copy(v)
  end
  return copy
end

local original = { name = "Kit", items = { "Bread" } }
local copy = deep_copy(original)
table.insert(copy.items, "Sword")
print(#original.items, #copy.items)  --> 1	2

This simple version doesn’t handle a table that contains itself, and it doesn’t copy metatables. It is fine for plain data.

More of the table library

table.unpack(list, i, j) returns items as separate values. With i and j it returns a slice:

local pos = { 100, 64, -30 }
local x, y, z = table.unpack(pos)
print(y)                          --> 64
print(table.unpack(pos, 2, 3))    --> 64	-30

table.move(a1, f, e, t, a2) copies items f to e of a1 into a2, starting at index t. It is a fast way to append one list to another:

local hotbar = { "Sword", "Bow" }
local backpack = { "Bread", "Arrow" }
table.move(backpack, 1, #backpack, #hotbar + 1, hotbar)
print(table.concat(hotbar, ", "))  --> Sword, Bow, Bread, Arrow

Sorting records

table.sort with a compare function can sort tables by any field:

local players = {
  { name = "Alex", kills = 4 },
  { name = "Noor", kills = 9 },
  { name = "Kai", kills = 4 },
}

table.sort(players, function(a, b)
  if a.kills ~= b.kills then
    return a.kills > b.kills   -- most kills first
  end
  return a.name < b.name       -- ties: alphabetical
end)

for i, p in ipairs(players) do
  print(i, p.name, p.kills)
end
1	Noor	9
2	Alex	4
3	Kai	4

The sort is not stable: items that compare equal can end up in any order. Add a tie-breaker, like the name above, when the order matters.

Common patterns

Count things by using the things as keys:

local drops = { "Bone", "Arrow", "Bone", "Bone", "Arrow", "Bow" }
local counts = {}
for _, item in ipairs(drops) do
  counts[item] = (counts[item] or 0) + 1
end
print(counts.Bone, counts.Arrow, counts.Bow)  --> 3	2	1

The (counts[item] or 0) handles the first time an item appears, when its count is still nil.

Group things into lists:

local mobs = {
  { name = "Zombie", kind = "hostile" },
  { name = "Cow", kind = "passive" },
  { name = "Skeleton", kind = "hostile" },
}

local by_kind = {}
for _, mob in ipairs(mobs) do
  by_kind[mob.kind] = by_kind[mob.kind] or {}
  table.insert(by_kind[mob.kind], mob.name)
end
print(table.concat(by_kind.hostile, ", "))  --> Zombie, Skeleton

Filter a list into a new one:

local function filter(list, keep)
  local result = {}
  for _, v in ipairs(list) do
    if keep(v) then
      result[#result + 1] = v
    end
  end
  return result
end

local levels = { 3, 18, 7, 30, 1 }
local high = filter(levels, function(n) return n >= 10 end)
print(table.concat(high, ", "))  --> 18, 30

Look up by a key instead of searching. If you often need “the player called X”, keep a dictionary from name to player instead of looping through a list every time.

Try it

Write merge(a, b) that returns a new table with all fields of a, then all fields of b (so b wins when both have a key). Neither input may change. Test it with default settings { pvp = false, max_players = 10 } and overrides { max_players = 20, motd = "Hi" }.

Show a solution
local function merge(a, b)
  local result = {}
  for k, v in pairs(a) do result[k] = v end
  for k, v in pairs(b) do result[k] = v end
  return result
end

local defaults = { pvp = false, max_players = 10 }
local overrides = { max_players = 20, motd = "Hi" }
local settings = merge(defaults, overrides)

print(settings.pvp, settings.max_players, settings.motd)  --> false	20	Hi
print(defaults.max_players)                               --> 10

Recap: tables are passed by reference, == compares identity, copy with a loop when you need independence, and dictionaries make counting and lookups easy.