Skip to content
ExponaBLOG
MONGODB, NOSQL & DATA MODELING · DAY 068 / 100

MongoDB data modeling interview questions for production

Questions about schema design.

Expona Admin7 min read

Production data modeling is where MongoDB decisions either scale smoothly or create costly refactors. We've fielded these MongoDB data modeling interview questions for production countless times when hiring and onboarding engineers, and the answers separate teams that think operationally from those that don't.

Whether you're preparing for an interview, architecting a new system, or reviewing someone else's schema, the core patterns repeat. Let's walk through the questions that matter.

Embed vs reference trade-offs

This is the first question almost every interviewer asks, and for good reason: it shapes everything downstream.

The question: When do you embed a document inside another versus store it separately and reference it by ID?

The practical answer: Embed when the child data is accessed almost always with the parent, is bounded in size, and updates rarely or independently. Reference when the child can exist independently, grows unbounded, or is shared across many parents.

Consider an e-commerce order system. An order has items, customer address, and payment info. The items and address are tightly coupled to the order—you almost never query them without the order. Embed them:

javascript
db.orders.insertOne({
  _id: ObjectId(),
  customerId: 12345,
  status: "shipped",
  items: [
    { sku: "SHIRT-001", qty: 2, price: 29.99 },
    { sku: "PANT-002", qty: 1, price: 79.99 }
  ],
  shippingAddress: {
    street: "123 Main St",
    city: "Portland",
    state: "OR",
    zip: "97201"
  },
  createdAt: ISODate("2025-01-15T10:30:00Z")
});

But the customer themselves? They exist independently, have their own profile, payment methods, and preferences. They're referenced by many orders. Reference them:

javascript
db.orders.insertOne({
  _id: ObjectId(),
  customerId: ObjectId("507f1f77bcf86cd799439011"),
  status: "shipped",
  items: [...],
  createdAt: ISODate("2025-01-15T10:30:00Z")
});

db.customers.insertOne({
  _id: ObjectId("507f1f77bcf86cd799439011"),
  email: "user@example.com",
  preferences: { notifications: true },
  createdAt: ISODate(...)
});

The trade-off: embedding gives you atomic writes and simpler queries but locks you into data duplication and size limits (16 MB per document). Referencing requires multiple round trips and more complex queries, but keeps data normalized and independent.

In interviews, mention both and explain your choice based on access patterns, not philosophy.

How to model many-to-many relationships

The question: How would you model students and courses where many students take many courses, and you frequently query by both sides?

The practical answer: You typically need a junction collection, just like in SQL, but MongoDB gives you more flexibility in how you structure it.

Option 1: Denormalize on both sides (works for smaller many-to-many sets):

javascript
db.students.insertOne({
  _id: ObjectId("a"),
  name: "Alice",
  courseIds: [ObjectId("c1"), ObjectId("c2")]
});

db.courses.insertOne({
  _id: ObjectId("c1"),
  title: "Data Modeling 101",
  studentIds: [ObjectId("a"), ObjectId("b")]
});

Option 2: Use a junction collection (cleaner, scales better):

javascript
db.enrollments.insertOne({
  _id: ObjectId(),
  studentId: ObjectId("a"),
  courseId: ObjectId("c1"),
  enrolledAt: ISODate(),
  grade: "A"
});

// Query: which courses is Alice taking?
db.enrollments.find({ studentId: ObjectId("a") });

// Query: who is taking Data Modeling 101?
db.enrollments.find({ courseId: ObjectId("c1") });

The junction collection approach wins in production because:

  • You can store enrollment metadata (grade, enrolledAt, status) without touching either parent.
  • Scaling to thousands of enrollments doesn't bloat parent documents.
  • Queries from both directions are equally efficient with proper indexing.
  • Maintenance is simpler—you only update one document on enroll/unenroll.

Real teams run into trouble trying to keep bidirectional denormalization in sync. The junction collection eliminates that operational burden.

How to handle unbounded growth

The question: A user has billions of events. Embedding them in the user document will exceed 16 MB. How do you handle it?

The practical answer: Don't embed unbounded arrays. Use a separate collection and reference it, or use the "outlier" pattern for mixed scenarios.

For a metrics or analytics system where a user generates events continuously:

javascript
// Users collection stays small
db.users.insertOne({
  _id: ObjectId("user1"),
  email: "alice@example.com",
  createdAt: ISODate()
});

// Events grow unbounded in a separate collection
db.events.insertOne({
  _id: ObjectId(),
  userId: ObjectId("user1"),
  type: "page_view",
  url: "/products",
  timestamp: ISODate(),
  sessionId: "sess-123"
});

// Index for efficient queries
db.events.createIndex({ userId: 1, timestamp: -1 });

If you have a small number of "hot" items per user (say, favorite bookmarks) but can also have thousands overall, use the outlier pattern:

javascript
db.users.insertOne({
  _id: ObjectId("user1"),
  email: "alice@example.com",
  bookmarks: [ /* first 100 */ ],
  bookmarkCount: 15000,
  hasOverflow: true
});

db.userBookmarkOverflow.insertOne({
  userId: ObjectId("user1"),
  bookmarks: [ /* the rest */ ]
});

This keeps the hot path fast (embed common cases) while gracefully handling outliers.

How to choose a shard key

The question: You're sharding a collection with 10 billion documents. What makes a good shard key, and what happens if you choose badly?

The practical answer: A good shard key distributes writes evenly across shards, supports your common queries, and grows monotonically if possible. A bad one causes hot shards, resharding nightmares, and query routing overhead.

Bad examples:

  • status — Only a few values (active, inactive, archived). Writes cluster on the "active" shard.
  • isDeleted — Boolean only. Half your data on each shard, all queries hit all shards.
  • createdAt alone — All writes hit the latest shard. Old shards sit idle.

Good examples:

  • userId — High cardinality, stable over time, routes queries naturally ("give me my data").
  • { userId: 1, timestamp: -1 } — Compound key for time-series data; routes by user, then ranges by time.
  • { region: 1, accountId: 1 } — Composite that balances geographic distribution and account isolation.

If you choose timestamp alone:

javascript
// BAD: All writes cluster on the "newest" shard
db.transactions.insertOne({
  _id: ObjectId(),
  accountId: 12345,
  amount: 100,
  timestamp: ISODate()  // Shard key = timestamp
});

// Every new write goes to the same shard.
// Old shards starve. MongoDB has to reshard frequently.

Better to combine it:

javascript
// BETTER: Distribute by account and time range
db.transactions.insertOne({
  _id: ObjectId(),
  accountId: 12345,
  amount: 100,
  timestamp: ISODate()
});

// Shard key = { accountId: 1, timestamp: -1 }
// Writes are spread across many shards based on accountId.
// Queries for a user's transactions route to a small set of shards.

Key constraints on shard keys:

  • It must exist on every document (or be null consistently).
  • You cannot change it after sharding without resharding.
  • It should have high cardinality (thousands to millions of unique values).
  • Monotonically increasing keys cause hotspots—avoid unless combined with another field.

In interviews, mention cardinality first, then monotonicity risk, then query routing benefits. Show that you think about operational impact, not just theoretical purity.

Common mistakes

Mistake 1: Optimizing for single-document reads. Teams embed everything to save a query, then wonder why bulk updates and schema changes become painful. Ask: do we update this child separately? If yes, reference it.

Mistake 2: Choosing a shard key after the fact. By the time you realize you need to reshard, you have terabytes of data. Make the call early, even if it's not perfect. A mediocre key chosen at design time beats resharding at 2 AM.

Mistake 3: Embedding arrays without size limits. Teams say "we'll cap it at 100" and then business logic adds 1000. Use a separate collection and spare yourself the 3 AM incident.

Mistake 4: Denormalizing "for performance" without measuring. One extra query to MongoDB (ms latency) is usually faster than bloated document deserialization and cache-miss churn in your application layer.

Key takeaways

  • Embed for locality, reference for independence. Tight coupling and bounded size favor embedding. Independent lifecycle and unbounded growth favor references.
  • Many-to-many almost always needs a junction collection. Don't try to keep bidirectional denormalization in sync at scale.
  • Unbounded growth requires a separate collection. The 16 MB document limit is a feature, not a bug; it forces you to think about access patterns.
  • Shard keys are irreversible; choose with cardinality and query routing in mind. A uniform key that supports your read and write patterns beats a theoretically "better" one that requires resharding.
  • In interviews, always tie schema choices to access patterns and operational impact. Avoid ivory-tower reasoning; talk about monitoring, resharding costs, and developer experience.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles