How to Migrate ESX → QBCore the Right Way

You want a clean switch from ESX to QBCore without losing data or breaking core systems. Follow this plan. You will finish with stable identifiers, oxmysql queries, and ox_lib powered code.

Goal: move your server from ESX to QBCore with minimal downtime.



Prerequisites

  1. Tools
    1. GIT and a separate branch for the migration.
    2. MariaDB or MySQL 8 with full backups enabled.
    3. A staging server that mirrors production.
  2. Server artifacts
    1. FXServer updated to the same build as production.
    2. QBCore base framework and default resources.
  3. Libraries you will use
    1. oxmysql for database.
    2. ox_lib for callbacks, UI helpers, and utility wrappers.

Step 1. Make a Plan and a Rollback Point

  1. Freeze production changes. Stop new script installs and DB writes not required for testing.
  2. Back up your entire database dump as a named snapshot.
  3. Branch your server repository and create a dedicated migrate-esx-to-qbcore branch.
  4. Write a runbook. Include commands to start and stop the staging server, restore DB, and run health checks.

Step 2. Build a Clean QBCore Base

  1. Deploy a fresh QBCore base to staging.
  2. Keep only essentials enabled. Disable jobs, inventories, and custom scripts until after DB migration.
  3. Install and start these resources first
    1. qb-core
    2. qb-vehicles or your preferred replacements
    3. oxmysql
    4. ox_lib

Step 3. Replace mysql-async with oxmysql

If any remaining ESX scripts still use MySQL.Async, convert the calls to oxmysql. Use simple find and replace with verification.

Common conversions

-- ESX mysql-async
MySQL.Async.fetchAll('SELECT * FROM users WHERE identifier = @id', {['@id'] = identifier}, function(rows)
  -- ...
end)
-- QBCore oxmysql
local rows = MySQL.query.await('SELECT * FROM players WHERE citizenid = ?', { citizenid })
-- rows is a Lua table; handle nil and length checks directly
-- ESX scalar example
MySQL.Async.fetchScalar('SELECT COUNT(1) FROM owned_vehicles', {}, function(count)
  -- ...
end)
-- oxmysql scalar
local count = MySQL.scalar.await('SELECT COUNT(1) FROM player_vehicles')
-- ESX insert
MySQL.Async.execute('INSERT INTO addon_account VALUES (@owner, @name, @money)', {
  ['@owner'] = identifier, ['@name'] = name, ['@money'] = money
})
-- oxmysql insert
MySQL.prepare.await('INSERT INTO player_accounts (citizenid, name, amount) VALUES (?, ?, ?)', { citizenid, name, amount })

Notes

  1. Prefer query.await, scalar.await, and prepare.await for clean flow.
  2. Use prepared statements for write operations.

Step 4. Map ESX Data Structures to QBCore

You will move player identities and owned entities. Use this reference to map tables.

ESX tableKey columnQBCore tableKey columnNotes
usersidentifierplayerscitizenidConvert identifiers and create citizenid for each row
owned_vehiclesownerplayer_vehiclescitizenidConvert plate casing and JSON payloads
datastore_dataownerplayer_metadatacitizenidIf you store JSON, merge carefully
addon_account_dataownerplayer_accountscitizenidMap account names to QBCore banking or cash
addon_inventory_itemsownerplayer_inventoriescitizenidIf you move to ox_inventory, migrate separately

You can keep custom tables. Adjust only foreign keys that reference ESX identifiers.


Step 5. Stabilize Identifiers

ESX often stores a CFX identifier like license:xxxx or historical steam:xxxx. QBCore uses citizenid as the stable player key and keeps the runtime identifiers for authentication only.

You will

  1. Create a citizenid for every player.
  2. Link legacy identifiers to the new record.
  3. Keep a lookup table for support and audits.

SQL bootstrap

Run this on a copy of your ESX database to prepare QBCore tables.

-- 1) Create players table if missing. Adjust to your QBCore schema.
CREATE TABLE IF NOT EXISTS players (
  citizenid VARCHAR(11) PRIMARY KEY,
  license VARCHAR(64) UNIQUE,
  identifiers JSON NOT NULL,
  name VARCHAR(64),
  charinfo JSON NOT NULL,
  metadata JSON NOT NULL,
  money JSON NOT NULL,
  job JSON NOT NULL,
  position VARCHAR(128) DEFAULT NULL,
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- 2) Helper function equivalent in SQL using a deterministic generator would be complex.
-- Instead, stage the mapping in a separate table and generate citizenid in Lua.
CREATE TABLE IF NOT EXISTS legacy_identifier_map (
  license VARCHAR(64) PRIMARY KEY,
  steam VARCHAR(64) NULL,
  fivem VARCHAR(64) NULL,
  discord VARCHAR(64) NULL,
  xbl VARCHAR(64) NULL,
  liveid VARCHAR(64) NULL,
  citizenid VARCHAR(11) UNIQUE
);

-- 3) Seed the mapping from ESX users
INSERT INTO legacy_identifier_map (license)
SELECT DISTINCT REPLACE(identifier, 'identifier:', '')
FROM users
WHERE identifier LIKE 'license:%' OR identifier LIKE 'steam:%';

Generate citizenid and insert players in Lua

Run once on staging. Back up first.

-- server/migrate_identifiers.lua
local QBCore = exports['qb-core']:GetCoreObject()

local function generateCitizenId()
  local charset = {}
  for c = 65, 90 do table.insert(charset, string.char(c)) end
  for n = 48, 57 do table.insert(charset, string.char(n)) end
  math.randomseed(GetGameTimer())
  local id = {}
  for i = 1, 11 do id[i] = charset[math.random(1, #charset)] end
  return table.concat(id)
end

local rows = MySQL.query.await('SELECT license FROM legacy_identifier_map WHERE citizenid IS NULL')
for _, r in ipairs(rows) do
  local citizenid = generateCitizenId()
  MySQL.prepare.await('UPDATE legacy_identifier_map SET citizenid = ? WHERE license = ?', { citizenid, r.license })
end

-- Build players from ESX users
local users = MySQL.query.await([[SELECT u.identifier, u.firstname, u.lastname, u.dateofbirth, u.sex, u.height
                                  FROM users u]])
for _, u in ipairs(users) do
  local license = u.identifier
  local map = MySQL.single.await('SELECT citizenid FROM legacy_identifier_map WHERE license = ?', { license })
  if map and map.citizenid then
    local name = string.format('%s %s', u.firstname or 'John', u.lastname or 'Doe')
    local charinfo = json.encode({ firstname = u.firstname, lastname = u.lastname, birthdate = u.dateofbirth, gender = u.sex, height = u.height })
    local metadata = json.encode({ hunger = 100, thirst = 100 })
    local money = json.encode({ cash = 0, bank = 0, crypto = 0 })
    local job = json.encode({ name = 'unemployed', label = 'Unemployed', grade = { name = '0', level = 0 }})

    MySQL.prepare.await('INSERT IGNORE INTO players (citizenid, license, identifiers, name, charinfo, metadata, money, job) VALUES (?, ?, ?, ?, ?, ?, ?, ?)', {
      map.citizenid,
      license,
      json.encode({ license = license }),
      name,
      charinfo,
      metadata,
      money,
      job
    })
  end
end
print('Identifier migration finished')

Move owned vehicles

INSERT IGNORE INTO player_vehicles (citizenid, plate, vehicle, garage, state)
SELECT m.citizenid,
       UPPER(JSON_UNQUOTE(JSON_EXTRACT(v.vehicle, '$.plate'))),
       v.vehicle,
       'legion',
       1
FROM owned_vehicles v
JOIN legacy_identifier_map m ON m.license = v.owner;

Validate random samples in game. Verify plate formats and garages.


Step 6. Port ESX Code to QBCore with ox_lib

Replace the ESX runtime API with QBCore equivalents. Use ox_lib for callbacks and notifications.

Player object

-- ESX
local xPlayer = ESX.GetPlayerFromId(src)
xPlayer.addMoney(100)
-- QBCore
local Player = QBCore.Functions.GetPlayer(src)
Player.Functions.AddMoney('cash', 100)

Jobs

-- ESX job check
if xPlayer.getJob().name == 'police' then
  -- ...
end
-- QBCore job check
local job = Player.PlayerData.job
if job and job.name == 'police' then
  -- ...
end

Callbacks and UI

-- ESX server callback
ESX.RegisterServerCallback('resource:getData', function(source, cb)
  cb({ ok = true })
end)
-- ox_lib callback
lib.callback.register('resource:getData', function(source)
  return { ok = true }
end)
-- Notification
lib.notify(source, { title = 'Job', description = 'Promotion granted', type = 'success' })

Commands

-- ESX
RegisterCommand('pay', function(src, args)
  local amount = tonumber(args[1]) or 0
  xPlayer.removeMoney(amount)
end)
-- QBCore with permissions
QBCore.Commands.Add('pay', 'Pay cash', {{name = 'amount', help = 'Amount'}}, false, function(src, args)
  local amount = tonumber(args[1]) or 0
  local Player = QBCore.Functions.GetPlayer(src)
  Player.Functions.RemoveMoney('cash', amount)
end)

Step 7. Inventory and Items

If you move from es_extended inventories to qb-inventory or ox_inventory, treat this as a separate sub‑migration.

  1. Freeze item additions.
  2. Export the item master list.
  3. Map item names one to one.
  4. Migrate player inventories in batches. Validate stack sizes and weights.

Example item mapping CSV

esx_name,qb_name,notes
bread,bread,
water,water,
lockpick,lockpick,

Step 8. Test and Roll Out

  1. Unit tests
    1. Test identifier lookups for a random set of players.
    2. Test money transfers, job changes, and vehicle ownership.
  2. Gameplay tests
    1. Spawn players with old ESX identifiers and confirm auto mapping.
    2. Run a police duty flow, a store robbery, and a vehicle purchase.
  3. Performance tests
    1. Use resmon to watch CPU and memory.
    2. Confirm DB query counts dropped after oxmysql conversion.
  4. Rollout plan
    1. Move staging DB to production during a maintenance window.
    2. Announce a 60 minute downtime.
    3. Monitor logs for missing identifiers and foreign key errors.

Troubleshooting

  1. Duplicated citizens
    1. Cause. Running the migration twice.
    2. Fix. Enforce unique keys on citizenid and use INSERT IGNORE during seeding.
  2. Missing vehicles
    1. Cause. Owner key mismatch between owned_vehicles.owner and legacy_identifier_map.license.
    2. Fix. Normalize owner values and re‑run the vehicle insert for the affected plates.
  3. Players spawn without inventory
    1. Cause. Inventory migration skipped.
    2. Fix. Rebuild the inventory mapping and re‑import.
  4. Scripts fail with MySQL.Async not found
    1. Cause. Script still depends on mysql-async.
    2. Fix. Replace calls with oxmysql and remove mysql-async from the server.

Cutover Checklist

  1. Back up production database with a timestamp.
  2. Stop the server and lock player joins.
  3. Restore the final staging dump to production.
  4. Deploy QBCore build with qb-core, oxmysql, ox_lib first in the ensure order.
  5. Run the identifier seeding script once.
  6. Enable converted scripts only when their queries are on oxmysql.
  7. Reopen the server and watch logs for 30 minutes.
  8. Post a rollback plan if critical errors appear.

Appendix A. Example fxmanifest for migration helper

fx_version 'cerulean'
game 'gta5'

lua54 'yes'

server_scripts {
  '@oxmysql/lib/MySQL.lua',
  '@ox_lib/init.lua',
  'server/migrate_identifiers.lua'
}

Appendix B. Safe JSON helpers

local function safeDecode(jsonStr, fallback)
  if type(jsonStr) ~= 'string' or jsonStr == '' then return fallback end
  local ok, result = pcall(json.decode, jsonStr)
  if not ok then return fallback end
  return result
end

What you achieved

  1. Stable player records keyed by citizenid.
  2. A clean oxmysql layer with prepared statements and awaits.
  3. ESX code ported to QBCore using ox_lib callbacks and utilities.
  4. A versioned plan you can repeat for future servers.

  1. Framework conversion hub. https://fivemx.com/framework-conversion
  2. MySQL Async to oxmysql guide. https://fivemx.com/mysql-async-to-oxmysql
  3. SQL identifiers migration. https://fivemx.com/sql-identifiers-migration
  4. Adapter patterns for script ports. https://fivemx.com/adapter-patterns
  5. QBCore install quickstart. https://fivemx.com/how-to-install-qbcore
  6. Script conversion checklist. https://fivemx.com/converting-fivem-scripts
  7. QBOX with ox stack overview. https://fivemx.com/qbox-ox-stack
  8. Resmon and performance. https://fivemx.com/how-to-use-resmon-in-fivem-optimize-resources

External references

  1. QBCore framework
  2. oxmysql documentation.
  3. ox_lib documentation.
  4. CFX.re identifiers reference.

Luke
Luke

I'm Luke, I am a gamer and love to write about FiveM, GTA, and roleplay. I run a roleplay community and have about 10 years of experience in administering servers.

Articles: 570