ワンクリックで
roblox-data
DataStores, ProfileStore, session locking, data persistence patterns.
Codex または Claude でインストール この Prompt をコピーして Codex、Claude、または他のアシスタントに貼り付けると、Skill ページを確認してインストールできます。
メニュー
DataStores, ProfileStore, session locking, data persistence patterns.
Codex または Claude でインストール この Prompt をコピーして Codex、Claude、または他のアシスタントに貼り付けると、Skill ページを確認してインストールできます。
SOC 職業分類に基づく
Service hierarchy, 7 foundational patterns, cross-platform input. Client-server architecture, module patterns, framework options.
Luau language fundamentals, type system, OOP, deprecation table, error patterns.
Roblox AnalyticsService: custom events, economy tracking, funnels, rate limits, event taxonomy.
Animations, particles, tweens, ContentProvider, visual effects.
Code review with security, performance, and monetization lenses for Roblox projects
Iterative debug loop for Luau/Roblox issues
| name | roblox-data |
| description | DataStores, ProfileStore, session locking, data persistence patterns. |
| last_reviewed | "2026-05-21T00:00:00.000Z" |
Data persistence in Roblox means saving player progress so it survives across sessions. Every time a player joins, the server loads their data from the cloud; every time they leave (or periodically), it saves back.
When data flows:
Player Joins --> Server loads from DataStore --> Populate in-game objects
Player Plays --> Data lives in server memory --> Auto-save on interval
Player Leaves --> Server saves to DataStore --> Data persists for next session
Data architecture decisions:
| Approach | Best For | Complexity |
|---|---|---|
| Raw DataStoreService | Simple games, prototypes | Low |
| ProfileStore | Production games (USE THIS) | Medium |
| Custom wrapper | Specific advanced requirements | High |
Use ProfileStore for any game that will ship. Raw DataStore examples in sections 2-3 exist to explain the underlying system. Do NOT implement manual auto-save, session locking, BindToClose handlers, or retry logic - ProfileStore handles all of this automatically. Section 4 is the production pattern.
Prerequisite: Enable API Services in Roblox Studio under Game Settings > Security > Enable Studio Access to API Services. Without this, DataStore calls will fail in Studio testing.
Load Full Reference below only when you need specific implementation examples or migration patterns.
Key rules:
profile.Data.fieldName. Mutate directly, ProfileStore auto-saves.profile:Release()For production games, skip to section 4 (ProfileStore). This section exists so you understand what's underneath. Do NOT implement manual auto-save, session locking, BindToClose handlers, or retry logic - ProfileStore handles all of this.
| Method | Purpose | Notes |
|---|---|---|
GetDataStore(name) | Get/create a named DataStore | Returns DataStore object |
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 - reads current value, transforms it, writes back in one operation.
Leaderstats are IntValue/StringValue children of a Folder named "leaderstats" parented to the Player. Roblox automatically displays these on the in-game leaderboard.
local leaderstats = Instance.new("Folder")
leaderstats.Name = "leaderstats"
leaderstats.Parent = player
local cash = Instance.new("IntValue")
cash.Name = "Cash"
cash.Value = profile.Data.Cash -- populated from ProfileStore
cash.Parent = leaderstats
Leaderstats are display-only. The authoritative data lives in ProfileStore's profile.Data. Sync leaderstats back to profile on save.
ProfileStore is the community-standard library for production-grade data persistence. It solves critical problems that raw DataStore usage does not handle.
| Feature | Raw DataStore | ProfileStore |
|---|---|---|
| Session locking | Manual (hard) | Automatic |
| Auto-save | Manual | Built-in |
| Schema migration | Manual | Supported |
| Data corruption protection | None | Built-in |
| Retry logic | Manual | Built-in |
| BindToClose handling | Manual | Automatic |
With Wally (recommended):
# wally.toml
[dependencies]
ProfileStore = "madstudioroblox/profileservice@1.4.0"
Run wally install, then require from the Packages folder.
Manual: Download from GitHub and place the ProfileStore ModuleScript into ServerScriptService or ReplicatedStorage.
-- ServerScript in ServerScriptService
local Players = game:GetService("Players")
local ServerScriptService = game:GetService("ServerScriptService")
local ProfileStore = require(ServerScriptService.Packages.ProfileStore)
-- 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 store (wraps a DataStore with session locking)
local PlayerStore = ProfileStore.New("PlayerProfiles_v1", PROFILE_TEMPLATE)
-- Active profiles cache
local Profiles: { [Player]: typeof(PlayerStore:LoadProfileAsync("")) } = {}
local function onProfileLoaded(player: Player, profile)
-- Session lock: if the profile was stolen by another server, release and kick
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
-- Store and set up the player
Profiles[player] = profile
-- Example: 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 = leaderstats
end
Players.PlayerAdded:Connect(function(player: Player)
local profile = PlayerStore: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()
end
end)
-- Helper to get a player's profile from other scripts
-- Export this via a ModuleScript in production
local function getProfile(player: Player)
return Profiles[player]
end
-- In another ServerScript or ModuleScript
local function addCash(player: Player, amount: number)
local profile = getProfile(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
end
end
Without session locking, data corruption can occur during server hops:
Timeline:
t=0 Player is on Server A, data loaded
t=1 Player teleports to Server B
t=2 Server B starts loading player data from DataStore
t=3 Server A fires PlayerRemoving, starts saving data
t=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 data
Result: Player loses progress from Server A session
Session locking ensures that only one server can own a player's data at a time:
Timeline with Session Locking:
t=0 Server A loads profile, acquires session lock
t=1 Player teleports to Server B
t=2 Server B tries to load -- sees lock owned by Server A, WAITS
t=3 Server A fires PlayerRemoving, saves data, RELEASES lock
t=4 Server B detects lock released, acquires lock, loads LATEST data
Result: No data loss
LoadProfileAsync is called, ProfileStore writes a session lock tag (server JobId) to the DataStore entry.profile:Release(), the lock is cleared and the data is saved.You do NOT need to implement session locking manually. ProfileStore handles all of this. This is the primary reason to use it over raw DataStoreService.
Flat (simple games):
local PROFILE_TEMPLATE = {
Cash = 0,
Level = 1,
Wins = 0,
Losses = 0,
}
Nested (complex games):
local PROFILE_TEMPLATE = {
DataVersion = 1,
Currency = {
Cash = 0,
Gems = 0,
Tickets = 0,
},
Progression = {
Level = 1,
Experience = 0,
Prestige = 0,
},
Inventory = {
Swords = {}, -- Array of item IDs or item tables
Armor = {},
Consumables = {},
},
Quests = {
Active = {},
Completed = {},
},
Settings = {
MusicVolume = 0.5,
SFXVolume = 0.8,
ShowTutorial = true,
},
}
Always include a DataVersion field. This lets you detect and migrate old data formats.
local PROFILE_TEMPLATE = {
DataVersion = 3, -- Increment when schema changes
-- ... fields ...
}
When you add new fields, existing players won't have them. ProfileStore's Reconcile() handles this automatically - it fills in any missing fields from your PROFILE_TEMPLATE. Call it after loading:
profile:Reconcile() -- Fills missing fields from template
No manual merge code needed when using ProfileStore.
Cash is a number, never save it as a string.{ number }, not mixed).nil explicitly -- DataStore omits nil keys, which can cause confusion. Use sentinel values (e.g., 0, "", false) instead.number, string, boolean, table (arrays and dictionaries). No Instances, Vector3s, CFrames, or other Roblox types directly.When your data schema changes, you need to migrate existing player data to the new format.
DataVersion when data is loaded.DataVersion to current.-- DataMigrations module
local DataMigrations = {}
-- Each migration transforms data from version N to version N+1
local 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 data
end
-- v2 -> v3: Move settings out of flat structure into nested table
migrations[2] = function(data)
data.Settings = {
MusicVolume = data.MusicVolume or 0.5,
SFXVolume = data.SFXVolume or 0.8,
}
data.MusicVolume = nil
data.SFXVolume = nil
return data
end
-- 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 data
end
local CURRENT_VERSION = 4
function 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 data
end
return DataMigrations
-- After loading the profile, before using the data:
local profile = PlayerStore:LoadProfileAsync(`Player_{player.UserId}`, "ForceLoad")
if profile then
profile.Data = DataMigrations.migrate(profile.Data)
profile:Reconcile() -- Fill in any remaining missing defaults
end
OrderedDataStore is a special DataStore type that stores integer values and supports sorted queries. It is the standard way to build global leaderboards.
-- ServerScript in ServerScriptService
local Players = game:GetService("Players")
local DataStoreService = game:GetService("DataStoreService")
local cashLeaderboard = DataStoreService:GetOrderedDataStore("CashLeaderboard")
local LEADERBOARD_SIZE = 100
local UPDATE_INTERVAL = 120 -- seconds
-- Update a player's score in the leaderboard
local function updateLeaderboardScore(userId: number, score: number)
local success, err = pcall(function()
cashLeaderboard:SetAsync(tostring(userId), score)
end)
if not success then
warn(`[Leaderboard] Failed to update score for {userId}: {err}`)
end
end
-- Fetch the top N entries from the leaderboard
local function getTopPlayers(count: number): { { UserId: number, Score: number, Rank: number } }
local results = {}
local success, pages = pcall(function()
return cashLeaderboard:GetSortedAsync(
false, -- isAscending: false = highest first
count -- pageSize
)
end)
if not success then
warn(`[Leaderboard] Failed to fetch leaderboard: {pages}`)
return results
end
local currentPage = pages:GetCurrentPage()
local rank = 0
for _, entry in currentPage do
rank += 1
table.insert(results, {
UserId = tonumber(entry.key),
Score = entry.value,
Rank = rank,
})
end
return results
end
-- Populate a SurfaceGui or Billboard leaderboard (example with a Frame)
local function displayLeaderboard(surfaceGui: SurfaceGui, entries: { { UserId: number, Score: number, Rank: number } })
local container = surfaceGui:FindFirstChild("Container")
if not container then
return
end
-- Clear old entries
for _, child in container:GetChildren() do
if child:IsA("Frame") then
child:Destroy()
end
end
for _, entry in entries do
-- Get player name (works for offline players too)
local success, name = pcall(function()
return Players:GetNameFromUserIdAsync(entry.UserId)
end)
if success then
local row = Instance.new("Frame")
row.Name = `Rank_{entry.Rank}`
row.Size = UDim2.new(1, 0, 0, 30)
row.LayoutOrder = entry.Rank
row.Parent = container
local rankLabel = Instance.new("TextLabel")
rankLabel.Text = `#{entry.Rank}`
rankLabel.Size = UDim2.new(0.15, 0, 1, 0)
rankLabel.Parent = row
local nameLabel = Instance.new("TextLabel")
nameLabel.Text = name
nameLabel.Size = UDim2.new(0.55, 0, 1, 0)
nameLabel.Position = UDim2.new(0.15, 0, 0, 0)
nameLabel.Parent = row
local scoreLabel = Instance.new("TextLabel")
scoreLabel.Text = tostring(entry.Score)
scoreLabel.Size = UDim2.new(0.3, 0, 1, 0)
scoreLabel.Position = UDim2.new(0.7, 0, 0, 0)
scoreLabel.Parent = row
end
end
end
-- Periodic leaderboard update loop
task.spawn(function()
while true do
-- Update scores for all online players
for _, player in Players:GetPlayers() do
local leaderstats = player:FindFirstChild("leaderstats")
if leaderstats and leaderstats:FindFirstChild("Cash") then
task.spawn(updateLeaderboardScore, player.UserId, leaderstats.Cash.Value)
end
end
-- Fetch and display updated leaderboard
task.wait(5) -- Brief delay for scores to propagate
local topPlayers = getTopPlayers(LEADERBOARD_SIZE)
task.wait(UPDATE_INTERVAL)
end
end)
Important: OrderedDataStore only supports integer values. If you need decimal scores, multiply by a factor (e.g., store score * 100).
For real-time communication between servers (announcements, events, cross-server trading).
local MessagingService = game:GetService("MessagingService")
-- Subscribe to a topic
local connection = MessagingService:SubscribeAsync("GlobalAnnouncement", function(message)
local data = message.Data -- The payload
local sent = message.Sent -- Timestamp when sent (Unix time)
-- Broadcast to all players on this server
for _, player in Players:GetPlayers() do
-- Show announcement UI, etc.
end
end)
-- Publish to a topic (reaches all servers)
local success, err = pcall(function()
MessagingService:PublishAsync("GlobalAnnouncement", {
Text = "Double XP weekend starts now!",
Duration = 3600,
})
end)
MessagingService limits:
For persistent cross-server state (global counters, server-wide events):
local globalStore = DataStoreService:GetDataStore("GlobalState")
-- Atomically increment a global counter
local function incrementGlobalCounter(key: string, amount: number): number?
local success, newValue = pcall(function()
return globalStore:UpdateAsync(key, function(old)
return (old or 0) + amount
end)
end)
if success then
return newValue
end
return nil
end
-- Example: Track total enemies defeated across all servers
local totalDefeated = incrementGlobalCounter("TotalEnemiesDefeated", 1)
If using ProfileStore (recommended), sections 10.1 through 10.4 are handled automatically. You only need to worry about these if you're building on raw DataStoreService. The patterns below are shown for understanding and for the rare case where raw DataStore is appropriate.
ProfileStore handles auto-save internally. If using raw DataStore, save every 5 minutes:
local AUTO_SAVE_INTERVAL = 300
task.spawn(function()
while true do
task.wait(AUTO_SAVE_INTERVAL)
for player, _data in playerDataCache do
task.spawn(savePlayerData, player)
end
end
end)
ProfileStore saves and releases the session lock when profile:Release() is called. If using raw DataStore:
Players.PlayerRemoving:Connect(function(player: Player)
savePlayerData(player)
playerDataCache[player] = nil
end)
ProfileStore handles shutdown saves automatically. If using raw DataStore, game:BindToClose fires when the server shuts down. You have 30 seconds to save all data before the server terminates. Use task.spawn for parallel saves.
-- Only needed with raw DataStore
game:BindToClose(function()
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
task.delay(25, function() finished:Fire() end)
finished.Event:Wait()
finished:Destroy()
end)
ProfileStore has built-in retry with exponential backoff. If using raw DataStore:
local MAX_RETRIES = 3
local RETRY_DELAY = 2
local 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)
end
end
warn(`[DataStore] All retries failed for {player.Name}`)
return false
end
This applies regardless of whether you use ProfileStore or raw DataStore. Validate before writing:
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 true
end
Wrong:
-- DO NOT DO THIS: saving on every coin pickup
coinTouched:Connect(function(player)
player.Data.Cash += 1
dataStore:SetAsync(`Player_{player.UserId}`, player.Data) -- Rate limit hit
end)
Right: Modify in-memory data immediately, rely on periodic auto-save.
DataStore rate limits: 60 + numPlayers * 10 requests per minute per DataStore. With 50 players, that is 560 requests/min total -- or about 11 per player per minute. Saving once per 5 minutes uses only 0.2 per player per minute.
Wrong:
-- DO NOT DO THIS: unprotected call
local data = dataStore:GetAsync(key) -- Will error and break the script
Right:
local success, data = pcall(function()
return dataStore:GetAsync(key)
end)
if not success then
warn("DataStore error:", data)
-- Handle gracefully
end
Wrong:
-- DO NOT DO THIS: Instances are not serializable
data.Weapon = workspace.Sword -- Will fail or produce garbage
data.Character = player.Character -- Same problem
Right: Store serializable identifiers.
data.WeaponId = "IronSword"
data.EquippedSlots = { "Helmet_01", "Armor_03" }
| Limit | Value |
|---|---|
| Key name length | 50 characters |
| Value size per key | 4,194,304 bytes (4 MB) |
| DataStore name length | 50 characters |
If you're approaching 4 MB, split data across multiple keys:
-- Split by category
local coreStore = DataStoreService:GetDataStore("PlayerCore")
local inventoryStore = DataStoreService:GetDataStore("PlayerInventory")
local questStore = DataStoreService:GetDataStore("PlayerQuests")
DataStore requests are throttled per-server, not per-player:
| Operation | Budget per Minute |
|---|---|
| GetAsync | 60 + numPlayers * 10 |
| SetAsync / UpdateAsync | 60 + numPlayers * 10 |
| GetSortedAsync | 5 + numPlayers * 2 |
| SetAsync on OrderedDataStore | 5 + numPlayers * 2 |
Exceeding these results in requests being queued or erroring. Plan save intervals accordingly.
DataStore reads are eventually consistent. After a SetAsync, a GetAsync from another server may briefly return stale data. UpdateAsync on the same key is atomic within a single call, but across keys or across servers there is no transaction guarantee.
When a Roblox server shuts down, BindToClose callbacks are given at most 30 seconds to finish. After that, the server process is killed regardless. If you have many players, you MUST save in parallel using task.spawn, not sequentially.
-- BAD: Sequential saves with 50 players could take > 30 seconds
for _, player in Players:GetPlayers() do
savePlayerData(player) -- Each call might take 0.5-2 seconds
end
-- GOOD: Parallel saves complete in the time of the slowest single save
for _, player in Players:GetPlayers() do
task.spawn(savePlayerData, player)
end
task.wait(25) -- Wait with buffer
Without session locking (i.e., using raw DataStore), the following scenario causes data loss:
PlayerRemoving fires, save begins.This is why you use ProfileStore. It handles session locking automatically. If you must use raw DataStore, implement manual session locking with UpdateAsync by writing a lock field containing the server's game.JobId and checking it before loading.
PlayerRemoving does NOT fire when you press Stop in Studio. Data will not save on exit during testing unless you also test via BindToClose.local RunService = game:GetService("RunService")
local PREFIX = RunService:IsStudio() and "Dev_" or ""
local dataStore = DataStoreService:GetDataStore(`{PREFIX}PlayerData_v1`)
0/0), SetAsync/UpdateAsync will error silently or corrupt the entry. Validate numeric fields.{} can deserialize as either an array or a dictionary depending on context. Be consistent."Player_123" and "player_123" are different keys. Standardize your key format.UpdateAsync must be pure (no yields, no side effects). It may be called multiple times if there is contention. Return nil to cancel the update.