ADR: The partition key
Status: proposed, 2026-09-08, written before the containers existed, because
this is the one decision on a Cosmos DB container that cannot be changed
afterwards. The numbers at the bottom are filled in by measurement (ADR:
Measuring both stores) and the record says which are estimates until then. Accepted as built: the
containers shipped in 1.0.0.89 and the numbers this record owed were
measured at 1.0.0.92.
Parent: ADR: A second store on Cosmos DB, and what it costs.
In plain words
This page chooses how the vehicle catalogue is split up inside the second database (the Cosmos DB partition key), a choice that cannot be changed later. It picks the vehicle's make, which spreads the data with no hot spot and matches the first filter a visitor reaches for, then measures what each query costs.
What that is worth: a developer who knows SQL Server sees how a partition key differs from a clustered index, with real numbers per query, and the organization gets a choice that is costed before it becomes permanent.
What the key is, for a reader who knows SQL Server
A SQL Server table has a clustered index that decides its physical order, and
this project chose Seq for the catalogue because the only query it serves reads
the table in that order (ADR: The SQL Server backend). A Cosmos DB container has
no order and no clustered index. What it has instead is a partition key: one
path in every document whose value decides which logical partition the document
lives in. Every document with the same value is one logical partition, capped at
20 GB. Logical partitions are packed onto physical partitions, each of which
serves up to 10,000 RU/s and holds up to 50 GB, and the service splits physical
partitions as data or throughput grows.
Two things follow, and they are the whole subject:
- A read that names the key value and the id is a point read, the cheapest
operation the service has, about 1 RU for a 1 KB document. A query that names
the key value runs inside one logical partition. A query that does not name it
fans out to every physical partition, is answered by each and merged, and pays
for the index lookup on each.
- The key is permanent. Changing it means creating a new container and copying
every document across. So the choice is made by writing down the queries the
application actually runs, not the ones it might.
The queries this application actually runs
Read out of the code, per container, on 2026-09-08.
The first four are the parity path and the key barely matters on it: nothing
queries vehicles or photos by a property, and bids and users are read by
key. The fifth is the experiment, and it is where the key is on the hot path.
The candidates for the catalogue
The page filters on make, body style, title status, province, a price range and
a condition grade, searches free text, sorts four ways, and pages. The seed's
distributions, over 200 records, expand unchanged to 100,000 because
SyntheticVehicleSource varies numbers and never the make, style, status or
province of a seed record:
/body_style, /province and /title_status are rejected by their largest
share. A key whose most common value holds half the data is a hot partition by
construction: half of every seed, and half of every read that names that value,
lands on one logical partition, and a logical partition can never use more
throughput than the physical partition it sits on. None of them is near the 20 GB
cap at this size, so the cost today would be throughput rather than storage, and
it would grow with the data rather than with the traffic.
/id is the perfect spread and the wrong answer for a catalogue. Every
document is its own partition, so a point read is always available and no
partition can be hot. But every filtered query is a cross-partition query,
because no filter names an id, and a catalogue is read by filters. It is the
right key for users, which is only ever read by id, and the wrong one here.
/make is chosen. Fifteen values with the largest at nine per cent is a flat
enough spread that no partition is hot, the largest logical partition at 100,000
documents is about 7 MB against a 20 GB cap, and make is the first filter on the
page and the one a visitor to a used-vehicle auction reaches for first. A query
with a make runs inside one logical partition; the ones without a make are the
cross-partition examples the interview question is about, and they are named
below rather than discovered later.
Two further shapes were considered and are recorded because they are the
answer to "what would you do at ten times the size":
- A hierarchical key,
/make then /body_style. Supported by the service
and the SDK. It sub-divides each make so a query naming both is narrower
still, and it is the move when a single make outgrows a physical partition. At
7 MB a make it buys nothing measurable, and it costs every query that names
only the make a wider fan-out than it has today.
- A synthetic key, a hash of the id into N buckets. The write-heavy answer:
it spreads a seed perfectly and makes every read cross-partition. This
container is written once and read by filter, which is the case it is worst
for.
What the key costs
Two things the choice takes away, said plainly so the measurements below mean
something:
A vehicle page does not know the make. /vehicles/{id} carries only the id,
so in the experiment a read by id without the make is a query, not a point read:
SELECT * FROM c WHERE c.id = @id across every partition. The fix, if the
experiment ever became the site, is to carry the make in the address or to give
the synthetic ids a prefix the key can be derived from. On the parity path this
does not arise, because by-id reads come from the in-memory dictionary.
The default view is a cross-partition query. The front page with no filter,
sorted by ending soonest, names no make. Every page of it fans out. That is not
a defect of the key, it is what an unfiltered listing over a partitioned store
is, and the number for it belongs in the table below rather than in an
adjective.
The caveat that keeps the numbers honest
At 82 MB and 1000 RU/s this container has one physical partition. A
cross-partition query therefore fans out to one place, and what the numbers
below measure is the cost of the index lookup and the documents loaded, not the
network cost of gathering answers from several servers. The Admin tab's store
log reads the physical partition count for each container through the SDK's feed
ranges and prints it beside every cross-partition operation, so the page says
"cross-partition, 1 physical partition" rather than implying a fan-out that is
not happening. The fan-out becomes real at 50 GB of data or 10,000 RU/s of
throughput, whichever arrives first, and the teaching record says what changes
then.
The numbers
Measured on 2026-09-08 against the real account, the catalogue container
holding 100,000 documents on one physical partition. Two views of the same
seven queries: from the container itself, in region, which is what the Admin
tab's card shows, and from Steve's machine in Missouri through
api/TheYard.Experiment, ten paired rounds, median of each, where the
network is most of every millisecond and the request charge is the same
either way because the charge is the store's and not the network's.
The same seven queries as the Admin tab's card shows them, run by the
container itself a few minutes later, warm:

Three things to read out of that.
The point read is the cheapest thing in the table by a factor of three
against the same document fetched by a query that does not know the make:
1.00 RU against 2.83. That is the cost of a partition key the address does not
carry, and it is the number behind the trade-off named above. On the parity
path it never arises; on a site that served pages from this container it
would be paid on every vehicle page.
A page of a hundred documents costs about six request units whether the
query names the make or not. Pinned and fanned out are within a request unit
of each other, and the record says why before the table does: with 82 MB on
one physical partition, a cross-partition query fans out to one place, and the
charge is the index lookup and the hundred documents loaded, not a gathering
from several servers. The wall-clock difference in region, 19 ms pinned against
41 ms for the province query, is the query engine planning and merging a
fan-out that has nowhere to go. This is the honest shape of the partition key
question at this size, and the teaching record says what changes at fifty
gigabytes or ten thousand request units a second.
The free-text scan is the dearest query and still cheap, 8.12 RU, because
model is one of the nine indexed paths and CONTAINS can use it. The
in-memory index the site actually uses answers the same question in under a
millisecond (ADR: The search index), and that comparison is the one that
decides where a search should run.
And the seed, both ways, which is the headline:
The default policy costs 1.8 times as much per write and buys nothing on any
of the seven queries: the same set against catalogue-default came back
within half a request unit of the tuned container on every row (6.26 against
5.86 for the pinned page, 6.39 against 5.98 for the province page). The seed
against the default policy also did not finish: 8,407 documents were refused
after the SDK's thirty retries over two minutes on a 1000 RU/s database that
was being asked for twice that, which is what throttling looks like from the
client, and the record keeps the number rather than re-running it until it
finishes.
Both seeds ran faster than the throughput arithmetic allows. 884,479 RU at
1000 RU/s is 14.7 minutes and the tuned seed took 12.8; the service lets an
idle database accumulate burst capacity and spend it, which is worth knowing
before reading any short measurement against a provisioned account.
Where it sits
The key paths are declared in Containers.PartitionKeyPaths in Infrastructure.Cosmos and in the definitions under infra/cosmos/, and the seven measured queries now run in PartitionExperiment, which implements Application's IStoreExperiment port so the Admin tab asks for rows without knowing Cosmos DB exists. Dependency inversion, where the inner ring owns the interface and the outer ring supplies the code, is what lets a container with no document store answer through NoStoreExperiment instead. The cost is permanent: /make means a vehicle page that carries only an id pays 2.83 RU for a cross-partition query where a point read would cost 1.00. If one make outgrew a physical partition, or pages were served from this container, the answer would shift to a hierarchical key or to addresses that carry the make.
Addendum, 2026-10-18: the catalogue container serves the site
The trade-off this record named ("if pages were served from this container") arrived in 1.0.3.178: the catalogue container is now the store the Cosmos DB site's listing, filter values and vehicle reads are queries against (ADR: The store does the work). The key stays /make. A vehicle read by id names its partition, because the synthetic id is a seed id and six characters and the seed's two hundred makes give every id its make, so the 2.83 RU query this record measured never runs on a visitor's path; a listing with no make fans out to the one physical partition, which the table above shows costs what a pinned page costs at this size. The indexing policy gained the slot, the price, the bid count and the searchable text, and one composite index per two-key order.