Cosmos's SQL feels familiar but it's running on a sharded JSON store, not a relational planner. The single most important rule — **always include the partition key in your `WHERE` clause**, or you fan out to every partition and the bill follows. Beyond that, queries support most of what you'd want — filters, projections, aggregations, and self-joins for arrays.
- ▸Single-partition queries (with the partition key in WHERE) cost 5–10 RUs. Cross-partition can be 30+, sometimes 100+.
- ▸The query engine doesn't have JOINs across documents. The JOIN keyword is for arrays inside one doc.
- ▸OFFSET ... LIMIT works but is paid linearly — Cosmos still scans skipped rows. For deep paging, use continuation tokens.
- ▸ORDER BY requires a range index on the field. Composite ORDER BYs need a composite index, opt-in only.
- ▸The Diagnostics object on every query response tells you exactly which partitions were hit and how many RUs each consumed.
Cosmos’s SQL surface is mostly familiar — SELECT, FROM, WHERE, ORDER BY, GROUP BY. But “mostly familiar” is the trap. Every difference compounds when you scale, and the biggest one is that there’s no relational planner — every query lives or dies by whether it touches one partition or many.
The mandatory rule
SELECT * FROM c WHERE c.id = "abc123" AND c.userId = "u-42"
That c.userId (assuming /userId is the partition key) is what turns this from a fan-out into a single-partition lookup. Without it, Cosmos has to broadcast the query to every physical partition and merge the results. Always include the partition key when you have it.
If you genuinely need to query across partitions — say, “all orders this month across all customers” — that’s fine, but treat it as an analytical query and budget the RUs accordingly. You might even ship the data to Synapse Link or a search index instead.
Projections, not SELECT *
Reading the whole document costs RUs proportional to its size. If you only need three fields, project them:
SELECT c.id, c.name, c.total
FROM c
WHERE c.userId = "u-42"
This isn’t just bandwidth — Cosmos charges RUs roughly by KB scanned. A 50-field document with three needed fields is wasted 90% of the time. Project early, project everywhere.
ORDER BY needs an index
By default Cosmos indexes everything as a hash index — perfect for equality, useless for ordering. To ORDER BY c.createdAt DESC, you need a range index on /createdAt. The default policy includes range indexes for everything; if you’ve narrowed the policy (lesson V07), make sure the path is still indexed for range.
Composite ORDER BYs (ORDER BY c.tenantId, c.createdAt) need a composite index, declared explicitly. Without it, the query fails — Cosmos is honest about this rather than scanning silently.
JOIN — but not the SQL kind
JOIN in Cosmos unpacks an array inside a document. Given:
{ "id": "o-1", "items": [{"sku": "A"}, {"sku": "B"}] }
SELECT o.id, i.sku
FROM o
JOIN i IN o.items
WHERE i.sku = "A"
That returns one row per matching array element. There is no cross-document JOIN. If you need data from two entities together, your model should already store them together (lesson V03).
Pagination, the right way
Two options:
OFFSET ... LIMIT — works for small offsets. Cosmos still has to walk every skipped doc, so OFFSET 10000 costs ~10000 docs of work.
Continuation tokens — the proper API. Each query response carries a token; pass it back in the next request. O(1) per page regardless of depth.
results = container.query_items(
query="SELECT * FROM c WHERE c.userId = @uid",
parameters=[{"name": "@uid", "value": "u-42"}],
enable_cross_partition_query=False,
max_item_count=20,
)
for page in results.by_page():
for item in page:
...
# The continuation token is opaque — store it client-side for "load more"
Reading the Diagnostics
Every response has a Diagnostics object. In production, log it for slow queries — it tells you:
- RU consumed per partition
- Which partitions were touched
- Time spent in each query operator
- Whether the index was used
If you see “Index utilization 0%” you’ve written a query that scans every doc. Either add an index path or restructure the filter.
What’s next
Lesson V07 is all about indexing — when to opt out of the default greedy policy, when to add composite indexes, when to use spatial. Lesson V09 is the RU economics that make all of this matter financially.
Q1. Why is my COUNT(*) so expensive? ▾
Because by default it scans every matching document across every partition. Two fixes — (1) add the partition key to the WHERE clause if your count is per-partition, (2) maintain a counter doc updated via Change Feed for global counts. Don't pay 1000 RUs every page-view to compute a number you could cache.
Q2. My query has the partition key but still costs 30 RUs. Why? ▾
Three usual suspects — (1) you're returning `*` and the doc is large (project only what you need), (2) `ORDER BY` on a path without a range index, (3) array unpacking via `JOIN` that explodes the working set. Check the Diagnostics — it lists each operator's cost.
Q3. Can I do a JOIN between two containers? ▾
No. Cosmos has no cross-document JOINs by design — it'd be a fan-out across two sharded sets. The pattern is to denormalize at write time (lesson V03) so the JOIN is implicit (the data is already in one document).
Comments 0
Discuss this page. Markdown supported. Be kind.