Agrupar y resumir datos con streams en Java

Con los streams en Java se pueden hacer en tres líneas los resúmenes que en SQL harías con GROUP BY: ventas por ciudad, cuántos pedidos por cliente, el promedio por categoría. Todo con los colectores de Collectors.

Los datos

Una lista de ventas, cada una con ciudad, producto e importe. Usaremos estos mismos datos en todos los ejemplos:

import java.util.*;
import static java.util.stream.Collectors.*;

record Venta(String ciudad, String producto, double importe) {}

final List<Venta> VENTAS = List.of(
    new Venta("CDMX", "café", 150), new Venta("Guadalajara", "té", 90),
    new Venta("CDMX", "pan", 45), new Venta("Monterrey", "café", 210),
    new Venta("Guadalajara", "café", 130), new Venta("CDMX", "café", 95));

void main() {
    // Cuántas ventas por ciudad (TreeMap: ordenadas por nombre)
    Map<String, Long> porCiudad = VENTAS.stream()
            .collect(groupingBy(Venta::ciudad, TreeMap::new, counting()));
    IO.println("ventas por ciudad: " + porCiudad);

    // Importe total por producto
    Map<String, Double> totalProducto = VENTAS.stream()
            .collect(groupingBy(Venta::producto, TreeMap::new, summingDouble(Venta::importe)));
    IO.println("total por producto: " + totalProducto);

    // Promedio por ciudad
    VENTAS.stream()
            .collect(groupingBy(Venta::ciudad, TreeMap::new, averagingDouble(Venta::importe)))
            .forEach((c, p) -> IO.println("promedio %s: %.2f".formatted(c, p)));

    // Ventas grandes y chicas
    Map<Boolean, List<String>> partes = VENTAS.stream()
            .collect(partitioningBy(v -> v.importe() >= 100, mapping(Venta::producto, toList())));
    IO.println("de 100 o más: " + partes.get(true) + "; menores: " + partes.get(false));

    // La venta más grande de cada ciudad
    VENTAS.stream()
            .collect(toMap(Venta::ciudad, v -> v, (a, b) -> a.importe() >= b.importe() ? a : b, TreeMap::new))
            .forEach((c, v) -> IO.println("mayor en " + c + ": " + v.producto() + " $" + v.importe()));
}
salidaventas por ciudad: {CDMX=3, Guadalajara=2, Monterrey=1}
total por producto: {café=585.0, pan=45.0, té=90.0}
promedio CDMX: 96.67
promedio Guadalajara: 110.00
promedio Monterrey: 210.00
de 100 o más: [café, café, café]; menores: [té, pan, café]
mayor en CDMX: café $150.0
mayor en Guadalajara: café $130.0
mayor en Monterrey: café $210.0

Qué hace cada colector

  • groupingBy(clave, colector) reparte los elementos en grupos según la clave y aplica el segundo colector a cada grupo. Sin el segundo, cada grupo es una lista. Con TreeMap::new en medio, el resultado sale ordenado por clave (un HashMap no garantiza ningún orden).
  • counting(), summingDouble() y averagingDouble() resumen cada grupo en un número.
  • partitioningBy es un groupingBy con dos grupos, true y false; mapping transforma los elementos antes de juntarlos.
  • toMap(clave, valor, combinar, mapa) crea un mapa y necesita saber qué hacer cuando dos elementos tienen la misma clave: aquí, quedarse con el de mayor importe. Sin esa función, una clave repetida lanza IllegalStateException.

Para importes reales usa BigDecimal en vez de double; con reducing(BigDecimal.ZERO, Venta::importe, BigDecimal::add) se suman igual.

Todo el código se compiló y se ejecutó con Java 25 LTS en un contenedor limpio antes de publicar esta página; las salidas están copiadas de esa ejecución.