
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.
This guide is part of our complete FiveM frameworks guide, where we compare ESX, QBCore, and QBOX in depth and help you choose the right one.
Goal: move your server from ESX to QBCore with minimal downtime.
Overview
- Prerequisites
- Step 1. Make a Plan and a Rollback Point
- Step 2. Build a Clean QBCore Base
- Step 3. Replace mysql-async with oxmysql
Prerequisites
- Tools
- GIT and a separate branch for the migration.
- MariaDB or MySQL 8 with full backups enabled.
- A staging server that mirrors production.
- Server artifacts
- FXServer updated to the same build as production.
- QBCore base framework and default resources.
- Libraries you will use
oxmysqlfor database.ox_libfor callbacks, UI helpers, and utility wrappers.
Step 1. Make a Plan and a Rollback Point
- Freeze production changes. Stop new script installs and DB writes not required for testing.
- Back up your entire database dump as a named snapshot.
- Branch your server repository and create a dedicated
migrate-esx-to-qbcorebranch. - 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
- Deploy a fresh QBCore base to staging.
- Keep only essentials enabled. Disable jobs, inventories, and custom scripts until after DB migration.
- Install and start these resources first
qb-coreqb-vehiclesor your preferred replacementsoxmysqlox_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
- Prefer
query.await,scalar.await, andprepare.awaitfor clean flow. - 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 table | Key column | QBCore table | Key column | Notes |
|---|---|---|---|---|
users | identifier | players | citizenid | Convert identifiers and create citizenid for each row |
owned_vehicles | owner | player_vehicles | citizenid | Convert plate casing and JSON payloads |
datastore_data | owner | player_metadata | citizenid | If you store JSON, merge carefully |
addon_account_data | owner | player_accounts | citizenid | Map account names to QBCore banking or cash |
addon_inventory_items | owner | player_inventories | citizenid | If 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
- Create a
citizenidfor every player. - Link legacy identifiers to the new record.
- 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.
- Freeze item additions.
- Export the item master list.
- Map item names one to one.
- 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
- Unit tests
- Test identifier lookups for a random set of players.
- Test money transfers, job changes, and vehicle ownership.
- Gameplay tests
- Spawn players with old ESX identifiers and confirm auto mapping.
- Run a police duty flow, a store robbery, and a vehicle purchase.
- Performance tests
- Use
resmonto watch CPU and memory. - Confirm DB query counts dropped after oxmysql conversion.
- Rollout plan
- Move staging DB to production during a maintenance window.
- Announce a 60 minute downtime.
- Monitor logs for missing identifiers and foreign key errors.
Troubleshooting
- Duplicated citizens
- Cause. Running the migration twice.
- Fix. Enforce unique keys on
citizenidand useINSERT IGNOREduring seeding. - Missing vehicles
- Cause. Owner key mismatch between
owned_vehicles.ownerandlegacy_identifier_map.license. - Fix. Normalize owner values and re‑run the vehicle insert for the affected plates.
- Players spawn without inventory
- Cause. Inventory migration skipped.
- Fix. Rebuild the inventory mapping and re‑import.
- Scripts fail with
MySQL.Asyncnot found- Cause. Script still depends on mysql-async.
- Fix. Replace calls with oxmysql and remove mysql-async from the server.
Cutover Checklist
- Back up production database with a timestamp.
- Stop the server and lock player joins.
- Restore the final staging dump to production.
- Deploy QBCore build with
qb-core,oxmysql,ox_libfirst in the ensure order. - Run the identifier seeding script once.
- Enable converted scripts only when their queries are on oxmysql.
- Reopen the server and watch logs for 30 minutes.
- Post a rollback plan if critical errors appear.
Appendix A. Example fxmanifest for migration helper
- Cause. Owner key mismatch between
- Duplicated citizens
- Use
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
- Stable player records keyed by
citizenid. - A clean oxmysql layer with prepared statements and awaits.
- ESX code ported to QBCore using ox_lib callbacks and utilities.
- A versioned plan you can repeat for future servers.
Useful links inside your site
- Framework conversion hub. https://vertexmods.com/en/blog/converting-fivem-scripts
- MySQL Async to oxmysql guide. https://vertexmods.com/en/blog/mysql-async-to-oxmysql
- SQL identifiers migration. https://vertexmods.com/en/blog/sql-identifiers-migration
- Adapter patterns for script ports. https://vertexmods.com/en/blog/adapter-patterns
- QBCore install quickstart. https://vertexmods.com/en/blog/how-to-install-qbcore
- Script conversion checklist. https://vertexmods.com/en/blog/converting-fivem-scripts
- QBOX with ox stack overview. https://vertexmods.com/en/blog/qbox-ox-stack
- Resmon and performance. https://vertexmods.com/en/blog/how-to-use-resmon-in-fivem-optimize-resources
External references
Bleib auf dem Laufenden
Erhalte die neuesten FiveM-Tutorials, Mod-Releases und exklusive Updates direkt in dein Postfach.
Kein Spam. Jederzeit abbestellbar.