Roblox Leaderboard Save Data Script (DataStore Guide)
Last updated:
Quick answer: Create your leaderstats folder, load the saved values with `GetAsync` inside a `pcall` when the player joins, and write them back with `UpdateAsync` in `PlayerRemoving` and `game:BindToClose`. If the load fails, never save over the key, or you will wipe the player's progress.
To save a Roblox leaderboard, you create the leaderstats folder when a player joins, fill it with values loaded from a DataStore, and write those values back when the player leaves and when the server shuts down. Every DataStore call can fail, so each one goes inside a pcall, and a failed load must block saving for that player. That last rule is the one most copy-pasted scripts get wrong.
This guide builds one complete script you can drop into ServerScriptService. It assumes you already know how a leaderstats folder is built; if not, read the leaderstats tutorial first.
What do you need before saving anything?
Turn on Studio access to the API: in Game Settings, open Security and enable "Enable Studio Access to API Services". Without it, DataStore calls in Studio throw errors. Your place also has to be published, since DataStores are tied to a live experience.
The data itself should be one table per player, saved under one key. Do not use a separate key per stat. A single key means one request instead of three, and the data can never end up half-saved.
How do you load data safely?
DataStore requests go over the network, so they can fail at any time. pcall runs a function and hands you back whether it worked instead of crashing your script. If you have not met it yet, the pcall explainer covers the basics.
Here is a small retry helper that every DataStore call in the script will go through:
local function withRetry(attempts: number, fn: () -> any): (boolean, any)
local lastError
for i = 1, attempts do
local ok, result = pcall(fn)
if ok then
return true, result
end
lastError = result
warn(string.format("DataStore attempt %d/%d failed: %s", i, attempts, tostring(result)))
task.wait(2 ^ i) -- back off: 2s, 4s, 8s
end
return false, lastError
end
Now the setup, with defaults and an in-memory table that holds the data of players whose load succeeded:
local Players = game:GetService("Players")
local DataStoreService = game:GetService("DataStoreService")
local store = DataStoreService:GetDataStore("PlayerData_v1")
local DEFAULTS = { Coins = 0, Level = 1 }
-- Only players whose data loaded successfully are in this table.
local loaded: { [Player]: boolean } = {}
Next, load and build the leaderboard:
local function keyFor(player: Player): string
return "Player_" .. player.UserId
end
local function onPlayerAdded(player: Player)
local ok, data = withRetry(3, function()
return store:GetAsync(keyFor(player))
end)
if not ok then
-- Do NOT mark as loaded. Kicking is safer than letting them play on blank data.
player:Kick("We could not load your data. Please rejoin in a minute.")
return
end
data = data or {}
local leaderstats = Instance.new("Folder")
leaderstats.Name = "leaderstats"
local coins = Instance.new("IntValue")
coins.Name = "Coins"
coins.Value = data.Coins or DEFAULTS.Coins
coins.Parent = leaderstats
local level = Instance.new("IntValue")
level.Name = "Level"
level.Value = data.Level or DEFAULTS.Level
level.Parent = leaderstats
leaderstats.Parent = player
loaded[player] = true
end
GetAsync returns nil for a brand-new player, which is not an error. The data or {} line plus the defaults handle that case. Also note that values are created before the folder is parented, so the leaderboard never shows half-built.
How do you save the leaderstats?
Use UpdateAsync instead of SetAsync for saves. It hands you the current stored value and lets you return the new one, which makes it the right tool the moment you add more fields or more than one writer.
local function savePlayer(player: Player)
if not loaded[player] then
return -- load failed or never finished: saving now would erase their data
end
local leaderstats = player:FindFirstChild("leaderstats")
if not leaderstats then
return
end
local snapshot = {
Coins = leaderstats.Coins.Value,
Level = leaderstats.Level.Value,
}
local ok, err = withRetry(3, function()
return store:UpdateAsync(keyFor(player), function(old)
return snapshot
end)
end)
if not ok then
warn("Could not save " .. player.Name .. ": " .. tostring(err))
end
end
Take the snapshot before the first attempt, as shown. When a player leaves, their instances can be destroyed while a retry is waiting, and reading .Value on a destroyed folder is another way to lose a save.
When should the save actually run?
Hook up three events: join, leave, and shutdown.
Players.PlayerAdded:Connect(onPlayerAdded)
-- Handle players who joined before this script connected.
for _, player in Players:GetPlayers() do
task.spawn(onPlayerAdded, player)
end
Players.PlayerRemoving:Connect(function(player)
savePlayer(player)
loaded[player] = nil
end)
game:BindToClose(function()
local pending = 0
for _, player in Players:GetPlayers() do
pending += 1
task.spawn(function()
savePlayer(player)
pending -= 1
end)
end
while pending > 0 do
task.wait()
end
end)
BindToClose is the piece people leave out. When Roblox shuts a server down, PlayerRemoving can fire so late that the process ends before the writes complete. The shutdown callback delays closing for up to 30 seconds, so saving every remaining player in parallel (one task.spawn each) fits comfortably in that window. Saving them one after another does not. If you want to understand how task.spawn behaves here, see task.spawn, defer and delay.
What about session locking?
Picture a player who leaves server A and joins server B within seconds. If server A's save is slow and server B has already loaded the old data, whichever write lands last wins, and one of them is stale. That is a lost purchase or a duplicated item.
Session locking solves this by writing a small "owned by server X" marker with the data. A server only loads when the lock is free or expired, and it releases the lock when saving on leave. Doing this correctly with UpdateAsync, lock expiry, and crash recovery is fiddly. For anything involving paid currency or tradable items, use a tested library like ProfileStore instead of rolling your own. For a prototype or a simple points leaderboard, the script above is a reasonable start, as long as you know the limit.
Common mistakes
- Saving after a failed load. The default empty table overwrites the real data. The
loadedcheck above exists for exactly this. - Using SetAsync with nothing to compare. It blindly replaces whatever is stored. Prefer UpdateAsync.
- Skipping pcall. One throttled request then kills the whole handler and the player's progress is gone.
- Reading values from the leaderstats after the player is gone. Snapshot first.
- Saving on every change. You will hit the per-key rate limit and see throttling warnings.
- Storing Instances or mixed-key tables. DataStores only accept plain data: numbers, strings, booleans, and tables of those with consistent keys.
- Changing the store name later.
PlayerData_v1is a different store fromPlayerData_v2. Treat the name as part of your save format.
If your data still is not persisting after all this, the DataStore not saving fix walks through a checklist of causes.
Practice it
Reading about retries is easy, and writing them is where it clicks. On Luablox you can practice pcall, tables, and event handlers in interactive exercises, with the theory lessons and practice problems for more reps.
Related guides: DataStore basics, leaderstats tutorial, what is pcall in Luau, and the DataStore not saving fix.
Frequently asked questions
Do I need BindToClose if I already save on PlayerRemoving?
Yes. When a server shuts down, the last players may not get their PlayerRemoving handlers finished before the process ends. BindToClose gives you up to 30 seconds to finish saves, so loop over the remaining players and save them there too.
Why does DataStore work in Studio but not in my live game?
In Studio you must turn on "Enable Studio Access to API Services" under Game Settings, Security, and the place must be published. If it works in Studio but fails live, compare the store name and key, and check the server output for throttling warnings.
What is session locking and do I need it?
Session locking makes sure only one server writes a player's data at a time, so a quick rejoin to a second server cannot overwrite newer data with older data. Small games often skip it, but anything with currency or items should use it. The simplest route is a maintained library such as ProfileStore rather than writing your own.
Should I save every time a stat changes?
No. DataStore calls are rate limited per key, roughly one write every few seconds is fine but one per change will throttle you. Keep the live values in memory, save on leave and shutdown, and add an autosave every few minutes if you like.
Understand Roblox DataStoreService patterns to save player data, implement safety checks, and handle load failures.