Every team that ever picked a document store for “flexibility” ended up writing the validation layer by hand six months later, and every team that picked a strict relational schema ended up with an extra_data TEXT column full of serialized junk that nothing could query, and the reason both endings keep happening is that the industry framed the decision as a binary when the data was never binary to begin with. The fields you build indexes on, join on, and bill against are structured and stable, and the payload around them, the webhook body, the feature flags, the third-party response you do not control, is genuinely variable, and forcing either half into the other half’s model is where the pain comes from. MongrelDB’s answer is that JSON is a column type, not a separate database, and it is worth walking through what that actually means at the storage layer, because “we support JSON” has been printed on a lot of boxes that were selling you a TEXT column with a marketing budget.

What a typed JSON column actually does

When you declare a column JSON in MongrelDB, the engine treats it as a first-class type in the same catalog as INT64 or DECIMAL128, not as a suggestion, and the first thing that buys you is validation at write time, because the server parses every JSON value before it accepts the row; if the bytes are not valid UTF-8 you get an error, if they do not parse as JSON you get an error, and the malformed payload never touches your table. That sounds small until you remember how the alternative goes, which is that a bad producer writes half a JSON document at 3 AM, the insert succeeds because the column is really TEXT, and you find out three weeks later when a reporting query dies on row 40 million and someone has to go hunting for the poisoned record by bisecting primary keys.

CREATE TABLE orders (
    order_id    UUID PRIMARY KEY,
    customer_id BIGINT NOT NULL,
    total       DECIMAL(19, 4) NOT NULL,
    placed_at   TIMESTAMP NOT NULL,
    payload     JSON
);

INSERT INTO orders (order_id, customer_id, total, placed_at, payload)
VALUES (
    'a8098c1a-f86e-11da-bd1a-00112444be1e',
    48151623,
    129.9900,
    '2026-07-31T08:55:00Z',
    '{"channel": "web", "items": [{"sku": "KB-87", "qty": 1}], "meta": {"coupon": null}}'
);

The typed columns around the JSON stay typed, so total still sums exactly, placed_at still sorts as time, and the planner still knows the byte width of everything it scans, while payload holds the part of the order that genuinely varies from one integration to the next. There is also a JSONB alias in the DDL, and if you came up on Postgres that name means something specific to you, so the honest footnote is that MongrelDB maps both spellings onto the same canonical JSON text model rather than maintaining a second binary format; the alias exists so that SQL written against Postgres habits parses and runs, and the storage decision stays the same either way. The concrete cost of that choice deserves naming, because Postgres JSONB is a pre-parsed binary form that skips re-parsing on every access, while a canonical text model pays a parse each time a JSON function touches the value, and in exchange you keep a storage format that is human-readable in a hex dump, trivial to diff, and impossible to corrupt into a state only the engine understands.

Querying from the inside

The reason opaque TEXT columns fail is not the storage, it is that you cannot ask questions of the contents without dragging every row into application code, so the second half of the feature is that the engine ships a full family of JSON functions that evaluate inside the query, where the filtering happens. Extraction uses the path syntax you already know, root $, object keys with .key, array indexes with [n], and the result of a scalar extraction is a scalar you can compare, sort, and filter on like any other value.

SELECT order_id, total
FROM orders
WHERE json_extract(payload, '$.channel') = 'web'
  AND json_array_length(payload, '$.items') > 0;

SELECT json_extract(payload, '$.items[0].sku') AS first_sku,
       json_array_length(payload, '$.items') AS line_count
FROM orders;

When the shape of the JSON is the question, the table functions json_each and json_tree expand a document into rows, so an array of line items becomes something you can aggregate over without leaving SQL, which is the difference between “the database holds my JSON” and “the database reads my JSON.” The write side is covered too, with json_set, json_insert, json_replace, json_remove, and RFC 7396-style json_patch, so you can update one key inside a stored document in a single statement instead of the read-modify-write dance in application code that quietly loses updates the moment two requests race.

SELECT order_id, json_extract(item.value, '$.sku') AS sku
FROM orders, json_each(orders.payload, '$.items') AS item;

UPDATE orders
SET payload = json_set(payload, '$.meta.fulfilled', json('true'))
WHERE order_id = 'a8098c1a-f86e-11da-bd1a-00112444be1e';

One behavior worth memorizing before you build on this: json_extract returns a SQL scalar when the path lands on a scalar, but when the path lands on an object or an array you get JSON text back, not some nested SQL value, so the pattern is to extract to the scalar you need or to hand the document to json_each and json_tree when you actually want the structure.

Where the trade actually sits

None of this turns MongrelDB into a document store, and pretending otherwise would be the kind of box-printing I opened with, so here is the honest ledger. Secondary indexes in MongrelDB are built on columns, which means a predicate buried three levels inside a JSON document is a function evaluation over the rows the rest of your WHERE clause selected, not an index seek, and there is no generated-column or expression-index escape hatch today, so you cannot index json_extract(payload, '$.channel') directly the way you would in SQLite or Postgres; if you find yourself filtering every query on the same nested key, the right move is to promote that key to a real typed column with a real index, which is exactly the discipline a document store lets you avoid until the collection gets big enough to punish you for it. What you give up against a purpose-built document database is automatic deep indexing of arbitrary paths, and what you get back is that one transaction covers the typed columns and the document together, one WAL guarantees both, one backup restores both, and you never run the second system, the second client, and the sync job between them that eventually becomes the incident.

The modern equivalent framing, for those of us who lived through it: we spent the 2000s stuffing serialize() output into TEXT columns in MySQL and calling it flexible, and we spent the 2010s standing up whole separate document clusters to atone for it, when what most applications actually needed was a typed schema with one honest JSON column and an engine that validated and queried it. Postgres got there with JSONB and the industry agreed that was the right shape, and MongrelDB takes the same bet into an embedded, single-binary engine, where the column next to your JSON is a DECIMAL128 that sums to the penny and a timestamp that sorts as time, and the document and the row commit together or not at all.