Picking a Cosmos DB Partition Key Before It Picks You
Your Cosmos DB partition key is chosen once and paid for forever. Here's how logical partitions, the 20 GB cap, and cross-partition fan-out should drive the decision — and how to validate it with measured RU charges.
02 Jan 2026, 07:46 UTC

The most expensive line of code in a Cosmos DB for NoSQL project often isn't code at all — it's the partition key you typed into the container creation dialog on day one. That single property decides where your data physically lives, how your throughput is divided, and whether your queries cost 3 request units or 300. And once the container exists, you can't change it without migrating to a new one.
The good news: you don't need to guess. A small amount of upfront reasoning about cardinality, the 20 GB logical partition limit, and your dominant query patterns gets you most of the way to a key you won't regret.
What a partition key actually controls
Every item in a container carries a partition key property (for example, /userId). Items that share a value form a logical partition — the unit Cosmos DB uses to place data and distribute throughput across physical partitions behind the scenes.
Three consequences follow directly:
- Size cap: a logical partition is limited to 20 GB of data. A low-cardinality key like
/isActiveor/countrycan hit that ceiling as data grows, and once it does, writes for that value start failing regardless of how much throughput you've provisioned. - Throughput splitting: provisioned RU/s is divided evenly across physical partitions. If one logical partition gets most of the traffic, it can throttle with HTTP 429s even while the container as a whole looks under-utilized — the classic "hot partition."
- Query cost: a point read (id + partition key) or a query filtered to one partition key value is the cheapest operation you can do. A query without the partition key in the filter fans out to every physical partition and multiplies the RU charge accordingly.
A worked example: telemetry from devices
Say you're storing sensor readings: { id, deviceId, timestamp, temperature }. Your two dominant access patterns are "latest readings for one device" and "occasional fleet-wide aggregates." Three candidate keys:
| Candidate key | Cardinality | Risk |
|---|---|---|
/deviceId | High (one partition per device) | Fleet-wide queries fan out cross-partition |
/date (e.g., 2026-10-13) | Low | All of today's writes hit one partition — hot by design, plus the 20 GB cap looms |
/id | Maximum | Every query becomes a point read or a full fan-out; no grouping at all |
/deviceId wins here: the hot path (per-device queries) stays in-partition and cheap, each device is unlikely to accumulate 20 GB, and the expensive fleet-wide aggregates are rare enough to tolerate fan-out — or better, to serve from a separate aggregated container populated via Change Feed.
Note the trap with /date: hot partitions aren't only a low-cardinality problem. Time-series workloads that write everything to "now" concentrate all write traffic on one partition even if the key technically has many values over time.
When one property isn't enough: hierarchical keys
Multi-tenant systems often want two things at once: isolate tenants, but query within a tenant by user or device. Hierarchical partition keys let you specify a path like /tenantId, /userId (and optionally a third level). Queries can then target a tenant, or a tenant-plus-user, without fanning out to the whole container — while the combined key keeps cardinality high enough to avoid the 20 GB problem for any single tenant.
The older workaround — concatenating values into a synthetic key like "tenantA|user123" — still works, but it forces queries to match the full synthetic value or fall back to cross-partition scans with a prefix filter. Hierarchical keys are the cleaner supported pattern for new designs; confirm current support for your API and SDK version before committing, since availability has rolled out over time.
Verify with RU charges, not vibes
You can validate a candidate key in an afternoon. Create a small test container, load a representative slice of data (a few thousand items with realistic value distribution), and compare the RU charge of your key queries. In the .NET SDK, every response exposes it:
// Run in any environment with the Azure Cosmos DB SDK and a test container.
// Requires: Microsoft.Azure.Cosmos package, a database/container you own.
var query = new QueryDefinition(
"SELECT * FROM c WHERE c.deviceId = @deviceId")
.WithParameter("@deviceId", "device-042");
using FeedIterator it = container.GetItemQueryIterator(
query, requestOptions: new QueryRequestOptions
{
PartitionKey = new PartitionKey("device-042")
});
while (it.HasMoreResults)
{
var page = await it.ReadNextAsync();
Console.WriteLine($"RU: {page.RequestCharge}");
}Now run the same query without the PartitionKey option and compare the charge — that delta is the fan-out tax you'd pay on every unscoped query in production. For hot-partition detection under load, check the "Normalized RU Consumption" metric split by partition key range in Azure Monitor or the portal: a range pinned near 100% while others idle is your smoking gun. (Exact metric names and SDK surface evolve; verify against the current SDK docs.)
The trade-off you can't escape
Partition key selection is a bet on your access patterns, and the bet is locked in: the key is immutable after container creation. Changing strategy means creating a new container and moving data — typically with Change Feed or the container copy feature — which is doable but never free. The other honest limitation: no single key optimizes every query. The standard answer is to optimize the key for your hot path and handle the rest with a second, differently-partitioned container fed by Change Feed, accepting the extra storage and RU cost as the price of fast reads on both patterns.
The actionable version: list your top three queries before you create the container, pick the key that keeps the most frequent one in-partition without threatening the 20 GB cap, and prove it with a measured RU comparison on realistic data. An hour of testing beats a migration project six months from now.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.