Comparison

Postgres jsonb vs json: Which Type Should You Use?

Compare Postgres json and jsonb types: storage format, query performance, GIN indexing, operators, and when to pick each with real SQL examples.

Postgres jsonb vs json: Which Type Should You Use?

PostgreSQL ships two JSON column types that look identical from the outside but behave very differently on disk and at query time. Picking the wrong one can cost you index support, query speed, and predictable read behavior. Here is the practical breakdown.

Storage: text vs binary

The json type stores the input exactly as you sent it, as text. Whitespace is preserved, duplicate keys are preserved, and key order is preserved. Every read reparses the string.

The jsonb type stores a decomposed binary representation. Whitespace is stripped, keys are deduplicated (the last value wins), and key order is not preserved. Parsing happens once, on write.

CREATE TABLE demo (a json, b jsonb);
INSERT INTO demo VALUES
  ('{"k": 1, "k": 2,   "x": "  spaced  "}',
   '{"k": 1, "k": 2,   "x": "  spaced  "}');

SELECT a, b FROM demo;
-- a: {"k": 1, "k": 2,   "x": "  spaced  "}   (as-is)
-- b: {"k": 2, "x": "  spaced  "}             (deduped, whitespace gone)

Notice the value inside the string is untouched in both cases; only structural whitespace and duplicate keys are normalized in jsonb.

Operators: the same syntax, different speed

All the standard JSON operators work on both types:

  • -> get object field or array element as JSON
  • ->> get object field or array element as text
  • #> get value at path as JSON
  • #>> get value at path as text

These are jsonb-only:

  • @> contains
  • <@ is contained by
  • ? key exists
  • ?| any of these keys exist
  • ?& all of these keys exist
-- Works on both types
SELECT data->'user'->>'email' FROM events;
SELECT data#>>'{address,city}' FROM events;

-- jsonb only
SELECT * FROM events WHERE data @> '{"status":"paid"}';
SELECT * FROM events WHERE data ? 'refund_id';
SELECT * FROM events WHERE data ?| array['email','phone'];

Because jsonb is already parsed, extraction is significantly faster, especially for deep paths and repeated access in the same query.

Indexing: GIN is jsonb-only

This is the single biggest reason most teams pick jsonb. You cannot build a GIN index on a json column.

-- Default jsonb_ops: supports @>, ?, ?|, ?&
CREATE INDEX idx_events_data ON events USING GIN (data);

-- Smaller, path-value only: supports @> but not ?
CREATE INDEX idx_events_data_path ON events USING GIN (data jsonb_path_ops);

-- B-tree on an extracted scalar works on both types via expression index
CREATE INDEX idx_events_status ON events ((data->>'status'));

With a GIN index, containment queries like WHERE data @> '{"plan":"pro"}' stay fast across millions of rows. Without it, Postgres does a sequential scan and reparses the text every row on a json column.

Disk footprint

jsonb is usually slightly larger on disk than the equivalent json, because of the binary header, jentry structure, and offset table. In exchange you get O(1) field lookup instead of reparsing. In most workloads this trade is a landslide win.

Write cost

jsonb is slower to insert because Postgres parses and normalizes on the way in. json is essentially a text cast. If you have a write-heavy pipeline that just archives payloads and never queries into them, json can be the right call.

When to use each

Use jsonb whenUse json when
You will query, filter, or index inside the documentYou just need to store and return payloads verbatim
You need containment (@>) or key existence checksWhitespace, duplicate keys, or key order carry meaning (audit logs, webhook receipts, JWT payloads)
You need GIN indexes for searchWrite throughput matters more than read cost
You do repeated extraction in the same queryThe column is effectively a text blob

Real-world example

CREATE TABLE orders (
  id       bigserial PRIMARY KEY,
  payload  jsonb NOT NULL,
  received timestamptz DEFAULT now()
);

CREATE INDEX idx_orders_payload ON orders USING GIN (payload jsonb_path_ops);
CREATE INDEX idx_orders_country ON orders ((payload#>>'{shipping,country}'));

-- Fast, uses GIN
SELECT id FROM orders WHERE payload @> '{"status":"fulfilled"}';

-- Fast, uses expression index
SELECT count(*) FROM orders
WHERE payload#>>'{shipping,country}' = 'DE';

-- Update a nested field
UPDATE orders
SET payload = jsonb_set(payload, '{shipping,tracking}', '"1Z999"')
WHERE id = 42;

The default

If you have no reason to preserve the raw bytes of the input, start with jsonb. Reach for json only when byte-for-byte fidelity is a hard requirement or when the column is truly write-only storage.

postgres jsonb vs json jsonb json gin index postgresql json operators jsonb performance postgres data types jsonb indexing

Explore 300+ Free Tools

Utilko has tools for developers, writers, designers, students, and everyday users — all free, all browser-based.