db-reservas solves the problem of a seat not being sold twice very well, but it is a poor home for Contoso Airlines' fare catalog. A "Flex Business Long Haul" fare has change conditions, baggage allowance, points accrual, per-leg penalties and seasonal restrictions; a "Basic Domestic" fare has four fields. Normalizing that means twenty tables and queries with ten joins to answer something as simple as "give me this fare in full". On top of that, the catalog is read thousands of times a minute from the website and from the Availability API, written twice a day, and there are customers in Europe and in the Americas waiting for the answer.

That is exactly the territory of Azure Cosmos DB: a globally distributed NoSQL database, with guaranteed single-digit millisecond latency, elastic scaling and a flexible schema. This lesson deploys it for the fare catalog and the passenger profiles, and takes its time over the two decisions people get wrong: the partition key and the consistency level.

Important cost warning: Cosmos DB can be cheap or extremely expensive depending on how it is configured. A container with 400 provisioned RU/s costs around €23 a month and bills even if nobody uses it; enabling multi-region writes multiplies the cost per region. To learn, use the free tier (1,000 RU/s and 25 GB at no cost, one account per subscription) or serverless mode, and delete the account when you finish.

Contents

  1. What problem it solves that a relational database does not
  2. The available APIs and which one to choose
  3. Resource model: account, database, container and item
  4. The partition key: the most irreversible decision
  5. Request units (RU/s) and capacity models
  6. The five consistency levels
  7. Global distribution and multi-region writes
  8. Deployment with Azure CLI
  9. Fare documents: inserting and querying
  10. Indexing policy and time to live
  11. The change feed as the basis for integration
  12. Continuous backup and spend control
  13. Common Mistakes and Tips
  14. Exercises
  15. Conclusion

  1. What problem it solves that a relational database does not

Azure SQL Database scales vertically: if you need more, you move up a tier, until you hit the machine's ceiling. Cosmos DB scales horizontally and transparently: it spreads the data across physical partitions that the service adds on its own, without you doing anything, and it can replicate those partitions to as many regions as you want with one click. The differences that matter when deciding:

Azure SQL Database Azure Cosmos DB
Schema Fixed, validated by the engine Flexible: every document can differ
Scaling Vertical, with a ceiling Horizontal and transparent, practically without a ceiling
Guaranteed latency No latency SLA Single-digit ms for reads and writes by key
Global distribution Replicas configured by hand Adding a region is a one-step operation
Queries across entities Arbitrary, powerful joins Only within the document; joins across containers, no
Transactions ACID across the whole database ACID within one logical partition
Billing vCores or DTU hours Request units consumed

The conclusion is not that one is better: it is that they answer different questions. If your query crosses entities nobody anticipated, you want SQL. If your query is "give me this document by its key, fast, from any continent, and never mind how many millions there are", you want Cosmos DB.

  1. The available APIs and which one to choose

Cosmos DB is one engine with several faces, compatible with existing protocols. The API is chosen when the account is created and cannot be changed afterwards:

API What it speaks Choose it when…
NoSQL (formerly SQL/Core) JSON with SQL-like query syntax It is a new project: it gets every new feature first and has the best documentation and SDKs
MongoDB The MongoDB protocol You are migrating an application that already uses MongoDB and you do not want to touch the code
Cassandra CQL You are migrating an existing Cassandra workload
Gremlin Graph queries The model is a network: routes, relationships, recommendations
Table The Azure Table Storage protocol You need Table Storage but with guaranteed latency and indexes

Contoso chooses the NoSQL API for three specific reasons: it is new development with nothing to migrate, new features always land on this API first, and Diego Salas's team already writes SQL queries, so the syntax feels familiar from day one.

  1. Resource model: account, database, container and item

The hierarchy has four levels and each one decides something different:

  • Account (cosmos-contoso-tarifas-pro): the boundary for the API, the regions, the default consistency and the access keys. It is the resource that shows up in the portal and on the bill.
  • Database (catalogo): a logical grouping; it can have throughput shared by all of its containers.
  • Container (tarifas, perfiles): the real unit of scaling and distribution. This is where the partition key, the throughput and the indexing policy are defined. It is the conceptual equivalent of a table, but without a schema.
  • Item: the JSON document. Each one has a mandatory id and its partition key value; the id + partition key pair uniquely identifies an item.

  1. The partition key: the most irreversible decision

Cosmos DB groups the items that share a partition key value into a logical partition, and spreads those logical partitions across physical partitions of up to 50 GB and 10,000 RU/s. All of the scalability depends on that spread being even. A good partition key satisfies three things at once:

  1. High cardinality: many distinct values, so that there are many logical partitions.
  2. Even distribution of requests and data, with no values far more active than others.
  3. It appears in most queries, so that they resolve within a single partition.

Applied to Contoso's fare catalog:

Candidate Cardinality Distribution Does it appear in the queries? Verdict
/origenDestino ("BCN-CDG") High: ~600 routes Even; no route concentrates the bulk Yes: searches are always by route Correct
/fecha Medium Terrible: all of today's traffic lands on today's partition Yes Hot partition
/tipoTarifa ("basica", "flex") Low: 5 values Very uneven and up against the 50 GB per-partition ceiling Yes Unusable
/id Maximum Perfect No: hardly any query filters by id Every query goes cross-partition

The /fecha case is worth pausing on, because it is the industry's most repeated mistake. It sounds reasonable: fares are queried by flight date. But 90% of the queries refer to the next 30 days, so a handful of logical partitions receive almost all the traffic while the rest sleep. That imbalance is called a hot partition: even if the container has 10,000 RU/s, each physical partition can only use its share, so rate-limit errors (code 429) start arriving while the container as a whole sits idle.

And why it is irreversible: a container's partition key cannot be changed. Fixing it forces you to create a new container and migrate all the data, with the application writing to both during the transition. That is why this decision is made by writing the queries first, not afterwards.

If no single property does the job on its own, you use a synthetic key: concatenating two fields ("BCN-CDG_2026-07") to raise the cardinality while keeping the usual filter.

  1. Request units (RU/s) and capacity models

A request unit (RU) is Cosmos DB's currency: it normalizes the CPU, memory and I/O cost of each operation. The reference point is that reading a 1 KB document by id costs 1 RU. A write of that same document costs about 5 RU, and a query that walks over many documents can cost hundreds. What you contract is a rate: RU per second.

Model How it bills When to use it
Standard provisioned You pay for the contracted RU/s 24 hours a day Sustained, predictable load
Autoscale You set a maximum; it scales between 10% and 100% and bills each hour's peak Variable load; it costs 50% more per RU but usually works out cheaper
Serverless You pay only for the RU consumed Development, testing, sporadic workloads
Free tier 1,000 RU/s and 25 GB at no cost Learning and prototyping (one account per subscription)

The best thing about this model is that every response tells you what it cost, in the x-ms-request-charge header. It is the way to optimize without guessing:

# The SDK and the portal show the charge for each operation. Two example queries:
#   SELECT * FROM c WHERE c.origenDestino = 'BCN-CDG'   ->  2.89 RU  (one partition)
#   SELECT * FROM c WHERE c.equipajeIncluido = true     -> 48.60 RU  (every partition)

Those two figures tell the whole story: the first query carries the partition key and resolves within a single partition; the second is a cross-partition query and costs 17 times more. To estimate the throughput you need, just multiply: if Contoso expects 900 reads per second at 3 RU each, it needs about 2,700 RU/s plus headroom for the peaks.

  1. The five consistency levels

This is where the CAP theorem from lesson 03-01 becomes a configuration setting. Cosmos DB offers five levels, from strictest to most relaxed, and they can be relaxed per request from the SDK:

Level What it guarantees Read latency Read cost in RU Example at Contoso
Strong Everyone always reads the last committed write The highest; it does not allow multi-region writes Double Seat inventory, if it lived here
Bounded staleness A lag bounded by a number of versions or by time Medium Double Seat quotas per fare, with a 5-second margin
Session (default) A given client always sees its own writes Low Normal Passenger profiles: you change your seat and you see it
Consistent prefix You never see writes out of order, though you may see them late Low Normal The change history of a fare
Eventual It converges in the end; no guaranteed order The lowest Normal Fare catalog: it changes twice a day

Applied to the seat inventory example: with eventual consistency, two customers in Madrid and in Bogotá could both read that 2 seats are left when in fact there is only 1, and you would sell a seat that does not exist. That is why Contoso does not put the seat inventory in Cosmos DB: it lives in db-reservas with its CHECK constraint, as decided in 03-01. What does go into Cosmos DB is perfectly happy with session consistency, which is the default level and the best balance for 90% of applications.

  1. Global distribution and multi-region writes

Adding a region to a Cosmos DB account replicates all the data and lets clients read from the nearest point. There are two configurations that differ greatly in cost and in consequences:

  • Single-region writes: one region is the write region and the rest are read regions. Simple, with no conflicts possible and at half the cost.
  • Multi-region writes: you can write in any region, with minimal write latency everywhere. The price is double: it costs more per RU, and conflicts appear when two regions modify the same document, which have to be resolved (by default the last write wins according to a timestamp, or you supply your own procedure).

Contoso's decision is consistent with the whole course: writes only in West Europe, reads also in North Europe. Fares are published by a single team from Barcelona, so multi-region writes would solve a non-existent problem in exchange for doubling the bill and adding conflicts.

flowchart LR
    subgraph WE[West Europe - write]
        C1[(Cosmos DB<br/>write region)]
    end
    subgraph NE[North Europe - read only]
        C2[(Read replica)]
    end
    APP[App Service<br/>Contoso Bookings] -->|writes and reads| C1
    API[Availability API] -->|reads| C1
    RESP[Backup / reports] -->|reads| C2
    C1 -->|continuous replication| C2

  1. Deployment with Azure CLI

RG="rg-contoso-reservas-pro"
ACCOUNT="cosmos-contoso-tarifas-pro"    # lowercase, unique across all of Azure

az cosmosdb create --name $ACCOUNT --resource-group $RG \
  --locations regionName=westeurope failoverPriority=0 isZoneRedundant=true \
  --locations regionName=northeurope failoverPriority=1 isZoneRedundant=false \
  --default-consistency-level Session \  # the right balance for 90% of cases
  --enable-multiple-write-locations false \
  --enable-automatic-failover true \
  --backup-policy-type Continuous \
  --tags entorno=produccion proyecto=contoso-reservas centro-coste=CC-1042 [email protected]

az cosmosdb sql database create -a $ACCOUNT -g $RG -n catalogo

# Fares container: autoscale up to 4000 RU/s
az cosmosdb sql container create -a $ACCOUNT -g $RG -d catalogo -n tarifas \
  --partition-key-path "/origenDestino" \  # IRREVERSIBLE DECISION
  --max-throughput 4000                    # autoscales between 400 and 4000 RU/s

For the development environment, the account is created with --enable-free-tier true (only one per subscription) or in serverless mode with --capabilities EnableServerless, which does not support autoscale but only charges for what is consumed. Notice that --partition-key-path is the only parameter in this script that you will not be able to change later.

  1. Fare documents: inserting and querying

This is what a fare document looks like. Everything that in a relational model would be five tables is nested here and read in one go:

{
  "id": "TAR-BCN-CDG-FLEX-2026",
  "origenDestino": "BCN-CDG",
  "tipoTarifa": "flex",
  "moneda": "EUR",
  "precioBase": 214.50,
  "condiciones": {
    "cambiosPermitidos": true,
    "penalizacionCambio": 0,
    "reembolsable": true,
    "equipajeFacturado": { "piezas": 2, "kgPorPieza": 23 }
  },
  "temporadas": [
    { "desde": "2026-06-15", "hasta": "2026-09-15", "recargo": 38.00 },
    { "desde": "2026-12-20", "hasta": "2027-01-07", "recargo": 45.00 }
  ],
  "puntosFidelidad": 850,
  "vigenteHasta": "2026-12-31"
}

That same document as relational rows would require tables for fares, conditions, baggage and seasons, plus three joins to reconstruct it. Here it is one read by key: 1 RU.

az cosmosdb sql container query -a $ACCOUNT -g $RG -d catalogo -n tarifas \
  --query-text "SELECT * FROM c WHERE c.origenDestino = 'BCN-CDG' AND c.tipoTarifa = 'flex'"

And the typical queries, in the NoSQL API's dialect, where c is each document in the container:

-- Efficient: it carries the partition key, it resolves within ONE partition
SELECT c.id, c.precioBase, c.condiciones.equipajeFacturado.piezas
FROM c
WHERE c.origenDestino = 'BCN-CDG' AND c.vigenteHasta >= '2026-08-14';

-- Query over a nested property of an array
SELECT c.id, t.recargo
FROM c JOIN t IN c.temporadas
WHERE c.origenDestino = 'BCN-CDG' AND t.desde <= '2026-07-20' AND t.hasta >= '2026-07-20';

-- INEFFICIENT: no partition key, it queries every partition
SELECT * FROM c WHERE c.puntosFidelidad > 500;

The JOIN in the second query does not join containers — that does not exist in Cosmos DB — it unfolds an array inside the document itself. It is the conceptual difference that people coming from the relational world find hardest.

  1. Indexing policy and time to live

By default, Cosmos DB indexes every property of every document. That is convenient at the start and expensive as the volume grows: every index consumes RU on every write. In a write-heavy container, tuning the policy is one of the adjustments that saves the most money.

{
  "indexingMode": "consistent",
  "includedPaths": [
    { "path": "/origenDestino/?" },
    { "path": "/tipoTarifa/?" },
    { "path": "/vigenteHasta/?" }
  ],
  "excludedPaths": [
    { "path": "/condiciones/*" },
    { "path": "/temporadas/*" }
  ]
}

You index what appears in WHERE or ORDER BY clauses and exclude what is only read as part of the document: the conditions and the seasons are returned when the fare is read, but nobody filters on them. On the passenger profiles, Contoso applies the same criterion and cuts the write cost almost in half.

Time to live (TTL) automatically deletes expired documents without consuming your container's RU, and it is perfect for data with a natural expiry:

# Search history: it deletes itself after 90 days (7,776,000 seconds)
az cosmosdb sql container update -a $ACCOUNT -g $RG -d catalogo -n busquedas \
  --ttl 7776000

A specific document can override that value with its own ttl property, or disable it with ttl: -1.

  1. The change feed as the basis for integration

The change feed is an ordered, persistent log of every creation and modification in a container, which can be read at any time from the beginning or from a saved checkpoint. It turns Cosmos DB into the starting point for event-driven architectures, with no additional queues.

At Contoso it solves three real needs with the same mechanism: when a fare changes, invalidating the website cache and notifying the pricing engine; when a passenger profile is created, triggering the welcome email; and copying every change to the data lake for the analytics in 03-06. The usual approach is to consume it with an Azure Functions trigger, which handles the distribution across instances and the saved read position for you; it is built in lesson 06-03. The change feed does not record deletions: when knowing about them matters, you do a soft delete by flagging the document and letting TTL remove it later.

  1. Continuous backup and spend control

Cosmos DB offers two backup modes, and the one chosen in the deployment in section 8 is the good one:

  • Periodic: snapshots at a set interval, with restore through a support ticket. It is the old mode.
  • Continuous (--backup-policy-type Continuous): restore to any second within the last 7 or 30 days, run by you and into a new account, just like Azure SQL Database's point-in-time restore.
az cosmosdb restore --target-database-account-name cosmos-contoso-tarifas-rec \
  --account-name $ACCOUNT --restore-timestamp "2026-08-11T16:38:00Z" \
  --location westeurope --resource-group $RG

On spend, three rules that avoid nasty surprises: provisioned throughput bills even if nobody reads anything, so a forgotten test container costs money every hour; each region added multiplies the throughput and storage cost; and the minimum for an autoscale container is 10% of the maximum, so setting a 40,000 RU/s ceiling means paying for at least 4,000 RU/s at all times. To stop paying, az cosmosdb delete --name $ACCOUNT --resource-group $RG --yes, because a Cosmos DB account cannot be paused.

Common Mistakes and Tips

  • Choosing the partition key without writing the queries first. It is irreversible: fixing it forces you to create another container and migrate everything. Write the five most frequent queries first.
  • Using the date as the partition key. It concentrates recent traffic in a few partitions and causes 429 errors while the container sits almost idle.
  • Querying without the partition key. A cross-partition query can cost 20 times more. If it is unavoidable and frequent, rethink the model or create a materialized view with the change feed.
  • Leaving the default indexing on write-heavy containers. Indexing everything makes every write more expensive; exclude what is never filtered on.
  • Putting the seat inventory in Cosmos DB with eventual consistency. You end up selling seats that do not exist. That piece of data belongs in the relational database.
  • Enabling multi-region writes "just in case". It doubles the cost and brings conflicts that have to be resolved. It only makes sense if you have users writing from several continents.
  • Tip: always look at the RU charge (x-ms-request-charge) while developing a new query. Optimizing with that figure in front of you turns tuning into something measurable.
  • Tip: to learn, enable the free tier or serverless mode. The difference between a €0 bill and a €300 one is one checkbox ticked when the account is created.

Exercises

Exercise 1: choosing the partition key

Contoso adds an embarques container with one document per boarded passenger: around 90 million a year, written at the moment of the scan and queried almost always as "all the boardings for a specific flight on a date".

  1. Evaluate /aeropuerto, /fechaEmbarque, /numeroVuelo and a synthetic key /vueloFecha ("CT1042_2026-07-14") as partition keys, applying the three criteria.
  2. Choose one and justify it.
  3. What TTL policy would you propose, and why?

Exercise 2: consistency per piece of data

For each piece of data, choose a consistency level and justify it:

  1. The price of a published fare, which changes twice a day.
  2. A passenger's seat preference, which they have just saved themselves on the website.
  3. The remaining quota of promotional seats for a campaign, with a tolerance of a few seconds.
  4. The status history of a fare, where the order matters but the delay does not.

Exercise 3: optimizing cost and queries

A perfiles container with 5 million documents has 20,000 RU/s fixed provisioned, default indexing, the partition key /pais and one very frequent query: SELECT * FROM c WHERE c.correo = @correo.

  1. Identify the three problems.
  2. Propose a fix for each one, saying which one requires recreating the container.
  3. What capacity model would you recommend if the traffic is concentrated between 8 and 22?

Solutions

Solution 1:

Candidate Assessment
/aeropuerto Low cardinality (dozens of airports) and very uneven: Barcelona would concentrate millions of documents and would hit the 50 GB limit per logical partition
/fechaEmbarque The classic hot partition: all of the day's writes land on the same value
/numeroVuelo Medium and acceptable cardinality, but the same number repeats every day for years, so the partition grows without limit
/vueloFecha High cardinality, even distribution (each flight-day is a distinct value), bounded size and it appears in the usual query
  1. /vueloFecha. It is the only candidate that satisfies all three criteria at once, and it also turns the most frequent query into a single-partition query, at minimal cost.
  2. A TTL of about 400 days: operational reporting needs the history of the season and of the previous year; anything older is already in the data lake for analytics (lesson 03-06), and keeping it in Cosmos DB only adds storage cost.

Solution 2:

  1. Eventual: it is the most tolerant piece of data in the catalog; a replica showing the previous price for a few seconds has no consequences, and it is the cheapest and fastest level.
  2. Session: the requirement is precisely "the client sees its own writes". With eventual, the passenger could save their seat, reload the page and see the previous one, which is the failure that generates the most support tickets.
  3. Bounded staleness, bounded by time: it guarantees that the lag does not exceed a few seconds, enough for a promotional quota where a small overshoot is acceptable.
  4. Consistent prefix: it does not demand immediacy, but it stops you seeing the statuses out of order, which is the only thing that would break reading the history.

Solution 3:

  1. The problems: (a) the /pais partition key has extremely low cardinality and terrible distribution — Spain would concentrate most of the profiles; (b) the query by correo does not carry the partition key, so it runs against every partition and costs dozens of RU; (c) 20,000 fixed provisioned RU/s are paid for 24 hours a day, including the small hours, and the default indexing makes every write more expensive.
  2. The fixes: change the partition key to /correo (or /pasajeroId, if that is what the rest of the application uses), which forces you to create a new container and migrate the data, since the key is immutable; exclude from indexing the properties that are never filtered on; and move to autoscale.
  3. Autoscale with a maximum of 20,000 RU/s: at night it will drop to 2,000 RU/s (10% of the maximum) and will only pay for each hour's real peak. Even though the autoscale RU costs 50% more, with a profile of 14 active hours out of 24 the saving is clear.

Conclusion

You now have both halves of Contoso Airlines' data core: the transactional side in Azure SQL Database and the flexible, global side in Azure Cosmos DB. You know what problem it solves that a relational database does not — transparent horizontal scaling, guaranteed single-digit millisecond latency and global distribution — and what you give up in exchange: joins across entities and transactions beyond a single partition. You know the five APIs and why a new project chooses the NoSQL one, and you can handle the account, database, container and item hierarchy knowing that the container is where everything important is decided.

Above all, you have worked through the decision that determines a project's fate: the partition key. You know its three criteria, why /origenDestino works for the fare catalog and why /fecha creates a hot partition that triggers 429 errors while the container sits idle, what a synthetic key is and why correcting yourself costs a full migration. You understand request units, the four capacity models, and how to read each query's RU charge so you can optimize with data instead of hunches. You have chosen a consistency level piece of data by piece of data with the table of the five levels, understanding why the seat inventory stays in the relational database and why session is the right balance for almost everything. You have deployed cosmos-contoso-tarifas-pro with reads in North Europe and writes only in West Europe, you have inserted and queried fare documents seeing the cost difference between a single-partition query and a cross-partition one, you have tuned the indexing policy, applied TTL and met the change feed that will feed module 6's integrations, along with continuous backup and the three rules that keep the bill free of surprises.

In the next lesson we come down from design decisions to a far more down-to-earth problem, and a very common one in any real migration. Contoso's content portal and corporate blog are a WordPress that has been running for eight years on a MySQL installed on a server in the basement in Barcelona, with its old version, its backups on a USB drive and nobody brave enough to touch it. Nobody is going to rewrite it. In Azure Database for MySQL you will see how it is taken to the cloud exactly as it is: flexible server, compute tiers, private access from the virtual network, server parameters, read replicas and, above all, the migration from the in-house server with mysqldump or with Azure Database Migration Service, with its verification checklist and its agreed maintenance window.

Azure Course

Module 1: Introduction to Azure

Module 2: Core Azure Services

Module 3: Azure Databases

Module 4: Security in Azure

Module 5: Azure DevOps

Module 6: Advanced Azure Services

Module 7: Monitoring and Management

Module 8: Cost Management and Optimization

Module 9: Case Studies and Best Practices

© Copyright 2026. All rights reserved