paste PostgreSQL or JSON, get your schema reviewed like a senior would

JSON vs JSONB in PostgreSQL: when the B matters

PostgreSQL has two JSON types and the names undersell the difference. JSON stores the text you sent, byte for byte. JSONB parses it once on write and stores a binary tree. Everything that matters downstream, query speed, indexing, operators, follows from that one choice.

What JSONB buys you

CREATE TABLE events (
    id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    payload JSONB NOT NULL
);

SELECT * FROM events WHERE payload @> '{"type": "refund"}';
SELECT payload->>'customer_id' FROM events;

What JSONB costs

The write pays the parse, and the binary form doesn't keep what the text had: key order is lost, duplicate keys are collapsed (last one wins), whitespace is gone, and 1.50 may come back as 1.5. Updates rewrite the whole value, jsonb_set on one field stores a new copy of the document (normal MVCC behavior, but people expect in-place edits and size their tables wrong). Very large documents get TOASTed either way; ripping one hot field out into a real column beats tuning JSON access to it.

The rare cases for plain JSON

You're storing the payload as evidence: a webhook body that must be replayable byte-for-byte, a signed document whose hash covers the exact text, an audit trail where "what did they actually send" is the requirement. There, fidelity is the feature and JSON (or even TEXT) is honest. If you both need the evidence and want to query it, store the raw text once and a JSONB shadow column for querying.

The column that should have been columns

The failure mode worth flagging in review isn't JSON vs JSONB, it's JSONB as a schema escape hatch. When every query touches payload->>'status' and the application can't function unless customer_id is inside the blob, those are columns wearing a JSON costume: no types, no NOT NULL, no FK, no cheap index. Keep the document for what's genuinely document-shaped (variable vendor payloads, user-defined attributes) and promote what the schema depends on:

CREATE TABLE events (
    id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    event_type VARCHAR(40) NOT NULL,
    customer_id BIGINT REFERENCES customers (id),
    payload JSONB NOT NULL,
    created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

In the generated code

Either type reaches Java as a String by default (or a mapped type via @JdbcTypeCode(SqlTypes.JSON)), Python as dict, TypeScript as unknown, the honest type, since nothing in the DDL says what's inside. If the payload has a stable shape, paste a sample of it and generate a real model for the application boundary; the database column stays JSONB, the code stops passing unknown around.

Schemint flags plain JSON columns when you paste your DDL, JSONB is almost always what you meant, and the migration is a one-line type change while the table is small.

Related