JSON in databases and storage
Query and index JSON columns in PostgreSQL and MySQL, work with nested documents in MongoDB, and recognise when normalising is the better answer.
PostgreSQL json and jsonb
CREATE TABLE events (
id bigserial PRIMARY KEY,
received_at timestamptz NOT NULL DEFAULT now(),
payload jsonb NOT NULL
);
-- json keeps the exact text; jsonb parses, deduplicates keys and is indexable
ALTER TABLE events
ADD CONSTRAINT payload_is_object CHECK (jsonb_typeof(payload) = 'object');
-- extraction: -> returns json, ->> returns text
SELECT payload ->> 'level' AS level,
payload -> 'context' ->> 'request_id' AS request_id
FROM events;
-- nested paths and array elements
SELECT payload #>> '{context,tags,0}' FROM events;
SELECT jsonb_array_length(payload -> 'items') FROM events;-- containment: does the payload contain this fragment?
SELECT * FROM events WHERE payload @> '{"level":"error"}';
-- does any of these keys exist?
SELECT * FROM events WHERE payload ?| array['error','exception'];
-- a generated column makes a hot field a first-class column
ALTER TABLE events
ADD COLUMN level text GENERATED ALWAYS AS (payload ->> 'level') STORED;
CREATE INDEX events_level_idx ON events (level, received_at DESC);
-- indexing the whole document, for containment and path queries
CREATE INDEX events_payload_idx ON events USING gin (payload jsonb_path_ops);| Type | Stored as | Index | Use for |
|---|---|---|---|
json | Exact text | Not directly | Preserving formatting and key order |
jsonb | Parsed binary | GIN, expression | Everything else |
| Generated column | A real column | B-tree, composite | Fields you filter and sort on |
jsonb_path_ops | Smaller GIN | Containment only | When you only use @> |
💡
A GIN index on
jsonb makes containment queries fast and makes every write slightly slower, because the index has to be maintained for a document that may never be queried that way. Add it when you can show the query it serves, not in anticipation.MySQL and MongoDB
-- MySQL: a JSON column with an extraction path
CREATE TABLE events (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
payload JSON NOT NULL,
level VARCHAR(16) GENERATED ALWAYS AS (payload ->> '$.level') STORED,
KEY events_level_idx (level)
);
SELECT payload ->> '$.context.request_id' AS request_id
FROM events
WHERE JSON_EXTRACT(payload, '$.level') = 'error';
-- a functional index on a nested path
CREATE INDEX events_request_idx
ON events ((payload ->> '$.context.request_id'));// MongoDB: documents are the native form
await db.collection("events").insertOne({
level: "error",
context: { requestId: "req_7c1a", tags: ["api", "eu"] },
receivedAt: new Date(),
});
// dot notation reaches into nested objects and arrays
await db.collection("events").find({ "context.tags": "api" }).toArray();
// match one element of an array against several conditions
await db.collection("events").find({
items: { $elemMatch: { sku: "A-1", quantity: { $gte: 2 } } },
}).toArray();
// an index on a nested field, and one on an array field
await db.collection("events").createIndex({ "context.requestId": 1 });
await db.collection("events").createIndex({ "context.tags": 1 }); // multikey
// project only what you need, which is the main lever on read cost
await db.collection("events")
.find({ level: "error" }, { projection: { "context.requestId": 1, receivedAt: 1 } })
.toArray();- A multikey index covers every element of an array in a document; one index can therefore serve a query over any tag.
- Projection matters more here than in a relational database: fetching a whole document to read one field moves a lot of bytes.
- An unbounded array inside a document is the classic modelling mistake - it grows until the document hits the 16 MB limit and updates become slow.
When to normalise instead
| Situation | JSON column | Real columns |
|---|---|---|
| Unknown or varying shape | Good fit | Needs migrations for every change |
| Provider webhook payloads | Good fit | Mostly stored, rarely queried |
| A field you filter on constantly | Generated column, or move it | Best as a column |
| A field with a foreign key | Awkward | A column with a real constraint |
| Values needing unique or check constraints | Not enforceable easily | Enforced by the database |
| Anything you join on | Poorly | A column |
| Audit trail of what was received | Good fit | Keep the raw form too |
- Store the raw payload in a JSON column for fidelity - it is your record of what actually arrived.
- Promote the fields you query into generated columns or real columns, and index those.
- Keep relational data relational. A foreign key inside JSON is a reference the database cannot protect.
- Watch for the moment a JSON column gains a schema in practice: once every row has the same keys, it is a table that has not been migrated yet.
- Add a check constraint on the type and required keys, so a malformed document cannot be inserted even before you have migrated.
-- a schema in practice, expressed as a constraint
ALTER TABLE events ADD CONSTRAINT payload_shape CHECK (
jsonb_typeof(payload) = 'object'
AND payload ? 'level'
AND jsonb_typeof(payload -> 'level') = 'string'
);FAQ
json or jsonb in PostgreSQL?
Almost always
jsonb: it is indexable, supports containment and path operators, and normalises duplicate keys. Use json only when you must preserve the exact text you received, such as storing a signed payload for verification.Should I put everything in one JSON column?
No. Fields you filter, sort, join or constrain belong in real columns, where the database can enforce and index them. A JSON column is for the parts of the data that are genuinely variable, plus the raw record of what arrived.
Related
JSON Lines, streaming and large documents When to use something else
Last refreshed 2026-09-18.