Databases 20 min read

PostgreSQL JSON vs JSONB: 1M Row Benchmark Shows 7x Query Speed Gap

This article benchmarks PostgreSQL JSON and JSONB types on 1 million rows, revealing JSONB delivers 6-9x faster queries and 26% less storage despite 31% slower writes, with practical guidance on when to choose each type and how to leverage GIN indexes for optimal performance.

dbaplus Community
dbaplus Community
dbaplus Community
PostgreSQL JSON vs JSONB: 1M Row Benchmark Shows 7x Query Speed Gap

Core Differences Between JSON and JSONB

PostgreSQL offers two JSON types that differ fundamentally in storage and capabilities:

Storage format: JSON stores plain text (exact input), JSONB stores parsed binary.

Whitespace and key order: JSON preserves both; JSONB removes whitespace and does not guarantee key order.

Duplicate keys: JSON retains duplicates; JSONB deduplicates, keeping the last value.

Index support: JSON has none; JSONB supports GIN indexes.

Query operators: JSON only provides -> and ->>; JSONB adds @> (containment), ? (key existence), ?& (multiple keys), and more.

Write speed: JSON is faster (no parsing); JSONB is slower due to parsing and conversion.

Query speed: JSON reparses on every read; JSONB operates on pre-parsed binary, making it far faster.

In short: JSON = lazy mode (fast write, slow read) , JSONB = serious mode (slightly slower write, lightning-fast read) .

JSONB-Exclusive Operators

Only JSONB supports advanced operators:

-- @> containment check: does the document contain the given key/value?
SELECT * FROM users WHERE data @> '{"role": "admin"}';

-- ? key existence check
SELECT * FROM users WHERE data ? 'email_verified';

-- ?& multiple keys existence
SELECT * FROM users WHERE data ?& array['email_verified', 'phone_verified'];

With JSON you must use verbose ->> extraction and cannot efficiently check nested structures or array containment.

GIN Index: JSONB's Secret Weapon

A GIN (Generalized Inverted Index) indexes every key and value inside a JSONB document, turning containment and key-existence queries into index lookups instead of full table scans:

CREATE INDEX idx_users_data ON users USING GIN (data);

-- After indexing, containment queries go from full scan to milliseconds
EXPLAIN ANALYZE SELECT * FROM users WHERE data @> '{"status": "active"}';

Complete Benchmark Reproduction (1 Million Rows)

Step 1: Create Test Tables

-- JSON table
CREATE TABLE bench_json (
  id SERIAL PRIMARY KEY,
  data JSON NOT NULL,
  created_at TIMESTAMPTZ DEFAULT NOW()
);

-- JSONB table
CREATE TABLE bench_jsonb (
  id SERIAL PRIMARY KEY,
  data JSONB NOT NULL,
  created_at TIMESTAMPTZ DEFAULT NOW()
);

Step 2: Generate 1 Million Rows

Python script using psycopg2 inserts batches of 10,000 rows into each table. Each row simulates a user configuration with nested objects, arrays, and random preferences.

import psycopg2, json, random, string
from datetime import datetime

def generate_user_data():
    roles = ["admin", "editor", "viewer", "moderator"]
    plans = ["free", "pro", "enterprise"]
    preferences = {}
    for i in range(random.randint(5, 15)):
        key = f"pref_{random.choice(string.ascii_lowercase)}"
        value = random.choice([True, False, random.randint(0, 100), None])
        preferences[key] = value
    data = {
        "user_id": f"u_{random.randint(10000, 99999)}",
        "username": f"user_{random.randint(10000, 99999)}",
        "email": f"user{random.randint(10000, 99999)}@example.com",
        "role": random.choice(roles),
        "plan": random.choice(plans),
        "settings": {
            "theme": random.choice(["dark", "light", "auto"]),
            "notifications_enabled": random.choice([True, False]),
            "language": random.choice(["zh", "en", "ja"]),
            "two_factor_enabled": random.choice([True, False])
        },
        "preferences": preferences,
        "tags": [f"tag_{i}" for i in range(random.randint(0, 8))],
        "metadata": {
            "signup_source": random.choice(["web", "api", "invite"]),
            "last_login": datetime.now().isoformat(),
            "login_count": random.randint(1, 500)
        }
    }
    return json.dumps(data)

# Batch insert into JSON table
batch_size = 10000
total = 1000000
for i in range(0, total, batch_size):
    batch = [(generate_user_data(),) for _ in range(batch_size)]
    cur.executemany("INSERT INTO bench_json (data) VALUES (%s::json)", batch)
    conn.commit()
    print(f"Inserted {min(i + batch_size, total)}/{total}")

# Same for JSONB table
for i in range(0, total, batch_size):
    batch = [(generate_user_data(),) for _ in range(batch_size)]
    cur.executemany("INSERT INTO bench_jsonb (data) VALUES (%s::jsonb)", batch)
    conn.commit()
    print(f"JSONB: Inserted {min(i + batch_size, total)}/{total}")

Step 3: Performance Comparison Results

Write performance (1M rows): JSON 8.6s, JSONB 11.3s → JSON ~31% faster (no parsing).

Storage size: JSON ~1200 MB, JSONB ~888 MB → JSONB saves 26% (whitespace removal + key deduplication).

Query performance (EXPLAIN ANALYZE):

Simple key extraction ( data->>'role' = 'admin'): JSON ~12ms, JSONB ~1.9ms → JSONB 6.2x faster.

Nested field access ( data->'settings'->>'theme' = 'dark'): JSON ~18ms, JSONB ~2.4ms → JSONB 7.6x faster.

Array element access ( data->'tags'->>0 IS NOT NULL): JSON ~15ms, JSONB ~2.1ms → JSONB 7.3x faster.

Multi-condition query (role, nested flag, plan): JSON ~25ms, JSONB ~2.7ms → JSONB 9.1x faster.

Partial UPDATE: JSON ~8ms, JSONB ~2.3ms → JSONB 71% faster.

Real-World Scenario Selection

Scenario A: Log/Event Storage → Choose JSONB ✅

CREATE TABLE application_logs (
  id BIGSERIAL PRIMARY KEY,
  timestamp TIMESTAMPTZ DEFAULT NOW(),
  service_name TEXT NOT NULL,
  level TEXT CHECK (level IN ('DEBUG','INFO','WARN','ERROR')),
  payload JSONB NOT NULL
);
CREATE INDEX idx_logs_payload ON application_logs USING GIN (payload);

-- Efficient query: find ERROR logs with specific error_code
SELECT timestamp, service_name, payload
FROM application_logs
WHERE level = 'ERROR'
  AND payload @> '{"error_code": "AUTH_001"}'
ORDER BY timestamp DESC
LIMIT 50;

Reason: write-once read-many, frequent payload filtering, GIN index enables sub-second search on billions of rows.

Scenario B: Raw Webhook Payload Staging → Consider JSON ⚠️

CREATE TABLE raw_webhook_payloads (
  id BIGSERIAL PRIMARY KEY,
  received_at TIMESTAMPTZ DEFAULT NOW(),
  source_app TEXT NOT NULL,
  raw_payload JSON NOT NULL,
  processed BOOLEAN DEFAULT FALSE
);
-- Write-heavy, almost never queried; only inspected during debugging.
-- JSON's write speed advantage matters here.
-- Must preserve original formatting (whitespace, key order) for upstream reconciliation.

Warning: if occasional queries emerge, migrate to JSONB early; ALTER TABLE rewrite cost grows with table size.

Scenario C: Dynamic Attributes / Schema-Free Fields → Must Use JSONB ✅

CREATE TABLE products (
  id BIGSERIAL PRIMARY KEY,
  name TEXT NOT NULL,
  sku TEXT UNIQUE NOT NULL,
  price DECIMAL(10,2),
  attributes JSONB NOT NULL
);
CREATE INDEX idx_products_attr ON products USING GIN (attributes);

-- Electronics
INSERT INTO products (name, sku, price, attributes) VALUES
('iPhone Pro Max', 'IPHONE-PM-256', 9999.00,
 '{"screen_size": "6.9 inch", "battery_mAh": 5000, "color": "black", "storage_gb": 256}');

-- Apparel
INSERT INTO products (name, sku, price, attributes) VALUES
('Wool Coat', 'COAT-WOOL-L', 2999.00,
 '{"size": "L", "material": "wool", "season": "winter", "weight_g": 800}');

-- Cross-category query: price < 5000 and has stock field
SELECT name, sku, attributes->>'color' as color
FROM products
WHERE price < 5000
  AND attributes ? 'stock';

Each record has a different attribute structure; JSONB + GIN indexes enable flexible, performant queries.

FAQ

Q1: Can I convert an existing JSON column to JSONB?

Yes, simple for small tables:

ALTER TABLE my_table ALTER COLUMN data TYPE JSONB USING data::jsonb;

This rewrites the entire table and locks it. For large tables (tens of millions of rows), use a non-blocking migration:

-- 1. Add new JSONB column
ALTER TABLE my_table ADD COLUMN data_new JSONB;

-- 2. Backfill in batches (e.g., 50k rows per transaction)
UPDATE my_table SET data_new = data::jsonb WHERE data_new IS NULL LIMIT 50000;
-- Repeat until done

-- 3. Create index on new column
CREATE INDEX idx_data_new ON my_table USING GIN (data_new);

-- 4. Switch application to new column, then drop old column
-- ALTER TABLE my_table DROP COLUMN data;
-- ALTER TABLE my_table RENAME data_new TO data;

Q2: Does a GIN index slow down writes?

Yes, but usually acceptable:

INSERT: +15-25% overhead (moderate).

UPDATE non-JSON columns: no extra cost.

UPDATE JSONB column itself: +30-50% (higher).

DELETE: +20-30% (low-moderate).

Trade-off guidance: If JSONB column is updated very frequently (dozens per second), monitor GIN overhead. For typical INSERT+SELECT workloads, query gains far outweigh write cost. Consider a partial index to reduce size and write impact:

CREATE INDEX idx_active_users_data ON users USING GIN (data)
WHERE (data->>'status') = 'active';

Q3: When should I use regular columns instead of JSON/JSONB?

Fixed-format fields belong in typed columns (with constraints, B-tree indexes). Reserve JSONB for truly variable attributes.

-- ❌ Bad: everything in JSONB
CREATE TABLE bad_design (
  id SERIAL PRIMARY KEY,
  data JSONB  -- username, email, age, registered_at all here
);

-- ✅ Good: fixed attributes in columns, only variable extras in JSONB
CREATE TABLE good_design (
  id SERIAL PRIMARY KEY,
  username TEXT NOT NULL UNIQUE,
  email TEXT NOT NULL,
  age INTEGER,
  registered_at TIMESTAMPTZ DEFAULT NOW(),
  extra_attributes JSONB
);

Decision tree: Is the value format fixed? → Yes → regular column. No → Does structure vary? → Yes → JSONB (+ GIN). No, just opaque text → JSON or TEXT.

Q4: How about jsonb_path_query (SQL/JSON Path, Postgres 12+)?

Powerful for complex nested queries:

-- Find users with dark theme and more than 3 tags
SELECT id, data FROM bench_jsonb
WHERE jsonb_path_exists(data, '$.settings.theme == "dark" && $.tags.size() > 3');

-- Recursively find any error field at any depth
SELECT id, jsonb_path_query_first(data, '$**.error') as first_error
FROM application_logs
WHERE jsonb_path_exists(data, '$**.error');

Pros: expressive, supports filters, recursion, array slicing. Cons: steeper learning curve, sometimes slower than @> + GIN, less intuitive for SQL veterans. Recommendation: stick to @> / ->> for daily queries; reach for jsonb_path_query only for exceptionally complex nesting.

Q5: MySQL 8.0 JSON vs PostgreSQL JSONB

Both use binary storage, but PostgreSQL's JSONB is more mature and feature-rich:

Indexing: MySQL uses multi-valued indexes; PostgreSQL uses GIN (more flexible).

Operators: MySQL lacks @> containment check; PostgreSQL has a rich set ( @>, ?, ?&, ||, -, etc.).

Path queries: MySQL has JSON_CONTAINS; PostgreSQL implements standard SQL/JSON Path ( jsonb_path_query).

Updates: MySQL uses JSON_SET / JSON_REMOVE; PostgreSQL uses || (concat) and - (delete).

Ecosystem maturity: PostgreSQL JSONB has extensive production validation; MySQL JSON is functional but less battle-tested.

For JSON-heavy workloads, PostgreSQL's JSONB is clearly stronger — a key reason many teams migrate from MySQL to Postgres.

Conclusion

99% of cases: just use JSONB.

The 1% exception: pure write-heavy ingest with almost zero reads and a requirement to preserve exact original formatting (whitespace, key order).

Don't save 31% on write time only to pay a 7x query penalty — the math never works out.

Original Source

Signed-in readers can open the original source through BestHub's protected redirect.

Sign in to view source
Republication Notice

This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactadmin@besthub.devand we will review it promptly.

SQLJSONPostgreSQLquery performanceData TypesGIN IndexDatabase BenchmarkJSONB
dbaplus Community
Written by

dbaplus Community

Enterprise-level professional community for Database, BigData, and AIOps. Daily original articles, weekly online tech talks, monthly offline salons, and quarterly XCOPS&DAMS conferences—delivered by industry experts.

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.