One of the biggest mistakes developers make when moving from relational databases to NoSQL is trying to design their NoSQL schema the same way they’d design a relational one — normalize everything, avoid duplication, and rely on joins to pull related data together. That approach doesn’t just underperform in NoSQL systems; it often defeats the entire purpose of choosing NoSQL in the first place.
This article covers the core data modeling techniques that make NoSQL databases work well: denormalization, the aggregate model, embedding versus referencing, and the query-driven design philosophy that ties it all together.
The Fundamental Shift: Query-Driven Design
In relational database design, the standard approach is entity-relationship modeling followed by normalization — you identify your entities, define their relationships, and structure tables to eliminate data redundancy, typically following normal forms (1NF, 2NF, 3NF, and beyond). Queries are written afterward, against this normalized structure, using joins to reconstruct related data at query time.
NoSQL data modeling inverts this process. You start by identifying the specific queries and access patterns your application needs — “get a customer’s order history,” “find all products in a category,” “look up a user’s profile by username” — and then you design your data structures specifically to serve those queries as efficiently as possible, often before you even fully finalize what the “ideal” normalized shape of the data would look like.
This is sometimes summarized as: in relational databases, you model data and then figure out queries; in NoSQL databases, you figure out queries and then model data around them.
This shift matters because most NoSQL databases either don’t support joins at all, or support them in a limited and often expensive way. Without the safety net of joining data together at query time, your data needs to already be arranged in roughly the shape your application needs when it’s stored.
Denormalization: Trading Redundancy for Speed
Denormalization is the practice of duplicating data across multiple places rather than storing a single normalized copy and referencing it. In relational systems, denormalization is generally considered a last resort optimization technique used sparingly. In NoSQL systems, especially document and wide-column databases, it’s often the default, expected approach.
Why denormalize?
Without joins, if you need to display a blog post along with its author’s name and avatar, you have two choices: make two separate queries (one for the post, one for the author) and stitch them together in application code, or store a copy of the author’s name and avatar directly within the blog post document itself. The second approach — denormalization — means a single read gets you everything you need, which is dramatically faster, especially at scale and especially across a distributed cluster where a second query might hit a different node entirely.
Example — Normalized (relational) approach:
-- Posts table
| post_id | title | author_id |
|---------|-------------|-----------|
| 1 | "Hello Web" | 42 |
-- Authors table
| author_id | name | avatar_url |
|-----------|---------|--------------------|
| 42 | "Alice" | "/avatars/42.png" |
Example — Denormalized (document) approach:
{
"post_id": 1,
"title": "Hello Web",
"author": {
"id": 42,
"name": "Alice",
"avatar_url": "/avatars/42.png"
}
}
The tradeoff: update complexity. The obvious cost of denormalization is that if Alice changes her avatar, you now need to update it in every single post document where her info is duplicated, rather than in one central authors table. This is the central tension in NoSQL data modeling — you’re trading write-time complexity and consistency risk for read-time speed and simplicity.
When denormalization makes sense:
- Data that rarely changes (a product’s name, a user’s display name at time of purchase)
- Data that’s read far more often than it’s written (read-heavy workloads benefit most)
- Data where slight staleness is acceptable (an author’s avatar being a few minutes out of date in old posts usually doesn’t matter)
When to be more careful:
- Data that changes frequently and must always be accurate everywhere it appears (like account balances)
- Data with many-to-many relationships where duplication would multiply excessively across many documents
The Aggregate Model
The “aggregate” is one of the most important concepts in NoSQL data modeling, particularly for document and key-value databases. An aggregate is a cluster of related data that’s treated as a single unit for the purposes of data storage and retrieval — essentially, everything you’d need to satisfy a common query, bundled together into one object.
The concept comes from domain-driven design, where an “aggregate root” is an entity that owns and controls access to a group of related objects. In NoSQL terms, this translates directly: if you always fetch an order along with its line items together, they should probably live together as a single aggregate (a single document, in document-database terms), rather than being split into separate collections that require additional lookups.
Example — an order as an aggregate:
{
"order_id": "ord_9981",
"customer": {
"id": "cust_123",
"name": "Bob Smith",
"email": "bob@example.com"
},
"items": [
{ "product_id": "p001", "name": "Wireless Mouse", "qty": 2, "price": 25.00 },
{ "product_id": "p002", "name": "USB-C Cable", "qty": 1, "price": 12.99 }
],
"shipping_address": {
"street": "123 Main St",
"city": "Lahore",
"postal_code": "54000"
},
"total": 62.99,
"status": "shipped",
"created_at": "2026-08-10T14:22:00Z"
}
Everything needed to display, process, or ship this order lives in a single aggregate. There’s no need to join across a separate order_items table or a separate addresses table — one read retrieves the complete picture.
Choosing aggregate boundaries is the central design decision in this model, and it comes down to two questions:
- What data is always (or almost always) accessed together? If two pieces of data are basically always read and written as a unit, they belong in the same aggregate.
- What data changes at the same rate, and by whom? Data that changes independently, or is updated by different parts of the system at different times, is often better kept as a separate aggregate, even if it’s logically related.
For instance, a customer’s shipping address at the time of an order is a good candidate to embed directly in the order aggregate (since it represents a historical snapshot — where the order was actually shipped, even if the customer later moves), while the customer’s live current profile is better kept as its own separate aggregate that the order simply references by ID.
Embedding vs. Referencing
This is the most concrete, day-to-day decision you’ll make repeatedly when modeling data in a document database like MongoDB: should related data be embedded directly inside a parent document, or referenced by ID and stored as a separate document?
Embedding
Embedding means nesting related data directly within a document, as shown in the order example above with the customer info and items.
Advantages:
- Single query retrieves everything needed — no additional round trips
- Atomic updates to the whole aggregate in a single write operation
- Better read performance for data that’s always accessed together
Disadvantages:
- Can lead to large, unwieldy documents if the embedded data grows unbounded (imagine embedding every comment ever made on a blog post directly inside the post document — that could grow indefinitely)
- Duplicated data across many documents makes updates more complex, as discussed above
- Most databases impose document size limits (MongoDB’s is 16MB per document), which embedding can eventually hit for high-cardinality relationships
Referencing
Referencing means storing a related entity in a separate document/collection and linking to it by an ID, similar in spirit to a foreign key in a relational database.
// Blog post document
{
"post_id": "post_501",
"title": "Getting Started with NoSQL",
"author_id": "user_42"
}
// Separate author document
{
"user_id": "user_42",
"name": "Alice",
"bio": "Backend engineer and technical writer."
}
Advantages:
- Avoids data duplication for entities referenced from many places
- Keeps individual documents smaller and more manageable
- Works well for one-to-many or many-to-many relationships where the “many” side is large or unbounded
Disadvantages:
- Requires a second query (or an explicit join-like operation, such as MongoDB’s
$lookupaggregation stage) to retrieve the referenced data - No enforced referential integrity by default — the database won’t automatically prevent you from referencing a deleted document, so your application needs to handle this
A Practical Rule of Thumb
- Embed when the relationship is “one-to-few” (a handful of related items, like a few shipping addresses on a user profile) or when the child data has no independent existence or query need outside its parent.
- Reference when the relationship is “one-to-many” or “many-to-many” at meaningful scale (a product with thousands of reviews, or a many-to-many relationship like students and courses), or when the referenced entity needs to be queried and updated independently.
- Consider a hybrid approach for “one-to-squillions” relationships — for example, embedding only the 5 most recent comments on a post directly for quick display, while storing the full comment history as a separate, referenced collection for when a user wants to see everything.
Data Modeling Techniques by Database Type
Different NoSQL categories favor different modeling techniques, even though the core principles (query-driven design, denormalization, aggregation) apply broadly across all of them.
Document databases (MongoDB, Couchbase): Rely heavily on the aggregate model with embedded sub-documents and arrays. The general guidance is to design one document type per aggregate root, embedding tightly coupled data and referencing loosely coupled or high-cardinality data.
Key-value stores (Redis, DynamoDB): Modeling revolves almost entirely around key design, since there’s no native way to query by anything other than the key. Composite, hierarchical keys (like user#123#orders#2026-08) simulate some of the organizational benefits of embedding or aggregation.
Column-family stores (Cassandra, HBase): Use “one table per query” design, where the same underlying data might be duplicated across multiple tables, each structured to serve one specific access pattern efficiently via partition and clustering keys.
Graph databases (Neo4j): Take an almost opposite approach — rather than denormalizing to avoid joins, graphs make relationships cheap to traverse natively, so data modeling here focuses on identifying the right nodes, relationship types, and properties rather than on embedding or duplication.
Practical Example: Modeling a Blogging Platform
Let’s walk through modeling a simple blog platform to see these techniques applied together.
Requirements (our queries):
- Display a blog post with author name and avatar
- Show the 5 most recent comments on a post, with a “load more” option
- List all posts by a given author
- Show a user’s profile with their bio and follower count
Resulting document design:
// posts collection
{
"post_id": "post_501",
"title": "Getting Started with NoSQL",
"body": "...",
"author": {
"id": "user_42",
"name": "Alice",
"avatar_url": "/avatars/42.png"
},
"recent_comments": [
{ "user": "Bob", "text": "Great post!", "created_at": "2026-08-14T10:00:00Z" },
{ "user": "Carol", "text": "Very helpful, thanks.", "created_at": "2026-08-14T09:15:00Z" }
],
"comment_count": 47,
"created_at": "2026-08-10T08:00:00Z"
}
// comments collection (full history, referenced)
{
"comment_id": "cmt_9001",
"post_id": "post_501",
"user": "Bob",
"text": "Great post!",
"created_at": "2026-08-14T10:00:00Z"
}
// users collection
{
"user_id": "user_42",
"name": "Alice",
"bio": "Backend engineer and technical writer.",
"avatar_url": "/avatars/42.png",
"follower_count": 1250
}
Here, the author’s name and avatar are denormalized directly into each post (query 1 solved with a single read). The 5 most recent comments are embedded for fast display, while the full comment history lives in a separate, referenced collection (query 2 solved efficiently for the common case, with a fallback query for “load more”). Listing all posts by an author (query 3) would use an index on author.id within the posts collection. The user profile (query 4) is its own aggregate with the follower count maintained as a denormalized counter, updated whenever a follow/unfollow event occurs, rather than counting follower relationships on every profile view.
Handling Consistency in Denormalized Data
Because denormalization inherently duplicates data, keeping those duplicates in sync becomes an application-level responsibility in most NoSQL systems. A few established patterns help manage this:
Eventual consistency with background jobs: Accept that duplicated data might be briefly stale, and use background processes (or database change streams, like MongoDB’s Change Streams) to propagate updates to all denormalized copies asynchronously.
Versioned or timestamped snapshots: For data like shipping addresses embedded in historical orders, don’t try to keep them in sync at all — treat the embedded copy as an intentional historical snapshot that shouldn’t change even if the source data does.
Application-level fan-out on write: When a piece of frequently-duplicated data changes (like a user’s display name), trigger an update job that fans out and updates all the denormalized copies across affected documents. This adds write-time complexity but keeps reads fast and simple.
Counters and aggregation via atomic operations: Rather than recalculating a comment_count by counting related documents every time, maintain it as a field that’s atomically incremented/decremented whenever a comment is added or removed, using the database’s atomic update operators (like MongoDB’s $inc).
Advantages of These Modeling Techniques
- Read performance: Fewer round trips and no expensive joins mean significantly faster reads for well-designed aggregates.
- Horizontal scalability: Self-contained aggregates are much easier to shard and distribute across a cluster than deeply normalized, interlinked tables.
- Natural fit for application objects: Aggregates often map closely to how data is actually used in application code (an “order object” in code corresponds neatly to an “order document” in the database).
- Flexibility for evolving schemas: Adding new fields to an aggregate doesn’t require a disruptive schema migration the way adding a column to a large relational table might.
Limitations and Challenges
- Data duplication risk: Poorly managed denormalization can lead to inconsistent data across a system if update propagation isn’t handled carefully.
- Larger storage footprint: Duplicating data across documents inherently uses more storage than a fully normalized schema.
- Harder ad-hoc querying: Data optimized for specific known queries can become awkward to query in new, unanticipated ways later, unlike a normalized relational schema which tends to be more flexible for arbitrary future queries.
- Requires deep understanding of access patterns upfront: Query-driven design demands that you know your application’s query patterns well before modeling data, which can be difficult in early-stage or rapidly evolving products.
Security Considerations
Denormalized and embedded data models can inadvertently increase the exposure surface of sensitive data — if personal information is duplicated across many documents rather than centralized in one place, it becomes harder to consistently apply access controls, encryption, or deletion (particularly relevant for compliance regimes like GDPR’s “right to be forgotten,” which becomes more complex when a person’s data might be scattered as embedded copies across thousands of unrelated documents).
A practical mitigation is to avoid embedding highly sensitive fields (like national ID numbers or payment details) directly into frequently-duplicated aggregates; instead, reference a centralized, access-controlled collection for sensitive data even if it costs an extra query, and reserve embedding for lower-sensitivity, display-oriented fields like names and avatars.
Best Practices
- Start with your queries, not your entities. List every access pattern your application needs before writing a single schema.
- Default to embedding for one-to-few relationships, and reference for one-to-many or many-to-many relationships at scale.
- Keep aggregates focused on a single transactional unit. If two pieces of data are always updated together, they usually belong in the same aggregate; if they’re updated independently, keep them separate.
- Maintain denormalized counters and summaries with atomic operations, not by recalculating on every read.
- Document your denormalization decisions. Future developers (including future you) need to know which fields are duplicated and where, so updates don’t silently create inconsistency.
- Avoid unbounded embedded arrays. Cap embedded lists (like “5 most recent comments”) and reference the full collection separately for anything that can grow indefinitely.
- Revisit your model as query patterns evolve. NoSQL schemas aren’t set in stone — as your application’s access patterns change, be willing to restructure aggregates to match.
Conclusion
Effective NoSQL data modeling isn’t about avoiding structure — it’s about designing structure around how your application actually uses data, rather than around abstract normalization rules inherited from the relational world. Denormalization trades some update complexity and storage overhead for dramatically faster reads. The aggregate model gives you a clear framework for deciding what data belongs together. And the embedding-versus-referencing decision is the practical, everyday choice that determines whether your documents stay fast and manageable or become bloated and hard to maintain.
Master these three techniques together, always starting from your application’s real query patterns, and you’ll avoid the single most common pitfall developers hit when they bring relational instincts into a NoSQL project: modeling data the way it looks, instead of the way it’s actually used.