Skip to content
ENSign in

How do I save player data in Roblox?

Keep everything about a player in one table, load it on join, write it with UpdateAsync when they leave, and write it again in BindToClose so a shutdown does not eat the session. Add a version number before you ship: data you cannot migrate is data you can never change.

In short

  • One key per player, one table. Not one DataStore per stat.
  • UpdateAsync, not SetAsync: two servers can hold the same player.
  • BindToClose, or every shutdown loses whatever was unsaved.

Lost save data is the one bug players never forgive. It is also the one bug that is almost always the same three mistakes: a stat saved in its own key, a SetAsync racing another server, and no BindToClose. Fix those three and the rest is detail.

Steps

1. One table, with a version

The version is four characters that cost nothing today and save the game later. Without it you cannot rename a field without guessing what old rows look like.

local DataStoreService = game:GetService("DataStoreService")
local store = DataStoreService:GetDataStore("PlayerData_v1")

local VERSION = 1

local function blank()
	return {
		version = VERSION,
		coins = 0,
		level = 1,
		pets = {},
		settings = { music = true },
	}
end

2. Load on join, and never trust the shape

A row written by last month's build is a row with last month's fields. Fill the gaps rather than indexing into nothing.

local Players = game:GetService("Players")
local cache: { [number]: any } = {}

local function migrate(data)
	if type(data) ~= "table" then return blank() end
	local fresh = blank()
	for k, v in fresh do
		if data[k] == nil then
			data[k] = v
		end
	end
	data.version = VERSION
	return data
end

Players.PlayerAdded:Connect(function(player: Player)
	local ok, data = pcall(function()
		return store:GetAsync("u_" .. player.UserId)
	end)
	-- A failed READ must not hand the player a blank save: that is how a
	-- level-40 account becomes level 1. Kick instead, with the reason.
	if not ok then
		player:Kick("Could not load your save. Rejoin in a minute.")
		return
	end
	cache[player.UserId] = migrate(data)
end)

3. Write with UpdateAsync, and only when there is something to write

UpdateAsync reads and writes in one operation, so the second server to touch the key sees the first one's result. SetAsync does not, and that is the whole difference.

local function save(userId: number)
	local data = cache[userId]
	if not data then return end
	local ok, err = pcall(function()
		store:UpdateAsync("u_" .. userId, function(old)
			-- Older session, newer row: keep the row. This happens on a teleport.
			if type(old) == "table" and (old.savedAt or 0) > (data.savedAt or 0) then
				return old
			end
			data.savedAt = os.time()
			return data
		end)
	end)
	if not ok then
		warn(`[data] save failed for {userId}: {err}`)
	end
end

Players.PlayerRemoving:Connect(function(player: Player)
	save(player.UserId)
	cache[player.UserId] = nil
end)

4. Catch the shutdown

Roblox gives a closing server a few seconds. Use them, or every deploy throws away whatever happened since the last write.

game:BindToClose(function()
	for _, player in Players:GetPlayers() do
		-- task.spawn so one slow key does not use up the whole window.
		task.spawn(save, player.UserId)
	end
	-- Give the writes a moment to land. In Studio this returns immediately.
	task.wait(3)
end)

Which write to use

CallSafe with two serversUse it for
UpdateAsyncyesEvery save of player data. The default
SetAsyncnoOnly data nothing else writes — a one-off admin tool
GetAsyncn/aLoading, once per join, and never in a loop
IncrementAsyncyesA pure counter, when you never need to read the rest
GetOrderedDataStoreyesLeaderboards only. It stores numbers, not tables

FAQ

Should I save on a timer as well?

Yes, every two or three minutes per player, through the same save function. PlayerRemoving and BindToClose cover a clean exit and a shutdown; a crash is covered only by the timer. Keep the interval above 60 seconds: the request budget is about 60 writes a minute for the whole server.

Why did my data come back empty once?

Almost always a failed GetAsync treated as "new player". The pcall above distinguishes the two: an error is an error, and the player gets kicked with a reason instead of a blank account. nil with no error really is a new player.

Do I need ProfileService or DataStore2?

No, but they exist because the code above is easy to get subtly wrong — session locking especially, when a player teleports between places. If your game has places, use a library; if it is one place, the pattern above is enough.

Can I keep a player's data in a folder of IntValues instead?

As a live mirror, yes, and it makes the GUI trivial. As the source of truth, no: the folder dies with the server. Write from the table to the folder on join and from the folder to the table on change, and keep the table as the thing that gets saved.

Updated: Tested in Studio: Roblox Studio, October 2026Author: GustavoLicense: Code in this guide may be used in any Roblox experience, including a paid one

Get notified

Coming soon

Drop your email and we'll ping you the moment this pack goes live.