Ask what an application actually stores and the answer is rarely one shape. The order is a row: keys, amounts, timestamps, foreign keys. Everything around the order — line-item options, device readings, customer preferences — changes shape faster than any migration schedule. So applications keep both: strict columns where the shape is stable, documents where it is not. The question is whether those two halves live in one system or two.
Two shapes, one application
The traditional answer is two systems: a relational database for the records and a document store for the flexible fields, joined in application code. That works until the question spans both halves — orders over a threshold whose customer profile prefers a channel — at which point the join happens in memory, without indexes, outside any transaction.
PLOMID treats the document as a value the relational engine understands rather than a foreign object. A JSON payload lives in the row, addressed with the same operators, filtered by the same predicates, committed by the same transaction. The SQL foundation covers the guarantees; this note covers the shape.
Querying JSON beside rows
Document fields participate in ordinary statements. The ->> operator extracts a text value from a payload, and it composes with joins, aggregates, and window functions like any column:
SELECT o.id, o.total, c.profile ->> 'tier' AS tier
FROM orders o
JOIN customers c ON c.id = o.customer_id
WHERE (c.profile ->> 'channel') = 'api'
AND o.placed_at > now() - INTERVAL '7 days'
ORDER BY o.total DESC
LIMIT 50;
Nothing about this statement knows or cares which side is “relational” and which is “document”. The planner sees one predicate set over one storage layer, so the same index and pruning machinery applies. Try it against the sample data in the playground.
The payload pattern
The pattern that earns its keep in practice: stable fields stay columns, volatile dimensions ride in a payload object. Time-ordered events work the same way — one row per event, a ts column indexed, hot JSON dimensions in payload:
{
"event": "meter.reading",
"device": "press-07",
"ts": "2026-09-23T08:14:00Z",
"payload": { "kwh": 41.7, "line": "B", "shift": "morning" }
}
SELECT date_trunc('hour', ts) AS hour, avg((payload ->> 'kwh')::float) AS kwh
FROM readings
WHERE ts > now() - INTERVAL '1 day' AND payload ->> 'line' = 'B'
GROUP BY 1
ORDER BY 1;
Because the event and its dimensions share a row, the time predicate prunes storage while the JSON predicate filters in place — the combination that time-series workloads depend on.
Relational versus document, honestly
| Concern | Columns | JSON payload |
|---|---|---|
| Shape | Fixed by schema, enforced on write | Flexible per row, enforced by the application |
| Indexing | B-tree on any column | Extracted paths where the query pattern is stable |
| Joins | Native, planned once | Joins through extracted values in the same statement |
| Transactions | Full BEGIN … COMMIT semantics | Same transaction — the payload commits with its row |
| Evolution | Migrations | No migration; old and new shapes coexist |
The table is not an argument that one side wins. It is the reason both sides must share a transaction: a migration and a payload update are the same deployment, and splitting them across systems turns a deploy into a distributed transaction.
What JSON does not do here
Two boundaries, stated plainly. There is no separate document query language — JSON is reached through SQL operators, which keeps one planner and one set of semantics. And there is no cross-document join engine beyond what the relational core already does; nested arrays are values to extract and aggregate, not tables to join against.
Documents are how the relational core stays honest about the parts of the application that never fit a column. The broader direction — rows, documents, events, and eventually similarity and relationship workloads through one layer — is laid out in Building a Unified Data Layer for Modern Applications and From Relational Data to Multi-Model Infrastructure.