MinhVo

Minh Vo

rss feed

Slaying code & making it lit fr fr 🔥 tagline

Hey there 👋 I'm an AI Engineer with 7 years of experience building scalable web and mobile applications. Currently at Neurond AI (May 2025 — present), architecting an Enterprise AI Assistant Platform with multi-tenant RAG on pgvector, multi-provider LLM orchestration, and Azure-native infrastructure. Previously spent 5+ years at SNAPTEC (Sep 2019 — Apr 2025), leading SaaS themes, admin dashboards, and e-commerce platforms — earned the Hero of the Year award in 2021. I specialize in TypeScript, React, Next.js, and AI-Native engineering with Claude Code and Cursor.bio

Back to blogs

PostgreSQL JSONB: Working with JSON in SQL

Use JSONB in PostgreSQL: indexing, querying, operators, and when to use JSONB vs normalized tables.

PostgreSQLJSONBJSONDatabase

By MinhVo

Introduction

PostgreSQL's JSONB data type has fundamentally changed how developers think about relational databases. Rather than choosing strictly between a rigid relational schema and a completely schemaless NoSQL document store, JSONB gives you a powerful middle ground. You can store semi-structured, nested JSON documents directly inside your PostgreSQL tables, index them efficiently, and query them with expressive operators—all without sacrificing the ACID guarantees, mature tooling, and ecosystem that make PostgreSQL the most loved database among developers.

Why does this matter in 2020 and beyond? Modern applications increasingly deal with heterogeneous data. User profiles may have custom fields that differ per tenant. Event logs contain payloads shaped differently depending on event type. Configuration data evolves faster than your migration pipeline can keep up with. In each of these scenarios, forcing every column into a rigid schema creates friction. JSONB removes that friction while keeping you inside PostgreSQL's transactional world.

In this comprehensive guide, you will learn exactly how JSONB works under the hood, when to use it versus normalized tables, how to index and query JSONB columns efficiently, and how to avoid the performance traps that catch developers off guard. We will cover real-world use cases, best practices for production, and advanced techniques like GIN indexing, containment operators, and partial indexing on JSONB paths.

PostgreSQL JSONB architecture overview

Understanding JSONB: Core Concepts

PostgreSQL actually offers two JSON-related data types: json and jsonb. Understanding the difference is critical before you start building anything.

The json type stores an exact copy of the input JSON text. It preserves key ordering, whitespace, and duplicate keys. Because it stores the raw text, operations on json values require re-parsing the document every time you access a field. This makes json slightly faster to insert (since there is no parsing overhead on write) but significantly slower to query.

The jsonb type, by contrast, stores JSON in a decomposed binary format. On insertion, PostgreSQL parses the JSON document and stores it as a structured binary representation. Key ordering is not preserved, duplicate keys are eliminated (last wins), and whitespace is discarded. This binary format enables indexing and dramatically faster query performance because PostgreSQL can navigate the document structure without re-parsing text on every access.

The binary format of jsonb also enables operators like containment (@>), existence checks (?, ?|, ?&), and path traversal (->, ->>, #>, #>>). These operators can be accelerated by GIN (Generalized Inverted Index) indexes, which we will cover in detail later.

JSONB Storage Model

Internally, jsonb values are stored as a sequence of nodes. Each node has a type header indicating whether it is an object, array, string, number, boolean, or null. Objects store key-value pairs sorted by key, and arrays store elements in order. This representation allows PostgreSQL to skip directly to a nested key without deserializing the entire document—a critical performance advantage over text-based JSON storage.

The storage overhead of jsonb compared to json is modest. Binary representation is typically similar in size to the original JSON text, and TOAST (The Oversized-Attribute Storage Technique) compression applies to jsonb just as it does to any other large column. You should generally prefer jsonb unless you have a specific reason to preserve the exact textual representation of your JSON input.

Type Safety and Validation

Both json and jsonb enforce valid JSON on insertion. If you try to insert malformed JSON, PostgreSQL will reject the statement with a clear error. However, neither type enforces any schema or structure within the JSON document itself. You can store a flat object in one row and a deeply nested array in another row of the same column. This flexibility is a double-edged sword, and we will discuss schema validation strategies later.

JSONB type system and validation

Architecture and Design Patterns

When to Use JSONB vs Normalized Tables

The decision to use jsonb should not be a binary choice between "all relational" and "all document." Instead, think of jsonb as a tool for specific parts of your data model where flexibility outweighs the benefits of rigid columnar structure.

Use JSONB when:

  • The shape of data varies across rows (e.g., custom fields per user, per-tenant configuration)
  • You need to store deeply nested or hierarchical data that would require many JOINs to normalize
  • The data is read-heavy but rarely filtered or joined on individual nested properties
  • You are building an event sourcing or audit log system where each event has a different payload shape
  • You want to evolve your data schema without running migrations for every field addition

Use normalized tables when:

  • You need foreign key constraints and referential integrity between entities
  • You need to perform aggregate queries (SUM, AVG, COUNT) over nested properties
  • Individual fields within the JSON will be used in WHERE clauses on high-cardinality columns with selective indexes
  • You need strict type checking at the database level (e.g., ensuring a nested field is always a TIMESTAMP)
  • You need to join the data with other tables frequently

Hybrid Schema Pattern

The most effective architecture uses a hybrid approach. Store stable, well-known columns as traditional relational columns and relegate flexible or variable-structure data to a jsonb column.

CREATE TABLE orders (
    id BIGSERIAL PRIMARY KEY,
    customer_id BIGINT NOT NULL REFERENCES customers(id),
    status VARCHAR(20) NOT NULL DEFAULT 'pending',
    total_cents BIGINT NOT NULL,
    currency CHAR(3) NOT NULL DEFAULT 'USD',
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    -- Flexible metadata in JSONB
    metadata JSONB DEFAULT '{}'::jsonb,
    -- Shipping details that vary by fulfillment type
    shipping_details JSONB DEFAULT '{}'::jsonb
);

This pattern lets you query customer_id, status, and total_cents with traditional indexes and foreign keys while storing variable-length metadata in metadata and shipping_details.

Document-per-Table Pattern

In some cases, you may want to store an entire document as a single JSONB column in a table with minimal relational columns. This pattern works well for logging, event sourcing, or storing third-party API responses.

CREATE TABLE events (
    id BIGSERIAL PRIMARY KEY,
    event_type VARCHAR(100) NOT NULL,
    occurred_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    payload JSONB NOT NULL
);

Collection-as-Object vs Collection-as-Array

When structuring your JSONB data, decide whether a collection of items should be stored as an array of objects or as an object keyed by a unique identifier:

-- Array of items (good for ordered lists)
UPDATE products SET tags = '["electronics", "sale", "featured"]'::jsonb WHERE id = 1;
 
-- Object keyed by ID (good for lookups by key)
UPDATE users SET preferences = '{"theme": "dark", "lang": "en", "tz": "UTC"}'::jsonb WHERE id = 1;

Arrays are natural for ordered collections but require scanning the entire array to check for element existence. Objects keyed by ID allow O(1) key lookups but do not preserve insertion order (though this rarely matters in practice).

JSONB architecture patterns

Step-by-Step Implementation

Creating Tables with JSONB Columns

Start by defining your tables with appropriate JSONB columns. Always set a default value to avoid null handling headaches:

CREATE TABLE user_profiles (
    id BIGSERIAL PRIMARY KEY,
    email VARCHAR(255) NOT NULL UNIQUE,
    display_name VARCHAR(100),
    attributes JSONB NOT NULL DEFAULT '{}'::jsonb,
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
 
-- Insert a user with custom attributes
INSERT INTO user_profiles (email, display_name, attributes)
VALUES (
    'alice@example.com',
    'Alice Chen',
    '{
        "age": 29,
        "location": {"city": "San Francisco", "state": "CA"},
        "interests": ["postgresql", "golang", "hiking"],
        "settings": {"notifications": true, "theme": "dark"}
    }'::jsonb
);

Basic Querying with Arrow Operators

PostgreSQL provides a rich set of operators for navigating and extracting values from JSONB documents:

-- -> returns JSONB (keeps the value as JSON type)
-- ->> returns text (extracts the value as a plain text)
 
-- Get the city as JSONB
SELECT attributes -> 'location' -> 'city' AS city_jsonb
FROM user_profiles WHERE email = 'alice@example.com';
-- Returns: "San Francisco" (as JSONB string, with quotes)
 
-- Get the city as text
SELECT attributes -> 'location' ->> 'city' AS city_text
FROM user_profiles WHERE email = 'alice@example.com';
-- Returns: San Francisco (as plain text, no quotes)
 
-- Access array elements by index (0-based)
SELECT attributes -> 'interests' ->> 0 AS first_interest
FROM user_profiles WHERE email = 'alice@example.com';
-- Returns: postgresql
 
-- Deep path extraction with #>> operator
SELECT attributes #>> '{settings,theme}' AS theme
FROM user_profiles WHERE email = 'alice@example.com';
-- Returns: dark

Updating JSONB Values

Updating JSONB data requires specific functions depending on whether you want to set, merge, remove, or append:

-- Set a single nested key (creates intermediate objects if needed)
UPDATE user_profiles
SET attributes = jsonb_set(attributes, '{settings,theme}', '"light"')
WHERE email = 'alice@example.com';
 
-- Add a new key at the top level
UPDATE user_profiles
SET attributes = attributes || '{"newsletter_subscribed": true}'::jsonb
WHERE email = 'alice@example.com';
 
-- Remove a key
UPDATE user_profiles
SET attributes = attributes - 'newsletter_subscribed'
WHERE email = 'alice@example.com';
 
-- Remove a nested key using path
UPDATE user_profiles
SET attributes = attributes #- '{settings,theme}'
WHERE email = 'alice@example.com';
 
-- Append to an array
UPDATE user_profiles
SET attributes = jsonb_set(
    attributes,
    '{interests}',
    (attributes -> 'interests') || '"databases"'::jsonb
)
WHERE email = 'alice@example.com';

The jsonb_set function takes the target document, a path array, and the new value. The path uses text array syntax where each element is a key name or array index. If the path does not exist, jsonb_set will create intermediate objects along the path by default.

Querying with WHERE Clauses

You can filter rows based on JSONB content using containment and existence operators:

-- Containment operator @>: does the left value contain the right?
SELECT * FROM user_profiles
WHERE attributes @> '{"location": {"city": "San Francisco"}}';
 
-- Check if a top-level key exists
SELECT * FROM user_profiles
WHERE attributes ? 'newsletter_subscribed';
 
-- Check if any of several keys exist
SELECT * FROM user_profiles
WHERE attributes ?| array['newsletter_subscribed', 'age'];
 
-- Check if all keys exist
SELECT * FROM user_profiles
WHERE attributes ?& array['age', 'interests'];
 
-- Filter on nested values using path extraction
SELECT * FROM user_profiles
WHERE (attributes -> 'location' ->> 'city') = 'San Francisco';
 
-- Filter on array containment
SELECT * FROM user_profiles
WHERE attributes -> 'interests' @> '["postgresql"]';

Real-World Use Cases

Use Case 1: Multi-Tenant Custom Fields

SaaS applications often need per-tenant custom fields. Instead of creating a separate custom_fields table for each tenant or running migrations every time a tenant needs a new field, store custom field definitions and values in JSONB:

CREATE TABLE tenant_fields (
    tenant_id BIGINT NOT NULL,
    entity_type VARCHAR(50) NOT NULL,
    field_definitions JSONB NOT NULL DEFAULT '[]',
    PRIMARY KEY (tenant_id, entity_type)
);
 
CREATE TABLE entities (
    id BIGSERIAL PRIMARY KEY,
    tenant_id BIGINT NOT NULL,
    entity_type VARCHAR(50) NOT NULL,
    standard_data JSONB NOT NULL,
    custom_data JSONB NOT NULL DEFAULT '{}'
);

Each tenant can define its own set of fields (e.g., text, dropdown, date) and the entity rows store the actual values in custom_data. Queries on custom_data use the same JSONB operators we covered earlier.

Use Case 2: Event Sourcing and Audit Logs

Event sourcing systems capture every state change as an immutable event. The event payload varies by event type, making JSONB a natural fit:

INSERT INTO events (event_type, payload) VALUES
('user.created', '{"user_id": 42, "email": "alice@example.com", "plan": "pro"}'),
('order.placed', '{"order_id": 1001, "items": [{"sku": "ABC", "qty": 2}], "total": 5998}'),
('payment.processed', '{"payment_id": "pay_123", "amount": 5998, "method": "card", "card_last4": "4242"}');

You can then query across all event types using JSONB containment or aggregate specific fields from a subset of events:

-- Find all events related to user 42
SELECT * FROM events WHERE payload @> '{"user_id": 42}';
 
-- Find all card payments over $50
SELECT * FROM events
WHERE event_type = 'payment.processed'
AND payload @> '{"method": "card"}'
AND (payload ->> 'amount')::int > 5000;

Use Case 3: API Response Caching

When integrating with third-party APIs, you often need to cache raw responses. The response format may change without notice, and you rarely need to query deeply into the cached data:

CREATE TABLE api_cache (
    provider VARCHAR(50) NOT NULL,
    external_id VARCHAR(255) NOT NULL,
    response JSONB NOT NULL,
    cached_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    expires_at TIMESTAMPTZ NOT NULL,
    PRIMARY KEY (provider, external_id)
);

Best Practices for Production

  1. Default to jsonb, not json: Always use the jsonb type unless you have a documented reason to preserve exact textual fidelity. The jsonb type supports indexing, is faster to query, and has identical storage characteristics in most cases.

  2. Create GIN indexes for containment queries: If you use @> or ? operators in WHERE clauses, create a GIN index on the JSONB column. Without a GIN index, every containment query will require a full table scan.

CREATE INDEX idx_user_profiles_attrs ON user_profiles USING GIN (attributes);
  1. Use expression indexes for frequently queried paths: If you regularly filter on a specific JSONB path, create an expression index on that path instead of relying on a full GIN index.
CREATE INDEX idx_user_profiles_city
ON user_profiles ((attributes -> 'location' ->> 'city'));
  1. Set jsonb_populate_record defaults for type casting: When extracting typed values, always cast explicitly. The ->> operator returns text, so you need ::int, ::boolean, etc.

  2. Validate JSONB structure at the application layer: PostgreSQL does not enforce JSONB schema, so validate the structure in your application code before insertion. Consider using JSON Schema validation libraries.

  3. Avoid deeply nested JSONB queries in high-traffic paths: Deep path extraction (#>> '{a,b,c,d}') cannot be efficiently indexed in all cases. If you frequently query a deeply nested field, consider promoting it to a top-level column or expression index.

  4. Use partial indexes to reduce index size: If you only need to index JSONB values for a subset of rows, use a partial index with a WHERE clause.

CREATE INDEX idx_active_user_attrs ON user_profiles USING GIN (attributes)
WHERE status = 'active';
  1. Monitor JSONB bloat: Because jsonb values are variable-length and updated in place, heavy updates can cause table bloat. Use VACUUM and monitor dead tuple counts.

Common Pitfalls and Solutions

PitfallImpactSolution
Using json instead of jsonbCannot create GIN indexes; slower queriesAlways use jsonb unless you need exact text preservation
No index on JSONB columnFull table scan on every @> or ? queryCreate a GIN index: USING GIN (column)
Querying deeply nested paths without indexSlow queries on large tablesCreate expression indexes on frequently queried paths
Updating entire JSONB object on every changeWrite amplification and bloat; lock contentionUse jsonb_set or `
Storing large arrays in JSONBArray scans are O(n); no individual element indexingFor large or frequently queried collections, normalize into a separate table
Casting ->> to wrong typeRuntime errors on bad dataUse COALESCE and CASE to handle missing or type-mismatched values
Using jsonb_set with nonexistent pathReturns original object unchanged or errorsCheck path existence before calling jsonb_set, or use jsonb_insert for adding new keys

Performance Optimization

GIN Index Strategies

The GIN index is the primary tool for accelerating JSONB queries. However, not all GIN indexes are created equal. PostgreSQL supports several GIN operator classes for jsonb:

-- Default GIN index (supports @>, ?, ?|, ?&)
CREATE INDEX idx_docs_gin ON documents USING GIN (data);
 
-- Path-based GIN index using jsonb_path_ops (smaller, faster for @> only)
CREATE INDEX idx_docs_path_ops ON documents USING GIN (data jsonb_path_ops);

The jsonb_path_ops operator class only supports the @> operator but produces a significantly smaller index and is faster to update. If you only need containment checks, prefer jsonb_path_ops.

Benchmarking JSONB Queries

Always benchmark your JSONB queries with EXPLAIN ANALYZE before deploying to production:

EXPLAIN ANALYZE
SELECT * FROM user_profiles
WHERE attributes @> '{"location": {"city": "San Francisco"}}';
 
-- Check if the GIN index is being used
-- Look for "Index Scan" or "Bitmap Index Scan" in the plan

If you see a "Seq Scan" on a large table, your query is not using the index. Verify the index exists, the operator class matches, and the query syntax is compatible.

Monitoring JSONB Column Size

Large JSONB values can impact both storage and query performance. Monitor the average and maximum size of your JSONB columns:

SELECT
    pg_size_pretty(pg_total_relation_size('user_profiles')) AS total_size,
    pg_size_pretty(pg_column_size(attributes)) AS avg_jsonb_size,
    pg_size_pretty(max(pg_column_size(attributes))) AS max_jsonb_size
FROM user_profiles;

Comparison with Alternatives

FeaturePostgreSQL JSONBMongoDBMySQL JSONNormalized Tables
ACID TransactionsFull supportMulti-doc since 4.0Full supportFull support
IndexingGIN indexes on full doc and pathsFlexible secondary indexesFunctional indexesB-tree, hash, GIN, GiST
Query ExpressivenessRich operator set, SQL joinsMQL, aggregation pipelineLimited operatorsFull SQL
Schema FlexibilityPer-column JSONB with typed relational columnsFully schemaless per collectionPer-column JSONRigid schema per table
Ecosystem Maturity30+ years, massive extension ecosystemLarge but separate ecosystemMySQL ecosystemDatabase-specific
JOIN PerformanceExcellent for relational data; JSONB for semi-structuredNo native joins; must use aggregationExcellent for relational dataExcellent
Migration EffortAdd JSONB column without downtimeNo migrations neededAdd JSON column without downtimeRequires migration for every change

Advanced Patterns

JSONB Aggregation

PostgreSQL provides JSONB-specific aggregation functions that let you build JSON documents from relational data:

-- Build a JSON array from rows
SELECT jsonb_agg(jsonb_build_object(
    'id', id,
    'email', email,
    'settings', attributes -> 'settings'
)) AS users_json
FROM user_profiles
WHERE (attributes ->> 'age')::int > 25;
 
-- Build a JSON object with custom keys
SELECT jsonb_object_agg(email, attributes -> 'settings') AS user_settings
FROM user_profiles;

Conditional JSONB Updates with CASE

When you need to apply different update logic based on existing JSONB values:

UPDATE user_profiles
SET attributes = CASE
    WHEN attributes ? 'age' AND (attributes ->> 'age')::int >= 18
        THEN jsonb_set(attributes, '{category}', '"adult"')
    WHEN attributes ? 'age' AND (attributes ->> 'age')::int < 18
        THEN jsonb_set(attributes, '{category}', '"minor"')
    ELSE jsonb_set(attributes, '{category}', '"unknown"')
END;

Lateral Joins with JSONB Arrays

When you need to expand a JSONB array into rows and join with other tables:

SELECT u.display_name, interest
FROM user_profiles u,
LATERAL jsonb_array_elements_text(u.attributes -> 'interests') AS interest
WHERE u.email = 'alice@example.com';

Testing Strategies

Unit Testing JSONB Queries

Test your JSONB queries with a variety of document shapes to catch edge cases:

-- Test with empty object
INSERT INTO user_profiles (email, attributes)
VALUES ('empty@test.com', '{}');
 
-- Test with nested nulls
INSERT INTO user_profiles (email, attributes)
VALUES ('nulls@test.com', '{"age": null, "location": null}');
 
-- Test with deeply nested document
INSERT INTO user_profiles (email, attributes)
VALUES ('deep@test.com', '{"a": {"b": {"c": {"d": "value"}}}}');

Validating JSONB Structure

Use CHECK constraints with JSONB operators to enforce basic structural rules:

ALTER TABLE user_profiles
ADD CONSTRAINT check_attributes_has_age
CHECK (attributes ? 'age' OR NOT attributes ? 'age');
 
-- More practical: require certain keys for new records
ALTER TABLE user_profiles
ADD CONSTRAINT check_new_user_attributes
CHECK (
    created_at < '2020-01-01'::timestamptz
    OR (attributes ? 'age' AND attributes ? 'interests')
);

Future Outlook

PostgreSQL continues to invest heavily in JSONB capabilities. PostgreSQL 14 introduced JSONB subscripting (data['key'] syntax), making JSONB queries more readable. Upcoming versions are expected to improve GIN index performance for large JSONB documents and add better support for JSON Schema validation at the database level.

The broader trend in database engineering is toward hybrid models. Google Spanner, CockroachDB, and YugabyteDB all support JSONB-like columns. The lesson is clear: the future is not relational vs document, but relational with document capabilities where they make sense.

Conclusion

PostgreSQL JSONB gives you the power of a document database inside your relational engine. You can store semi-structured data, query it efficiently with GIN indexes and expressive operators, and evolve your data model without migrations. The key takeaways are:

  1. Always prefer jsonb over json for new projects
  2. Use the hybrid schema pattern: stable columns as relational, flexible data as JSONB
  3. Create GIN indexes with jsonb_path_ops for containment queries
  4. Create expression indexes for frequently queried specific paths
  5. Validate JSONB structure at the application layer
  6. Monitor JSONB column sizes and bloat
  7. Use jsonb_set and || for partial updates instead of replacing the entire document
  8. Benchmark with EXPLAIN ANALYZE before deploying JSONB queries to production

Start by adding a JSONB column to an existing table for metadata or custom fields. As you gain confidence, explore GIN indexing and expression indexes. The PostgreSQL documentation on JSON Functions and Operators is the definitive reference for further study.