Módulo 10 — Agrupar, pivotar y unir tablas
Aquí Pandas pasa de “Excel programado” a herramienta de análisis seria. Agrupar (groupby) responde a “¿cuánto vendió cada ciudad?”. Pivotar reorganiza una tabla para verla desde otro ángulo. Unir (merge) combina dos tablas relacionadas, como un JOIN de SQL. Estas tres operaciones son las que separan a quien sabe Pandas de quien solo lo usa para mirar datos.
El dataset de trabajo
import pandas as pd
ventas = pd.DataFrame({
"fecha": ["2026-01", "2026-01", "2026-02", "2026-02", "2026-01", "2026-03", "2026-02", "2026-03"],
"vendedor": ["Ada", "Luis", "Ada", "Marta", "Marta", "Luis", "Ada", "Marta"],
"ciudad": ["Madrid", "Sevilla", "Madrid", "Bilbao", "Bilbao", "Sevilla", "Madrid", "Bilbao"],
"producto": ["Monitor", "Teclado", "Monitor", "Ratón", "Teclado", "Monitor", "Ratón", "Teclado"],
"importe": [450, 75, 600, 90, 50, 300, 120, 100],
})
groupby: el patrón “dividir, aplicar, combinar”
groupby hace tres cosas: divide la tabla en grupos según una columna, aplica una operación a cada grupo y combina los resultados en una tabla nueva. La pregunta “¿cuánto ha vendido cada ciudad?” se traduce directamente:
ventas.groupby("ciudad")["importe"].sum()
# ciudad
# Bilbao 240
# Madrid 1170
# Sevilla 375
Lee la línea por partes: agrupa por ciudad, coge la columna importe, y suma dentro de cada grupo. Cambia sum por lo que necesites:
ventas.groupby("ciudad")["importe"].mean() # ticket medio por ciudad
ventas.groupby("ciudad")["importe"].count() # número de ventas por ciudad
ventas.groupby("ciudad")["importe"].max() # venta más grande de cada ciudad
ventas.groupby("vendedor")["importe"].sum() # total por vendedor
Agrupar por varias columnas
Pasa una lista para agrupar por combinaciones. “¿Cuánto vendió cada vendedor en cada ciudad?”:
ventas.groupby(["vendedor", "ciudad"])["importe"].sum()
# vendedor ciudad
# Ada Madrid 1170
# Luis Sevilla 375
# Marta Bilbao 240
El resultado tiene un índice de dos niveles. Para volverlo una tabla plana, añade reset_index():
resumen = ventas.groupby(["vendedor", "ciudad"])["importe"].sum().reset_index()
reset_index() convierte el índice del groupby otra vez en columnas normales. Lo usarás casi siempre después de agrupar, porque deja el resultado listo para seguir trabajando o exportar.
agg: varias métricas a la vez
Cuando quieres más de un resumen por grupo, agg los calcula todos juntos:
ventas.groupby("ciudad")["importe"].agg(["sum", "mean", "count", "max"])
# sum mean count max
# ciudad
# Bilbao 240 80.0 3 100
# Madrid 1170 390.0 3 600
# Sevilla 375 187.5 2 300
Y puedes aplicar distintas funciones a distintas columnas, con nombres a medida:
ventas.groupby("vendedor").agg(
total=("importe", "sum"),
media=("importe", "mean"),
operaciones=("importe", "count"),
)
Esta forma con nombres (nombre=("columna", "función")) produce tablas limpias y legibles. Es la que conviene usar en informes.
Tablas dinámicas con pivot_table
Una tabla dinámica reorganiza los datos: una variable a las filas, otra a las columnas, y los valores agregados en las celdas. Es lo mismo que las tablas dinámicas de Excel, pero reproducible:
ventas.pivot_table(
index="vendedor", # filas
columns="ciudad", # columnas
values="importe", # qué agregar
aggfunc="sum", # cómo agregar
fill_value=0, # rellenar huecos con 0 en vez de NaN
)
# ciudad Bilbao Madrid Sevilla
# vendedor
# Ada 0 1170 0
# Luis 0 0 375
# Marta 240 0 0
De un vistazo ves quién vende dónde. Cambia index, columns y aggfunc para mirar los mismos datos desde otro ángulo. pivot_table es groupby con presentación bidimensional.
crosstab: contar combinaciones
Para contar cuántas veces aparece cada combinación de dos variables, crosstab es el atajo:
pd.crosstab(ventas["ciudad"], ventas["producto"])
# producto Monitor Ratón Teclado
# ciudad
# Bilbao 0 1 2
# Madrid 2 1 0
# Sevilla 1 0 1
Unir tablas: merge (el JOIN de Pandas)
Los datos suelen estar repartidos en varias tablas. Una con las ventas, otra con los datos de cada vendedor. merge las combina por una columna común, igual que un JOIN en SQL (terreno que ya conoces de tus clases de bases de datos).
vendedores = pd.DataFrame({
"vendedor": ["Ada", "Luis", "Marta", "Iván"],
"equipo": ["Norte", "Sur", "Norte", "Sur"],
"antiguedad": [5, 2, 8, 1],
})
combinado = ventas.merge(vendedores, on="vendedor")
on="vendedor" es la columna por la que casan las dos tablas. Ahora cada venta lleva pegado el equipo y la antigüedad de su vendedor, y puedes agrupar por equipo:
combinado.groupby("equipo")["importe"].sum()
Los cuatro tipos de unión
Igual que en SQL, importa qué haces con las filas que no casan. Lo controla how:
ventas.merge(vendedores, on="vendedor", how="inner") # solo los que están en AMBAS (por defecto)
ventas.merge(vendedores, on="vendedor", how="left") # todas las de ventas, completa lo que pueda
ventas.merge(vendedores, on="vendedor", how="right") # todas las de vendedores
ventas.merge(vendedores, on="vendedor", how="outer") # todas las de las dos
inner se queda solo con las coincidencias. left conserva todas las filas de la tabla izquierda (las ventas) aunque no encuentren pareja: ahí Iván, que no tiene ventas, no aparece con inner pero sí con outer. El que más usarás es left: “quiero todas mis ventas, enriquecidas con datos del vendedor cuando los haya”.
Si las columnas se llaman distinto en cada tabla, usa left_on y right_on:
ventas.merge(otra, left_on="vendedor", right_on="nombre_empleado")
concat: apilar tablas
merge une por columnas (a lo ancho). concat apila filas (a lo largo): juntar las ventas de enero con las de febrero, por ejemplo:
enero = ventas[ventas["fecha"] == "2026-01"]
febrero = ventas[ventas["fecha"] == "2026-02"]
todo = pd.concat([enero, febrero], ignore_index=True)
ignore_index=True renumera el índice de 0 en adelante en lugar de arrastrar los índices originales.
Encadenar operaciones: el flujo real de análisis
En la práctica encadenas varias operaciones para responder a una pregunta concreta. “Top 3 de vendedores por ingresos del primer trimestre”:
(ventas
.groupby("vendedor")["importe"].sum()
.sort_values(ascending=False)
.head(3))
Los paréntesis exteriores te dejan partir la cadena en líneas para que se lea bien. Cada paso recibe el resultado del anterior. Así se escribe análisis de datos legible.
Ejercicios
Usa la tabla ventas (y vendedores donde se indique).
10.1 — Calcula el importe total vendido por cada vendedor.
10.2 — Calcula el importe medio y el número de operaciones por ciudad, en una sola tabla con agg.
10.3 — ¿Cuánto se vendió de cada producto en cada mes (fecha)? Usa groupby con dos columnas.
10.4 — Crea una tabla dinámica con los productos en las filas, las ciudades en las columnas y la suma de importes en las celdas. Rellena los huecos con 0.
10.5 — Une ventas con vendedores por la columna vendedor. Después, calcula el importe total por equipo.
10.6 — Usa un merge con how="left" y otro con how="inner" entre ventas y vendedores. Explica por qué Iván aparece o no en cada caso.
10.7 — Encuentra, para cada vendedor, su venta más alta (el importe máximo) y ordénalos de mayor a menor.
10.8 — ¿Qué ciudad tiene el ticket medio (importe medio por venta) más alto?
10.9 — Usa pd.merge con how="outer" entre dos DataFrames con algunas filas sin par. Identifica qué filas están solo en cada uno (columnas del otro lado serán NaN).
10.10 — Crea una pivot_table que muestre el importe total por vendedor (filas) y ciudad (columnas). Añade totales con margins=True.
10.11 — Usa groupby + transform("mean") para añadir al DataFrame original una columna con la media del importe del grupo de cada fila, sin reducir el número de filas.
Soluciones
10.1
print(ventas.groupby("vendedor")["importe"].sum())
# Ada 1170
# Luis 375
# Marta 240
10.2
print(ventas.groupby("ciudad")["importe"].agg(["mean", "count"]))
10.3
print(ventas.groupby(["fecha", "producto"])["importe"].sum())
10.4
print(ventas.pivot_table(index="producto", columns="ciudad",
values="importe", aggfunc="sum", fill_value=0))
10.5
vendedores = pd.DataFrame({
"vendedor": ["Ada", "Luis", "Marta", "Iván"],
"equipo": ["Norte", "Sur", "Norte", "Sur"],
"antiguedad": [5, 2, 8, 1],
})
combinado = ventas.merge(vendedores, on="vendedor")
print(combinado.groupby("equipo")["importe"].sum())
# Norte 1410 (Ada + Marta)
# Sur 375 (Luis)
10.6
izq = ventas.merge(vendedores, on="vendedor", how="left")
inter = ventas.merge(vendedores, on="vendedor", how="inner")
Iván no tiene ninguna venta. Como merge parte de las filas de ventas, Iván no aparece en ninguno de los dos (no hay filas de ventas suyas que casar). Si hicieras how="right" o how="outer", sí saldría Iván, con sus importes a NaN. La diferencia entre left e inner se notaría si hubiera una venta con un vendedor que no está en la tabla vendedores: left la conserva, inner la descarta.
10.7
print(ventas.groupby("vendedor")["importe"].max().sort_values(ascending=False))
# Ada 600
# Luis 300
# Marta 100
10.8
print(ventas.groupby("ciudad")["importe"].mean().sort_values(ascending=False).head(1))
# Madrid 390.0
10.9
import pandas as pd
df1 = pd.DataFrame({"id": [1, 2, 3, 4], "valor_a": ["a1", "a2", "a3", "a4"]})
df2 = pd.DataFrame({"id": [2, 3, 5, 6], "valor_b": ["b2", "b3", "b5", "b6"]})
outer = pd.merge(df1, df2, on="id", how="outer")
print(outer)
print("Solo en df1:", outer[outer["valor_b"].isna()]["id"].tolist())
print("Solo en df2:", outer[outer["valor_a"].isna()]["id"].tolist())
10.10
tabla = pd.pivot_table(
ventas,
values="importe",
index="vendedor",
columns="ciudad",
aggfunc="sum",
fill_value=0,
margins=True,
margins_name="Total"
)
print(tabla)
10.11
ventas["media_ciudad"] = ventas.groupby("ciudad")["importe"].transform("mean")
print(ventas[["ciudad", "importe", "media_ciudad"]].head(8))
transform devuelve una Serie del mismo tamaño que el DataFrame original, a diferencia de agg que reduce a una fila por grupo.