When a Single Partition Key Isn't Enough: Hierarchical Partition Keys in Azure Cosmos DB
A single partition key like /tenantId breaks down when one tenant outgrows 20 GB. Hierarchical partition keys in Cosmos DB fix that — if your queries filter top-down. Here's how to choose and verify.
24 May 2026, 03:03 UTC

You picked /tenantId as your Cosmos DB partition key because every query in your multi-tenant app filters by tenant. It works beautifully — until your biggest tenant blows past 20 GB of data and their traffic saturates a single physical partition while everyone else idles. The takeaway: Cosmos DB's hierarchical partition keys let you keep tenant-scoped queries efficient and give large tenants room to grow, but only if your access patterns match the hierarchy you choose.
Why the obvious key stops working
Cosmos DB hashes your partition key value to place each item on a physical partition. Two hard limits follow from that design:
- A logical partition (all items sharing one partition key value) is capped at 20 GB.
- A physical partition has a throughput ceiling, so provisioned RU/s beyond what one partition can serve only helps if your data and traffic actually spread across multiple partitions.
With /tenantId alone, one whale tenant means one logical partition holding all their data. Once it nears 20 GB, writes start failing for that tenant — and no amount of extra provisioned throughput fixes a hot partition.
What hierarchical partition keys actually do
Instead of one key path, you define up to three levels, for example /tenantId, then /deviceId, then optionally a third. Cosmos DB effectively treats the concatenation of the levels as the full key, so each combination of values gets its own logical partition — each still subject to the 20 GB limit, but now scoped to something much smaller than an entire tenant.
The crucial part is how queries map onto the hierarchy:
- A filter on a full prefix — say
tenantId = 't-42'— targets only the partitions holding that tenant's data. Efficient. - A filter on the full key —
tenantIdplusdeviceId— is a single-partition operation. Cheapest possible. - A filter on a deeper level without the levels above it —
deviceId = 'd-9'with no tenant — becomes a cross-partition fan-out. Cosmos DB must query every physical partition, which costs more request units and adds latency.
So the hierarchy is not free distribution; it's a promise that your common queries filter from the top down.
A worked example: device telemetry
Say you're storing telemetry events for a SaaS product. Each event belongs to a tenant and a device. You create the container with a two-level key:
// Azure CLI, run anywhere with the az CLI installed and logged in.
// Requires contributor access on the Cosmos DB account.
az cosmosdb sql container create \
--account-name <your-account> \
--resource-group <your-rg> \
--database-name telemetry \
--name events \
--partition-key-path "/tenantId,/deviceId" \
--throughput 10000
Items look like { "id": "...", "tenantId": "t-42", "deviceId": "d-9", ... }. Now compare the dominant query shapes:
| Query | Routing | Cost profile |
|---|---|---|
| Point read by id + tenantId + deviceId | Single partition | Lowest, fixed RU |
| All events for tenant t-42 | Subset of partitions (tenant's devices) | Moderate, scales with tenant size |
| Events for device d-9 across all tenants | Cross-partition fan-out | Highest; grows with total data |
The large tenant's data now spreads across as many logical partitions as they have devices, so the 20 GB ceiling applies per device instead of per tenant — and their write load spreads across physical partitions too.
Verify before you commit
You cannot change a partition key on an existing container. Migrating means creating a new container and copying data (via the change feed, container copy jobs, or your own pipeline), which is exactly the kind of project you want to avoid doing under pressure. So validate early:
- Create a test container and load synthetic data with realistic skew — one tenant 100x larger than the rest.
- Check per-partition storage in the Azure portal's metrics to confirm no logical partition approaches 20 GB.
- Run your real queries with query metrics enabled (populate
QueryMetricsin the SDK, or use the portal's query stats) and compare RU charges for key-filtered versus cross-partition queries. Don't trust rules of thumb — RU cost depends on data size and indexing policy.
Also confirm the current documented limits (number of hierarchy levels, SDK support) before designing around them; feature details can change.
The trade-off to accept up front
Hierarchical keys optimize for one query shape: top-down. If a meaningful share of your traffic filters on a deeper level alone — "find this device regardless of tenant" is common for support tooling — those queries fan out and get expensive as you grow. Options include maintaining a small lookup container keyed by deviceId that maps to tenantId (a deliberate denormalization), or accepting the fan-out cost because the query is rare. What doesn't work is hoping it stays cheap.
The actionable version: list your five most frequent queries, check that each filters on a prefix of your proposed hierarchy, then prove it with skewed test data and measured RU charges. An afternoon of benchmarking beats a migration project later.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.