MongoDB indexing, query optimization, and data modeling patterns for Payload CMS applications. Use when designing collections, debugging slow queries, optimizing database performance, or reviewing MongoDB schema decisions.
Guidance for MongoDB indexing, query patterns, and data modeling in Payload CMS projects.
Payload CMS automatically creates indexes for:
_id (MongoDB default)slug fields when index: true is set in the field configcreatedAt and updatedAt timestampsFor common Gallery 1882 query patterns, consider these compound indexes:
// In collection config, use the `indexes` property or create via MongoDB directly
// Posts: commonly queried by status + date for listing pages
// db.posts.createIndex({ _status: 1, publishedAt: -1 })
// Happenings: queried by type + date range
// db.happenings.createIndex({ _status: 1, startDate: 1, endDate: 1 })
// Artists: queried by status for directory pages
// db.artists.createIndex({ _status: 1, lastName: 1 })
explain() to verify index usage:
db.posts.find({ _status: "published" }).sort({ publishedAt: -1 }).explain("executionStats")
featured) -- combine with other fields in a compound index// Use `where` with indexed fields for server-side queries
const posts = await payload.find({
collection: 'posts',
where: {
_status: { equals: 'published' },
},
sort: '-publishedAt',
limit: 10,
depth: 1, // Control relationship depth to reduce joins
})
// List page: shallow depth
const listings = await payload.find({ collection: 'happenings', depth: 0 })
// Detail page: deeper population
const detail = await payload.findByID({ collection: 'happenings', id, depth: 2 })
Always paginate large collections:
const result = await payload.find({
collection: 'posts',
page: 1,
limit: 12,
where: { _status: { equals: 'published' } },
})
// result.totalDocs, result.totalPages, result.hasNextPage
Limit returned fields to reduce data transfer:
const slugsOnly = await payload.find({
collection: 'posts',
select: {
slug: true,
title: true,
publishedAt: true,
},
})
| Pattern | Use When | Example |
|---|---|---|
| Embedded (Payload blocks/arrays) | Data is always accessed together, no independent queries needed | SEO metadata on a post, address on Space global |
| Referenced (Payload relationships) | Data is shared across documents or queried independently | Artists linked to Happenings, Categories on Posts |
// One-to-many: Use relationship field
{
name: 'artists',
type: 'relationship',
relationTo: 'artists',
hasMany: true,
}
// Bidirectional: Add relationship on both sides or use hooks to populate
// Avoid circular depth issues by controlling depth parameter
| Anti-Pattern | Problem | Fix |
|---|---|---|
| Unbounded arrays | Documents grow past 16MB limit | Use relationships instead of deeply nested arrays |
| Deep nesting (>3 levels) | Slow queries, hard to index | Flatten structure, use relationships |
| Querying without indexes | Full collection scans | Add indexes for all where clauses |
| depth: 5+ in list queries | Cascading lookups tank performance | Use depth: 0-1 for lists, deeper only for single docs |
| Storing computed data | Stale data, sync issues | Compute at read time or use hooks to update on write |
db.setProfilingLevel(1, { slowms: 100 })
db.system.profile.find().sort({ ts: -1 }).limit(5)
db.posts.aggregate([{ $indexStats: {} }])
db.posts.stats()
When using MongoDB Atlas (common with Payload Cloud / Vercel):
DATABASE_URI includes appropriate options