Roblox GuidebookKnowledge base
English ⌄

Development / ROBLOX

A Luau inventory copy: why table.clone shares nested changes with the original

Compare assignment, a shallow clone and separately copied records using a small fictional inventory. A verified example shows how to edit a draft while retaining the original quantity, and where the two-level model's capabilities end.

Updated:

Define the draft's intended behavior #

Imagine a teaching inventory with two materials: copper has count=3, and wood has count=1. You want to open an editing draft and set its copper quantity to 9 while retaining 3 in the original data. Two values must be checked together; a label on one panel is insufficient. First write the expected pair, original=3 and draft=9. Then choose a copying method that is supposed to provide exactly that separation.

The materials and fields label and equipped are invented for the exercise. They are ordinary in-memory tables, not a real Roblox player's inventory. We verified the standalone logic in Luau without connecting a GUI, server, RemoteEvent or saves. That boundary allows an honest statement about the example rather than presenting a complete item system. It also explains why a successful copying check does not prove resource delivery in a published experience.

Distinguish a new name from a new table #

Writing draft=original gives the same table another name. The official guide explains tables as referenced values; ordinary assignment does not create an independent snapshot. Editing draft.copper.count can therefore also be observed through original.copper.count. The issue is not text rendering. Both variables lead to the same data, so the required separation is absent before any interface is involved or any player sees a panel.

For the first check, create a fresh data set and use this second name to set the quantity to 7. In our standalone test, alias==source is true and source.copper.count becomes 7. This is a verified example of a shared object. Do not call it a copy merely because the variable name differs. Create another fresh set with quantity 3 before the next check so an earlier mutation cannot distort your comparison.

Understand the boundary of table.clone #

table.clone creates a new outer table but copies its contents shallowly. When a value was another table, that reference remains shared. Consequently, draft~=original can be true at the same time as draft.copper==original.copper. There is no contradiction: the outer containers differ while one nested record is shared. An editor must check the depth where its change occurs rather than treating a different outer reference as complete independence.

In the second standalone check, changing the nested quantity through a shallow clone to 8 also makes the original quantity 8. However, removing wood from the outer clone does not remove wood from the source container. Those actions give different outcomes because they affect different levels. Do not combine them into copying works or copying fails. State the field and level you checked, preserving both observations instead of discarding the inconvenient result.

MethodShared copper record?
AssignmentYes
table.cloneYes
Copied recordsNo

Compare references and values separately #

The count value answers what quantity is stored. Comparing tables with == answers whether they are the same table in the current example. Matching numbers do not prove independence. Two different records can both contain 3 before either changes. Check reference structure first, then the action and both resulting values. Otherwise, a sharing mistake remains invisible until actual editing begins and the original data has already changed.

Prepare a short journal: outer tables differ, copper records differ, and draft editing leaves the source value unchanged. For the reverse direction, edit wood in the original and compare the draft. Checking both directions is useful when one object may update while another is being edited. Our test performs those comparisons, but it does not measure simultaneous network behavior or establish that every possible conflict in an experience has been eliminated.

From data to draftOpen full-size image ↗
Original diagram of the limited two-level helper.

Copy records for an explicit data shape #

Our original copyInventoryDraft function supports a dictionary with nonempty string item identifiers. Each value is a plain record table whose named fields contain only strings, numbers or booleans. The function first copies the outer container, then copies each record separately. For this shape, quantity, label and equipped=false retain their values while the record tables no longer share identity with the original. The function's stated scope is part of its design.

The contract is deliberately limited. Metatables, a nested metadata table, functions and numeric field keys fall outside it and cause an explicit rejection. This is not a universal deep copy for arbitrary data. The limitation makes the example understandable and testable without promising cycle handling or preservation of a complex reference graph. If your structure differs, describe it and choose copying rules first instead of deleting checks just to make execution succeed.

-- Teaching model only: string item IDs -> plain records -> scalar fields.
-- This is not a universal deep-copy function or a server validation layer.
local function copyInventoryDraft(original)
    assert(type(original) == "table", "Expected an inventory table")
    assert(getmetatable(original) == nil, "Root metatables are outside this model")
    local draft = table.clone(original)
    for id, record in pairs(original) do
        assert(type(id) == "string" and id ~= "", "Expected a nonempty item ID")
        assert(type(record) == "table", "Expected an item record")
        assert(getmetatable(record) == nil, "Record metatables are outside this model")
        for field, value in pairs(record) do
            assert(type(field) == "string", "Expected named record fields")
            local valueType = type(value)
            assert(valueType == "string" or valueType == "number" or valueType == "boolean",
                "Only scalar record fields are supported")
        end
        draft[id] = table.clone(record)
    end
    return draft
end

return copyInventoryDraft

Run the small usage example #

For standalone Luau, save the helper as table-copy-example.luau and the usage example as table-copy-usage.luau in the same folder. The second file obtains the function through require with a relative path, creates two teaching records and changes draft.copper.count to 9. This is the execution arrangement we verified. If adapting it to a Roblox ModuleScript, review module access and environment separately; the relative-path file is not a complete Studio setup.

The verified example output is GUIDEBOOK_TABLE_COPY_USAGE original=3 draft=9. Assertions additionally compare both quantities and retain equipped=false. The article provides the complete limited helper and its short application. Neither renders anything or grants items. If your output differs, check the executed file, where the helper came from and whether the source data had already been changed before the example. Preserve that context rather than relying only on an output label.

local copyInventoryDraft = require("./table-copy-example")
local original = {
    copper = {count = 3, label = "Copper", equipped = false},
    wood = {count = 1, label = "Wood", equipped = true},
}
local draft = copyInventoryDraft(original)
draft.copper.count = 9
assert(original.copper.count == 3)
assert(draft.copper.count == 9)
assert(draft.copper.equipped == false)
print("GUIDEBOOK_TABLE_COPY_USAGE original=" .. original.copper.count .. " draft=" .. draft.copper.count)

Check edits from both sides #

After separately copying records, editing copper quantity in the draft leaves the original at 3. Editing wood quantity in the source then leaves the draft's previous wood value unchanged. Inserting and removing outer entries in the draft likewise does not insert or remove source entries. These observations apply to the tested shape. They are more useful than one successful function-call message, which says nothing about whether subsequent edits remain independent.

If two source identifiers reference one record, the helper makes a separate copy for each identifier. Our test confirms that those new records differ and editing one does not edit the other. The original sharing relationship is therefore not retained. That is an intentional characteristic of this helper. A project where the shared record matters needs another policy; do not describe this result as an exact copy of every possible object graph or identity relationship.

Verified outcomeOpen full-size image ↗
Standalone Luau result;not an actual gameplay inventory.

Do not treat freeze as deep independence #

The official library explains that table.freeze changes the passed table in place and does not automatically freeze nested values. Our test freezes the outer inventory and checks that nested copper remains mutable. Editing its count is still possible. An immutable outer container therefore does not establish that all contained data is protected from editing or detached from other references. These are separate properties that require more specific observations.

table.clone of a frozen table returns an unfrozen outer copy, while the nested record remains shared under shallow copying. We check that separately, then use the limited helper to create independent records. Do not use one frozen marker as proof of every property of the result. Decide whether data needs copying, shared references or restrictions on mutation. Those are different requirements and should be checked through different actions rather than inferred from the same flag.

Preserve the standalone verification boundary #

The checks cover assignment, shallow cloning, two-level record independence, false, an empty dictionary, shared source records and explicitly unsupported shapes. Frozen outer-container behavior is checked separately. Do not generalize to every recursive structure: the helper implements no traversal of that graph. Rejecting a deeper record is part of its contract rather than a universal Luau or Roblox failure, and the test does not claim to provide a general-purpose graph-copy library.

In-memory copying does not establish a player's authority to edit a real inventory and does not replace server validation. It also saves no progress between sessions. A UI needs its own checks for refreshes, abandoning a draft and receiving new data; persistence needs a separate protocol. They were not performed for this article. Pass those limits together with the verified output to another developer so the helper cannot be mistaken for a complete gameplay system.

CaseOutcome
Draft editOriginal stays3
Original editDraft unchanged
Deeper objectRejected by contract
GUI and savesNot yet tested

Choose copying for a particular action #

The final journal should identify the copied level, references that remain shared and edits that were actually isolated. For this exercise, original=3 and draft=9 match the intended editing task. If you only need a new list of references to existing objects, a shallow clone can be intentional instead. Define the action and its expected effect before choosing a copying approach, rather than automatically selecting the most complicated method for every piece of data.

The original records, helper and checks are available for study and adaptation. Keep the data shape and limitations with them; adding a nested field calls for reviewing the contract and repeating relevant checks. Do not promise speed improvements, memory savings or repair of a real inventory without measurement and integration testing. Consult official sources for current table.clone and table.freeze behavior, and confirm conclusions about a particular implementation by actually running it.

Original sources

Luau — Standard Library
Roblox Creator Hub — Tables