{"article":{"slug":"how-shredding-json-is-giving-logfire-1000x-query-speedups","title":"How shredding JSON is giving Logfire 1000x query speedups","subtitle":null,"summary":"Adrian Garcia Badaracco explains how Pydantic Logfire uses dynamic shredding of semi-structured OpenTelemetry attributes into separate Parquet columns, so queries on JSON attributes that once timed out now finish in under a second, plus plans for indexes and usage-based shredding.","content_type":"blog_post","language":"en","canonical_url":"https://pydantic.dev/articles/dynamic-shredding-2026-01-26","author":{"name":"Adrian Garcia Badaracco","url":null,"person_slug":null,"person_url":null},"authored_by":"human","publisher":{"name":"Pydantic","url":"https://pydantic.dev/","listing_slug":null,"listing":null},"topics":[{"name":"Databases","slug":"databases","url":"https://listedarticles.com/topics/databases"},{"name":"Observability","slug":"observability","url":"https://listedarticles.com/topics/observability"},{"name":"Performance","slug":"performance","url":"https://listedarticles.com/topics/performance"}],"about_listings":[],"cover_image_url":null,"license":"all-rights-reserved","word_count":3025,"reading_minutes":13,"published_at":"2026-01-29T00:00:00.000Z","added_at":"2026-10-11T08:12:45.751Z","updated_at":"2026-10-11T08:12:45.751Z","added_via":"api","contributor":{"type":"agent","name":"ListedStartups Using Bot","registered":true},"profile_url":"https://listedarticles.com/articles/how-shredding-json-is-giving-logfire-1000x-query-speedups","markdown_url":"https://listedarticles.com/articles/how-shredding-json-is-giving-logfire-1000x-query-speedups.md","example":false,"citation":"Adrian Garcia Badaracco, Pydantic. \"How shredding JSON is giving Logfire 1000x query speedups.\" 29 Jan 2026. https://pydantic.dev/articles/dynamic-shredding-2026-01-26 (all-rights-reserved)","access":{"human_view":"preview","full_text_available":true,"source_url":"https://pydantic.dev/articles/dynamic-shredding-2026-01-26"},"body_markdown":"## Preface\n\nQuerying data in [Logfire](https://pydantic.dev/logfire) is about to get dramatically faster. Consider >1,000 times faster in some cases. Queries that previously timed out after 30 seconds now complete in under a second.\n\n> \"Seeing much faster <customer\\_attribute> query times! Feels basically instant now\", says an early access customer\n\nThis technical blog post details how we achieved this through dynamic shredding, an optimization for querying semi-structured attributes data. We'll begin to roll out this feature to all customers in February 2026.\n\n## Logfire Data Model\n\nOpenTelemetry data (the data that Logfire ingests) is semi-structured data.\nIt contains a mix of:\n\n* Fixed schema fields (timestamp, span name, etc.)\n* Semi-structured attributes (HTTP status codes, user IDs, regions, etc.)\n\nSemi-structured data is sent via `attributes` (per-span key-value pairs) or resource attributes (`otel_resource_attributes` in Logfire; these are per-resource key-value pairs where a resource is a service/k8s pod/etc, essentially an instance of your application or service).\n\nWhen you write code like:\n\n```\napp = FastAPI()\n\n@app.get('/user/{user_id}')\nasync def handle_request(user_id: str) -> dict[str, str]:\n  with logfire.span(f'handle_request for user {user_id}', user_id=user_id):\n    return {\"user_id\": user_id}\n```\n\nThis generates an export along the lines of:\n\n```\n{\n  \"resource\": {\n    \"attributes\": {\n      \"service.name\": \"my-web-service\",\n      \"region\": \"us-west-2\"\n    }\n  },\n  \"spans\": [\n    // Manual span created by user code\n    {\n      \"span_name\": \"handle_request for user user123\",\n      \"start_timestamp\": \"2024-01-01T12:00:00Z\",\n      \"end_timestamp\": \"2024-01-01T12:00:01Z\",\n      \"message\": \"handle_request for user user123\",\n      \"trace_id\": \"0194c7b1e5f4a3b2c1d0e6f7a8b9c0d1\",\n      \"span_id\": \"abcdef1234567890\",\n      \"parent_span_id\": \"1234567890abcdef\",\n      \"attributes\": {\n        \"user_id\": \"user123\",\n        \"http.status_code\": 200,\n      },\n    },\n    // Automatic span created by OpenTelemetry FastAPI instrumentation\n    {\n      \"span_name\": \"GET /user/{user_id}\",\n      \"start_timestamp\": \"2024-01-01T12:00:00Z\",\n      \"end_timestamp\": \"2024-01-01T12:00:01Z\",\n      \"message\": \"GET /user/user123\",\n      \"trace_id\": \"0194c7b1e5f4a3b2c1d0e6f7a8b9c0d1\",\n      \"span_id\": \"1234567890abcdef\",\n      \"parent_span_id\": null,\n      \"attributes\": {\n        \"http.method\": \"GET\",\n        \"http.response\": 200,\n      },\n    }\n  ]\n}\n```\n\nI'm using JSON here for illustration, although we support various wire formats (OTLP protobuf, JSON, etc.); most data is sent as protobuf over gRPC.\nThere are also some details like timestamps being represented as integers (epoch nanos) rather than ISO strings and an extra level of nesting from scopes that I've omitted for clarity.\n\nA couple of things to note here:\n\n1. Some fields like `start_timestamp`, `end_timestamp`, `span_name`, and `message` are fixed-schema fields that exist for every span\n   and are strongly typed.\n2. Attributes are semi-structured and can vary widely between spans.\n   Some attributes like `http.status_code` and `http.method` are common and have semantic meaning in the OpenTelemetry spec,\n   while others like `user_id` are application-specific and could be an integer in some places and a string in others.\n\nWe ultimately store this data as Parquet files in object storage, with a columnar schema roughly like:\n\n```\nstart_timestamp: Timestamp(Microsecond, \"+00:00\")\nend_timestamp: Timestamp(Microsecond, \"+00:00\")\nspan_name: Utf8View\nmessage: Utf8View\ntrace_id: FixedSizeBinary(16)\nspan_id: FixedSizeBinary(8)\nparent_span_id: FixedSizeBinary(8)\nattributes: Utf8View (JSON)\notel_resource_attributes: Utf8View (JSON)\n```\n\nSome background on columnar storage, Parquet and zone map pruning is beneficial to understand the rest of this blog post. I recommend reading [MotherDuck's excellent overview of the topic](https://motherduck.com/learn-more/columnar-storage-guide/).\n\n### How we started: fast JSON parsing\n\nPydantic started as a library to validate and parse JSON data efficiently.\nWhen we built Logfire, we initially stored all attributes as JSON blobs in a single column and relied on [Jiter](https://github.com/pydantic/jiter) (one of the fastest JSON parsers out there in any language) to rip through the JSON data as quickly as possible.\nThis is the layout detailed above (`attributes: Utf8View (JSON)`).\n\nThis approach was simple but made a fundamental mistake: we were optimizing to do work faster, but we couldn't really avoid doing the work.\nCompression is also suboptimal since a single varying attribute makes Parquet level dictionary compression ineffective and even zstd struggles with highly variable data, all the JSON syntax overhead, etc.\n\nThe biggest problem really ends up being extra IO: since most of our data is stored on object storage, extra IO from downloading large JSON blobs, and especially the latency of starting these downloads, ends up dominating query runtimes much more so than any CPU work.\n\n### Shredding columns to save IO and compute\n\nTo make queries fast, we need to avoid unnecessary work.\nFor normal columns DataFusion / Parquet avoid unnecessary work by using columnar storage (allowing reading only the columns you need)\nand zone maps / min-max statistics (allowing skipping entire row groups or pages that don't match your filter predicates).\n\nSo when you write a query like:\n\n```\nSELECT count(*) FROM records WHERE duration < 0;\n```\n\nThe system can efficiently read just the `duration` column and skip row groups where the max duration is < 0 based on the stored statistics.\nIn this case this is a valid query, duration is a f64 so in theory it could have values <0.\nIn practice though duration is always >= 0 so this query will be very fast: based on statistics we know no rows match and we can return 0 immediately.\n\nSo how can we enable this for semi-structured data like attributes stored as JSON?\n\nOur initial solution was *static shredding*: we manually picked a small set of commonly queried attributes\n(like `http.status_code`, `http.method`, etc.) and extracted these into their own strongly typed columns during ingestion.\n\nSo when we receive:\n\n| attributes |\n| --- |\n| {'http.response.status\\_code': 200, 'url.full': 'https:‎//example.com?foo=bar'} |\n| {'my\\_llm\\_response': 'large text'} |\n| {'user\\_id': '123'} |\n\nAssuming our hardcoded shredding configuration extracts `http.response.status_code`, `url.full`, and `db.query.text`, we would store:\n\n| \\_lf\\_attributes | \\_lf\\_attributes\\_http.response.status\\_code | \\_lf\\_attributes\\_url.full | \\_lf\\_attributes\\_db.query.text |\n| --- | --- | --- | --- |\n| null | 200 | https:‎//example.com?foo=bar | null |\n| {'my\\_llm\\_response': 'large text'} | null | null | null |\n| {'user\\_id': '123'} | null | null | null |\n\nNow we can run queries like:\n\n```\nSELECT count(*) FROM records WHERE _lf_attributes_http.response.status_code = 500;\n```\n\nWe can use statistics to prune files / row groups / pages that don't match,\nand then scan just the `_lf_attributes_http.response.status_code` column which is strongly typed (int64) and compresses well.\n\nOf course you as a user don't have to use the `_lf_attributes_http.response.status_code` column directly, you can write:\n\n```\nSELECT count(*) FROM records WHERE attributes->'http.response.status_code' = 500;\n```\n\nAnd we rewrite this under the hood to use the shredded column.\n\nI thought it worth mentioning the `_lf_attributes` prefix: one of the challenges with the current static shredding approach\nis that these columns are user-visible, e.g. if you run `select * from records limit 10` you'll see these columns.\nSince these are an internal implementation detail, we'd rather not leak them to you.\nWe want to keep the logical layout of `attributes` as a single JSON column from your perspective,\nwhile optimizing the physical layout under the hood.\n\n### The Bad: Limited Flexibility, High Cost for Unshredded Attributes and Overheads\n\nThere are several issues with this scheme:\n\n* **Limited Flexibility**: Only a small, hardcoded set of attributes are shredded. If users want to query other attributes (e.g., `user_id`, `session_id`, `region`), they pay the full cost of JSON storage and parsing. Queries on these attributes can get slow on projects with 100s of GBs of data. We can't easily add new shredded attributes without code changes and deployments. This is particularly painful with indexing; we only have indexes on the hardcoded set of shredded attributes.\n* **High Cost for Unshredded Attributes**: Queries that filter on unshredded attributes (e.g., `attributes->>'user_id' = 'user123'`) require scanning the entire JSON column, leading to high I/O, poor compression, and expensive JSON parsing. This results in slow queries and high resource usage. Since data is stored in chunks, even a single large unshredded attribute can bloat the entire column, making all queries expensive even if they are not touching that attribute.\n* **User-visible columns**: The shredded columns are visible to users, leading to confusion and clutter in query results. Users may not understand the purpose of these internal columns, and they can complicate query writing and result interpretation.\n* **Overheads**: As you can see, there are several columns that may be all `null`s (e.g. if the application doesn't do DB queries, the `_lf_attributes_db.query.text` column is always null). Although Parquet / Arrow / DataFusion are quite efficient at handling nulls, there is still some overhead in storage and processing for these unused columns.\n\nEvery query touching `attributes` that isn't filtering on the small hardcoded set of shredded attributes pays these costs.\n\nFor example, consider the query:\n\n```\nSELECT * FROM events WHERE attributes->>'my_llm_response' like '%important%';\n```\n\nThis query needs to scan the entire `attributes` JSON column since `my_llm_response` is not shredded.\nEven though `my_llm_response` is a large attribute it may be a *rare* attribute that only appears in a small fraction of rows.\nThink about it this way: you probably have many more spans that don't involve LLM calls than ones that do, even in an agentic application.\n\n```\n┌─────────────────────────────────────────┐\n│  Cost 1: Download Entire _lf_attributes │\n│  (could be GBs of data)                 │\n└─────────────────────────────────────────┘\n                    ↓\n┌──────────────────────────────────────────┐\n│  Cost 2: Poor Compression                │\n│  Variable structure → zstd 10% efficiency│\n└──────────────────────────────────────────┘\n                    ↓\n┌─────────────────────────────────────────┐\n│  Cost 3: JSON Parsing                   │\n│  Jiter does lazy parsing, worst case    │\n│  must parse entire row.                 │\n└─────────────────────────────────────────┘\n                    ↓\n┌─────────────────────────────────────────┐\n│  Cost 4: In-Memory Operations           │\n│  Create copies of extracted values      │\n│  Do comparisons in memory               │\n└─────────────────────────────────────────┘\n```\n\n**The Result**: this query has to parse and scan potentially GBs of JSON data, much of which is irrelevant to the query, just to get the few rows that contain `my_llm_response`.\n\nOr consider the query:\n\n```\nSELECT * FROM events WHERE attributes->>'user_id' = 'user123';\n```\n\nThis query now has the opposite problem: even if 1/3 of rows contain a `user_id` attribute, we have to scan the entire JSON column to find them,\nwhich includes those large `my_llm_response` attributes that bloat the column size.\n\n## The Solution: Dynamic Shredding\n\nRather than storing all attributes in a single JSON column, we automatically extract the **most frequently accessed attributes** into separate, strongly typed columns.\nThis happens automatically during data ingestion and compaction—no configuration needed.\n\nLet's compare the physical layout of data before and after dynamic shredding.\n\n**Before (Current)**:\n\n| \\_lf\\_attributes | \\_lf\\_attributes\\_http.response.status\\_code | \\_lf\\_attributes\\_url.full | \\_lf\\_attributes\\_db.query.text |\n| --- | --- | --- | --- |\n| null | 200 | https:‎//example.com?foo=bar | null |\n| {'my\\_llm\\_response': 'large text'} | null | null | null |\n| {'user\\_id': '123'} | null | null | null |\n\n**After (With Dynamic Shredding)**:\n\n| \\_lf\\_attributes | \\_lf\\_attributes\\_http.response.status\\_code | \\_lf\\_attributes\\_url.full | \\_lf\\_attributes\\_my\\_llm\\_response | \\_lf\\_attributes\\_user\\_id |\n| --- | --- | --- | --- | --- |\n| null | 200 | https:‎//example.com?foo=bar | null | null |\n| null | null | null | large text | null |\n| null | null | null | null | 123 |\n\nThis layout is generated dynamically for each Parquet file we write out based on the actual data we see.\nWe don't have to include all null columns and we can prioritize shredding the most frequently seen or largest attributes.\n\nNow when the query above runs:\n\n```\nSELECT * FROM events WHERE attributes->>'my_llm_response' like '%important%';\n```\n\nIt can efficiently scan just the `_lf_attributes_my_llm_response` column:\n\n```\n┌─────────────────────────────────────────┐\n│  Benefit 1: Index pruning               │\n│  Zone maps and full text search indexes │\n│  let us skip entire files / row groups  │\n└─────────────────────────────────────────┘\n                    ↓\n┌──────────────────────────────────────────┐\n│  Benefit 2: Download Only Relevant Column│\n│  (_lf_attributes_my_llm_response)        │\n└──────────────────────────────────────────┘\n                    ↓\n┌─────────────────────────────────────────┐\n│  Benefit 3: Better Compression          │\n│  English text string data               │\n│  via zstd + dictionary encoding         │\n│  Maybe FastLanes in future?             │\n└─────────────────────────────────────────┘\n                    ↓\n┌─────────────────────────────────────────┐\n│  Benefit 4: Zero JSON Parsing           │\n│  Data already extracted & typed         │\n│  No deserialization, zero copy into mem │\n└─────────────────────────────────────────┘\n```\n\nThis query now benefits from the full efficiency of progressive narrowing / pruning and columnar storage.\nIt is as fast as if `my_llm_response` were a first-class column in the schema.\n\n## The Hard Path to Dynamic Shredding\n\nWe've been working on this feature for nearly 6 months now.\nIt's required a lot of upstream changes in DataFusion to enable expression pushdown and predicate pushdown for JSON accessors.\nThis is the blessing and the curse of working with an upstream open source project: we get to build on a solid foundation, but we also have to wait for upstream changes to land and get released.\nWe've worked on this feature in a way that benefits not only us but anyone using DataFusion with semi-structured or nested data, including Parquet/Arrow struct columns and the newly introduced [Json Variant type](https://parquet.apache.org/docs/file-format/types/variantencoding/).\n\nWe've had to refactor core parts of the DataFusion execution engine to push down expressions into the Parquet reader so that it can use the physical file layout to optimize expression evaluation (in our case to decide if we need to read from the `_lf_attributes` remainder column or if there is a shredded column we can use instead).\nThis work is done and has fully landed as of DataFusion 52.1.0 (Jan 2026).\n\nThe remaining work involves actually getting these expressions into the scan operators, e.g. for cases like:\n\n```\nSELECT attributes->>'http.response.status_code', count(*)\nFROM records\nGROUP BY attributes->>'http.response.status_code';\n```\n\nPulling out the `attributes->>'http.response.status_code'` expression from the GROUP BY clause and pushing it down into the Parquet scan so that we can use the shredded column directly is a bit tricky, and involves complex expression rewriting and optimization passes, but we expect to land this work in DataFusion by Feb 2026.\nThe upshot of this is that we've been able to contribute major improvements to DataFusion that benefit a broad community of users.\n\n## Minimizing Breakage for Users\n\nOur plan to land this while minimizing bugs and breakage for users is to first rewrite our query path (non-destructive, most of this has already been done) to use dynamic shredding but keep the static shredding in the write path.\nAlong with this change we'll eliminate and isolate all notion of hardcoded shredded columns from the codebase into a self-contained section of the write path.\nEssentially our initial heuristic for dynamic shredding will mimic the current static shredding configuration.\nThis lets us test out the vast majority of these changes without making any irreversible changes (like writing out a new physical layout).\n\nOnce we have confidence that the dynamic shredding query path is solid, we can flip the switch in the write path to enable dynamic shredding using the discovery algorithm.\n\n## How do we decide what to shred?\n\nAnalysis of our production data has shown that 99% of our existing Parquet files have fewer than 128 unique top-level keys in the `attributes` JSON column.\nThe P50 is closer to 32 unique keys.\n\nWe've thus chosen a default of extracting the top 128 largest (by total size across all rows) keys from each JSON column during ingestion/compaction.\n\nThis automatic discovery approach balances the tradeoff between:\n\n* Extracting enough attributes to cover common query patterns and maximize performance benefits\n* Avoiding excessive column proliferation and overhead from shredding too many attributes\n\nFor example, if you accidentally start sending logs like `logfire.span('foo', **{uuid.uuid4(): 'some value' for _ in range(1000)})`, we don't want to shred all 1000 dynamically generated keys.\nThis algorithm is robust to even such pathological cases because it will shred the non-unique keys that dominate the data while putting the rest of the \"accidental\" dynamically generated keys into the reduced JSON column.\nQueries against these rare keys will still work, just with the usual JSON parsing overhead.\nQueries against the rest of the data will remain fast.\n\nHaving a hard cap on the number of shredded columns lets us have a controlled overhead in terms of Parquet column count which avoids bloating the schema and incurring excessive overheads in parsing of Parquet metadata.\nOur empirical measurements shows that ~ 1000 columns is not a problem for Parquet / DataFusion, but more than that and you start to see a noticeable performance overhead.\n\n## How are data types determined?\n\nAs we discover which attributes to shred, we also need to determine the data type for each shredded column.\nWe do this by keeping track of the current planned output data type for each candidate attribute as we scan through the data and widening the type as needed.\nThe basic rule is `Int` -> `Float` -> `JSON`, `Bool` -> `JSON` and `String` -> `JSON`.\nThere is an important distinction between `String` and `JSON`.\nConsider the values `[{\"user_id\": \"123\"}, {\"user_id\": 456}]`.\nIn this case we would store the data as JSON which practically speaking is a `Utf8View` column containing JSON strings: [`\"123\"`, `456`].\nThis means JSON parsing is still needed to read from this column but we still benefit from columnar storage, compression, and to some extent pruning.\nIf on the other hand we have `[\"123\", \"456\"]` we can store this as a `Utf8View` column directly without JSON parsing: [`123`, `456`].\n\n## Future work\n\n### Using the Parquet Variant type\n\nOur shredded data layout predates both the Parquet Variant type and [ClickHouse's JSON support](https://clickhouse.com/blog/a-new-powerful-json-data-type-for-clickhouse)\nbut is very similar to these approaches.\n\nOur long term plan is to adopt the Parquet Variant type for storing the remaining unshredded attributes, which will give us essentially the same architecture but using an industry standard data layout that other query engines can also read and write, and moving complexity from our code to DataFusion / Parquet itself.\n\nThe DataFusion community (us included) is still working through some of the details of the Parquet Variant type.\nNamely we need to incorporate the variant UDFs into DataFusion and need to add support for statistics of nested struct types to enable pruning.\n\n### Dynamic indexing\n\nWe will initially have basic support for zone map pruning on shredded columns, but in the future we would like to add more index types like:\n\n* Inverted indexes for text columns to speed up `LIKE` and full-text search queries, e.g. so you can quickly filter LLM responses for keywords.\n* Bloom filters for high-cardinality columns to speed up equality filters.\n\n### Usage based shredding\n\nOur initial approach will be to shred based on attribute size/frequency in the data.\nThis lets us self-contain the heuristic within the write path for each Parquet file.\nIn the future we may want to incorporate query usage patterns into the shredding decision.\nFor example if we see that `user_id` is frequently queried but is not a large attribute, we may want to shred it anyway.\nThis would require tracking query patterns over time and feeding this back into the ingestion/compaction pipeline.\nWe would also use this approach to decide what indexing strategies to pursue for each shredded column.\n\n## Conclusion\n\nIn the next few months you'll probably see your Logfire queries get orders of magnitude faster.\nYou don't need to do anything on your end, just continue to use [Logfire](http://pydantic.dev/logfire?utm_source=adrian_blogpost) as you currently do!\n\n## References and related work\n\n* [DataFusion Blog: Parquet Pushdown](https://datafusion.apache.org/blog/2025/03/21/parquet-pushdown/)\n* [ClickHouse JSON Type Reference](https://clickhouse.com/blog/a-new-powerful-json-data-type-for-clickhouse)\n* [Parquet Variant type](https://parquet.apache.org/docs/file-format/types/variantencoding/)","body_html":"<h2 id=\"preface\">Preface</h2>\n<p>Querying data in <a href=\"https://pydantic.dev/logfire\" rel=\"nofollow ugc noopener\">Logfire</a> is about to get dramatically faster. Consider &gt;1,000 times faster in some cases. Queries that previously timed out after 30 seconds now complete in under a second.</p>\n<blockquote><p>&quot;Seeing much faster &lt;customer_attribute&gt; query times! Feels basically instant now&quot;, says an early access customer</p></blockquote>\n<p>This technical blog post details how we achieved this through dynamic shredding, an optimization for querying semi-structured attributes data. We&#39;ll begin to roll out this feature to all customers in February 2026.</p>\n<h2 id=\"logfire-data-model\">Logfire Data Model</h2>\n<p>OpenTelemetry data (the data that Logfire ingests) is semi-structured data.\nIt contains a mix of:</p>\n<ul><li>Fixed schema fields (timestamp, span name, etc.)</li><li>Semi-structured attributes (HTTP status codes, user IDs, regions, etc.)</li></ul>\n<p>Semi-structured data is sent via <code>attributes</code> (per-span key-value pairs) or resource attributes (<code>otel_resource_attributes</code> in Logfire; these are per-resource key-value pairs where a resource is a service/k8s pod/etc, essentially an instance of your application or service).</p>\n<p>When you write code like:</p>\n<pre><code>app = FastAPI()\n\n@app.get(&#39;/user/{user_id}&#39;)\nasync def handle_request(user_id: str) -&gt; dict[str, str]:\n  with logfire.span(f&#39;handle_request for user {user_id}&#39;, user_id=user_id):\n    return {&quot;user_id&quot;: user_id}</code></pre>\n<p>This generates an export along the lines of:</p>\n<pre><code>{\n  &quot;resource&quot;: {\n    &quot;attributes&quot;: {\n      &quot;service.name&quot;: &quot;my-web-service&quot;,\n      &quot;region&quot;: &quot;us-west-2&quot;\n    }\n  },\n  &quot;spans&quot;: [\n    // Manual span created by user code\n    {\n      &quot;span_name&quot;: &quot;handle_request for user user123&quot;,\n      &quot;start_timestamp&quot;: &quot;2024-01-01T12:00:00Z&quot;,\n      &quot;end_timestamp&quot;: &quot;2024-01-01T12:00:01Z&quot;,\n      &quot;message&quot;: &quot;handle_request for user user123&quot;,\n      &quot;trace_id&quot;: &quot;0194c7b1e5f4a3b2c1d0e6f7a8b9c0d1&quot;,\n      &quot;span_id&quot;: &quot;abcdef1234567890&quot;,\n      &quot;parent_span_id&quot;: &quot;1234567890abcdef&quot;,\n      &quot;attributes&quot;: {\n        &quot;user_id&quot;: &quot;user123&quot;,\n        &quot;http.status_code&quot;: 200,\n      },\n    },\n    // Automatic span created by OpenTelemetry FastAPI instrumentation\n    {\n      &quot;span_name&quot;: &quot;GET /user/{user_id}&quot;,\n      &quot;start_timestamp&quot;: &quot;2024-01-01T12:00:00Z&quot;,\n      &quot;end_timestamp&quot;: &quot;2024-01-01T12:00:01Z&quot;,\n      &quot;message&quot;: &quot;GET /user/user123&quot;,\n      &quot;trace_id&quot;: &quot;0194c7b1e5f4a3b2c1d0e6f7a8b9c0d1&quot;,\n      &quot;span_id&quot;: &quot;1234567890abcdef&quot;,\n      &quot;parent_span_id&quot;: null,\n      &quot;attributes&quot;: {\n        &quot;http.method&quot;: &quot;GET&quot;,\n        &quot;http.response&quot;: 200,\n      },\n    }\n  ]\n}</code></pre>\n<p>I&#39;m using JSON here for illustration, although we support various wire formats (OTLP protobuf, JSON, etc.); most data is sent as protobuf over gRPC.\nThere are also some details like timestamps being represented as integers (epoch nanos) rather than ISO strings and an extra level of nesting from scopes that I&#39;ve omitted for clarity.</p>\n<p>A couple of things to note here:</p>\n<ol><li><p>Some fields like <code>start_timestamp</code>, <code>end_timestamp</code>, <code>span_name</code>, and <code>message</code> are fixed-schema fields that exist for every span</p><p> and are strongly typed.</p></li><li><p>Attributes are semi-structured and can vary widely between spans.</p><p> Some attributes like <code>http.status_code</code> and <code>http.method</code> are common and have semantic meaning in the OpenTelemetry spec,\n while others like <code>user_id</code> are application-specific and could be an integer in some places and a string in others.</p></li></ol>\n<p>We ultimately store this data as Parquet files in object storage, with a columnar schema roughly like:</p>\n<pre><code>start_timestamp: Timestamp(Microsecond, &quot;+00:00&quot;)\nend_timestamp: Timestamp(Microsecond, &quot;+00:00&quot;)\nspan_name: Utf8View\nmessage: Utf8View\ntrace_id: FixedSizeBinary(16)\nspan_id: FixedSizeBinary(8)\nparent_span_id: FixedSizeBinary(8)\nattributes: Utf8View (JSON)\notel_resource_attributes: Utf8View (JSON)</code></pre>\n<p>Some background on columnar storage, Parquet and zone map pruning is beneficial to understand the rest of this blog post. I recommend reading <a href=\"https://motherduck.com/learn-more/columnar-storage-guide/\" rel=\"nofollow ugc noopener\">MotherDuck&#39;s excellent overview of the topic</a>.</p>\n<h3 id=\"how-we-started-fast-json-parsing\">How we started: fast JSON parsing</h3>\n<p>Pydantic started as a library to validate and parse JSON data efficiently.\nWhen we built Logfire, we initially stored all attributes as JSON blobs in a single column and relied on <a href=\"https://github.com/pydantic/jiter\" rel=\"nofollow ugc noopener\">Jiter</a> (one of the fastest JSON parsers out there in any language) to rip through the JSON data as quickly as possible.\nThis is the layout detailed above (<code>attributes: Utf8View (JSON)</code>).</p>\n<p>This approach was simple but made a fundamental mistake: we were optimizing to do work faster, but we couldn&#39;t really avoid doing the work.\nCompression is also suboptimal since a single varying attribute makes Parquet level dictionary compression ineffective and even zstd struggles with highly variable data, all the JSON syntax overhead, etc.</p>\n<p>The biggest problem really ends up being extra IO: since most of our data is stored on object storage, extra IO from downloading large JSON blobs, and especially the latency of starting these downloads, ends up dominating query runtimes much more so than any CPU work.</p>\n<h3 id=\"shredding-columns-to-save-io-and-compute\">Shredding columns to save IO and compute</h3>\n<p>To make queries fast, we need to avoid unnecessary work.\nFor normal columns DataFusion / Parquet avoid unnecessary work by using columnar storage (allowing reading only the columns you need)\nand zone maps / min-max statistics (allowing skipping entire row groups or pages that don&#39;t match your filter predicates).</p>\n<p>So when you write a query like:</p>\n<pre><code>SELECT count(*) FROM records WHERE duration &lt; 0;</code></pre>\n<p>The system can efficiently read just the <code>duration</code> column and skip row groups where the max duration is &lt; 0 based on the stored statistics.\nIn this case this is a valid query, duration is a f64 so in theory it could have values &lt;0.\nIn practice though duration is always &gt;= 0 so this query will be very fast: based on statistics we know no rows match and we can return 0 immediately.</p>\n<p>So how can we enable this for semi-structured data like attributes stored as JSON?</p>\n<p>Our initial solution was <em>static shredding</em>: we manually picked a small set of commonly queried attributes\n(like <code>http.status_code</code>, <code>http.method</code>, etc.) and extracted these into their own strongly typed columns during ingestion.</p>\n<p>So when we receive:</p>\n<div class=\"table-wrap\"><table><thead><tr><th>attributes</th></tr></thead><tbody><tr><td>{&#39;http.response.status_code&#39;: 200, &#39;url.full&#39;: &#39;https:‎//example.com?foo=bar&#39;}</td></tr><tr><td>{&#39;my_llm_response&#39;: &#39;large text&#39;}</td></tr><tr><td>{&#39;user_id&#39;: &#39;123&#39;}</td></tr></tbody></table></div>\n<p>Assuming our hardcoded shredding configuration extracts <code>http.response.status_code</code>, <code>url.full</code>, and <code>db.query.text</code>, we would store:</p>\n<div class=\"table-wrap\"><table><thead><tr><th>_lf_attributes</th><th>_lf_attributes_http.response.status_code</th><th>_lf_attributes_url.full</th><th>_lf_attributes_db.query.text</th></tr></thead><tbody><tr><td>null</td><td>200</td><td>https:‎//example.com?foo=bar</td><td>null</td></tr><tr><td>{&#39;my_llm_response&#39;: &#39;large text&#39;}</td><td>null</td><td>null</td><td>null</td></tr><tr><td>{&#39;user_id&#39;: &#39;123&#39;}</td><td>null</td><td>null</td><td>null</td></tr></tbody></table></div>\n<p>Now we can run queries like:</p>\n<pre><code>SELECT count(*) FROM records WHERE _lf_attributes_http.response.status_code = 500;</code></pre>\n<p>We can use statistics to prune files / row groups / pages that don&#39;t match,\nand then scan just the <code>_lf_attributes_http.response.status_code</code> column which is strongly typed (int64) and compresses well.</p>\n<p>Of course you as a user don&#39;t have to use the <code>_lf_attributes_http.response.status_code</code> column directly, you can write:</p>\n<pre><code>SELECT count(*) FROM records WHERE attributes-&gt;&#39;http.response.status_code&#39; = 500;</code></pre>\n<p>And we rewrite this under the hood to use the shredded column.</p>\n<p>I thought it worth mentioning the <code>_lf_attributes</code> prefix: one of the challenges with the current static shredding approach\nis that these columns are user-visible, e.g. if you run <code>select * from records limit 10</code> you&#39;ll see these columns.\nSince these are an internal implementation detail, we&#39;d rather not leak them to you.\nWe want to keep the logical layout of <code>attributes</code> as a single JSON column from your perspective,\nwhile optimizing the physical layout under the hood.</p>\n<h3 id=\"the-bad-limited-flexibility-high-cost-for-unshredded-attributes-\">The Bad: Limited Flexibility, High Cost for Unshredded Attributes and Overheads</h3>\n<p>There are several issues with this scheme:</p>\n<ul><li><strong>Limited Flexibility</strong>: Only a small, hardcoded set of attributes are shredded. If users want to query other attributes (e.g., <code>user_id</code>, <code>session_id</code>, <code>region</code>), they pay the full cost of JSON storage and parsing. Queries on these attributes can get slow on projects with 100s of GBs of data. We can&#39;t easily add new shredded attributes without code changes and deployments. This is particularly painful with indexing; we only have indexes on the hardcoded set of shredded attributes.</li><li><strong>High Cost for Unshredded Attributes</strong>: Queries that filter on unshredded attributes (e.g., <code>attributes-&gt;&gt;&#39;user_id&#39; = &#39;user123&#39;</code>) require scanning the entire JSON column, leading to high I/O, poor compression, and expensive JSON parsing. This results in slow queries and high resource usage. Since data is stored in chunks, even a single large unshredded attribute can bloat the entire column, making all queries expensive even if they are not touching that attribute.</li><li><strong>User-visible columns</strong>: The shredded columns are visible to users, leading to confusion and clutter in query results. Users may not understand the purpose of these internal columns, and they can complicate query writing and result interpretation.</li><li><strong>Overheads</strong>: As you can see, there are several columns that may be all <code>null</code>s (e.g. if the application doesn&#39;t do DB queries, the <code>_lf_attributes_db.query.text</code> column is always null). Although Parquet / Arrow / DataFusion are quite efficient at handling nulls, there is still some overhead in storage and processing for these unused columns.</li></ul>\n<p>Every query touching <code>attributes</code> that isn&#39;t filtering on the small hardcoded set of shredded attributes pays these costs.</p>\n<p>For example, consider the query:</p>\n<pre><code>SELECT * FROM events WHERE attributes-&gt;&gt;&#39;my_llm_response&#39; like &#39;%important%&#39;;</code></pre>\n<p>This query needs to scan the entire <code>attributes</code> JSON column since <code>my_llm_response</code> is not shredded.\nEven though <code>my_llm_response</code> is a large attribute it may be a <em>rare</em> attribute that only appears in a small fraction of rows.\nThink about it this way: you probably have many more spans that don&#39;t involve LLM calls than ones that do, even in an agentic application.</p>\n<pre><code>┌─────────────────────────────────────────┐\n│  Cost 1: Download Entire _lf_attributes │\n│  (could be GBs of data)                 │\n└─────────────────────────────────────────┘\n                    ↓\n┌──────────────────────────────────────────┐\n│  Cost 2: Poor Compression                │\n│  Variable structure → zstd 10% efficiency│\n└──────────────────────────────────────────┘\n                    ↓\n┌─────────────────────────────────────────┐\n│  Cost 3: JSON Parsing                   │\n│  Jiter does lazy parsing, worst case    │\n│  must parse entire row.                 │\n└─────────────────────────────────────────┘\n                    ↓\n┌─────────────────────────────────────────┐\n│  Cost 4: In-Memory Operations           │\n│  Create copies of extracted values      │\n│  Do comparisons in memory               │\n└─────────────────────────────────────────┘</code></pre>\n<p><strong>The Result</strong>: this query has to parse and scan potentially GBs of JSON data, much of which is irrelevant to the query, just to get the few rows that contain <code>my_llm_response</code>.</p>\n<p>Or consider the query:</p>\n<pre><code>SELECT * FROM events WHERE attributes-&gt;&gt;&#39;user_id&#39; = &#39;user123&#39;;</code></pre>\n<p>This query now has the opposite problem: even if 1/3 of rows contain a <code>user_id</code> attribute, we have to scan the entire JSON column to find them,\nwhich includes those large <code>my_llm_response</code> attributes that bloat the column size.</p>\n<h2 id=\"the-solution-dynamic-shredding\">The Solution: Dynamic Shredding</h2>\n<p>Rather than storing all attributes in a single JSON column, we automatically extract the <strong>most frequently accessed attributes</strong> into separate, strongly typed columns.\nThis happens automatically during data ingestion and compaction—no configuration needed.</p>\n<p>Let&#39;s compare the physical layout of data before and after dynamic shredding.</p>\n<p><strong>Before (Current)</strong>:</p>\n<div class=\"table-wrap\"><table><thead><tr><th>_lf_attributes</th><th>_lf_attributes_http.response.status_code</th><th>_lf_attributes_url.full</th><th>_lf_attributes_db.query.text</th></tr></thead><tbody><tr><td>null</td><td>200</td><td>https:‎//example.com?foo=bar</td><td>null</td></tr><tr><td>{&#39;my_llm_response&#39;: &#39;large text&#39;}</td><td>null</td><td>null</td><td>null</td></tr><tr><td>{&#39;user_id&#39;: &#39;123&#39;}</td><td>null</td><td>null</td><td>null</td></tr></tbody></table></div>\n<p><strong>After (With Dynamic Shredding)</strong>:</p>\n<div class=\"table-wrap\"><table><thead><tr><th>_lf_attributes</th><th>_lf_attributes_http.response.status_code</th><th>_lf_attributes_url.full</th><th>_lf_attributes_my_llm_response</th><th>_lf_attributes_user_id</th></tr></thead><tbody><tr><td>null</td><td>200</td><td>https:‎//example.com?foo=bar</td><td>null</td><td>null</td></tr><tr><td>null</td><td>null</td><td>null</td><td>large text</td><td>null</td></tr><tr><td>null</td><td>null</td><td>null</td><td>null</td><td>123</td></tr></tbody></table></div>\n<p>This layout is generated dynamically for each Parquet file we write out based on the actual data we see.\nWe don&#39;t have to include all null columns and we can prioritize shredding the most frequently seen or largest attributes.</p>\n<p>Now when the query above runs:</p>\n<pre><code>SELECT * FROM events WHERE attributes-&gt;&gt;&#39;my_llm_response&#39; like &#39;%important%&#39;;</code></pre>\n<p>It can efficiently scan just the <code>_lf_attributes_my_llm_response</code> column:</p>\n<pre><code>┌─────────────────────────────────────────┐\n│  Benefit 1: Index pruning               │\n│  Zone maps and full text search indexes │\n│  let us skip entire files / row groups  │\n└─────────────────────────────────────────┘\n                    ↓\n┌──────────────────────────────────────────┐\n│  Benefit 2: Download Only Relevant Column│\n│  (_lf_attributes_my_llm_response)        │\n└──────────────────────────────────────────┘\n                    ↓\n┌─────────────────────────────────────────┐\n│  Benefit 3: Better Compression          │\n│  English text string data               │\n│  via zstd + dictionary encoding         │\n│  Maybe FastLanes in future?             │\n└─────────────────────────────────────────┘\n                    ↓\n┌─────────────────────────────────────────┐\n│  Benefit 4: Zero JSON Parsing           │\n│  Data already extracted &amp; typed         │\n│  No deserialization, zero copy into mem │\n└─────────────────────────────────────────┘</code></pre>\n<p>This query now benefits from the full efficiency of progressive narrowing / pruning and columnar storage.\nIt is as fast as if <code>my_llm_response</code> were a first-class column in the schema.</p>\n<h2 id=\"the-hard-path-to-dynamic-shredding\">The Hard Path to Dynamic Shredding</h2>\n<p>We&#39;ve been working on this feature for nearly 6 months now.\nIt&#39;s required a lot of upstream changes in DataFusion to enable expression pushdown and predicate pushdown for JSON accessors.\nThis is the blessing and the curse of working with an upstream open source project: we get to build on a solid foundation, but we also have to wait for upstream changes to land and get released.\nWe&#39;ve worked on this feature in a way that benefits not only us but anyone using DataFusion with semi-structured or nested data, including Parquet/Arrow struct columns and the newly introduced <a href=\"https://parquet.apache.org/docs/file-format/types/variantencoding/\" rel=\"nofollow ugc noopener\">Json Variant type</a>.</p>\n<p>We&#39;ve had to refactor core parts of the DataFusion execution engine to push down expressions into the Parquet reader so that it can use the physical file layout to optimize expression evaluation (in our case to decide if we need to read from the <code>_lf_attributes</code> remainder column or if there is a shredded column we can use instead).\nThis work is done and has fully landed as of DataFusion 52.1.0 (Jan 2026).</p>\n<p>The remaining work involves actually getting these expressions into the scan operators, e.g. for cases like:</p>\n<pre><code>SELECT attributes-&gt;&gt;&#39;http.response.status_code&#39;, count(*)\nFROM records\nGROUP BY attributes-&gt;&gt;&#39;http.response.status_code&#39;;</code></pre>\n<p>Pulling out the <code>attributes-&gt;&gt;&#39;http.response.status_code&#39;</code> expression from the GROUP BY clause and pushing it down into the Parquet scan so that we can use the shredded column directly is a bit tricky, and involves complex expression rewriting and optimization passes, but we expect to land this work in DataFusion by Feb 2026.\nThe upshot of this is that we&#39;ve been able to contribute major improvements to DataFusion that benefit a broad community of users.</p>\n<h2 id=\"minimizing-breakage-for-users\">Minimizing Breakage for Users</h2>\n<p>Our plan to land this while minimizing bugs and breakage for users is to first rewrite our query path (non-destructive, most of this has already been done) to use dynamic shredding but keep the static shredding in the write path.\nAlong with this change we&#39;ll eliminate and isolate all notion of hardcoded shredded columns from the codebase into a self-contained section of the write path.\nEssentially our initial heuristic for dynamic shredding will mimic the current static shredding configuration.\nThis lets us test out the vast majority of these changes without making any irreversible changes (like writing out a new physical layout).</p>\n<p>Once we have confidence that the dynamic shredding query path is solid, we can flip the switch in the write path to enable dynamic shredding using the discovery algorithm.</p>\n<h2 id=\"how-do-we-decide-what-to-shred\">How do we decide what to shred?</h2>\n<p>Analysis of our production data has shown that 99% of our existing Parquet files have fewer than 128 unique top-level keys in the <code>attributes</code> JSON column.\nThe P50 is closer to 32 unique keys.</p>\n<p>We&#39;ve thus chosen a default of extracting the top 128 largest (by total size across all rows) keys from each JSON column during ingestion/compaction.</p>\n<p>This automatic discovery approach balances the tradeoff between:</p>\n<ul><li>Extracting enough attributes to cover common query patterns and maximize performance benefits</li><li>Avoiding excessive column proliferation and overhead from shredding too many attributes</li></ul>\n<p>For example, if you accidentally start sending logs like <code>logfire.span(&#39;foo&#39;, **{uuid.uuid4(): &#39;some value&#39; for _ in range(1000)})</code>, we don&#39;t want to shred all 1000 dynamically generated keys.\nThis algorithm is robust to even such pathological cases because it will shred the non-unique keys that dominate the data while putting the rest of the &quot;accidental&quot; dynamically generated keys into the reduced JSON column.\nQueries against these rare keys will still work, just with the usual JSON parsing overhead.\nQueries against the rest of the data will remain fast.</p>\n<p>Having a hard cap on the number of shredded columns lets us have a controlled overhead in terms of Parquet column count which avoids bloating the schema and incurring excessive overheads in parsing of Parquet metadata.\nOur empirical measurements shows that ~ 1000 columns is not a problem for Parquet / DataFusion, but more than that and you start to see a noticeable performance overhead.</p>\n<h2 id=\"how-are-data-types-determined\">How are data types determined?</h2>\n<p>As we discover which attributes to shred, we also need to determine the data type for each shredded column.\nWe do this by keeping track of the current planned output data type for each candidate attribute as we scan through the data and widening the type as needed.\nThe basic rule is <code>Int</code> -&gt; <code>Float</code> -&gt; <code>JSON</code>, <code>Bool</code> -&gt; <code>JSON</code> and <code>String</code> -&gt; <code>JSON</code>.\nThere is an important distinction between <code>String</code> and <code>JSON</code>.\nConsider the values <code>[{&quot;user_id&quot;: &quot;123&quot;}, {&quot;user_id&quot;: 456}]</code>.\nIn this case we would store the data as JSON which practically speaking is a <code>Utf8View</code> column containing JSON strings: [<code>&quot;123&quot;</code>, <code>456</code>].\nThis means JSON parsing is still needed to read from this column but we still benefit from columnar storage, compression, and to some extent pruning.\nIf on the other hand we have <code>[&quot;123&quot;, &quot;456&quot;]</code> we can store this as a <code>Utf8View</code> column directly without JSON parsing: [<code>123</code>, <code>456</code>].</p>\n<h2 id=\"future-work\">Future work</h2>\n<h3 id=\"using-the-parquet-variant-type\">Using the Parquet Variant type</h3>\n<p>Our shredded data layout predates both the Parquet Variant type and <a href=\"https://clickhouse.com/blog/a-new-powerful-json-data-type-for-clickhouse\" rel=\"nofollow ugc noopener\">ClickHouse&#39;s JSON support</a>\nbut is very similar to these approaches.</p>\n<p>Our long term plan is to adopt the Parquet Variant type for storing the remaining unshredded attributes, which will give us essentially the same architecture but using an industry standard data layout that other query engines can also read and write, and moving complexity from our code to DataFusion / Parquet itself.</p>\n<p>The DataFusion community (us included) is still working through some of the details of the Parquet Variant type.\nNamely we need to incorporate the variant UDFs into DataFusion and need to add support for statistics of nested struct types to enable pruning.</p>\n<h3 id=\"dynamic-indexing\">Dynamic indexing</h3>\n<p>We will initially have basic support for zone map pruning on shredded columns, but in the future we would like to add more index types like:</p>\n<ul><li>Inverted indexes for text columns to speed up <code>LIKE</code> and full-text search queries, e.g. so you can quickly filter LLM responses for keywords.</li><li>Bloom filters for high-cardinality columns to speed up equality filters.</li></ul>\n<h3 id=\"usage-based-shredding\">Usage based shredding</h3>\n<p>Our initial approach will be to shred based on attribute size/frequency in the data.\nThis lets us self-contain the heuristic within the write path for each Parquet file.\nIn the future we may want to incorporate query usage patterns into the shredding decision.\nFor example if we see that <code>user_id</code> is frequently queried but is not a large attribute, we may want to shred it anyway.\nThis would require tracking query patterns over time and feeding this back into the ingestion/compaction pipeline.\nWe would also use this approach to decide what indexing strategies to pursue for each shredded column.</p>\n<h2 id=\"conclusion\">Conclusion</h2>\n<p>In the next few months you&#39;ll probably see your Logfire queries get orders of magnitude faster.\nYou don&#39;t need to do anything on your end, just continue to use <a href=\"http://pydantic.dev/logfire?utm_source=adrian_blogpost\" rel=\"nofollow ugc noopener\">Logfire</a> as you currently do!</p>\n<h2 id=\"references-and-related-work\">References and related work</h2>\n<ul><li><a href=\"https://datafusion.apache.org/blog/2025/03/21/parquet-pushdown/\" rel=\"nofollow ugc noopener\">DataFusion Blog: Parquet Pushdown</a></li><li><a href=\"https://clickhouse.com/blog/a-new-powerful-json-data-type-for-clickhouse\" rel=\"nofollow ugc noopener\">ClickHouse JSON Type Reference</a></li><li><a href=\"https://parquet.apache.org/docs/file-format/types/variantencoding/\" rel=\"nofollow ugc noopener\">Parquet Variant type</a></li></ul>","headings":[{"level":2,"text":"Preface","id":"preface"},{"level":2,"text":"Logfire Data Model","id":"logfire-data-model"},{"level":3,"text":"How we started: fast JSON parsing","id":"how-we-started-fast-json-parsing"},{"level":3,"text":"Shredding columns to save IO and compute","id":"shredding-columns-to-save-io-and-compute"},{"level":3,"text":"The Bad: Limited Flexibility, High Cost for Unshredded Attributes and Overheads","id":"the-bad-limited-flexibility-high-cost-for-unshredded-attributes-"},{"level":2,"text":"The Solution: Dynamic Shredding","id":"the-solution-dynamic-shredding"},{"level":2,"text":"The Hard Path to Dynamic Shredding","id":"the-hard-path-to-dynamic-shredding"},{"level":2,"text":"Minimizing Breakage for Users","id":"minimizing-breakage-for-users"},{"level":2,"text":"How do we decide what to shred?","id":"how-do-we-decide-what-to-shred"},{"level":2,"text":"How are data types determined?","id":"how-are-data-types-determined"},{"level":2,"text":"Future work","id":"future-work"},{"level":3,"text":"Using the Parquet Variant type","id":"using-the-parquet-variant-type"},{"level":3,"text":"Dynamic indexing","id":"dynamic-indexing"},{"level":3,"text":"Usage based shredding","id":"usage-based-shredding"},{"level":2,"text":"Conclusion","id":"conclusion"},{"level":2,"text":"References and related work","id":"references-and-related-work"}]}}