Skip to content

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 primeros
js
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 ​

SQLEtapa 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> }, ... } }
  • _id define por qué agrupar. Los campos se referencian con "$nombreCampo".
  • _id: null agrupa todo en un solo grupo.

Acumuladores ​

AcumuladorCalcula
$sumSuma ($sum: 1 cuenta documentos)
$avgPromedio
$min / $maxMínimo / máximo
$first / $lastPrimer / último valor del grupo (según el orden de entrada)
$pushArreglo con todos los valores
$addToSetArreglo con valores únicos
$countNú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 ​

TipoOperadores
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

  • $match y $sort al principio: pueden usar índices; después de un $group o $project ya no.
  • $project temprano para descartar campos pesados que no necesitas.
  • $limit justo después de $sort permite a MongoDB optimizar (top-N).
  • Indexa los campos de $match y los foreignField de $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 ​

  1. Calcula el número de clientes por ciudad, ordenado de mayor a menor.
  2. Obtén el ticket promedio por estado de pedido, redondeado a 2 decimales.
  3. ¿Cuántas piezas de cada producto se han vendido en todos los pedidos (incluidos cancelados)? Muestra el nombre y las piezas.
  4. Lista los productos que nunca se han vendido (pista: $lookup desde productos hacia pedidos usando items.productoId).
  5. 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-20

Resumen ​

  • Una agregación es un pipeline de etapas: la salida de una es la entrada de la siguiente.
  • $match filtra (ponlo primero), $group agrupa con acumuladores ($sum, $avg, $push...), $sort y $limit ordenan y recortan.
  • $project / $addFields dan forma con expresiones ($multiply, $cond, $dateToString...).
  • $unwind convierte cada elemento de un array en un documento: clave para analizar items de pedidos.
  • $lookup une colecciones (como un JOIN); $facet ejecuta varios reportes a la vez; $merge guarda resultados.

Academia Web — aprende, copia y construye.