Migrating a Live Roblox Game From a Custom DataStore Module to ProfileStore Without Losing Player Data

Do you know which of your live servers are still running the old DataStore module right now? If you are about to swap persistence layers in a game with real players, that question matters more than any line of ProfileStore code you will write.
A homegrown DataStore layer usually starts as a few hundred lines of GetAsync, SetAsync, and retry loops, and over time it picks up session flags, autosave timers, and schema patches. ProfileStore replaces most of that with session locking, autosave, and reconciliation you no longer have to maintain. That said, the hard part of the move is not the new module but the handoff, because every player's data has to cross from one format to another while servers on both versions may be running at once.
This guide covers the mechanics we use for that handoff. It walks through key mapping, lazy migration on join, version flags, draining old servers, and a rollback path that holds up under real traffic. If you need a refresher on the underlying service first, start with our breakdown of how Roblox DataStores work.
What Actually Changes When You Move To ProfileStore?
Before you write any migration code, it helps to be precise about what ProfileStore does differently from your module. Most migration bugs come from assuming the two layers store data the same way.
ProfileStore wraps your player table inside its own record with session and metadata fields. Your legacy keys hold raw tables, so the two formats cannot share a key and must be bridged with an explicit copy step.
ProfileStore sessions are opened with StartSessionAsync, and your game state lives under profile.Data. Meanwhile, the module handles session locking and conflict resolution between servers, which is the job most custom layers do least reliably. If your old module had a hand-rolled lock, our guide to Roblox session locking explains the failure modes ProfileStore is designed to close.
The practical consequences fall into a few areas, including but not limited to:
- Storage format. ProfileStore writes a structured record rather than your bare table. Pointing it at a key your old module wrote invites a format mismatch, so use a new store name.
- Save cadence. ProfileStore autosaves on its own timer and saves again when a session ends. Any code that called SetAsync on every coin pickup should be deleted, not ported.
- Session ownership. Only one server holds a profile's session at a time. As a result, the migration copy can safely run inside that session without racing other ProfileStore servers.
All of these point toward the same design. The new store should live beside the old one, and data should move across in a controlled, logged, reversible step.
Map Your Legacy Keys Before You Write Any Code
Key mapping is the least exciting part of the job and the one most likely to cause silent data loss. If your old module used "Player_" .. userId in one release and tostring(userId) in an earlier one, some players have data under a key your new code will never look at.
Start by auditing every key format your module has ever written. Check your Git history (our guide to Rojo Git workflows covers keeping that history usable), and list every store name, key pattern, and scope the module touched.
Here's how the two sides typically line up:
| Concern | Legacy custom module | ProfileStore target |
|---|---|---|
| Store name | PlayerData (plus any older stores) | PlayerData_v2 (new, empty at launch) |
| Key pattern | Often mixed across releases | One pattern, such as Player_ plus UserId |
| Record shape | Raw table, sometimes with lock fields | ProfileStore record wrapping profile.Data |
| Schema tracking | Implicit or missing | Explicit SchemaVersion field |
| Session lock | Hand-rolled or none | Built in |
| After cutover | Read-only, retained, mirrored | Source of truth |
Once the audit is done, encode it in one function. Every legacy read in the migration should go through it, so a missed key pattern is a one-line fix instead of a hunt.
local function legacyKeysFor(userId)
-- Newest pattern first; older releases wrote the bare id
return { "Player_" .. userId, tostring(userId) }
end
Use a new DataStore name for ProfileStore and leave the legacy store untouched. A separate store keeps old records readable for migration and rollback, and stops the two formats from ever colliding on one key.
How Lazy Migration On Join Works
Lazy migration moves each player's data the first time they join a server running the new code. Rather than running a bulk job against millions of keys, the copy happens inside a ProfileStore session that already guarantees no other server owns that player.
The trick is a migration state field in your template. New profiles start as "pending", and the first session resolves that state to either "migrated" or "fresh" depending on whether a legacy record exists.
local ProfileStore = require(ServerScriptService.ProfileStore)
local DataStoreService = game:GetService("DataStoreService")
local TEMPLATE = {
Coins = 0,
Inventory = {},
SchemaVersion = 2,
MigrationState = "pending",
MigratedAt = 0,
}
local PlayerStore = ProfileStore.New("PlayerData_v2", TEMPLATE)
local LegacyStore = DataStoreService:GetDataStore("PlayerData")
Next, the migration function reads the legacy record, transforms it, and resolves the flag. Note that a DataStore error returns false rather than marking the profile, which keeps the player in a retryable state.
local function migrateFromLegacy(profile, userId)
if profile.Data.MigrationState ~= "pending" then
return true
end
for _, key in legacyKeysFor(userId) do
local ok, legacy = pcall(function()
return LegacyStore:GetAsync(key)
end)
if not ok then
return false -- transient failure: retry on next join
end
if legacy ~= nil then
applyLegacy(profile.Data, legacy)
profile.Data.MigrationState = "migrated"
profile.Data.MigratedAt = os.time()
return true
end
end
profile.Data.MigrationState = "fresh"
return true
end
The join handler then wires it together. Keep in mind that the profile is not handed to gameplay code until migration succeeds.
Players.PlayerAdded:Connect(function(player)
local profile = PlayerStore:StartSessionAsync("Player_" .. player.UserId, {
Cancel = function()
return player.Parent ~= Players
end,
})
if profile == nil then
player:Kick("Data failed to load - please rejoin")
return
end
profile:AddUserId(player.UserId)
profile:Reconcile()
profile.OnSessionEnd:Connect(function()
Profiles[player] = nil
player:Kick("Data session ended - please rejoin")
end)
if not migrateFromLegacy(profile, player.UserId) then
profile:EndSession()
return
end
if player.Parent == Players then
Profiles[player] = profile
else
profile:EndSession()
end
end)
If the legacy read fails, end the session and kick the player instead of letting them play on template data. A pending profile that earns progress would be overwritten when the migration finally runs on a later join.
That kick feels harsh, but it is the correct trade. A player who rejoins after a 30-second outage loses nothing, while a player who plays on a blank template and gets migrated later loses an entire session of progress.
Why A Version Flag Belongs In Every Profile
The applyLegacy function is where your old schema history comes back to haunt you. If the custom module went through three data shapes, some players are still sitting on version one.
This is why the transform should branch on the legacy record's own version, then stamp the new profile with an explicit SchemaVersion. Every future schema change then becomes a numbered step instead of a guess. For structuring those step functions cleanly, our Luau scripting patterns guide covers the module layout we prefer.
In practice, you will want two kinds of flags working together:
- Per-profile schema version. A number inside
profile.Datathat records which shape the data is in. Upgrade functions run in order from the stored version to the current one, so a player who skipped six months of updates still lands on the right shape. - Per-server backend flag. A setting read at server start that decides whether this server uses ProfileStore, the legacy module, or ProfileStore with mirroring. Storing it in a small config DataStore key lets you change behavior on new servers without publishing.
Overall, the schema flag protects the shape of each player's data, and the backend flag protects your ability to change course. Neither one is optional in a live migration.
Store a SchemaVersion number inside every profile and upgrade it in numbered steps. Players who skipped several updates then pass through each transform in order instead of hitting code written for a shape they never had.
Draining The Old Servers Without A Gap
The most dangerous window is the one where old and new servers run at the same time. A player can leave an old server, which saves to the legacy store, and join a new server a few seconds later, which reads that legacy record before the save lands.
There are two defenses, and we recommend using both. First, ship a final legacy release before the cutover that writes a tombstone check: when the old module sees a MigratedTo field on a record, it stops writing to that key.
Then have the migration stamp that field atomically. Swapping the read for UpdateAsync lets you read the legacy record and mark it in one operation:
LegacyStore:UpdateAsync(key, function(old)
if old == nil then
return nil -- nothing to migrate, leave key absent
end
legacy = old
old.MigratedTo = "PlayerData_v2"
return old
end)
Second, use the Creator Hub's migrate-to-latest-update option for the place rather than waiting for old servers to empty out on their own. Moreover, if your old module kept its own session lock with a timestamp, the migration should treat a fresh lock as a reason to retry rather than a value to trust.
Keep in mind that teleports complicate this. A player teleporting between places mid-rollout can land on an old server in another place, so publish every place in the experience together, and review our TeleportService guide if your game moves players often.
Building A Rollback Path You Can Actually Use
A rollback path that only restores old code is not a rollback path. If you revert to the legacy module after three days, every coin, item, and level earned under ProfileStore disappears unless the legacy store has been kept current.
The fix is a mirror write. While the backend flag is set to mirror mode, each session end also writes a legacy-shaped copy of the data back to the old key:
local function mirrorToLegacy(userId, data)
local ok, err = pcall(function()
LegacyStore:UpdateAsync("Player_" .. userId, function(old)
return toLegacyShape(data, old)
end)
end)
if not ok then
warn("Mirror write failed", userId, err)
end
end
Call it from PlayerRemoving before EndSession, and from BindToClose for players still in the server at shutdown. The toLegacyShape function must keep the MigratedTo tombstone, or an old server could start writing over the key again.
Be aware that mirror writes spend DataStore write budget, which scales at roughly 60 plus 10 per player per minute for each server. Since mirroring only runs at session end, the overhead is usually small, but log failures so you know how stale the legacy copy could be.
A usable rollback needs current data in the legacy store, not just old code. Mirror a legacy-shaped copy on every session end during the transition so reverting loses at most one session of progress.
The rollback build itself is the legacy module with its tombstone check switched off. Once mirroring has run for a few weeks with no incidents, you can retire it, but keep the legacy store itself; deleting it gains you nothing and removes your last fallback.
How Do You Know The Migration Is Working?
You cannot judge a data migration by the absence of complaints. Players who lose progress often just leave, so you need numbers before you need bug reports.
Some of the signals worth logging per server include:
- Migration outcomes. Counts of migrated, fresh, and retried joins. A sudden rise in fresh profiles among accounts older than your cutover date usually means a missed key pattern.
- Load failures. How often
StartSessionAsyncreturns nil or the legacy read fails. Those should track overall DataStore health, not your deploy timeline. - Value spot checks. For a sample of migrated players, compare key totals such as coins and item counts against the legacy record. If your inventory has nested structures, our Roblox inventory systems guide shows how to normalize them for comparison.
- Mirror write failures. Any nonzero rate means your rollback copy is drifting. Investigate before you need it.
All of these can go to AnalyticsService custom events or your own logging endpoint. What matters is that you set expected ranges before the flip, so a bad number is obvious on day one.
A Rollout Sequence For A Live Game
Putting the pieces together, the order of operations matters as much as the code. Here's the sequence we follow:
- Ship the tombstone-aware legacy release. Old servers learn to respect
MigratedTobefore any new server exists. - Test against ProfileStore's mock store. Seed legacy-shaped fixtures for every historical schema version and confirm each one lands correctly.
- Publish the ProfileStore build in mirror mode. Use migrate-to-latest-update across every place in the experience at once.
- Watch the migration metrics for at least a week. Pay particular attention to returning players from older versions.
- Turn off mirroring once the numbers hold. Keep the legacy store and the rollback build archived.
After all, players who skipped the update window will keep arriving for months. Lazy migration handles them whenever they return, which is exactly why the legacy store stays in place long after the code that wrote it is gone.
Keep the legacy store permanently and migrate players as they return. Lapsed players can come back months later, and lazy migration on join handles them without a bulk job or a deadline.
Planning Your Own Cutover
A persistence migration is one of the few changes in a Roblox game where a mistake cannot be patched out later. If the key map, the version flags, and the rollback path are in place before the flip, the cutover itself becomes routine.
If your game also syncs state between servers during the transition, our guide to cross-server messaging covers the patterns that pair well with ProfileStore. Remember that the safest migration is the one you can reverse, so build the reverse path first.


