Tema
Agregaciones
find() sirve para traer documentos. Pero cuando el cliente pregunta "¿cuánto vendí por mes?", "¿cuáles son mis 5 productos estrella?" o "¿cuánto ha gastado cada cliente?", necesitas el framework de agregación: una línea de producción por la que pasan los documentos y en cada estación se filtran, agrupan, transforman o combinan. Es el equivalente de GROUP BY, JOIN, HAVING y las funciones de SQL, y es una de las herramientas más poderosas de MongoDB.
Antes de empezar
Ejecuta el semilla.js de la lección de CRUD. Los resultados de esta lección coinciden con los del curso de SQL porque los datos son los mismos.
Concepto: el pipeline
Un pipeline (tubería) es un arreglo de etapas. La salida de cada etapa es la entrada de la siguiente:
txt
colección pedidos (5 documentos)
│
▼
┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ $match │──►│ $group │──►│ $sort │──►│ $limit │──► resultado
│ (filtrar) │ │ (agrupar) │ │ (ordenar) │ │ (recortar) │
└──────────────┘ └──────────────┘ └──────────────┘ └──────────────┘
4 documentos 3 grupos 3 ordenados 2 primerosjs
db.pedidos.aggregate([
{ $match: { ... } }, // etapa 1
{ $group: { ... } }, // etapa 2
{ $sort: { ... } } // etapa 3
])Como en una fábrica de jugos: entran naranjas (documentos), una estación descarta las podridas ($match), otra las exprime ($project), otra junta el jugo por tipo ($group) y al final se embotella ($sort, $limit).
Equivalencias con SQL
| SQL | Etapa de agregación |
|---|---|
WHERE | $match |
SELECT columnas, expresiones | $project / $addFields / $set |
GROUP BY + SUM, COUNT, AVG | $group con acumuladores |
HAVING | $match después de $group |
ORDER BY | $sort |
LIMIT / OFFSET | $limit / $skip |
JOIN | $lookup |
COUNT(*) | $count |
UNION ALL | $unionWith |
| Expandir una lista en filas | $unwind |
| Varias consultas a la vez | $facet |
$match: filtrar
Usa la misma sintaxis de filtros que find(). Ponlo lo más al principio posible: reduce los documentos que procesan las etapas siguientes y puede usar índices.
js
db.pedidos.aggregate([
{ $match: { estado: { $ne: "cancelado" } } }
])
// 4 pedidos (todos menos el 5)$group: agrupar y acumular
js
{ $group: { _id: <expresión de agrupación>, campo: { <acumulador>: <expresión> }, ... } }_iddefine por qué agrupar. Los campos se referencian con"$nombreCampo"._id: nullagrupa todo en un solo grupo.
Acumuladores
| Acumulador | Calcula |
|---|---|
$sum | Suma ($sum: 1 cuenta documentos) |
$avg | Promedio |
$min / $max | Mínimo / máximo |
$first / $last | Primer / último valor del grupo (según el orden de entrada) |
$push | Arreglo con todos los valores |
$addToSet | Arreglo con valores únicos |
$count | Número de documentos (MongoDB 5+) |
js
// Resumen general de ventas válidas
db.pedidos.aggregate([
{ $match: { estado: { $ne: "cancelado" } } },
{ $group: {
_id: null,
pedidos: { $sum: 1 },
ventas: { $sum: "$total" },
ticketPromedio: { $avg: "$total" },
pedidoMayor: { $max: "$total" }
} }
])js
[ { _id: null, pedidos: 4, ventas: 8089, ticketPromedio: 2022.25, pedidoMayor: 3499 } ]js
// Pedidos y ventas por estado
db.pedidos.aggregate([
{ $group: { _id: "$estado", pedidos: { $sum: 1 }, ventas: { $sum: "$total" } } },
{ $sort: { ventas: -1 } }
])js
[
{ _id: "enviado", pedidos: 1, ventas: 3499 },
{ _id: "entregado", pedidos: 2, ventas: 3444 },
{ _id: "pendiente", pedidos: 1, ventas: 1146 },
{ _id: "cancelado", pedidos: 1, ventas: 899 }
]js
// Productos por categoría con precio promedio y la lista de nombres
db.productos.aggregate([
{ $group: {
_id: "$categoria",
productos: { $sum: 1 },
precioPromedio: { $avg: "$precio" },
nombres: { $push: "$nombre" }
} },
{ $sort: { productos: -1 } }
])js
[
{ _id: "Electrónica", productos: 3, precioPromedio: 1882.67, nombres: ["Audífonos Bluetooth", "Teclado mecánico", "Monitor 24 pulgadas"] },
{ _id: "Libros", productos: 2, precioPromedio: 319, nombres: ["Libro Aprende SQL", "Cien años de soledad"] },
{ _id: "Hogar", productos: 2, precioPromedio: 329, nombres: ["Lámpara de escritorio", "Taza térmica"] },
{ _id: null, productos: 1, precioPromedio: 99, nombres: ["Cable USB-C"] }
](El promedio de Electrónica en realidad es 1882.666...; abajo verás cómo redondear. Los grupos empatados pueden salir en otro orden.)
Agrupar por varios campos o por fecha
js
// Ventas por año y mes (sin cancelados)
db.pedidos.aggregate([
{ $match: { estado: { $ne: "cancelado" } } },
{ $group: {
_id: { anio: { $year: "$fecha" }, mes: { $month: "$fecha" } },
pedidos: { $sum: 1 },
ventas: { $sum: "$total" }
} },
{ $sort: { "_id.anio": 1, "_id.mes": 1 } }
])js
[
{ _id: { anio: 2024, mes: 5 }, pedidos: 2, ventas: 3444 },
{ _id: { anio: 2024, mes: 6 }, pedidos: 2, ventas: 4645 }
]Alternativa más cómoda: $dateTrunc (MongoDB 5+) o $dateToString:
js
{ $group: { _id: { $dateToString: { format: "%Y-%m", date: "$fecha", timezone: "America/Mexico_City" } },
ventas: { $sum: "$total" } } }
// _id: "2024-05", "2024-06"HAVING: $match después de $group
js
// Clientes con más de un pedido
db.pedidos.aggregate([
{ $group: { _id: "$clienteId", pedidos: { $sum: 1 } } },
{ $match: { pedidos: { $gt: 1 } } }
])
// [ { _id: 1, pedidos: 2 } ]$project, $addFields y $set: dar forma
$project elige y calcula campos (como el SELECT). $addFields (alias $set) agrega campos conservando los demás.
js
db.productos.aggregate([
{ $match: { categoria: "Electrónica" } },
{ $project: {
_id: 0,
nombre: 1,
precio: 1,
precioConIva: { $round: [ { $multiply: ["$precio", 1.16] }, 2 ] },
valorInventario: { $multiply: ["$precio", "$stock"] },
nivelStock: {
$switch: {
branches: [
{ case: { $lte: ["$stock", 5] }, then: "Crítico" },
{ case: { $lte: ["$stock", 15] }, then: "Bajo" }
],
default: "Suficiente"
}
}
} }
])js
[
{ nombre: "Audífonos Bluetooth", precio: 899, precioConIva: 1042.84, valorInventario: 22475, nivelStock: "Suficiente" },
{ nombre: "Teclado mecánico", precio: 1250, precioConIva: 1450, valorInventario: 12500, nivelStock: "Bajo" },
{ nombre: "Monitor 24 pulgadas", precio: 3499, precioConIva: 4058.84, valorInventario: 17495, nivelStock: "Crítico" }
]Operadores de expresión más usados
| Tipo | Operadores |
|---|---|
| Aritméticos | $add, $subtract, $multiply, $divide, $round, $mod, $abs |
| Comparación | $eq, $ne, $gt, $gte, $lt, $lte (forma [a, b]) |
| Condicionales | $cond (if/then/else), $switch, $ifNull |
| Texto | $concat, $toUpper, $toLower, $substrCP, $split, $trim |
| Fechas | $year, $month, $dayOfMonth, $dayOfWeek, $dateToString, $dateDiff, $dateTrunc |
| Arrays | $size, $filter, $map, $reduce, $in, $arrayElemAt, $sum sobre arrays |
| Tipos | $toString, $toInt, $toDouble, $toDecimal, $toDate |
js
// Pedidos con número de piezas, total recalculado desde los items y días desde la compra
db.pedidos.aggregate([
{ $addFields: {
piezas: { $sum: "$items.cantidad" },
totalCalculado: {
$sum: { $map: { input: "$items", as: "it", in: { $multiply: ["$$it.cantidad", "$$it.precio"] } } }
},
cliente: { $concat: ["Cliente #", { $toString: "$clienteId" }] }
} },
{ $project: { items: 0 } }
])
// pedido 1: piezas 3, totalCalculado 1597 · pedido 2: piezas 4, totalCalculado 1847 ..."$$it" (doble $) es una variable definida en la expresión (as: "it"), distinta de un campo.
$unwind: un documento por elemento del array
$unwind "desarma" un array: genera un documento por cada elemento. Es la clave para analizar los items de los pedidos.
txt
{ _id: 1, items: [A, B] } ──$unwind: "$items"──► { _id: 1, items: A }
{ _id: 1, items: B }js
// Productos más vendidos (piezas e ingresos), sin pedidos cancelados
db.pedidos.aggregate([
{ $match: { estado: { $ne: "cancelado" } } },
{ $unwind: "$items" },
{ $group: {
_id: "$items.productoId",
producto: { $first: "$items.nombre" },
piezas: { $sum: "$items.cantidad" },
ingresos: { $sum: { $multiply: ["$items.cantidad", "$items.precio"] } }
} },
{ $sort: { ingresos: -1 } },
{ $limit: 5 }
])js
[
{ _id: 3, producto: "Monitor 24 pulgadas", piezas: 1, ingresos: 3499 },
{ _id: 2, producto: "Teclado mecánico", piezas: 1, ingresos: 1250 },
{ _id: 7, producto: "Taza térmica", piezas: 5, ingresos: 995 },
{ _id: 1, producto: "Audífonos Bluetooth", piezas: 1, ingresos: 899 },
{ _id: 4, producto: "Libro Aprende SQL", piezas: 2, ingresos: 698 }
]js
// ¿Cuántos productos tiene cada etiqueta?
db.productos.aggregate([
{ $unwind: "$etiquetas" },
{ $group: { _id: "$etiquetas", total: { $sum: 1 } } },
{ $sort: { total: -1, _id: 1 } }
])
// oficina 3, gamer 2, audio 1, clasico 1, cocina 1, inalambrico 1, novela 1, programacion 1$unwind y arrays vacíos
Por defecto, los documentos con el array vacío o sin el campo desaparecen (el Cable USB-C no aparece arriba). Para conservarlos: { $unwind: { path: "$etiquetas", preserveNullAndEmptyArrays: true } }.
$lookup: el "JOIN" de MongoDB
js
{ $lookup: {
from: "clientes", // colección a unir
localField: "clienteId", // campo en pedidos
foreignField: "_id", // campo en clientes
as: "cliente" // nombre del nuevo campo (¡siempre es un array!)
} }js
// Pedidos con el nombre del cliente
db.pedidos.aggregate([
{ $lookup: { from: "clientes", localField: "clienteId", foreignField: "_id", as: "cliente" } },
{ $unwind: "$cliente" }, // de array de 1 elemento a objeto
{ $project: { _id: 1, estado: 1, total: 1, cliente: "$cliente.nombre", ciudad: "$cliente.direccion.ciudad" } }
])js
[
{ _id: 1, estado: "entregado", total: 1597, cliente: "Ana López", ciudad: "Ciudad de México" },
{ _id: 2, estado: "entregado", total: 1847, cliente: "Carlos Ruiz", ciudad: "Guadalajara" },
{ _id: 3, estado: "enviado", total: 3499, cliente: "Ana López", ciudad: "Ciudad de México" },
{ _id: 4, estado: "pendiente", total: 1146, cliente: "María Pérez", ciudad: "Monterrey" },
{ _id: 5, estado: "cancelado", total: 899, cliente: "Jorge Díaz", ciudad: "Ciudad de México" }
]Gasto por cliente, incluidos los que no compran
Partimos de clientes (como un LEFT JOIN) y usamos un $lookup con pipeline para filtrar los pedidos válidos:
js
db.clientes.aggregate([
{ $lookup: {
from: "pedidos",
let: { idCliente: "$_id" },
pipeline: [
{ $match: { $expr: { $eq: ["$clienteId", "$$idCliente"] }, estado: { $ne: "cancelado" } } },
{ $project: { total: 1 } }
],
as: "pedidos"
} },
{ $project: {
_id: 0,
nombre: 1,
compras: { $size: "$pedidos" },
totalGastado: { $sum: "$pedidos.total" }
} },
{ $sort: { totalGastado: -1 } }
])js
[
{ nombre: "Ana López", compras: 2, totalGastado: 5096 },
{ nombre: "Carlos Ruiz", compras: 1, totalGastado: 1847 },
{ nombre: "María Pérez", compras: 1, totalGastado: 1146 },
{ nombre: "Jorge Díaz", compras: 0, totalGastado: 0 },
{ nombre: "Lucía Gómez", compras: 0, totalGastado: 0 }
]Ventas por categoría (unwind + lookup + group)
Los items guardan productoId pero no la categoría: hay que traerla de productos.
js
db.pedidos.aggregate([
{ $match: { estado: { $ne: "cancelado" } } },
{ $unwind: "$items" },
{ $lookup: { from: "productos", localField: "items.productoId", foreignField: "_id", as: "producto" } },
{ $unwind: "$producto" },
{ $group: {
_id: { $ifNull: ["$producto.categoria", "Sin categoría"] },
piezas: { $sum: "$items.cantidad" },
ventas: { $sum: { $multiply: ["$items.cantidad", "$items.precio"] } }
} },
{ $sort: { ventas: -1 } }
])js
[
{ _id: "Electrónica", piezas: 3, ventas: 5648 },
{ _id: "Hogar", piezas: 6, ventas: 1454 },
{ _id: "Libros", piezas: 3, ventas: 987 }
]$lookup no es gratis
Cada $lookup busca en otra colección por cada documento. Asegúrate de que foreignField tenga índice (el _id siempre lo tiene) y filtra con $match antes. Si haces el mismo $lookup en cada petición de tu app, quizá el modelo debería embeber esos datos (Índices y modelado).
$count, $sortByCount y $bucket
js
// Contar resultados
db.productos.aggregate([ { $match: { precio: { $lt: 500 } } }, { $count: "baratos" } ])
// [ { baratos: 5 } ]
// Agrupar + contar + ordenar en una etapa
db.pedidos.aggregate([ { $sortByCount: "$estado" } ])
// [ { _id: "entregado", count: 2 }, { _id: "enviado", count: 1 }, ... ]
// Rangos de precio (histograma)
db.productos.aggregate([
{ $bucket: {
groupBy: "$precio",
boundaries: [0, 300, 1000, 5000],
default: "Otro",
output: { productos: { $sum: 1 }, nombres: { $push: "$nombre" } }
} }
])js
[
{ _id: 0, productos: 3, nombres: ["Cien años de soledad", "Taza térmica", "Cable USB-C"] },
{ _id: 300, productos: 3, nombres: ["Audífonos Bluetooth", "Libro Aprende SQL", "Lámpara de escritorio"] },
{ _id: 1000, productos: 2, nombres: ["Teclado mecánico", "Monitor 24 pulgadas"] }
]$facet: varios reportes en una sola consulta
Ideal para un dashboard o para páginas de búsqueda con resultados + filtros + total:
js
db.productos.aggregate([
{ $match: { stock: { $gt: 0 } } },
{ $facet: {
porCategoria: [ { $sortByCount: { $ifNull: ["$categoria", "Sin categoría"] } } ],
rangoPrecios: [ { $group: { _id: null, min: { $min: "$precio" }, max: { $max: "$precio" } } } ],
masCaros: [ { $sort: { precio: -1 } }, { $limit: 3 }, { $project: { _id: 0, nombre: 1, precio: 1 } } ],
total: [ { $count: "productos" } ]
} }
])js
[ {
porCategoria: [ { _id: "Electrónica", count: 3 }, { _id: "Libros", count: 2 }, { _id: "Hogar", count: 2 }, { _id: "Sin categoría", count: 1 } ],
rangoPrecios: [ { _id: null, min: 99, max: 3499 } ],
masCaros: [ { nombre: "Monitor 24 pulgadas", precio: 3499 }, { nombre: "Teclado mecánico", precio: 1250 }, { nombre: "Audífonos Bluetooth", precio: 899 } ],
total: [ { productos: 8 } ]
} ]Paginación con total en una sola consulta
js
const pagina = 1, porPagina = 3;
db.productos.aggregate([
{ $match: { categoria: { $exists: true } } },
{ $sort: { precio: 1 } },
{ $facet: {
datos: [ { $skip: (pagina - 1) * porPagina }, { $limit: porPagina } ],
total: [ { $count: "n" } ]
} }
])Guardar el resultado: $out y $merge
js
// Crear/reemplazar una colección con el reporte (como una vista materializada)
db.pedidos.aggregate([
{ $match: { estado: { $ne: "cancelado" } } },
{ $group: { _id: { $dateToString: { format: "%Y-%m", date: "$fecha" } }, ventas: { $sum: "$total" } } },
{ $merge: { into: "reporte_ventas_mensuales", whenMatched: "replace", whenNotMatched: "insert" } }
])
db.reporte_ventas_mensuales.find()Programa este pipeline cada noche (con un cron o Atlas Triggers) y tus dashboards leerán un resumen instantáneo.
Consejos de rendimiento
Buenas prácticas
$matchy$sortal principio: pueden usar índices; después de un$groupo$projectya no.$projecttemprano para descartar campos pesados que no necesitas.$limitjusto después de$sortpermite a MongoDB optimizar (top-N).- Indexa los campos de
$matchy losforeignFieldde$lookup. - Usa
explain:db.pedidos.explain("executionStats").aggregate([...]). - Cada etapa tiene un límite de 100 MB de memoria; para agregaciones enormes,
{ allowDiskUse: true }(activado por defecto desde MongoDB 6). - Construye pipelines etapa por etapa en Compass (pestaña Aggregations): ves el resultado de cada etapa y luego exportas el código a Node.
Ejercicios
- Calcula el número de clientes por ciudad, ordenado de mayor a menor.
- Obtén el ticket promedio por estado de pedido, redondeado a 2 decimales.
- ¿Cuántas piezas de cada producto se han vendido en todos los pedidos (incluidos cancelados)? Muestra el nombre y las piezas.
- Lista los productos que nunca se han vendido (pista:
$lookupdesdeproductoshaciapedidosusandoitems.productoId). - Crea un reporte con, para cada cliente que compró, su nombre, su primera y última fecha de compra.
Ver solución
js
// 1
db.clientes.aggregate([
{ $group: { _id: "$direccion.ciudad", clientes: { $sum: 1 } } },
{ $sort: { clientes: -1, _id: 1 } }
])
// Ciudad de México 2, Guadalajara 1, Monterrey 1, Puebla 1
// 2
db.pedidos.aggregate([
{ $group: { _id: "$estado", ticket: { $avg: "$total" } } },
{ $project: { ticket: { $round: ["$ticket", 2] } } }
])
// entregado 1722, enviado 3499, pendiente 1146, cancelado 899
// 3
db.pedidos.aggregate([
{ $unwind: "$items" },
{ $group: { _id: "$items.nombre", piezas: { $sum: "$items.cantidad" } } },
{ $sort: { piezas: -1 } }
])
// Taza térmica 5, Audífonos Bluetooth 2, Libro Aprende SQL 2, y los demás 1
// 4
db.productos.aggregate([
{ $lookup: { from: "pedidos", localField: "_id", foreignField: "items.productoId", as: "ventas" } },
{ $match: { ventas: { $size: 0 } } },
{ $project: { _id: 0, nombre: 1 } }
])
// [ { nombre: "Cable USB-C" } ]
// 5
db.pedidos.aggregate([
{ $group: { _id: "$clienteId", primera: { $min: "$fecha" }, ultima: { $max: "$fecha" } } },
{ $lookup: { from: "clientes", localField: "_id", foreignField: "_id", as: "c" } },
{ $unwind: "$c" },
{ $project: { _id: 0, nombre: "$c.nombre", primera: 1, ultima: 1 } },
{ $sort: { primera: 1 } }
])
// Ana López: 2024-05-02 → 2024-06-01; Carlos Ruiz: 2024-05-10; María Pérez: 2024-06-15; Jorge Díaz: 2024-06-20Resumen
- Una agregación es un pipeline de etapas: la salida de una es la entrada de la siguiente.
$matchfiltra (ponlo primero),$groupagrupa con acumuladores ($sum,$avg,$push...),$sorty$limitordenan y recortan.$project/$addFieldsdan forma con expresiones ($multiply,$cond,$dateToString...).$unwindconvierte cada elemento de un array en un documento: clave para analizar items de pedidos.$lookupune colecciones (como un JOIN);$facetejecuta varios reportes a la vez;$mergeguarda resultados.