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

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.

Related pages