PostgreSQL has two JSON types: json and jsonb. The simple rule is: use jsonb. But the longer answer is worth knowing, because each has tradeoffs that matter at scale.

What the two types actually are

json stores the exact text you give it. Whitespace, key order, duplicate keys — all preserved. Every read re-parses the text. There's no index support beyond functional indexes on extracted values.

jsonb stores a parsed binary form. Whitespace is gone, keys are sorted (for fast lookup), duplicates collapse to last-wins. Writes are slightly slower because of the parsing; reads are dramatically faster because the parsing is already done. jsonb supports GIN indexes, allowing fast containment and key-existence queries.

For 95% of use cases, this comparison ends here: jsonb wins. The exceptions are below.

Concrete differences

Featurejsonjsonb
StorageText, as-inputParsed binary
Preserves whitespaceYesNo
Preserves key orderYesNo (alphabetical)
Preserves duplicate keysYesNo (last wins)
GIN index supportNoYes
Containment operator @>NoYes
Insert speedSlightly fasterSlightly slower
Read/query speedSlowerFaster

Querying jsonb

jsonb supports a rich set of operators. The four to know:

-- Extract a field as jsonb
SELECT data->'name' FROM users;

-- Extract a field as text
SELECT data->>'name' FROM users;

-- Extract a nested path
SELECT data#>'{address,city}' FROM users;

-- Containment: does data contain this subobject?
SELECT * FROM users WHERE data @> '{"role": "admin"}';

-- Key existence
SELECT * FROM users WHERE data ? 'phone';

The -> vs ->> distinction trips people up. -> returns jsonb, ->> returns text. For comparing against a string, use ->>. For chaining further extractions, use ->.

Indexing jsonb

For containment queries, GIN indexes are the go-to:

CREATE INDEX idx_user_data ON users USING gin (data);

-- Now this query is index-backed:
SELECT * FROM users WHERE data @> '{"role": "admin"}';

For queries on a specific field, use a B-tree functional index:

CREATE INDEX idx_user_email ON users ((data->>'email'));

-- Index-backed:
SELECT * FROM users WHERE data->>'email' = 'alice@example.com';

If you only ever query one path, the B-tree functional index is faster and smaller. If you query many paths or use containment, GIN.

When to use json instead

Audit logs where the exact original text matters. Storing the raw payload from a webhook for compliance purposes — you want to preserve byte-for-byte. Use json.

Insert-once, never-query. If you're writing 10,000 events/second and never querying inside the JSON, json avoids the parse cost on write. (You probably still want jsonb — even occasional queries benefit massively. But the option exists.)

You depend on key order or duplicate keys. Rare, but it happens. jsonb normalizes both away.

Common patterns

Sparse columns. You have a table with hundreds of optional fields. Modeling each as a column is wasteful (NULLs everywhere) and rigid (migrations for new fields). Put them in a jsonb column. Add functional indexes on the fields you query often.

User-defined fields. Multi-tenant SaaS where each tenant defines their own custom fields. jsonb is the natural fit — the schema lives in the data, not the table.

Tag-style filtering. Products with arbitrary tag/value pairs. jsonb with a GIN index handles "find products where data contains {"color": "red", "size": "L"}" cleanly.

When jsonb is the wrong tool

If you query a field on every row. Move it to a real column. It'll be faster, more space-efficient, and easier to constrain (NOT NULL, foreign key, CHECK).

If you do heavy joins on JSON fields. Postgres can do it but the planner has less information than with normal columns. Especially painful with millions of rows.

If the structure is well-known and stable. Don't use jsonb as a lazy alternative to designing tables. Schema migrations are temporary pain; querying the wrong shape is forever.

Validating jsonb shapes

Postgres doesn't natively enforce JSON Schema on a jsonb column, but you can do it with a CHECK constraint that calls a function:

CREATE EXTENSION IF NOT EXISTS pg_jsonschema;

ALTER TABLE users ADD CONSTRAINT data_shape
  CHECK (jsonb_matches_schema(data, '{
    "type": "object",
    "required": ["email"],
    "properties": { "email": {"type": "string", "format": "email"} }
  }'));

Before you commit to a schema in the database, validate your data with our JSON Schema Validator — it's much faster to iterate on a schema interactively than via failed inserts.

Wrap-up

Default to jsonb. Add GIN indexes for containment queries; B-tree functional indexes for hot single-field lookups. Move fields to real columns once you're sure you'll always have them and always query them. Use json only when the original text matters or write throughput is the bottleneck.