It is hilarious to me when companies act like they've made some huge discovery that's been well-known to anyone working in DBs for the past decade, if not more.

JSON is slow as hell, both for serdes, and especially querying it. Use it sparingly, if at all. Normalization is not that hard, and it's trivial now with LLMs. Tell Claude you want your schema in BCNF, or 5NF if you're feeling froggy. Denormalize if and only if you have performance issues.

In my group, the person who helped the vendor set up our new DB has convinced management to convert all of our metadata tables (which we use for every filter operation) to JSON. As you can guess, he's never benchmarked a DB in his life.

Since our visualization tool went from 2 seconds per click (when I set it up the original DB) to over 40 now, even before implementing this change, I'm watching in amazement, waiting for someone to complain. It's truly incredible how tolerant users are. It's one of the reasons I think AI will take our jobs much sooner than most think: the average user has no useful perspective on silly things like performance or determinism, "that's just how it is".

They do hate it; they have been taught that no matter the complaints, the bad experience will never change, so why waste time complaining.

> I'm watching in amazement, waiting for someone to complain

Sometimes you have to be the change you want to see in the world.

(the other possibility is that much fewer people are looking at this data than you'd expect ...)

Whenever I call some client support service to complain about their website being broken, incorrect, malfunctioning, or something similar, and I explicitly ask them to notify their IT system about it, I feel like some weird alien or raging boomer asking for something impossible. They don't even understand it, and my complaints just become useless. There are no more e-mails to contact, nothing that can be done to improve the situation. It's enshittification all the way down, and too bad for those of us who had known a better state of affairs, we just have to become blind to it.

The point they make is that user data is dynamic, meaning you can't just blindly normalize every json attribute into its own column. The challenge is determining which attributes deserve "shredding" and doing it automatically iiuc

Author here. You hit the nail on the head. It's multi-tenant dynamic data. It varies between tenants, even within a single tenant there are large variations across data streams and across time. We decide the layout per physical file we write out using a combination of heuristics and query statistics (we shred attributes that are heavily queried).

Not even companies. I've seen this with colleagues. They wanted JSON parsing in a moderately embedded device. No thank you. Parse it elsewhere, give me the binary result

Meh, it's not ideal but it's not that bad either.

I'm working on an embedded system with a MCU and I've benchmarked JSON serialization at over 500 KiB/s (through a HTTP GET request) using my own library. Deserialization is slower, but that's partly because I have super-linear algorithms downstream of the parsing.

CBOR is much nicer and faster to work with (smaller documents, definite length strings directly consumable) and I do support it in my library, but I derive most of my performance gains by ditching DOM-style APIs and using stream processing instead. Allocating nodes and chasing pointers all over the heap sucks for performance, especially for large documents, regardless of the format (and that's assuming it even fits within available RAM).

Hi, author here.

The article doesn't claim to have made some huge discovery, it tells the journey of making our product / system much better by applying well known techniques. We linked to similar things (ClickHouse's handling of semi structured data, Parquet's Variant type). There are others (e.g. BigQuery). I'm sure there's some system that did similar things before (if you have a reference I'd be interested in learning the history here) but all of the ones I mentioned above are from 2022+.

But besides that I think your comment misses the entire point of the article: how do you normalize semi-structured data that varies in schema vastly across tenants, within those tenants between those systems and drifts over time rather rapidly? There is no schema to pick and normalize to.

Hi, author here.

I agree this is not some huge discovery. We link ClickHouse's JSON type and Parquet Variant in the post, and Snowflake's VARIANT and BigQuery do similar things. We didn't mean to present it as a discovery. The post is about how we applied it to our system.

The part normalization doesn't answer for us, as guites said in this thread, is that there's no schema to normalize to. Every tenant sends different OpenTelemetry attributes, different services within a tenant send different ones, and they change from week to week. Picking a fixed set of columns is what we tried first (we call it static shredding in the post). So each Parquet file now picks its own columns from the data it contains, without any configuration.

I know in the database world we are often recycling or tweaking ideas from decades ago so if you know where these ideas for handling semi-structured data in columnar databases comes from I'd be interested to learn!

Upsie, I posted my first draft and an attempt to rewrite to make more constructive. Take that for what you will, sorry for any hostility in the first draft.

If you have proper indexes into the JSON columns I wonder what the performance difference is, if any, for Postgres

As with everything, it depends. Postgres JSON[B] columns support GIN indices (technically they also support BTree and Hash, but using them can be confusing), which supports a handful of operators [0]. You can also do partial indices with these (e.g. CREATE INDEX foo_data_pidx ON foo (data) WHERE data ->> 'is_active' = 'true'), though that can then create confusion for using it, because while the index predicate accepts / requires equality (SELECT ... WHERE data ->> 'is_active' = 'true'), the rest of the object requires an operator like @> (contains), so it can be awkward to write. This is the BTree expression index I stated at the beginning could be confusing. I have no idea if ORMs / query builders get this right or not.

The other major problem is lack of statistics. JSON[B] is opaque to Postgres; it has no idea about the distribution of values within an object. This starts to become very obvious if you're doing JOINs and have predicates from the JSON object. Take for example geographical information: maybe you want to find some number of customers from Delaware who have onboarded in the last week. If that geographical information is in a JSON object, and you have 5,000,000 rows, Postgres has no idea how many rows it should expect, so it may not choose the optimal join strategy. The same is true in the opposite direction - it's probable that you have more customers in California than you do in Delaware purely based on relative population, but because the planner doesn't know that, it's unlikely to choose the optimal strategy.

To be fair, for correlated columns (extending the above example, think of postal_code and state), the planner can also make the same mistake in underestimations, because by default it assumes the columns are uncorrelated. This can be solved with extended statistics [1], where you explicitly tell it which column[s] are correlated, and in what manner.

I created a table in PG18 on my Mac, with 5,000,000 rows of fake customers (various columns like names, geo information, email, opaque ids, integer id, date times, etc.), and another where most of it was shifted into a JSONB column, exact same information, same ordering of rows. In general, simple point lookups on the scalar table took 0.1 msec, as you'd expect; the JSONB table could at best do 2.5 msec, if and only if the correct operator was used. Use the wrong operator, and it still works, but it reverts to a parallel sequential scan, which took ~175 - 400 msec, depending on the specific operator being used. For joins, it was 20-30% slower with an expression index, and 20-350% (yes, large range) slower with a GIN index.

JSON[B] also struggles with memory usage during queries, and WAL generation. Since the entire object has to be extracted, intermediate query results can get quite large; if you don't have a large-enough `work_mem`, this slows things down (the same is true for scalars, of course, but JSON objects tend to be larger, if nothing else due to repeated keys). If you ever have to UPDATE a JSON[B] object, that's a full re-write, and an update to any GIN indices it may have. I measured 5.5 sec vs. 2 sec for updating 1 attribute on 100K rows, and 831 MB WAL vs. 227 MB WAL generation. Those times would be much, much more if this wasn't on a fast local NVMe drive, as well.

There's also TOAST/DE-TOAST [2] time, which I didn't measure, because my objects were small. With a large enough value (it doesn't have to be JSON, but it usually is) - by default, 2 KB - Postgres will first attempt to compress, and then shift those values out to a separate table, vaguely similar to InnoDB's DYNAMIC page format. That means when you're querying with predicates on the TOAST'd object, it now has to do another indirect lookup, retrieve the page, and check the predicate. At scale, this can severely slow things down.

Finally, and most importantly IMO, guaranteeing invariants is next to impossible with JSON objects. Was that field left NULL when it shouldn't have been? Did the email field actually contain an email address? Hopefully your application is checking everything rigorously, and no one has changed its logic from what was intended.

0: https://www.postgresql.org/docs/current/datatype-json.html#J...

1: https://www.postgresql.org/docs/current/sql-createstatistics...

2: https://www.postgresql.org/docs/current/storage-toast.html

Oh yeah I would not do things like joins but for simple filtering I have found that it works very well. Of course if I knew I needed to filter on that value beforehand it would not be in a json column but I found that it's sufficient enough that it beats doing a re-architecture or dual writes if you realize you actually do need to do a filter after the fact

Everyone needs their aha moment :}.