Skip to content

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 fastQuick Start
Understand store vs keys and {UserId}How store and keys work
Choose a strategyStrategy Picker
Copy config snippetsConfig Reference
Track outcomes in logsMigrationResult Signal
Validate behavior end-to-endTesting
Debug issues quicklyTroubleshooting

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:

  1. Set enabled = true
  2. Set store to your old DataStore name
  3. Start with strategy = "Auto"
  4. Start with keys = { "{UserId}" }
  5. 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:

ConfigMeaning
storeDataStore name (the container). Example: "OldStore" — same as DataStoreService:GetDataStore("OldStore")
keysKeys 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.

StrategyUse this when...Conflict winnerRisk
AutoYou want safe defaultsTemplate/new profileLow
OverwriteLegacy is more trustedLegacyMedium
KeepNewCurrent profile is newerCurrent profileLow
ManualYou need field mapping logicYour transformMedium
ContinuousOverwriteLegacy must re-apply every loadLegacy (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

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 KeyModern Key
Enabledenabled
DataStoreNamestore
Scopeadvanced.scope
MergeStrategystrategy
KeyCandidateskeys
ProfileStorePayloadadvanced.payload.mode (true means profileStore)
Transformadvanced.manual.transform
MigrationMarkerPathadvanced.marker.path
WriteMarkerOnNoLegacyDataadvanced.marker.writeOnNoData
FetchLegacyDataadvanced.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:

FieldMeaning
appliedMigration was applied
legacyFoundLegacy payload was found
markerWrittenMarker was written
keyUsedKey that returned payload
strategyReported strategy used
markerPathMarker path used
continuousContinuous mode was active
reasonStatus reason (applied, already_marked, no_legacy_data, etc.)

Testing

Fast Validation

  1. Seed old store: SetAsync("123456789", { Coins = 999, Level = 5 })
  2. Enable minimal config
  3. Join test user and verify values imported
  4. 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

SymptomLikely causeFix
Migration did not runDisabled, wrong key/store, or marker existsCheck enabled, keys, store, marker path
Wrong values importedStrategy mismatchSwitch strategy or use Manual transform
Migration ran once onlyMarker-based behaviorExpected; change marker path to re-test
Legacy updates no longer overwriteOne-time migration completedUse ContinuousOverwrite only if required
session.* fields not importedRuntime roots are strippedExpected; 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 MigrationResult logs

PlayerState - High-Performance Roblox Data Management