Luau Tables Explained: Arrays vs Dictionaries

Quick answer: In Luau, tables are the only built-in data structure, and every table can act as an array (integer keys starting at 1), a dictionary (string keys), or both at once. Use `#t` for array lengths only on clean sequences — mixed or holey tables give unreliable results.

Tables are the only data structure Luau gives you, so everything else — arrays, dictionaries, stacks, queues, inventories, save files — is just a table used in a particular style. The same type powers two very different layouts: the array part, where keys are consecutive integers starting at 1, and the dictionary part, where keys are whatever strings (or other values) you choose. Understanding which part you're using at any moment is what makes Roblox scripting click.

What does a table actually look like in Luau?

A table is a collection of key-value pairs wrapped in curly braces. When the keys happen to be 1, 2, 3, ... you call it an array. When the keys are names like "Gold" or "SwordLevel", you call it a dictionary:

-- Array style: integer keys starting at 1
local fruits = { "Apple", "Banana", "Cherry" }
print(fruits[1]) --> Apple  (indexing starts at ONE, not zero)
print(#fruits)   --> 3

-- Dictionary style: string keys
local playerStats = {
    Gold = 150,
    Kills = 7,
}
print(playerStats.Gold)          --> 150
print(playerStats["Kills"])      --> 7 (bracket syntax works too)

The 1-based indexing trips up almost everyone coming from Python, JavaScript, or C. In Luau, fruits[0] is nil, and fruits[3] is the last element of that array. Internalize this early and half the "why is my value nil?" bugs disappear.

Array vs dictionary: when do I use each one?

Reach for the array when order matters or you have a list of similar things: spawn points, quest steps, items in a backpack. Reach for the dictionary when you look things up by name or id: stats per stat name, settings per option, players keyed by their UserId. Nothing stops you from mixing them in one table, but as you'll see below, the length operator stops making sense when you do.

local inventory = {
    -- array part: ordered slots
    "Sword",
    "Health Potion",
    -- dictionary part: quick lookup by name
    Gold = 250,
}

print(inventory[1])     --> Sword        (array access)
print(inventory.Gold)   --> 250          (dictionary access)

Why is #myTable wrong sometimes?

The length operator # answers "how many elements are in this table" reliably only for sequences: arrays whose keys are exactly 1 through n with no gaps. Two situations break it.

First, holes. If you set items[3] = nil in the middle of an array, Luau doesn't shift anything; you've punched a gap, and #items may now report any border between existing elements. Second, mixed tables. A table with both numeric and string keys confuses nobody but # — it only ever counts toward the numeric frontier, and even that isn't guaranteed once holes exist:

local items = { "A", "B", "C" }
items[5] = "E"           -- hole at index 4
print(items[5])          --> E (the value is there)
print(#items)            --> could be 3 OR 5, don't rely on it

local mixed = { "A", "B", Name = "Bag" }
print(#mixed)            --> 2 (strings aren't counted)

The safe rules: keep arrays dense (remove with table.remove, never assign nil to the middle), and never use # on a table that also has dictionary-style keys — track its size separately or count it yourself.

How do table.insert, table.remove, table.find, and table.sort work?

These four functions from the standard library cover most day-to-day array work, and searching for table.insert luau usually means you want one of these patterns:

local inventory = { "Sword", "Shield" }

-- Add to the end
table.insert(inventory, "Bow")
--> { "Sword", "Shield", "Bow" }

-- Insert at a specific position (everything after shifts right)
table.insert(inventory, 1, "Torch")
--> { "Torch", "Sword", "Shield", "Bow" }

-- Remove by position; later entries shift left to stay dense
local removed = table.remove(inventory, 2)
print(removed)           --> Sword

-- Remove the last element (a stack pop)
local last = table.remove(inventory)

-- Find the index of a VALUE (nil if absent)
local idx = table.find(inventory, "Bow")
print(idx)               --> 3 (or wherever Bow ended up)

-- Sort in place; pass a comparator for custom order
table.sort(inventory)                          -- alphabetical
table.sort(inventory, function(a, b)
    return #a < #b                             -- shortest name first
end)

Note the asymmetry: table.find searches by value, while removing happens by position. To delete a known value, find it first, then remove that index. And always prefer table.insert(t, v) over hand-writing t[#t + 1] = v on holey tables — the library call is explicit about appending.

How do I iterate arrays and dictionaries?

Arrays iterate with ipairs, which walks indices 1, 2, 3... in guaranteed order and stops at the first nil. Dictionaries iterate with pairs, which visits every key in no particular order. We compare the two in depth in our pairs vs ipairs guide, but the short version:

local potions = { "Minor Heal", "Greater Heal" }
for i, name in ipairs(potions) do
    print(i, name)       --> 1 Minor Heal / 2 Greater Heal
end

local prices = { Sword = 100, Shield = 80 }
for item, cost in pairs(prices) do
    print(item .. " costs " .. cost)
end

If you need a plain count instead, for i = 1, #potions works fine on clean arrays.

How do nested tables work?

Table values can be other tables, which is how you build structured records — and eventually how you'll shape DataStore save data:

local inventory = {
    Gold = 250,
    Items = {
        { Name = "Sword", Damage = 25 },
        { Name = "Shield", Defense = 10 },
    },
}

print(inventory.Items[2].Name)   --> Shield
inventory.Items[1].Damage += 5   -- mutate a nested record

One warning before this becomes a bug: tables are copied by reference, not by value. Assigning one table to another variable does not duplicate it — both variables point at the same underlying data:

local original = { Gold = 100 }
local copy = original
copy.Gold = 999

print(original.Gold)   --> 999! Same table, not a snapshot.

To make a real copy you have to build a new table element by element (a shallow copy via for k, v in pairs(original), or a recursive loop if it nests). This reference behavior is exactly why ModuleScripts can act as shared singletons — every script that calls require() gets handed the same module table rather than a fresh clone. That pattern is covered step by step in our ModuleScript guide.

Putting it together: a small inventory system

Here's a compact example combining arrays, dictionaries, nesting, and the standard-library functions:

local Inventory = {}
Inventory.__index = Inventory

function Inventory.new()
    return setmetatable({ Slots = {}, Gold = 0 }, Inventory)
end

function Inventory:Add(itemName)
    table.insert(self.Slots, itemName)
end

function Inventory:Remove(itemName)
    local index = table.find(self.Slots, itemName)
    if index then
        return table.remove(self.Slots, index) ~= nil
    end
    return false
end

function Inventory:Count()
    return #self.Slots
end

local bag = Inventory.new()
bag:Add("Sword")
bag:Add("Potion")
bag:Remove("Sword")
print(bag:Count())   --> 1

When this grows past ~50 lines it belongs in its own ModuleScript rather than inside a server Script — see how to structure code with ModuleScripts for that refactor.

Learn Luau by doing

Reading about tables only goes so far; the mental model of references versus copies settles after you've broken something real. On Luablox you can work through interactive theory, drill targeted practice problems, or follow our full Luablox lessons to write and run table-manipulation code until arrays and dictionaries feel automatic.

Frequently asked questions

Are Luau arrays 0-indexed or 1-indexed?

Luau arrays are 1-indexed. The first element of { "A", "B" } is at index 1, not 0. Accessing t[0] returns nil unless you deliberately put a value there, which you generally should not because it breaks the sequence contract that #, ipairs, and table functions rely on.

Can a Luau table be both an array and a dictionary?

Yes. Tables store arbitrary key-value pairs, so one table can hold consecutive integer keys (the array part) and string keys (the dictionary part) side by side. However, the # length operator should only be trusted on pure, gap-free arrays — on mixed or holey tables it can return misleading values.

How do I get the number of keys in a dictionary in Luau?

There is no built-in length for dictionaries. Count them manually: local n = 0 for _ in pairs(t) do n += 1 end. If you need frequent size checks, maintain a counter alongside the dictionary or restructure the data as an array.

Does assigning a table to another variable copy it?

No. Assignment copies the reference, not the contents, so both variables point to the same table and changes through either one are visible through both. To copy, iterate the source with pairs and insert each key-value pair into a new table (recursively, if the table contains nested tables).

What is the difference between table.find and pairs lookup?

table.find searches an ARRAY for a value and returns its index (or nil), scanning positions one by one. A dictionary lookup like t[key] finds a VALUE by its key in constant time. If your data is keyed by name or id, use a dictionary lookup instead of storing names in an array and calling table.find repeatedly.

Understand tables as sequence arrays and key-value dictionaries to manage dynamic data sets.

Related pages