Skip to content

RedDB navigation

fast state

Key-Value

Configuration, feature flags, hot keys — millisecond reads.

What it is

Key-Value on RedDB.

Two complementary surfaces under one umbrella. User KV collections are SQL-queryable (key, value) collections — same engine, same WAL, same backup story as your tables. Read with SELECT, write with INSERT / UPDATE — all the SQL surface applies.

Engine config + vault is a separate namespace for runtime knobs (SET CONFIG acme.pagination.pageSize = 100) and encrypted secrets (SET CONFIG red.secret.openai.api_key = 'sk-...'). Reference both inline from any SQL statement via $config.<path> and $secret.<path> — the substitution is a typed Value, never a textual splice, so injection is structurally impossible.

For ephemeral data with TTL eviction (sessions, render fragments), see the dedicated Cache primitive — different shape, different durability story.

Code

Write it. Read it. Same engine.

Insert
SET CONFIG acme.risk_threshold = 'high';
Query
SELECT * FROM users WHERE risk = $config.acme.risk_threshold;

Use cases

Where key-value earn their place.

  • Feature flags

    Per-environment toggles read by every request, pushed by deploys.

  • Idempotency keys

    Webhook event ids, payment retries — durable dedup the engine handles in one round trip.

  • Counters + locks

    INSERT ... ON CONFLICT for idempotent writes and counters. Atomic INCR / CAS roadmap (reddb-io/reddb#238).

  • App config

    Tunables you change without a redeploy, observable from your admin panel like any other table.

Build it

End-to-end walkthrough.

Four steps from empty database to a real key-value workload — each step shows the exact code and explains what the engine is doing.

  1. Step 1

    1. Engine config — SET CONFIG, read inline via $config

    SET CONFIG <path> = <value> is the engine-config surface. Values are addressable from any SQL statement via $config.<path> as typed substitutions. Operations team adjusts at runtime — application code does not refetch.

    Step 1
    -- Set runtime knobs for your app.
    SET CONFIG acme.pagination.pageSize = 100;
    SET CONFIG acme.search.maxResults  = 50;
    SET CONFIG feature.new_dashboard   = 'on';
    
    -- Reference inline from any SQL statement.
    SELECT id, name FROM users
    ORDER BY id LIMIT $config.acme.pagination.pageSize;
    
    -- $config.<path> is resolved as a typed Value at parse time —
    -- not a textual splice — so SQL injection through config writes
    -- is structurally impossible.
  2. Step 2

    2. Vault secrets — same shape, sealed reads

    red.secret.* keys are auto-encrypted with the vault AES key. $secret.<path> resolves the typed Value when policy allows; everyone else sees ***. Provider keys for AUTO EMBED, OAuth, payments — none of them ever land in your repo.

    Step 2
    -- Write under the red.secret.* namespace.
    SET CONFIG red.secret.openai.api_key   = 'sk-...';
    SET CONFIG red.secret.stripe.api_key   = 'sk_live_...';
    
    -- Reference inline; the engine pulls the typed Value from vault.
    INSERT INTO notes (body) VALUES ($1)
      WITH AUTO EMBED (body) USING openai;
    -- The OpenAI provider reads $red.secret.openai.api_key internally —
    -- never appears in your application code, logs, or query plans.
    
    -- Sealed read protects the value from non-privileged principals.
    SELECT $secret.red.secret.stripe.api_key;
    -- → '***' unless the policy allows the principal to UNSEAL.
  3. Step 3

    3. User KV collection — SQL-queryable (key, value) rows

    User KV is shaped as KV collections — (key, value) rows you query with full SQL. WITH TTL evicts on the engine sweep; ON CONFLICT gives you idempotent writes without a separate CAS verb. Same WAL, same backup, same SELECT surface as any other table.

    Step 3
    -- Create a KV collection with a TTL on every entry.
    CREATE TABLE sessions KV (
      key      TEXT PRIMARY KEY,
      value    JSONB NOT NULL,
      created_at TIMESTAMPTZ NOT NULL DEFAULT now()
    );
    
    -- It is a regular collection — the full SQL surface applies.
    INSERT INTO sessions DOCUMENT (key, value)
    VALUES ('abc-123', {"user_id": 42, "scope": "admin"})
    WITH TTL 3600000;
    
    SELECT value FROM sessions WHERE key = 'abc-123';
    
    -- ON CONFLICT DO NOTHING gives you idempotent inserts.
    INSERT INTO idempotency (key, value) VALUES ($1, '1')
    ON CONFLICT (key) DO NOTHING
    RETURNING key;
    -- 0 rows back means the key already existed (replay).
  4. Step 4

    4. Combine — config-driven query against a KV collection

    Config + vault references are typed-Value substitutions, so they compose with the rest of the SQL surface — they're available everywhere a literal would be. Tune at runtime, rotate secrets without redeploys, never touch the values in application code.

    Step 4
    -- Page-size knob in engine config.
    SET CONFIG acme.pagination.pageSize = 25;
    
    -- Iterate a KV namespace using the config knob.
    SELECT key, value
    FROM sessions
    WHERE key LIKE 'org:42:%'
    ORDER BY created_at DESC
    LIMIT $config.acme.pagination.pageSize;
    
    -- Combine with $secret for ops that need a vault value.
    INSERT INTO webhook_events (id, payload, signature_check)
    VALUES ($1, $2, hmac_sha256($2, $secret.red.secret.stripe.webhook_secret));

Engine features

What RedDB adds on top of key-value.

The engine knobs you would otherwise wire up yourself — TTL, encryption, indexes, atomic ops, eventual consistency — applied to this data model.

  • TTL per key

    User KV collections use INSERT INTO <kv_table> (key, value) VALUES (...) WITH TTL <duration> for time-bounded writes that evict on the engine sweep. Atomic increment for sliding-window rate limits is roadmap (reddb-io/reddb#238).

  • Atomic ops (INCR / DECR / CAS) — roadmap

    Planned in reddb-io/reddb#238 (Redis-flavor KV DSL). Today, use INSERT ... ON CONFLICT DO UPDATE SET count = count + 1 for atomic counters and INSERT ... ON CONFLICT DO NOTHING for idempotent writes.

  • WATCH push streams — roadmap

    Planned in reddb-io/reddb#238. Today, fan-out to services on write via the queue primitive or by polling the KV collection with a SELECT WHERE updated_at > $last_seen.

  • Configs pattern

    KV is the natural home for runtime config and feature flags. Read by every request, observable from your admin panel like any other table, audited via the same WAL.

  • Secrets — Value::Secret + red.secret.* namespace

    Sensitive values (red.secret.stripe_api_key) are auto-encrypted with the vault AES key on SET CONFIG, sealed reads return ***. The Value::Secret shape applies to KV the same way it applies to columns.

  • Prefix scan

    SCAN config.* returns matching keys with pagination. Sub-linear in total key count — the namespace tree is indexed.

  • Eventual consistency reducers

    For high-write counters (visit counts, abuse rates), an EC reducer (Sum, Max) gives you append-throughput without a write lock. Reads return the consolidated value; consolidation runs in background.

  • Policy-gated namespaces

    Policies scope by key prefix. A dev role can read config.feature.* but not red.secret.*; a secrets_admin role gets the latter only via UNSEAL. Same KV, two visibility planes.

  • Audit log on secret reads

    Every UNSEAL of a red.secret.* value writes an audit row — actor, secret name, timestamp. Fits SOC 2 / ISO 27001 access-review controls without a sidecar.

  • $config / $secret references inline in SQL

    KV values are addressable directly from any SQL statement: LIMIT $config.acme.pagination.pageSize reads a typed value from the KV store at parse time. The substitution is typed (not string interpolation) so SQL injection is structurally impossible. Same shape for $secret.<path> from the vault.

Showcases

What key-value can do once you bring the rest of the engine in.

Cross-model correlation, algorithm flex, multi-model patterns — things that take a stack of services elsewhere.

Config-driven SQL (no app-layer fetch)

Reference $config.acme.pagination.pageSize directly inside a SELECT. The engine resolves it as a typed value at parse time.

A pagination knob lives in the KV namespace. Every query that paginates reads it inline — operations team adjusts the page size at runtime, no deploy, no client change. The substitution is structural (typed Value through an AST function call), so SQL injection is impossible regardless of who can write the key.

sql
Config-driven SQL (no app-layer fetch)
-- 1. Set the config (admin panel, deploy script, runbook).
SET CONFIG acme.pagination.pageSize = 100;

-- 2. Use it directly in any query.
SELECT id, name, created_at
FROM users
WHERE org_id = $1
ORDER BY id DESC
LIMIT $config.acme.pagination.pageSize;

-- 3. Tune at runtime — no app deploy.
SET CONFIG acme.pagination.pageSize = 250;
-- Subsequent queries pick up the new value on next execution.

-- Same pattern for vault secrets used inside SQL functions.
INSERT INTO notes (body) VALUES ($1)
  WITH AUTO EMBED (body) USING openai;
-- The engine reads $red.secret.openai.api_key from vault internally;
-- it never appears in your application code or query string.

Configs + secrets in one namespace

Runtime config, feature flags, and encrypted secrets share one KV — different shapes, same admin panel.

Plain config (config.risk_threshold = high) sits next to encrypted secrets (red.secret.stripe_api_key = ...). The engine encrypts the secret namespace with the vault AES key, regular reads return ***, only authorised principals can decrypt. Both surfaces in your admin panel like any other KV row.

sql
Configs + secrets in one namespace
-- Plain config — readable, durable, audited via WAL.
SET CONFIG config.risk_threshold = 'high';
SET CONFIG feature.new_dashboard = 'on';

-- Sensitive — auto-encrypted with vault AES key.
SET CONFIG red.secret.stripe_api_key = 'sk_live_...';
SET CONFIG red.secret.openai_api_key = 'sk-...';

-- Sealed read — $secret.<path> returns '***' unless the caller is authorized.
SELECT $secret.red.secret.stripe_api_key;
-- → '***'

-- Authorised principal sees the plaintext.
SET ROLE secrets_admin;
SELECT $secret.red.secret.stripe_api_key;
-- → 'sk_live_...'

Distributed coordination via INSERT ... ON CONFLICT

Exactly-one-runs semantics — no Zookeeper, no etcd, no Redis.

A multi-worker deploy pipeline needs exactly-one-runs semantics. INSERT ... ON CONFLICT DO NOTHING gives one worker the lock row; the loser gets rowCount 0 and backs off. Atomic CAS with TTL is planned in reddb-io/reddb#238.

typescript
Distributed coordination via INSERT ... ON CONFLICT
async function withDeployLock<T>(workerId: string, fn: () => Promise<T>) {
  const acquired = await db.query(
    `INSERT INTO deploy_locks (name, holder, acquired_at)
     VALUES ('deploy', $1, NOW())
     ON CONFLICT (name) DO NOTHING`,
    [workerId],
  )
  if (acquired.rowCount === 0) throw new Error('lock taken')
  try {
    return await fn()
  } finally {
    await db.query(
      "DELETE FROM deploy_locks WHERE name = 'deploy' AND holder = $1",
      [workerId],
    )
  }
}

Migrate from

Bring key-value over from your current tool.

From

Redis (durable cases only)

Use redis-cli to dump the keys you treat as durable state — feature flags, idempotency keys, counters — and replay as RedDB SET CONFIG writes.

Migrate from Redis (durable cases only)
redis-cli --scan --pattern 'config.*' | while read k; do
  v=$(redis-cli GET "$k")
  red sql -d "reds://admin@.../my-db:5050" \
    "SET CONFIG $k = '$v'"
done

From

etcd

etcdctl get with a prefix → JSON lines → red import.

Migrate from etcd
etcdctl --prefix get /service/ -w json \
  | jq -r '.kvs[] | "SET CONFIG \(.key | @base64d) = \u0027\(.value | @base64d)\u0027"' \
  | red sql -d "reds://admin@.../my-db:5050"

Recipes

Common patterns, real driver code.

  • Per-IP rate limit

    sql

    INSERT ... ON CONFLICT to count hits per window — one round trip per request.

    sql
    INSERT INTO rate_limits (ip, window_start, count)
    VALUES ($1, date_trunc('minute', NOW()), 1)
    ON CONFLICT (ip, window_start)
    DO UPDATE SET count = rate_limits.count + 1
    RETURNING count;
    -- if count > 100, reject the request.
  • Idempotent webhook intake

    typescript

    Dedupe replays by upstream event id with INSERT ... ON CONFLICT DO NOTHING.

    typescript
    const inserted = await db.query(
      `INSERT INTO idempotency_keys (key, created_at)
       VALUES ($1, NOW())
       ON CONFLICT (key) DO NOTHING`,
      [`stripe.${event.id}`]
    )
    if (inserted.rowCount === 0) return new Response('replay', { status: 200 })
    // fresh event — process it
  • Live feature flag fan-out

    typescript

    Poll the config key on a short interval, or subscribe via the WATCH stream (roadmap: reddb-io/reddb#238).

    typescript
    // Today: poll the config key.
    const { rows } = await db.query(
      "SELECT value FROM app_config WHERE key = 'feature.new_dashboard'"
    )
    featureFlags.set('new_dashboard', rows[0]?.value === 'on')

Limits

What it does. What it costs.

Operational caps and durability semantics, no hand-waving.

Max value size
1 MB (use Cache or Documents for larger)
Key namespace
Dotted (`a.b.c`); SCAN by prefix is sub-linear
TTL granularity
Per-key, second precision
Atomic writes
INSERT ... ON CONFLICT for idempotent ops; INCR / CAS roadmap (reddb-io/reddb#238)
Durability
fsync + WAL on every commit, same as tables

When NOT to use

Don't pick key-value for these.

Honest constraints — when another model fits better.

  • Don't use KV for ephemeral cached data

    KV writes hit the WAL. For TTL-evicted blobs that you can lose, the Cache primitive (L1 + L2) is faster and operationally cheaper.

  • Don't stuff JSON blobs > 100 KB

    KV is shaped for small, high-frequency reads. Larger structured data belongs in document collections — they are queryable by path, KV is not.

  • Avoid synthesising lists with `key.001`, `key.002`

    If you need an ordered collection, use a regular table with an autoincrement id. SCAN by prefix isn't a substitute for ORDER BY.

vs

How RedDB key-value compare.

  • Redis (KV side)

    Durable by default. Lives in the same .rdb file as your relational data — one backup story.

  • etcd

    Same KV semantics for app state without standing up a separate cluster for a side-line workload.

Pairs with

Key-Value earns more next to these.

The data models key-value compose with — a click away.

  • Cache

    KV is durable; Cache is ephemeral with TTL eviction. Configs in KV, sessions in Cache.

    tiered TTL Cache guide
  • Tables

    KV stores configs that drive table queries — feature flags toggle SELECT branches without code change.

    relational Tables guide
  • Queues

    Idempotency keys live in KV; the corresponding job lives in a queue. INSERT ... ON CONFLICT coordinates exactly-once semantics.

    workflows Queues guide

Questions teams ask before they pick key-value.

Explore

Every other data model on RedDB.

One engine, 9 first-class data models. Mix as needed.

  • Tables

    Postgres-wire SQL with indexes, joins, transactions.

    relational Tables guide
  • Documents

    Schemaless JSON when structure is partial — query by path.

  • Graphs

    Native edges and traversals — multi-hop without a side-car.

  • Vectors

    Auto-embed on insert, similarity search, no vector DB to sync.

  • Cache

    Tiered blob cache built into the engine: L1 memory + L2 durable.

    tiered TTL Cache guide
  • Time-series

    Retention, downsampling, native window queries.

  • Queues

    FIFO, priority, consumer groups — durable workflow primitives.

    workflows Queues guide
  • Probabilistic

    HyperLogLog, Count-Min Sketch, Cuckoo Filter — bounded-memory analytics.

Try key-value on RedDB.

Free nano database, no credit card. Self-host under BSL 1.1 or managed Cloud — same engine, same file format.