iwantcoding.com
🔥 Daily 👥 Rooms 🏆 Top Log in Sign up

$match

The aggregation pipeline starts with $match — the filter stage that picks which documents continue. Put it FIRST in the pipeline so MongoDB can use indexes and discard rows before the expensive stages ($group, $lookup) run.

Filters, operators, indexes, optimisation

EXAMPLE
// 1) Simplest match
db.orders.aggregate([
    { $match: { status: 'paid' } },
]);

// Equivalent to db.orders.find({ status: 'paid' }) but composable with more stages.

// 2) Operators — same as find()
{ $match: { totalCents: { $gte: 5000, $lte: 100000 } } }
{ $match: { status: { $in: ['paid', 'shipped'] } } }
{ $match: { status: { $nin: ['cancelled'] } } }
{ $match: { 'shippingAddress.country': 'AU' } }
{ $match: { tags: 'priority' } }                       // matches if 'priority' is in the array
{ $match: { tags: { $all: ['priority', 'new'] } } }    // all listed must be present
{ $match: { items: { $size: 3 } } }                    // array length
{ $match: { 'metadata.coupon': { $exists: true } } }
{ $match: { totalCents: { $type: 'long' } } }

// 3) Logical operators
{ $match: {
    $and: [
        { status: 'paid' },
        { totalCents: { $gte: 5000 } },
    ],
} }

{ $match: {
    $or: [
        { vip: true },
        { totalCents: { $gte: 100000 } },
    ],
} }

{ $match: { status: { $ne: 'cancelled' } } }
{ $match: { 'reviews.0': { $exists: false } } }        // no reviews yet

// 4) Regex
{ $match: { email: { $regex: '@example\\.com$', $options: 'i' } } }

// 5) Date ranges
{ $match: { createdAt: { $gte: ISODate('2024-01-01'), $lt: ISODate('2025-01-01') } } }

// 6) Match on aggregated / computed fields with $expr
{ $match: { $expr: { $gt: ['$totalCents', '$threshold'] } } }
// $expr lets you use aggregation expressions inside match.

// 7) Multiple matches as filters across stages
db.orders.aggregate([
    { $match: { status: 'paid' } },                     // filter first — uses index
    { $lookup: { from: 'users', localField: 'customerId', foreignField: '_id', as: 'customer' } },
    { $unwind: '$customer' },
    { $match: { 'customer.country': 'AU' } },            // filter again AFTER join
]);
// Putting the second match AFTER lookup is necessary — the joined field doesn't exist before.

// 8) Always put $match first when possible
// Aggregation runs left to right. The earlier you filter, the less data flows through later stages.

// Bad: filter after group
[
    { $group: { _id: '$customerId', total: { $sum: '$totalCents' } } },
    { $match: { total: { $gte: 100000 } } },             // late
]

// Better: pre-filter + group
[
    { $match: { status: 'paid' } },                       // narrows input
    { $group: { _id: '$customerId', total: { $sum: '$totalCents' } } },
    { $match: { total: { $gte: 100000 } } },
]

// 9) Indexes — match can use them; group cannot
// Verify with explain:
db.orders.aggregate([
    { $match: { status: 'paid', createdAt: { $gte: ISODate('2024-01-01') } } },
    { $group: { _id: '$customerId', total: { $sum: '$totalCents' } } },
]).explain('executionStats');

// Look for: IXSCAN (index used) vs COLLSCAN (full scan).
// Build a compound index that matches your filter shape:
db.orders.createIndex({ status: 1, createdAt: -1 });

// 10) Match with text search
db.products.createIndex({ name: 'text', description: 'text' });
db.products.aggregate([
    { $match: { $text: { $search: 'laptop bag' } } },
]);

// $text must be the FIRST match clause; combine with other filters too:
{ $match: { $text: { $search: 'shoes' }, price: { $lte: 5000 } } }

// 11) Geo queries
db.poi.createIndex({ location: '2dsphere' });
db.poi.aggregate([
    { $match: { location: {
        $nearSphere: { $geometry: { type: 'Point', coordinates: [151.2, -33.87] }, $maxDistance: 5000 },
    } } },
]);

// 12) Match in update / delete operations
// updateMany, deleteMany, findOneAndUpdate — all accept a filter that is essentially a $match.

db.orders.updateMany(
    { status: 'paid', shippedAt: { $exists: false } },     // filter
    { $set: { reviewNeeded: true } },
);

// 13) Common bugs
// • Operator syntax: '$gte' (string key), not gte (no $) — refuses
// • Comparing different BSON types: 5 vs '5' — different types, no match
// • Filter on a joined field BEFORE the lookup — no such field yet
// • Forgetting to put $match before $group / $lookup — slow + scans everything
// • Using $regex without anchors — slow + may bypass indexes; prefer 'starts with' (^foo)
// • Long $or branches with mixed indexes — index intersection costly; combine into one compound
// • Filtering by 'createdAt' as a string instead of Date — type mismatch; ALWAYS new Date()
// • Date timezone confusion — store UTC; compute boundaries with explicit Date(Z) values

Why it matters

Put $match at the top of every aggregation pipeline so MongoDB can use indexes and trim rows before the expensive stages run. Build compound indexes that match your filter + sort shape, use $expr when you need field-vs-field comparisons, and verify with .explain() that you see IXSCAN, not COLLSCAN.

Tip: Tweak the snippet with Try it Yourself », then sit the quiz at the bottom of the page.

Example

Example
// Filter stage — uses regular query syntax.
{ $match: { country: 'AU' } }
Try it Yourself »

Discussion

Loading…