Postgres JSONB vs JSON: Which One Should You Use?

Use jsonb. In the Postgres JSONB vs JSON choice, jsonb stores a parsed binary form that is faster to query, supports containment and existence operators, and can be indexed with GIN, B-tree and hash indexes. Plain json stores the exact input text and reparses it every time you touch it, so it only wins when you must keep the original text byte for byte, including key order, whitespace and duplicate keys.

That is the whole answer for most apps. The rest of this article shows the differences in SQL so you can see them yourself, covers the few cases where json is the better choice, and explains how to convert an existing json column without locking your app.

#The differences at a glance

jsonjsonb
Stored asThe input text, unchangedA decomposed binary format
WhitespaceKeptDropped
Key orderKept as writtenNot kept (Postgres uses its own internal order)
Duplicate keysAll kept; functions see the last oneOnly the last value is kept
WritingSlightly faster (just validates the text)Slightly slower (parses and converts)
Reading and queryingReparsed on every accessFast, no reparse
@>, <@, ?, ?|, ?&Not availableAvailable
@? and @@ (jsonpath)Not availableAvailable
IndexesOnly expression indexes on extracted valuesGIN on the whole document, plus B-tree and hash
Equality (=)Not definedDefined

The official PostgreSQL documentation makes the same recommendation: most applications should store JSON as jsonb unless they have specialized needs, such as legacy assumptions about the order of object keys. See the JSON types page for the full rules.

#See it in SQL

Run these in any Postgres 17 database, or in the SQL Editor of a StitchDB project.

#Whitespace and key order

SQL
select '{"b": 1,   "a": 2}'::json  as as_json,
       '{"b": 1,   "a": 2}'::jsonb as as_jsonb;
Text
     as_json        |     as_jsonb
--------------------+------------------
 {"b": 1,   "a": 2} | {"a": 2, "b": 1}

json gives you back exactly what you stored. jsonb normalizes spacing and reorders keys (shorter keys first, then by bytes). Your app should never depend on key order in a JSON object anyway; the JSON spec does not promise one.

#Duplicate keys

SQL
select '{"role": "user", "role": "admin"}'::json  ->> 'role' as from_json,
       '{"role": "user", "role": "admin"}'::jsonb ->> 'role' as from_jsonb,
       '{"role": "user", "role": "admin"}'::jsonb             as stored_jsonb;

Both lookups return admin, because the last value wins. But the json column still stores both keys, while jsonb stores only {"role": "admin"}. If an upstream system sends duplicate keys and you need to prove what it sent, that is an argument for json (or for storing the raw payload as text).

#Numbers

jsonb stores numbers as Postgres numeric. That means:

SQL
select '{"n": 1.230e-5}'::json  as as_json,
       '{"n": 1.230e-5}'::jsonb as as_jsonb;
-- as_json:  {"n": 1.230e-5}
-- as_jsonb: {"n": 0.00001230}

Exponent notation is expanded and trailing zeros are kept. Values beyond the range of numeric are rejected by jsonb but accepted by json. For normal app data neither matters; for scientific payloads it is worth knowing.

#Operators that only jsonb has

SQL
create table public.products (
  id bigint generated always as identity primary key,
  attrs jsonb not null default '{}'
);

insert into public.products (attrs) values
  ('{"color": "red", "sizes": ["S", "M"], "brand": "Acme"}'),
  ('{"color": "blue", "sizes": ["L"], "discontinued": true}');

-- Containment: rows whose attrs include this sub-document
select id from public.products where attrs @> '{"color": "red"}';

-- Existence: rows that have a top-level key
select id from public.products where attrs ? 'discontinued';

-- jsonpath: any size equal to "L"
select id from public.products where attrs @? '$.sizes[*] ? (@ == "L")';

Try the same queries on a json column and Postgres reports that the operator does not exist. With json you are limited to the extraction operators ->, ->>, #> and #>>, and to functions that reparse the text on every call.

#Why jsonb is faster to read

A json value is text. Every time a query runs attrs ->> 'color', Postgres parses the whole document to find the key. For a 20 KB document in a table of a million rows, that is a lot of repeated parsing.

A jsonb value has already been parsed. Postgres can jump to a key without scanning the text, and it can compare whole values for equality. The cost moves to write time, where the input is parsed once. For a typical app that reads far more than it writes, that trade is clearly worth it.

#Indexing is the bigger win

Speed per row matters less than being able to skip rows entirely. jsonb supports a GIN index over the whole document:

SQL
create index products_attrs_gin on public.products using gin (attrs);

That one index serves @>, ?, ?|, ?&, @? and @@ on any key. If you only use containment and jsonpath, the smaller jsonb_path_ops class is usually faster:

SQL
create index products_attrs_path on public.products using gin (attrs jsonb_path_ops);

With json the best you can do is an expression index on one extracted value, such as create index on t ((doc ->> 'email')). That works, and the same trick works for jsonb, but you need one index per key you filter on. The companion article Querying JSONB in Postgres covers operators and index choices in depth.

#When plain json is the right choice

There are a few honest cases for json:

  • Audit or webhook logs that must match the original bytes. If you verify a signature over the raw body, or you need to show a partner exactly what they sent, keep the original. Many teams store it as text or json next to a parsed jsonb copy.
  • Write-heavy append logs you rarely query. If you insert millions of payloads and read them only when debugging, skipping the parse on write saves a little CPU.
  • Order-sensitive consumers you cannot change. Some old clients expect keys in a fixed order. Fixing the client is better, but json buys time.

Even then, a common pattern is both: a raw json column for evidence and a generated jsonb column for queries.

SQL
create table public.webhook_events (
  id bigint generated always as identity primary key,
  received_at timestamptz not null default now(),
  raw json not null,
  body jsonb generated always as (raw::jsonb) stored
);

create index webhook_events_body_gin on public.webhook_events using gin (body jsonb_path_ops);

#When neither: use real columns

jsonb is not a replacement for a schema. If every row has an email, a status and a created_at, make them columns. You get type checks, not null, foreign keys, cheap B-tree indexes and clear statistics for the query planner. Postgres keeps statistics on columns, not on keys inside a JSON document, so filters on JSON keys often get poor row estimates.

A good rule: columns for anything you filter, join or sort on regularly; jsonb for the long tail of optional, varying or user-defined attributes. If you are weighing a document database for that flexibility, see Postgres JSONB vs MongoDB.

You can still validate the shape of a jsonb column with a check constraint:

SQL
alter table public.products
  add constraint attrs_is_object check (jsonb_typeof(attrs) = 'object');

For full JSON Schema validation, the pg_jsonschema extension is in the list of extensions available on StitchDB (see /docs/database/extensions).

#Converting json to jsonb

If you already have a json column, the conversion is one statement:

SQL
alter table public.events alter column payload type jsonb using payload::jsonb;

Be careful on large tables. Changing a column type rewrites the table and blocks reads and writes while it runs. On a table with millions of rows that can be minutes of downtime. The safer pattern:

  1. Add a new nullable jsonb column.
  2. Backfill it in batches of a few thousand rows.
  3. Update your code to write both columns, then read the new one.
  4. Drop the old column.
SQL
alter table public.events add column payload_b jsonb;

-- repeat until it updates 0 rows
update public.events set payload_b = payload::jsonb
where id in (
  select id from public.events where payload_b is null limit 5000
);

On StitchDB, the SQL Editor checks schema changes before they run: an alter column ... type on a busy table triggers a warning that it blocks reads and writes while the table is rewritten, with a safer version offered instead. The details are in Safe schema changes.

#Using jsonb columns on StitchDB

Every StitchDB project is a dedicated Postgres database, so jsonb works exactly as described above. A few platform specifics:

  • REST and SDK filters. The cs (contains), cd (contained by) and ov (overlaps) operators work on JSON columns through the auto-generated REST API. In the SDK:
JavaScript
import { createClient } from 'stitchdb-js'

const stitchdb = createClient('https://<project-ref>.p.stitchdb.com', '<anon-key>')

const { data, error } = await stitchdb
  .from('products')
  .select('id, attrs')
  .contains('attrs', { color: 'red' })
  • Indexes in Studio. Database, then Indexes, can create gin indexes as well as btree, gist, hash and brin, so you do not need SQL for the basic case.
  • Search. A search index can include json and jsonb columns, so text inside your documents is searchable with ranking.
  • Row Level Security still applies. A jsonb column is just a column. Policies on the table decide who can read it, as with any other data (Row Level Security).

#FAQ

#Is jsonb always faster than json?

For reads and queries, yes in practice, because json is reparsed on every access. For inserts, json is slightly faster since it only validates the text. Most apps read far more than they write, so jsonb comes out ahead.

#Does jsonb take more disk space than json?

It can be somewhat larger or smaller depending on the document, because it stores structure alongside values but drops whitespace and duplicate keys. Both are compressed by TOAST when large. Size is rarely the deciding factor; indexability is.

#Can I index a json column?

Only with expression indexes on extracted values, for example ((doc ->> 'email')). You cannot create a GIN index over a whole json document. Convert to jsonb if you need flexible indexed queries.

#Does jsonb keep the order of array elements?

Yes. Only object key order is lost. Arrays keep their element order in both types.

#Should I store everything in one jsonb column?

No. Put fields you filter, join or sort on in normal columns, and use jsonb for optional or varying attributes. You get better constraints and better query plans.

Try jsonb with GIN indexes on a real database: create a free StitchDB project.