<!-- fivemad.com, Markdown source of https://fivemad.com/docs/code-optimisation/tables-and-loops -->
> Source: https://fivemad.com/docs/code-optimisation/tables-and-loops
> Section: Code optimisation
> Tags: tables, loops, pairs, ipairs, table.insert, garbage collection, arrays, performance
> Last updated: 2026-09-25
> Site map for assistants: https://fivemad.com/llms.txt

# Tables and loops

Building arrays without table.insert, numeric for against pairs, caching repeated lookups, keeping a size counter, and not generating garbage every frame.

Tables are the only data structure Lua has, so almost every hot loop in a FiveM resource is a table loop. None of the changes below are dramatic on their own. Together, in code that runs per frame or over a few thousand rows, they add up.

## Let arrays index themselves

Writing the indexes out is noise, and it is a chance to get them wrong:

```lua
-- Noise, and nothing checks that the numbers are in order
local spawns = {
    [1] = vector3(-269.4, -955.3, 31.2),
    [2] = vector3(195.1, -933.5, 30.6),
    [3] = vector3(-1037.7, -2737.5, 20.1),
}

-- Same table
local spawns = {
    vector3(-269.4, -955.3, 31.2),
    vector3(195.1, -933.5, 30.6),
    vector3(-1037.7, -2737.5, 20.1),
}
```

Write explicit keys only when the number means something, such as a seat index or a component id where `[4]` skipping `[3]` is deliberate.

## Dot for fixed keys, brackets for variable keys

```lua
local price = items.water.price      -- key is known while you write it
local price = items[itemName].price  -- key comes from a variable
```

They compile to the same lookup, so this is purely about the reader. `items["water"]` in the middle of a line makes people stop and check whether the key is dynamic.

## Cache repeated lookups

Every `a.b.c` is two hash lookups. Repeat it five times in one function and you have done ten:

```lua
-- Four lookup chains
if Config.Shops[id].stock > 0 and Config.Shops[id].open then
    Config.Shops[id].stock = Config.Shops[id].stock - 1
end

-- One chain, then a local
local shop = Config.Shops[id]
if shop.stock > 0 and shop.open then
    shop.stock = shop.stock - 1
end
```

The second version is faster and much easier to read, and because tables are references, writing to `shop.stock` still writes to the real table.

## Appending: `t[#t + 1]` over `table.insert`

For a plain append, `table.insert(t, v)` and `t[#t + 1] = v` have the same result, but `table.insert` is a C library function: it validates the argument, takes the table length through the C API, and assigns through the C API. Direct indexing is a few VM instructions and never leaves the interpreter.

```lua
-- Slightly more work per call
table.insert(results, value)

-- The direct form
results[#results + 1] = value
```

Setting a named key with `table.insert` does not work at all, whatever you may have seen. `table.insert(t, 'key', 'value')` is interpreted as "insert `'value'` at position `'key'`" and errors. Write `t.key = value`.

Keep `table.insert` for what it is actually for: inserting into the middle of an array and shifting the rest up, which is the one thing the direct form cannot do.

> [!NOTE]
> The difference is small. It is worth writing the direct form by default because it costs nothing to type, and worth changing inside a loop over thousands of rows. It is not worth a pull request across a working resource.

## Keep a counter when you are filling an array

```lua
-- The length is looked up on every pass
local out = {}
for i = 1, #source do
    out[#out + 1] = transform(source[i])
end

-- No length lookup at all
local out, n = {}, 0
for i = 1, #source do
    n = n + 1
    out[n] = transform(source[i])
end
```

You will find claims that `#t` is a linear scan. It is not: Lua looks for the array boundary with the size it already tracks and a binary search at worst, so the cost is small and does not grow the way a scan would. The counter is still worth it in a tight loop because it replaces that search with an addition, and there is a correctness reason too: if the array can contain `nil` holes, `#t` is allowed to return any boundary, so `t[#t + 1]` can overwrite an existing value. A counter you control never does.

## Numeric `for` over `pairs` for arrays

```lua
-- Slower, and the order is not defined
for _, shop in pairs(shops) do
    drawShop(shop)
end

-- Fastest, and in order
for i = 1, #shops do
    drawShop(shops[i])
end
```

`pairs` returns the `next` function and calls it on every iteration, and the standard makes no promise about the order it walks a table in. For an array where the order matters, that alone is a reason to use a numeric loop. `ipairs` keeps the order and is close in speed, but it stops at the first `nil`, which silently truncates an array with a hole in it.

Use `pairs` for what it is for: hash tables with string keys, where there is no index to count.

## Do not build garbage every frame

The collector is the one cost that shows up as a stutter rather than a number. Anything allocated in a per frame loop is collected later, in a frame you did not choose:

```lua
-- A new table and a new string, 60 times a second
CreateThread(function()
    while true do
        Wait(0)
        local pos = { x = 0.5, y = 0.9 }
        drawText('Speed: ' .. math.floor(speed) .. ' km/h', pos)
    end
end)

-- One table, reused, and the string only rebuilt when it changes
local pos = { x = 0.5, y = 0.9 }
local lastSpeed, label = -1, ''

CreateThread(function()
    while true do
        Wait(0)
        local s = math.floor(speed)
        if s ~= lastSpeed then
            lastSpeed = s
            label = 'Speed: ' .. s .. ' km/h'
        end
        drawText(label, pos)
    end
end)
```

Strings in Lua are immutable, so every `..` creates a new one. Building a string from many parts belongs in a table plus one `table.concat`, not in a loop of concatenations:

```lua
local parts, n = {}, 0
for i = 1, #rows do
    n = n + 1
    parts[n] = rows[i].name
end
local line = table.concat(parts, ', ')
```

## Search once, not every time

A linear search through a config table inside a loop is the shape that quietly costs the most. Build the lookup once:

```lua
-- O(n) per call, every call
local function isBlacklisted(model)
    for i = 1, #Config.Blacklist do
        if Config.Blacklist[i] == model then return true end
    end
    return false
end

-- Built once when the file loads, O(1) per call
local blacklist = {}
for i = 1, #Config.Blacklist do
    blacklist[Config.Blacklist[i]] = true
end

local function isBlacklisted(model)
    return blacklist[model] == true
end
```

If the config holds model names and you compare against hashes, hash the keys once while building the set instead of hashing inside the check. See [Cheap native calls](/docs/code-optimisation/natives).

## Clean up what you keep

A table you keep adding to and never remove from is a memory leak with extra steps. Set entries to `nil` when they are done, especially tables keyed by player server id or entity handle, and clear them on `playerDropped` and `onResourceStop`. resmon's memory column is where you notice.

Next: [Cheap native calls](/docs/code-optimisation/natives).
