Appearance
Legacy Data Migration
Move player data from an old system into PlayerState during profile load.
Migration Is Opt-In
Migration is disabled by default. It only runs when LegacyDataStore.enabled = true.
Start Here
| If you need to... | Go to |
|---|---|
| Enable migration fast | Quick Start |
Understand store vs keys and {UserId} | How store and keys work |
| Choose a strategy | Strategy Picker |
| Copy config snippets | Config Reference |
| Track outcomes in logs | MigrationResult Signal |
| Validate behavior end-to-end | Testing |
| Debug issues quickly | Troubleshooting |
Quick Start
Use this first:
lua
LegacyDataStore = {
enabled = true,
store = "OldStore",
strategy = "Auto",
keys = { "{UserId}" }, -- {UserId} auto-replaced with player's UserId
}Expected behavior:
- First join after enabling migration: legacy data is merged, marker is written.
- Next joins: migration skips for that user because marker already exists.
Beginner Path (1 minute)
If you are migrating from an older system, use this exact flow:
- Set
enabled = true - Set
storeto your old DataStore name - Start with
strategy = "Auto" - Start with
keys = { "{UserId}" } - Test in a safe place/scope before production
If migration does not find data, add a second key format (example: "PlayerData_{UserId}").
How store and keys work
store and keys are different things:
| Config | Meaning |
|---|---|
| store | DataStore name (the container). Example: "OldStore" — same as DataStoreService:GetDataStore("OldStore") |
| keys | Keys inside that DataStore to look up (like row IDs). Example: "123456789" — same as store:GetAsync("123456789") |
Analogy: store is the file cabinet; keys are the drawer labels (one per player).
PlayerState tries each key in order; the first that returns data wins.
{UserId}— Built-in token, replaced with the joining player's UserId (e.g.123456789). No setup needed.- Examples:
"{UserId}"→"123456789","PlayerData_{UserId}"→"PlayerData_123456789" - Why multiple keys? Old systems may use different formats. List them; PlayerState uses the first that exists.
Strategy Picker
Most teams should start with Auto.
| Strategy | Use this when... | Conflict winner | Risk |
|---|---|---|---|
Auto | You want safe defaults | Template/new profile | Low |
Overwrite | Legacy is more trusted | Legacy | Medium |
KeepNew | Current profile is newer | Current profile | Low |
Manual | You need field mapping logic | Your transform | Medium |
ContinuousOverwrite | Legacy must re-apply every load | Legacy (every load) | High |
ContinuousOverwrite
ContinuousOverwrite bypasses one-time marker short-circuit and reapplies every load. Use only when this is explicitly required.
Advanced (Optional)
Config Reference
Minimal Config (Recommended)
lua
LegacyDataStore = {
enabled = true,
store = "OldStore",
strategy = "Auto",
keys = { "{UserId}" },
}Most Common Key Setups
lua
-- Most common:
keys = { "{UserId}" }
-- If old system prefixed keys:
keys = { "{UserId}", "PlayerData_{UserId}" }For most users
If you are unsure, use keys = { "{UserId}" } first. Only add extra key formats if your legacy system used them.
Advanced config (optional)
Advanced Config
lua
LegacyDataStore = {
enabled = true,
store = "OldStore",
strategy = "Manual",
keys = { "{UserId}", "PlayerData_{UserId}" },
advanced = {
scope = nil,
marker = {
path = "_Migration.LegacyDataStoreV1",
writeOnNoData = true,
},
payload = {
mode = "auto", -- auto | profileStore
},
manual = {
transform = function(legacyData, template, player)
return {
Coins = legacyData.Coins or 0,
}
end,
},
fetch = {
custom = nil, -- function(keyCandidates, userId, player) -> payload, keyUsed
},
},
}Flat Keys (Legacy Compatibility)
| Flat Key | Modern Key |
|---|---|
Enabled | enabled |
DataStoreName | store |
Scope | advanced.scope |
MergeStrategy | strategy |
KeyCandidates | keys |
ProfileStorePayload | advanced.payload.mode (true means profileStore) |
Transform | advanced.manual.transform |
MigrationMarkerPath | advanced.marker.path |
WriteMarkerOnNoLegacyData | advanced.marker.writeOnNoData |
FetchLegacyData | advanced.fetch.custom |
MigrationResult Signal
Use this for analytics, logging, and debugging:
lua
PlayerState.MigrationResult:Connect(function(player, result, data)
print("Migration for", player.Name, result.applied, result.reason, result.strategy, result.keyUsed)
end)Full result fields
Result fields:
| Field | Meaning |
|---|---|
applied | Migration was applied |
legacyFound | Legacy payload was found |
markerWritten | Marker was written |
keyUsed | Key that returned payload |
strategy | Reported strategy used |
markerPath | Marker path used |
continuous | Continuous mode was active |
reason | Status reason (applied, already_marked, no_legacy_data, etc.) |
Testing
Fast Validation
- Seed old store:
SetAsync("123456789", { Coins = 999, Level = 5 }) - Enable minimal config
- Join test user and verify values imported
- Rejoin same user and verify migration does not re-run
Full validation checklist
- Key order: confirm first matching key is used
- Profile payload mode: test payload with
.Data - No-data case: test marker behavior with
writeOnNoData - Continuous mode: verify overwrite happens every load
Advanced Examples
Skip this section if you are new. Quick Start + Strategy Picker are enough for most migrations.
Show advanced strategy examples
Auto (Template-Safe Merge)
Use when your new template is cleaner and should win conflicts.
lua
LegacyDataStore = {
enabled = true,
store = "OldStore",
strategy = "Auto",
keys = { "{UserId}", "PlayerData_{UserId}" },
}Overwrite (Legacy Wins)
Use when legacy values are more trustworthy than current profile values.
lua
LegacyDataStore = {
enabled = true,
store = "LegacyMain",
strategy = "Overwrite",
keys = { "{UserId}" },
advanced = {
marker = {
path = "_Migration.LegacyOverwriteV1",
writeOnNoData = true,
},
},
}KeepNew (Current Profile Wins)
Use when players already have newer PlayerState data and legacy should only fill gaps.
lua
LegacyDataStore = {
enabled = true,
store = "OldStore",
strategy = "KeepNew",
keys = { "{UserId}" },
}Manual (Custom Field Mapping)
Use when the old schema and new schema do not match.
lua
LegacyDataStore = {
enabled = true,
store = "OldGameData",
strategy = "Manual",
keys = { "{UserId}" },
advanced = {
manual = {
transform = function(legacyData, template, player)
return {
Coins = legacyData.Gems or legacyData.Coins or 0,
Level = legacyData.Level or 1,
Inventory = legacyData.Items or legacyData.Inventory or {},
}
end,
},
},
}ContinuousOverwrite (Advanced / High-Risk)
Use only when legacy must overwrite current data on every load.
lua
LegacyDataStore = {
enabled = true,
store = "ExternalSourceOfTruth",
strategy = "ContinuousOverwrite",
keys = { "{UserId}" },
advanced = {
marker = {
path = "_Migration.ContinuousV1",
writeOnNoData = false,
},
},
}ProfileStore Payload Format
lua
LegacyDataStore = {
enabled = true,
store = "ProfileStoreV1",
strategy = "Auto",
keys = { "{UserId}" },
advanced = {
payload = { mode = "profileStore" },
},
}Custom Fetcher Source
lua
LegacyDataStore = {
enabled = true,
store = "LegacyStore",
strategy = "Overwrite",
keys = { "{UserId}" },
advanced = {
fetch = {
custom = function(keyCandidates, userId, player)
local DataStoreService = game:GetService("DataStoreService")
local store = DataStoreService:GetDataStore("CustomLegacyStore", "v1")
for _, key in keyCandidates do
local ok, data = pcall(function()
return store:GetAsync(key)
end)
if ok and data then
return data, key
end
end
return nil, nil
end,
},
},
}Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| Migration did not run | Disabled, wrong key/store, or marker exists | Check enabled, keys, store, marker path |
| Wrong values imported | Strategy mismatch | Switch strategy or use Manual transform |
| Migration ran once only | Marker-based behavior | Expected; change marker path to re-test |
| Legacy updates no longer overwrite | One-time migration completed | Use ContinuousOverwrite only if required |
session.* fields not imported | Runtime roots are stripped | Expected; session data is non-persistent |
Safety Checklist
- Keep migration disabled until testing is complete
- Test with
Scope = "Testing" - Use a unique marker path for re-testing
- Back up legacy data before rollout
- Roll out in stages and monitor
MigrationResultlogs