GROUP BY cardinality can make a shopifyqlQuery date range unqueryable at any width

Sharing a reproducible case in case it helps others, plus two questions about the API.

The documented limit is 1000 points per query, with cost described as structural rather than row driven. What we hit is a consequence of that which was not obvious to us: the cardinality of a GROUP BY dimension can push a query over the cap for specific dates, and no amount of narrowing the date range gets you back under it.

The case

Query shape, unchanged across every test, run against one merchant store within a few minutes of each other:

FROM sessions SHOW sessions GROUP BY day, landing_page_path SINCE <a> UNTIL <b> ORDER BY day LIMIT 400000
range width rows result
2025-09-01 to 2025-10-01 30 days 2,879 OK
2026-05-01 to 2026-05-31 30 days 5,225 OK
2026-06-01 to 2026-07-01 30 days 10,416 OK
2026-07-24 to 2026-07-31 8 days 50,494 OK
2026-07-01 to 2026-07-31 30 days none THROTTLED on 3 attempts, 65s apart
2026-07-22 to 2026-07-23 1 day none THROTTLED on 3 attempts, 65s apart

A one day range failing while an eight day range covering the same period succeeds rules out width, range scaled cost, and bucket exhaustion together. A 30 day June query succeeded seconds either side of the July failures.

The cause shows up in the paths that do come back:

/collections/new-arrivals/designer_askk-ny+designer_bao-bao-by-issey-miyake+designer_dries-van-noten+style_blazer+style_dress+style_knitwear

Faceted collection URLs. Every filter combination is a distinct landing_page_path, so cardinality grows combinatorially with the number of filters a storefront offers. This store went from roughly 175 distinct paths a day in May to roughly 6,300 a day in late July, and on 2026-07-22 the query stopped being runnable at all.

The fix, for anyone hitting the same thing

Filter the high cardinality dimension inside the query rather than in your own code afterwards. We only ever wanted product pages:

FROM sessions SHOW sessions GROUP BY day, landing_page_path WHERE landing_page_path STARTS WITH '/products/' SINCE 2026-07-22 UNTIL 2026-07-23 ORDER BY day LIMIT 400000

That returned the previously unqueryable day in under a second, with 133 rows.

Two syntax notes that cost us time. STARTS WITH and CONTAINS both work; LIKE is rejected by the parser with “no viable alternative at input”. The WHERE clause has to come before SINCE.

Questions

  1. Does the cost estimate account for the cardinality of a GROUP BY dimension, or only the number of dimensions and the span of the date range? The observed behaviour suggests cardinality, and knowing that explicitly would change how people design these queries.

  2. A rejected query carries no cost information in the response. Is there any way to see the requested cost of a query that was refused? Without it a client cannot tell “this query is too expensive, change its shape” apart from “the bucket is empty, wait and retry”, and those need opposite responses. We spent a day narrowing date ranges when narrowing could never have worked.

Happy to share the store domain and exact timestamps privately with staff.

Following up with an answer to my own second question, in case it saves anyone else the time.

A rejected query returns no ShopifyQL cost information. It carries only the ordinary Admin GraphQL cost object, never shopifyqlCost.

Rejected, unfiltered, one day range:

"cost": { "requestedQueryCost": 3, "actualQueryCost": 1,
          "throttleStatus": { "maximumAvailable": 4000, "currentlyAvailable": 3999, "restoreRate": 200 } }

Successful, same day, only difference is the WHERE clause:

"cost": { "requestedQueryCost": 3, "actualQueryCost": 3,
          "throttleStatus": { "maximumAvailable": 4000, "currentlyAvailable": 3997, "restoreRate": 200 } }
"shopifyqlCost": { "requestedQueryCost": 14, "maximumAvailable": 1000,
                   "currentlyAvailable": 976, "windowResetAt": "2026-08-01T10:02:00+00:00" }

Two things worth noting.

The outer cost bucket sits at 3999 of 4000 while ShopifyQL is refusing the query outright. If you read that meter and conclude you have headroom, you will conclude wrongly. shopifyqlCost is the one that matters, and it only appears on success.

The filtered query costs 14 points against a 1000 maximum. Same day, same shape, same limit, the only change being the landing_page_path filter. So the unfiltered version is somewhere north of 1000, which is fairly direct confirmation that GROUP BY cardinality is what drives the cost.

The practical upshot is that requestedQueryCost is only observable on queries that succeed, so it can be used to size the next window but never to explain the one that just failed. On a failure the only safe assumption is that the query was too expensive rather than that the bucket was empty.

Still curious whether exposing the requested cost on a rejection is feasible. It is the one number that would let a client respond correctly first time.

Hey @Luke! Your investigation is very thorough and the STARTS WITH filter is a good workaround for what you’ve bumped into. The cost model works a bit differently than you’ve inferred though.

The ShopifyQL docs describe query complexity as growing with the number of keywords and clauses, metrics in SHOW, and GROUP BY dimensions. The number of distinct values a grouped dimension produces isn’t listed as a complexity factor. Narrowing the date range can definitely help, but if the query shape is complex enough, even a one-day range can exceed the limit.

Your STARTS WITH filter aligns with the query optimization guidance to filter early and limit dimensions. The single query limit of 1,000 points is enforced before execution based on requested cost, which is separate from the actual execution load.

On your second question, the docs describe shopifyqlCost as something you read on a successful response to check your remaining budget before you hit a 429. You’re right that a rejected response doesn’t reliably include it, so you can’t distinguish “query too expensive” from “budget temporarily exhausted” from the response alone. This is useful feedback for the team to consider, so I’ve passed that along internally.

If you want to pin down what caused a specific rejection, share the x-request-id for one of the failed requests and I can look at the logs for you - thanks for sharing your findings here!

Thanks @Donal-Shopify, that’s useful, and good to know the rejection vs exhaustion ambiguity has gone to the team.

Here’s a request id for a failed run, reproduced today at 11:07:59 UTC, 7 August:

36d977b7-ac18-4c74-bddc-8e0847c8e579-1786100879

Happy to DM you the store domain if you need it to pull the logs.

The query was unchanged from the original report:

FROM sessions SHOW sessions GROUP BY day, landing_page_path SINCE 2026-07-22 UNTIL 2026-07-23 ORDER BY day LIMIT 400000

Three attempts, 65 seconds apart, all rejected, no shopifyqlCost on any of them.

On the cost model, the part I can’t reconcile is that the query which succeeds has more clauses than the one that fails. Adding WHERE landing_page_path STARTS WITH ‘/products/’ is the only difference between them: same day, same metric, same dimensions, same limit. Filtered, it returns 133 rows at requestedQueryCost 14 against the 1000 cap. If cost were purely structural, the unfiltered version should sit at or below 14 rather than above 1000. That same shape also runs fine unfiltered across 30 days of June, so it isn’t the shape or the range.

Happy to be wrong about the mechanism, but something the WHERE clause changes is being priced, and it doesn’t appear to be structure. Hopefully the logs for that request id show what.

Thanks for the request ID @Luke! I pulled the logs and the one-day query didn’t trip the 1,000-point cost cap at all. It executed, then failed when the result set exceeded a backend response-size limit on the way back. The missing shopifyqlCost isn’t a cost-check rejection hiding the number. The cost check passed and the query ran, but the response couldn’t be delivered.

That also reconciles your filtered/unfiltered comparison. WHERE landing_page_path STARTS WITH '/products/' works because it cuts the result from thousands of rows down to 133, which shrinks the payload under the size limit. Your instinct that cost can’t be the whole story was correct.

The misleading part is that a response-size failure surfaces as THROTTLED / “Rate limited. Please retry later,” which points you toward retrying when the fix is to narrow the query. I’ve raised that error mapping with the analytics team for review, and I’ll follow up here once I hear back on whether a distinct, machine-readable error is planned. The ShopifyQL errors, limits, and performance page covers the cost model but doesn’t document this failure path.

Brilliant stuff as always, thanks for your cooperation on this!

That’s the missing piece, thanks for digging into the logs.

So the cost model was never involved, and my cardinality theory was wrong: high-cardinality dimensions blow up the row count, and the row count blows the payload. Same fix, different mechanism. Good to know the filter works for a better reason than I thought.

Agreed the error mapping is the painful part. THROTTLED plus “retry later” sends you to back off and wait, which is the one response that can never work, and there’s no cost number to contradict it. In our case that was a day of narrowing date ranges when narrowing by date only helps if it happens to shrink the result set.

One thing that would help in the meantime: is there a documented or shareable ceiling for the response size, in rows or bytes? Even a rough figure would let us size a query before sending it rather than discovering the limit by failure. Right now our only signal is that one query returned 50,494 rows fine and another failed, so we’re guessing at where the line sits.

Happy to test anything if it’s useful. Thanks again for chasing this down.