SQL Server → Azure Cosmos DB Migration
End-to-end runbook for migrating relational SQL Server workloads to Azure Cosmos DB for NoSQL. Covers schema transformation, partition key design, data movement, CDC live sync, validation, and production cut-over.
Architecture Overview
Azure Cosmos DB for NoSQL stores data as JSON documents in containers, replacing SQL Server tables. Understanding the mapping between relational and document concepts is critical before any migration work begins.
Relational → Document Concept Mapping
| SQL Server (Relational) | Cosmos DB for NoSQL (Document) | Notes |
|---|---|---|
| Database | Database | Direct equivalent; created via Portal or SDK |
| Table | Container | Each container holds JSON documents |
| Row | Document (JSON item) | Max 2 MB per document |
| Column | Document property | Schema-less; no enforced structure |
| Primary Key | id + Partition Key | id must be unique within a logical partition |
| Foreign Key | Embedded document / reference by id | No enforced referential integrity |
| Index | Indexing Policy (JSON) | All properties indexed by default |
| Stored Procedure | Server-side JavaScript (limited) | Scoped to single partition |
| View | Query / materialized via Change Feed | No native view concept |
| Schema | Not applicable | Documents in same container can differ in shape |
Prerequisites — Source: SQL Server
Required Permissions
| Permission / Role | Scope | Purpose | Minimum Required |
|---|---|---|---|
db_datareader |
Source database | Read all tables for migration | Yes |
VIEW DATABASE STATE |
Source database | Query DMVs for row counts, stats | Yes |
VIEW SERVER STATE |
Instance | Monitor blocking, active sessions | Recommended |
db_owner on CDC DB |
Source database | Enable / manage CDC for live sync | CDC only |
| SQL Server Agent access | Instance | CDC capture jobs | CDC only |
ODBC Driver Check
Verify the correct ODBC driver is installed on the migration host:
-- PowerShell: list installed ODBC drivers
Get-OdbcDriver | Where-Object { $_.Name -like "*SQL*" } | Select-Object Name, Platform
Required: ODBC Driver 17 for SQL Server or ODBC Driver 18 for SQL Server.
Pre-Flight T-SQL Queries
Run these on the source instance before launching the tool:
-- 1. Verify connectivity and SQL Server version SELECT SERVERPROPERTY('ProductVersion') AS ProductVersion, SERVERPROPERTY('Edition') AS Edition, SERVERPROPERTY('EngineEdition') AS EngineEdition, @@SERVERNAME AS ServerName;
-- 2. Row counts for all user tables in the source database SELECT s.name AS SchemaName, t.name AS TableName, p.rows AS RowCount, SUM(a.total_pages) * 8 / 1024 AS TotalSizeMB FROM sys.tables t JOIN sys.schemas s ON s.schema_id = t.schema_id JOIN sys.indexes i ON i.object_id = t.object_id AND i.index_id <= 1 JOIN sys.partitions p ON p.object_id = i.object_id AND p.index_id = i.index_id JOIN sys.allocation_units a ON a.container_id = p.partition_id GROUP BY s.name, t.name, p.rows ORDER BY TotalSizeMB DESC;
-- 3. Identify unsupported column types that need special handling SELECT OBJECT_SCHEMA_NAME(c.object_id) AS SchemaName, OBJECT_NAME(c.object_id) AS TableName, c.name AS ColumnName, tp.name AS DataType, c.max_length, c.is_nullable FROM sys.columns c JOIN sys.types tp ON tp.user_type_id = c.user_type_id WHERE tp.name IN ('hierarchyid', 'geometry', 'geography', 'sql_variant', 'xml', 'image', 'text', 'ntext') ORDER BY SchemaName, TableName;
-- 4. Check for tables without a primary key (complicates id assignment) SELECT s.name AS SchemaName, t.name AS TableName FROM sys.tables t JOIN sys.schemas s ON s.schema_id = t.schema_id WHERE t.object_id NOT IN ( SELECT parent_object_id FROM sys.key_constraints WHERE type = 'PK' ) ORDER BY SchemaName, TableName;
Prerequisites — Target: Azure Cosmos DB
Python SDK Installation
The Migration Tool requires the Azure Cosmos DB Python SDK:
pip install azure-cosmos==4.7.0 azure-identity==1.16.0 pyodbc==5.1.0
Verify installation:
python -c "import azure.cosmos; print(azure.cosmos.__version__)"
Connection String Format
Retrieve from Azure Portal → Cosmos DB Account → Keys:
AccountEndpoint=https://<account-name>.documents.azure.com:443/;AccountKey=<primary-or-secondary-key>==;
For managed identity (recommended for production), use the endpoint URI only — no key required.
Azure Portal — Database Creation Steps
- Open the Azure Portal and navigate to your Cosmos DB account.
- Select Data Explorer → New Container.
- Enter the Database id (create new or use existing).
- Enter the Container id (maps to source table name).
- Set the Partition key (e.g.,
/CustomerId). - Choose throughput: Autoscale (recommended) or manual RU/s.
- Click OK. Container is ready within seconds.
RBAC Assignment (Azure CLI)
az cosmosdb sql role assignment create \ --account-name <cosmos-account> \ --resource-group <rg-name> \ --role-definition-name "Cosmos DB Built-in Data Contributor" \ --principal-id <managed-identity-object-id> \ --scope "/subscriptions/<sub-id>/resourceGroups/<rg>/providers/Microsoft.DocumentDB/databaseAccounts/<cosmos-account>"
Network Requirements
| Direction | Port | Protocol | Purpose |
|---|---|---|---|
| Migration host → SQL Server | 1433 | TCP | ODBC / TDS connection |
| Migration host → Cosmos DB | 443 | HTTPS | SDK data plane |
| Migration host → Azure AD | 443 | HTTPS | Token acquisition (managed identity) |
Step 1 — Configure Connections
Launch the Migration Tool and configure source and target connections. All credentials are stored encrypted in the local session and are never written to disk.
Source — SQL Server
Target — Cosmos DB for NoSQL
AccountEndpoint=...;AccountKey=...;), paste the entire string into the
Account Endpoint URI field. The tool will automatically parse both the endpoint
and the key from the connection string.
Step 2 — Partition Key Assessment
The assessment engine analyzes each source table and recommends a partition key based on cardinality, query pattern sampling, and write distribution. Review and override recommendations before proceeding.
Partition Key Assessment — AdventureWorks Sample
| Table | Rows | Recommended Partition Key | Cardinality | Confidence | Notes |
|---|---|---|---|---|---|
Sales.SalesOrderHeader |
31,465 | /CustomerId |
847 distinct values | High | Aligns with common query filter |
Sales.SalesOrderDetail |
121,317 | /SalesOrderId |
31,465 distinct values | High | Co-locate with header via same partition |
Production.Product |
504 | /ProductSubcategoryId |
37 distinct values | Medium | Low cardinality; consider synthetic key |
Person.Person |
19,972 | /EmailPromotion |
3 distinct values | Low — Review | Hot partition risk; use /BusinessEntityId hash instead |
HumanResources.Employee |
290 | /OrganizationLevel |
4 distinct values | Medium | Contains hierarchyid column — will be skipped (see Known Issues) |
Skipped / Unsupported Column Types
| SQL Server Type | Action | Alternative |
|---|---|---|
hierarchyid | Skipped by default | Export as string via .ToString() |
geometry / geography | Skipped by default | Export as WKT string or GeoJSON |
sql_variant | Skipped by default | Cast to nvarchar in source view |
image / text / ntext | Skipped by default | Cast to varbinary(max) / nvarchar(max) |
xml | Converted to string | Stored as escaped JSON string property |
varbinary(max), nvarchar(max), or large xml
columns that routinely exceed this threshold must be split into a separate container or
store binary data in Azure Blob Storage with a reference property in the document.
Step 3 — Table Mapping & Migration Options
Configure the mapping between source tables and target containers. Set the partition key, throughput, and per-table options before starting the migration run.
| Source Table | Target Container | Partition Key | Throughput | Mode | Status |
|---|---|---|---|---|---|
Sales.SalesOrderHeader |
SalesOrderHeader |
/CustomerId |
2,000 RU/s | Bulk | Ready |
Sales.SalesOrderDetail |
SalesOrderDetail |
/SalesOrderId |
4,000 RU/s | Bulk | Ready |
Production.Product |
Product |
/ProductSubcategoryId |
400 RU/s | Bulk | Ready |
Person.Person |
Person |
/BusinessEntityId |
1,000 RU/s | Bulk | Review PK |
HumanResources.Employee |
Employee |
/OrganizationLevel |
400 RU/s | Bulk | hierarchyid warn |
Migration Options
| Option | Default | Description |
|---|---|---|
| Batch Size | 1,000 documents | Documents per upsert batch; reduce if hitting 429s |
| Parallel Workers | 4 | Concurrent threads per container |
| Retry on 429 | Enabled (5 retries) | Exponential back-off on RU throttle |
| Checkpoint File | ./migration_checkpoint.json | Enables resume on failure |
| Skip Unsupported Types | Enabled | Log and skip; do not abort migration |
| Create Containers | Enabled | Auto-create containers if not present |
| Upsert Mode | Enabled | Overwrite existing documents by id |
Pre-Migration Checklist
- Partition key reviewed and approved for all containers
- Target database and throughput settings confirmed
- Network connectivity tested (SQL Server port 1433, Cosmos DB port 443)
- Migration service account permissions validated
- Checkpoint directory writable
- Source tables backed up or snapshot taken
- Change window approved with stakeholders
Step 4 — Execute Migration
Click Start Migration to begin the bulk data movement. Monitor the live log panel. The migration is resumable via the checkpoint file if interrupted.
Step 5 — Post-Migration Validation
Validate document counts and data integrity across all migrated containers. Use the Data Explorer queries, the Python SDK script, and T-SQL cross-validation.
Cosmos DB Data Explorer Queries
-- Count all documents in SalesOrderHeader SELECT VALUE COUNT(1) FROM c -- Spot-check a specific customer SELECT * FROM c WHERE c.CustomerId = 29825 -- Verify field presence (no nulls on SalesOrderId) SELECT VALUE COUNT(1) FROM c WHERE IS_NULL(c.SalesOrderId) = true
Expected Document Count Table
| Container | Source Row Count (T-SQL) | Target Doc Count (Cosmos) | Delta | Status |
|---|---|---|---|---|
SalesOrderHeader |
31,465 | 31,465 | 0 | PASS |
SalesOrderDetail |
121,317 | 121,317 | 0 | PASS |
Product |
504 | 504 | 0 | PASS |
Person |
19,972 | 19,972 | 0 | PASS |
Employee |
290 | 290 | 0 | PASS |
Python SDK Verification Script
from azure.cosmos import CosmosClient
ENDPOINT = "https://he-cosmos-prod.documents.azure.com:443/"
KEY = "<account-key>"
DB_NAME = "AdventureWorks_Cosmos"
client = CosmosClient(ENDPOINT, credential=KEY)
db = client.get_database_client(DB_NAME)
containers = ["SalesOrderHeader", "SalesOrderDetail",
"Product", "Person", "Employee"]
for name in containers:
container = db.get_container_client(name)
count = list(container.query_items(
"SELECT VALUE COUNT(1) FROM c",
enable_cross_partition_query=True
))[0]
print(f"{name:30s} documents: {count:,}")
T-SQL Cross-Validation (Source)
-- Run on source SQL Server to confirm expected row counts pre-cut-over SELECT 'Sales.SalesOrderHeader' AS TableName, COUNT(*) AS RowCount FROM Sales.SalesOrderHeader UNION ALL SELECT 'Sales.SalesOrderDetail' , COUNT(*) FROM Sales.SalesOrderDetail UNION ALL SELECT 'Production.Product' , COUNT(*) FROM Production.Product UNION ALL SELECT 'Person.Person' , COUNT(*) FROM Person.Person UNION ALL SELECT 'HumanResources.Employee' , COUNT(*) FROM HumanResources.Employee;
Step 6 — Cut Over & Post-Migration
Index Policy Review
Cosmos DB indexes all properties by default. For large containers, a custom indexing policy
reduces RU overhead on writes. Example policy for SalesOrderHeader:
{
"indexingMode": "consistent",
"automatic": true,
"includedPaths": [
{ "path": "/CustomerId/?" },
{ "path": "/OrderDate/?" },
{ "path": "/Status/?" }
],
"excludedPaths": [
{ "path": "/LineItems/*" },
{ "path": "/_etag/?" }
]
}
RU/s Scaling Steps
- During migration: set throughput to maximum needed (monitor CloudWatch / Azure Monitor).
- After migration and validation: scale down containers to steady-state RU/s.
- Enable Autoscale for variable workloads to avoid manual scaling.
- Set an alert if normalized RU consumption exceeds 80% for >5 minutes.
Change Feed Consumer (Application Cut-Over)
To detect and react to new writes after cut-over, configure a Change Feed processor:
from azure.cosmos.aio import CosmosClient
from azure.cosmos import PartitionKey
async def process_changes(docs):
for doc in docs:
print(f"Changed document id={doc['id']}, partition={doc.get('CustomerId')}")
# Attach processor to SalesOrderHeader container
# See: https://aka.ms/cosmos-change-feed-processor
deleted: true flag and a TTL) before cut-over. Hard deletes are invisible to Change Feed consumers.
Decommission Checklist
- Application connection strings updated to Cosmos DB endpoint
- All read/write traffic verified routing to Cosmos DB (zero SQL Server queries)
- Cosmos DB document counts match final SQL Server row counts
- SQL Server database set to READ_ONLY for 72-hour observation period
- Alerts configured on Cosmos DB (RU throttle, availability, latency)
- Backup policy enabled on Cosmos DB account (periodic or continuous)
- SQL Server database backup taken and archived before decommission
- SQL Server database drop / decommission approved by Change Advisory Board
Live CDC Sync (Change Data Capture)
For near-zero downtime migrations, enable SQL Server CDC on the source database to capture ongoing changes while the initial full-load runs. After full-load completes, replay CDC events into Cosmos DB before cutting over application traffic.
Enable CDC on Source Database
-- Step 1: Enable CDC on the database USE AdventureWorks2019; EXEC sys.sp_cdc_enable_db; -- Step 2: Enable CDC on target tables EXEC sys.sp_cdc_enable_table @source_schema = N'Sales', @source_name = N'SalesOrderHeader', @role_name = NULL, @supports_net_changes = 1; EXEC sys.sp_cdc_enable_table @source_schema = N'Sales', @source_name = N'SalesOrderDetail', @role_name = NULL, @supports_net_changes = 1; -- Step 3: Verify CDC is enabled SELECT name, is_cdc_enabled FROM sys.databases WHERE name = N'AdventureWorks2019';
Full-Load-First Sequence
- Record the current CDC LSN (Log Sequence Number) before starting the full load.
- Execute the full-load migration (Step 4) while CDC continues capturing changes.
- After full-load completes, replay CDC change events from the saved LSN forward.
- Drain the CDC queue until lag reaches near-zero.
- Pause writes on the source (maintenance window), apply final CDC events, cut over.
-- Capture the starting LSN before full load begins DECLARE @start_lsn binary(10); SET @start_lsn = sys.fn_cdc_get_min_lsn(N'Sales_SalesOrderHeader'); SELECT sys.fn_cdc_map_lsn_to_time(@start_lsn) AS StartLSNTime, @start_lsn AS StartLSN;
CDC Operation Mapping
CDC __$operation |
Operation | Cosmos DB Action | Notes |
|---|---|---|---|
1 | DELETE | Delete document by id + partition key | Requires full PK value |
2 | INSERT | Upsert new document | Assign id from PK |
3 | UPDATE (before) | Ignore (use after-image) | Pre-update snapshot |
4 | UPDATE (after) | Upsert updated document | Full row replacement |
CDC Monitoring Queries
-- Monitor CDC capture latency SELECT ct.capture_instance, ct.source_schema, ct.source_table, sys.fn_cdc_map_lsn_to_time(ct.start_lsn) AS CaptureStartTime, js.latency AS LatencySeconds, js.tran_count AS UnprocessedTrans FROM cdc.change_tables ct JOIN cdc.dm_cdc_log_scan_sessions js ON js.session_id = (SELECT MAX(session_id) FROM cdc.dm_cdc_log_scan_sessions) ORDER BY ct.source_schema, ct.source_table;
Type Conversions Reference
The Migration Tool applies the following type mappings when transforming SQL Server rows to JSON documents.
| SQL Server Type | JSON / Cosmos DB Type | Notes |
|---|---|---|
int, bigint, smallint, tinyint | Number (integer) | Direct mapping |
decimal, numeric | Number (decimal string) | Stored as string to preserve precision; cast in application layer |
float, real | Number (float) | Potential precision loss for very large values |
money, smallmoney | Number (decimal string) | Same as decimal — precision-safe string form |
bit | Boolean | 1 → true, 0 → false |
char, varchar | String | Trimmed of trailing spaces |
nchar, nvarchar | String | Unicode preserved |
text, ntext | String (if < 2 MB) | Deprecated types; cast to nvarchar(max) first recommended |
date | String (ISO 8601: YYYY-MM-DD) | |
datetime, datetime2 | String (ISO 8601: YYYY-MM-DDTHH:mm:ss.fffffffZ) | UTC normalized |
datetimeoffset | String (ISO 8601 with offset) | Offset preserved in string |
time | String (HH:mm:ss.fffffff) | |
uniqueidentifier | String (lowercase UUID) | Used as id when it is the PK |
varbinary, binary, image | String (Base64) | Warn if > 1 MB — 2 MB doc limit risk |
xml | String (escaped XML) | Not parsed to nested JSON by default |
hierarchyid | Skipped (default) / String | Enable Convert hierarchyid to string option to emit /1/2/3/ form |
geometry / geography | Skipped (default) / GeoJSON | Enable Convert spatial to GeoJSON option |
sql_variant | Skipped | No safe automatic conversion; pre-cast in source view |
Sample Output — SalesOrderHeader Document
{
"id": "75123",
"SalesOrderId": 75123,
"CustomerId": 29825,
"OrderDate": "2014-06-01T00:00:00.0000000Z",
"DueDate": "2014-06-13T00:00:00.0000000Z",
"ShipDate": "2014-06-08T00:00:00.0000000Z",
"Status": 5,
"OnlineOrderFlag": false,
"SalesOrderNumber":"SO75123",
"PurchaseOrderNumber": "PO18850127500",
"AccountNumber": "10-4020-000676",
"SubTotal": "880.3484",
"TaxAmt": "70.4279",
"Freight": "22.0087",
"TotalDue": "972.7850",
"Comment": null,
"rowguid": "79b65321-39ca-4115-9cba-8fe0903f12e6",
"ModifiedDate": "2014-06-08T00:00:00.0000000Z",
"_migrationRunId": "a3f9c2d7",
"_migratedAt": "2026-07-05T09:14:49Z"
}
Known Issues & Limitations
hierarchyid CLR type cannot be serialized to JSON without explicit conversion.
By default, columns of this type are skipped. Enable the Convert hierarchyid to string
option to emit the string representation (e.g., /1/2/3/), but note that
hierarchyid-based queries and methods will not work in Cosmos DB — redesign the data model
for tree traversal using parent-id references or materialized paths.
varbinary(max) or nvarchar(max)
columns must be split: store large binary payloads in Azure Blob Storage and embed a reference
URI in the Cosmos DB document. Validate document sizes in the assessment phase.
decimal and money values are stored as strings to preserve precision.
Application code must parse these strings back to decimal/currency types. JSON number format
cannot represent all SQL Server decimal precision without rounding.
Known Issues — Severity Summary
| # | Issue | Severity | Workaround Available |
|---|---|---|---|
| 1 | hierarchyid not serializable | High | Yes — convert to string or skip |
| 2 | 2 MB document size limit | High | Yes — offload large blobs to Azure Blob Storage |
| 3 | Partition key immutability | Medium | Partial — requires full re-migration |
| 4 | No cross-partition ACID transactions | Medium | Yes — redesign with compensating transactions |
| 5 | Decimal precision as string | Medium | Yes — parse string in application layer |
| 6 | geometry / geography columns | Low | Yes — GeoJSON export option |
UI Busy States & Checkpoint Behavior
During long-running migration operations, the tool enters a busy state and displays a progress indicator. The following behaviors apply:
| Operation | UI State | Cancelable | Behavior on Cancel |
|---|---|---|---|
| Connection Test | Spinner, "Testing connection…" | Yes | Immediate cancel, no side effects |
| Assessment Scan | Progress bar, table-by-table | Yes | Partial results shown; re-run to complete |
| Full Migration Run | Log panel + per-table progress bars | Yes (graceful) | Completes current batch, writes checkpoint, stops |
| Validation Run | Progress bar, container-by-container | Yes | Partial results flagged as incomplete |
| CDC Drain | Log panel, LSN lag counter | Yes (graceful) | Stops after current event batch; LSN saved |
./migration_checkpoint.json after each successful batch.
If the migration is interrupted (cancel, crash, network loss), re-launching the tool
and clicking Resume Migration will read the checkpoint and continue
from the last committed batch — no documents are re-written unnecessarily (upsert is idempotent).
Delete the checkpoint file only after a successful, fully-validated migration.