Articles

PostgreSQL JSONB Explained

JSONB stores JSON in PostgreSQL as a parsed, indexable binary format instead of raw text. How it works, and when to reach for it.

The Lycoris Team The Lycoris Team · · 4 min read
The PostgreSQL elephant logo

JSONB is a PostgreSQL column type that stores JSON data in a decomposed binary format instead of as raw text, which is what the older json type does. The tradeoff is a small amount of extra work on write — parsing and reformatting the document — in exchange for much faster reads, native indexing support, and the ability to query into nested structure without ever leaving SQL.

JSON vs JSONB

PostgreSQL has supported the plain json type since version 9.2, and jsonb since 9.4. Both store the same data model, but the storage representation and the operations available on each are meaningfully different.

jsonjsonb
StorageExact text copy of the inputParsed, decomposed binary format
Write speedFaster (no parsing)Slightly slower (parses on insert)
Read speedSlower (reparses every access)Faster (already parsed)
Key orderPreserved exactly as inputNot preserved
Duplicate keysPreservedLast value wins, duplicates discarded
IndexingNot directly indexableSupports GIN indexes
Containment operators (@>, ?)Not supportedSupported

Unless there’s a specific reason to preserve exact input formatting — an audit log storing an untouched copy of received payloads, for instance — jsonb is the default choice for anything that gets queried, filtered, or indexed.

Querying into JSONB documents

PostgreSQL exposes JSON-specific operators for reaching into a document without deserializing it in application code:

  • -> extracts a field as JSON (data->'address' returns a JSON object)
  • ->> extracts a field as text (data->>'name' returns a plain string)
  • #> and #>> do the same for a nested path given as an array (data#>>'{address,city}')
  • @> tests containment — whether the left JSONB value contains the right one as a subset
  • ? tests whether a top-level key exists

A query like SELECT * FROM users WHERE data @> '{"plan": "enterprise"}' filters directly on nested document structure, no application-side parsing required. This is the core appeal of JSONB over storing serialized JSON in a plain text column: the database itself can reason about the document’s contents.

Indexing JSONB with GIN

Without an index, filtering on a JSONB field forces a full table scan that deserializes every row’s document to check it — exactly the cost JSONB’s binary format was supposed to avoid. A GIN index (Generalized Inverted Index) solves this by indexing the keys and values inside the document, similar in purpose to how a B-tree index speeds up equality and range lookups on a plain column, but structured for the containment queries JSONB workloads actually run.

The default GIN operator class indexes every key and value in the document, which supports @>, ?, ?&, and ?| efficiently but produces a large index. The jsonb_path_ops operator class indexes only the paths that actually appear together with values, producing a smaller, often faster index — at the cost of supporting a narrower set of operators (@> only, not the key-existence operators). Choosing between them is a real tradeoff between index size and query flexibility, not a free upgrade either direction; see B-tree, GIN, and GiST indexes explained for how GIN compares to PostgreSQL’s other index types beyond JSONB specifically.

For queries that only ever touch a single known key rather than arbitrary document structure, an expression index on that specific path — CREATE INDEX ON events ((data->>'event_type')) — is often smaller and faster than a general-purpose GIN index over the whole document.

When JSONB is the right call, and when it isn’t

JSONB is a strong fit for data with a genuinely variable or evolving shape — user-supplied metadata, event payloads with per-type fields, configuration blobs, or third-party API responses you want to store without a rigid predefined column set. It lets you stay in a single, transactional, ACID-compliant database rather than reaching for a separate document store for that one piece of your schema — a common case against splitting a PostgreSQL vs MySQL or PostgreSQL vs MongoDB decision purely on document-flexibility grounds, since PostgreSQL’s JSONB support covers much of what a document database offers.

It’s a poor substitute for proper columns when the shape is actually fixed and known in advance. Querying inside a JSONB blob is slower than querying an indexed native column even with a GIN index in place, and you lose the type checking, foreign key constraints, and query planner statistics that a normal column gets for free — see when to denormalize a database for the broader tradeoff between flexible and structured schema design, and EXPLAIN ANALYZE for confirming which approach the planner actually favors for a given query rather than assuming.

The takeaway

JSONB trades a small write-time parsing cost for fast, indexable reads and native containment queries, making it the right default over the older json type for anything that gets queried rather than just stored and retrieved whole. GIN indexes make JSONB filtering fast at scale, but they’re a real design decision with size and flexibility tradeoffs, not a default to apply blindly. Reach for JSONB when a field’s shape is genuinely variable — not as a way to avoid designing a schema for data that actually has a fixed structure.

The Lycoris Team The Lycoris Team · · 4 min read

SQL CTEs vs Subqueries: When to Use Which

Common table expressions and subqueries both let you build a query from smaller pieces, but they differ in readability, reuse, and optimizer behavior.

#Databases #SQL #Backend
The Lycoris Team The Lycoris Team · · 4 min read

Natural Keys vs Surrogate Keys in Database Design

Natural keys use real-world data as a primary key; surrogate keys use a generated ID. Here's how to choose, with the trade-offs of each.

#Databases #SQL #Backend