Shredding JSON columns gave Logfire up to 1000x faster queries
pydantic.dev
pydantic.dev
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.
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).
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
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".
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 ...)
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!
I'm glad they made the discovery but this is DB 101
But yes, what's old is new. I didn't mean to claim we invented this technique. I actually cited ClickHouse's article (https://clickhouse.com/blog/a-new-powerful-json-data-type-fo... in the article) in the post :
> Our shredded data layout predates both the Parquet Variant type and ClickHouse's JSON support but is very similar to these approaches.
I said _predates_ based on when the article I cited came out, but based on your linked PR it sounds you landed it earlier than I thought, I'll remove the wording. I'm not sure where we originally got the idea from or where it comes from, I recall hearing the Databricks and BigQuery folks sharing similar ideas around the same time. Do you recall where the idea originated / where did you first see it? Happy to include that in the article!
Most of our work went into making the technique work w/ Parquet in object storage queried with DataFusion. We had to rewrite big chunks of the Parquet scanning machinery, filter pushdown optimizers, etc.
Long term we're going to move a lot of this to the now "standard" Variant data type, which we are partially responsible for bringing into the Rust / DataFusion ecosystem.
Then you never have to change the data. Only the index files.
In practice we do both: we have key presence indexes and zone maps on shredded and unshredded values. Then we go an grab the data and if it's shredded it's going to be faster to grab even once we know where it is.
Dremel paper explained shredding in like 2010? Parquet had it in a while?
https://blog.x.com/engineering/en_us/a/2013/dremel-made-simp...
Yes, things like nested repeated fields (or structs) will mess you up hard time. When I was playing with Duckdb querying parquet several years ago I had issues with column names and even was digging some source code to improve statistics use, but it was doable and reasonably efficient on my datasets.
Although yes. If you have resources of large companies, both computational and manpower, you can do very nice things with your data. All that determination of “what would be nice to shred” based on real query statistics, compaction, regular optimization and improvements of data at rest by maintenance jobs… again, use better databases/engines and there’s no free lunch, you get what you paid for (hopefully).
When the structure ossifies, converting the structure into its tabular representation makes sense.
------
Every single other comment here shitting on the choices made act as if their commits have always been perfect first try, with no need for modifications or improvements.
When the demands of your target customers constantly shift & have not yet been ossified, the optimizations made early in its process more often than not bite back hard & painful.
In our case it's more than that: the data is dynamically changing, constantly, across tenants, across their data streams and across time.
What we're talking about here is basically dynamically creating a table structure from semi-structured data. It's not about "we don't know what the right columns are", it's about "there are no universally right columns, but there are some better and some worse ones within the bounded confines of some finite amount of data".