FiveM KVP Storage Explained: SetResourceKvp, ResourceKvp Natives and When You Don’t Need a Database

Every FiveM server — and every FiveM client — ships with a persistent key-value database that most script devs never touch. FiveM KVP (key-value pair) storage lets a resource write data to disk with a single native call: no connection string, no schema, no oxmysql dependency, no async callback ceremony. Used well, it replaces a surprising number of MySQL tables. Used blindly, it eats data in ways that only show up in production. This guide covers the full native set, where the data physically lives, and the honest line between “KVP is enough” and “you need a real database”.

What KVP Actually Is, and Where It Lives on Disk

KVP is an embedded key-value store built into both fxserver and the FiveM client. On the server, the data sits in the db/ directory at the root of your server data folder, right next to resources/ and cache/ — delete that folder and every KVP entry on the server is gone, so it belongs in your backup set. On the client, it’s a LevelDB database inside the FiveM application data at fxd:/kvs/, on the player’s machine.

Internally, every key you write is namespaced as res:<resourceName>:<key> — that exact format is in the citizenfx source. This one implementation detail explains most of KVP’s behavior: isolation is per resource name, not per resource instance, and there’s no server identifier in the key at all. Both facts have consequences we’ll get to.

Client KVP vs Server KVP: Two Databases That Never Talk

The natives are identical on both sides, but they hit two completely unrelated stores. A SetResourceKvp on the server writes to the server’s db/ folder; the same call in a client script writes to the player’s local LevelDB. Nothing replicates between them, ever — KVP is local storage, not a sync mechanism.

  • Server KVP is scoped to that one server installation. It survives restarts and txAdmin updates as long as db/ survives, and players can never read or tamper with it.
  • Client KVP lives on the player’s PC and follows the player, not the server. Because the stored key is only res:<resourceName>:<key>, a player who joins two different servers that both run a resource named hud shares one KVP bucket between them.

That cross-server bleed is a genuine feature for quality-of-life settings — a player’s minimap preferences can follow them into every city running your HUD. It’s also a reason to prefix client keys with a server identity convention if collisions would hurt. And since the player owns the disk, treat client KVP as player-editable: fine for a radio volume, never for whitelist status, owned items, or anything an anticheat reads.

The Typed Setters and Getters — Don’t Mix Them

KVP stores three value types, each with its own setter and getter:

  • SetResourceKvp(key, value)GetResourceKvpString(key)
  • SetResourceKvpInt(key, value)GetResourceKvpInt(key)
  • SetResourceKvpFloat(key, value)GetResourceKvpFloat(key)

The types are not interchangeable. Reading an int-stored key with GetResourceKvpString doesn’t return nil — it throws a bad-cast script error, which is a classic head-scratcher the first time it kills a resource. Pick one type per key prefix and stay consistent. Missing keys return nil (strings) or 0 (numbers), so an int counter that was never written and one explicitly set to zero look identical; use a string sentinel if that distinction matters.

SetResourceKvp('config:motd', 'Wipe night is Friday')
SetResourceKvpInt('stats:restarts', 42)
SetResourceKvpFloat('economy:taxRate', 0.08)

local motd = GetResourceKvpString('config:motd')   -- nil if unset
local n    = GetResourceKvpInt('stats:restarts')   -- 0 if unset

Tables go through JSON — SetResourceKvp('shop:prices', json.encode(prices)) and decode on the way out. DeleteResourceKvp(key) removes a single entry. There’s no “clear all”, which brings us to key design.

Prefix Scans: StartFindKvp, FindKvp, EndFindKvp

KVP has exactly one query primitive: iterate keys matching a prefix. StartFindKvp(prefix) returns a handle (-1 on failure), FindKvp(handle) returns the next matching key until it runs out, and EndFindKvp(handle) releases the handle — skip that last call and you leak handles until restart.

local handle = StartFindKvp('cache:veh:')
if handle ~= -1 then
    local key = FindKvp(handle)
    while key do
        print(key, GetResourceKvpString(key))
        key = FindKvp(handle)
    end
    EndFindKvp(handle)
end

This is why colon-namespaced keys (ban:, cache:veh:, pref:hud:) matter: the prefix is your only index. To delete by prefix, collect the keys into a table during the scan, end the scan, then loop DeleteResourceKvp over the table — mutating while iterating is asking for trouble. There’s also a read-only escape hatch across resource boundaries: GetExternalKvpString(resourceName, key) (plus Int/Float variants and StartFindExternalKvp) lets one resource read another’s KVP without any export wiring.

NoSync Writes and FlushResourceKvp

The standard setters wait for the data to hit disk before returning. One write, who cares; five hundred writes in a save loop on a busy tick, you’ll feel it. The _NO_SYNC variants — SetResourceKvpNoSync, SetResourceKvpIntNoSync, SetResourceKvpFloatNoSync, DeleteResourceKvpNoSync — return immediately without waiting for the filesystem, and FlushResourceKvp() forces everything pending onto disk in one go.

AddEventHandler('onResourceStop', function(res)
    if res ~= GetCurrentResourceName() then return end
    for plate, data in pairs(vehicleCache) do
        SetResourceKvpNoSync('cache:veh:' .. plate, json.encode(data))
    end
    FlushResourceKvp()
end)

The trade is durability: if the process dies between a NoSync write and the flush, those writes are gone. Batch-then-flush is perfect for rebuildable caches and shutdown saves; for a one-off write of something you can’t recompute, the plain synchronous setter is the right default.

When KVP Beats MySQL

Use KVP when the data is small, self-contained, and owned by one resource: script settings, feature toggles, computed caches, counters, and client-side player preferences. Reach for MySQL when data is relational, shared across resources, queried by anything other than a key prefix, or needs to be inspected and edited outside the server.

Concrete cases where KVP is flatly the better tool:

  • Standalone releases. A script that persists its own config via KVP installs with zero dependencies — no oxmysql, no .sql import file, no framework assumptions. For anything you sell or release, that’s less support tickets per hundred installs.
  • Client-side preferences. HUD layout, crosshair choice, radio volume, seen-tutorial flags — stored on the player’s own machine with no server round-trip and no table full of cosmetic junk.
  • Restart-surviving caches. Expensive computed results (parsed vehicle lists, zone grids, shop price tables) written once and reloaded instantly on resource start.
  • Operational counters. Restart counts, last-wipe timestamps, migration version flags — the kind of one-row bookkeeping that never deserved a table.

When You Still Want the Database

KVP has no WHERE clause, no ORDER BY, no JOIN, and no admin UI. The moment you need “all vehicles owned by this citizen id” or “top ten richest players”, key-value lookups stop being a storage engine and start being a liability. Keep MySQL for relational player data (characters, inventories, owned property), anything multiple resources write to, anything staff edit through HeidiSQL or a web panel, and anything money-shaped where you want an audit trail. The server’s db/ folder is an opaque binary blob — you can’t eyeball it, patch a row, or hand a subset to another server. If the data outlives the server folder or crosses server boundaries, it doesn’t belong in KVP.

Pitfalls That Bite in Production

  • Isolation follows the resource name. Rename myhud to myhud_v2 and every stored key is invisible to it — the data still sits on disk under res:myhud:, and the external KVP getters can rescue it, but plan a migration step instead of discovering this from player reports.
  • No TTL. Keys live forever. Embed a timestamp in the value and check it on read, and sweep stale prefixes on resource start: if hit and (os.time() - hit.at) < 600 then return hit.data end.
  • No quota will save you. Nothing enforces a hard per-resource cap you can design against, so discipline is on you: small JSON values, thousands of keys at most, never a dump of ten thousand rows that belonged in a table.
  • Backups. Most backup scripts grab resources/ and the SQL dump and skip db/ entirely. If KVP holds anything you’d miss, add the folder.
  • Client KVP is player-owned. Assume any client-stored value can be read and edited. Convenience data only.

KVP won’t replace your framework’s database, and it shouldn’t — but for settings, caches and preferences it removes an entire dependency from your stack, and the best script devs treat it that way. If you’re stocking a server while you’re at it: qb-tebex.io carries QBCore-ready script bundles, 0resmon-tebex.io focuses on performance-audited releases that respect your resmon budget, and cfx-tebex.store rounds out the catalog with maps, vehicles and standalone tools.