Skip to main content

Query-First Data Modeling

Level 2: Level 2: Data Storage & Modelingmedium30 minaccess-patternsmodelingnosql

Enumerate access patterns first, then design composite keys so each pattern is a single-partition lookup, with write sharding for hot partitions and GSIs added per named pattern.

List the access patterns first

Relational modeling starts with entities: you draw the nouns, normalize them, and trust the query planner to join them at read time. NoSQL modeling inverts this completely. In a system like DynamoDB or Cassandra there is no join and no flexible query planner, so if you model entities first and hope to query them later, you will find that the query you need is impossible or requires a full-table scan. The mindset shift is: list the access patterns first, then design keys and tables so each pattern is a single lookup.

Start by writing every read and write your feature performs, as concrete sentences: "list a user's conversations, most recent first," "load the last 50 messages in a thread," "get the unread count per conversation." Each of these must become one query against one partition. If any access pattern would require scanning or a scatter-gather, the model is wrong, not the database.

Composite keys co-locate the data

The core tool is the composite primary key: a partition key plus a sort key. The partition key decides which physical node the item lives on; everything with the same partition key is stored together, sorted by the sort key. To store a thread's messages, set partition key = THREAD#<id> and sort key = MSG#<timestamp>; "load the last 50 messages" is then a single Query on that partition, ScanIndexForward=false, Limit=50. No join, one partition, one round trip.

Modeling relationships is about embedding versus referencing. A one-to-many where the many are always read with the one, and are bounded, can be embedded: store the child items in the same partition as the parent (same partition key, distinct sort keys). If the many are large or unbounded, or read independently, reference them: give them their own partition and store just an id. A many-to-many is handled with an adjacency-list pattern or a global secondary index that lets you query the relationship from both directions.

Check yourself

Same partition or its own partition? Decide by how the related data is read and whether it is bounded.

Order line items: bounded, always rendered with the order
A user's clickstream events, accumulating forever
Product reviews that load on their own page, independent of the product
A conversation's participant list: a handful of members, always shown with the thread

Interview nuance: the tell of a weak NoSQL answer is designing a users table, a conversations table, and a messages table that mirror a relational schema, then discovering you cannot list a user's conversations without a scan. The strong answer often puts multiple entity types in one table (single-table design), keyed so each access pattern hits one partition.

Table
Single-table design: three item types in one table, keyed so every access pattern on the list is one contiguous Query slice against one partition, never a scan.
partition keysort keyitem typeaccess pattern it serves
USER#7CONV#2026-07-23T09:14conversation pointerlist user 7's conversations, most recent first: one Query on USER#7, descending
USER#7CONV#2026-07-21T11:02conversation pointersame slice, next row
THREAD#123MSG#2026-07-23T09:14messageload the last 50 messages: Query THREAD#123, ScanIndexForward=false, Limit=50
THREAD#123MSG#2026-07-23T09:12messagesame partition, already sorted by the timestamp in the sort key
THREAD#123MEMBER#USER#7participantbounded and always read with the thread, so it is embedded in the same partition
Single-table design: three item types in one table, keyed so every access pattern on the list is one contiguous Query slice against one partition, never a scan.
Check yourself
A tasks table uses partition key 'status'. Every new task is written with status ACTIVE. Writes start throttling, so you provision 10x more total table capacity. What happens?

Hot partitions and secondary indexes

The failure mode you must actively design against is the hot partition. Because the partition key routes to a physical node with a throughput ceiling (DynamoDB caps a single partition around 3,000 read and 1,000 write units per second), a key that concentrates traffic becomes a bottleneck no matter how much total capacity you provision. A celebrity user's thread, or a partition key of status=ACTIVE that every write touches, will throttle. Spread heat with a high-cardinality partition key and, for known-heavy keys, write sharding: append a suffix (THREAD#123#<0..9>) to fan one logical partition across ten physical ones, then scatter-read the ten on the way out.

Secondary indexes buy additional access patterns without a second table. A global secondary index (GSI) has its own partition and sort key over the same items, so you can query by a different attribute. GSIs are eventually consistent and cost extra write capacity (every base write replicates to the index), so add them per access pattern, not by default. A local secondary index (LSI) shares the partition key but offers an alternate sort key, and can be strongly consistent.

Recap: enumerate access patterns first, turn each into a single-partition lookup using composite partition and sort keys, choose embedding versus referencing by how the related data is read, design the partition key to avoid hot partitions, and add secondary indexes only to serve a named additional access pattern.

Check yourself
You are about to model a chat app on DynamoDB. What is the first artifact you write down?

Apply

Your turn

The task this lesson builds to.

Design the primary keys and item layout for the top 3 access patterns of a chat app (list conversations, load a thread, unread counts).

Think about

  1. What are the access patterns, and how does each become a single lookup?
  2. How do partition key + sort key co-locate related data?
  3. How do you avoid a hot partition in the key design?

Practice

Make it stick

A second problem on the same idea, so it survives past today.

Design the DynamoDB key schema for Slack-scale messaging where a single channel can have 500,000 members and a viral message triggers hundreds of thousands of unread-count updates in seconds. Show how you keep 'list my channels,' 'load channel history,' and 'unread badge' as single lookups without a hot partition melting down.

Think about

  1. What does one message cost if unread is a per-member counter in a 500k-member channel?
  2. How does a sequence-number difference turn an O(members) write into O(1)?
  3. Where does channel history need write sharding, and what does the read pay for it?