Start physical data modeling from observable workload evidence: commands, queries, keys, invariants, cardinalities, change rates, routing, and bounded result sets.

Start from Queries, Commands, Invariants, Cardinalities, and Change Rates

Build an access-pattern worksheet before choosing a document shape, partition key, index, or cache key. Then prove why a normalized-looking or aesthetically simple schema can still be operationally wrong when common requests scatter across partitions or read without a bound.

Intermediate–Advanced100–130 minutesMechanism-first modeling labPython 3.13+ · standard libraryVendor-neutral · free/local mandatory pathLast reviewed: August 2026

Learning outcomes

Build an access-pattern worksheet before choosing a document shape, partition key, index, or cache key. Then prove why a normalized-looking or aesthetically simple schema can still be operationally wrong when common requests scatter across partitions or read without a bound.

01

Record commands and queries with keys, predicates, sort order, cardinality, frequency, latency targets, invariants, and change rates.

02

Distinguish logical entities from physical access paths and route common requests to bounded work.

03

Detect scatter-gather, unbounded history scans, hot keys, and write-heavy projections before implementation.

04

Use measurable evidence to reject an elegant schema that contradicts AtlasMart workload requirements.

Implementation snapshot

Mandatory work uses Python 3.13+ standard library only in one local process. No MongoDB, Cassandra, Redis, PostgreSQL server, cloud service, Docker image, paid feature, network manipulation, or destructive failure injection is required. Optional implementation references are PostgreSQL 18, MongoDB 8.3.8 (current released patch in the 8.3 stable series as of August 29, 2026), Apache Cassandra 5.0.9, and Redis Open Source 8.10. Product-specific transaction, indexing, partition, quota, security, and licensing behavior is illustrative rather than universal.

1. Physical design starts with verbs, not nouns

A relational ER diagram starts from entities and relationships because normalization is a powerful general-purpose way to preserve facts without uncontrolled duplication. Distributed physical design adds another question: what exact work must each command or query perform at runtime? A command asks the system to change state; a query asks it to return state without changing the business fact. An invariant is a condition that must remain true across successful changes. Cardinality describes how many matching objects or relationships may exist. A change rate describes how often a fact mutates. These attributes determine whether locality, duplication, synchronization, or a transaction boundary is valuable.

2. Build the access-pattern worksheet before the schema

Operation Keys / predicates Bound / sort Frequency & latency Invariant / change rate
PlaceOrder command tenant_id, order_id 1 aggregate 450/s peak · p95 ≤ 180 ms total ≥ 0; inventory reservation coordinated · append-heavy
GetOrder query tenant_id, order_id 1 order high · p95 ≤ 80 ms exact durable order · low mutation after completion
ListCustomerOrders tenant_id, customer_id, month ≤100/page · newest first high · p95 ≤ 120 ms no omission in requested bucket · append-heavy
Catalog browse tenant_id, category, price band paged very high · p95 ≤ 80 ms staleness seconds may be acceptable · frequent price changes
Session lookup tenant_id, user_id 1 key very high · p95 ≤ 20 ms ephemeral; bounded TTL · frequent refresh

The worksheet is not documentation theater. It is a falsifiable design input. For each row, ask: can the proposed primary/partition key route the request directly? Is the maximum result set bounded? Does sorting require work across partitions? Does the invariant fit one atomic boundary? If not, the model needs another access path or a different ownership boundary.

3. AtlasMart lab: reject scatter-gather with evidence

Save the following as lesson1_access_patterns.py and run it with python lesson1_access_patterns.py. It creates 2,400 synthetic orders across eight shards. The “bad” model routes by order identifier only, so a common customer-history query must inspect every shard. The query-shaped projection groups bounded customer-month partitions.

python · AtlasMart deterministic simulation
from collections import Counter, defaultdict

SHARDS = 8
orders = []
for customer in range(1, 41):
    for month in range(1, 13):
        for seq in range(5):
            order_id = f"o-{customer:02d}-{month:02d}-{seq}"
            orders.append({
                "order_id": order_id,
                "customer_id": f"c-{customer:02d}",
                "month": month,
                "status": "paid" if seq % 2 == 0 else "shipped",
                "amount": 40 + customer + seq,
            })

def shard_for_order_id(order_id):
    return sum(order_id.encode()) % SHARDS

# Elegant-looking but workload-hostile: authority keyed only by order_id.
by_order = defaultdict(list)
for o in orders:
    by_order[shard_for_order_id(o["order_id"])].append(o)

customer = "c-17"
months = {11, 12}
scanned_bad = 0
found_bad = []
for shard_rows in by_order.values():
    scanned_bad += len(shard_rows)
    found_bad += [o for o in shard_rows if o["customer_id"] == customer and o["month"] in months]

# Query-shaped projection: bounded customer-month partitions.
by_customer_month = defaultdict(list)
for o in orders:
    by_customer_month[(o["customer_id"], o["month"])].append(o)

scanned_good = 0
found_good = []
for m in months:
    rows = by_customer_month[(customer, m)]
    scanned_good += len(rows)
    found_good += rows

print("orders total:", len(orders))
print("bad model shards touched:", len(by_order), "of", SHARDS)
print("bad model rows examined:", scanned_bad)
print("good model partitions touched:", len(months))
print("good model rows examined:", scanned_good)
print("same logical result:", sorted(o["order_id"] for o in found_bad) == sorted(o["order_id"] for o in found_good))

worksheet = [
    ("GetOrder", "order_id", "1", "p95<=80ms", "exact order", "rare change"),
    ("ListCustomerOrders", "customer_id+month", "<=100/page", "p95<=120ms", "no omission", "append-heavy"),
    ("PlaceOrder", "order_id", "1 write", "p95<=180ms", "total >= 0", "450/s peak"),
]
print("worksheet rows:", len(worksheet))
print("rejected reason: scatter-gather + unbounded scan for common customer-history query")
Expected evidence

The bad model touches all eight shards and examines all 2,400 orders to find ten rows for one customer across two months. The query-shaped projection touches exactly two bounded partitions and examines ten rows. The simulation proves routing work for this dataset; it does not prove a universal latency ratio for real products or hardware.

4. Elegant logical schemas can still be operationally wrong

A schema may be perfectly normalized and still create a poor distributed access path. If ListCustomerOrders is among AtlasMart’s dominant reads, an order table partitioned only by a globally distributed order_id may force scatter-gather across all partitions unless a secondary projection exists. Similarly, storing all customer history under one customer key may solve routing but create an unbounded partition that grows for years. Good physical design therefore balances locality with boundedness.

The deliberately wrong response is to “fix” every query by adding a new duplicate table immediately. That can replace read amplification with uncontrolled write fan-out. The worksheet must also record mutation rate, synchronization tolerance, failure recovery, index/storage cost, and which fact is authoritative.

5. Invariants decide where flexibility stops

Not all AtlasMart data can accept the same consistency tradeoff. A search result may lag a catalog update for several seconds if freshness is visible and repairable. An order total must not silently diverge from accepted line items. Inventory that must never go negative may need single-owner serialization, conditional mutation, reservation, or a transactional workflow rather than a weakly synchronized duplicate. Model the strongest invariant first; then choose the weakest consistency and smallest coordination scope that still preserves it.

6. Production judgment and bridge

Before approving a physical model, review routing fan-out, maximum partition/object size, hot-key risk, write amplification, tail-latency sensitivity, durability, restore/rebuild path, tenant isolation, authorization on every derived path, observability, migration/rollback, and operational skill. A simulator can reveal hidden work, but real acceptance requires production-like data distributions and failure tests. The next lesson treats denormalization explicitly as precomputed work and makes its write/repair bill visible.

Wrong approach: design from entities alone and “let the database figure out the queries”

The failure is not that normalized models are bad. It is that a distributed physical design that ignores dominant query keys, cardinality, and bounds can turn an ordinary request into scatter-gather or an unbounded scan. Repair the design by recording the workload, preserving authority, and adding only the access paths whose benefit justifies their write and repair cost.

Verification, cleanup, and production checklist

Verification is the deterministic program output plus the reasoning checks below. Cleanup is simply deleting the local lesson1.py file because the lab creates no external service or persistent database. In production, repeat the design with representative cardinality/skew, tail-latency, failure injection, tenant-isolation tests, restore/rebuild drills, migration rollback, and cost/capacity evidence before committing to a physical model.

Check your understanding

  1. Why record result-size bounds in the worksheet?
  2. Why is scatter-gather not automatically wrong?
  3. What is the difference between a logical entity and a physical access path?
  4. Why must change rate be recorded before denormalizing?
  5. What decides whether a weaker model is acceptable?
Review the answers

1. Because a query that is fast for ten rows can become unsafe when the same key accumulates millions; boundedness is part of the access contract.

2. Some infrequent analytical or administrative queries can tolerate it. The problem is making it the hidden critical path for common low-latency operations.

3. The entity describes the business fact; the access path describes how a concrete request finds or maintains that fact efficiently.

4. Frequently changing duplicated facts create more write fan-out, stale windows, reconciliation work, and operational cost.

5. The business invariant and tolerated anomalies, not a generic database category or performance slogan.

References

Foundational statements are kept vendor-neutral. Version-sensitive examples use current official documentation and are labeled as examples rather than definitions.

Keep knowledge open

Help the academy stay free and grow.

If these tutorials save you time, a small donation supports new lessons, technical review, diagrams, examples, and long-term maintenance.

ETHEthereum / ERC-20 only
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0

Send only Ethereum or ERC-20 compatible assets to this address.