
Cosmosdb Best Practices
- 675 installs
- 47 repo stars
- Updated July 29, 2026
- azurecosmosdb/cosmosdb-agent-kit
cosmosdb-best-practices is an agent skill that ensures Azure Cosmos DB code follows performance, cost, and scalability best practices across NoSQL data modeling, queries, partitioning, and SDK usage.
About
cosmosdb-best-practices is an agent skill from azurecosmosdb/cosmosdb-agent-kit that encodes Azure Cosmos DB performance optimization guidelines for NoSQL workloads. It applies when writing, reviewing, or refactoring Cosmos DB code, designing partition keys, optimizing RU consumption, enforcing point reads over cross-partition queries, configuring CosmosClient SDK singletons, modeling containers, and implementing change feed, bulk operations, vector search, full-text search, hierarchical partition keys, global distribution, autoscale throughput, and indexing policies. Developers reach for cosmosdb-best-practices during schema design, query tuning, or code review to avoid costly hot partitions and excessive request units. The skill explicitly excludes PostgreSQL and other non-Cosmos databases.
- 100+ rules across 12 categories prioritized by impact
- Guides partition key selection, RU optimization, and indexing policy
- Covers SDK singleton usage, bulk operations, change feed, and vector search
- Supports automated refactoring and code generation with Cosmos DB patterns
- Hard-gate review before committing high-scale database code
Cosmosdb Best Practices by the numbers
- 675 all-time installs (skills.sh)
- Ranked #109 of 911 Databases skills by installs in the Skillselion catalog
- Data as of Jul 30, 2026 (Skillselion catalog sync)
npx skills add https://github.com/azurecosmosdb/cosmosdb-agent-kit --skill cosmosdb-best-practicesAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 675 |
|---|---|
| repo stars | ★ 47 |
| Last updated | July 29, 2026 |
| Repository | azurecosmosdb/cosmosdb-agent-kit ↗ |
How do you optimize Azure Cosmos DB performance and RU cost?
Ensure their Azure Cosmos DB code follows performance, cost, and scalability best practices across data modeling, queries, and SDK usage.
Who is it for?
Backend developers writing or reviewing Azure Cosmos DB NoSQL code who need partition design, RU optimization, and SDK performance guidance.
Skip if: Developers using PostgreSQL, MongoDB outside Cosmos, or projects with no Azure Cosmos DB dependency.
When should I use this skill?
The user writes, reviews, or refactors Azure Cosmos DB code involving partition keys, RU costs, queries, indexing, or CosmosClient SDK usage.
What you get
Partition key designs, optimized queries, SDK singleton configuration, indexing policies, and refactored Cosmos DB access patterns.
- partition key design
- optimized queries
- indexing policy recommendations
Files
Azure Cosmos DB Best Practices
Comprehensive performance optimization guide for Azure Cosmos DB applications, containing 100+ rules across 12 categories, prioritized by impact to guide automated refactoring and code generation.
When to Apply
Reference these guidelines when:
- Designing data models for Cosmos DB
- Choosing partition keys
- Writing or optimizing queries
- Implementing SDK patterns
- Using the Cosmos DB Emulator for local development
- Inspecting or managing Cosmos DB data with developer tooling
- Implementing vector search or RAG features on Cosmos DB
- Reviewing code for performance issues
- Configuring throughput and scaling
- Building globally distributed applications
Rule Categories by Priority
| Priority | Category | Impact | Prefix |
|---|---|---|---|
| 1 | Data Modeling | CRITICAL | model- |
| 2 | Partition Key Design | CRITICAL | partition- |
| 3 | Query Optimization | HIGH | query- |
| 4 | SDK Best Practices | HIGH | sdk- |
| 5 | Indexing Strategies | MEDIUM-HIGH | index- |
| 6 | Throughput & Scaling | MEDIUM | throughput- |
| 7 | Global Distribution | MEDIUM | global- |
| 8 | Monitoring & Diagnostics | LOW-MEDIUM | monitoring- |
| 9 | Design Patterns | HIGH | pattern- |
| 10 | Developer Tooling | MEDIUM | tooling- |
| 11 | Vector Search | HIGH | vector- |
Quick Reference
1. Data Modeling (CRITICAL)
- model-embed-related - Embed related data retrieved together
- model-reference-large - Reference data when items get too large
- model-avoid-2mb-limit - Keep items well under 2MB limit
- model-id-constraints - Follow ID value length and character constraints
- model-nesting-depth - Stay within 128-level nesting depth limit
- model-numeric-precision - Understand IEEE 754 numeric precision limits
- model-denormalize-reads - Denormalize for read-heavy workloads including pre-computed aggregates
- model-schema-versioning - Version your document schemas
- model-type-discriminator - Use type discriminators for polymorphic data
- model-json-serialization - Handle JSON serialization correctly for Cosmos DB documents
- model-relationship-references - Use ID references with transient hydration for document relationships
2. Partition Key Design (CRITICAL)
- partition-high-cardinality - Choose high-cardinality partition keys
- partition-avoid-hotspots - Distribute writes evenly
- partition-hierarchical - Use hierarchical partition keys for flexibility; order levels broad→narrow
- [partition-query-patterns
{
"version": "1.1.0",
"organization": "CosmosDB Agent Kit",
"date": "January 2026",
"abstract": "Performance optimization and best practices guide for Azure Cosmos DB applications, ordered by impact. Contains rules for data modeling, partition key design, query optimization, SDK usage, indexing, throughput management, global distribution, monitoring, developer tooling, and vector search.",
"references": [
"https://learn.microsoft.com/azure/cosmos-db/",
"https://learn.microsoft.com/azure/well-architected/service-guides/cosmos-db"
]
}
cosmosdb-best-practices
Azure Cosmos DB best practices for AI coding agents, following the Agent Skills specification.
Overview
This skill contains 118 rules across 13 categories, ordered by impact:
| Category | Impact | Description |
|---|---|---|
| Data Modeling | CRITICAL | Document structure and embedding vs referencing patterns |
| Partition Key Design | CRITICAL | Key selection for scalability and query efficiency |
| Query Optimization | HIGH | Minimize RU consumption and latency |
| SDK Best Practices | HIGH | Connection management and error handling |
| Indexing Strategies | MEDIUM-HIGH | Index configuration for cost/performance balance |
| Throughput & Scaling | MEDIUM | RU provisioning and scaling strategies |
| Global Distribution | MEDIUM | Multi-region configuration |
| Monitoring & Diagnostics | LOW-MEDIUM | Observability and troubleshooting |
| Design Patterns | HIGH | Reusable Cosmos DB architecture patterns |
| Developer Tooling | MEDIUM | Emulator and extension guidance for day-to-day work |
| Vector Search | HIGH | Semantic search and RAG-related configuration |
| Full-Text Search | HIGH | Keyword matching, BM25 ranking, and hybrid search configuration |
| Security | CRITICAL | Authentication, RBAC, network isolation, and backup configuration |
Installation
Using add-skill (Recommended)
npx skills add AzureCosmosDB/cosmosdb-agent-kitThis installs the skill into your .copilot/skills/ directory.
Manual Installation
Clone this repository and copy the skill:
git clone https://github.com/AzureCosmosDB/cosmosdb-agent-kit.git
cp -r cosmosdb-agent-kit/skills/cosmosdb-best-practices ~/.copilot/skills/Claude Code
cp -r skills/cosmosdb-best-practices ~/.claude/skills/File Structure
skills/cosmosdb-best-practices/
├── SKILL.md # Skill definition (triggers agent activation)
├── AGENTS.md # Compiled rules (what agents read)
├── metadata.json # Version and metadata
├── README.md # This file
└── rules/
├── _sections.md # Section definitions
├── _template.md # Template for new rules
├── model-*.md # Data modeling rules
├── partition-*.md # Partition key rules
├── query-*.md # Query optimization rules
├── sdk-*.md # SDK best practices rules
├── index-*.md # Indexing rules
├── throughput-*.md # Throughput rules
├── global-*.md # Global distribution rules
├── monitoring-*.md # Monitoring rules
├── pattern-*.md # Design pattern rules
├── tooling-*.md # Developer tooling rules
├── vector-*.md # Vector search rules
└── fts-*.md # Full-text search rulesHow It Works
When you're working on Cosmos DB code, AI coding agents (Claude Code, GitHub Copilot, Gemini CLI, etc.) that support Agent Skills will automatically:
1. Detect the skill based on SKILL.md triggers 2. Load SKILL.md as the lightweight index 3. Follow linked rule files in rules/ as needed 4. Apply best practices while generating or reviewing code
AGENTS.md remains the compiled version of the full guidance for environments that want one monolithic document.
Compiling Rules
To rebuild AGENTS.md from individual rules:
npm run build
# or
node scripts/compile.jsContributing
Adding a New Rule
1. Copy rules/_template.md to a new file in the appropriate category 2. Fill in the frontmatter (title, impact, impactDescription, tags) 3. Add Incorrect and Correct code examples 4. Run npm run build to recompile AGENTS.md 5. Submit a pull request
Rule Format
---
title: Rule Title
impact: HIGH
impactDescription: Brief explanation of why this matters
tags: [relevant, tags, here]
---
**Incorrect (brief reason):**
// Anti-pattern code
**Correct (brief reason):**
// Best practice code
Impact Levels
- CRITICAL: Prevents data loss, outages, or unrecoverable issues
- HIGH: Significant performance or cost impact
- MEDIUM-HIGH: Notable optimization opportunity
- MEDIUM: Recommended best practice
- LOW-MEDIUM: Nice to have
- LOW: Minor optimization
Compatibility
This skill follows the Agent Skills open standard and is compatible with:
- Claude Code
- VS Code (GitHub Copilot)
- GitHub.com
- Gemini CLI
- OpenCode
- Factory
- OpenAI Codex
License
MIT
Acknowledgments
- Inspired by Vercel's React Best Practices
- Based on the Agent Skills specification from Anthropic
- Azure Cosmos DB team for official documentation
1. Data Modeling (model)
Impact: CRITICAL Description: Proper data modeling is foundational to Cosmos DB performance. Poor modeling leads to expensive queries, excessive RU consumption, and scalability issues that are difficult to fix later.
2. Partition Key Design (partition)
Impact: CRITICAL Description: Partition key choice determines data distribution, query efficiency, and scalability limits. A bad partition key creates hot partitions and cross-partition query overhead.
3. Query Optimization (query)
Impact: HIGH Description: Optimized queries minimize RU consumption and latency. Inefficient queries cause unnecessary cross-partition scans and index misses.
4. SDK Best Practices (sdk)
Impact: HIGH Description: Proper SDK usage ensures connection efficiency, retry handling, and optimal throughput. Common mistakes include creating multiple clients and ignoring throttling.
5. Indexing Strategies (index)
Impact: MEDIUM-HIGH Description: Strategic indexing reduces query costs while minimizing write overhead. Default indexing often includes unused paths.
6. Throughput & Scaling (throughput)
Impact: MEDIUM Description: Right-sizing throughput balances cost and performance. Over-provisioning wastes money; under-provisioning causes throttling.
7. Global Distribution (global)
Impact: MEDIUM Description: Multi-region configuration enables low-latency reads globally and disaster recovery. Consistency choices impact both.
8. Monitoring & Diagnostics (monitoring)
Impact: LOW-MEDIUM Description: Proactive monitoring catches issues before they impact users. Diagnostics enable root cause analysis.
9. Design Patterns (pattern)
Impact: HIGH Description: Architecture patterns for common scenarios like cross-partition query optimization, event sourcing, and multi-tenant designs.
10. Developer Tooling (tooling)
Impact: MEDIUM Description: Tooling guidance improves local development, inspection workflows, and developer productivity without replacing core SDK or data model guidance.
11. Vector Search (vector)
Impact: HIGH Description: Vector search configuration enables AI-powered semantic search and RAG patterns. Proper embedding storage, indexing, and query optimization are essential for performance and accuracy.
12. Full-Text Search (fts)
Impact: HIGH Description: Native full-text search (FTS) provides inverted-index-backed keyword matching, BM25 relevance ranking, and language-aware tokenization. Requires three coordinated changes: an account-level capability flag, a container fullTextPolicy, and a fullTextIndexes entry in the indexing policy.
13. Security (security)
Impact: HIGH Description: Secure authentication, network isolation, least-privilege access, and data protection for Cosmos DB accounts. Use Entra ID with managed identity instead of keys, restrict network access, assign minimum RBAC roles, and enable continuous backup for point-in-time restore.
<!-- NOTE: Rules must be GENERIC. Write for any application hitting this Cosmos DB pattern, not for a specific scenario. Use scenario-specific terms only as illustrative examples, never in the title or top-level guidance. -->
Rule Title Here
Impact: MEDIUM (optional impact description)
Brief explanation of the rule and why it matters. This should be clear and concise, explaining the performance implications for Azure Cosmos DB.
Incorrect (description of what's wrong):
// Bad code example here
var container = cosmosClient.GetContainer("db", "container");
// Example of anti-patternCorrect (description of what's right):
// Good code example here
var container = cosmosClient.GetContainer("db", "container");
// Example of best practiceReference: Link to documentation or resource
Add Full-Text Index in the Indexing Policy
Impact: HIGH (without the index, FTS functions fall back to a full scan)
The fullTextIndexes array in the indexingPolicy tells Cosmos DB to build an inverted index for the corresponding path. This is separate from the range index — a field can have both. Fields covered by a full-text index should not also appear in excludedPaths.
Incorrect (field excluded from range index but no FTS index — slow scan):
excludedPaths: [
{ path: '/description/?' } // excluded from range index...
] // ...but no fullTextIndexes entry → full scanCorrect (Bicep):
indexingPolicy: {
indexingMode: 'consistent'
includedPaths: [
{ path: '/name/?' }
{ path: '/userid/?' }
]
excludedPaths: [
{ path: '/*' } // root wildcard
// description NOT listed here — managed by FTS index below
]
#disable-next-line BCP037
fullTextIndexes: [
{ path: '/description' } // inverted index — case-insensitive, tokenized
]
}A field underfullTextIndexesincurs extra write RU for index maintenance. Only index fields that are actually queried withFullTextContainsorFullTextScore.
Reference: Indexing policy for full-text search
Define Full-Text Policy on the Container
Impact: HIGH (required for tokenizer and stop-word configuration)
The fullTextPolicy declares which paths are full-text searchable and their language. Supported languages: en-US, de-DE (preview), fr-FR (preview), it-IT (preview), pt-BR (preview), pt-PT (preview), es-ES (preview). Language codes are case-sensitive — use the exact casing shown (e.g., en-US not en-us).
Incorrect (wrong language casing causes ARM BadRequest):
fullTextPolicy: {
defaultLanguage: 'en-us' // ❌ lowercase — rejected by ARM
fullTextPaths: [
{ path: '/description', language: 'en-us' } // ❌
]
}Correct (Bicep):
#disable-next-line BCP037
fullTextPolicy: {
defaultLanguage: 'en-US' // ✅ exact casing required
fullTextPaths: [
{
path: '/description'
language: 'en-US' // ✅
}
]
}Correct — Java SDK (container creation):
FullTextPolicy ftsPolicy = new FullTextPolicy()
.setDefaultLanguage("en-US")
.setFullTextPaths(List.of(
new FullTextPath().setPath("/description").setLanguage("en-US")
));
CosmosContainerProperties props = new CosmosContainerProperties("videos", "/videoid");
props.setFullTextPolicy(ftsPolicy);
database.createContainerIfNotExists(props).block();Reference: Configure full-text policy
Enable Full-Text Search Capability on Account
Impact: HIGH (prerequisite — FTS SQL functions fail without it)
Full-text search is an opt-in account-level capability. The SQL functions FullTextContains, FullTextContainsAll, FullTextContainsAny, and FullTextScore all return an error if this capability is not enabled.
Incorrect (capability absent — FTS queries fail at runtime):
-- This query fails with "Function 'FullTextContains' is not supported"
-- when EnableNoSQLFullTextSearch capability is missing on the account
SELECT * FROM c WHERE FullTextContains(c.description, 'cosmos')Correct — enable via Azure CLI:
az cosmosdb update \
--resource-group <rg> \
--name <account-name> \
--capabilities EnableNoSQLFullTextSearchCorrect — enable via Bicep (account resource):
resource cosmosAccount 'Microsoft.DocumentDB/databaseAccounts@2024-05-15' = {
name: cosmosAccountName
properties: {
// ... other properties ...
capabilities: [
{ name: 'EnableNoSQLFullTextSearch' }
]
}
}Note: As of Bicep type library v0.41,fullTextIndexesandfullTextPolicymay emitBCP037warnings. Suppress with#disable-next-line BCP037— the properties are valid at the ARM REST API level.
Reference: Full-text search in Azure Cosmos DB
Combine FTS with Range Filters for Hybrid Queries
Impact: MEDIUM (avoids full-container scans when combined with equality/range filters)
FTS predicates can be combined with standard SQL predicates. Cosmos DB uses the most selective predicate first. Put the most restrictive filter (e.g., equality on a high-cardinality property) before the FTS predicate to reduce the candidate set.
Incorrect (FTS-only query — no range filters, scans all partitions):
-- ❌ No equality filter — Cosmos DB must scan every partition before ranking
SELECT * FROM c
WHERE FullTextContains(c.description, @q)
ORDER BY RANK FullTextScore(c.description, @q)Correct — filter by partition + FTS:
SELECT * FROM c
WHERE c.type = 'video'
AND c.userid = @userid
AND FullTextContains(c.description, @q)
ORDER BY RANK FullTextScore(c.description, @q)// Hybrid: exact field filters narrow partition, FTS ranks within results
String sql = "SELECT * FROM c " +
"WHERE c.type = 'video' " +
"AND FullTextContains(c.description, @q) " +
"ORDER BY RANK FullTextScore(c.description, @q)";
CosmosQueryRequestOptions opts = new CosmosQueryRequestOptions();
// enableCrossPartitionQuery is true by default for FTS ORDER BY RANK
return container.queryItems(
new SqlQuerySpec(sql, new SqlParameter("@q", term)),
opts, Video.class
).byPage(pageSize).next().toFuture();Fields that should NOT use FTS:
- Short identifiers (
id,userid) — use point read or range index equality - Numeric fields — use range index with
=,>,< - Array elements already indexed with
[]/?—CONTAINS(LOWER(t), @q)via EXISTS is fine
Reference: Full-text search queries
Use FullTextContains for Keyword Matching
Impact: HIGH (replaces expensive CONTAINS(LOWER(...)) string scans with O(log n) inverted index lookup)
FullTextContains(path, term) performs a single-keyword lookup against the inverted index and is case-insensitive by design. It is dramatically faster than CONTAINS(LOWER(c.field), @q) on large containers because it does an O(log n) index lookup instead of a full document scan.
Incorrect (scan-based — avoid for long text fields with FTS index):
-- Full document scan, case folding at query time
SELECT * FROM c
WHERE CONTAINS(LOWER(c.description), @q)String sql = "SELECT * FROM c WHERE CONTAINS(LOWER(c.description), @q)";Correct:
-- Inverted index lookup — no LOWER() needed, FTS tokenizer handles casing
SELECT * FROM c
WHERE FullTextContains(c.description, @q)// Java SDK — parameterized query with FullTextContains
String sql = "SELECT * FROM c WHERE c.type = 'video' " +
"AND (CONTAINS(LOWER(c.name), @q) " + // short field — range index OK
"OR FullTextContains(c.description, @q) " + // long text — FTS index
"OR EXISTS(SELECT VALUE t FROM t IN c.tags WHERE CONTAINS(LOWER(t), @q)))";
SqlQuerySpec querySpec = new SqlQuerySpec(sql,
new SqlParameter("@q", query.trim().toLowerCase()));
return container.queryItems(querySpec, opts, Video.class)
.byPage(continuationToken, pageSize)
.next()
.map(page -> new ResultListPage<>(page.getResults(), page.getContinuationToken()))
.toFuture();Variants:
FullTextContains(path, term)— document contains the termFullTextContainsAll(path, term1, term2, ...)— document contains ALL terms (AND)FullTextContainsAny(path, term1, term2, ...)— document contains ANY term (OR)
Reference: FullTextContains function
Use FullTextScore for Relevance Ranking
Impact: MEDIUM-HIGH (enables BM25-based ranked results instead of arbitrary order)
FullTextScore(path, term) returns a BM25 relevance score. Use it in ORDER BY to surface the most relevant documents first. It requires FullTextContains in the WHERE clause on the same path.
Incorrect (FullTextScore without FullTextContains — parse error):
SELECT * FROM c
ORDER BY FullTextScore(c.description, 'cosmos') -- ❌ missing WHERE FullTextContainsCorrect:
SELECT c.name, c.description, c.addedDate
FROM c
WHERE FullTextContains(c.description, @q)
ORDER BY RANK FullTextScore(c.description, @q)String sql = "SELECT c.name, c.description, c.addedDate FROM c " +
"WHERE FullTextContains(c.description, @q) " +
"ORDER BY RANK FullTextScore(c.description, @q)";
SqlQuerySpec querySpec = new SqlQuerySpec(sql, new SqlParameter("@q", searchTerm));RANK FullTextScore(...) is cross-partition — Cosmos DB merges and re-ranks results from all partitions before returning the page.Reference: FullTextScore function
Implement Conflict Resolution
Configure appropriate conflict resolution policies for multi-region write scenarios. Without proper handling, data can be lost.
Understanding conflicts:
// Conflicts occur when same document is written in multiple regions
// before replication completes
// Region A: Update order status to "shipped"
// Region B: Update order status to "cancelled" (same time)
// Both writes succeed locally, then conflict during replicationIncorrect (ignoring conflicts):
// Using default LWW with _ts but not understanding implications
// Later timestamp wins - but "later" may be wrong server
// Server A clock: 10:00:00.100 → "shipped"
// Server B clock: 10:00:00.050 → "cancelled"
// Result: "shipped" wins even though B's write may be logically laterCorrect (explicit conflict resolution):
// Option 1: Last Writer Wins with logical clock (recommended)
var containerProperties = new ContainerProperties
{
Id = "orders",
PartitionKeyPath = "/customerId",
ConflictResolutionPolicy = new ConflictResolutionPolicy
{
Mode = ConflictResolutionMode.LastWriterWins,
ResolutionPath = "/version" // Use application-managed version
}
};
// Document with version counter
public class Order
{
public string Id { get; set; }
public string CustomerId { get; set; }
public string Status { get; set; }
public long Version { get; set; } // Increment on each update
}
// Update with version increment
public async Task UpdateOrderStatus(Order order, string newStatus)
{
order.Status = newStatus;
order.Version++; // Higher version always wins
await container.UpsertItemAsync(order, new PartitionKey(order.CustomerId));
}// Option 2: Stored procedure for custom resolution
var containerWithCustom = new ContainerProperties
{
Id = "inventory",
PartitionKeyPath = "/productId",
ConflictResolutionPolicy = new ConflictResolutionPolicy
{
Mode = ConflictResolutionMode.Custom,
ResolutionProcedure = "dbs/mydb/colls/inventory/sprocs/resolveConflict"
}
};
// Stored procedure for custom logic
// Example: For inventory, take the LOWER value (conservative)
const string resolveConflictSproc = @"
function resolveConflict(incomingItem, existingItem, isTombstone, conflictingItems) {
if (isTombstone) {
// Delete wins
return existingItem;
}
// For inventory: lower quantity wins (conservative)
if (existingItem.quantity < incomingItem.quantity) {
return existingItem;
}
return incomingItem;
}";// Option 3: Read and resolve conflicts manually (async)
// Conflicts written to conflicts feed when no automatic resolution
var conflictsFeed = container.Conflicts.GetConflictQueryIterator<dynamic>();
while (conflictsFeed.HasMoreResults)
{
var conflicts = await conflictsFeed.ReadNextAsync();
foreach (var conflict in conflicts)
{
// Read conflicting versions
var conflictContent = await container.Conflicts.ReadCurrentAsync<Order>(
conflict, new PartitionKey(conflict.PartitionKey));
// Apply custom resolution logic
var resolvedOrder = ResolveOrderConflict(conflictContent.Resource);
// Write resolved version
await container.UpsertItemAsync(resolvedOrder);
// Delete conflict record
await container.Conflicts.DeleteAsync(conflict, new PartitionKey(conflict.PartitionKey));
}
}Best practices:
- Use LWW with application-controlled version for simple cases
- Use stored procedures when business logic determines winner
- Monitor conflicts feed if using Custom mode
- Design to minimize conflicts (partition by user, idempotent operations)
Reference: Conflict resolution
Choose Appropriate Consistency Level
Select the consistency level that matches your application's requirements. Each level has different tradeoffs for latency, availability, and consistency.
Consistency levels (strongest to weakest):
// STRONG - Linearizable reads
// Reads always see most recent committed write
// Highest latency, lowest availability in multi-region
var client = new CosmosClient(connectionString, new CosmosClientOptions
{
ConsistencyLevel = ConsistencyLevel.Strong
});
// Use: Financial transactions, inventory management
// Tradeoff: Higher latency, reduced availability during regional outage
// BOUNDED STALENESS - Reads lag behind writes by bounded amount
// "Reads at least this fresh" guarantee
var client = new CosmosClient(connectionString, new CosmosClientOptions
{
ConsistencyLevel = ConsistencyLevel.BoundedStaleness
});
// Use: Stock tickers, leaderboards (where slight delay is OK)
// Tradeoff: May read slightly old data, better performance than Strong
// SESSION (DEFAULT) - Monotonic reads within session
// Client always sees its own writes
var client = new CosmosClient(connectionString, new CosmosClientOptions
{
ConsistencyLevel = ConsistencyLevel.Session
});
// Use: Most applications - user sees their changes
// Best balance of consistency and performance
// CONSISTENT PREFIX - Reads never see out-of-order writes
// Guarantees ordering but may lag behind
var client = new CosmosClient(connectionString, new CosmosClientOptions
{
ConsistencyLevel = ConsistencyLevel.ConsistentPrefix
});
// Use: Event sourcing, activity feeds
// Tradeoff: May read stale data, but always in order
// EVENTUAL - Weakest, highest performance
// No ordering guarantees, eventually converges
var client = new CosmosClient(connectionString, new CosmosClientOptions
{
ConsistencyLevel = ConsistencyLevel.Eventual
});
// Use: View counts, likes, non-critical telemetry
// Best performance, lowest costCorrect (choosing based on requirements):
// Example: E-commerce platform
// Orders container - Strong or Session
// User must see their order immediately after placing
var ordersClient = new CosmosClient(connectionString, new CosmosClientOptions
{
ConsistencyLevel = ConsistencyLevel.Session // Recommended
});
// Product catalog - Eventual or Consistent Prefix
// Slight delay in inventory updates is acceptable
var catalogClient = new CosmosClient(connectionString, new CosmosClientOptions
{
ConsistencyLevel = ConsistencyLevel.Eventual
});
// Analytics/metrics - Eventual
// Historical data doesn't need immediate consistency
var analyticsClient = new CosmosClient(connectionString, new CosmosClientOptions
{
ConsistencyLevel = ConsistencyLevel.Eventual
});// Session consistency with session token (most common pattern)
// SDK handles session tokens automatically within a client instance
// For scenarios where you need to share session across requests:
var response = await container.CreateItemAsync(order);
var sessionToken = response.Headers["x-ms-session-token"];
// Later request can use same session for read-your-writes
var readOptions = new ItemRequestOptions
{
SessionToken = sessionToken
};
var order = await container.ReadItemAsync<Order>(id, pk, readOptions);RU cost comparison (relative to Strong):
- Strong: 2x RU for reads (waits for quorum)
- Bounded Staleness: 2x RU for reads
- Session: 1x RU (default)
- Consistent Prefix: 1x RU
- Eventual: 1x RU
Reference: Consistency levels
Configure Automatic Failover
Enable automatic failover for high availability. Without it, regional outages require manual intervention.
Incorrect (no failover configuration):
// Multi-region account without automatic failover
// If primary region goes down:
// - Manual intervention required
// - Downtime until you notice and trigger failover
// - MTTR (Mean Time To Recovery) = hours potentially
// ARM template without failover
{
"properties": {
"enableAutomaticFailover": false, // DEFAULT - dangerous!
"locations": [
{ "locationName": "West US 2", "failoverPriority": 0 },
{ "locationName": "East US 2", "failoverPriority": 1 }
]
}
}Correct (automatic failover enabled):
// ARM template with automatic failover
{
"type": "Microsoft.DocumentDB/databaseAccounts",
"apiVersion": "2021-10-15",
"name": "my-cosmos-account",
"properties": {
"enableAutomaticFailover": true, // Enable automatic failover!
// Define failover priority order
"locations": [
{
"locationName": "West US 2",
"failoverPriority": 0, // Primary
"isZoneRedundant": true // Zone redundancy for HA
},
{
"locationName": "East US 2",
"failoverPriority": 1 // First failover target
},
{
"locationName": "West Europe",
"failoverPriority": 2 // Second failover target
}
]
}
}// Configure SDK to handle failovers gracefully
var client = new CosmosClient(connectionString, new CosmosClientOptions
{
ApplicationName = "MyApp",
// SDK will automatically discover new endpoints after failover
EnableTcpConnectionEndpointRediscovery = true,
// Preferred regions in priority order
ApplicationPreferredRegions = new List<string>
{
Regions.WestUS2, // Primary
Regions.EastUS2, // Failover 1
Regions.WestEurope // Failover 2
},
// Connection will retry and discover new primary
MaxRetryAttemptsOnRateLimitedRequests = 9,
MaxRetryWaitTimeOnRateLimitedRequests = TimeSpan.FromSeconds(30)
});
// SDK handles failover transparently - your code doesn't change
await container.CreateItemAsync(order, new PartitionKey(order.CustomerId));
// If West US 2 is down, SDK automatically routes to East US 2// Monitor failover status
var accountProperties = await client.ReadAccountAsync();
Console.WriteLine($"Write regions: {string.Join(", ",
accountProperties.WritableRegions.Select(r => r.Name))}");
Console.WriteLine($"Read regions: {string.Join(", ",
accountProperties.ReadableRegions.Select(r => r.Name))}");
// Set up Azure Monitor alerts for:
// - Region failover events
// - Replication lag metrics
// - Availability metrics// Test failover (non-production)
// Azure CLI command to trigger manual failover
// az cosmosdb failover-priority-change \
// --name mycosmosdb \
// --resource-group myrg \
// --failover-policies "East US 2"=0 "West US 2"=1
// Monitor your application behavior during failover test
// Expect: brief increase in latency, no data lossAutomatic failover behavior:
- Triggered after region unresponsive for ~1 minute
- Promotes next region in priority order
- SDK automatically reconnects to new primary
- No data loss with synchronous replication
Reference: Automatic failover
Configure Multi-Region Writes
Enable multi-region writes for globally distributed applications. Allows writes to any region with automatic conflict resolution.
Incorrect (single write region):
// Default: Single write region
// All writes must travel to one region
// Users in Asia writing to US region: 200-300ms latency
// No multi-region write configuration
var client = new CosmosClient(connectionString);
// Write from Asia still goes to US (write region)
await container.CreateItemAsync(order); // 200ms+ latency for Asian usersCorrect (multi-region writes enabled):
// Step 1: Enable multi-region writes on account (Azure Portal or ARM)
{
"type": "Microsoft.DocumentDB/databaseAccounts",
"properties": {
"enableMultipleWriteLocations": true, // Enable multi-region writes
"locations": [
{ "locationName": "West US 2", "failoverPriority": 0 },
{ "locationName": "East Asia", "failoverPriority": 1 },
{ "locationName": "West Europe", "failoverPriority": 2 }
]
}
}
// Step 2: Configure SDK to write locally
var client = new CosmosClient(connectionString, new CosmosClientOptions
{
// SDK automatically routes to nearest region
ApplicationPreferredRegions = new List<string>
{
Regions.EastAsia, // First choice (if deployed in Asia)
Regions.WestUS2,
Regions.WestEurope
}
});
// Write goes to nearest region (East Asia for Asian users)
await container.CreateItemAsync(order); // <10ms latency locally!// Step 3: Handle conflicts (Last Writer Wins is default)
// For custom conflict resolution, configure container
// Last Writer Wins (LWW) - Default
// Uses _ts (timestamp) to determine winner
var containerWithLWW = new ContainerProperties
{
Id = "orders",
PartitionKeyPath = "/customerId",
ConflictResolutionPolicy = new ConflictResolutionPolicy
{
Mode = ConflictResolutionMode.LastWriterWins,
ResolutionPath = "/_ts" // Higher timestamp wins
}
};
// Custom resolution path (e.g., version number)
var containerWithCustomLWW = new ContainerProperties
{
Id = "products",
PartitionKeyPath = "/categoryId",
ConflictResolutionPolicy = new ConflictResolutionPolicy
{
Mode = ConflictResolutionMode.LastWriterWins,
ResolutionPath = "/version" // Higher version wins
}
};// Verify multi-region write is working
var accountProperties = await client.ReadAccountAsync();
Console.WriteLine($"Multi-region writes: {accountProperties.EnableMultipleWriteLocations}");
Console.WriteLine($"Write regions: {string.Join(", ",
accountProperties.WritableRegions.Select(r => r.Name))}");Benefits:
- Local write latency (< 10ms vs 200ms+)
- Higher write availability (any region can accept writes)
- Better disaster recovery
Considerations:
- Higher cost (replication in both directions)
- Requires conflict resolution strategy
- Some operations have restrictions (stored procedures)
Reference: Multi-region writes
Add Read Regions Near Users
Add read regions in geographic locations close to your users. Reads can be served from any region, reducing latency for global users.
Incorrect (single region for global users):
// Only one region configured
// Users from all locations read from single region
// Asia users → 200ms+ latency to US region
// Europe users → 100ms+ latency to US region
{
"properties": {
"locations": [
{ "locationName": "West US 2", "failoverPriority": 0 }
]
}
}Correct (read regions near user populations):
// Add read replicas near major user bases
{
"type": "Microsoft.DocumentDB/databaseAccounts",
"properties": {
"locations": [
// Primary write region
{
"locationName": "West US 2",
"failoverPriority": 0
},
// Read replica for European users
{
"locationName": "West Europe",
"failoverPriority": 1
},
// Read replica for Asian users
{
"locationName": "Southeast Asia",
"failoverPriority": 2
},
// Read replica for Australian users
{
"locationName": "Australia East",
"failoverPriority": 3
}
]
}
}// Configure SDK for region-local reads
// Deployed in Europe - prioritize European region
var europeClient = new CosmosClient(connectionString, new CosmosClientOptions
{
ApplicationPreferredRegions = new List<string>
{
Regions.WestEurope, // Nearest region first
Regions.NorthEurope, // Backup within Europe
Regions.WestUS2 // Primary (for writes)
}
});
// Deployed in Asia - prioritize Asian region
var asiaClient = new CosmosClient(connectionString, new CosmosClientOptions
{
ApplicationPreferredRegions = new List<string>
{
Regions.SoutheastAsia, // Nearest region first
Regions.EastAsia, // Backup within Asia
Regions.WestUS2 // Primary (for writes)
}
});// Dynamic region selection based on deployment
public static CosmosClient CreateRegionalClient(string connectionString)
{
var deploymentRegion = Environment.GetEnvironmentVariable("AZURE_REGION")
?? "westus2";
var preferredRegions = deploymentRegion.ToLower() switch
{
"westeurope" or "northeurope" => new List<string>
{
Regions.WestEurope, Regions.NorthEurope, Regions.WestUS2
},
"southeastasia" or "eastasia" => new List<string>
{
Regions.SoutheastAsia, Regions.EastAsia, Regions.WestUS2
},
"australiaeast" => new List<string>
{
Regions.AustraliaEast, Regions.SoutheastAsia, Regions.WestUS2
},
_ => new List<string>
{
Regions.WestUS2, Regions.EastUS2
}
};
return new CosmosClient(connectionString, new CosmosClientOptions
{
ApplicationPreferredRegions = preferredRegions
});
}// Verify reads are going to correct region
var response = await container.ReadItemAsync<Order>(orderId, pk);
// Check diagnostics for contacted region
var diagnostics = response.Diagnostics.ToString();
_logger.LogDebug("Request served from: {Diagnostics}", diagnostics);
// Look for "Contacted Region" in diagnosticsCost considerations:
- Each read replica adds cost (~same as primary)
- Calculate: User latency improvement × request volume vs. replica cost
- Start with regions serving most users, add more based on metrics
Reference: Global distribution
Configure Zone Redundancy for High Availability
Enable zone redundancy to protect against availability zone failures. Zone-redundant accounts distribute replicas across multiple availability zones within a region.
Incorrect (no zone redundancy):
// Single-region account without zone redundancy
// If an availability zone fails:
// - Potential data loss
// - Availability loss until recovery
// - SLA: 99.99%
{
"type": "Microsoft.DocumentDB/databaseAccounts",
"properties": {
"locations": [
{
"locationName": "East US",
"failoverPriority": 0,
"isZoneRedundant": false // DEFAULT - no zone protection!
}
]
}
}Correct (zone redundancy enabled):
// ARM template with zone redundancy
{
"type": "Microsoft.DocumentDB/databaseAccounts",
"apiVersion": "2023-04-15",
"name": "my-cosmos-account",
"properties": {
"locations": [
{
"locationName": "East US",
"failoverPriority": 0,
"isZoneRedundant": true // Enable zone redundancy!
},
{
"locationName": "West US",
"failoverPriority": 1,
"isZoneRedundant": true // Enable in secondary too
}
]
}
}// Bicep template with zone redundancy
resource cosmosAccount 'Microsoft.DocumentDB/databaseAccounts@2023-04-15' = {
name: 'my-cosmos-account'
location: 'East US'
properties: {
locations: [
{
locationName: 'East US'
failoverPriority: 0
isZoneRedundant: true // Replicas spread across 3 AZs
}
{
locationName: 'West US'
failoverPriority: 1
isZoneRedundant: true
}
]
enableAutomaticFailover: true
}
}SLA Improvements with Zone Redundancy:
| Configuration | Write SLA | Read SLA | Zone Failure | Regional Failure |
|---|---|---|---|---|
| Single region, no ZR | 99.99% | 99.99% | Data/availability loss | Data/availability loss |
| Single region + ZR | 99.995% | 99.995% | No loss | Data/availability loss |
| Multi-region, no ZR | 99.99% | 99.999% | Data/availability loss | Dependent on consistency |
| Multi-region + ZR | 99.995% | 99.999% | No loss | Dependent on consistency |
| Multi-region writes + ZR | 99.999% | 99.999% | No loss | No loss (with conflicts) |
Cost Considerations:
- Zone redundancy adds 25% premium to provisioned throughput
- Premium is waived for:
- Multi-region write accounts
- Autoscale collections
- Adding a region adds ~100% to existing bill
When to Enable Zone Redundancy:
1. Always for single-region accounts - Primary protection against AZ failures 2. Write regions in multi-region accounts - Protects write availability 3. Production workloads - Required for high SLA guarantees
Regions Supporting Zone Redundancy:
Check current availability: Azure regions with availability zones
Reference: High availability in Azure Cosmos DB
Composite Index Directions Must Match ORDER BY
Every composite index entry must specify sort directions that exactly match the ORDER BY clause of the queries it serves. If the directions don't match, Cosmos DB will reject the query or fall back to an expensive scan.
For cross-partition ORDER BY queries, this is especially critical — the query will fail if no matching composite index exists.
Incorrect (direction mismatch — query fails):
# Composite index defined as descending
indexing_policy = {
"compositeIndexes": [
[{"path": "/score", "order": "descending"}]
]
}
# But query uses ascending order — no matching index!
query = "SELECT * FROM c ORDER BY c.score ASC"
# Fails: "The order by query does not have a corresponding composite index"// Index covers (score DESC) only
new Collection<CompositePath>
{
new CompositePath { Path = "/score", Order = CompositePathSortOrder.Descending }
}
// Query needs ASC — fails!
var query = "SELECT * FROM c ORDER BY c.score ASC";Correct (directions match exactly, with both orderings):
# Define BOTH directions to support ASC and DESC queries
indexing_policy = {
"compositeIndexes": [
[{"path": "/score", "order": "descending"}],
[{"path": "/score", "order": "ascending"}]
]
}// Always provide both sort directions for each composite index pattern
CompositeIndexes =
{
// For ORDER BY score DESC
new Collection<CompositePath>
{
new CompositePath { Path = "/score", Order = CompositePathSortOrder.Descending }
},
// For ORDER BY score ASC
new Collection<CompositePath>
{
new CompositePath { Path = "/score", Order = CompositePathSortOrder.Ascending }
}
}# Multi-property example: provide paired directions
indexing_policy = {
"compositeIndexes": [
# For ORDER BY gameId ASC, score DESC
[
{"path": "/gameId", "order": "ascending"},
{"path": "/score", "order": "descending"}
],
# For ORDER BY gameId DESC, score ASC (reverse pair)
[
{"path": "/gameId", "order": "descending"},
{"path": "/score", "order": "ascending"}
]
]
}Best practice: whenever you define a composite index, always include the inverse direction pair so that both ASC and DESC queries on those paths are served.
Reference: Composite index sort order
Use Composite Indexes for ORDER BY
Create composite indexes for queries with ORDER BY on multiple properties. Without them, queries may fail or require expensive client-side sorting.
The default indexing policy indexes every property but does not create composite indexes. Any query that combines a WHERE equality filter with ORDER BY on a different field needs a composite index declared explicitly, or the query will either fail in production or require expensive client-side sorting.
Emulator warning: The Cosmos DB emulator silently permits ORDER BY queries without a matching composite index and returns identical RU charges. Production containers reject the same query with "The order by query does not have a corresponding composite index that it can be served from." Always declare composite indexes at container-create time — do not rely on emulator success as validation.⚠️ CreateContainerIfNotExists warning: Defining a composite index inCreateContainerIfNotExists(orcreateIfNotExists) only applies the indexing policy when the container is created for the first time. If the container already exists, Cosmos DB returns the existing container, silently ignores the indexing policy argument, and keeps the existing indexing policy unchanged. To update composite indexes on an existing container, read the container, update itsIndexingPolicy, and replace the container resource using the SDK's container replace operation. Always read the container back and verify that the expected composite indexes are present.
Incorrect (ORDER BY without composite index):
// Query with multi-property ORDER BY
var query = @"
SELECT * FROM c
WHERE c.status = 'active'
ORDER BY c.createdAt DESC, c.priority ASC";
// Without composite index, this may:
// 1. Fail with: "Order-by item requires a corresponding composite index"
// 2. Or consume excessive RU for sortingCorrect (composite index for ORDER BY):
// Create composite index matching the ORDER BY
var indexingPolicy = new IndexingPolicy
{
IndexingMode = IndexingMode.Consistent,
CompositeIndexes =
{
// Must match ORDER BY exactly (properties and sort order)
new Collection<CompositePath>
{
new CompositePath { Path = "/createdAt", Order = CompositePathSortOrder.Descending },
new CompositePath { Path = "/priority", Order = CompositePathSortOrder.Ascending }
},
// Add reverse order for flexibility
new Collection<CompositePath>
{
new CompositePath { Path = "/createdAt", Order = CompositePathSortOrder.Ascending },
new CompositePath { Path = "/priority", Order = CompositePathSortOrder.Descending }
},
// Common filter + sort pattern
new Collection<CompositePath>
{
new CompositePath { Path = "/status", Order = CompositePathSortOrder.Ascending },
new CompositePath { Path = "/createdAt", Order = CompositePathSortOrder.Descending }
}
}
};
var containerProperties = new ContainerProperties
{
Id = "tasks",
PartitionKeyPath = "/userId",
IndexingPolicy = indexingPolicy
};// JSON indexing policy with composite indexes
{
"indexingMode": "consistent",
"automatic": true,
"includedPaths": [
{ "path": "/*" }
],
"compositeIndexes": [
[
{ "path": "/status", "order": "ascending" },
{ "path": "/createdAt", "order": "descending" }
],
[
{ "path": "/createdAt", "order": "descending" },
{ "path": "/priority", "order": "ascending" }
]
]
}// Common patterns that need composite indexes:
// Pattern 1: Filter + Sort
// WHERE status = 'x' ORDER BY date DESC
new Collection<CompositePath>
{
new CompositePath { Path = "/status", Order = CompositePathSortOrder.Ascending },
new CompositePath { Path = "/date", Order = CompositePathSortOrder.Descending }
}
// Pattern 2: Multi-column sort
// ORDER BY lastName ASC, firstName ASC
new Collection<CompositePath>
{
new CompositePath { Path = "/lastName", Order = CompositePathSortOrder.Ascending },
new CompositePath { Path = "/firstName", Order = CompositePathSortOrder.Ascending }
}
// Pattern 3: Range + Sort
// WHERE price >= 10 ORDER BY rating DESC
new Collection<CompositePath>
{
new CompositePath { Path = "/price", Order = CompositePathSortOrder.Ascending },
new CompositePath { Path = "/rating", Order = CompositePathSortOrder.Descending }
}Multi-Tenant Composite Index Patterns
In multi-tenant designs using type discriminators and hierarchical partition keys, composite indexes are critical for queries that filter by entity type and sort by common fields:
// Multi-tenant SaaS: tasks by status, sorted by date
{
"compositeIndexes": [
[
{ "path": "/type", "order": "ascending" },
{ "path": "/status", "order": "ascending" },
{ "path": "/createdAt", "order": "descending" }
],
[
{ "path": "/type", "order": "ascending" },
{ "path": "/assigneeId", "order": "ascending" },
{ "path": "/dueDate", "order": "ascending" }
],
[
{ "path": "/type", "order": "ascending" },
{ "path": "/priority", "order": "descending" },
{ "path": "/createdAt", "order": "descending" }
]
]
}// Java: Composite indexes with IndexingPolicy
IndexingPolicy policy = new IndexingPolicy();
// Type + Status + Date (for: WHERE type='task' AND status='open' ORDER BY createdAt DESC)
List<CompositePath> statusSort = Arrays.asList(
new CompositePath().setPath("/type").setOrder(CompositePathSortOrder.ASCENDING),
new CompositePath().setPath("/status").setOrder(CompositePathSortOrder.ASCENDING),
new CompositePath().setPath("/createdAt").setOrder(CompositePathSortOrder.DESCENDING)
);
// Type + Assignee + DueDate (for: WHERE type='task' AND assigneeId=@id ORDER BY dueDate)
List<CompositePath> assigneeSort = Arrays.asList(
new CompositePath().setPath("/type").setOrder(CompositePathSortOrder.ASCENDING),
new CompositePath().setPath("/assigneeId").setOrder(CompositePathSortOrder.ASCENDING),
new CompositePath().setPath("/dueDate").setOrder(CompositePathSortOrder.ASCENDING)
);
policy.setCompositeIndexes(Arrays.asList(statusSort, assigneeSort));// Rust (azure_data_cosmos): Composite indexes via JSON deserialization
// CompositeIndex types cannot be constructed directly (marked non_exhaustive),
// so use JSON deserialization instead
use azure_data_cosmos::models::{ContainerProperties, IndexingPolicy, PartitionKeyDefinition};
let indexing_policy: IndexingPolicy = serde_json::from_value(serde_json::json!({
"automatic": true,
"indexingMode": "consistent",
"includedPaths": [{"path": "/*"}],
"excludedPaths": [{"path": "/_etag/?"}],
"compositeIndexes": [
[
{"path": "/status", "order": "ascending"},
{"path": "/createdAt", "order": "descending"}
],
[
{"path": "/customerId", "order": "ascending"},
{"path": "/createdAt", "order": "descending"}
]
]
})).expect("valid indexing policy JSON");
let properties = ContainerProperties::new(
"orders".to_string(),
PartitionKeyDefinition::new(vec!["/customerId".to_string()]),
)
.with_indexing_policy(indexing_policy);
// Create container with composite indexes
db_client.create_container(properties, None).await?;Why type discriminators need composite indexes: When a single container holds multiple entity types (tenant, user, project, task), queries always filter by type. Without a composite index on (type, sortField), the query engine cannot efficiently sort within a single entity type. This is especially costly in containers with millions of mixed-type documents.
Node.js / TypeScript (@azure/cosmos v4)
Incorrect (container created with default indexing policy — no composites):
// ❌ No indexingPolicy → default (indexes everything, no composite)
await database.containers.createIfNotExists({
id: 'orders',
partitionKey: { paths: ['/userId'] },
});
// This query works on the emulator but FAILS in production:
await container.items.query({
query: 'SELECT * FROM c WHERE c.userId = @u ORDER BY c.createdAt DESC',
parameters: [{ name: '@u', value: userId }],
}, { partitionKey: userId }).fetchAll();Correct (composite indexes declared at container creation):
import { IndexingPolicy } from '@azure/cosmos';
// ✅ Declare composite indexes alongside container creation
const ordersIndexingPolicy: IndexingPolicy = {
indexingMode: 'consistent',
automatic: true,
includedPaths: [{ path: '/*' }],
excludedPaths: [{ path: '/"_etag"/?' }],
compositeIndexes: [
// WHERE c.userId = @u ORDER BY c.createdAt DESC
[
{ path: '/userId', order: 'ascending' },
{ path: '/createdAt', order: 'descending' },
],
// WHERE c.userId = @u AND c.status = @s ORDER BY c.createdAt DESC
[
{ path: '/userId', order: 'ascending' },
{ path: '/status', order: 'ascending' },
{ path: '/createdAt', order: 'descending' },
],
],
};
await database.containers.createIfNotExists({
id: 'orders',
partitionKey: { paths: ['/userId'] },
indexingPolicy: ordersIndexingPolicy,
});Updating an existing container's indexing policy:
// Replace indexing policy on an existing container
const { resource: existing } = await database.container('orders').read();
await database.container('orders').replace({
id: 'orders',
partitionKey: existing!.partitionKey,
indexingPolicy: ordersIndexingPolicy,
});
// Indexing is rebuilt in the background; monitor indexTransformationProgressRules:
- Composite index order must match ORDER BY exactly
- First path can be equality filter
- Include both ASC/DESC variants for flexibility
- Maximum 8 paths per composite index
- Composite indexes consume additional write RU — declare only the composites you actually query against
- Always define composite indexes when using type discriminators in shared containers
- Include
/typeas the first path in multi-tenant composite indexes
Reference: Composite indexes
Exclude Unused Index Paths
Exclude paths from indexing that you never query. Every indexed path adds write cost with no read benefit.
Incorrect (indexing everything):
// Default indexing policy indexes ALL paths
// Great for flexibility, expensive for writes
{
"indexingMode": "consistent",
"automatic": true,
"includedPaths": [
{
"path": "/*" // Indexes everything including unused fields
}
],
"excludedPaths": []
}
// Document with large unused fields gets indexed unnecessarily
{
"id": "order-123",
"customerId": "cust-1", // Queried
"status": "shipped", // Queried
"items": [...], // Not queried
"internalNotes": "...", // Not queried
"auditLog": [...] // Large array, never queried!
}
// Write cost includes indexing auditLog array - wasted RU⚠️ CreateContainerIfNotExists warning: Custom indexing policies supplied toCreateContainerIfNotExists(orcreateIfNotExists) are applied only when the container is created. If the container already exists, the call succeeds, the indexing policy argument is ignored, and the existing indexing policy remains unchanged. To apply new included or excluded paths to an existing container, update the container'sIndexingPolicyand replace the container resource using the SDK's container replace operation. After deployment, read the container definition back and verify that the expected included and excluded paths are present.
Correct (exclude-all-first, then include back):
// Exclude everything, then include only what you query
var indexingPolicy = new IndexingPolicy
{
IndexingMode = IndexingMode.Consistent,
Automatic = true,
// Start with exclude all — no field is indexed by default
ExcludedPaths = { new ExcludedPath { Path = "/*" } },
// Explicitly include only what you query
IncludedPaths =
{
new IncludedPath { Path = "/customerId/?" },
new IncludedPath { Path = "/status/?" },
new IncludedPath { Path = "/orderDate/?" },
new IncludedPath { Path = "/total/?" }
}
};
var containerProperties = new ContainerProperties
{
Id = "orders",
PartitionKeyPath = "/customerId",
IndexingPolicy = indexingPolicy
};// JSON equivalent indexing policy
{
"indexingMode": "consistent",
"automatic": true,
"excludedPaths": [
{ "path": "/*" }
],
"includedPaths": [
{ "path": "/customerId/?" },
{ "path": "/status/?" },
{ "path": "/orderDate/?" },
{ "path": "/total/?" }
]
}⚠️ Alternative (less optimal — indexes all paths by default):
// Selectively include and exclude paths
// WARNING: any new fields added to documents are auto-indexed
var indexingPolicy = new IndexingPolicy
{
IndexingMode = IndexingMode.Consistent,
Automatic = true,
// Only include paths you actually query
IncludedPaths =
{
new IncludedPath { Path = "/customerId/?" },
new IncludedPath { Path = "/status/?" },
new IncludedPath { Path = "/orderDate/?" },
new IncludedPath { Path = "/total/?" }
},
// Exclude known unused paths (but new fields still auto-indexed)
ExcludedPaths =
{
new ExcludedPath { Path = "/items/*" }, // Embedded array
new ExcludedPath { Path = "/internalNotes/?" },
new ExcludedPath { Path = "/auditLog/*" }, // Large array
new ExcludedPath { Path = "/_etag/?" } // System field
}
};Monitor and adjust:
- Review query patterns periodically
- Use Query Stats to see index utilization
- Balance write cost reduction vs query flexibility
Reference: Indexing policies
Understand Indexing Modes
Choose the appropriate indexing mode based on your workload. Consistent mode ensures query results are current; None disables indexing entirely.
Indexing modes explained:
// CONSISTENT MODE (Default - recommended for most cases)
// Indexes are updated synchronously with writes
// Queries always see latest data
var consistentPolicy = new IndexingPolicy
{
IndexingMode = IndexingMode.Consistent, // Default
Automatic = true
};
// Benefits:
// - Query results are always up-to-date
// - Strong consistency between writes and reads
// Tradeoffs:
// - Write latency includes index update time// NONE MODE (Write-only containers)
// No automatic indexing - fastest writes
// Only point reads work (by id + partition key)
var nonePolicy = new IndexingPolicy
{
IndexingMode = IndexingMode.None,
Automatic = false
};
// Use cases:
// - Pure key-value store (only point reads)
// - High-volume write ingestion
// - Time-series data queried via external system (Synapse Link)Correct (choosing mode based on workload):
// Typical transactional workload - use Consistent
var ordersPolicy = new IndexingPolicy
{
IndexingMode = IndexingMode.Consistent,
Automatic = true,
IncludedPaths = { new IncludedPath { Path = "/*" } }
};
var ordersContainer = new ContainerProperties
{
Id = "orders",
PartitionKeyPath = "/customerId",
IndexingPolicy = ordersPolicy
};
// Queries immediately see new orders// High-volume telemetry ingestion - consider None
var telemetryPolicy = new IndexingPolicy
{
IndexingMode = IndexingMode.None, // Maximum write throughput
Automatic = false
};
var telemetryContainer = new ContainerProperties
{
Id = "telemetry",
PartitionKeyPath = "/deviceId",
IndexingPolicy = telemetryPolicy,
// Enable analytical store for querying via Synapse
AnalyticalStorageTimeToLiveInSeconds = -1
};
// Point reads still work
var reading = await container.ReadItemAsync<Telemetry>(
readingId, new PartitionKey(deviceId));
// Complex queries via Synapse Link (analytical store)
// No indexing overhead on transactional writes// Selective indexing - best of both worlds
var hybridPolicy = new IndexingPolicy
{
IndexingMode = IndexingMode.Consistent,
Automatic = true,
// Only index fields you query
IncludedPaths =
{
new IncludedPath { Path = "/customerId/?" },
new IncludedPath { Path = "/orderDate/?" }
},
ExcludedPaths =
{
new ExcludedPath { Path = "/*" } // Exclude everything else
}
};
// Fast writes (minimal indexing) + efficient queries (on indexed paths)Decision guide:
- Consistent: Default, transactional workloads, need queries
- None: Write-only, pure key-value, using Synapse Link for analytics
Note: Lazy mode was deprecated - use Consistent instead.
Reference: Indexing modes
Use Correct Indexing Path Syntax
Cosmos DB indexing paths use specific notation for scalars, arrays, and wildcards. Using the wrong notation causes container creation to fail with a BadRequest error.
Three valid path notations:
| Notation | Meaning | Example |
|---|---|---|
/? | Scalar value (string or number) | /price/? |
/[] | Array element traversal | /items/[]/name/? |
/* | Terminal wildcard — everything below this node | /metadata/* |
*Incorrect (using `` for array traversal):**
// ❌ WRONG — * cannot be used mid-path for array traversal
// This causes: "The indexing path could not be accepted, failed near position ..."
{
"excludedPaths": [
{ "path": "/lineItems/*/productSnapshot/?" },
{ "path": "/orders/*/items/?" }
]
}Correct (using `[]` for array traversal):
// ✅ CORRECT — use [] to traverse array elements
{
"excludedPaths": [
{ "path": "/lineItems/[]/productSnapshot/?" },
{ "path": "/orders/[]/items/?" }
]
}*Correct (terminal `` wildcard for subtree):**
// ✅ CORRECT — * at the END of a path matches everything below
{
"includedPaths": [
{ "path": "/*" }
],
"excludedPaths": [
{ "path": "/metadata/*" },
{ "path": "/auditLog/*" },
{ "path": "/\"_etag\"/?" }
]
}Common patterns:
{
"includedPaths": [
{ "path": "/*" }
],
"excludedPaths": [
{ "path": "/\"_etag\"/?" },
{ "path": "/largeBlob/*" },
{ "path": "/items/[]/internalNotes/?" },
{ "path": "/events/[]/payload/*" }
]
}Key rules:
/?terminates a path to a scalar value — use for leaf properties/[]traverses into array elements — use when the parent is an array and you need to reach nested properties/*is a terminal wildcard — it means "all descendants" and must be the LAST segment in the path- NEVER use
*in the middle of a path (e.g.,/items/*/name/?is INVALID) - For composite indexes, paths do NOT use
/?or/*— they have an implicit/?at the end. Use/[]for array traversal in composite paths (e.g.,/children/[]/age)
Reference: Indexing policy path syntax
Choose Appropriate Index Types
Understand when to use different index types. Range indexes support equality, range, and ORDER BY; Hash indexes are deprecated.
Understanding index types:
// Range Index (DEFAULT - recommended for most cases)
// Supports: =, >, <, >=, <=, !=, ORDER BY, JOINs
// Index entries: ["a"], ["a", "b"], ["a", "b", "c"]...
{
"includedPaths": [
{
"path": "/price/?",
"indexes": [
{
"kind": "Range", // Default, most flexible
"dataType": "Number",
"precision": -1 // -1 = maximum precision
},
{
"kind": "Range",
"dataType": "String",
"precision": -1
}
]
}
]
}Correct (modern indexing approach):
// Modern Cosmos DB automatically uses optimal index types
// You typically just specify paths, not index kinds
var indexingPolicy = new IndexingPolicy
{
IndexingMode = IndexingMode.Consistent,
Automatic = true,
// Just specify paths - Cosmos DB handles index types
IncludedPaths =
{
new IncludedPath { Path = "/category/?" }, // Equality queries
new IncludedPath { Path = "/price/?" }, // Range queries
new IncludedPath { Path = "/createdAt/?" }, // ORDER BY
new IncludedPath { Path = "/tags/*" } // Array elements
},
ExcludedPaths =
{
new ExcludedPath { Path = "/description/?" }, // Large text, not queried
new ExcludedPath { Path = "/metadata/*" } // Nested object, not queried
}
};// For special query patterns, add composite or spatial indexes
var indexingPolicy = new IndexingPolicy
{
// Standard range indexes (automatic)
IncludedPaths =
{
new IncludedPath { Path = "/*" } // Index everything by default
},
// Composite indexes for multi-property ORDER BY
CompositeIndexes =
{
new Collection<CompositePath>
{
new CompositePath { Path = "/category", Order = CompositePathSortOrder.Ascending },
new CompositePath { Path = "/price", Order = CompositePathSortOrder.Descending }
}
},
// Spatial indexes for geo queries
SpatialIndexes =
{
new SpatialPath
{
Path = "/location/?",
SpatialTypes = { SpatialType.Point }
}
}
};// JSON policy showing all index types
{
"indexingMode": "consistent",
"automatic": true,
"includedPaths": [
{ "path": "/*" }
],
"excludedPaths": [
{ "path": "/largeContent/?" }
],
"compositeIndexes": [
[
{ "path": "/status", "order": "ascending" },
{ "path": "/createdAt", "order": "descending" }
]
],
"spatialIndexes": [
{
"path": "/location/?",
"types": ["Point"]
}
]
}Index type summary:
- Range (default): Equality, range, ORDER BY - use for everything
- Composite: Multi-property ORDER BY, filter+sort
- Spatial: Geographic/geometric queries
- Hash: DEPRECATED - don't use
Reference: Index types
Add Spatial Indexes for Geo Queries
Create spatial indexes for properties that store geographic data when you need to perform proximity or geometry queries.
Incorrect (geo queries without spatial index):
// Document with location
{
"id": "store-1",
"name": "Downtown Store",
"location": {
"type": "Point",
"coordinates": [-122.4194, 37.7749] // [longitude, latitude]
}
}
// Query without spatial index - expensive full scan!
var query = @"
SELECT * FROM c
WHERE ST_DISTANCE(c.location, {'type':'Point','coordinates':[-122.4,37.7]}) < 5000";Correct (spatial index for location queries):
// Create indexing policy with spatial index
var indexingPolicy = new IndexingPolicy
{
IndexingMode = IndexingMode.Consistent,
// Include path with spatial index
SpatialIndexes =
{
new SpatialPath
{
Path = "/location/?",
SpatialTypes =
{
SpatialType.Point
}
}
}
};
// If you have multiple geometry types
var indexingPolicyMulti = new IndexingPolicy
{
SpatialIndexes =
{
// Store locations as points
new SpatialPath
{
Path = "/location/?",
SpatialTypes = { SpatialType.Point }
},
// Delivery zones as polygons
new SpatialPath
{
Path = "/deliveryArea/?",
SpatialTypes = { SpatialType.Polygon }
}
}
};// JSON indexing policy with spatial index
{
"indexingMode": "consistent",
"spatialIndexes": [
{
"path": "/location/?",
"types": ["Point"]
},
{
"path": "/boundaries/?",
"types": ["Polygon"]
}
]
}// Efficient spatial queries with index
// Find stores within 5km of user
var nearbyQuery = @"
SELECT c.name, c.address,
ST_DISTANCE(c.location, @userLocation) AS distanceMeters
FROM c
WHERE ST_DISTANCE(c.location, @userLocation) < 5000
ORDER BY ST_DISTANCE(c.location, @userLocation)";
var userLocation = new
{
type = "Point",
coordinates = new[] { -122.4194, 37.7749 }
};
var stores = await container.GetItemQueryIterator<Store>(
new QueryDefinition(nearbyQuery)
.WithParameter("@userLocation", userLocation)
).ReadNextAsync();
// Check if point is within polygon (delivery zone)
var withinQuery = @"
SELECT * FROM c
WHERE ST_WITHIN(@orderLocation, c.deliveryArea)";
// Find intersecting regions
var intersectQuery = @"
SELECT * FROM c
WHERE ST_INTERSECTS(c.boundaries, @searchArea)";Supported spatial functions:
ST_DISTANCE- Distance between geometriesST_WITHIN- Point within polygonST_INTERSECTS- Geometries intersectST_ISVALID- Validate GeoJSONST_ISVALIDDETAILED- Validation with details
Reference: Geospatial queries
Keep Items Well Under 2MB Limit
Azure Cosmos DB enforces a 2MB maximum item size. Design documents to stay well under this limit to avoid runtime failures.
Incorrect (risk of hitting limit):
// Anti-pattern: storing large binary data in documents
public class Document
{
public string Id { get; set; }
public string Name { get; set; }
// Large base64-encoded file content - DANGER!
public string FileContent { get; set; } // Could be megabytes
// Or large arrays that grow
public List<AuditEntry> AuditLog { get; set; } // Unbounded
}
// This will fail when content exceeds 2MB
await container.CreateItemAsync(doc);
// Microsoft.Azure.Cosmos.CosmosException: Request Entity Too LargeCorrect (bounded document size):
// Store metadata in Cosmos DB, large content in Blob Storage
public class Document
{
public string Id { get; set; }
public string Name { get; set; }
public long FileSizeBytes { get; set; }
public string ContentType { get; set; }
// Reference to blob storage instead of inline content
public string BlobUri { get; set; }
// Keep only recent/relevant audit entries
public List<AuditEntry> RecentAuditEntries { get; set; } // Max 10-20 items
}
// Large content goes to Blob Storage
await blobClient.UploadAsync(largeFileStream);
var doc = new Document
{
Id = Guid.NewGuid().ToString(),
Name = "large-file.pdf",
BlobUri = blobClient.Uri.ToString()
};
await container.CreateItemAsync(doc);Size monitoring:
// Check item size before writing
var json = JsonSerializer.Serialize(item);
var sizeBytes = Encoding.UTF8.GetByteCount(json);
if (sizeBytes > 1_500_000) // 1.5MB warning threshold
{
_logger.LogWarning("Item approaching size limit: {SizeKB}KB", sizeBytes / 1024);
}Reference: Azure Cosmos DB service quotas
Denormalize for Read-Heavy Workloads
In read-heavy workloads, denormalize frequently-queried data to avoid expensive lookups. Accept write overhead for faster reads.
Incorrect (normalized requires multiple queries):
// Displaying product list with category names
public class Product
{
public string Id { get; set; }
public string Name { get; set; }
public string CategoryId { get; set; } // Just the ID
public decimal Price { get; set; }
}
// To display "Product Name - Category Name" requires JOIN-like pattern:
var products = await GetProductsAsync();
foreach (var product in products)
{
// N+1 query problem!
var category = await container.ReadItemAsync<Category>(
product.CategoryId, new PartitionKey(product.CategoryId));
product.CategoryName = category.Name;
}
// 1 + N queries = terrible performanceCorrect (denormalized for read efficiency):
public class Product
{
public string Id { get; set; }
public string Name { get; set; }
public string CategoryId { get; set; }
// Denormalized category info for display
public string CategoryName { get; set; }
public string CategorySlug { get; set; }
public decimal Price { get; set; }
}
// Single query returns everything needed for display
var query = "SELECT c.id, c.name, c.categoryName, c.price FROM c WHERE c.type = 'product'";
var products = await container.GetItemQueryIterator<Product>(query).ReadNextAsync();
// No additional queries needed!
// When category changes, update products using Change Feed
public async Task HandleCategoryChange(Category category)
{
var query = $"SELECT * FROM c WHERE c.categoryId = '{category.Id}'";
await foreach (var product in container.GetItemQueryIterator<Product>(query))
{
product.CategoryName = category.Name;
await container.UpsertItemAsync(product);
}
}Denormalize when:
- Read-to-write ratio is high (10:1 or more)
- Denormalized data changes infrequently
- Query patterns benefit from co-located data
Additional strategies to consider for denormalization: Pre-computed Aggregates :
- Definition: When an entity is frequently read and the read response includes aggregated statistics (counts, averages, totals), store those aggregates as persistent document fields rather than computing them per-request
- When to use:
- The entity's read response includes derived values such as counts, sums, averages, or min/max
- Reads significantly outnumber writes (high read-to-write ratio)
- Computing aggregates on-demand would require COUNT/AVG/SUM queries or application-level iteration
- Update strategy: Update aggregate fields inline at write time (within the same operation that records new data) or asynchronously via Change Feed
- Include a
lastUpdatedtimestamp field to enable staleness detection
Incorrect (aggregates computed on-demand):
@Container(containerName = "players")
public class PlayerProfile {
@Id
private String id;
@PartitionKey
private String playerId;
private String displayName;
private int bestScore;
// No stored aggregates — totalGamesPlayed requires COUNT query,
// averageScore requires AVG query or app-level computation per request
}Correct (pre-computed aggregates stored as fields):
@Container(containerName = "players")
public class PlayerProfile {
@Id
private String id;
@PartitionKey
private String playerId;
private String displayName;
private int bestScore;
private int totalGamesPlayed; // pre-computed, updated at write time
private double averageScore; // pre-computed, updated at write time
private long lastUpdated; // timestamp for staleness detection
} // Updating aggregates inline at write time
public async Task RecordGameScore(string playerId, int score)
{
var profile = await container.ReadItemAsync<PlayerProfile>(
playerId, new PartitionKey(playerId));
var p = profile.Resource;
p.TotalGamesPlayed += 1;
p.BestScore = Math.Max(p.BestScore, score);
p.AverageScore = p.TotalGamesPlayed == 1
? score
: ((p.AverageScore * (p.TotalGamesPlayed - 1)) + score) / p.TotalGamesPlayed;
p.LastUpdated = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
await container.ReplaceItemAsync(p, p.Id, new PartitionKey(playerId));
}Short-Circuit Denormalization :
- Definition: Duplicate only specific fields (not the full related document) to avoid a cross-partition lookup
- When to use:
- The duplicated property is mostly immutable (e.g., product name) or the app can tolerate staleness
- The property is small (a string, not an object)
- The access pattern would otherwise require a cross-partition read
- Example: Copy
customerNameinto Order doc to avoid looking up the Customer doc
Workload-Driven Cost Comparison Template for Denormalization Strategy :
Option 1 — Denormalized:
Read cost: [read_RPS] × [RU_per_read] = X RU/s
Write cost: [write_RPS] × [RU_per_write] + [update_propagation_cost] = Y RU/s
Total: X + Y RU/s
Option 2 — Normalized:
Read cost: [read_RPS] × ([RU_per_read] + [RU_for_lookup]) = X' RU/s
Write cost: [write_RPS] × [RU_per_write] = Y' RU/s
Total: X' + Y' RU/s
Decision: Choose option with lower total RU/s when workload profile details availableCascade Delete and Update of Denormalized Documents:
When a source document is deleted or a key field used in denormalized copies is updated, all related derived documents in other containers must be updated or removed. Failing to cascade deletes/updates leaves orphaned or stale denormalized data, which causes queries to return ghost entries (deleted entities still appearing in listings) or outdated information (entities appearing under old field values).
This is one of the most commonly missed patterns: developers implement the source document delete/update correctly but forget to propagate the change to all containers that hold derived documents.
Cascade DELETE — remove all related documents when source is deleted:
# ❌ WRONG — only deletes the source document, orphans derived documents
async def delete_player(player_id: str):
await players_container.delete_item(item=player_id, partition_key=player_id)
# Missing: delete from scores container
# Missing: delete from leaderboard container # ✅ CORRECT — cascade delete across all related containers
async def delete_player(player_id: str):
# 1. Delete the source document
await players_container.delete_item(item=player_id, partition_key=player_id)
# 2. Delete all related score documents (different container, same partition key)
scores_query = "SELECT c.id FROM c WHERE c.playerId = @pid"
async for page in scores_container.query_items(
query=scores_query, parameters=[{"name": "@pid", "value": player_id}]
):
await scores_container.delete_item(item=page["id"], partition_key=player_id)
# 3. Delete all leaderboard entries for this player (derived documents)
lb_query = "SELECT c.id, c.leaderboardKey FROM c WHERE c.playerId = @pid"
async for entry in leaderboard_container.query_items(
query=lb_query, parameters=[{"name": "@pid", "value": player_id}],
enable_cross_partition_query=True,
):
await leaderboard_container.delete_item(
item=entry["id"], partition_key=entry["leaderboardKey"]
) // ✅ CORRECT — .NET cascade delete
public async Task DeletePlayerAsync(string playerId)
{
// 1. Delete source
await _playersContainer.DeleteItemAsync<Player>(playerId, new PartitionKey(playerId));
// 2. Delete related scores
var scoreQuery = new QueryDefinition("SELECT c.id FROM c WHERE c.playerId = @pid")
.WithParameter("@pid", playerId);
await foreach (var score in _scoresContainer.GetItemQueryIterator<dynamic>(
scoreQuery, requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey(playerId) }))
await _scoresContainer.DeleteItemAsync<dynamic>(score.id, new PartitionKey(playerId));
// 3. Delete derived leaderboard entries (enumerate all leaderboard partitions or use cross-partition query)
var lbQuery = new QueryDefinition("SELECT c.id, c.leaderboardKey FROM c WHERE c.playerId = @pid")
.WithParameter("@pid", playerId);
await foreach (var entry in _leaderboardContainer.GetItemQueryIterator<dynamic>(lbQuery))
await _leaderboardContainer.DeleteItemAsync<dynamic>(
(string)entry.id, new PartitionKey((string)entry.leaderboardKey));
}Cascade UPDATE — re-derive documents when a partitioning field changes:
When an entity has a field that determines which partition its derived documents belong to (e.g., a region field used as the leaderboard partition key), updating that field requires: 1. Deleting the old derived documents from the previous partition 2. Creating new derived documents in the new partition
# ❌ WRONG — updates player region but leaves stale leaderboard entry in old region
async def update_player(player_id: str, updates: dict):
player = await players_container.read_item(item=player_id, partition_key=player_id)
player.update(updates)
await players_container.replace_item(item=player_id, body=player)
# Missing: remove leaderboard entry from old region, add to new region # ✅ CORRECT — cascade update when a partition-key field changes
async def update_player(player_id: str, updates: dict):
player = await players_container.read_item(item=player_id, partition_key=player_id)
old_region = player.get("region")
player.update(updates)
new_region = player.get("region")
await players_container.replace_item(item=player_id, body=player)
if "region" in updates and old_region != new_region:
# Remove old regional leaderboard entry
old_key = f"{old_region}_all-time"
try:
await leaderboard_container.delete_item(
item=player_id, partition_key=old_key
)
except Exception:
pass # May not exist if player had no scores
# Re-create in new regional leaderboard if player has scores
if player.get("bestScore", 0) > 0:
new_key = f"{new_region}_all-time"
new_entry = {
"id": player_id,
"leaderboardKey": new_key,
"playerId": player_id,
"displayName": player["displayName"],
"score": player["bestScore"],
}
await leaderboard_container.upsert_item(body=new_entry)Key rules for cascade operations:
- Every DELETE endpoint for an entity that has denormalized copies elsewhere must also delete those copies
- Every UPDATE endpoint that changes a field used in derived documents must propagate the change
- If the updated field is a partition key of the derived container, you must delete-and-recreate (Cosmos DB does not support updating partition key values)
- Consider listing all containers where derived data lives in a comment near each delete/update handler
Reference: Denormalization patterns
Embed Related Data Retrieved Together
Embed related data within a single document when they're always accessed together. This eliminates the need for multiple queries (Cosmos DB has no JOINs across documents).
Incorrect (requires multiple queries):
// Separate documents require multiple round-trips
var order = await container.ReadItemAsync<Order>(orderId, new PartitionKey(customerId));
var customer = await container.ReadItemAsync<Customer>(order.CustomerId, new PartitionKey(order.CustomerId));
var items = await container.GetItemQueryIterator<OrderItem>(
$"SELECT * FROM c WHERE c.orderId = '{orderId}'").ReadNextAsync();
// 3 separate queries = 3x latency + 3x RU costCorrect (single read operation):
// Embedded document - single query retrieves everything
public class Order
{
public string Id { get; set; }
public string CustomerId { get; set; }
// Embedded customer summary (not full customer document)
public CustomerSummary Customer { get; set; }
// Embedded order items
public List<OrderItem> Items { get; set; }
public decimal Total { get; set; }
public DateTime OrderDate { get; set; }
}
// Single read gets everything needed
var order = await container.ReadItemAsync<Order>(orderId, new PartitionKey(customerId));
// 1 query = lowest latency + minimal RUEmbed when:
- Data is read together frequently
- Embedded data changes infrequently
- Embedded data is bounded in size
Consider following Aggregate Decision Framework for embedding vs referencing: 1. Access Correlation Thresholds
- \>90% accessed together → Strong single-document aggregate candidate (embed)
- 50–90% accessed together → Multi-document container aggregate candidate (same container, separate docs, shared partition key)
- <50% accessed together → Separate containers
2. Constraint Checks :
- Size: Will combined size exceed 1MB? → Force multi-document or separate containers for child documents
- Updates: Different update frequencies? → Consider multi-document
- Atomicity: Need transactional updates? → Favor same partition with small batched updates or distributed transactional outbox pattern
Reference: Data modeling in Azure Cosmos DB
Follow ID Value Length and Character Constraints
Azure Cosmos DB enforces a 1,023 byte maximum for the id property and restricts certain characters. Using URL-reserved or path-separator characters in id values causes authentication failures (401) or routing errors (404) that are difficult to diagnose because they only surface on read/update/delete — not on create.
URL-reserved characters break Cosmos DB auth signing
Cosmos DB's REST protocol computes an HMAC signature over a canonical string that includes the ResourceLink (dbs/{db}/colls/{coll}/docs/{id}). When the SDK sends an HTTP request whose URL embeds a URL-reserved character in the id segment, the HTTP transport may strip or reinterpret the URL (e.g. a # is a fragment delimiter per RFC 3986 and is removed before the request leaves the client). The server then recomputes the signature over the truncated ResourceLink and returns 401 Unauthorized: "The input authorization token can't serve the request" — even though the key is correct.
The failure surfaces on read_item, replace_item, delete_item, and patch_item. It does not surface on create_item (the id is not part of the signed ResourceLink for creates — the parent collection is), so the bug often hides until the first update or read.
This is a cross-SDK issue affecting any SDK using Gateway mode. The Python SDK uses Gateway mode by default and always hits this. The .NET SDK hits the same failure in Gateway mode but not in Direct mode (Direct bypasses HTTP URI parsing). The .NET SDK's own test suite (CosmosItemIdEncodingTestsBase.cs, test IdWithDisallowedCharPoundSign) confirms 401 on read/replace/delete in Gateway mode with # in the id.
Never use any of these in `id`:
| Char | Reason |
|---|---|
# | URL fragment delimiter — HTTP client strips everything after # before sending; server sees truncated id, HMAC signature mismatch → 401 |
? | URL query delimiter — same truncation class of failure → 401 |
/ \ | Path separators — change the ResourceLink structure → 404 or 400 |
Avoid (interoperability / encoding risk):
| Char | Reason |
|---|---|
(space) | Percent-encoding inconsistency across SDKs and connectors |
% | Ambiguous with percent-encoding sequences |
| Any non-ASCII | Encoded differently across clients; known issues in ADF / Spark / Kafka connectors |
Safe synthetic-id separators: _, -, :
The id property is always a string
Azure Cosmos DB stores and indexes the id system property as a JSON string. There is no numeric id type.
When migrating from a relational database, keep the primary-key value but store it as a string id value:
| Relational key | Cosmos DB id |
|---|---|
42 | "42" |
90001 | "90001" |
Bind id to a string type in DTOs, domain models, and API contracts.
Incorrect:
public record Product(int Id, string Name);Correct:
public record Product(string Id, string Name);SQL to NoSQL migration guidance
Do not introduce a parallel numeric copy of id solely for sorting or pagination.
Incorrect:
SELECT * FROM c
ORDER BY c.idNumCorrect (for string ordering by id):
SELECT * FROM c
ORDER BY c.idIf numeric ordering is required, use a dedicated business field such as sku, sequenceNumber, or another domain-specific numeric property:
SELECT * FROM c
ORDER BY c.sequenceNumberDo not introduce a numeric shadow copy of id solely for sorting or pagination.
| Symptom | Cause |
|---|---|
Could not convert $.id to Int32 | DTO binds id to a numeric type |
| Unexpected pagination ordering | Sorting by a numeric shadow id instead of c.id |
Incorrect (oversized or problematic IDs):
// Anti-pattern 1: ID derived from unbounded user input
public class Document
{
// ID could exceed 1,023 bytes if title is very long
public string Id => $"{Category}_{SubCategory}_{Title}_{Description}";
public string Category { get; set; }
public string SubCategory { get; set; }
public string Title { get; set; }
public string Description { get; set; } // Unbounded!
}
// Anti-pattern 2: IDs containing forbidden or problematic characters
var doc = new Document
{
Id = "files/reports\\2026/Q1", // Contains '/' and '\' - FORBIDDEN
Content = "..."
};
await container.CreateItemAsync(doc);
// Fails or causes routing issues
// Anti-pattern 3: Non-ASCII characters in IDs
var doc2 = new Document
{
Id = "レポート_2026_データ", // Non-ASCII - interoperability risk
Content = "..."
};
// Works in some SDKs but may break in ADF, Spark, Kafka connectors# Anti-pattern 4: Using '#' as composite-id separator — 401 on read/update/delete
doc_id = f"best#{player_id}#{week}#{region}"
await container.upsert_item(body={"id": doc_id, ...}) # succeeds (create)
await container.read_item(item=doc_id, partition_key=pk) # 💥 401 UnauthorizedCorrect (safe, bounded IDs):
// Use GUIDs or short alphanumeric identifiers
public class Document
{
public string Id { get; set; }
public string Category { get; set; }
public string Title { get; set; }
}
// Option 1: GUID-based IDs (always safe, always unique)
var doc = new Document
{
Id = Guid.NewGuid().ToString(), // "a1b2c3d4-e5f6-..."
Category = "reports",
Title = "Q1 Report"
};
// Option 2: Compact, deterministic IDs from business keys
var doc2 = new Document
{
Id = $"report-{tenantId}-{DateTime.UtcNow:yyyyMMdd}-{sequenceNum}",
Category = "reports",
Title = "Q1 Report"
};
// Option 3: Base64-encode when you must derive from non-ASCII data
var rawId = "レポート_2026_データ";
var doc3 = new Document
{
Id = Convert.ToBase64String(Encoding.UTF8.GetBytes(rawId))
.Replace('/', '_').Replace('+', '-'), // URL-safe Base64
Category = "reports",
Title = rawId // Keep original value as a property
};# Correct: Use ':' or '_' or '-' as composite-id separators
doc_id = f"best:{player_id}:{week}:{region}" # ✅ works on all operations
await container.upsert_item(body={"id": doc_id, ...})
await container.read_item(item=doc_id, partition_key=pk) # ✅ 200 OKKey constraints:
- Max length: 1,023 bytes
- Forbidden characters:
#,?,/, and\are not allowed —#and?cause 401 Unauthorized on read/update/delete;/and\cause routing failures - Best practice: Use only alphanumeric ASCII characters (
a-z,A-Z,0-9,-,_) and:as a separator - Why: URL-reserved characters break REST auth signing across all SDKs in Gateway mode; some SDK versions, Azure Data Factory, Spark connector, and Kafka connector have additional issues with non-alphanumeric IDs
- Encode non-ASCII IDs with Base64 + custom encoding if needed for interoperability
See also: partition-synthetic-keys for synthetic-key construction patterns.
Reference: Azure Cosmos DB service quotas - Per-item limits | Access control on Cosmos DB resources
Handle JSON Serialization Correctly for Cosmos DB
Cosmos DB stores documents as JSON. Every field on an entity that must be persisted needs to be serializable. Incorrect use of @JsonIgnore, missing constructors, or incompatible field types (like BigDecimal on JDK 17+) cause silent data loss or runtime failures.
Incorrect (common serialization mistakes):
@Container(containerName = "users")
public class User {
@Id
private String id;
@PartitionKey
private String partitionKey = "user";
private String login;
@JsonIgnore // ❌ WRONG: Password will NOT be saved to Cosmos DB
private String password;
@JsonIgnore // ❌ WRONG: Authorities will NOT be saved to Cosmos DB
private Set<String> authorities = new HashSet<>();
private BigDecimal accountBalance; // ❌ Fails on JDK 17+ with reflection errors
}Correct (proper serialization for Cosmos DB):
@JsonIgnoreProperties(ignoreUnknown = true) // ✅ Ignore Cosmos DB system metadata (_rid, _self, _etag, _ts, _lsn)
@Container(containerName = "users")
public class User {
@Id
private String id;
@PartitionKey
private String partitionKey = "user";
private String login;
// ✅ No @JsonIgnore — field is persisted to Cosmos DB
private String password;
// ✅ Use @JsonProperty for explicit field naming, NOT @JsonIgnore
@JsonProperty("authorities")
private Set<String> authorities = new HashSet<>();
// ✅ Use Double instead of BigDecimal for JDK 17+ compatibility
private Double accountBalance;
}Rule 1: Never `@JsonIgnore` persisted fields
@JsonIgnore prevents a field from being written to Cosmos DB. This is the #1 cause of "Cannot pass null or empty values to constructor" errors after reading a document back:
// ❌ Data loss: field is not stored in Cosmos
@JsonIgnore
private String password;
// ✅ Field is stored in Cosmos
private String password;
// ✅ Rename in JSON but still store
@JsonProperty("pwd")
private String password;Only use `@JsonIgnore` on transient/computed fields that should NOT be stored in Cosmos DB (e.g., hydrated relationship objects — see model-relationship-references).
Rule 2: BigDecimal fails on JDK 17+
Java 17+ module system restricts reflection access to BigDecimal internal fields during Jackson serialization:
Unable to make field private final java.math.BigInteger
java.math.BigDecimal.intVal accessibleSolutions (in order of preference):
1. Replace with `Double` — sufficient for most use cases:
private Double amount; // Instead of BigDecimal2. Replace with `String` — for high-precision requirements:
private String amount; // Store "1500.00"
public BigDecimal getAmountAsBigDecimal() {
return new BigDecimal(amount);
}3. Add JVM argument — if BigDecimal must be kept:
--add-opens java.base/java.math=ALL-UNNAMEDRule 3: Provide a default constructor
Cosmos DB deserialization requires a no-arg constructor. If you add parameterized constructors, always keep the default:
@Container(containerName = "items")
public class Item {
// ✅ Default constructor required for deserialization
public Item() {}
public Item(String name, Double price) {
this.name = name;
this.price = price;
}
}Rule 4: Store complex objects as simple types
For complex Cosmos DB compatibility, prefer simple types over JPA entity references:
// ❌ Complex nested entity — may cause serialization issues
private Set<Authority> authorities;
// ✅ Simple string set — reliable serialization
private Set<String> authorities;Convert between simple and complex types in the service layer, not in the entity.
Rule 5: Ignore unknown properties from Cosmos DB system metadata
Cosmos DB documents contain system metadata fields (_rid, _self, _etag, _ts, _lsn) that are not part of your entity model. Without handling these, Jackson throws UnrecognizedPropertyException when deserializing documents — during point reads, queries, and Change Feed processing:
com.fasterxml.jackson.databind.exc.UnrecognizedPropertyException:
Unrecognized field "_lsn" (class PlayerProfile), not marked as ignorableOption A (recommended): Configure globally at the ObjectMapper or Spring Boot level
This handles unknown properties for all entity classes without requiring per-class annotations:
// ✅ Global ObjectMapper configuration — covers all Cosmos DB entities
ObjectMapper mapper = new ObjectMapper();
mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);For Spring Boot applications, add to application.properties:
# ✅ Spring Boot global setting
spring.jackson.deserialization.fail-on-unknown-properties=falseOption B: Annotate each entity class with `@JsonIgnoreProperties(ignoreUnknown = true)`
If global configuration is not possible, annotate every Cosmos DB entity class:
// ❌ Fails on system metadata fields from Cosmos DB
@Container(containerName = "players")
public class PlayerProfile {
@Id
private String id;
private String playerId;
private int score;
}
// ✅ Ignores unknown fields — safe for all Cosmos DB reads
@JsonIgnoreProperties(ignoreUnknown = true)
@Container(containerName = "players")
public class PlayerProfile {
@Id
private String id;
private String playerId;
private int score;
}⚠️ This annotation must be on every entity class. If you miss even one, deserialization of that entity will fail when Cosmos DB system metadata is present.
Reference: Jackson annotations guide
Stay Within 128-Level Nesting Depth Limit
Azure Cosmos DB allows a maximum of 128 levels of nesting for embedded objects and arrays. While 128 is generous, recursive or auto-generated structures can exceed this limit unexpectedly.
Incorrect (risk of exceeding nesting limit):
// Anti-pattern 1: Recursive tree stored as deeply nested JSON
public class TreeNode
{
public string Id { get; set; }
public string Name { get; set; }
// Recursive children - each level adds nesting depth
public List<TreeNode> Children { get; set; }
}
// A category hierarchy with 130+ levels will fail on write
var root = BuildDeepTree(depth: 150); // Exceeds 128 levels!
await container.CreateItemAsync(root);
// Microsoft.Azure.Cosmos.CosmosException: Document nesting depth exceeds limit
// Anti-pattern 2: Deeply nested auto-generated JSON from ORMs
// Serializing complex object graphs without cycle detection
var entity = LoadEntityWithAllRelations(); // Lazy-loaded relations
var json = JsonSerializer.Serialize(entity); // May create deep nestingCorrect (bounded nesting depth):
// Solution 1: Flatten deep hierarchies using path-based approach
public class CategoryNode
{
public string Id { get; set; }
public string Name { get; set; }
public string ParentId { get; set; }
// Materialized path captures hierarchy without nesting
public string Path { get; set; } // e.g., "/root/electronics/phones/android"
public int Depth { get; set; }
// Only store immediate children IDs, not nested objects
public List<string> ChildIds { get; set; }
}
// Each node is a flat document, hierarchy expressed via Path and ParentId
var node = new CategoryNode
{
Id = "cat-android",
Name = "Android",
ParentId = "cat-phones",
Path = "/root/electronics/phones/android",
Depth = 3,
ChildIds = new List<string> { "cat-samsung", "cat-pixel" }
};// Solution 2: Cap nesting depth when building recursive structures
public class TreeNode
{
public string Id { get; set; }
public string Name { get; set; }
public List<TreeNode> Children { get; set; }
}
// Limit nesting at serialization time
public static TreeNode TruncateTree(TreeNode node, int maxDepth, int currentDepth = 0)
{
if (currentDepth >= maxDepth || node.Children == null)
{
node.Children = null; // Stop nesting here
return node;
}
node.Children = node.Children
.Select(c => TruncateTree(c, maxDepth, currentDepth + 1))
.ToList();
return node;
}
// Keep well under 128 - aim for practical limits like 10-20
var safeTree = TruncateTree(root, maxDepth: 20);
await container.CreateItemAsync(safeTree);Key points:
- Maximum nesting depth is 128 levels for embedded objects/arrays
- Recursive data structures (trees, graphs) are the most common cause of violations
- Prefer flat representations with references (parent IDs, materialized paths) for deep hierarchies
- If nesting is required, enforce a practical depth cap well under 128
Understand IEEE 754 Numeric Precision Limits
Azure Cosmos DB stores numbers using IEEE 754 double-precision 64-bit format. This means integers larger than 2^53 and decimals requiring more than ~15-17 significant digits will lose precision silently.
Incorrect (precision loss with large numbers):
// Anti-pattern 1: Storing large integers that exceed safe range
public class Transaction
{
public string Id { get; set; }
// 64-bit integer IDs from external systems - DANGER!
public long ExternalTransactionId { get; set; } // e.g., 9007199254740993
// Values > 9,007,199,254,740,992 (2^53) lose precision
// 9007199254740993 becomes 9007199254740992 silently!
}
// Anti-pattern 2: Financial calculations requiring exact decimal precision
public class Invoice
{
public string Id { get; set; }
// Double can't represent all decimal values exactly
public double Amount { get; set; } // 0.1 + 0.2 != 0.3 in IEEE 754
public double TaxRate { get; set; }
}
// 99999999999999.99 stored as double may become 99999999999999.98Correct (preserving precision):
// Solution 1: Store large integers and precise decimals as strings
public class Transaction
{
public string Id { get; set; }
// Store large IDs as strings to preserve all digits
[JsonPropertyName("externalTransactionId")]
public string ExternalTransactionId { get; set; } // "9007199254740993"
}
// Solution 2: Use string representation for financial amounts
public class Invoice
{
public string Id { get; set; }
// Store monetary values as strings with fixed decimal places
[JsonPropertyName("amount")]
public string Amount { get; set; } // "99999999999999.99"
[JsonPropertyName("taxRate")]
public string TaxRate { get; set; } // "0.0825"
// Parse in application code for calculations
public decimal GetAmount() => decimal.Parse(Amount);
public decimal GetTaxRate() => decimal.Parse(TaxRate);
}// Solution 3: Store amounts as integer minor units (cents, paise, etc.)
public class Payment
{
public string Id { get; set; }
// Store $199.99 as 19999 cents - always safe as integer within 2^53
public long AmountInCents { get; set; }
public string Currency { get; set; } // "USD"
// Helper for display
public decimal GetDisplayAmount() => AmountInCents / 100m;
}
var payment = new Payment
{
Id = Guid.NewGuid().ToString(),
AmountInCents = 19999, // $199.99
Currency = "USD"
};
await container.CreateItemAsync(payment);Key points:
- Safe integer range: -2^53 to 2^53 (±9,007,199,254,740,992)
- Significant digits: ~15-17 decimal digits of precision
- Store large integers (snowflake IDs, blockchain hashes) as strings
- Store financial/monetary values as strings or integer minor units (cents)
- Numbers within the safe range (most counters, ages, quantities) are fine as-is
Reference Data When Items Grow Large
Use document references instead of embedding when embedded data would make items too large, or when embedded data changes independently.
Incorrect (embedded array grows unbounded):
// Anti-pattern: blog post with all comments embedded
public class BlogPost
{
public string Id { get; set; }
public string Title { get; set; }
public string Content { get; set; }
// This array can grow forever - will eventually hit 2MB limit!
public List<Comment> Comments { get; set; } // Could be thousands
}
// Eventually fails when document exceeds 2MB
await container.UpsertItemAsync(blogPost);
// RequestEntityTooLarge exceptionCorrect (reference pattern for unbounded relationships):
// Blog post document (bounded size)
public class BlogPost
{
public string Id { get; set; }
public string PostId { get; set; } // Partition key
public string Type { get; set; } = "post";
public string Title { get; set; }
public string Content { get; set; }
public int CommentCount { get; set; } // Denormalized count
}
// Separate comment documents (same partition for efficient queries)
public class Comment
{
public string Id { get; set; }
public string PostId { get; set; } // Partition key - same as post
public string Type { get; set; } = "comment";
public string AuthorId { get; set; }
public string Text { get; set; }
public DateTime CreatedAt { get; set; }
}
// Query comments within same partition - efficient!
var comments = container.GetItemQueryIterator<Comment>(
new QueryDefinition("SELECT * FROM c WHERE c.postId = @postId AND c.type = 'comment' ORDER BY c.createdAt DESC")
.WithParameter("@postId", postId),
requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey(postId) }
);Use references when:
- Embedded data is unbounded (arrays that grow)
- Embedded data changes frequently/independently
- You need to query embedded data separately
Reference: Model document data
Use ID References with Transient Hydration for Document Relationships
Cosmos DB has no cross-document JOINs. When entities need to reference each other, store relationship IDs as persistent fields and use transient (@JsonIgnore) properties for hydrated object access. A service layer populates the transient properties before rendering.
This pattern goes beyond basic referencing (see model-reference-large) by providing a complete strategy for applications that need both document storage efficiency and runtime object graphs (e.g., web apps with templates, REST APIs returning nested objects).
Incorrect (JPA relationship annotations — no Cosmos equivalent):
@Entity
public class Vet {
@Id
private Integer id;
@ManyToMany
@JoinTable(name = "vet_specialties")
private List<Specialty> specialties; // JPA manages this relationship
}Also incorrect (embedding unbounded relationships directly):
@Container(containerName = "vets")
public class Vet {
@Id
private String id;
// ❌ Stores full Specialty objects — grows unbounded, duplicates data
private List<Specialty> specialties;
}Correct (ID references + transient hydration):
@Container(containerName = "vets")
public class Vet {
@Id
@GeneratedValue
private String id;
@PartitionKey
private String partitionKey = "vet";
private String firstName;
private String lastName;
// ✅ Persisted to Cosmos DB — stores only IDs
private List<String> specialtyIds = new ArrayList<>();
// ✅ Transient — NOT stored in Cosmos DB, populated by service layer
@JsonIgnore
private List<Specialty> specialties = new ArrayList<>();
// Both getters needed
public List<String> getSpecialtyIds() { return specialtyIds; }
public List<Specialty> getSpecialties() { return specialties; }
// Count methods should use the transient list when populated,
// fall back to ID list
public int getNrOfSpecialties() {
return specialties.isEmpty() ? specialtyIds.size() : specialties.size();
}
}When to use this pattern:
| Scenario | Approach |
|---|---|
| Related data always read together, bounded size | Embed (see model-embed-related) |
| Related data read independently, unbounded | ID reference (this pattern) |
| UI/template needs object access to related data | ID reference + transient hydration (this pattern) |
| REST API returns nested objects | ID reference + transient hydration (this pattern) |
| Related data rarely accessed after write | ID reference only (no transient needed) |
The transient hydration flow:
1. Entity stores List<String> specialtyIds (persisted) 2. Service layer reads the entity, then looks up each ID to get full objects 3. Service populates List<Specialty> specialties (transient) 4. Controller/template accesses vet.getSpecialties() as if it were a normal object graph
Important: @JsonIgnore is correct here because transient properties should NOT be stored in Cosmos DB — they are populated on read by the service layer. This is the one legitimate use of @JsonIgnore (see model-json-serialization for when NOT to use it).
Reference: Data modeling in Azure Cosmos DB
Version Your Document Schemas
Include schema version in documents to handle evolution gracefully. This enables safe migrations and backward-compatible reads.
For multi-entity or event-heavy workloads, apply this to every persisted document type (for example: metadata documents, events, telemetry records, and denormalized read models), not just top-level business entities.
Use a consistent field name such as schemaVersion (camelCase) and set it at write time so raw document checks, migrations, and mixed-version readers all work reliably.
Incorrect (no version tracking):
// Original schema
public class UserV1
{
public string Id { get; set; }
public string Name { get; set; } // Later split into FirstName + LastName
public string Address { get; set; } // Later becomes Address object
}
// After schema change, old documents break deserialization
public class User
{
public string Id { get; set; }
public string FirstName { get; set; } // Null for old docs!
public string LastName { get; set; } // Null for old docs!
public Address Address { get; set; } // Deserialization fails!
}Correct (versioned documents):
public abstract class UserBase
{
public string Id { get; set; }
public int SchemaVersion { get; set; }
}
public class UserV1 : UserBase
{
public string Name { get; set; }
public string Address { get; set; }
}
public class UserV2 : UserBase
{
public string FirstName { get; set; }
public string LastName { get; set; }
public AddressV2 Address { get; set; }
}
// Read with version handling
public async Task<User> GetUserAsync(string id, string partitionKey)
{
var response = await container.ReadItemStreamAsync(id, new PartitionKey(partitionKey));
using var doc = await JsonDocument.ParseAsync(response.Content);
var version = doc.RootElement.GetProperty("schemaVersion").GetInt32();
return version switch
{
1 => MigrateV1ToV2(JsonSerializer.Deserialize<UserV1>(doc)),
2 => JsonSerializer.Deserialize<UserV2>(doc),
_ => throw new NotSupportedException($"Unknown schema version: {version}")
};
}
// Background migration using Change Feed
public async Task MigrateUserDocuments()
{
var changeFeed = container.GetChangeFeedProcessorBuilder<UserV1>("migration", HandleChanges)
.WithInstanceName("migrator")
.WithStartTime(DateTime.MinValue.ToUniversalTime())
.Build();
await changeFeed.StartAsync();
}Always increment version when:
- Adding required fields
- Changing field types
- Restructuring nested objects
Reference: Schema evolution in Cosmos DB
Use Type Discriminators for Polymorphic Data
Use a single Cosmos DB container to co-locate related parent/child or different entity types when:
- similar entities are written and read together, share a natural or business partition key, require a simple transactional boundary, and do not exceed Cosmos DB partition key limits.
When storing multiple entity types in the same container, include a type discriminator field for efficient filtering and deserialization.
Incorrect (no type discrimination):
// Multiple types in same container without clear identification
public class Order { public string Id { get; set; } /* ... */ }
public class Customer { public string Id { get; set; } /* ... */ }
public class Product { public string Id { get; set; } /* ... */ }
// How do you query just orders? Full scan!
var allItems = await container.GetItemQueryIterator<dynamic>("SELECT * FROM c").ReadNextAsync();
var orders = allItems.Where(x => x.orderDate != null); // Brittle, inefficientCorrect (explicit type discriminator):
// Base class with type discriminator
public abstract class BaseEntity
{
[JsonPropertyName("id")]
public string Id { get; set; }
[JsonPropertyName("type")]
public abstract string Type { get; }
[JsonPropertyName("partitionKey")]
public string PartitionKey { get; set; }
}
public class Order : BaseEntity
{
public override string Type => "order";
public DateTime OrderDate { get; set; }
public List<OrderItem> Items { get; set; }
}
public class Customer : BaseEntity
{
public override string Type => "customer";
public string Email { get; set; }
public string Name { get; set; }
}
public class Product : BaseEntity
{
public override string Type => "product";
public string Name { get; set; }
public decimal Price { get; set; }
}
// Efficient queries by type - uses index!
var ordersQuery = new QueryDefinition(
"SELECT * FROM c WHERE c.type = @type AND c.partitionKey = @pk")
.WithParameter("@type", "order")
.WithParameter("@pk", customerId);
// Polymorphic deserialization
public static BaseEntity DeserializeEntity(JsonDocument doc)
{
var type = doc.RootElement.GetProperty("type").GetString();
return type switch
{
"order" => doc.Deserialize<Order>(),
"customer" => doc.Deserialize<Customer>(),
"product" => doc.Deserialize<Product>(),
_ => throw new InvalidOperationException($"Unknown type: {type}")
};
}Benefits:
- Efficient filtering with indexed
typefield - Clear deserialization logic
- Self-documenting data structure
When NOT to Use Multi-Entity Containers :
- Independent throughput requirements → Use separate containers
- Different scaling patterns → Use separate containers
- Different indexing needs → Use separate containers
- Distinct change feed processing requirements → Use separate containers
- Low access correlation (<20%) → Use separate containers
Single-Container Anti-Patterns :
- "Everything container" → Complex filtering → Difficult analytics
- One throughput allocation for all entity types
- One change feed with mixed events requiring filtering
- Difficult to maintain and onboard new developers
Reference: Model data in Cosmos DB
Integrate Azure Monitor
Enable Azure Monitor integration for comprehensive visibility into Cosmos DB performance, availability, and cost metrics.
Incorrect (no monitoring integration):
// Flying blind - no visibility into:
// - RU consumption trends
// - Latency patterns
// - Throttling events
// - Availability issues
// - Cost attribution
// Application runs but you only know about problems from user complaintsCorrect (Azure Monitor integration):
// Step 1: Enable diagnostic settings (Azure Portal, CLI, or ARM)
{
"type": "Microsoft.DocumentDB/databaseAccounts/providers/diagnosticSettings",
"properties": {
"logs": [
{
"category": "DataPlaneRequests",
"enabled": true,
"retentionPolicy": { "enabled": true, "days": 30 }
},
{
"category": "QueryRuntimeStatistics",
"enabled": true
},
{
"category": "PartitionKeyStatistics",
"enabled": true
},
{
"category": "PartitionKeyRUConsumption",
"enabled": true
}
],
"metrics": [
{
"category": "Requests",
"enabled": true
}
],
"workspaceId": "/subscriptions/.../workspaces/my-workspace"
}
}// Step 2: Key metrics to monitor in Azure Monitor
// a) Normalized RU Consumption (% of provisioned used)
// Alert if > 90% sustained - indicates need to scale
// b) Total Requests by Status Code
// Alert on 429s (throttling) and 5xx (errors)
// c) Server Side Latency
// Track P50, P99 for performance baselines
// d) Data Usage
// Monitor storage growth
// e) Availability
// Alert on availability drops below 99.99%// Step 3: Application Insights integration
public static class CosmosDbTelemetry
{
public static void ConfigureWithAppInsights(
CosmosClientOptions options,
TelemetryClient telemetry)
{
// Track all operations as dependencies
options.CosmosClientTelemetryOptions = new CosmosClientTelemetryOptions
{
DisableDistributedTracing = false // Enable distributed tracing
};
// Custom handler for detailed telemetry
options.CustomHandlers.Add(new AppInsightsHandler(telemetry));
}
}
public class AppInsightsHandler : RequestHandler
{
private readonly TelemetryClient _telemetry;
public override async Task<ResponseMessage> SendAsync(
RequestMessage request,
CancellationToken cancellationToken)
{
using var operation = _telemetry.StartOperation<DependencyTelemetry>(
"CosmosDB",
request.RequestUri.ToString());
operation.Telemetry.Type = "Azure DocumentDB";
operation.Telemetry.Target = request.RequestUri.Host;
var response = await base.SendAsync(request, cancellationToken);
operation.Telemetry.Success = response.IsSuccessStatusCode;
operation.Telemetry.ResultCode = ((int)response.StatusCode).ToString();
operation.Telemetry.Properties["RU"] = response.Headers.RequestCharge.ToString();
return response;
}
}// Useful Log Analytics queries
// RU consumption by operation
AzureDiagnostics
| where ResourceProvider == "MICROSOFT.DOCUMENTDB"
| summarize TotalRU = sum(requestCharge_s),
AvgRU = avg(requestCharge_s),
Count = count()
by OperationName
| order by TotalRU desc
// Slow queries
AzureDiagnostics
| where ResourceProvider == "MICROSOFT.DOCUMENTDB"
| where duration_s > 100 // > 100ms
| project TimeGenerated, OperationName, duration_s,
requestCharge_s, partitionKey_s, querytext_s
// Storage growth trend
AzureMetrics
| where ResourceProvider == "MICROSOFT.DOCUMENTDB"
| where MetricName == "DataUsage"
| summarize StorageGB = max(Total) / 1073741824 by bin(TimeGenerated, 1d)
| order by TimeGeneratedEssential alerts to configure: 1. Throttling (429s) > 0 2. Normalized RU > 90% for 5 min 3. Availability < 99.99% 4. P99 latency > threshold 5. Storage approaching limits
Reference: Monitor Azure Cosmos DB
Related skills
How it compares
Use cosmosdb-best-practices for Azure Cosmos DB-specific modeling and RU tuning rather than generic SQL or MongoDB guidance skills.
FAQ
What Cosmos DB topics does cosmosdb-best-practices cover?
cosmosdb-best-practices covers NoSQL data modeling, partition keys, RU optimization, point reads, cross-partition queries, CosmosClient singleton usage, change feed, bulk operations, vector and full-text search, hierarchical partition keys, global distribution, autoscale, and ind
When should cosmosdb-best-practices not be used?
cosmosdb-best-practices should not be used for PostgreSQL or other databases outside Azure Cosmos DB. The skill is scoped to Cosmos DB NoSQL performance, cost, and scalability patterns during code writing and review.