---
url: /luau-basics/tables-in-depth.md
description: >-
  References versus copies, cloning, freezing, and the full table library
  without mutation hazards.
---

# Tables in Depth

*Written: {{ $frontmatter.date }}*

## Learning objectives

* You predict aliasing when tables share references.
* You copy shallow with table.clone and deep with recursion.
* You freeze shallow with table.freeze and deep with recursion.
* You apply find, sort, move, concat, clear, pack, and unpack.
* You explain table equality and mutation hazards.

## Prerequisites

* [Variables and Values](../programming-fundamentals/variables-and-values.md)
* [Tables Part 1](../programming-fundamentals/tables-part-1.md)
* [Functions and Scope](../programming-fundamentals/functions-and-scope.md)
* [Syntax and Control Flow](./syntax-and-control-flow.md)

## References point, they do not copy

A table variable holds a reference to a table, not the table itself. Assignment copies the reference. Two variables then point at one shared table. A change through one name shows through the other. The reference guide in [tables](https://create.roblox.com/docs/luau/tables) demonstrates this sharing.

Run this pure snippet in the Command Bar.

```luau
--!strict
local original = {10, 20}
local alias = original
alias[1] = 999
print(original[1])
print(alias[1])
```

```txt
999
999
```

Assigning `original` to `alias` means a write through `alias` shows in both prints as 999 because both names reach the same storage. This sharing suits shared config. Break it with a clone when you need independence.

Function arguments behave the same way. Passing a table gives the function a second reference to your table. Writes inside the function mutate your data. You pass a clone when the function should not touch the original.

Run this pure snippet in the Command Bar.

```luau
--!strict
local function bump(list: { number })
	list[1] = list[1] + 1
end
local scores = {5, 6}
bump(scores)
print(scores[1])
```

```txt
6
```

The function receives a reference to `scores`. It adds 1 in place. Your table now holds 6. You expect this whenever you hand a table to another function.

## Shallow copies with table.clone

Use `table.clone` when you need a fresh top level but you can share nested tables. It copies each key and value into a new table. Nested tables stay shared. The result is never frozen, even when the source is frozen.

Run this pure snippet in the Command Bar.

```luau
--!strict
local src = {name = "Mara", level = 4}
local copy = table.clone(src)
copy.level = 9
print(src.level)
print(copy.level)
```

```txt
4
9
```

Cloning `src` and changing only the copy leaves the source at 4 while the copy shows 9.

Nested sharing still applies. Test it before you trust a shallow copy with nested data.

Run this pure snippet in the Command Bar.

```luau
--!strict
local src = {stats = {wins = 2}}
local copy = table.clone(src)
copy.stats.wins = 99
print(src.stats.wins)
```

```txt
99
```

Both tables share one inner `stats` table. The write leaks into the source. You need a deep copy when nested independence matters.

## Deep copies copy every level

A deep copy recurses into nested tables. It clones each child table too. Use it for snapshots, configs, and save data. Skip it when sharing is fine, because it costs time and memory.

Run this pure snippet in the Command Bar.

```luau
--!strict
local function deepCopy(value: any): any
	if type(value) ~= "table" then
		return value
	end
	local out = table.clone(value)
	for k, v in out do
		if type(v) == "table" then
			out[k] = deepCopy(v)
		end
	end
	return out
end
local src = {stats = {wins = 2}}
local snap = deepCopy(src)
snap.stats.wins = 50
print(src.stats.wins)
print(snap.stats.wins)
```

```txt
2
50
```

The check returns plain values untouched, then a shallow clone replaces each nested table with its own deep copy. The source keeps 2 while the snapshot moves to 50. This matches the deep pattern in [tables](https://create.roblox.com/docs/luau/tables).

Keep the function small and total. Cycles and exotic keys need a fancier copier with a seen map. You do not need that weight for plain config and save shapes. Add it only when data can loop back on itself.

## Shallow freezes lock one level

Use `table.freeze` to make a table read-only. Writes to a frozen table error. It is shallow: nested tables stay writable. It is permanent. There is no unfreeze. Check state with `table.isfrozen`, all lowercase after the dot.

Run this pure snippet in the Command Bar.

```luau
--!strict
local config = {maxPlayers = 8}
table.freeze(config)
print(table.isfrozen(config))
```

```txt
true
```

Freezing `config` makes the check return true. Any later assignment to `config.maxPlayers` raises `attempt to modify a readonly table`. You freeze constants, tuning tables, and shared enums.

Nested tables escape a shallow freeze. Prove it before you rely on a freeze for safety.

Run this pure snippet in the Command Bar.

```luau
--!strict
local config = {spawn = {x = 1}}
table.freeze(config)
config.spawn.x = 42
print(config.spawn.x)
print(table.isfrozen(config.spawn))
```

```txt
42
false
```

The outer table is frozen but the inner table accepts the write and reports false. You need a deep freeze when nested writes must also fail.

## Deep freezes lock every level

A deep freeze recurses through nested tables and freezes each one. It guards each child only when that child is not already frozen. Use it for constant data such as item definitions and role tables.

Run this pure snippet in the Command Bar.

```luau
--!strict
local function deepFreeze(target: { any })
	table.freeze(target)
	for _, v in target do
		if type(v) == "table" and table.isfrozen(v) == false then
			deepFreeze(v)
		end
	end
end
local config = {spawn = {x = 1}}
deepFreeze(config)
print(table.isfrozen(config))
print(table.isfrozen(config.spawn))
```

```txt
true
true
```

The outer table freezes first, then each value is visited, recursing into unfrozen child tables. Both checks print true. Later writes at any level raise `attempt to modify a readonly table`. This follows the deep pattern in [tables](https://create.roblox.com/docs/luau/tables).

Freeze early, right after you build the constant. This avoids a half-initialized table escaping into gameplay code. Keep frozen data small because every access still pays normal lookup cost but writes now error.

## Equality means same table

Two table variables are equal only when they reference the same table. Same contents do not count. An empty literal never equals another empty literal. This surprises readers from value-compare languages.

Run this pure snippet in the Command Bar.

```luau
--!strict
local a = {id = 1}
local b = a
local c = {id = 1}
print(a == b)
print(a == c)
```

```txt
true
false
```

`a == b` is true because both names point at one table. `a == c` is false because `c` is a second table with matching contents. Compare contents field by field when you need value equality. Keep `==` for identity checks such as membership and removal.

Custom `__eq` can change equality for tables that share the same metamethod, as described in [metatables](https://create.roblox.com/docs/en-us/luau/metatables). Skip it for plain data. Reach for it in [Metatables and OOP](./metatables-and-oop.md) when values need semantic equality.

## The everyday table functions

Luau keeps table helpers in one library. They assume array-like tables unless stated. Indices stay 1-based.

`table.find` returns the first index of a value or nil. It scans from the start or from a given position.

Run this pure snippet in the Command Bar.

```luau
--!strict
local roles = {"tank", "healer", "dps"}
print(table.find(roles, "healer"))
print(table.find(roles, "mage"))
```

```txt
2
nil
```

The first search finds `"healer"` at 2. The second search returns nil because `"mage"` is absent. You test the result against nil before you use the index.

`table.sort` sorts in place. It returns nothing. It uses `<` by default. Pass a comparator when you need another order. Keep the comparator consistent or Luau raises `invalid order function for sorting`.

Run this pure snippet in the Command Bar.

```luau
--!strict
local nums = {5, 2, 9, 1}
table.sort(nums)
local ascending = {}
for i, v in nums do
	ascending[i] = tostring(v)
end
print(table.concat(ascending, ","))
table.sort(nums, function(a, b)
	return a > b
end)
local descending = {}
for i, v in nums do
	descending[i] = tostring(v)
end
print(table.concat(descending, ","))
```

```txt
1,2,5,9
9,5,2,1
```

The first sort is ascending and prints `1,2,5,9`. The second uses `a > b` and prints `9,5,2,1`. The function mutates `nums` both times.

`table.move` copies a slice from one table to another, or inside one table. You give source bounds and a destination start. It returns the destination table.

Run this pure snippet in the Command Bar.

```luau
--!strict
local src = {10, 20, 30}
local dst = {1, 2}
table.move(src, 1, 3, 3, dst)
local text = {}
for i, v in dst do
	text[i] = tostring(v)
end
print(table.concat(text, ","))
```

```txt
1,2,10,20,30
```

You copy all three source values into `dst` starting at index 3. The destination grows to five entries. This suits batch appends and queue shifts.

`table.concat` joins an array of strings with a separator. Strict mode expects `{string}`, so convert numbers with `tostring` first, as the samples above do. It ignores nil gaps past the length boundary, so keep arrays dense.

Run this pure snippet in the Command Bar.

```luau
--!strict
local words = {"red", "green", "blue"}
print(table.concat(words, "|"))
```

```txt
red|green|blue
```

`table.clear` empties a table but keeps its array capacity for reuse. The length becomes 0. The table itself survives.

Run this pure snippet in the Command Bar.

```luau
--!strict
local buf = {1, 2, 3}
table.clear(buf)
print(#buf)
```

```txt
0
```

Clear scratch buffers between rounds instead of allocating fresh tables to avoid garbage spikes in hot loops.

`table.pack` builds an array from arguments and records the count in field `n`. `table.unpack` spreads an array into separate return values, but it does not read `n` on its own, so always pass the bounds explicitly: `table.unpack(packed, 1, packed.n)`. This matters most when trailing values can be nil. Always spell `table.unpack` in full. A global `unpack` with the same behavior also exists, but the table form reads clearly and matches current docs.

Run this pure snippet in the Command Bar.

```luau
--!strict
local packed = table.pack("a", "b", "c")
print(packed.n)
print(table.unpack(packed, 1, packed.n))
```

```txt
3
a	b	c
```

The pack holds three values plus `n`. The unpack returns all three as separate values. You use this pair to store argument lists and forward them. Full forwarding appears in [Advanced Functions](./functions-advanced.md).

## Mutation hazards and safe habits

Aliasing a table, handing it to a helper, and sorting it in place leaves the caller with a reorder it never asked for. Adding keys while iterating a dictionary leaves visit order unspecified. Holding a reference you thought was a copy causes the same surprise.

Adopt three habits. Clone at boundaries when the receiver must not mutate your state. Freeze constants right after construction. Prefer functions that return new tables over helpers that edit in place, unless in-place speed is the point and the name says so.

Run this pure snippet in the Command Bar.

```luau
--!strict
local team = {"Cy", "Ana", "Bo"}
local view = table.clone(team)
table.sort(view)
print(table.concat(team, ","))
print(table.concat(view, ","))
```

```txt
Cy,Ana,Bo
Ana,Bo,Cy
```

Sorting only the clone keeps the original order for spawn logic while the view shows alphabetical order.

## Common mistakes

Wrong: you assume assignment copies.

```luau
--!strict
local a = {score = 1}
local b = a
b.score = 99
print(a.score)
```

You print 99, not 1, because `b` aliases `a`. Fix it with `table.clone` when you need independence.

```luau
--!strict
local a = {score = 1}
local b = table.clone(a)
b.score = 99
print(a.score)
print(b.score)
```

```txt
1
99
```

Wrong: you trust a shallow clone for nested data.

```luau
--!strict
local src = {pos = {x = 1}}
local copy = table.clone(src)
copy.pos.x = 7
print(src.pos.x)
```

You print 7 because the inner table is shared. Fix it with a deep copy for nested shapes.

Wrong: you trust a shallow freeze for nested data.

```luau
--!strict
local defs = {sword = {dmg = 5}}
table.freeze(defs)
defs.sword.dmg = 9
```

No error fires because the inner table is unfrozen. Studio would only raise `attempt to modify a readonly table` for a write to the frozen outer level. Fix it with a deep freeze right after construction.

Wrong: you compare tables by contents with `==`.

```luau
--!strict
print({1, 2} == {1, 2})
```

```txt
false
```

Literals are distinct tables, so equality is false. Fix it by comparing lengths and elements, or by keeping one shared reference for identity checks.

Wrong: you sort the caller's table by accident.

```luau
--!strict
local function sorted(list: { string })
	table.sort(list)
	return list
end
```

Callers lose their order. Fix it by cloning first and sorting the copy.

## Exercises

1. Prove aliasing. Share a table, mutate through the alias, and print both names.
2. Show shallow clone limits. Clone a nested table, mutate the child, and observe the leak. Then fix it with a deep copy.
3. Freeze a config shallow, then deep. Probe which writes error and which succeed before the deep version.
4. Use find, sort with a comparator, move, concat, clear, pack, and unpack in one script. Print each result.

::: details Solution 1
Run this pure snippet in the Command Bar.

```luau
--!strict
local main = {hp = 10}
local alias = main
alias.hp = 3
print(main.hp)
print(alias.hp)
```

```txt
3
3
```

Both names reach one table, so one write shows through both prints.
:::

::: details Solution 2
Run this pure snippet in the Command Bar.

```luau
--!strict
local function deepCopy(value: any): any
	if type(value) ~= "table" then
		return value
	end
	local out = table.clone(value)
	for k, v in out do
		if type(v) == "table" then
			out[k] = deepCopy(v)
		end
	end
	return out
end
local src = {bag = {gems = 1}}
local shallow = table.clone(src)
shallow.bag.gems = 9
print(src.bag.gems)
local fixed = deepCopy({bag = {gems = 1}})
fixed.bag.gems = 5
print(fixed.bag.gems)
```

```txt
9
5
```

The shallow clone leaks the inner write, so the source shows 9. The deep copy keeps its own child table and holds 5 independently.
:::

::: details Solution 3
Run this pure snippet in the Command Bar.

```luau
--!strict
local shallow = {opt = {on = true}}
table.freeze(shallow)
print(table.isfrozen(shallow))
print(table.isfrozen(shallow.opt))
local function deepFreeze(t: { any })
	table.freeze(t)
	for _, v in t do
		if type(v) == "table" and table.isfrozen(v) == false then
			deepFreeze(v)
		end
	end
end
local deep = {opt = {on = true}}
deepFreeze(deep)
print(table.isfrozen(deep.opt))
```

```txt
true
false
true
```

The shallow freeze locks only the outer table. The deep freeze locks the child too.
:::

::: details Solution 4
Run this pure snippet in the Command Bar.

```luau
--!strict
local inv = {"sword", "bow", "axe"}
print(table.find(inv, "bow"))
table.sort(inv, function(a, b)
	return a < b
end)
print(table.concat(inv, ","))
local extra = {"potion"}
table.move(extra, 1, 1, 4, inv)
print(table.concat(inv, ","))
local args = table.pack(10, 20)
print(args.n)
print(table.unpack(args, 1, args.n))
table.clear(inv)
print(#inv)
```

```txt
2
axe,bow,sword
axe,bow,sword,potion
2
10	20
0
```

You find `"bow"` at 2, sort ascending, move one item into slot 4, pack two numbers and unpack them, then clear the list to length 0.
:::

## Summary

* Assignment shares tables. Clone for independence. Deep-copy nested shapes.
* Freeze makes tables read-only. Deep-freeze nested constants. Check with table.isfrozen.
* Tables equal only by identity. Use find, sort, move, concat, clear, pack, and table.unpack for daily work.
* Clone at boundaries and freeze constants to stop mutation bugs.
* Next: [Strings and Libraries](./strings-and-libraries.md).
