Learn Lua in Neovim: Table References vs Copies — The Copy Trap, Shallow & Deep Copy | Episode 22
Video: Learn Lua in Neovim: Table References vs Copies — The Copy Trap, Shallow & Deep Copy | Episode 22 by Taught by Celeste AI - AI Coding Coach
Lua Table References vs Copies: The Copy Trap
local b = afor tables doesn't copy — both names point to the same table. Mutations through one are visible through the other. Shallow copy withpairs; deep copy with recursion.
This is the trap that catches everyone the first time. In Lua, tables are reference types — assigning a table to a new variable doesn't create a new table; both names refer to the same one.
The reference trap
local a = { 1, 2, 3 }
local b = a
b[1] = 99
print("a[1] = " .. a[1])
print("b[1] = " .. b[1])
print("Same table! Both changed.")
Output:
a[1] = 99
b[1] = 99
Same table! Both changed.
local b = a doesn't duplicate the table — it copies the reference. Both a and b point at the same underlying table.
This contrasts with primitive types:
local x = 5
local y = x
y = 99
print(x) -- 5 (unchanged — numbers are by value)
print(y) -- 99
Numbers, strings, booleans, nil are by value. Tables and functions are by reference.
The same applies to function arguments
function modify(t)
t[1] = 99
end
local list = { 1, 2, 3 }
modify(list)
print(list[1]) -- 99
The function received a reference to list. Mutating through t mutates the original. No "copy on entry" like C structs.
Shallow copy
local function shallow_copy(original)
local copy = {}
for k, v in pairs(original) do
copy[k] = v
end
return copy
end
local c = { 10, 20, 30 }
local d = shallow_copy(c)
d[1] = 999
print("c[1] = " .. c[1]) -- 10 (unchanged)
print("d[1] = " .. d[1]) -- 999
A new table; copy each key-value over. Modifications to d no longer affect c.
This works fine for flat tables where all values are primitives.
The shallow copy trap
local e = { name = "Alice", scores = { 90, 85 } }
local f = shallow_copy(e)
f.name = "Bob"
f.scores[1] = 50
print("e.name = " .. e.name) -- "Alice" (good — shallow copy worked)
print("f.name = " .. f.name) -- "Bob"
print("e.scores[1] = " .. e.scores[1]) -- 50 (!!)
print("Nested table still shared!")
shallow_copy copies the top-level keys. The string "Alice" is by-value, so e.name and f.name are independent. But scores is a table — both e.scores and f.scores point at the same nested table.
Modifying f.scores[1] modifies the shared nested table. e.scores[1] sees the change.
Deep copy
local function deep_copy(original)
if type(original) ~= "table" then
return original
end
local copy = {}
for k, v in pairs(original) do
copy[k] = deep_copy(v)
end
return copy
end
local g = { name = "Alice", scores = { 90, 85 } }
local h = deep_copy(g)
h.scores[1] = 50
print("g.scores[1] = " .. g.scores[1]) -- 90 (unchanged)
print("h.scores[1] = " .. h.scores[1]) -- 50
print("Fully independent copy!")
Recursion: if a value is a table, deep-copy it; else, return as-is.
This handles nested tables of any depth. Each table gets its own fresh copy.
Deep copy limitations
local t = {}
t.self = t -- circular reference
local copy = deep_copy(t) -- INFINITE RECURSION
Naive deep_copy blows the stack on cycles. Production-quality deep-copy tracks visited tables:
local function deep_copy(original, seen)
seen = seen or {}
if seen[original] then return seen[original] end
if type(original) ~= "table" then return original end
local copy = {}
seen[original] = copy
for k, v in pairs(original) do
copy[k] = deep_copy(v, seen)
end
return copy
end
seen is a table of (original_table, copy) pairs. If we've seen this table before, return the copy we've already made.
When to copy, when to share
Copy when: - The caller might modify the value but the original shouldn't change. - You're freezing a snapshot for logging or persistence. - The function semantics are "transform and return new" — don't mutate inputs.
Share when: - Performance matters and the caller won't mutate. - The table represents shared state intentionally. - The caller is supposed to mutate (e.g., a buffer being populated).
The default in Lua is sharing. Most APIs don't copy unless explicitly documented. Read library docs carefully.
Testing for "same table"
local a = {1, 2}
local b = a
local c = {1, 2}
print(a == b) -- true (same reference)
print(a == c) -- false (different tables, even if contents match)
Table equality is reference equality, not deep equality. To check deep equality, write a recursive helper:
local function deep_eq(a, b)
if type(a) ~= "table" or type(b) ~= "table" then return a == b end
for k, v in pairs(a) do
if not deep_eq(v, b[k]) then return false end
end
for k in pairs(b) do
if a[k] == nil then return false end
end
return true
end
Functions are also reference types
local function f() return 1 end
local g = f
print(f == g) -- true (same function reference)
Same rules — functions compare by reference. Two function literals with identical bodies are not equal.
Common stumbles
local b = a "copies" tables. It doesn't. It copies the reference.
Forgetting that arguments are passed by reference for tables. Functions can mutate their input tables.
Shallow copy of nested tables. The nested tables are still shared. Use deep copy when nesting matters.
Deep copy of cyclic data. Stack overflow without a seen table.
Comparing tables with ==. Reference equality, not deep equality. Use a custom helper.
What's next
Episode 23: math and string libraries. The standard library essentials — math.floor, math.random, string.sub, string.find, string.format. Build a dice roller.
Recap
Tables and functions are reference types in Lua — assignment and parameter passing copy the reference, not the value. Mutations through one variable are visible through all aliases. Shallow copy with pairs works for flat tables; nested tables remain shared. Deep copy via recursion (with a seen map for cycles) creates fully independent copies. == on tables is reference equality, not content equality.
Next episode: math and string libraries.