Skip to content
Back to skills

Roblox Datastores

ASecurity

Roblox DataStoreService persistence: load on join, save on leave and BindToClose, UpdateAsync vs SetAsync, session locking and OrderedDataStore leaderboards. Use for lost player data, items duplicating after teleport, or "too many requests" errors. Not for engine-neutral saves (`save-systems`), Luau basics (`roblox-luau`) or economy numbers (`roblox-simulator-economy`).

  • 8,520 stars
  • 0 votes
  • 0 copies
  • 1 view
  • Added September 5, 2026
ai-agentsgobashdebuggingapisecurity

Works with

  • cli
  • api
  • mcp

Security analysis

A100/100

Pro scans all 2 files and shows the line behind each finding

Scanned November 12, 2026

npx -y skills add gamedev-skills/awesome-gamedev-agent-skills --skill roblox-datastores --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Roblox Datastores?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Roblox Datastores
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/gamedev-skills-roblox-datastores/badge)](https://www.skillsdirectory.com/skills/gamedev-skills-roblox-datastores)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: roblox-datastores
description: >
  Roblox DataStoreService persistence: load on join, save on leave and BindToClose, UpdateAsync vs
  SetAsync, session locking and OrderedDataStore leaderboards. Use for lost player data, items
  duplicating after teleport, or "too many requests" errors. Not for engine-neutral saves
  (`save-systems`), Luau basics (`roblox-luau`) or economy numbers (`roblox-simulator-economy`).
---

# Roblox DataStores

Persist data across sessions without losing or duplicating it. All DataStore calls are network
requests from server `Script`s; they can fail, be throttled, and race with another server writing
the same key.

## Workflow

1. **Find the existing persistence layer.** Search for `DataStoreService`, `GetDataStore`, and any
   profile or session library before writing new code. If the project already uses one, extend it;
   two systems writing the same key is the most common cause of rollbacks.
2. **Use a test place for Studio work.** Studio reads and writes the same live data as players.
   In a separate published test place, turn on File → Experience Settings → Security → *Enable
   Studio Access to API Services*; without it, requests fail with `StudioAccessToApisNotAllowed`.
3. **Key by `UserId`**, never by name: `"Player_" .. player.UserId`.
4. **Guard every call with `pcall`.** On a failed *load*, mark the session as not loaded and refuse
   to save, so defaults never overwrite real data.
5. **Load on `PlayerAdded`, save on `PlayerRemoving` and in `game:BindToClose`**, plus a periodic
   save (every 60-120 s), never on every value change.
6. **Write with `UpdateAsync` and session ownership.** `UpdateAsync` reads the latest value before
   writing; session ownership (in `references/sessions-and-limits.md`) stops a stale server from
   writing an old snapshot over newer progress.
7. **Use `OrderedDataStore` only for numeric rankings** (leaderboards).
8. **Check before reporting.** Run the static check below and fix every hit. Then, with the Studio
   MCP server connected and API access on in a test place, play, change a value, stop, play again,
   and read it back with an uncached read from `execute_luau` on the `Server` datamodel. Without
   MCP, ask the user to do join, change, leave, rejoin in the test place and report the value seen
   after rejoining and any Output warnings. Rate-limit behavior needs Team Create, not Run mode.

```bash
# Every DataStore call should sit inside pcall; this lists the lines to inspect by hand.
rg -n ":(GetAsync|SetAsync|UpdateAsync|IncrementAsync|RemoveAsync|GetSortedAsync)\(" src
rg -n "BindToClose" src        # expect at least one save-everyone handler
rg -n ":SetAsync\(" src        # each one on a player profile key needs a reason
```

## Patterns

### Load on join

```lua
local DataStoreService = game:GetService("DataStoreService")
local Players = game:GetService("Players")
local store = DataStoreService:GetDataStore("PlayerData")

local DEFAULT = { Coins = 0, Level = 1 }

Players.PlayerAdded:Connect(function(player)
    local key = "Player_" .. player.UserId
    local ok, data = pcall(function()
        return store:GetAsync(key)
    end)
    if not ok then
        warn("Load failed for", player.UserId, data)  -- network failure, not a new player
        player:SetAttribute("DataLoaded", false)
        return
    end
    player:SetAttribute("DataLoaded", true)
    applyToLeaderstats(player, data or table.clone(DEFAULT))  -- nil means a genuinely new player
end)
```

### Save with UpdateAsync (session ownership still required)

This callback ignores `old`, so on its own it can overwrite newer progress from another server.
Before shipping, add the session check from `references/sessions-and-limits.md` inside the
callback, or use a vetted profile library.

```lua
local function savePlayer(player)
    -- `~= true` also covers a player who left while GetAsync was still in flight.
    if player:GetAttribute("DataLoaded") ~= true then return end
    local key = "Player_" .. player.UserId
    local newData = gatherDataFor(player)    -- plain, JSON-serializable table

    local ok, err = pcall(function()
        store:UpdateAsync(key, function(old)
            -- Must not yield and may run more than once. Return nil to cancel the write
            -- when this session no longer owns the key.
            return newData
        end)
    end)
    if not ok then warn("Save failed for", player.UserId, err) end
end
```

### Save on leave and on shutdown

```lua
Players.PlayerRemoving:Connect(savePlayer)

game:BindToClose(function()
    local players = Players:GetPlayers()
    local remaining = #players
    for _, player in players do
        task.spawn(function()
            savePlayer(player)
            remaining -= 1
        end)
    end
    while remaining > 0 do task.wait() end  -- the server waits at most 30 s in total
end)
```

### Retry only what is safe to repeat

```lua
local function withRetry(fn, attempts)
    attempts = attempts or 3
    for i = 1, attempts do
        local ok, result = pcall(fn)
        if ok then return true, result end
        if i < attempts then task.wait(2 ^ i) end
    end
    return false
end
```

A failed write does not prove the write did not happen; the server may only have missed the
response. Re-running an `UpdateAsync` that writes the full state is harmless; re-running
`IncrementAsync` can add twice. When the outcome matters, confirm with an uncached read
(`DataStoreGetOptions` with `UseCache = false`) before retrying.

### Leaderboard with OrderedDataStore

```lua
local board = DataStoreService:GetOrderedDataStore("Coins")
pcall(function() board:SetAsync("Player_" .. player.UserId, coins) end)  -- on change, throttled

local ok, pages = pcall(function()
    return board:GetSortedAsync(false, 10)   -- false = descending, highest first
end)
if ok then
    for rank, entry in pages:GetCurrentPage() do
        print(rank, entry.key, entry.value)
    end
end
```

Values must be integers. For a few specific players (friends, a party), `BatchGetAsync(keys)`
reads up to 100 keys in one request instead of paging the whole board.

## Gotchas

- **Defaults saved over real data.** A failed load treated as "new player" wipes progress on the
  next save. Gate saves on "load succeeded", not on "load did not fail".
- **`SetAsync` races between servers.** During a teleport or fast rejoin two servers can hold the
  same player; the last blind write wins. Use `UpdateAsync` with session ownership.
- **Yields or side effects inside the `UpdateAsync` callback.** The callback cannot yield, and
  Roblox re-runs it when another server changed the key first, so granting items, firing remotes
  or incrementing counters inside it can happen twice. Act after the call returns.
- **No `BindToClose` save.** Players in the server at shutdown lose everything since the last
  periodic save.
- **Saving on every change.** Throttling queues requests and then drops them with 301-306 errors.
  Coalesce changes and save on a timer and on leave.
- **Stale reads.** `GetAsync` caches a key for about four seconds after the first read, so an
  immediate re-read after a write may return the old value.
- **Non-serializable values.** Only JSON-like data persists: numbers, strings, booleans, and tables
  with string or sequential number keys. Convert `Vector3`, `CFrame`, `Color3` and Instances to
  plain tables first.
- **Metadata and UserIds cleared on write.** Once a key has metadata or UserIds, every write must
  supply them again or they are removed.
- **`DataStoreKeyInfo` is nil for ordered stores.** They have no versioning or metadata; use a
  regular `DataStore` when you need those.
- **Temporary cross-server state.** Matchmaking queues and per-round data belong in
  `MemoryStoreService`, not DataStores.

## Resources

- Read `references/sessions-and-limits.md` before shipping save code or when debugging rollbacks,
  duplication, or throttling: session locking, versioning and metadata, pagination, request
  budgets, storage limits, Right to be Forgotten, and Open Cloud.

## Related skills

- `roblox-luau`: services, events, and the server/client model.
- `roblox-simulator-economy`: repairing loaded values and offline earnings.
- `save-systems`: engine-neutral serialization, slots, and migration.

## Version notes

Last verified: 2026-11-11.

- Since 2026-07-29, in-game and Open Cloud requests share one per-experience budget per request
  type: 300 per minute plus a per-CCU multiplier (reads 40, writes 20, lists 2 for standard and
  ordered stores). `UpdateAsync` spends from both read and write. Per-server limits still apply.
- Storage limit per experience: 500 MB + 1 MB per lifetime user, measured on the compressed latest
  version of each key.
- Per key: about 25 MB per minute of reads and 4 MB per minute of writes. Value size: 4,194,304
  characters. Key, store and scope names: 50 characters.
- `BindToClose` handlers get 30 seconds in total. Studio Run mode uses its own lower static
  limits; test rate limits in Team Create.
- Automated Right to be Forgotten processing for data stores was in beta during 2026; check its
  status before relying on it.

Sources: `https://create.roblox.com/docs/cloud-services/data-stores`,
`https://create.roblox.com/docs/cloud-services/data-stores/error-codes-and-limits`,
`https://create.roblox.com/docs/reference/engine/classes/GlobalDataStore`,
`https://create.roblox.com/docs/reference/engine/classes/DataModel` (BindToClose),
`https://devforum.roblox.com/t/unifying-data-stores-open-cloud-and-game-apis-and-increasing-storage-limits/4739240`.

Files in this skill

  • SKILL.md7.5 KB
  • references/sessions-and-limits.md4.3 KB

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

View all in ai-agents →

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.