NoSQL Data Modeling: Design from Queries, Not Entities
The most expensive mistake teams make with NoSQL is bringing relational habits with them. They create a โtableโ per entity, sprinkle in references, and then discover โ usually in production โ that the database they chose for speed now needs twelve round-trips to render one screen. NoSQL databases are not โschemaless relational databases.โ They reward a fundamentally different modeling process, and this post explains it.
Audience: engineers who know relational modeling and are designing for DynamoDB, MongoDB, Cassandra, Redis, or similar systems for the first time.
The Inversion: Relational Models Data, NoSQL Models Queries
Relational modeling works forward from the domain: identify entities, normalize, and trust the query optimizer to answer any question with joins. That flexibility is the relational modelโs superpower โ and its cost, because joins across distributed machines are expensive, which is precisely why most NoSQL systems donโt offer them (or offer them reluctantly).
NoSQL modeling works backwards from the access patterns:
- List every query and write the application will perform. Literally enumerate them: โget user profile by ID,โ โlist a userโs 20 most recent orders,โ โget all items in an order.โ
- Estimate each patternโs frequency and latency requirement.
- Design the data layout so that each important read is served by a single lookup โ one key fetch, one partition read, one document.
- Accept whatever duplication that layout requires, and plan how writes keep the copies consistent.
The mantra worth memorizing: relational databases optimize for storage and flexibility; NoSQL databases optimize for known access at scale. If your access patterns are genuinely unknown, that is an argument for a relational database, not against modeling.
The Central Trade: Embed vs. Reference
Every NoSQL design decision eventually reduces to one question โ when two pieces of data are related, do you embed one inside the other, or store them separately and reference?
Embed when data is accessed together and owned together. An order and its line items are read together, written together, and line items have no life outside their order. Embedding them in one document means one read serves the whole order screen:
{ "order_id": "ord_9812", "customer_id": "cus_311", "status": "shipped", "items": [ { "sku": "KB-45", "name": "Keyboard", "qty": 2, "price": 45.0 }, { "sku": "MN-210", "name": "Monitor", "qty": 1, "price": 210.0 } ], "shipping_address": { "city": "Pune", "pincode": "411001" }}Notice name and price are copied from the product catalog into the order. In relational thinking this is a violation; here itโs correct twice over โ it makes the read self-contained, and it snapshots what the customer actually saw at purchase time.
Reference when data is shared, unbounded, or independently updated. The customer isnโt embedded in the order, because customers exist across thousands of orders and their profile changes independently. Similarly, never embed unbounded lists โ a userโs โactivityโ array that grows forever will eventually hit document size limits (16 MB in MongoDB, 400 KB per item in DynamoDB) and slow every read of the parent.
Three questions decide it:
- Read together? โ leans embed.
- Bounded size? โ embed only if yes.
- Updated from elsewhere? โ leans reference (or embed a deliberate snapshot).
The Model Families and What Theyโre Each Shaped For
โNoSQLโ covers four genuinely different data models. Choosing among them is a modeling decision:
Key-value (Redis, DynamoDB in its simplest use). The model is a hash map: opaque value behind a key. Blazing and rigid โ you get exactly the lookups you designed keys for. The modeling skill is key design: session:{user_id}, cart:{user_id}. Perfect for sessions, caches, feature flags, counters.
Document (MongoDB, Firestore, Couchbase). Values become structured, queryable JSON with secondary indexes. The modeling skill is the embed/reference balance above, plus index design. The natural fit is aggregate-shaped data: orders, profiles, product listings โ anything a screen displays as one cohesive object. (The next post in this series goes deep on document design.)
Wide-column (Cassandra, HBase, ScyllaDB). Data is organized by a partition key (which machine holds it) and a clustering key (sort order within the partition). The modeling skill is designing partitions so each query reads exactly one, already sorted:
-- Cassandra: "recent readings per device" as a table designed for that queryCREATE TABLE readings_by_device ( device_id text, reading_ts timestamp, value double, PRIMARY KEY ((device_id), reading_ts)) WITH CLUSTERING ORDER BY (reading_ts DESC);In Cassandra you create one table per query pattern and duplicate data across them at write time. Thatโs not a workaround; it is the intended methodology.
Graph (Neo4j, Neptune). Relationships become first-class stored objects, ideal when the questions are about connections โ covered in its own post later in this series.
Handling Duplication Without Getting Burned
Denormalization is the price of single-read access, and itโs payable โ but only with eyes open:
- Know your copy map. For every duplicated attribute, write down where copies live and what updates them. โProduct name appears in: catalog (source of truth), embedded order items (snapshot, never updated), search cache (refreshed hourly).โ
- Distinguish snapshots from mirrors. Order-item prices should diverge from the catalog (snapshot). A username shown on comments should follow the profile (mirror โ updated asynchronously, usually via an event or background job). Confusing the two produces either rewritten history or stale screens.
- Accept eventual consistency deliberately. If a username update takes thirty seconds to propagate to old comments, is that acceptable? Usually yes โ but decide it, donโt discover it.
A Worked Miniature: Modeling a Simple E-Commerce App on DynamoDB
Access patterns: (1) get customer profile, (2) list customerโs recent orders, (3) get one order with items, (4) get product details.
A common single-table design uses generic keys where several entity types share one table, laid out so each pattern is one query:
| PK | SK | contents |
|---|---|---|
CUS#311 | PROFILE | name, email |
CUS#311 | ORD#2026-06-01#9812 | status, total (order summary) |
ORD#9812 | ITEM#1 โฆ ITEM#n | embedded snapshot of each line |
PRD#KB-45 | DETAILS | catalog record |
Pattern 2 becomes a single query โ PK = CUS#311, SK begins_with ORD# โ returning orders already sorted by date because the date is in the sort key. That trick, encoding sort order into keys, is the wide-column mindset applied inside DynamoDB, and itโs the kind of design you simply cannot retrofit after launch. Model first.
When NoSQL Is the Wrong Answer
Honesty clause: reach for relational when access patterns are exploratory or ad hoc, when multi-entity transactions dominate (ledgers, inventory reservation), or when data volume is modest and always will be โ PostgreSQL at ordinary scale gives you flexibility, integrity, and joins for free. The strongest NoSQL designs come from teams who could articulate exactly why the relational model didnโt fit this workload.
The takeaway: in NoSQL, the schema lives in your key design and your duplication strategy, and itโs decided by the queries you enumerate before writing code. Skip that enumeration and you donโt have a schemaless database โ you have an unmodeled one.