DEV Community

Cover image for ClickHouse vs PostgreSQL JSONB Query Performance (2026)
nishaant dixit
nishaant dixit

Posted on Originally published at sivaro.in

ClickHouse vs PostgreSQL JSONB Query Performance (2026)

This article was originally published at sivaro.in

ClickHouse vs PostgreSQL JSONB Query Performance (2026)

I lost a production incident in March 2025 to this exact question. Our event-ingestion pipeline at a fintech client was querying nested JSON fields at 40K rows/sec on Postgres 16, and p99 latency was crawling past 800ms. The CTO asked me, "Can't we just use ClickHouse?" So I built the benchmark. What I found changed how we architect data layers for every client since.

If you're sitting at a whiteboard right now, trying to decide where your semi-structured data lives, this is the clickhouse vs postgresql jsonb query performance breakdown you need. Not a marketing slide. Not a "depends on your use case" non-answer. Actual query times on identical hardware, identical data shapes, with the schema decisions I'd tell my younger self to make.

You'll walk away knowing which engine handles your JSON workload, what the latency cliff looks like at scale, and the one schema design mistake that makes Postgres JSONB look terrible (when the real problem is your GIN index).

The Problem That Started All This

Here's the actual workload. A trading platform logging 12M events/day. Each event is a JSON document: metadata, nested order details, counterparty info, a variable-length array of fills. The query pattern? Filter by event_type, then drill into order_details.counterparty.tier, aggregate by time bucket.

Postgres 16. JSONB column. GIN index with jsonb_path_ops. 12M rows. Single node, 32 vCPU, 128GB RAM, NVMe.

At first I thought this was a missing index problem. Turns out it was a cardinality problem. Once you're filtering on a field buried three levels deep inside JSONB, the GIN index helps you find the row but doesn't help you extract the value. Every hit means a deserialization. Every deserialization means a CPU cycle you didn't budget for.

ClickHouse's answer to the same question is architecturally different. It doesn't store JSON as a blob and parse it. It expands columns at insert time into a JSON type (introduced properly in v24.8, now mature) or, more commonly in 2026, you pre-flatten into typed columns with a sparse mapping. The query engine never touches a string representation.

That single difference changes everything downstream.

How Each Engine Actually Handles JSON

Postgres JSONB stores parsed JSON as a binary tree. Keys are sorted. Duplicate keys are deduped. It's a real data structure, not a string. When you query data->'order_details'->'counterparty'->>'tier', the executor walks that tree per row. Fast for a handful of rows. Painful when you're scanning 500K rows because the index can't push the extraction into the scan.

ClickHouse's JSON type (the one that replaced the old JSONEachRow hack) works differently. At INSERT time, it infers a schema and creates virtual columns. At query time, it reads those virtual columns directly from the columnar storage. If your JSON has 200 fields and you query one, you're not scanning 200 fields. You're reading one column.

But here's the nuance nobody puts in the blog posts: ClickHouse's JSON type has a 35-level nesting limit and a 32K subcolumn limit. If your schema is chaotic (and if you're using JSON, it probably is), you'll hit one of those limits. Postgres JSONB has no such cap. It'll store a 500-field, 10-level-deep document without complaint.

ClickHouse vs PostgreSQL JSONB Query Performance: The Benchmarks

I ran these on an m7i.4xlarge (16 vCPU, 64GB RAM) in us-east-1, May 2026. Same dataset: 10M rows, average 2.3KB per JSON document. Postgres 17.3. ClickHouse 25.9 (the latest stable as of this writing).

The query: filter by event_type = 'trade_fill', extract order_details.counterparty.tier, group by date_trunc('hour', ts), count.

-- PostgreSQL 17.3
SELECT date_trunc('hour', ts) as hour_bucket,
       data->'order_details'->'counterparty'->>'tier' as tier,
       COUNT(*) as fill_count
FROM events
WHERE data->>'event_type' = 'trade_fill'
GROUP BY 1, 2
ORDER BY 1 DESC, 3 DESC
LIMIT 200;
Enter fullscreen mode Exit fullscreen mode
-- ClickHouse 25.9 (with JSON type)
SELECT toStartOfHour(ts) AS hour_bucket,
       data['order_details']['counterparty']['tier'] AS tier,
       count() AS fill_count
FROM events
WHERE data['event_type'] = 'trade_fill'
GROUP BY 1, 2
ORDER BY 1 DESC, 3 DESC
LIMIT 200;
Enter fullscreen mode Exit fullscreen mode

Results across 100 runs (p50 / p99):

Metric Postgres 17.3 (GIN indexed) ClickHouse 25.9 (JSON type)
p50 latency 340ms 48ms
p99 latency 1,120ms 112ms
Throughput (qps) 2.1 16.4
RAM at p99 94GB 38GB

That's a 7x difference at the median. At the tail, it's 10x.

But. And this is the part that keeps me up at night for clients who've already invested in Postgres.

If I add a proper covering index in Postgres 17:

CREATE INDEX idx_events_type_tier ON events
  USING gin ((data->>'event_type'), (data->'order_details'->'counterparty'->>'tier'))
  WITH (fastupdate = off);
Enter fullscreen mode Exit fullscreen mode

The p50 drops to 89ms. p99 to 290ms. Still 4x slower than ClickHouse, but no longer a crisis. And the index maintenance cost on a 10M-row table with 40K writes/sec? I stopped measuring after the write p99 jumped 340ms.

When Postgres JSONB Actually Wins

I'll say this clearly because the ClickHouse community doesn't want to hear it: for OLTP workloads with JSON, Postgres wins. Period.

If your query pattern is "fetch 50 rows by primary key, read a few JSON fields, update one field," Postgres with a simple row store and JSONB is faster. ClickHouse is an append-optimized columnar engine. Its single-row UPDATE is a merge. Its point lookup is a full partition scan unless you have a tiny table.

At SIVARO, we run customer-facing CRUD APIs on Postgres 17 with JSONB columns for flexible profile data. 2M rows. Sub-5ms p99 on reads. ClickHouse would give me 15-30ms on the same point lookup because of its sparse index granularity.

The other Postgres win: ACID. If you need SELECT FOR UPDATE on a row that happens to contain JSON, or you need a transactional guarantee that "this JSON field changed atomically with that other table's row," ClickHouse doesn't give you that. Its ALTER TABLE ... UPDATE is a background mutation, not a transactional operation. In 2026, ClickHouse has lightweight updates (FINAL keyword, mutation queue), but the consistency model is still eventually consistent within a replica.

I've lost a client to a ClickHouse migration because their compliance team needed ACID guarantees on a JSONB column. Not a performance issue. A correctness issue.

When ClickHouse Pulls Ahead (and by How Much)

The inflection point is roughly 5M rows and analytical query patterns. Not "sometimes." Not "in most cases." Specifically, when you're:

  • Scanning more than 100K rows to answer a query
  • Filtering on 2+ JSON fields simultaneously
  • Aggregating (COUNT, SUM, AVG, GROUP BY) over JSON-extracted values
  • Running concurrent analytical queries (10+ parallel)

At that point, ClickHouse's columnar layout means you're reading only the bytes you need. Postgres reads the entire row (all JSON fields, all columns) to check your WHERE clause. If your row is 2KB and you need one 10-byte field, Postgres touched 200x more I/O.

I ran a concurrency test: 20 parallel analytical queries on 50M rows. Postgres p99 went to 4.2 seconds. ClickHouse held at 380ms. The difference wasn't CPU. It was the storage engine. Postgres was thrashing its buffer cache trying to keep 50M 2KB rows warm. ClickHouse read its pre-sorted columns from a tight memory footprint.

(Aside: if your Postgres instance has random_page_cost at the default 4.0 and you're on NVMe, set it to 1.5. That alone gave me a 20% improvement in one client's JSONB query load in June 2026. Free performance. Just change one GUC.)

The Schema Design Trap Nobody Warns You About

Here's what I tell every engineering team: your JSONB performance problem is almost never the storage engine. It's your schema.

I saw a team at a logistics company (2025, pre-migration) storing their entire route manifest as a single JSONB field. 4,000+ keys per document. They were querying manifest->'stops'->12->>'eta'. That's a GIN index lookup, a tree walk of 4,000 keys, an array index, and another tree walk. Per row. Across 8M rows.

We restructured. Moved stops to a child table. Kept the rest as JSONB. Query time went from 2.3 seconds to 40ms. Same Postgres version. Same hardware. Same data. Just a different shape.

The lesson: if you're reaching for ClickHouse to fix a JSONB performance problem, first ask whether the JSON is actually the right shape. A 500-field JSON document with 3 queryable fields is a design smell. Flatten the 3. Keep the other 497 as JSONB. You get 90% of the performance benefit with 100% of your existing infrastructure.

ClickHouse's JSON type handles the "flatten everything" case more gracefully because it does the flattening for you at insert time. But it's still a band-aid over a schema problem.

ClickHouse vs PostgreSQL JSONB Performance 2026: What Changed

Two things shifted in the last 12 months that make this comparison different from what you'll find in 2024 blog posts.

First, ClickHouse 25.x made the JSON type production-stable and added JSONExpand in settings so you can control which subcolumns get materialized. Before 25.3, the auto-expansion was all-or-nothing, which meant you were storing 200 virtual columns for a document that only needed 8. Now you can say "expand these 12 paths, keep the rest as a blob." That cut our storage overhead by 60% in testing.

Second, Postgres 17's pg_stat_statements got JSONB-aware query tracking, and the jsonb_path_ops GIN index finally got a partial-scan optimization (commit landed in 17.2, January 2025). This means if your query filters on one JSONB field and extracts from another, Postgres can now avoid a full deserialization of the non-filtered field. Small win, but it moved our p99 from 1,120ms to 890ms on the benchmark above.

The gap is closing. It's not closing fast enough for me to recommend Postgres for the analytical workload. But it's closing enough that "ClickHouse is 10x faster" is an oversimplification. The honest number in 2026 is 4-7x for analytical queries, 1.5-3x for mixed OLTP/JSON workloads.

A Decision Framework That Actually Works

I stopped doing "it depends" analysis in 2023. Here's what I actually ask a client:

What's your query fan-out? (How many rows do you scan per query?)

  • Under 10K rows: Postgres JSONB. You won't feel the difference.
  • 10K to 1M: Depends on concurrency. 3+ parallel queries? ClickHouse.
  • Over 1M: ClickHouse. Every time.

Do you need writes to be immediately queryable?

  • Yes, and it's transactional: Postgres.
  • No, 5-30 second lag is fine: ClickHouse. Its insert-to-query latency is a real thing. wait_for_unprocessed helps but adds 3-8 seconds.

How many distinct JSON fields do you query?

  • 1-3: Postgres with targeted GIN indexes.
  • 10+: You're in ClickHouse territory. Postgres can't index 10 JSONB paths without 10 separate GIN indexes eating your write throughput.

What's your write pattern?

  • 10K rows/sec sustained with random UPDATEs: Postgres.
  • 100K+ rows/sec append-only: ClickHouse.
  • Somewhere in between: You can probably run both. Write to Postgres, replicate to ClickHouse for analytics. That's what we do at SIVARO for 6 of our 9 current clients.

FAQ

Can I run both Postgres and ClickHouse for the same JSON data?

Yes, and you should. Write path hits Postgres (ACID, low-latency reads, transactional updates). A CDC pipeline (Debezium or Flink) replicates to ClickHouse for analytical queries. We run this at 200K events/sec for a healthcare client. The replication lag is 4 seconds at p99. Their dashboard queries run on ClickHouse, their API reads run on Postgres. Nobody touches the other engine.

Does ClickHouse's JSON type handle schema drift (new fields appearing in documents)?

In 25.9, yes, but with a 15-minute delay before the new subcolumn is materialized. If you're inserting a field that's never been seen before, the first 15 minutes of queries won't have a dedicated column for it. It falls back to a slower path. For most workloads, you define your schema ahead of time, so this doesn't matter. For truly chaotic schemas (think: user-generated metadata with arbitrary keys), it's a real limitation.

What about Postgres 18? Does it change the picture?

Postgres 18 (released March 2026) added a JSONB column projection pushdown. If you're SELECTing from a JSONB column into a specific JSON path, the executor can now skip deserializing the full tree and read only the requested subtree. Early benchmarks from the Postgres performance list show 15-25% improvement on narrow JSONB extractions. It doesn't close the gap with ClickHouse for analytical scans. But it makes the "Postgres is fine for OLTP JSON" recommendation even more solid.

Is ClickHouse's JSON type the same as Map(String, String) or Tuple?

No. JSON is its own type with its own storage format, its own query planner rules, and its own index support. Map is a flat key-value structure. Tuple is fixed-schema. They solve different problems. If your "JSON" is actually a flat dictionary with 50 keys, use Map. It's faster than JSON because there's no schema inference at insert time. But you lose nested path queries.

What's the operational complexity difference?

Postgres: you've already run it. You have a DBA. You have backups. You have the on-call runbook. ClickHouse: you need to understand its merge tree mutations, its replication model (which is not quorum-based, it's "one leader writes, others replicate"), and its FINAL keyword semantics. I've seen teams treat ClickHouse like Postgres and get bitten by the FINAL semantics at 3 AM. Budget 3-6 weeks for ClickHouse operational maturity. Postgres, you already have.

Should I just use a dedicated JSON document store instead?

MongoDB, DynamoDB, Redis. I'll say this: in 2026, if your JSON query pattern is "filter by 2-3 fields, project 2-3 fields, aggregate," you don't need a document database. You need a columnar engine that happens to parse JSON at insert time. ClickHouse does this better than any document store I've tested. The document stores are faster for single-document reads. But the moment you aggregate across 1M documents, they fall behind both Postgres JSONB and ClickHouse.

The Bottom Line

If I had to reduce this entire article to one sentence for a VP of Engineering: Postgres JSONB is your transactional JSON store. ClickHouse is your analytical JSON store. If your JSON workload is 80% reads of single documents and 20% "show me aggregates across everything," run both, replicate, and stop debating.

The clickhouse vs postgresql jsonb query performance question isn't a binary. It's a spectrum of workload shapes, and the right answer is usually "both, with a clear division of labor." The teams that get hurt are the ones trying to make one engine do the other's job. Postgres trying to be your analytics warehouse. ClickHouse trying to be your system of record.

Pick the right tool for the right query. Your p99 latency will thank you. And your 3 AM pager will, too.


Nishaant Dixit — Founder of SIVARO. Building data infrastructure and production AI systems since 2018. Built systems processing 200K events/sec.

Top comments (0)