Skip to content

RedDB navigation

json

Documents

Schemaless JSON when structure is partial — query by path.

What it is

Documents on RedDB.

Document storage for cases where the schema is nested, sparse, or evolving faster than a migration cadence allows. JSON in, JSON out, indexed by path.

You can mix tables and documents in the same query — join a users table to an events document collection by foreign key, with no glue code in the middle.

Optional schema validation kicks in when you decide a collection has stabilised, without rewriting historical data.

Code

Write it. Read it. Same engine.

Insert
INSERT INTO logs DOCUMENT (body) VALUES ({"level":"warn","ip":"10.0.0.1","path":"/admin"});
Query
SELECT body->>'ip', body->>'path' FROM logs WHERE body->>'level' = 'warn' ORDER BY created_at DESC;

Use cases

Where documents earn their place.

  • Event logs

    Webhook payloads, audit trails, third-party API responses where the shape varies per provider.

  • User-defined fields

    Custom form responses, CRM extra attributes, B2B tenant overrides — anything you cannot DDL ahead of time.

  • Config snapshots

    Versioned application config and feature-flag bundles you query by path.

Build it

End-to-end walkthrough.

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

  1. Step 1

    1. Create the document collection

    DOCUMENT collections still have an id and created_at — what they trade is column DDL for the freedom to put any shape in body. Indexes on JSON paths are first-class: body->>'kind' is indexed exactly like a typed column.

    Step 1
    CREATE TABLE events DOCUMENT (
      id          UUID PRIMARY KEY DEFAULT gen_uuid(),
      body        JSONB NOT NULL,
      created_at  TIMESTAMPTZ NOT NULL DEFAULT now()
    );
    
    -- Index a hot path for fast filtering.
    CREATE INDEX idx_events_kind ON events ((body->>'kind'));
  2. Step 2

    2. Insert mixed payloads

    Three different shapes, same collection. No migration needed when a new event kind shows up — write the row, query by kind later when reporting needs it.

    Step 2
    INSERT INTO events DOCUMENT (body) VALUES
      ({"kind":"signup","user_id":42,"plan":"nano"}),
      ({"kind":"login","user_id":42,"ip":"1.2.3.4","ua":"chrome/120"}),
      ({"kind":"webhook.stripe","event_id":"evt_1","amount":1900});
  3. Step 3

    3. Query by JSON path

    JSON path operators (->> for text, -> for nested) are SQL-native. The optimiser uses the index on body->>kind to skip a full scan, even though the column was never declared.

    Step 3
    SELECT
      body->>'kind'  AS kind,
      COUNT(*)       AS occurrences
    FROM events
    WHERE created_at > now() - INTERVAL '24 hours'
      AND body->>'user_id' = '42'
    GROUP BY kind
    ORDER BY occurrences DESC;
  4. Step 4

    4. Promote a hot field to a column

    When a path goes from "occasionally queried" to "always filtered on", promote it to a generated column. RedDB stores it once and lets the optimiser use it like a regular column — no change at the application layer.

    Step 4
    ALTER TABLE events ADD COLUMN kind TEXT
    GENERATED ALWAYS AS (body->>'kind') STORED;
    
    CREATE INDEX idx_events_kind_created ON events (kind, created_at DESC);

Engine features

What RedDB adds on top of documents.

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

  • Path indices (functional)

    Index any JSON path: CREATE INDEX ON events ((body->'meta'->>'tenant')). The planner uses it for equality / prefix queries on the expression. No need to flatten the document into columns first.

  • Generated columns

    Promote a hot path to a column: kind TEXT GENERATED ALWAYS AS (body->>'kind') STORED. Stored once, used like any other column — composite indexes ((kind, created_at)) follow.

  • Optional JSON Schema validation

    Attach a JSON Schema validator to the collection when its shape stabilises. Runs on INSERT, rejects malformed payloads. You don't have to lock down the schema before it has settled.

  • TTL on document rows

    Per-row WITH TTL <ms> makes documents auto-evict (engine drops them on the next sweep). Useful for transient event payloads, signed-URL records, anything with a natural expiry.

  • Cross-model joins

    Documents are first-class to the SQL planner. Join an events document collection to a users table by body->>"user_id" in one statement — the optimiser picks the cheaper side as the driver.

  • Eventual consistency reducers

    For high-write counters embedded in documents (visit counts, score accumulators), a Sum reducer applies the EC consolidation pattern — appends in the txn log, consolidated periodically. Avoids the lock-contention bottleneck of read-modify-write.

  • Field-level policy enforcement

    Policies can target specific JSON paths — Deny: jsonPath: $.payload.ssn redacts a path from SELECT for non-privileged principals. PII handling without rewriting every query.

  • Audit log of write events

    Every INSERT into a document collection can fire an audit row capturing actor, payload digest, and policy that admitted it — useful for compliance-heavy workloads (HIPAA, GDPR DSARs).

Showcases

What documents 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.

Custom fields without DDL

Each tenant attaches its own attributes — store them, query them, index the hot ones.

A B2B SaaS has 5,000 tenants, each defining custom fields on projects. Storing them as columns means 5,000 ALTER TABLE migrations; storing them as JSON in a sibling table means glue code. RedDB documents put them on the row itself, indexable by tenant via a generated column.

sql
Custom fields without DDL
CREATE TABLE projects (
  id      UUID PRIMARY KEY,
  org_id  UUID NOT NULL,
  name    TEXT NOT NULL,
  custom  JSONB DEFAULT '{}'::jsonb
);

-- Promote the most-queried tenant's hot field to a column.
ALTER TABLE projects ADD COLUMN priority TEXT
  GENERATED ALWAYS AS (custom->>'priority') STORED;

CREATE INDEX idx_projects_org_priority
  ON projects (org_id, priority)
  WHERE custom->>'priority' IS NOT NULL;

-- App-tier code keeps writing free-form JSON; the report query above
-- now hits a composite BTree.

Joined cross-collection report

Webhook documents + a relational users table = one report query.

Stripe webhook payloads come in as documents (the upstream shape changes monthly). The reporting query joins them to the relational users table on the embedded customer_id — no ETL, no nightly sync, just SQL.

sql
Joined cross-collection report
SELECT
  u.email,
  COUNT(*) FILTER (WHERE e.body->>'type' = 'invoice.paid')      AS paid,
  COUNT(*) FILTER (WHERE e.body->>'type' = 'invoice.failed')    AS failed,
  SUM((e.body->'data'->'object'->>'amount')::int) FILTER (
    WHERE e.body->>'type' = 'invoice.paid'
  ) AS total_cents
FROM stripe_events e
JOIN users u ON u.stripe_customer_id = e.body->>'customer'
WHERE e.created_at > now() - INTERVAL '30 days'
GROUP BY u.email
ORDER BY total_cents DESC NULLS LAST;

Migrate from

Bring documents over from your current tool.

From

MongoDB

Stream a mongoexport JSONL into a RedDB document collection — one row per document, no schema design needed up-front.

Migrate from MongoDB
mongoexport --uri "mongodb://prod" --collection events \
  --jsonArray=false > events.jsonl

red import --collection events --as document \
  reds://admin@<db>.<org>.db.reddb.io:5050 \
  events.jsonl

From

Postgres JSONB

A JSONB column already maps cleanly. Move the row into a DOCUMENT collection with the same body field — queries keep working.

Migrate from Postgres JSONB
-- in source Postgres:
COPY (SELECT id, body, created_at FROM events) TO STDOUT;

-- in RedDB:
INSERT INTO events DOCUMENT (id, body, created_at)
SELECT $1, $2::jsonb, $3 FROM stdin;

Recipes

Common patterns, real driver code.

  • Audit log

    sql

    Append-only log of who-did-what — variable shape per actor type, queryable by path.

    sql
    INSERT INTO audit_log DOCUMENT (body) VALUES
      ('{"actor":"user:42","action":"role.grant","target":"user:99","meta":{"role":"admin"}}');
    
    SELECT body->>'actor', body->>'action', created_at
    FROM audit_log
    WHERE body->>'target' = 'user:99'
    ORDER BY created_at DESC LIMIT 50;
  • Webhook intake

    typescript

    Accept opaque provider payloads (Stripe, GitHub, Linear) without DDL each time the schema changes upstream.

    typescript
    app.post('/webhooks/stripe', async (req) => {
      const event = JSON.parse(req.rawBody)
      await db.query(
        'INSERT INTO stripe_events DOCUMENT (body) VALUES ($1)',
        [event],
      )
      return new Response('ok')
    })
  • Tenant-specific extra fields

    sql

    Per-tenant custom attributes alongside a strongly-typed core row.

    sql
    CREATE TABLE projects (
      id      UUID PRIMARY KEY,
      name    TEXT NOT NULL,
      custom  JSONB DEFAULT '{}'::jsonb
    );
    SELECT name, custom->>'priority' FROM projects WHERE custom->>'tenant' = 'acme';

Limits

What it does. What it costs.

Operational caps and durability semantics, no hand-waving.

Max document size
16 MB (per body field)
Path expression depth
No hard cap — index any path
Indexable paths per collection
Unlimited
Schema validation
Optional JSON Schema enforced on insert
Cross-model joins
Joinable to tables / vectors / time-series

When NOT to use

Don't pick documents for these.

Honest constraints — when another model fits better.

  • Don't use Documents for high-frequency time-stamped points

    A body field with timestamp + reading is what time-series collections are for. You pay JSON parse cost on every read for nothing.

  • Avoid documents that are 95% the same shape

    A regular table with one nullable JSONB extras column reads better and validates better. Documents earn their keep when shape varies.

  • Don't pretend documents replace relations

    Embedding posts inside users is a denormalisation pattern that hides update anomalies. Keep the relation, use a JOIN.

vs

How RedDB documents compare.

  • MongoDB

    Document semantics without losing relational joins, transactions, or wire compatibility with the rest of your stack.

  • Postgres JSONB

    Same path query model, same indexing — but on an engine that also speaks queues, vectors, time-series natively.

Pairs with

Documents earns more next to these.

The data models documents compose with — a click away.

  • Tables

    Join an `events` document collection to a `users` table by `body->>"user_id"` — same SQL planner, no glue.

    relational Tables guide
  • Vectors

    Documents make great RAG source content — auto-embed a `body` field and the vector index lives next to the JSON.

  • Graphs

    Documents reference graph nodes via `body->>"entity_id"` — traversal expands documents in the same query.

Questions teams ask before they pick documents.

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
  • Graphs

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

  • Vectors

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

  • Key-Value

    Configuration, feature flags, hot keys — millisecond reads.

    fast state Key-Value guide
  • 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 documents on RedDB.

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