Use this file to discover all available pages before exploring further.
Saving player progress in Roblox means reading from and writing to Roblox’s cloud key-value store (DataStoreService) on every session boundary. While the raw API is straightforward, using SetAsync directly is dangerous in production: it offers no protection against race conditions during server hops, has no built-in retry logic, and gives you no schema migration story. Production games almost universally layer ProfileService on top to get session locking, auto-save, and data reconciliation for free.
SE-1 — DataStore data loss from improper session locking. Without session locking, a player who teleports between servers can lose all progress from the first session. Server B loads stale data before Server A finishes saving, then Server B’s stale copy overwrites Server A’s write. Always use ProfileService (or manual UpdateAsync-based locking) in any game where players can be on more than one server.
DataStoreService stores JSON-serializable values under string keys. Each key belongs to a named DataStore. The four core methods are:
Method
Purpose
Notes
GetAsync(key)
Read a value
Returns nil if key doesn’t exist
SetAsync(key, value)
Write a value (overwrites)
No conflict protection
UpdateAsync(key, callback)
Atomic read-modify-write
Preferred for saves
RemoveAsync(key)
Delete a key
Returns the old value
UpdateAsync is preferred over SetAsync because it is atomic — it reads the current value, lets you transform it, and writes it back in a single operation, preventing two servers from overwriting each other.Every DataStore call must be wrapped in pcall because network errors are a normal operating condition:
-- ServerScript in ServerScriptServicelocal Players = game:GetService("Players")local DataStoreService = game:GetService("DataStoreService")local playerDataStore = DataStoreService:GetDataStore("PlayerData_v1")-- Default data for new playerslocal DEFAULT_DATA = { Cash = 0, Level = 1, Experience = 0, Inventory = {},}-- In-memory cache: Player -> data tablelocal playerDataCache: { [Player]: { [string]: any } } = {}local function loadPlayerData(player: Player): { [string]: any }? local key = "Player_" .. player.UserId local success, result = pcall(function() return playerDataStore:GetAsync(key) end) if not success then warn(`[DataStore] Failed to load data for {player.Name}: {result}`) return nil end -- Merge with defaults so new fields get default values local data = result or {} for field, default in DEFAULT_DATA do if data[field] == nil then data[field] = default end end return dataendlocal function savePlayerData(player: Player): boolean local data = playerDataCache[player] if not data then return false end local key = "Player_" .. player.UserId local success, err = pcall(function() playerDataStore:UpdateAsync(key, function(_oldData) return data end) end) if not success then warn(`[DataStore] Failed to save data for {player.Name}: {err}`) return false end return trueend-- Load on joinPlayers.PlayerAdded:Connect(function(player: Player) local data = loadPlayerData(player) if not data then -- Kick if data fails to load to prevent overwriting with defaults player:Kick("Failed to load your data. Please rejoin.") return end playerDataCache[player] = data -- Set up leaderstats, UI, etc. using dataend)-- Save on leavePlayers.PlayerRemoving:Connect(function(player: Player) savePlayerData(player) playerDataCache[player] = nilend)-- Auto-save every 5 minutestask.spawn(function() while true do task.wait(300) for player, _data in playerDataCache do task.spawn(savePlayerData, player) end endend)
Enable Game Settings > Security > Enable Studio Access to API Services in Roblox Studio, or every DataStore call will fail during Studio testing.
ProfileService is the community-standard library for production-grade persistence. It solves every critical problem that raw DataStore usage leaves open.
-- ServerScript in ServerScriptServicelocal Players = game:GetService("Players")local ServerScriptService = game:GetService("ServerScriptService")local ProfileService = require(ServerScriptService.Packages.ProfileService)-- Adjust the require path based on where you installed it-- Define the profile template (default data for new players)local PROFILE_TEMPLATE = { DataVersion = 1, Cash = 0, Level = 1, Experience = 0, Inventory = {}, Settings = { MusicVolume = 0.5, SFXVolume = 0.8, }, Statistics = { TotalPlayTime = 0, GamesPlayed = 0, },}-- Create the ProfileStore (wraps a DataStore)local ProfileStore = ProfileService.GetProfileStore("PlayerProfiles_v1", PROFILE_TEMPLATE)-- Active profiles cachelocal Profiles: { [Player]: typeof(ProfileStore:LoadProfileAsync("")) } = {}local function onProfileLoaded(player: Player, profile) profile:AddUserId(player.UserId) -- GDPR compliance profile:Reconcile() -- Fills in missing fields from PROFILE_TEMPLATE profile:ListenToRelease(function() Profiles[player] = nil player:Kick("Your data was loaded on another server. Please rejoin.") end) -- Check if player is still in game (they may have left during async load) if not player:IsDescendantOf(Players) then profile:Release() return end Profiles[player] = profile -- Set up leaderstats from profile data local leaderstats = Instance.new("Folder") leaderstats.Name = "leaderstats" leaderstats.Parent = player local cash = Instance.new("IntValue") cash.Name = "Cash" cash.Value = profile.Data.Cash cash.Parent = leaderstats local level = Instance.new("IntValue") level.Name = "Level" level.Value = profile.Data.Level level.Parent = leaderstatsendPlayers.PlayerAdded:Connect(function(player: Player) local profile = ProfileStore:LoadProfileAsync( "Player_" .. player.UserId, "ForceLoad" -- Wait until the session lock is acquired ) if profile == nil then player:Kick("Unable to load your data. Please rejoin.") return end onProfileLoaded(player, profile)end)Players.PlayerRemoving:Connect(function(player: Player) local profile = Profiles[player] if profile then -- Sync leaderstats back to profile before release local leaderstats = player:FindFirstChild("leaderstats") if leaderstats then profile.Data.Cash = leaderstats.Cash.Value profile.Data.Level = leaderstats.Level.Value end profile:Release() endend)
-- In another ServerScript or ModuleScriptlocal function addCash(player: Player, amount: number) local profile = Profiles[player] if not profile then return end profile.Data.Cash += amount -- Also update leaderstats if visible local leaderstats = player:FindFirstChild("leaderstats") if leaderstats and leaderstats:FindFirstChild("Cash") then leaderstats.Cash.Value = profile.Data.Cash endend
Without session locking, data corruption occurs during server hops:
t=0 Player is on Server A, data loadedt=1 Player teleports to Server Bt=2 Server B starts loading player data from DataStoret=3 Server A fires PlayerRemoving, starts saving datat=4 Server B finishes loading (gets STALE data)t=5 Server A finishes saving (writes LATEST data)t=6 Server B eventually saves its stale copy, OVERWRITING the latest dataResult: Player loses progress from Server A session
Session locking ensures that only one server can own a player’s data at a time:
t=0 Server A loads profile, acquires session lockt=1 Player teleports to Server Bt=2 Server B tries to load -- sees lock owned by Server A, WAITSt=3 Server A fires PlayerRemoving, saves data, RELEASES lockt=4 Server B detects lock released, acquires lock, loads LATEST dataResult: No data loss
When LoadProfileAsync is called, ProfileService writes a session lock tag (the server’s JobId) to the DataStore entry.
2
Wait or steal
If another server already holds the lock, ProfileService either waits ("ForceLoad") or gives up ("Steal").
3
Refresh lock
The locking server periodically refreshes the lock via its internal auto-save.
4
Release
On profile:Release(), the lock is cleared and the data is saved atomically.
5
Expiry
If a server crashes without releasing, the lock expires after ~30 minutes and another server can claim it.
You do not need to implement session locking manually. ProfileService handles all of this. This is the primary reason to use it over raw DataStoreService.
-- DataMigrations modulelocal DataMigrations = {}-- Each migration transforms data from version N to version N+1local migrations: { [number]: (data: { [string]: any }) -> { [string]: any } } = {}-- v1 -> v2: Split "Money" into "Cash" and "Gems"migrations[1] = function(data) if data.Money then data.Cash = data.Money data.Gems = 0 data.Money = nil end return dataend-- v2 -> v3: Move settings into nested tablemigrations[2] = function(data) data.Settings = { MusicVolume = data.MusicVolume or 0.5, SFXVolume = data.SFXVolume or 0.8, } data.MusicVolume = nil data.SFXVolume = nil return dataend-- v3 -> v4: Add Quests system and rename "Wins" to "Statistics.Wins"migrations[3] = function(data) data.Quests = { Active = {}, Completed = {}, } data.Statistics = data.Statistics or {} data.Statistics.Wins = data.Wins or 0 data.Wins = nil return dataendlocal CURRENT_VERSION = 4function DataMigrations.migrate(data: { [string]: any }): { [string]: any } local version = data.DataVersion or 1 if version > CURRENT_VERSION then warn(`[Migration] Data version {version} is newer than code version {CURRENT_VERSION}`) return data end while version < CURRENT_VERSION do local migrator = migrations[version] if migrator then data = migrator(data) print(`[Migration] Migrated data from v{version} to v{version + 1}`) end version += 1 end data.DataVersion = CURRENT_VERSION return dataendreturn DataMigrations
-- After loading the profile, before using the data:local profile = ProfileStore:LoadProfileAsync("Player_" .. player.UserId, "ForceLoad")if profile then profile.Data = DataMigrations.migrate(profile.Data) profile:Reconcile() -- Fill in any remaining missing defaultsend
game:BindToClose fires when the server is shutting down. You have 30 seconds before the process is killed. Always save in parallel using task.spawn — sequential saves with 50 players can easily exceed the time limit.
-- Complete BindToClose handlergame:BindToClose(function() -- In Studio, don't block long if game:GetService("RunService"):IsStudio() then task.wait(1) return end local finished = Instance.new("BindableEvent") local allPlayers = Players:GetPlayers() local remaining = #allPlayers if remaining == 0 then return end for _, player in allPlayers do task.spawn(function() savePlayerData(player) remaining -= 1 if remaining <= 0 then finished:Fire() end end) end -- Wait for all saves to complete, with a 25-second buffer before hard limit task.delay(25, function() finished:Fire() end) finished.Event:Wait() finished:Destroy()end)
BAD — sequential saves will time out with many players:
for _, player in Players:GetPlayers() do savePlayerData(player) -- Each call may take 0.5-2 secondsend
GOOD — parallel saves complete in the time of the slowest single save:
for _, player in Players:GetPlayers() do task.spawn(savePlayerData, player)endtask.wait(25)
local MAX_RETRIES = 3local RETRY_DELAY = 2local function saveWithRetry(player: Player): boolean for attempt = 1, MAX_RETRIES do local success = savePlayerData(player) if success then return true end if attempt < MAX_RETRIES then warn(`[DataStore] Retry {attempt}/{MAX_RETRIES} for {player.Name}`) task.wait(RETRY_DELAY * attempt) -- Exponential-ish backoff end end warn(`[DataStore] All retries failed for {player.Name}`) return falseend
local function validateData(data: { [string]: any }): boolean if typeof(data) ~= "table" then return false end if typeof(data.Cash) ~= "number" or data.Cash < 0 then return false end if typeof(data.Level) ~= "number" or data.Level < 1 then return false end return trueendlocal function safeSave(player: Player) local data = playerDataCache[player] if not data or not validateData(data) then warn(`[DataStore] Invalid data for {player.Name}, skipping save`) return end savePlayerData(player)end
DataStore requests are budgeted at 60 + numPlayers × 10 per minute per DataStore. With 50 players that is 560 req/min total. Saving on every coin pickup will exhaust this instantly.Wrong:
Right: Modify in-memory data immediately, rely on periodic auto-save every 5 minutes.
Storing non-serializable types
DataStore serializes to JSON internally. Only number, string, boolean, and plain table values survive the round trip. Never store Instance references, Vector3, CFrame, or other Roblox types.
Unwrapped DataStore calls will throw errors and break your script on any transient network failure.
-- WRONGlocal data = dataStore:GetAsync(key) -- will error and break the script-- RIGHTlocal success, data = pcall(function() return dataStore:GetAsync(key)end)if not success then warn("DataStore error:", data)end
PlayerRemoving does not fire in Studio on Stop
If you press Stop in Studio, PlayerRemoving does not fire. Data will not save unless you also handle BindToClose in your tests. Use a Play Solo session and let the game end naturally, or add an explicit save button during development.
Key size limits
Limit
Value
Key name length
50 characters
Value size per key
4 MB (4,194,304 bytes)
DataStore name length
50 characters
If you approach 4 MB, split data across multiple named DataStores by category (PlayerCore, PlayerInventory, PlayerQuests).