Domain 2 of 4 · Chapter 2 of 12

Optimize Cosmos DB RUs with indexing policies and consistency levels

What a request unit actually buys

Two teams store the same documents in Azure Cosmos DB and run the same queries. One team's write path costs several times more than the other's. Nothing in their application code differs; the difference is entirely in how the container was configured. That gap is what this page is about.

The previous page in this domain, querying Cosmos DB for NoSQL from the Python SDK, covers how you connect, address an item, and page through a result set. This page picks up where that leaves off. A read_item call and a query_items call, which you already know how to write, are two differently priced ways to reach the same document; the work here is reading those prices off a response and then moving them, so that a container you inherit can be made cheaper, or stopped from throttling, without a line of application code changing.

The currency

Azure Cosmos DB normalizes the cost of all database operations using Request Units[1] (RUs) and measures throughput in request units per second (RU/s). A request unit is a performance currency that abstracts the processing (CPU), input/output operations per second (IOPS), and memory needed to perform an operation. Reads, writes, and queries are all priced in the same unit, across every API the service offers.

The anchor value in the documentation is a point read: reading a single item by its ID and partition key uses one request unit[1], for an item of about 1 KB. Treat that as a reference point rather than a constant to memorize. Microsoft's own table shows the same point read costing about 10 RUs once the item grows to 100 KB, and the factors that move a charge[1] are item size, whether items are indexed, item property count, the number of indexed properties, the consistency level, the type of read, query patterns, and script usage.

Read the charge, do not estimate it

Azure Cosmos DB makes the number of RUs for a given operation over a given dataset deterministic, and returns the actual figure in the x-ms-request-charge response header. Microsoft calls that value the request charge; this page also says RU charge and RU cost for the same number, and they are one quantity under three names, not three quantities. In the Python SDK the header is reachable through last_response_headers on the client connection.

Listing 1: reading the request charge of one write in Python

# The charge is a response header, so read it AFTER the operation returns.
new_item = {"id": "order-1041", "customerId": "c-77", "total": 24.50}
container.create_item(new_item)

# last_response_headers holds the headers of the LAST operation on this client.
charge = container.client_connection.last_response_headers["x-ms-request-charge"]
print("write cost", charge, "RU")
# ...  repeat around a representative query to compare shapes

The Python SDK exposes a last_response_headers dictionary that maps all the headers returned by the underlying HTTP API for the last operation executed[2], and the request charge sits under the x-ms-request-charge key. Because the same query on the same data always costs the same number of RUs on repeated executions[1], a single measurement is a stable baseline you can tune against.

The three dials

Everything else here is one of three moves. Calling them dials is an analogy, not Azure terminology, but the split is real: the figure below groups every lever under one of the three.

The first dial is cost per operation: the indexing policy, composite indexes, the consistency level a read asks for, and whether a repeated read can be served from cache. The second is capacity provisioned: manual RU/s, autoscale, or serverless. The third is spread across partitions, because provisioned throughput is divided across a container's physical partitions (the internal storage and throughput units Azure Cosmos DB creates and manages for you) and a badly chosen partition key leaves most of that capacity unreachable; the repairs for that, meaning partition key design, synthetic keys, and redistributing throughput across partitions, are the last section's subject. When a workload is over budget or throttling, the useful question is always which of the three is actually wrong.

Your RU/s billcost per second, per container1. Cost per operationmake each request cheaper2. Capacity provisionedbuy the right amount3. Spread across partitionsmake capacity reachableIndexing policyComposite indexesConsistency levelIntegrated cacheManual RU/sAutoscale maximumServerlessPartition key designSynthetic or hierarchical keysThroughput redistribution
The three dials this page turns, with the concrete lever that sits under each one.

Provisioning capacity: manual, autoscale, or serverless

Before choosing a number, choose a mode. Azure Cosmos DB bills consumed request units in one of three account modes[1]: provisioned throughput, which you set manually; autoscale, which moves the provisioned value for you; and serverless, where nothing is provisioned at all. The comparison table in the free overview above lays the three side by side; this section explains the mechanism behind each row.

Manual (standard) provisioned throughput

In provisioned throughput mode you assign a number of RUs per second, and Azure Cosmos DB reserves that capacity. You can assign it at two distinct granularities[1]: on a container, or on a database whose containers then share the allocation. Shared database throughput is convenient but risky, and Microsoft is direct about it: when containers share a scaling range, one container's burst can consume capacity that other containers need, resulting in throttling and less consistent response times[3]. Container-level throughput is the recommended default.

One fact that surprises people building multi-region accounts: if you assign R RUs to a container and the account spans N regions, Azure Cosmos DB ensures that R RUs are available in each region[1]. You cannot selectively assign RU/s to one region, so the total capacity globally is R times N.

Autoscale

With autoscale you specify a maximum throughput, written Tmax in the documentation, and Azure Cosmos DB scales the throughput T so that 0.1 x Tmax is less than or equal to T, which is less than or equal to Tmax[3]. A maximum of 20,000 RU/s therefore floats between 2,000 and 20,000 RU/s. Scaling is automatic and instantaneous, so the full maximum is available without a warm-up delay, and you are billed each hour for the highest value the system scaled to during that hour.

That billing rule gives a clean selection heuristic: if you use the full Tmax for 66 percent or fewer hours in a month, autoscale can save costs[3]. A workload pinned at its ceiling around the clock is cheaper on manual throughput.

Autoscale does not make throttling impossible, and this is a favourite exam trap. Microsoft answers the question directly: it is still possible to see 429 responses, the rate-limiting reply this page covers in full further down, with autoscale, either because consumed RU/s exceeds the maximum, or because a hot partition pushes one physical partition past its own share[4] of the ceiling. A related surprise involves normalized RU consumption, the per-partition utilization figure the hot-partition section below reads to spot skew: it can sit at 100 percent without the system scaling to Tmax, because autoscale only scales to the maximum when consumption is at 100 percent for a sustained period, so single momentary spikes deliberately do not trigger a scale-up.

Serverless

A serverless account removes provisioning entirely: you are charged only for the request units your database operations consume and for the storage your data consumes[5]. It fits scenarios with intermittent and unpredictable traffic and long idle times, including prototypes and Azure Functions back ends.

Microsoft documents several constraints; these are the ones that shape most designs. A serverless account can run only in a single Azure region, and regions cannot be added after the account is created[5]. You cannot pass throughput when creating a serverless container, cannot read or update throughput on one, and cannot create a shared throughput database in a serverless account; each of those returns an error. The mode is also chosen at account creation, so it is a design decision, not a runtime toggle.

The takeaway for all three: manual buys predictability, autoscale buys elasticity within a ceiling you still have to pick, and serverless buys the removal of capacity planning at the cost of a single region and less predictable spend.

The indexing policy is the write-cost dial

The RU cost of writing an item depends on two things: the item size, and the number of properties covered by the indexing policy and needing to be indexed[6]. That single sentence is the whole lever. Everything below is how to act on it.

By default Azure Cosmos DB automatically indexes every property for all items in your container without having to define any schema or configure secondary indexes[7], and the default policy for newly created containers enforces range indexes for any string or number[8]. A range index is the ordered index Azure Cosmos DB keeps over one property's values; the sorting section below shows why a single-property ORDER BY cannot run without one. That default is why queries feel free on day one and why write charges creep up as documents grow wider.

From an item to a set of paths

Every time an item is stored, its content is projected as a JSON document, then converted into a tree representation[7] where every property becomes a node and leaf nodes hold the scalar values. Concatenating the labels from the root to a property gives that property's path, and Azure Cosmos DB indexes each property's path and its value when an item is written. The figure below traces that pipeline from the item you write to the set of paths the policy actually indexes.

Indexing paths use a small notation with three pieces you need to recognize on sight: a path leading to a scalar value ends with /?, elements from an array are addressed together through /[], and the /* wildcard matches any elements below the node[8]. So /category/? indexes one scalar, /metadata/* indexes everything under metadata, and /* matches the whole item.

Include, exclude, and who wins

Any indexing policy has to include the root path /* as either an included or an excluded path. Microsoft recommends the opt-out approach[8]: include the root and selectively exclude the paths that do not need indexing, because that lets the service proactively index new properties you add to the model later. The opt-in shape (exclude /*, include specific paths) is available and has one gotcha, since the partition key path is not indexed by default under that strategy and must be included explicitly.

When the two lists disagree, the more precise path takes precedence[8]. The rules are stated as: deeper paths are more precise than narrower ones, so /a/b/? beats /a/?; and /? is more precise than /*, so /a/? beats /a/*. That is what makes the common pattern safe, where you include /* and exclude one bulky subtree.

Listing 2: an opt-out policy that keeps writes cheap in Python

indexing_policy = {
    "indexingMode": "consistent",
    # Index everything by default so new properties are covered automatically.
    "includedPaths": [{"path": "/*"}],
    # Exclude only what no query filters or sorts on: bulky, unqueried data.
    "excludedPaths": [
        {"path": "/rawText/*"},
        {"path": "/embedding/*"},
    ],
    # ...  compositeIndexes, vectorIndexes and spatialIndexes go here too
}

database.create_container(
    id="documents",
    partition_key=PartitionKey(path="/tenantId"),
    indexing_policy=indexing_policy,
)

The partition key path in that listing, /tenantId, is a convention picked for the example and not a requirement; any path with the right distribution works, and the last section covers how to choose one. The policy shape itself is the one Microsoft demonstrates for Python, where the use of indexing paths can offer improved write performance and lower index storage, as indexing costs are directly correlated to the number of unique paths indexed[9]. The same page's advice about large properties is blunt: do not store binary content or large chunks of text that you do not need to query on[6]; put them in Azure Blob Storage and keep a reference. Where the data must live in the item, as an embedding array does, excluding its path from the standard index is the next best thing. Microsoft's own sample vector index policy does exactly that, including /*, excluding /vector1/*, and declaring a vectorIndexes entry on /vector1[10], so the specialized search path survives while the standard index stops paying for the array. How that vector index is chosen and queried belongs to vector search in Cosmos DB; only its write-cost consequence is in scope here.

Indexing mode, and one hard dependency

Azure Cosmos DB supports two indexing modes[8]. consistent, the default, updates the index synchronously as you create, update, or delete items, which means the consistency of your read queries is the consistency configured on the account. That name collides with the account consistency levels covered later on this page, and they are two different settings: indexingMode decides whether an index exists and is kept in step with writes, while a consistency level decides how fresh a replica a read is served from. none disables indexing on the container, which suits a pure key-value store served only by point reads, and is also used to speed up bulk loads before switching back. A third mode, lazy, still exists but new containers cannot select lazy indexing[8], and it can produce inconsistent or incomplete query results, so treat it as unavailable.

Switching to none has a consequence people miss: it is not possible to activate time to live on a container where the indexing mode is set to none[8], and not possible to set the mode to none where TTL is already on. If you want automatic expiry with no property indexes, the documented recipe is consistent mode with no included paths and /* as the only excluded path.

Finally, changing the policy is not free or instant. Index transformation is an operation that consumes request units[8]; it runs online at lower priority than your own operations, and removal takes effect immediately while an addition needs the transformation to finish. When you replace one index with another, add the new one and wait for the transformation before removing the old one, or active queries lose the index they relied on.

The through-line for this dial: the default policy buys query flexibility you may not need with write RU you certainly pay, so every path you exclude is a charge you stop paying on every single write.

One write, four stagesItem writtenid, title, tags,rawText, embeddingProjected to a treeevery propertybecomes a nodeNodes give paths/title/? /tags/[]/?/rawText/*Policy filters pathsincluded minusexcludedWrite RU chargeitem size plus the number of paths that survived the filterMost precise rule wins: /a/b/? beats /a/?, and /a/? beats /a/*Excluding /rawText/* removes those paths from the charge without touching other queries
Microsoft's items-to-trees-to-property-paths model, followed through to the write charge it produces.

Composite indexes and the cost of sorting

Sorting is where a query silently stops working rather than merely getting expensive, so start with the rule. An ORDER BY clause that orders by a single property always needs a range index and fails if the path it references does not have one; similarly, an ORDER BY query that orders by multiple properties always needs a composite index[7]. The default policy already supplies range indexes, but by default no composite indexes are defined[8], so the multi-property case is one you must plan for.

A composite index is defined as an ordered list of two or more property paths, each with an optional sort order. Composite paths have their own notation rules: they carry an implicit /? because only the scalar value at that path is indexed, the /* wildcard is not supported in composite paths, and composite paths are case sensitive[11].

Listing 3: a composite index on (name ascending, age descending)

{
  "indexingMode": "consistent",
  "includedPaths": [ { "path": "/*" } ],
  "excludedPaths": [],
  "compositeIndexes": [
    [
      { "path": "/name", "order": "ascending" },
      { "path": "/age",  "order": "descending" }
    ]
  ]
}

Each inner array in compositeIndexes is one composite index, so a policy can hold several. The order key is optional and defaults to ascending when it is not specified[11]. JSON has no comment syntax, so an indexing policy you paste into the portal must carry no commentary.

Matching an index to an ORDER BY

Three conditions decide whether a composite index serves a sort. If the composite index paths do not match the sequence of the properties in the ORDER BY clause, the index cannot support the query; the order of the paths, ascending or descending, must also match; and the composite index also supports an ORDER BY clause with the opposite order on all paths[8]. That third clause is the one worth remembering, because it means one index covers a sort and its exact mirror image, but nothing in between.

Composite index ORDER BY in the query Served?
(name ASC, age ASC) c.name ASC, c.age ASC Yes
(name ASC, age ASC) c.name DESC, c.age DESC Yes, the exact reverse
(name ASC, age ASC) c.name ASC, c.age DESC No, directions must agree
(name ASC, age ASC) c.age ASC, c.name ASC No, sequence must agree
(name ASC, age ASC, timestamp ASC) c.name ASC, c.age ASC No, the query must use every path

The table reproduces Microsoft's own ORDER BY support matrix[8]; the practical reading is that a composite index is not a general-purpose sort accelerator but a match for one specific clause and its reverse.

Composite indexes as a pure cost saving

Beyond sorting, composite indexes are optional and purely an economy measure. A query with an equality filter and a range filter, such as WHERE c.name = "John" AND c.age > 18, is more efficient, taking less time and consuming fewer RUs, if it is able to apply a composite index on (name ASC, age ASC)[8]. Three rules govern that case, all from the same page: properties with equality filters must be defined first in the composite index; a property with a range filter (>, <, <=, >=, !=) should be defined last; and each individual composite index can only optimize a single range filter, so a query with two range filters needs two composite indexes rather than one longer one.

A query that filters on one property and sorts on another can often be rewritten to use a composite index by adding the filter properties to the front of the ORDER BY clause. Microsoft's example turns WHERE c.name = "John" ORDER BY c.timestamp into WHERE c.name = "John" ORDER BY c.name, c.timestamp, which a composite index on (name, timestamp) can then serve. The rewrite does not change the result set, only the plan and the charge.

One operational note ties back to the previous section: adding a composite index triggers an index transformation, and until it completes the query utilizes existing range indexes[8], so measure the improvement after the transformation finishes, not the moment you save the policy.

The pattern across all three uses: a composite index is a promise about one exact query shape, so define it from the query you actually run rather than from the properties you happen to have.

Consistency sets the price of every read

Consistency looks like a correctness setting, and it is, but on this page it matters because it is also a price list. Azure Cosmos DB offers five well-defined levels, from strongest to weakest: strong, bounded staleness, session, consistent prefix, and eventual[12].

Why two of them cost double

The pricing follows directly from how many replicas a read touches. For strong and bounded staleness, reads are done against two replicas in a four-replica set to ensure consistency guarantees, while session, consistent prefix, and eventual consistency use single-replica reads; as a result, for the same number of request units, read throughput for strong and bounded staleness is half that of the other consistency levels[12]. Microsoft states the same fact from the cost side on the request-units page: the strong and bounded staleness consistency levels consume approximately two times more RUs while performing read operations[1] than the relaxed levels.

Writes are different, and the symmetry people expect is not there. For a given type of write operation, the write throughput for request units is identical across all consistency levels[12]. Strong changes where a write must commit, not what it costs in RUs.

Strong carries two more constraints worth internalizing. Its write latency on a multi-region account is roughly two round trips between the two farthest regions, because the operation completes only after committing to every region. And accounts with multiple write regions cannot use strong consistency[12] at all, because a distributed system cannot offer a recovery point objective of zero and a recovery time objective of zero at the same time.

Session, the default, and its token

Session is the level accounts get by default and the one most applications keep. Within a single client session, reads are guaranteed to honor the read-your-writes and write-follows-reads guarantees[12], at single-replica read cost. Two of those guarantee names are worth unpacking, because the cheat sheet uses them: read-your-writes means the session always sees its own completed writes, and monotonic reads means a read in that session never returns a value older than one the session has already read. The machinery is a session token: after every write operation, the client receives an updated session token from the server, caches it, and sends it with later reads in that region so the replica serving the read is at least as fresh as that token.

The token is produced by the operation response, not configured on the client, and that is exactly why it becomes your problem in a multi-node service. Consider a web application with several nodes, each with its own client instance. To let those nodes participate in one session, you have to send the session token from the write response to the end user through a cookie or some other mechanism and have that token flow back[13] to the tier that reads. A round-robin load balancer with no session affinity will otherwise land the read on a node that never saw the write. The figure below traces that hand-off.

Listing 4: capturing and replaying a session token in Python

# A write is what advances the token; any response then carries the current one
# in its headers, which is what Microsoft's own sample reads here.
item = client.ReadItem(item_link)
session_token = client.last_response_headers["x-ms-session-token"]

# Resume that session on another process by passing the token in the options.
options = {"sessionToken": session_token}
item = client.ReadItem(doc_link, options)

Two properties of the token change how you use it. Session tokens are partition-bound, meaning they are exclusively associated with one partition[12], so you use the token last generated for the items you care about. And a client with no cached token for a physical partition, which includes a freshly restarted process, behaves as reads with eventual consistency[12] against that partition until its own writes rebuild the cache. If you do not need to manage tokens by hand, the SDK tracks them for you.

Relaxing a single request

The account default applies to every request, and a client can override it per request. The direction of that override is bounded: with the classic ConsistencyLevel override, consistency can only be relaxed at the SDK instance or request level, and to move from weaker to stronger consistency using this approach you update the default consistency for the account[13]. Reading a dashboard tile at eventual consistency on a strong account is therefore fine and cheaper; asking one read to be stronger than the account is not available through that option. Microsoft has since added a separate read-consistency strategy feature that can strengthen a single read, but it is in preview, limited to direct mode, and shipped for the Java and .NET SDKs, so treat relax-only as the rule for Python work.

One more consequence, which the next-but-one section depends on: overriding consistency applies only to reads within the SDK client[12]. An account configured for strong still writes and replicates synchronously to every region even when a client reads at session level.

The takeaway for this dial: consistency is a read-side price paid per read, and session is the level that gives most applications the guarantee they actually need at the cheaper single-replica rate.

Node A, which writesNode B, which readscreate_item()the write commitsResponse headerx-ms-session-tokenyou carry itToken arrivescookie, header, or messageRead with the token setread-your-writes holdsif skippedNo token flowsnode B reads behave as eventual until it writes
The session token hand-off between two nodes, and what session consistency degrades to when it is skipped.

Multi-region writes: which version wins

A conflict resolution policy decides which version of an item survives a concurrent write in two regions; it does not decide which region a client talks to. That one distinction is why this consistency-adjacent setting gets its own space here, and why exam items like to swap it for the routing setting. Conflicts and conflict resolution policies are applicable if your Azure Cosmos DB account is configured with multiple write regions[14]. On a single-write-region account the question never arises.

With several writable regions, update conflicts occur when writers concurrently update the same item in more than one region, and Microsoft categorizes them as insert conflicts (two items created with the same unique index in different regions), replace conflicts (the same item updated simultaneously), and delete conflicts (deleted in one region, updated in another).

You choose from two conflict resolution policies on a container[14].

Last Write Wins (LWW) is the default. It uses a system-defined timestamp property, and the documentation names it: LWW is the default policy and uses the timestamp _ts for the NoSQL, MongoDB, Cassandra, Gremlin, and Table APIs. On the API for NoSQL you can nominate any other custom numerical property instead, which is then called the conflict resolution path. When items conflict on insert or replace, the item with the highest value for that path wins, all regions converge on it, and a delete always beats a competing insert or replace regardless of the path value.

Custom resolution hands the decision to your code. Setting it requires registering a merge stored procedure, which the system invokes automatically when conflicts are detected, with an exactly-once execution guarantee as part of the commitment protocol. If you configure custom resolution and either fail to register the procedure or the procedure throws, the conflicts are written to the conflicts feed for your application to resolve manually. Two constraints matter for design: custom resolution is available only on API for NoSQL accounts, and it can be set only at creation time[14], so it cannot be added to an existing container.

The routing setting named in this section's opening rule is the client's preferred-regions list, configured as preferred_locations in the Python SDK, which an application uses to ensure that requests go to a collocated region[9]. So the takeaway is a pair: the conflict resolution policy picks the winning version after the fact, the preferred-regions list picks where requests go in the first place, and neither substitutes for the other.

Making a repeat read cost nothing

If a workload reads the same items over and over, the cheapest optimization is not to make the read cheaper but to stop paying for it. That is what the integrated cache does: point reads and queries that hit the integrated cache have a Request Units charge of zero[15].

The cache is an in-memory cache that uses the dedicated gateway within your Azure Cosmos DB account[15], a set of nodes you provision by count and size. It has two halves: an item cache for point reads, and a query cache that turns a query into a key-value lookup keyed by the query text. Both share one capacity and one least-recently-used (LRU) eviction policy.

Be clear about what it is for. Microsoft states the goal plainly: the main goal of the integrated cache is to reduce costs for read-heavy workloads, and low latency, while helpful, is not the main benefit because Azure Cosmos DB is already fast without caching[15]. It is a bill lever, not a performance rescue.

The three conditions

A cache hit needs all three of these, and forgetting any one produces silent full-price reads rather than an error. The figure below follows one read through the three gates and shows where a miss sends it.

  1. Requests go through the dedicated gateway connection string, in gateway connection mode. Two similar names sit in that sentence and they are not the same thing: the dedicated gateway is a resource you provision on the account, while gateway mode is one of the SDK's two connection modes, and a cache hit needs both. If the app still uses the original connection string or direct mode, the DedicatedGatewayRequests metric stays at zero and nothing is cached.
  2. The read uses session or eventual consistency. The integrated cache supports read requests with session and eventual consistency only; if a read has consistent prefix, bounded staleness, or strong consistency, it bypasses the integrated cache and is served from the backend[15]. Writes with other consistency levels still populate the cache; it is the read side that is restricted.
  3. The cached entry is fresher than the request's staleness window. MaxIntegratedCacheStaleness is the maximum acceptable staleness for a cached read, and it is configured per request.

The staleness setting behaves differently from a normal cache time-to-live, which is where most misreadings start. It enforces consistency when you try to read cached data and does not affect how long a request is cached; there is no global TTL or cache retention setting[15], so an entry leaves the cache only when the cache is full or a new read arrives with a staleness window shorter than the entry's age. Left unconfigured, MaxIntegratedCacheStaleness defaults to 5 minutes. Microsoft's worked timeline makes the asymmetry concrete:

Time Request Result
0 s Query A, staleness 30 s Backend, normal RU charge, cache populated
0 s Query B, staleness 60 s Backend, normal RU charge, cache populated
20 s Query A, staleness 30 s Cache hit, 0 RU
20 s Query B, staleness 60 s Cache hit, 0 RU
40 s Query A, staleness 30 s Backend, normal RU charge, cache refreshed
40 s Query B, staleness 60 s Cache hit, 0 RU
50 s Query B, staleness 20 s Backend, normal RU charge, cache refreshed

At 40 s the two queries diverge on identical entry age: A's entry is past its 30-second window and returns to the backend, while B's is still inside its 60-second window and costs nothing. The last row is the sharp one — B has a warm entry and still pays, because that request asks for a 20-second window against a 50-second-old entry. The staleness number rides on the request, not on the entry.

What it does not cover

Each dedicated gateway node holds an independent cache, so if data is cached within one node, it is not necessarily cached in the others[15], and multiple pages of one query are not guaranteed to route to the same node. Session-consistency reads that arrive without a matching session token incur RU charges[15], which includes the first request after an application restarts.

Microsoft also names the workloads that should not bother: write-heavy workloads, rarely repeated point reads or queries, and workloads reading the change feed. That last one matters here because the change feed has its own page, the change feed processor; a cache in front of it saves nothing. When you do run the cache, IntegratedCacheItemHitRate and IntegratedCacheQueryHitRate are the metrics that tell you whether it is earning its keep, and IntegratedCacheEvictedEntriesSize tells you whether the gateway is too small.

The takeaway: this is the only lever on the page that takes a read's charge to zero, and its conditions are strict enough that the real question is whether your reads repeat often enough to reach it.

Through the dedicated gatewayin gateway mode?noyesRead at session oreventual consistency?noyesEntry fresher than therequest's staleness window?noyesCache hitcharged zero request unitsServed fromthe backendnormal RU charge
The three gates a read passes before the integrated cache can serve it at zero request units.

Letting Cosmos DB expire data for you

Deletes cost request units like any other write, so a container that accumulates telemetry, sessions, or cached responses can spend a real share of its budget removing data. Time to live (TTL) moves that work to the service. With TTL, Azure Cosmos DB deletes items automatically from a container after a certain time period[16], measured in seconds from the time an item was last modified, with no delete operation issued by your application.

The configuration has exactly two knobs, a container-level default and a per-item override, and the interaction between them is the part exams probe.

On the container, DefaultTimeToLive takes three meaningful states: if missing or set to null, items are not expired automatically; if present and set to -1, it is equal to infinity and items do not expire by default; and if present and set to some nonzero number n, items expire n seconds after their last modified time[16]. On an item, the ttl property overrides the container default, but only applies if DefaultTimeToLive is present and not null on the parent container.

That produces one rule and one trap. The rule: the container setting is the master switch, and if TTL is not set on a container, the time to live on an item in this container has no effect[16]. The trap: -1 does not mean off. It means TTL is enabled with no default expiry, which is precisely the setting you want when only some items should expire and each carries its own ttl.

DefaultTimeToLive on container Item with no ttl Item with ttl = 2000
null or missing never expires, TTL disabled never expires, TTL disabled
-1 never expires, TTL enabled expires after 2,000 seconds
1000 expires after 1,000 seconds expires after 2,000 seconds

What expiry costs

Expired items are deleted as a background task, and the visible behaviour runs ahead of the physical delete: an item no longer appears in query responses immediately after the TTL expires, even if it is not yet permanently deleted from the container[16]. Your queries therefore behave as though the item is gone the moment it expires.

The charge depends on the throughput mode, which is easy to state wrongly. On a provisioned throughput account, the deletion of expired items uses leftover RUs that have not been consumed by user requests[16], which is what makes TTL close to free there. On a serverless account, the deletion of expired items is charged in RUs at the same rate as delete item operations[16]. And on either, if the container does not have enough RUs to perform the deletion, the deletion is delayed until sufficient RUs are available; the item stays invisible to queries but keeps occupying storage in the meantime.

One dependency carries over from the indexing section: TTL needs indexing, so a container with indexingMode set to none cannot have TTL activated. The documented workaround, if you want expiry and no property indexes at all, is consistent mode with an empty includedPaths list and /* as the only excluded path.

The takeaway: TTL converts a recurring write cost into a background one you mostly do not pay for, which makes it the cheapest way to bound a container that would otherwise grow forever.

Rate limiting: what a 429 actually tells you

A 429 response is not a failure of the database; it is the database telling you the second's budget is spent. In a given second, if the operations consume more than the provisioned request units, Azure Cosmos DB returns a 429 exception, and each second the number of request units available to use is reset[4].

The response carries the remedy with it. The server preemptively ends the request with RequestRateTooLarge (HTTP status code 429) and returns the x-ms-retry-after-ms header indicating the amount of time, in milliseconds, that the user must wait before reattempting the request[9].

The retry contract the SDK already implements

You usually do not write that wait loop, because the SDKs all implicitly catch this response, respect the server-specified retry-after header, and retry the request[9]. The figure below traces the loop and the two ways out of it.

The budget is bounded in two independent ways, and both need to be in your head. In the Python SDK, the default retry count is currently set to 9 internally by the client, and can be changed by passing retry_total configuration to the client[9]. Separately, by default the CosmosHttpResponseError with status code 429 is returned after a cumulative wait time of 30 seconds if the request continues to operate above the request rate, and this occurs even when the current retry count is less than the max retry count[9]. Raising retry_total therefore does not guarantee more attempts; the 30-second cumulative wait can end the sequence first.

The consequence people trip over is diagnostic rather than functional: because retries succeed silently, Azure Monitor can show 429 responses your application never saw. That is expected. For a production workload, if you see between 1 and 5 percent of requests with 429 responses, and your end-to-end latency is acceptable, this is a healthy sign that the RU/s are being fully utilized[4]. One caveat rides with that range: it assumes your partitions are evenly distributed, and a skewed account can hide a badly throttled partition inside a low overall rate.

Not every 429 means the same thing

The documentation separates several 429 shapes, and increasing RU/s is the wrong response to most of them.

  • Request rate is large. The ordinary case: data operations exceeded the provisioned RU/s, or the autoscale maximum. This is the one where more capacity or a better partition key helps.
  • Metadata rate limiting. Triggered by a high volume of container and database operations such as creating, reading, or listing containers. There is a system-reserved RU limit for these, so increasing the provisioned RU/s of the database or container has no effect and is not recommended[4]. The fixes are a singleton client and caching database and container names rather than re-reading them.
  • Transient service error. Retry; if it persists for several minutes, open a support ticket. Again, more RU/s has no effect.
  • TXN_WAIT_FOR_TRANSACTION_END. Multiple clients attempting concurrent transactions on the same logical partition key, meaning the set of items that share one partition key value, which the next section takes apart. Only one transactional operation at a time can run for a given logical partition key.

One accounting detail that catches people auditing a spend: charges apply to requests that did not succeed. Microsoft flags that these charges include requests that do not complete successfully due to application errors such as 400, 412, and 449[4], so an optimistic concurrency retry storm on 412 responses is quietly consuming throughput.

The takeaway: a 429 is information, not damage. Work out which flavour you have before reaching for the throughput slider, because three of the four above do not respond to more RU/s at all.

Consumption exceeds RU/sHTTP 429x-ms-retry-after-msSDK waits that intervalthen retriesa retry succeedsretry budget spentRequest completesCosmosHttpResponseErrorstatus 429Budget ends at whichever comes first:retry_total attempts (9 by default)or 30 s of cumulative wait
The SDK's 429 retry loop and its two exits, one on success and one when the retry budget runs out.

Hot partitions: when more RU/s cannot help

The third dial is the one that makes capacity you already bought unreachable. Provisioned throughput for a container divides evenly among physical partitions[17], and a partition key design that does not distribute requests evenly might result in too many requests directed to a small subset of partitions that become hot, causing inefficient use of provisioned throughput, which can result in rate limiting and higher costs[17].

The mechanics are worth stating precisely, because the loose version of this rule leads people to the wrong fix. Items are grouped into logical partitions by their partition key value. One or more logical partitions map to a single physical partition, and a physical partition is the unit that owns a slice of the container's RU/s. So logical partitions that share a physical partition also share its slice; they do not each get their own. Microsoft's worked example makes the arithmetic explicit: with 18,000 RU/s and three physical partitions, each uses one third of the total, and the logical partition keys inside one physical partition can collectively utilize the physical partition's 6,000 provisioned RU/s[17]. The figure below draws that split and the throttle it produces.

A hot partition arises when one or a few logical partition keys consume a disproportionate amount of the total RU/s due to higher request volume[4]. Because the other partitions' slices sit idle, the account-level metrics look calm while one physical partition returns 429s.

Diagnosing it

Microsoft's procedure has two steps and neither requires guessing. First, look at Normalized RU Consumption broken out by PartitionKeyRangeId, where each range ID maps to one physical partition; one range consistently near 100 percent while others sit at 30 percent or less is the signature. Second, if you need the offending key itself, Azure Diagnostic Logs[4] can sum request charge per logical partition key per second, which surfaces the individual tenant or device that is dominating. Diagnostic logs bill separately for ingested data, so turn them on for the investigation and off afterwards.

Designing the key

A good partition key satisfies two conditions at once, and skipping either is a documented anti-pattern. It should have a high cardinality, in other words a wide range of possible values, and spread request unit consumption and data storage evenly across all logical partitions[17]. Microsoft names several anti-patterns; these four cover most real failures:

  • Low-cardinality fields. Keys like status, type, or country create only as many logical partitions as there are distinct values, which often leads to uneven RU and storage distribution, and can create hot partitions under load[17].
  • A monotonically advancing value. An IoT container partitioned by date puts every write for a day on one logical partition, so it produces a fresh hot partition every day. A higher-cardinality id, or a synthetic key combining id and date, fixes it.
  • A dominant tenant. Partitioning by tenantId is fine until one tenant is far more active than the rest; the documented remedy is a dedicated container for the largest tenant, partitioned by something more granular such as UserId.
  • High cardinality with no query alignment. A random GUID distributes writes perfectly and leaves most reads as cross-partition queries. Cardinality alone is not the goal.

That last point is where this page meets its sibling: how a partition key value scopes a query, and what a cross-partition query costs you in fan-out, belongs to querying Cosmos DB from the SDK. Here the concern is throughput distribution, and the two pull in the same direction.

Fixing it

The long-term fix is a different partition key, and it is expensive because the partition key cannot be updated in place, so it requires migrating the data to a new container[4]. Two short-term measures exist. You can temporarily raise the container's RU/s so the hot partition's share grows, which Microsoft explicitly does not recommend as a long-term strategy because it overprovisions everything else. Or you can redistribute throughput across partitions, a preview feature recommended only when the hot physical partition is predictable and consistent.

The rule to carry into an exam question: if throttling appears while normalized RU consumption is uneven across partition key ranges, the answer involves the partition key, not the throughput setting.

Container throughput18,000 RU/s provisionedPhysical partition 16,000 RU/sPhysical partition 26,000 RU/sPhysical partition 36,000 RU/stenant-anntenant-botenant-cytenant-ditenant-edmost traffictenant-fiLogical partitions inside one physical partition share its slice; they do not each get their own.Physical partition 3 throttles at 6,000 RU/s429 responses here while partitions 1 and 2 sit idle, so account utilization still looks low
Microsoft's 18,000 RU/s example: throughput divides evenly by physical partition, so skew wastes most of it.

Exam-pattern recognition

Questions in this area rarely ask for a number. They describe a symptom and expect you to name the dial. These are the recurring shapes.

"Writes have become expensive as documents grew." The answer is the indexing policy: exclude the paths nothing queries. Distractors usually offer more RU/s (buys the same problem at a higher price), a composite index (helps queries, not writes), or a weaker consistency level (halves read cost, does nothing for writes). Remember the asymmetry: consistency prices reads, indexing prices writes.

"ORDER BY c.lastName, c.firstName stopped working." A composite index is required for an ORDER BY over two or more properties, and none exists by default. If the stem sorts by one property, the right answer is a range index instead. If the stem's index is (name ASC, age ASC) and the query sorts name ASC, age DESC, the correct answer is that the index does not serve it; only the exact reverse of all directions is also covered.

"The same reads repeat and the bill is dominated by them." The integrated cache, with the dedicated gateway, gateway connection mode, and session or eventual consistency. If the stem mentions strong or bounded staleness, the cache is bypassed, and the correct answer is either to relax the read or to accept backend cost. If the stem is write-heavy, the cache is the wrong tool.

"429s appear although normalized RU consumption is well under the maximum." A hot partition. Look for a partition key like a date, a status flag, or a single dominant tenant. Raising RU/s and raising the retry count are the classic wrong answers; both are mitigations that leave the skew in place.

"Reads must never be stale, and the account has multiple write regions." Strong consistency is not available on a multiple-write-region account, so an answer offering it is wrong on availability grounds before cost even enters. Where strong is available, expect the doubled read RU cost to be the point of the question.

"Old telemetry should disappear without a cleanup job." Time to live. Watch for the -1 case: it enables TTL with no default expiry, which is not the same as disabling it. And a container with indexingMode set to none cannot have TTL at all.

"Two regions wrote the same item at the same time." A conflict resolution policy, Last Write Wins by default on _ts or a custom numeric path, or Custom with a registered merge stored procedure. If the option talks about sending requests to the nearest region, that is the preferred-regions list, not conflict resolution.

A final habit worth carrying in: when a question hands you an RU figure, check whether it is doing any work. Most well-formed items here turn on a mechanism (which replica count a level reads from, which paths an index covers, how throughput divides across partitions) rather than on arithmetic you were supposed to memorize.

The capacity dial: three throughput modes

AspectManual (standard)AutoscaleServerless
What you configureA fixed RU/s value on a database or containerA maximum RU/s (Tmax) on a database or containerNothing; throughput cannot be set on a serverless container
Scaling behaviourStays where you set it until you change itScales automatically and instantly between 0.1 x Tmax and TmaxNo provisioning step; capacity follows the container's physical partitions
Billing basisThe RU/s you provisioned, per hourThe highest RU/s the system scaled to in that hourThe request units your operations consumed
When 429s appearConsumption exceeds the provisioned RU/s within a secondConsumption exceeds Tmax, or one physical partition exceeds its shareConsumption exceeds what the container's partitions can serve
Best fitPredictable, stable trafficVariable or unpredictable spikes, including dev and test hoursBursting, intermittent traffic that is hard to forecast
Region scopeProvisioned RU/s is available in every region on the accountSame, and with dynamic scaling each region and partition scales independentlySingle region only; regions cannot be added after account creation

Decision tree

429s while some partitionssit idle?yesHot partitionredesign the partition keynoWrite charges dominatethe bill?yesIndexing policyexclude paths nothing queriesnoDo the same readsrepeat constantly?yesIntegrated cachededicated gateway, session or eventualnoSorting or filtering ontwo or more properties?yesComposite indexmatch sequence and directionnoConsistency and capacityrelax reads, then pick the throughput modeSkew and over-indexing waste capacityyou already paid for, so rule them out first.

Sharp facts the exam loves — give these one last read before exam day.

Cheat sheet

Sharp facts the exam loves — scan these before test day.

Request Units (RUs) are the normalized currency billed for every read, write, and query

A Request Unit abstracts the CPU, memory, and IOPS of an operation, so all reads, writes, and queries are charged in RUs against the container or database throughput. A point read of a 1-KB item is about 1 RU; writes and queries cost more depending on item size, indexed paths, and query complexity.

4 questions test this
Read x-ms-request-charge to measure the exact RU cost of any operation

Every Cosmos response carries the RUs consumed in the x-ms-request-charge header, exposed through the SDK's response headers. Reading it is the authoritative way to profile and optimize a specific read, write, or query rather than estimating cost.

Trap Estimating an operation's RU cost from item size instead of reading x-ms-request-charge.

2 questions test this
Throughput is provisioned as manual RU/s, autoscale, or serverless, each with different scaling behavior

You allocate throughput at the database or container level as manual (fixed RU/s), autoscale (scales automatically between 10% and a configured maximum), or serverless (pay per consumed RU with no provisioning). Autoscale fits spiky or unpredictable traffic; serverless fits low, intermittent workloads.

3 questions test this
Time to Live (TTL) auto-expires items using background RUs; -1 enables TTL with no default expiry

A container's DefaultTimeToLive governs automatic expiry: a positive value is the default item lifetime in seconds, -1 enables TTL so only items carrying their own ttl expire, and null/absent disables it. Expired items disappear from queries immediately and are purged by a background task: on a provisioned throughput account that purge uses leftover RUs that user requests did not consume, while on a serverless account it is charged in RUs at the same rate as delete operations.

Cosmos DB integrated cache via dedicated gateway

The integrated cache serves repeated point reads and identical queries from an in-memory cache on a provisioned dedicated gateway at effectively 0 RU — the lever for cutting RU on read-heavy repeat traffic. It requires connecting through the dedicated-gateway endpoint (gateway connection mode) with session or eventual consistency, and a MaxIntegratedCacheStaleness window bounds how stale a cached response may be.

Exceeding provisioned throughput returns HTTP 429 with an x-ms-retry-after-ms hint

When consumption exceeds the provisioned RU/s, Cosmos rejects the request with HTTP 429 (Request rate too large) and includes an x-ms-retry-after-ms header stating how long to wait before retrying. Sustained 429s indicate under-provisioned throughput or a hot partition.

8 questions test this
The SDK automatically retries throttled requests up to a configurable maximum, honoring retry-after

By default the SDK transparently retries 429 responses, waiting the x-ms-retry-after-ms interval each time, up to a bounded number of attempts (nine by default, changed with the Python SDK's retry_total setting) and a maximum cumulative wait time (30 seconds by default, after which a CosmosHttpResponseError with status 429 reaches the application even if attempts remain). Raising these limits absorbs larger bursts at the cost of latency but does not add capacity.

Trap Increasing max retry attempts masks a hot partition or too-low RU/s; it does not provision more throughput.

5 questions test this
A hot logical partition can throttle even when total RU/s appears sufficient

Because throughput is divided across physical partitions, a partition key that concentrates traffic on one logical partition can trigger 429s while overall utilization looks low. The remedy is a higher-cardinality, evenly distributed partition key, not simply more RU/s.

Partition-key cardinality design rule

The design rule for even throughput is to pick a partition key with high cardinality whose access pattern spreads work uniformly — a per-user or per-device id distributes load, whereas a low-cardinality field (a status flag) or a monotonically increasing timestamp concentrates writes and creates a hot partition. Because provisioned throughput divides evenly across a container's physical partitions and every logical partition maps to exactly one of them, a skewed key throttles one physical partition even when total account RUs are ample.

Cosmos indexes every property automatically by default; you tune write cost by excluding paths

A container's indexing policy defaults to automatic, consistent indexing of all paths (/*). Write RU cost scales with the number of indexed paths, so excluding properties you never filter or sort on reduces write and storage cost without affecting the queries you actually run.

Trap Assuming properties you never filter or sort on are free because no query touches them.

7 questions test this
includedPaths and excludedPaths use JSON path expressions such as /category/? and /*

Indexing paths use JSON path syntax: /* matches everything recursively, /category/? indexes the scalar at that path, and /metadata/* indexes a subtree. The most specific matching rule wins, so you can include /* while excluding a large subtree like /rawText/*.

Trap Assuming a broad /* include outranks a more specific excludedPaths entry.

5 questions test this
indexingMode consistent keeps the index in sync with writes; none disables indexing entirely

indexingMode consistent (the default) updates the index synchronously as you create, update, or delete items, so read queries see the consistency configured for the account rather than a lagging index; none removes the index — suitable for a pure key/value point-read store where any query would otherwise scan. A third mode, lazy, can return inconsistent or incomplete query results and can no longer be selected for new containers.

Trap Setting indexingMode to none on a container that still has to answer queries.

2 questions test this
Excluding a vector or large-text path from the standard index avoids paying to index data you search specially

Large properties such as embedding arrays or raw document text add write RU and storage when covered by the default index. Adding their path to excludedPaths while defining a dedicated vector index keeps writes cheap without losing the specialized search path.

A composite index is required to ORDER BY two or more properties

A single-property range index handles ORDER BY on one field, but ordering by multiple properties (for example ORDER BY c.lastName ASC, c.firstName ASC) requires a compositeIndexes entry listing those paths with their sort orders, or the query fails.

Trap Expecting two single-property range indexes to satisfy an ORDER BY over both.

6 questions test this
Composite index property order and direction must match the query, including its exact reverse

A composite index defined as (name ASC, age ASC) serves ORDER BY name ASC, age ASC and its exact reverse (name DESC, age DESC), but not (name ASC, age DESC). Define the composite index to mirror the query's property sequence and directions.

Trap Assuming one composite index serves every combination of sort directions on its properties.

6 questions test this
Composite indexes can also lower RU for queries that filter on one property and sort or range on another

Beyond multi-property ORDER BY, a composite index can reduce the RU charge of queries that filter on one property and range-filter or sort on another, because the engine resolves them from the composite index instead of a broader index scan.

Cosmos offers five consistency levels from Strong to Eventual, with Session as the default

The five levels — Strong, Bounded Staleness, Session, Consistent Prefix, and Eventual — trade read consistency against latency, availability, and RU cost. Session, the default, guarantees read-your-own-writes and monotonic reads within a client session and satisfies most applications.

6 questions test this
Strong and Bounded Staleness reads consume roughly double the RUs of weaker levels

Strong guarantees a linearizable, always-latest read but adds latency and is unavailable with multi-region writes; Bounded Staleness bounds lag by versions or time. Reads at Strong or Bounded Staleness cost about twice the RUs of Session, Consistent Prefix, or Eventual reads.

Trap Strong consistency is not compatible with multi-region (multi-master) write accounts.

5 questions test this
Session consistency is carried by a session token emitted on the write response

Under Session consistency each write response returns a session token; to preserve read-your-own-writes across processes or nodes you capture that token and supply it on later requests' options. The token is produced by the operation response, not configured on the client.

Trap Hunting for the session token as a client setting instead of on the write response.

2 questions test this
A request can relax to a weaker consistency than the account default; the ConsistencyLevel override cannot strengthen it

The account's default consistency applies to all requests, but an individual read may request a weaker level than the default (for example Eventual on a Strong account) to save RUs and latency. With the per-request ConsistencyLevel option that override can only relax consistency; moving to a level stronger than the account default means changing the account's own default.

Conflict-resolution policy for multi-region write accounts

When a multi-region write account takes concurrent writes to the same item in different regions, the container's conflict-resolution policy reconciles them: Last-Writer-Wins (the default, on a numeric/timestamp conflict path such as _ts) or a Custom policy backed by a merge stored procedure. The policy decides WHICH version wins; it does not route writes to the nearest region (that is the preferred-regions list).

Also tested in

References

  1. Request Units as a throughput and performance currency in Azure Cosmos DB
  2. Find the request unit charge in Azure Cosmos DB
  3. Provision autoscale throughput on a database or container in Azure Cosmos DB
  4. Diagnose and troubleshoot Azure Cosmos DB request rate too large (429) exceptions
  5. Azure Cosmos DB serverless
  6. Optimize request cost in Azure Cosmos DB
  7. Indexing in Azure Cosmos DB - overview
  8. Indexing policies in Azure Cosmos DB
  9. Performance tips for the Azure Cosmos DB Python SDK
  10. Vector search in Azure Cosmos DB for NoSQL
  11. Manage indexing policies in Azure Cosmos DB
  12. Consistency level choices in Azure Cosmos DB
  13. Manage consistency levels in Azure Cosmos DB
  14. Conflict types and resolution policies in Azure Cosmos DB
  15. Azure Cosmos DB integrated cache - overview
  16. Time to Live (TTL) in Azure Cosmos DB
  17. Partitioning and horizontal scaling in Azure Cosmos DB