StackPractices
intermediate By Mathias Paulenko

NoSQL Data Modeling Patterns

A practical guide to NoSQL data modeling: embedding vs referencing, access pattern-driven design, and patterns for MongoDB, DynamoDB, Cassandra, and Redis.

Overview

NoSQL databases abandoned the rigid table-row model in favor of flexible schemas optimized for specific access patterns. Document stores (MongoDB), key-value stores (Redis), wide-column stores (Cassandra, DynamoDB), and graph databases (Neo4j) each follow different modeling principles, and a schema that works beautifully in one can be a disaster in another.

This guide is for engineers who already know relational modeling and need to design — or fix — a NoSQL schema. It covers the core decision every NoSQL model comes down to (embed or reference), the flagship pattern for each major engine family, and the operational limits that turn a clean design into a production incident when you ignore them.

When to Use This Guide

Reach for this guide when:

  • Your schema evolves frequently and migrations on a relational model would be painful
  • Your read patterns are known upfront and you want each served by a single query
  • You need horizontal scaling beyond what a relational primary can offer
  • Your data is naturally hierarchical (documents) or relationship-heavy (graphs)
  • Extreme throughput or single-digit-millisecond latency justifies a specialized store

If you’re still deciding which NoSQL engine fits your workload, read NoSQL Database Selection — MongoDB, DynamoDB, Cassandra first and come back here for the modeling work.

The Core Rule: Model for Queries

In relational modeling you normalize first and answer queries with joins. NoSQL inverts that order: you write down the access patterns your application actually executes, then shape the data so each one resolves in a single read.

Concretely, before touching a schema, list:

  1. Every query your app runs — with its frequency. A query that runs 10,000 times per second deserves a dedicated structure; one that runs hourly can tolerate a scan or an application-side join.
  2. Cardinality per relationship — is it one-to-few (a post’s tags), one-to-many (an order’s items), or one-to-unbounded (a user’s events)? Cardinality is what decides embedding vs referencing.
  3. Read/write ratio — read-heavy data can be denormalized aggressively; write-heavy data pays for every duplicated field on each update.
  4. Consistency requirements — which fields can be stale for seconds, and which can’t be stale at all?

A normalized model answers “what is the data?” A NoSQL model answers “how is it read?” If your first modeling question is about entities instead of queries, you’re still designing for SQL.

Embedding vs Referencing

The fundamental choice in document modeling: do you nest related data inside the parent document, or store it separately and link by ID?

ApproachBest ForTrade-off
EmbeddingOne-to-few, data read together, rarely updated independentlyLarger documents, duplication on update
ReferencingOne-to-many, unbounded growth, independent updatesRequires application-level joins
flowchart diagram: Are the entities read together?
// MongoDB: Embedded (order with items)
{
    _id: "order-001",
    customerId: "cust-123",
    items: [
        { productId: "p1", name: "Widget", qty: 2, price: 10.00 },
        { productId: "p2", name: "Gadget", qty: 1, price: 25.00 }
    ],
    total: 45.00
}

// MongoDB: Referenced (separate collections)
// orders collection
{ _id: "order-001", customerId: "cust-123", itemIds: ["li-1", "li-2"] }
// line_items collection
{ _id: "li-1", productId: "p1", name: "Widget", qty: 2, price: 10.00 }
{ _id: "li-2", productId: "p2", name: "Gadget", qty: 1, price: 25.00 }

The decision tree above encodes the rules of thumb that hold across document databases:

  • Embed when the child list stays small — a user’s addresses (2-3), a post’s tags (5-10). Anything bounded by a business rule, not by user growth, is safe to embed.
  • Reference when cardinality is unbounded — comments, events, activity feeds. An embedded list that grows forever eventually hits the document size limit and forces an emergency migration under load.
  • Mind the hard limits — MongoDB documents cap at 16 MB, and WiredTiger rewrites the whole document on update. A 200 KB document with 1,000 embedded comments means every comment edit rewrites all of them.
  • Duplication isn’t free on the write side — embedding copies data; when that data changes, you update every copy. If a product name appears in 50,000 order documents, renaming it becomes a migration, not an UPDATE.

There’s also a middle ground worth knowing: extended reference — embed a few fields (name, avatar) for display while referencing the full entity. You get one-query reads without duplicating everything that changes.

DynamoDB Single-Table Design

DynamoDB has no joins at all, so related entities live in one table, separated by key prefixes rather than collections.

// DynamoDB: Single table with overloaded GSI
{
    "PK": "USER#123",
    "SK": "PROFILE",
    "name": "Alice",
    "email": "alice@example.com"
}
{
    "PK": "USER#123",
    "SK": "ORDER#001",
    "total": 45.00,
    "status": "shipped"
}
{
    "PK": "ORDER#001",
    "SK": "DETAIL",
    "items": [...]
}

// Query all orders for a user
Query PK = "USER#123" AND begins_with(SK, "ORDER#")

// Query order details
Query PK = "ORDER#001"

Two things make or break a single-table design:

Hot partitions. DynamoDB partitions data by partition key. If USER#123 accounts for 40% of traffic, that partition becomes a throughput ceiling no provisioned capacity can fix. High-cardinality keys (user IDs, timestamps with entropy) spread load; low-cardinality keys (status, type) concentrate it. When a hot key is unavoidable, use write sharding — suffix the key (ORDER#001#shard-3) and scatter reads across the suffixes.

GSI overloading. A Global Secondary Index can serve a completely different access pattern on the same items. Store SK = "ORDER#001" under GSI1PK = "USER#123" and the index gives you orders-by-user for free. One table, many query shapes — that’s the entire trick. The cost: every GSI replicates storage and adds write amplification, so design indexes only for queries you actually run.

Cassandra Wide-Row Pattern

Cassandra’s mental model is “sorted map inside a partition.” You choose the partition key, and rows inside that partition are stored pre-sorted by clustering columns — which is exactly what makes time-series queries cheap.

-- Time-series data: one row per sensor, columns for time buckets
CREATE TABLE sensor_readings (
    sensor_id UUID,
    day DATE,
    hour INT,
    minute INT,
    temperature DOUBLE,
    humidity DOUBLE,
    PRIMARY KEY ((sensor_id, day), hour, minute)
) WITH CLUSTERING ORDER BY (hour DESC, minute DESC);

-- Query: last 24 hours for a sensor
SELECT * FROM sensor_readings
WHERE sensor_id = ? AND day >= ?;

The primary key does all the work here. (sensor_id, day) is the partition key — it decides which node holds the data and keeps a sensor’s day contiguous. hour, minute are clustering columns that sort within the partition, so “latest readings” is a sequential read with no index. ALLOW FILTERING is the escape hatch that looks tempting in development and destroys clusters in production: it scans every partition.

Two limits to respect:

  • Partition size. Keep partitions under ~100 MB and under ~100k rows. A sensor writing every second hits 86,400 rows per day — that’s why the model buckets by day instead of partitioning by sensor alone.
  • Tombstones. Deletes write markers instead of removing data immediately. High-delete workloads accumulate tombstones that reads must skip past; use TTL (INSERT ... USING TTL 86400) or time-bucketed tables you can drop wholesale instead of deleting rows.

Redis Data Structure Patterns

Redis modeling isn’t about tables at all — it’s about picking the structure that makes your query O(1) or O(log n). Choosing the structure is the schema design.

# Leaderboard with sorted sets
import redis
r = redis.Redis()

r.zadd('leaderboard:2024', {'alice': 1500, 'bob': 1200, 'charlie': 1800})
top_players = r.zrevrange('leaderboard:2024', 0, 9, withscores=True)

# Rate limiter with sliding window
pipe = r.pipeline()
pipe.zremrangebyscore('rate:user:123', 0, time.time() - 60)
pipe.zcard('rate:user:123')
current_count = pipe.execute()[1]
if current_count < 100:
    r.zadd('rate:user:123', {str(time.time()): time.time()})

The mapping that covers most use cases:

  • Sorted Set — leaderboards, rate limiters, priority queues. Rank operations are O(log n); ZREVRANGE gives you a top-N with scores in one call.
  • Hash — objects and counters (HINCRBY, HGETALL). Cheaper than serializing a JSON blob per user.
  • String + TTL — caches, sessions, idempotency keys. SET key val EX 3600 NX is the entire pattern.
  • Pub/Sub or Streams — transient fan-out vs persistent event logs. Streams keep history and consumer groups; Pub/Sub drops messages to offline subscribers.

The trade-off nobody prices upfront: everything lives in RAM. A “cheap” design that stores 10 GB in Redis costs real money on managed services — model for eviction policies (allkeys-lru for caches, volatile-ttl for expiring data) and set TTLs on everything that should expire.

Graph Modeling in Neo4j

Graph databases flip the question again: instead of embedding or referencing, you model relationships as first-class citizens. In Neo4j you store nodes (entities) and relationships (typed edges with properties), and queries traverse edges instead of joining tables.

// Find friends of friends who liked a post
MATCH (u:User {id: "user-123"})-[:FRIEND]->(f:User)-[:LIKED]->(p:Post)
WHERE p.publishedAt > date("2026-01-01")
RETURN DISTINCT f.name, p.title
LIMIT 10;

Model for traversals, not for entities: a (:User)-[:FRIEND]->(:User) edge makes “friends of friends” a two-hop walk, where the relational equivalent is a recursive join that gets exponentially expensive. Create relationships in the direction your queries traverse, put frequently-filtered attributes on the edges themselves (like SINCE on FRIEND), and avoid super-nodes — a single node with 10 million relationships slows every traversal that touches it.

Pattern Selection at a Glance

EngineFlagship patternYour key decisionHard limit to watch
MongoDBEmbed vs referenceCardinality of each relationship16 MB per document
DynamoDBSingle-table designPartition key cardinalityHot partition throughput
CassandraWide rowsPartition key + clustering order~100 MB per partition, tombstones
RedisStructure mappingWhich structure answers the queryEverything lives in RAM
Neo4jRelationship-firstEdge direction and propertiesSuper-nodes

When NoSQL Is the Wrong Choice

The honest counterpoint, because modeling around a bad fit is worse than picking the right database:

  • Ad-hoc queries and reporting. If business users need to ask questions nobody anticipated, a relational engine with joins wins every time. NoSQL optimizes for known access patterns.
  • Complex multi-entity transactions. Banking-style invariants across entities are what ACID databases exist for. Sagas and outbox patterns handle this, but they’re engineering effort you’d spend on infrastructure, not product.
  • Small, well-understood data. A 50 GB dataset with three relationships is a boring Postgres table, not a distributed system. Boring is good.

Pick NoSQL when the access patterns are known, the scale or flexibility argument is real, and the team is ready to own the operational model — not because it’s the modern default.

Worked Example: Blog Platform with MongoDB

Theory aside — here’s a real schema decision worked end to end.

System: Blog platform with 1M posts, 10M comments
Access patterns:
  1. Read full post with author and comments (90% of reads)
  2. List posts by author
  3. List posts by tag
  4. Search posts by text

Embedded model (post + comments):
  db.posts.insertOne({
    _id: "post-001",
    title: "NoSQL Patterns in Practice",
    slug: "nosql-patterns-practice",
    author: {
      id: "user-123",
      name: "Ana Garcia",
      avatar: "/avatars/ana.jpg"
    },
    tags: ["nosql", "mongodb", "modeling"],
    content: "Post content...",
    comments: [
      {
        id: "c-001",
        author: "Carlos",
        text: "Great article",
        createdAt: ISODate("2026-03-01"),
        likes: 12
      },
      {
        id: "c-002",
        author: "Beatriz",
        text: "I have a question",
        createdAt: ISODate("2026-03-02"),
        likes: 3
      }
    ],
    commentCount: 2,
    publishedAt: ISODate("2026-03-01"),
    updatedAt: ISODate("2026-03-05")
  });

Indexes:
  db.posts.createIndex({ slug: 1 }, { unique: true });
  db.posts.createIndex({ "author.id": 1, publishedAt: -1 });
  db.posts.createIndex({ tags: 1, publishedAt: -1 });
  db.posts.createIndex({ title: "text", content: "text" });

Queries:
  // Full post by slug (single query)
  db.posts.findOne({ slug: "nosql-patterns-practice" });

  // Posts by author (paginated)
  db.posts.find({ "author.id": "user-123" })
    .sort({ publishedAt: -1 })
    .skip(0)
    .limit(10);

  // Posts by tag
  db.posts.find({ tags: "mongodb" })
    .sort({ publishedAt: -1 })
    .limit(20);

The four access patterns map directly onto four indexes — that’s the “model for queries” rule working. Notice the embedded author object: it’s the extended-reference trick, keeping name and avatar denormalized for display while author.id still links to the full user record.

The one fragile choice here is embedded comments. It works at launch — posts average a dozen comments — but viral threads or a spam wave will grow the array unboundedly:

CriterionEmbedReference
Comments per post< 100> 1000
Update frequencyLowHigh
Comment sizeShortLong
Independent comment queriesNoYes

Migration trigger: move to a separate comments collection when documents approach the 16 MB limit, when write latency degrades on comment edits, or when product asks for comment pagination independent of the post. Keep commentCount denormalized on the post either way — it avoids a COUNT query on every read.

Eventual Consistency Between Collections

Once you reference instead of embed, you own the consistency between documents. Two patterns handle it:

Outbox pattern. When you update a collection, write an event document to an outbox collection inside the same transaction. A separate process reads the outbox and applies the change to dependent collections — say, updating a denormalized authorName across 10,000 posts when the user renames. Guaranteed delivery, no dual-write race.

Change streams. MongoDB’s change streams let you react to writes without an outbox: listen to users, update downstream denormalizations on change events. Simpler operationally, but the consumer must be idempotent — streams can redeliver events after failures.

For genuinely atomic multi-document updates, MongoDB has supported multi-document transactions since 4.0. They work, but they carry performance costs — reach for them for real invariants (money moving between accounts), not as a default.

Common Mistakes

  • Applying relational modeling to NoSQL — normalizing for consistency is correct in SQL; denormalizing for reads is correct here. A “clean” normalized NoSQL schema just moves the join cost into your application layer.
  • Unbounded embedded arrays — a list that grows forever causes document bloat and eventually hits the size limit. Comments, events, and feeds almost always belong in their own collection.
  • Ignoring access patterns — designing around entities instead of queries means every read becomes a scan or a multi-hop fetch.
  • No pagination strategy — skip() gets slower as offsets grow. Use keyset pagination (filter by id or timestamp of the last item) for large result sets.
  • Treating all NoSQL databases the same — MongoDB embedding, DynamoDB single-table, and Cassandra wide-rows are completely different approaches. A pattern learned on one doesn’t port to the others.
  • Low-cardinality partition keys — in DynamoDB and Cassandra, keys like status or country concentrate load on a few partitions. Pick high-cardinality keys or shard artificially.

Troubleshooting

  • Query is slow in MongoDB after adding an index: check explain("executionStats") — the index may not cover the sort order, or the query filters a non-indexed field. Compound index order matters: equality fields first, then sort, then range.
  • DynamoDB throttling despite provisioned capacity: you almost certainly have a hot partition. Check CloudWatch ThrottledRequests per key, and consider write sharding or a better key.
  • Cassandra read latency climbing over weeks: usually tombstone accumulation from deletes or TTL churn. Verify with nodetool cfstats (tombstones per read) and switch to time-bucketed tables or longer TTLs.
  • Redis memory growing without bound: check INFO memory and audit keys without TTL (SCAN + TTL). Set an eviction policy and expire everything that isn’t permanent.
  • Eventually consistent data never converging: your outbox consumer or change-stream listener is probably dying silently. Alert on outbox lag — unprocessed events older than a threshold mean a broken consumer.

Production Checklist

  • Measure partition health: monitor DynamoDB throttled requests and Cassandra partition sizes before they become incidents.
  • Track document growth: alert on documents approaching size limits — 16 MB is a hard failure, not a degradation.
  • Set TTLs everywhere data expires: caches, sessions, rate-limit buckets. Unbounded keys are unbounded bills.
  • Test migrations under load: embedded → referenced migrations touch every document; rehearse on a production-sized dataset in staging.
  • Document the denormalizations: every duplicated field needs a note about who owns the sync — code, outbox, or manual.

Further Reading

Frequently Asked Questions

When should I use a document store vs a relational database?

Use documents when schema flexibility, hierarchical data, and read-heavy workloads dominate. Use relational when ACID transactions, complex joins, and strict schema enforcement are required.

Can I enforce referential integrity in NoSQL?

Generally no, not at the database level. Applications must enforce constraints, or use eventual consistency patterns like saga transactions.

How do I migrate schema in NoSQL?

Use lazy migration: update application code to handle both old and new formats, and migrate data on read or in background jobs.

What's the maximum document size in MongoDB and what happens when I hit it?

16 MB per document. Writes that would exceed it fail — you can't append past the limit. Design unbounded lists as separate collections before you get near it, not after the first failed write.

How do I paginate in NoSQL without OFFSET?

Use keyset (cursor) pagination: remember the sort key of the last item shown and query for items after it. It stays O(1)-ish at any depth where skip() degrades linearly.

Should I denormalize everything for read speed?

Denormalize what you read together and update rarely. Every duplicated field is a field you must keep in sync — denormalize for the access patterns you've got, not for hypothetical ones.