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.

    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.

    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.

    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.

    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

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

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.

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.

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

sql

Audit log

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

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;

typescript

Webhook intake

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

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')
})

sql

Tenant-specific extra fields

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

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.

FAQ

Questions teams ask before they pick documents.

Do I lose schema enforcement entirely?

Only if you want to. A DOCUMENT collection is schemaless by default, but you can attach a JSON Schema validator that runs on insert when the shape stabilises. Mix-and-match is the norm.

Can I join a document collection to a regular table?

Yes. The query planner treats body->>field like any other expression, so JOIN events e ON e.body->>"user_id" = u.id::text works exactly as written.

How do I index a deeply-nested path?

Use a generated column or a functional index: CREATE INDEX idx ON t ((body->'meta'->>'tenant')). The optimiser uses it for any query with the same expression.

Is this really faster than just using Postgres JSONB?

Per-query, comparable. The win is operational: documents live next to the rest of your data — vectors, queues, time-series — on the same engine, with one backup story and one consistency story.

Try documents on RedDB.

Free nano database, no credit card. AGPL self-host or managed Cloud — same engine, same file format.