
Building an E-Commerce Product Catalog with MongoDB
Product catalogs are one of the places relational schemas struggle most. A t-shirt has a size and a color. A laptop has a CPU, RAM, storage, and screen size. A bottle of wine has a vintage, region, and grape. Put all of that in one products table and you end up with hundreds of mostly-null columns, or an entity-attribute-value table that turns every filter into a tangle of self-joins.
MongoDB handles this naturally. Each product is a document with exactly the fields it needs, variants can live inside their parent product, and a single index design can support filtering on attributes that vary wildly between product types. The result is a catalog where a product page is one read and a category page with filters is one aggregation.
This guide walks through designing a catalog: the product document, variants, polymorphic attributes with the attribute pattern, category hierarchies, pricing, the indexes that make browsing fast, faceted filters with $facet, and when to bring in Atlas Search.
Start From the Access Patterns
Before designing documents, list what the storefront actually does. For a typical catalog:
- Product page: fetch one product by slug with all its variants, images, and attributes.
- Category page: list products in a category (including subcategories), filtered by attributes like brand, size, or price, sorted by price or popularity, paginated.
- Filter counts: show how many products match each filter value ("Brand: Acme (42)").
- Search: free-text search across names and descriptions.
- Cart and checkout: look up specific variants by SKU and check price and stock.
Reads outnumber writes by orders of magnitude, and most reads are pages of products, so the design should favor fast, self-contained reads.
The Product Document
Here's a product with variants:
db.products.insertOne({
_id: ObjectId("66e9a1b2c3d4e5f601234567"),
slug: "trail-runner-2",
name: "Trail Runner 2",
brand: "Acme",
status: "active",
description:
"A lightweight trail shoe with a grippy outsole and a rock plate.",
categoryIds: ["footwear", "footwear/running", "footwear/running/trail"],
primaryCategory: "footwear/running/trail",
images: [
{ url: "/img/trail-runner-2/main.jpg", alt: "Trail Runner 2, side view" },
{ url: "/img/trail-runner-2/sole.jpg", alt: "Outsole detail" },
],
attrs: [
{ k: "gender", v: "unisex" },
{ k: "drop_mm", v: 6 },
{ k: "waterproof", v: false },
],
priceRange: { min: NumberDecimal("119.00"), max: NumberDecimal("129.00") },
currency: "USD",
variants: [
{
sku: "TR2-BLK-42",
color: "black",
size: "42",
price: NumberDecimal("119.00"),
},
{
sku: "TR2-BLK-43",
color: "black",
size: "43",
price: NumberDecimal("119.00"),
},
{
sku: "TR2-ORG-42",
color: "orange",
size: "42",
price: NumberDecimal("129.00"),
},
],
rating: { avg: 4.6, count: 212 },
popularity: 8841,
createdAt: ISODate("2026-03-02T10:00:00Z"),
updatedAt: ISODate("2026-09-10T14:21:00Z"),
});
Several design choices are packed in here.
Variants Are Embedded
A product rarely has more than a few dozen variants, they're always displayed with the product, and they're bounded. That makes them ideal for embedding. The product page is a single findOne({ slug }).
If you sell something with thousands of variants per product (made-to-order configurations, for example), move variants into their own collection with a productId reference. For most stores, embedding wins.
Stock Lives Elsewhere
Notice there's no stock count on the variants. Inventory changes constantly (every order, every return, every warehouse sync), while product content changes rarely. Mixing them means every stock update rewrites a large product document and invalidates any cache of it. Keeping inventory in a separate collection keyed by SKU is covered in implementing a shopping cart and inventory system.
Computed Summary Fields
priceRange, rating, and popularity are derived from other data (variants, reviews, sales). Storing them on the product is the computed pattern: a little extra work on write so that category pages can sort and filter on them without joining. Recompute them when the underlying data changes, or on a schedule.
Decimal Prices
Prices use Decimal128 (NumberDecimal in mongosh) so that 0.1 + 0.2 problems never appear in totals. Integer cents are an equally valid choice. Whatever you choose, be consistent across the whole catalog.
Polymorphic Attributes with the Attribute Pattern
The hardest catalog problem is filtering on attributes that differ per product type. Shoes filter by drop_mm and waterproof, laptops by ram_gb and cpu. If you store these as top-level fields (ram_gb: 16), you'd need a separate index for every attribute of every product type.
The attribute pattern stores them as an array of key-value pairs instead:
attrs: [
{ k: "ram_gb", v: 16 },
{ k: "cpu", v: "M4" },
{ k: "screen_in", v: 14.2 },
];
Now a single compound multikey index covers filtering on any attribute:
db.products.createIndex({ "attrs.k": 1, "attrs.v": 1 });
Query with $elemMatch so the key and value match within the same array element:
db.products.find({
status: "active",
attrs: {
$all: [
{ $elemMatch: { k: "ram_gb", v: { $gte: 16 } } },
{ $elemMatch: { k: "cpu", v: { $in: ["M4", "M4 Pro"] } } },
],
},
});
Without $elemMatch, { "attrs.k": "ram_gb", "attrs.v": 16 } could match a product where one element has k: "ram_gb" and a completely different element has v: 16. Working with arrays and $elemMatch explains why in detail.
Keep common, universal fields like brand, status, and priceRange as top-level fields. They appear in almost every query, and top-level fields are easier to index in compound indexes with sort keys. The attribute pattern is for the long tail.
Store Attribute Values with Consistent Types
v: 16 and v: "16" are different values in MongoDB and won't match the same queries. Define attribute types in a small schema per product type (for example, ram_gb is always a number) and enforce it when products are created or imported.
Category Hierarchies
Categories form a tree: Footwear, then Running, then Trail. The storefront needs to show products from a category and all its descendants, and render breadcrumbs.
The simplest approach that satisfies both is to store the full ancestor path on each product, as in categoryIds above. Querying a category includes everything below it automatically:
db.products.find({ categoryIds: "footwear/running", status: "active" });
That returns road and trail running shoes alike. Using path strings as IDs also makes breadcrumbs trivial to render.
Category metadata (display name, description, sort order, SEO fields) lives in its own collection:
db.categories.insertMany([
{ _id: "footwear", name: "Footwear", parent: null, ancestors: [] },
{
_id: "footwear/running",
name: "Running",
parent: "footwear",
ancestors: ["footwear"],
},
{
_id: "footwear/running/trail",
name: "Trail",
parent: "footwear/running",
ancestors: ["footwear", "footwear/running"],
},
]);
To list a category's direct children for navigation, query { parent: "footwear" }. To move a category in the tree, you update the category documents and the categoryIds of affected products, which is rare enough that a batched update is fine.
Indexes for Browsing
A category page typically filters by category and status, optionally by brand and price, and sorts by popularity or price. Following the equality, sort, range (ESR) guideline:
db.products.createIndex({ categoryIds: 1, status: 1, popularity: -1 });
db.products.createIndex({ categoryIds: 1, status: 1, "priceRange.min": 1 });
db.products.createIndex({ slug: 1 }, { unique: true });
db.products.createIndex({ "variants.sku": 1 }, { unique: true });
db.products.createIndex({ "attrs.k": 1, "attrs.v": 1 });
The unique index on variants.sku is worth highlighting. A unique multikey index prevents two different products from using the same SKU. (It doesn't prevent a duplicate SKU within one product's array, so validate that in application code.)
A category page query:
db.products
.find(
{
categoryIds: "footwear/running",
status: "active",
brand: { $in: ["Acme", "Stride"] },
},
{
projection: {
slug: 1,
name: 1,
brand: 1,
priceRange: 1,
rating: 1,
images: { $slice: 1 },
},
},
)
.sort({ popularity: -1 })
.limit(24);
The projection returns just what a product card needs, including only the first image. Check it with explain("executionStats") to confirm it uses the categoryIds_1_status_1_popularity_-1 index with no in-memory sort.
Faceted Filters with $facet
Filter sidebars need counts: how many products match for each brand, each size, each price bucket, given the currently applied filters. $facet computes the page of results and all the counts in one aggregation:
const match = { categoryIds: "footwear/running", status: "active" };
db.products.aggregate([
{ $match: match },
{
$facet: {
results: [
{ $sort: { popularity: -1 } },
{ $skip: 0 },
{ $limit: 24 },
{ $project: { slug: 1, name: 1, brand: 1, priceRange: 1, rating: 1 } },
],
brands: [{ $sortByCount: "$brand" }],
sizes: [
{ $unwind: "$variants" },
{ $group: { _id: "$variants.size", products: { $addToSet: "$_id" } } },
{ $project: { count: { $size: "$products" } } },
{ $sort: { _id: 1 } },
],
price: [
{
$bucket: {
groupBy: "$priceRange.min",
boundaries: [0, 50, 100, 150, 250, 100000],
default: "other",
output: { count: { $sum: 1 } },
},
},
],
total: [{ $count: "count" }],
},
},
]);
{
"results": [
{ "slug": "trail-runner-2", "name": "Trail Runner 2", "brand": "Acme" }
],
"brands": [
{ "_id": "Acme", "count": 42 },
{ "_id": "Stride", "count": 31 }
],
"sizes": [
{ "_id": "42", "count": 58 },
{ "_id": "43", "count": 61 }
],
"price": [
{ "_id": 50, "count": 12 },
{ "_id": 100, "count": 47 }
],
"total": [{ "count": 96 }]
}
The sizes facet counts distinct products per size (using $addToSet on _id), not variants, which is what shoppers expect. $bucket boundaries are compared against the stored values; with Decimal128 prices, MongoDB compares numeric types correctly across integers, doubles, and decimals.
A caveat: $facet sub-pipelines can't use indexes, so everything after the initial $match works on the matched documents in memory. That's fine for categories with a few thousand products. For large catalogs, or for "disjunctive" facets where selecting a brand shouldn't hide the other brands' counts, $facet for multi-faceted search goes deeper, and Atlas Search facets are the scalable answer.
Search
For a small catalog, a text index covers the basics:
db.products.createIndex(
{ name: "text", brand: "text", description: "text" },
{ weights: { name: 10, brand: 5, description: 1 }, name: "product_text" },
);
db.products
.find(
{ $text: { $search: "waterproof trail" }, status: "active" },
{ score: { $meta: "textScore" } },
)
.sort({ score: { $meta: "textScore" } })
.limit(20);
Text indexes don't do typo tolerance, autocomplete, synonyms, or fast facet counts. If you're on Atlas, Atlas Search provides all of those, and its $searchMeta stage with the facet collector returns filter counts computed from the search index rather than by scanning documents. For a serious storefront, that's usually the right move.
Keeping the Catalog Valid
Catalog data often arrives from imports, spreadsheets, and third-party feeds, so validate it at the database level too:
db.runCommand({
collMod: "products",
validator: {
$jsonSchema: {
bsonType: "object",
required: ["slug", "name", "status", "categoryIds", "variants"],
properties: {
slug: { bsonType: "string", pattern: "^[a-z0-9-]+$" },
status: { enum: ["draft", "active", "archived"] },
categoryIds: {
bsonType: "array",
minItems: 1,
items: { bsonType: "string" },
},
variants: {
bsonType: "array",
minItems: 1,
items: {
bsonType: "object",
required: ["sku", "price"],
properties: {
sku: { bsonType: "string" },
price: { bsonType: "decimal" },
},
},
},
},
},
},
});
Updating Products Safely
Some common write operations and how to do them atomically:
// Change one variant's price, then recompute the price range
db.products.updateOne(
{ "variants.sku": "TR2-ORG-42" },
{
$set: {
"variants.$.price": NumberDecimal("124.00"),
updatedAt: new Date(),
},
},
);
db.products.updateOne({ slug: "trail-runner-2" }, [
{
$set: {
priceRange: {
min: { $min: "$variants.price" },
max: { $max: "$variants.price" },
},
},
},
]);
// Add a variant only if the SKU isn't already present
db.products.updateOne(
{ slug: "trail-runner-2", "variants.sku": { $ne: "TR2-ORG-43" } },
{
$push: {
variants: {
sku: "TR2-ORG-43",
color: "orange",
size: "43",
price: NumberDecimal("129.00"),
},
},
},
);
The pipeline update computes priceRange directly from the variants array, so the summary can't drift from the data. The positional $ operator targets the variant matched by the filter.
Common Mistakes
Putting stock counts on product documents. High-frequency inventory writes churn large documents and fight with content edits. Keep inventory in its own collection keyed by SKU.
Top-level fields for every attribute. You'll need an index per attribute and still miss some. Use the attribute pattern for the long tail, top-level fields for the universal ones.
Querying the attribute array without $elemMatch. Key and value can match different elements, returning wrong products.
Storing only the leaf category. Then "show everything in Running" needs a recursive lookup. Store the full ancestor path on each product.
Inconsistent attribute types. "16" and 16 don't match the same filters. Enforce types per attribute at import time.
Returning full documents on listing pages. Product cards need a slug, name, price, rating, and one image. Project only those.
Conclusion
A MongoDB catalog works best when you design for how the storefront reads: embed variants in products, store the category path on each product, keep summary fields like price range and rating on the product, use the attribute pattern with a single multikey index for varied attributes, and keep fast-changing inventory in a separate collection. $facet handles filter counts for small and mid-size catalogs, and Atlas Search takes over when you need relevance, typo tolerance, and scalable facets.
Take one real category from your store, model three very different products in it with the document shape above, and run the $facet query against them. If the filter counts come back right on the first try, your attribute design is sound.


