Tables in Luau: arrays start at 1, and that is what breaks your loop
A table is Luau's only collection type, and it works as an array and as a dictionary. Two things catch people coming from other languages: the index starts at 1, and removing an item during a loop reorders the table underneath you.
Produced with AI from the sources cited below, and checked by automated verification against those same sources. No person read this text before publication. Editorial Policy

A table is Luau's only collection type. There is no separate list, no dictionary, no set, no tuple — there is a table, and it plays all three roles depending on how you use it.
That is simple to explain and produces two mistakes everybody makes once: the index that starts at 1, and removal during a loop, which reorders the table underneath you.
The problem
The official definition is generous: a table stores multiple values of any type — boolean, number, string, function and other tables. It is built with braces, {}.
And the same table serves as an array or as a dictionary. The difference is not in the type; it is in what you use as the index. An array uses an ordered list of numbers; a dictionary accepts numbers, strings and objects.
That flexibility is both the strength and the confusion. A table can be both things at once, with values at numeric indices and others at string keys — which works and is almost always a sign that two different ideas were mixed in the same place.
The question that organises the choice is a single one: does the order matter, or does the lookup matter? If you need to know who is first, who is next, who joined earlier — array. If you need to know whether an item is there, or the value associated with it — dictionary.
The difference is not taste, it is cost. Asking an array "is this player in the list?" means walking the whole list until you find them — and if the answer is no, walking all of it. In a dictionary, the same question is a direct lookup, independent of size. With ten items nobody notices; with three hundred, in a check running every frame, it is very noticeable.
And there is a less obvious consequence about clarity. An array of players does not say, by itself, whether its order means anything. Whoever reads the code later does not know whether they can reorder it without breaking something. A dictionary says in its structure that the order does not matter — and that information, written into the type rather than into a comment, is the one that survives.
Step by step
Array. To create one, you declare the values in order, separated by commas. To read, square brackets with the position number.
And here is the first stumble, stated literally in the documentation: unlike other languages, Luau indexes arrays from 1. The first item is at index [1], not [0].
To write to an index, you declare the number in brackets followed by = and the value. That works both for overwriting and for creating a new position.
To walk it, there are two forms. General iteration, which hands over index and value, and the numeric loop — since the index is numeric, you can go from 1 to the array's length, which you get with #.
Inserting. There are two built-in ways to append at the end: table.insert(), passing the table and the value; and the length-plus-one index, array[#array+1]. They do the same thing; the second reads faster when you are already handling indices.
To insert in the middle, you pass the position as the second argument to table.insert(). That pushes the following items one position up.
Removing. Removal is through table.remove(), which takes the item out of the given position and moves the following ones one position back. Keep that sentence — it is the whole of the next section.
Dictionary. It is an extension of the array: it stores key-value pairs, and the key can be a number, a string or an object. To create one, you define each key followed by = and the value.
The detail that pays most in Roblox: the key can be an Instance. A dictionary with the player as the key answers "is this player in the list?" without walking anything. To use an object as a key, you declare the key in square brackets.
To read, square brackets with the key. And a string key with no spaces can be indexed with a dot, which is usually more readable.
The artifact
The module below is a player queue, and it exists to show the decision from the start of this text applied: both forms, at the same time, for different things.
The array keeps the order — first in, first out. The dictionary, with the player's Instance as the key, answers "are they queued?" in one operation, instead of walking the whole queue on every query. Keeping the two in sync costs two lines per operation and pays off on every query.
In the middle of it are the two correct ways of removing during a walk, which is the next subject.
Common mistakes
Index zero. Anyone coming from any popular language writes list[0] without thinking. The result is nil, and nil is not an error — it is a valid value. The code carries on with nil, and the error appears three lines later, pointing at the wrong place.
The cruellest case is the loop for i = 0, #list do, which runs one time too many, starting on a nil. Handling nil with a check makes the bug disappear and leaves the loop wrong.
Removal during the loop. This is the one that survives experience, because it works in most tests.
The mechanism is written in the documentation: table.remove() takes the item out of the given position and moves the following ones one position back. So, in an ascending loop, when you remove the item at position i, the item that was at i+1 moves to i — and the loop, on the next step, goes to i+1, skipping the item that just moved.
The symptom is characteristic and misleading: with one item to remove, it works. With two consecutive ones, one of the two survives. And since you rarely test with two adjacent items, the bug reaches production and shows up as "sometimes somebody stays in the queue".
There are two fixes, and the artifact carries both.
The first is walking back to front. Whatever gets reordered always sits behind where the loop has already been, so nothing is skipped. It is a one-line fix.
The second is building a new table with what stays, instead of taking things out of the old one. It is clearer when the condition is complicated, and it does not depend on anyone remembering the loop direction six months from now.
The same logic applies to inserting during the walk, in the opposite direction: inserting in the middle pushes the following ones up, and the loop passes over the same item twice.
The third trap is using # on a table with holes. The length operator counts the sequence from 1, and a table where somebody deleted a middle item with array[3] = nil has unpredictable behaviour. Removing is table.remove(), not assigning nil.
Next step
The most useful exercise is looking at your existing tables and asking, for each one, whether the order matters.
It is common to find arrays that exist only to answer "is it here?" — and every one of those queries is a whole loop. Swapping them for a dictionary with the Instance or the UserId as the key turns a scan into a direct lookup, and the code gets shorter along the way.
The reverse turns up too: dictionaries used where the order mattered, and somebody added a position field to compensate. That is an array asking to exist.
It is also worth opening the loops that walk tables and checking two things: whether any of them removes or inserts mid-walk, and whether any starts at zero. Both are quick to find by reading, and both produce bugs that raise no error.
A third habit that pays: when an array and a dictionary describe the same collection — as in the artifact's queue — keep them in a single function that changes both together. Spreading that synchronisation across several places is how one of the two goes stale, and then the queue says the player is in it while the order has already taken them out.
One last habit, small and saving rework: when a table has string keys, prefer names with no spaces. A key with no spaces can be indexed with a dot, which is shorter and more readable, and it also saves having to remember which keys need brackets and quotes and which do not.
After that, it is worth getting to know the whole table library, which the documentation points to next. table.insert and table.remove are the two most used, and they are not the only ones — and every function of it you know is a loop you do not have to write.
Sources
- Tables | Documentation - Roblox Creator Hub — checked on
Ready-to-use artifact
-- ModuleScript: a player queue, showing WHEN to use each form of the same
-- table -- and the trap of removing during a loop.
local Queue = {}
-- ARRAY: the order IS the data. First in, first out.
local order = {}
-- DICTIONARY with the Instance as the key: answers "is this player in the
-- queue?" without walking anything. The documentation allows objects as
-- keys, and this is what that is for.
local present = {}
function Queue.join(player)
if present[player] then
return false
end
order[#order + 1] = player -- the second official way to append
present[player] = true
return true
end
function Queue.isQueued(player)
-- O(1). With a plain array this would be a loop on every query.
return present[player] == true
end
function Queue.positionOf(player)
for i, p in order do
if p == player then
return i -- remember: the first position is 1, not 0
end
end
return nil
end
function Queue.leave(player)
local i = Queue.positionOf(player)
if not i then
return false
end
table.remove(order, i) -- moves the following ones one position BACK
present[player] = nil
return true
end
-- ─────────────────────────────────────────────────────────────────
-- THE TRAP: removing INSIDE the loop
-- ─────────────────────────────────────────────────────────────────
-- WRONG. `table.remove` pulls everyone one slot back, and the next index
-- skips the item that has just moved into the removed position. With two
-- players who left side by side, one of the two STAYS in the queue.
--
-- for i = 1, #order do
-- if left(order[i]) then table.remove(order, i) end
-- end
--
-- RIGHT, way 1: walk BACK TO FRONT. Whatever is reordered always sits
-- behind where the loop has already been.
function Queue.clearDisconnected()
for i = #order, 1, -1 do
local player = order[i]
if not player.Parent then
table.remove(order, i)
present[player] = nil
end
end
end
-- RIGHT, way 2: build a new table. Clearer when the condition is
-- complicated, and it does not depend on remembering the loop direction.
function Queue.onlyAlive()
local fresh = {}
for _, player in order do
local character = player.Character
local humanoid = character and character:FindFirstChildOfClass("Humanoid")
if humanoid and humanoid.Health > 0 then
fresh[#fresh + 1] = player
end
end
return fresh
end
function Queue.next()
local player = order[1]
if not player then
return nil
end
table.remove(order, 1)
present[player] = nil
return player
end
return Queue


