Copies this lesson plus 2026 ground rules (no lua54 'yes', Cfx.re Portal, correct callback signatures) as a ready-to-paste mentor prompt.
Module 01 · Lesson 06
Tables: the one data structure to rule them all
In Lua, almost every collection of data is a table. Player lists, config files, inventories, job data: they are all tables. This lesson teaches you how to read them, write them, loop through them, and avoid the classic beginner mistake that the rest of this lesson is built to expose: forgetting that tables are references, not copies.
You'll build
A qu_tables resource that prints a live player roster with levels, then proves the reference gotcha live in the console.
Time
~22 minutes (longest in the module, earned)
You need
Module 01 lessons 01–05 under your belt. Server running. Console visible.
You'll learn
Array tables -> dictionary tables -> ipairs vs pairs -> nested tables -> table.insert / table.remove -> the reference gotcha
Older tutorials add a lua54 'yes' line here. As of June 2025 that setting is deprecated and ignored: Lua 5.4 is now the only Lua runtime, so you leave it out.
4
Write the lesson code
The topic is now represented by runnable code.
Open server.lua and paste this exactly. It packs every concept from the arc into one file, and the two commands at the bottom let you prove the reference gotcha by hand.
lua
-- Nested table: a list (array) whose values are themselves tables (dictionaries).local roster = { { name = 'Maya', level = 5, status = 'online' }, { name = 'Rami', level = 12, status = 'afk' },}-- Print the roster in order. ipairs walks 1, 2, 3... and stops at the first gap.local function printRoster() print('[qu_tables] --- roster ---') for index, player in ipairs(roster) do print(('[qu_tables] %d. %s (level %d, %s)'):format(index, player.name, player.level, player.status)) endend-- /roster: add a player to the end, then print the list.RegisterCommand('roster', function() table.insert(roster, { name = 'New Player', level = 1, status = 'online' }) printRoster()end, true)-- /alias: prove the reference gotcha. We "copy" Maya, then rename the copy.RegisterCommand('alias', function() local maya = roster[1] -- this does NOT copy Maya. maya now points at the SAME table. maya.name = 'Maya_ALT' -- so this rename also changes roster[1].name. print(('[qu_tables] alias set name to "%s"'):format(maya.name)) print(('[qu_tables] roster[1].name is now "%s" (same table!)'):format(roster[1].name))end, true)
5
Start and test it
The expected proof appears in the correct console.
Open server.cfg and add this line:
text
ensure qu_tables
Save, then run:
text
restart qu_tables
Run this test in the server console:
text
roster
Now run the second command to see the gotcha fire:
You ran it, the roster printed in order, and then alias renamed one variable and somehow changed the roster too. That second result looks like a bug. It is not. It is the single most important fact about tables in Lua, and the whole point of this lesson. Let us take the file apart in the order it runs.
Every table you will ever write is one of two shapes, or a mix of both.
An array table uses sequential integer keys starting at 1. You write it as a bare list and Lua numbers the slots for you:
lua
local items = { 'bread', 'water', 'radio' }-- items[1] is 'bread', items[2] is 'water', items[3] is 'radio'
A dictionary table uses named string keys. You write the key, an equals sign, then the value:
lua
local player = { name = 'Maya', level = 5, status = 'online' }-- player.name is 'Maya', player.level is 5
player.name and player['name'] mean exactly the same thing. The dot is just shorthand for the bracket form when the key is a plain word.
Now look at roster in your file. It is both shapes at once, nested: an array table on the outside, and each slot holds a dictionary table.
lua
local roster = { { name = 'Maya', level = 5, status = 'online' }, { name = 'Rami', level = 12, status = 'afk' },}
So roster[1] is the whole dictionary { name = 'Maya', level = 5, status = 'online' }, and roster[1].name reaches inside it to pull out 'Maya'. This array-of-dictionaries shape is exactly how FiveM frameworks hand you player lists, inventory slots, and job grades. Learn to read this one structure and most framework data stops looking scary.
for index, player in ipairs(roster) do print(('[qu_tables] %d. %s (level %d, %s)'):format(index, player.name, player.level, player.status))end
ipairs walks an array table in order: index 1, then 2, then 3, and it stops the instant it hits a nil hole. That ordered, predictable walk is exactly what you want for a roster you intend to print in rank order. On each pass, index is the slot number and player is the dictionary in that slot.
pairs is the other looping tool. It visits every key in the table, including string keys, but in no guaranteed order. You reach for pairs when the table is a dictionary (named keys, no sequence) or when you genuinely do not care about order. The rule of thumb: ordered list, use ipairs; bag of named fields, use pairs. The trap that bites beginners is using ipairs on a table that has a gap, for example slots 1, 2, and 4 with nothing at 3. ipairs stops at the hole and silently skips slot 4, and you spend an hour wondering where your data went.
In Lua, strings have methods you can call on them, and format is one. The colon syntax someString:format(...) calls the format method on that string. The parentheses around the string literal are required so Lua knows you mean to call a method on that exact piece of text. Inside the template, each % marker is a slot that gets filled in order by the arguments you pass: %d means "a whole number goes here" and %s means "a string goes here". So the four arguments drop into the four markers left to right. This is cleaner than gluing strings with .. once you have more than two pieces, and it is the standard way FiveM scripts build readable log lines.
table.insert(roster, { name = 'New Player', level = 1, status = 'online' })
table.insert appends a value to the end of an array table and bumps the length by one. Before this line roster has two slots; after it, three, and the new dictionary lands at roster[3]. That is why your first test printed a third line. Its sibling is table.remove(roster, 2), which deletes slot 2 and then shifts every later element down to close the gap, so the old slot 3 becomes the new slot 2. That shifting is the reason you almost never remove items inside an ipairs loop: you are renumbering the very list you are walking.
✓You loop a roster with ipairs and call table.remove on the current slot when a player is offline. Some offline players survive the purge. Why?
Because table.remove shifts every later element down by one the moment you delete a slot, but the loop's index keeps marching forward. Remove slot 2 and the old slot 3 slides into slot 2, then the loop moves to slot 3 and skips the element that just moved into 2. The fix is to either loop backwards (from the last index down to 1, so removals never affect slots you have not visited yet) or build a fresh table of the survivors instead of editing the list you are iterating.
local maya = roster[1] -- maya points at the SAME table as roster[1]maya.name = 'Maya_ALT' -- editing through maya edits roster[1] too
When you write local maya = roster[1], Lua does not copy Maya's dictionary into a new table. It copies the reference: the address of the existing table. Now maya and roster[1] are two names for the one same table in memory. Mutate it through either name and both names see the change, because there is only one table. That is why renaming maya also renamed roster[1], exactly as your alias output proved.
Numbers and strings do not behave this way. If you write local x = roster[1].level and then x = 99, you change only x; the roster is untouched, because numbers are copied by value. Tables are the exception, and forgetting it causes some of the nastiest FiveM bugs: you "duplicate" a config block or an inventory item, edit the duplicate, and silently corrupt the original that other code still relies on.
When you actually want an independent copy, you must build a new table and copy the fields in yourself:
lua
-- A shallow copy: a brand-new table with the same top-level values.local function shallowCopy(t) local out = {} for key, value in pairs(t) do out[key] = value end return outendlocal mayaCopy = shallowCopy(roster[1])mayaCopy.name = 'Maya_ALT' -- now roster[1].name stays 'Maya'
Note this is a shallow copy: it copies the top level only. If a value is itself a table, the copy still shares that inner table by reference. For the flat dictionaries in this roster a shallow copy is enough. In real projects most teams reach for a battle-tested deep-copy helper instead, such as the one in ox_lib (a community utility library from Overextended, not a Cfx.re built-in), so they do not hand-roll this on every resource.
You reach for a table the moment you have more than one of anything, or more than one fact about one thing. A single player has a name, a level, and a status: that is a dictionary. A list of players is an array of those dictionaries. Config blocks, job grades, vehicle spawn lists, and shop inventories are all this same array-of-dictionaries shape. The reference behaviour matters every time you pass a table into a function or pull one out of a list to edit: you are handing over a live reference, not a snapshot, so the function can change your data out from under you. Decide on purpose whether you want that shared edit or an independent copy.
The folder name or the ensure line do not match. The folder must be resources/qu_tables and server.cfg must say ensure qu_tables, spelled identically.
attempt to index a nil value (field 'level')
A roster entry is missing a key, or you typed player.lvl instead of player.level. Every dictionary in the array needs name, level, and status.
bad argument #2 to 'format' (no value)
The format template has more % markers than you passed arguments. Count the %d and %s slots and make sure one value follows for each, in order.
The command does nothing when a player runs it in chat
The restricted flag is true, so only the server console and aces may run roster or alias. Type them in the txAdmin Live Console, not the in-game chat box (press T).
Tell an array table (integer keys) from a dictionary table (string keys), and read a nested array-of-dictionaries like the roster.
Choose ipairs for an ordered list and pairs for a bag of named keys, and explain why ipairs stops at the first nil hole.
Use table.insert to append and know that table.remove shifts later indices down, so you do not remove inside a forward ipairs loop.
Read ('%d %s'):format(...) as a method call on a string literal that fills % markers in order.
State the reference gotcha out loud: assigning a table copies the reference, not the data, so mutating one name mutates both, and copy fields into a new table when you need independence.
No server needed for this part. Edit the Lua below and press RUN to execute it in your browser on real Lua 5.4. The script proves the reference gotcha without FiveM around it: watch the original change when you edit the alias.