Skip to main content

KV Store

Luat includes a built-in Key-Value store with a familiar, industry-standard API. The KV store is only available in server-side contexts (+page.server.lua, +server.lua).

Overview

The KV store provides persistent data storage for your Luat applications:

  • CLI: SQLite-backed storage (persists across restarts)
  • WASM: In-memory storage (persists within the browser session)

Basic Usage

-- Get a namespace
local kv = KV.namespace("my-namespace")

-- Store a value
kv:put("user:123", "Alice")

-- Retrieve a value
local name = kv:get("user:123")
print(name) -- "Alice"

-- Delete a value
kv:delete("user:123")

API Reference

KV.namespace(name)

Creates or retrieves a KV namespace. Namespaces isolate data between different parts of your application.

local users = KV.namespace("users")
local posts = KV.namespace("posts")

-- Data in "users" namespace doesn't affect "posts" namespace
users:put("key", "value1")
posts:put("key", "value2")

kv:get(key, type?)

Retrieves a value by key.

ParameterTypeDescription
keystringThe key to retrieve
typestring?Optional type hint: "text" (default), "json", or "arrayBuffer"
-- Get as text (default)
local value = kv:get("key")

-- Get and parse as JSON
local data = kv:get("config", "json")
print(data.setting)

-- Non-existent keys return nil
local missing = kv:get("nonexistent") -- nil

kv:getWithMetadata(key, type?)

Retrieves a value along with its metadata.

local value, meta = kv:getWithMetadata("key")

if value then
print(value)
print(meta.expiration) -- Unix timestamp or nil
print(meta.metadata) -- Custom metadata table or nil
end

kv:put(key, value, options?)

Stores a value with optional expiration and metadata.

ParameterTypeDescription
keystringThe key to store
valuestring/tableThe value (tables are JSON-serialized)
optionstable?Optional settings

Options

OptionTypeDescription
expirationnumberUnix timestamp when entry expires
expirationTtlnumberSeconds from now until expiration
metadatatableArbitrary JSON metadata
-- Simple put
kv:put("key", "value")

-- Put with TTL (expires in 1 hour)
kv:put("session:abc", "data", {
expirationTtl = 3600
})

-- Put with specific expiration
kv:put("token:xyz", "secret", {
expiration = os.time() + 86400 -- Tomorrow
})

-- Put with metadata
kv:put("post:123", json.encode(post), {
metadata = {
author = "alice",
created = os.time()
}
})

kv:delete(key)

Deletes a key. No error is raised if the key doesn't exist.

kv:delete("key")

kv:list(options?)

Lists keys with optional filtering and pagination.

OptionTypeDescription
prefixstringOnly return keys starting with prefix
limitnumberMaximum keys to return (default: 1000)
cursorstringPagination cursor from previous call

Returns a table with:

  • keys: Array of key info objects
  • list_complete: Boolean indicating if all keys returned
  • cursor: Pagination cursor (if more keys exist)
-- List all keys
local result = kv:list()
for _, key in ipairs(result.keys) do
print(key.name)
end

-- List with prefix
local posts = kv:list({ prefix = "post:" })
for _, key in ipairs(posts.keys) do
print(key.name) -- e.g., "post:123"
print(key.expiration) -- Unix timestamp or nil
print(key.metadata) -- Metadata table or nil
end

-- Pagination
local page1 = kv:list({ limit = 100 })
if not page1.list_complete then
local page2 = kv:list({
limit = 100,
cursor = page1.cursor
})
end

Examples

Session Storage

-- src/lib/session.lua
local kv = KV.namespace("sessions")

local M = {}

function M.create(user_id)
local session_id = generate_id()
kv:put("session:" .. session_id, json.encode({
user_id = user_id,
created = os.time()
}), {
expirationTtl = 86400 -- 24 hours
})
return session_id
end

function M.get(session_id)
return kv:get("session:" .. session_id, "json")
end

function M.destroy(session_id)
kv:delete("session:" .. session_id)
end

return M

Caching API Responses

-- src/routes/api/posts/+server.lua
local kv = KV.namespace("cache")

function GET(ctx)
-- Check cache first
local cached = kv:get("posts:all", "json")
if cached then
return {
status = 200,
body = cached,
headers = { ["X-Cache"] = "HIT" }
}
end

-- Fetch from database
local posts = fetch_posts_from_db()

-- Cache for 5 minutes
kv:put("posts:all", json.encode(posts), {
expirationTtl = 300
})

return {
status = 200,
body = posts,
headers = { ["X-Cache"] = "MISS" }
}
end

Blog with Persistent Storage

-- src/lib/blog.lua
local kv = KV.namespace("blog")

local M = {}

function M.get_posts()
local result = kv:list({ prefix = "post:" })
local posts = {}

for _, key in ipairs(result.keys) do
local post = kv:get(key.name, "json")
if post then
table.insert(posts, post)
end
end

return posts
end

function M.get_post(slug)
return kv:get("post:" .. slug, "json")
end

function M.create_post(post)
post.created = os.time()
kv:put("post:" .. post.slug, json.encode(post), {
metadata = {
author = post.author,
created = post.created
}
})
return post
end

function M.update_post(slug, data)
local existing = M.get_post(slug)
if not existing then
return nil, "Post not found"
end

for k, v in pairs(data) do
existing[k] = v
end
existing.updated = os.time()

kv:put("post:" .. slug, json.encode(existing))
return existing
end

function M.delete_post(slug)
kv:delete("post:" .. slug)
end

return M

Using with Form Actions

-- src/routes/blog/[slug]/edit/+page.server.lua
local blog = require("blog")

function load(ctx)
local post = blog.get_post(ctx.params.slug)
if not post then
return { error = "Post not found", status = 404 }
end
return { post = post }
end

actions = {
default = function(ctx)
local result, err = blog.update_post(ctx.params.slug, {
title = ctx.form.title,
content = ctx.form.content
})

if err then
return fail(400, { error = err })
end

return { saved = true }
end,

delete = function(ctx)
blog.delete_post(ctx.params.slug)
return { redirect = "/blog" }
end
}

Storage Location

CLI

Data is stored in SQLite at .luat/data/kv.db relative to your project root. You can configure this in luat.toml:

[routing]
data_dir = ".luat/data"

WASM

Data is stored in memory and persists for the duration of the WasmEngine instance. In browser environments, data is lost on page refresh.

Best Practices

  1. Use namespaces to organize data logically (e.g., "sessions", "cache", "posts")

  2. Set expiration for cache entries to prevent stale data:

    kv:put("cache:key", data, { expirationTtl = 3600 })
  3. Use prefixes for related keys to enable efficient listing:

    kv:put("user:123:profile", ...)
    kv:put("user:123:settings", ...)
    kv:list({ prefix = "user:123:" })
  4. Store metadata for debugging and auditing:

    kv:put("doc:abc", content, {
    metadata = { author = user_id, version = 1 }
    })
  5. Handle missing keys gracefully:

    local data = kv:get("key", "json") or {}