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.
Build it
Make the resource folder
Inside your server's resources folder, create this folder:
resources/qu_tablesCreate the files
Create this exact file layout:
resources/qu_tables/
fxmanifest.lua
server.luaWrite fxmanifest.lua
Open fxmanifest.lua and paste this:
fx_version 'cerulean'
game 'gta5'
server_script 'server.lua'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.
Write the lesson 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.
-- 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))
end
end
-- /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)
-- /leaderboard: sort the roster by level (highest first), then print it.
-- table.sort reorders the SAME table in place; it does not return a new one.
RegisterCommand('leaderboard', function()
-- The second argument is a comparator: it must return true when a should
-- come before b. 'a.level > b.level' means "higher level wins", so the
-- list ends up sorted from highest level down to lowest.
table.sort(roster, function(a, b)
return a.level > b.level
end)
printRoster()
end, true)
-- /names: build ONE line listing every player's name, separated by commas.
-- table.concat only works on the array part (slot 1, 2, 3...) and only on
-- strings or numbers, so we first collect the names into a flat array.
RegisterCommand('names', function()
local names = {}
for _, player in ipairs(roster) do
table.insert(names, player.name)
end
print(('[qu_tables] roster: %s'):format(table.concat(names, ', ')))
end, true)Start and test it
Open server.cfg and add this line:
ensure qu_tablesSave, then run:
restart qu_tablesRun this test in the server console (the txAdmin Live Console, or the FXServer console window where the server is running -- NOT the in-game F8 console or the chat box):
rosterNow run the second command to see the gotcha fire:
aliasRestart qu_tables again so the roster is back to its two starting entries, then run this in the server console (the txAdmin Live Console or the FXServer window, NOT the in-game F8 console or the chat box):
leaderboardRami jumped to the top because his level (12) is higher than Maya's (5). Now run:
namesHow it works
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.
Two shapes of table: array vs dictionary
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:
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:
local player = { name = 'Maya', level = 5, status = 'online' }
-- player.name is 'Maya', player.level is 5player.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.
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.
Looping: ipairs versus pairs
for index, player in ipairs(roster) do
print(('[qu_tables] %d. %s (level %d, %s)'):format(index, player.name, player.level, player.status))
endipairs 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.
What ('...'):format(...) is doing
This piece confuses everyone the first time:
('[qu_tables] %d. %s (level %d, %s)'):format(index, player.name, player.level, player.status)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.
Growing the table: table.insert
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.
Ordering and joining: table.sort and table.concat
You have a list. Two things you constantly need with a list are: put it in a useful order, and squash it into one readable line. That is table.sort and table.concat.
table.sort(roster, function(a, b)
return a.level > b.level
end)table.sort reorders an array table in place. It does not hand you back a new sorted table; it rearranges the one you passed in, and returns nothing. So after this line runs, roster itself is sorted and the old order is gone. With no second argument it sorts ascending using <, which only works when every element is directly comparable (all numbers, or all strings). Our roster holds dictionaries, and Lua has no idea how to compare two dictionaries, so we hand it a comparator function. The comparator receives two elements, a and b, and must return true when a should come before b. We wrote a.level > b.level, which reads as "the higher level comes first", giving a highest-to-lowest leaderboard. Flip it to a.level < b.level and you get lowest-first. This is exactly how you build a kill leaderboard, a job-grade list, or a nearest-player list in a real resource.
table.concat(names, ', ')table.concat glues the elements of an array table into a single string, putting the separator you pass between each one. table.concat(names, ', ') turns { 'Rami', 'Maya' } into 'Rami, Maya'. Two hard limits trip people up. First, it only reads the array part (slots 1, 2, 3...); it ignores string-keyed fields entirely. Second, every element it touches must be a string or a number, never a table. That is why /names does not call table.concat(roster, ...) directly: each roster slot is a dictionary, which would throw invalid value (at index 1) in table for 'concat'. We first walk the roster with ipairs and push each player.name into a flat names array, then concat that. Reach for table.concat whenever you want one clean log line, one chat announcement, or one comma-separated value instead of a print per element, since one line is far easier to read in the server console and far cheaper than spamming many print calls.
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.
The reference gotcha (the whole point)
Here is the line that surprised you, broken down:
local maya = roster[1] -- maya points at the SAME table as roster[1]
maya.name = 'Maya_ALT' -- editing through maya edits roster[1] tooWhen 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:
-- 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 out
end
local 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-maintained utility library, not a Cfx.re built-in), so they do not hand-roll this on every resource.
When you reach for tables
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.
If something went wrong
| Symptom | Fix |
|---|---|
Resource qu_tables does not exist | 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). |
invalid value (at index 1) in table for 'concat' | table.concat only joins strings and numbers. You passed it a table whose slots are dictionaries (like roster), or an array with a nil hole. Build a flat array of just the strings or numbers first, then concat that, exactly as /names does. |
invalid order function for sorting | Your table.sort comparator is inconsistent. It must give a strict order: never return true for both (a, b) and (b, a). Using >= or <= instead of > or < is the usual cause, because equal elements then satisfy both directions. Use a.level > b.level, not a.level >= b.level. |
What you can do now
- 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.
- Sort an array table in place with table.sort, and write a comparator that returns true when a should come first (a.level > b.level for highest-first).
- Join an array of strings or numbers into one line with table.concat(t, separator), and know it ignores string keys and rejects table elements.
Run it live
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.
Press RUN to execute.