SQL Server and Cosmos DB, side by side
One page puts the two stores this application runs on next to each other,
Azure SQL Database on the left and Azure Cosmos DB on the right, row by row:
the same bid at rest in both, the same write in both adapters as live code,
the numbers the records measured, the record that decided each row, and the
reading behind it. Read ADR: Cosmos DB, explained for someone who knows SQL
Server first; it teaches the vocabulary, and this page is the comparison a
reader makes after learning it.
In plain words
The site runs on two kinds of database at once: a relational one (Azure SQL Database) and a document one (Azure Cosmos DB). This page sets them side by side, from a bid at rest to the monthly bill. Measured here, the gap between them is distance to the server; with that taken out, they answer in the same time.
What that is worth: a developer who knows one of these databases well can learn the other from one page, and the organization can choose between them on numbers measured on its own application.
Why this page exists
TheYard runs on two stores at once, Azure SQL Database and Azure Cosmos DB,
each serving one of its two sites, and the question a reader of the records
asks first is how the two compare on the same work. That comparison deserves
a page of its own, at the top of the sidebar beside Hosting, rather than a
place inside the decision records: nothing here was decided, it lays out
decisions already made, side by side, with the code and the numbers behind
them.
The material existed and was spread across eleven records. The SQL Server side
was decided over four days (ADR: The SQL Server backend; ADR: Data first, and
the database in source control; ADR: Two providers and a SQL project,
explained; ADR: What the database is actually doing), the Cosmos DB side over
two (ADR: The partition key; ADR: A second store on Cosmos DB, and what it
costs; ADR: The ports learn to wait; ADR: Accounts on a document store; ADR:
What the store is actually doing), and the comparison itself over the night
between (ADR: Backends, side by side; ADR: Measuring both stores; ADR: Same
performance, proven). A reader who wants the two stores next to each other
had to hold all of them open. This page is that reading, done once, with
the code pulled in live so it cannot drift from what runs, and the diagram
on its own page for a screen or an interview.
Who it is for: a developer who knows one of these stores well and is meeting
the other, which describes most of the people who will read it. Every row is
a question that developer asks first.
The picture

A preview. Open the side-by-side diagram in a new page
to zoom in and read across. It is drawn by docs/images/sql-vs-cosmos.mjs
from the same facts this page cites, and redrawn when a store, a file or a
number changes; a picture that disagrees with the records is a bug in the
picture (ADR: Docs and testing).
The same application, both stores
Nothing above the adapters knows which store it is on. The auction in the
Application layer speaks to three ports, a vehicle source, a photo manifest
source and a bid store, and Identity speaks to a user store; each side
implements all four (ADR: The ports learn to wait). One container
image runs both adapters in one process, brought up side by side, and each
site serves one of them by default: the live site,
https://theyard.stevenstout.biz, serves Azure SQL Database, and the second
site, https://theyard-cosmos.stevenstout.biz, serves Azure Cosmos DB, with the
Store bar linking each to the other at the same page (ADR: One container, both
stores; ADR: A permanent address for the second site). So every comparison
below is the same code path, the same requests and the same measuring
instrument, with only the store changed. That is what makes the numbers
comparable, and it is the reason the second store was built as a complete
backend rather than a demo (ADR: A second store on Cosmos DB, and what it
costs).
Row by row
Each row names the records that decided it. Open one from the sidebar, or
follow its address.
The proof's verdict, from the container itself rather than from a client
(ADR: Same performance, proven): on three of eight paths the two stores
answer in the same time, and on the other five the difference is the round
trip to the store, 39 ms to Azure SQL Database and 2 ms to Azure Cosmos DB;
taking one round trip per operation off each side leaves them the same. Those
two numbers are a fact about where the compute was. On 20 September it moved
to the relational server's region, and the same proof read 1 to 2 ms to Azure
SQL Database and 38 to 40 ms to Azure Cosmos DB, with every store-bound row
flipped and every request unit unchanged (ADR: One plan, two sites).
The same bid, as it is stored
The row and the document, read from this build. The left declares what the
engine will refuse; the right declares which path the engine will index,
which here is none, and says why in the file itself.
CREATE TABLE [dbo].[Bids] (
[UserId] nvarchar(128) NOT NULL,
[VehicleId] nvarchar(64) NOT NULL,
[Amount] int NOT NULL,
[BidCount] int NOT NULL,
[WonBuyNow] bit NOT NULL,
[AtMs] bigint NOT NULL,
[RowVersion] rowversion NULL,
CONSTRAINT [PK_Bids] PRIMARY KEY ([UserId], [VehicleId]),
CONSTRAINT [FK_Bids_AspNetUsers_UserId] FOREIGN KEY ([UserId]) REFERENCES [dbo].[AspNetUsers] ([Id]) ON DELETE CASCADE
);
GO
Live from api/TheYard.Database/Tables/Bids.sql, region *, read from this build at 8003063.
{
"container": "bids",
"partitionKeyPath": "/user_id",
"defaultTtlSeconds": null,
"why": "One document per buyer per vehicle: id is the vehicle id, the partition is the buyer. Every write is a point write and a reset is one query inside the buyer's partition, so no path is indexed.",
"indexingPolicy": {
"indexingMode": "consistent",
"automatic": true,
"includedPaths": [],
"excludedPaths": [
{
"path": "/*"
}
]
}
}
Live from infra/cosmos/bids.json, region *, read from this build at 8003063.
The same write, in both adapters
The relational bid store, then the document bid store. Read the two
SaveAsync methods against each other: the loop is the same, the exception it
catches is the store's way of saying the same thing, and the document side's
comment names the relational rule it follows.
public async Task<IReadOnlyList<StoredBid>> LoadAsync(CancellationToken cancellation)
{
using var db = await factory.CreateDbContextAsync(cancellation);
return await db.Bids.AsNoTracking().Select(row => new StoredBid(
row.UserId,
row.VehicleId,
new BidState(row.Amount, row.BidCount, row.WonBuyNow, row.AtMs)))
.ToListAsync(cancellation);
}
public async Task<IReadOnlyList<StoredBid>> BidsOnAsync(string vehicleId, CancellationToken cancellation)
{
using var db = await factory.CreateDbContextAsync(cancellation);
return await db.Bids.AsNoTracking().Where(row => row.VehicleId == vehicleId).Select(row => new StoredBid(
row.UserId,
row.VehicleId,
new BidState(row.Amount, row.BidCount, row.WonBuyNow, row.AtMs)))
.ToListAsync(cancellation);
}
public async Task<bool> TrySaveAsync(string userId, string vehicleId, BidState state, VehicleStanding standing, string? expectedVersion, CancellationToken cancellation)
{
using var db = await factory.CreateDbContextAsync();
await using var transaction = await db.Database.BeginTransactionAsync();
if (expectedVersion is not null && !await TryMoveStandingAsync(db, vehicleId, standing, long.Parse(expectedVersion, CultureInfo.InvariantCulture), CancellationToken.None))
{
return false;
}
if (expectedVersion is null)
{
db.Standings.Add(RowOf(vehicleId, standing, version: 1));
}
await StageBuyerRowAsync(db, userId, vehicleId, state, cancellation);
try
{
await db.SaveChangesAsync();
}
catch (DbUpdateConcurrencyException)
{
return false;
}
catch (DbUpdateException) when (expectedVersion is null)
{
await transaction.RollbackAsync();
if (await StandingExistsAsync(vehicleId, CancellationToken.None))
{
return false;
}
throw;
}
await transaction.CommitAsync();
return true;
}
private static async Task StageBuyerRowAsync(YardDbContext db, string userId, string vehicleId, BidState state, CancellationToken cancellation)
{
var existing = await db.Bids.FindAsync(userId, vehicleId);
if (existing is null)
{
existing = new BidRow
{
UserId = userId,
VehicleId = vehicleId,
Amount = state.Amount,
BidCount = state.BidCount,
WonBuyNow = state.WonBuyNow,
AtMs = state.AtMs,
};
db.Bids.Add(existing);
}
else
{
existing.Amount = state.Amount;
existing.BidCount = state.BidCount;
existing.WonBuyNow = state.WonBuyNow;
existing.AtMs = state.AtMs;
}
if (db.Database.IsSqlite())
{
existing.RowVersion = Guid.NewGuid().ToByteArray();
}
}
public async Task ClearAsync(string userId, CancellationToken cancellation)
{
using var db = await factory.CreateDbContextAsync();
await using var transaction = await db.Database.BeginTransactionAsync();
var touched = await db.Bids.Where(bid => bid.UserId == userId).Select(bid => bid.VehicleId).ToListAsync();
await db.Standings.Where(row => touched.Contains(row.VehicleId)).ExecuteDeleteAsync();
await db.Bids.Where(bid => bid.UserId == userId).ExecuteDeleteAsync();
await transaction.CommitAsync();
}
Live from api/TheYard.Infrastructure/Shared/Auction/EfSources.cs, region bid-store, read from this build at 8003063.
public async Task<IReadOnlyList<StoredBid>> LoadAsync(CancellationToken cancellation)
{
var documents = await store.QueryAsync<BidDocument>(
store.Bids,
new QueryDefinition("SELECT * FROM c WHERE c.user_id != @standing").WithParameter("@standing", StandingDocument.Partition),
partitionKey: null,
"cross-partition", cancellation: cancellation);
return documents.Select(d => d.ToStoredBid()).ToList();
}
public async Task<IReadOnlyList<StoredBid>> BidsOnAsync(string vehicleId, CancellationToken cancellation)
{
var documents = await store.QueryAsync<BidDocument>(
store.Bids,
new QueryDefinition("SELECT * FROM c WHERE c.id = @vehicle AND c.user_id != @standing")
.WithParameter("@vehicle", vehicleId)
.WithParameter("@standing", StandingDocument.Partition),
partitionKey: null,
"cross-partition", cancellation: cancellation);
return documents.Select(d => d.ToStoredBid()).ToList();
}
public async Task<bool> TrySaveAsync(string userId, string vehicleId, BidState state, VehicleStanding standing, string? expectedVersion, CancellationToken cancellation)
{
if (!await TryWriteStandingAsync(vehicleId, standing, expectedVersion, CancellationToken.None))
{
return false;
}
await SaveBuyerAsync(userId, vehicleId, state, CancellationToken.None);
return true;
}
private async Task SaveBuyerAsync(string userId, string vehicleId, BidState state, CancellationToken cancellation)
{
for (int attempt = 1; ; attempt++)
{
try
{
await WriteAsync(userId, vehicleId, state, cancellation);
return;
}
catch (CosmosException ex) when (attempt < 3
&& ex.StatusCode is HttpStatusCode.PreconditionFailed or HttpStatusCode.Conflict)
{
}
}
}
private async Task WriteAsync(string userId, string vehicleId, BidState state, CancellationToken cancellation)
{
var existing = await store.ReadAsync<BidDocument>(store.Bids, vehicleId, userId, Partition, cancellation: cancellation);
var document = new BidDocument
{
Id = vehicleId,
UserId = userId,
Amount = state.Amount,
BidCount = state.BidCount,
WonBuyNow = state.WonBuyNow,
AtMs = state.AtMs,
};
if (existing is null)
{
await store.CreateAsync(store.Bids, document, userId, Partition, cancellation: cancellation);
}
else
{
await store.ReplaceAsync(store.Bids, document, vehicleId, userId, existing.ETag, Partition, cancellation: cancellation);
}
}
public async Task ClearAsync(string userId, CancellationToken cancellation)
{
var ids = await store.QueryAsync<string>(
store.Bids,
new QueryDefinition("SELECT VALUE c.id FROM c WHERE c.user_id = @user").WithParameter("@user", userId),
partitionKey: userId,
Partition, cancellation: cancellation);
if (ids.Count > 0)
{
await store.DeleteBatchAsync(store.Bids, userId, ids, Partition, cancellation: CancellationToken.None);
await ForgetStandingsAsync(ids, CancellationToken.None);
}
}
Live from api/TheYard.Infrastructure.Cosmos/Shared/Auction/CosmosSources.cs, region cosmos-bid-store, read from this build at 8003063.
The same guarantee, given and built
Uniqueness of an address. On the relational side it is one line of DDL and the
engine's job; on the document side it is a method, because no index spans
partitions (ADR: Accounts on a document store).
CREATE TABLE [dbo].[AspNetUsers] (
[Id] nvarchar(128) NOT NULL,
[CreatedAtMs] bigint NOT NULL,
[UserName] nvarchar(256) NULL,
[NormalizedUserName] nvarchar(256) NULL,
[Email] nvarchar(256) NULL,
[NormalizedEmail] nvarchar(256) NULL,
[EmailConfirmed] bit NOT NULL,
[PasswordHash] nvarchar(max) NULL,
[SecurityStamp] nvarchar(max) NULL,
[ConcurrencyStamp] nvarchar(max) NULL,
[PhoneNumber] nvarchar(max) NULL,
[PhoneNumberConfirmed] bit NOT NULL,
[TwoFactorEnabled] bit NOT NULL,
[LockoutEnd] datetimeoffset NULL,
[LockoutEnabled] bit NOT NULL,
[AccessFailedCount] int NOT NULL,
CONSTRAINT [PK_AspNetUsers] PRIMARY KEY ([Id])
);
GO
CREATE INDEX [EmailIndex] ON [dbo].[AspNetUsers] ([NormalizedEmail]);
GO
CREATE UNIQUE INDEX [UserNameIndex] ON [dbo].[AspNetUsers] ([NormalizedUserName]) WHERE [NormalizedUserName] IS NOT NULL;
GO
Live from api/TheYard.Database/Tables/Identity/AspNetUsers.sql, region *, read from this build at 8003063.
public async Task<IdentityResult> CreateAsync(YardUser user, CancellationToken cancellationToken)
{
string normalizedEmail = user.NormalizedEmail ?? user.NormalizedUserName
?? throw new InvalidOperationException("an account needs a normalized email address before it is stored");
var claim = new EmailClaimDocument { Id = EmailClaimDocument.IdFor(normalizedEmail), UserId = user.Id };
try
{
await store.CreateAsync(store.Users, claim, claim.Id, ClaimPartition, "CreateItem (email claim)");
}
catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.Conflict)
{
return IdentityResult.Failed(Errors.DuplicateEmail(user.Email ?? ""));
}
try
{
var response = await store.CreateAsync(store.Users, ToDocument(user), user.Id, Partition, "CreateItem (account)");
user.ConcurrencyStamp = response.ETag;
return IdentityResult.Success;
}
catch (CosmosException)
{
await TryDeleteClaimAsync(claim.Id, CancellationToken.None);
throw;
}
}
Live from api/TheYard.Infrastructure.Cosmos/Features/Accounts/CosmosUserStore.cs, region create, read from this build at 8003063.
Where the two are the same
The application does not care. The three ports are the same interfaces on
both sides; BidService serializes simultaneous bids the same way whichever
store is underneath; a sign-in is Identity's own code on both, with a
different IUserStore behind it. Both are reached with a managed identity
and no secret. Both are logged, statement for operation, into the same ring
the Admin tab reads. Both cost nothing until 14 September, when the relational
side's free allowance ran out and the sites moved to a Basic database at $4.90
a month. And on the pages a visitor
spends most of their time on, the listing, the vehicle and the filter values,
neither store is on the path at all, because the catalogue is served from
memory, so those pages measure the same to within a few milliseconds on both
sites (ADR: Measuring both stores).
Where they differ, and what that buys
The relational side buys guarantees the application uses: a foreign key that
takes an account's bids with it, a unique index that is nobody's job to
remember, a transaction that can touch every table, a schema in source control
as DDL, and a planner that will sort by anything. The document side buys a
cost model you can see on every response, point reads at single-digit
milliseconds from compute in the account's own region, a shape you can change without a
migration, and the freedom to add regions if the site ever needed them. What
the document side charges for those is visible on this page: the claim
document that stands in for a unique index, the cascade that does not exist,
the sort that needs an index on the sorted path, and the synchronous port that
had to learn to wait (ADR: The ports learn to wait).
The measured difference between them on this workload is geography, not
engine: until 20 September the SQL server was one region away because that
region refused to create one, and the Cosmos DB account was next door; since
the move to the App Service plan the two have traded places. With the round trips taken
out the two are the same, which is the sentence the proof record was written
to be able to say (ADR: Same performance, proven). The honest choice for a
used-vehicle auction whose catalogue fits in memory and whose writes are bids
is the one whose guarantees it uses, and that reasoning is ADR: Cosmos DB,
explained for someone who knows SQL Server; this page is the evidence laid
out beside it.
Reading
Microsoft's own documentation for each row, thirty-six pages, every one read
on 2026-09-09 before it was linked here (three more that answered 404 that
morning were left out). The relational side first, then the document side, then the
guides that compare them.
Azure SQL Database and Entity Framework Core:
Azure Cosmos DB for NoSQL:
Choosing between them:
Keeping it true
- The comparison has one address on each site, and a picture that is a claim
about the repository rather than an illustration: the diagram names files,
and the page shows those files live.
- A change to either adapter's bid store changes this page on the next
request, because the code is read from the build; a change to a number in
ADR-064 or ADR-067 has to be carried here by hand, and the drawing redrawn.
- This is the page to send a reader who asks "why not just one database":
the answer is in the last two sections, with the measurements above them.