Hay una idea muy extendida y muy falsa: que SQL sirve para sacar los datos y que el análisis de verdad se hace en otro sitio. En la práctica, la mayor parte del trabajo analítico de una empresa —las métricas del mes, la serie temporal, el desglose por país, el Pareto de clientes, las cohortes— cabe entero en SQL, se ejecuta donde están los datos y no necesita mover un solo fichero. Esta lección convierte las funciones de ventana de 10-03 en informes completos. Pero antes de la primera consulta hay algo más importante, y es la razón por la que un analista con criterio vale mucho más que uno rápido: la mitad de los errores de análisis no son de SQL, son de definición. ¿"Ventas" incluye los portes? ¿Y el pedido cancelado? ¿"Cliente activo" es el que compró alguna vez o el que compró este año? Cada una de esas preguntas cambia la cifra, y ninguna la resuelve el motor.
Contenido
- El flujo de trabajo del analista
- Las definiciones de TiendaVerde
- Métricas fundamentales
- Análisis temporal
- Segmentación
- Análisis ABC / Pareto
- Cohortes y retención
- Presentación: pivotar con
CASEy concrosstab - Reproducibilidad y dónde encaja SQL
- Errores de análisis frecuentes
- Errores Comunes y Consejos
- Ejercicios
- Conclusión
- El flujo de trabajo del analista
flowchart LR
A["<b>Pregunta de negocio</b><br/>'¿vendemos más que el año pasado?'"] --> B["<b>Métrica definida</b><br/>qué se suma, qué se excluye,<br/>qué periodo, qué granularidad"] --> C["<b>Consulta</b>"]
C --> D["<b>Validación</b><br/>¿cuadra con un total conocido?"] --> E["<b>Presentación</b><br/>tabla, gráfica, cuadro de mando"]
D -.->|"no cuadra"| B
Los dos pasos que se saltan siempre son el segundo y el cuarto, y son los que separan un número correcto de un número plausible. Definir obliga a hablar con quien hace la pregunta —y muchas veces descubres que la pregunta era otra—. Validar es comprobar el resultado contra algo que ya sabías: un total, un recuento, una cifra del año pasado. Si un desglose por país no suma lo mismo que el total general, el desglose está mal, y da igual lo elegante que sea la consulta.
- Las definiciones de TiendaVerde
Estas son las definiciones que usa el curso. No son "las correctas": son las que hemos elegido, y lo importante es que estén escritas.
| Métrica | Definición exacta | Valor |
|---|---|---|
| Facturación de producto ("ventas") | SUM(cantidad * precio_unitario * (1 - descuento)) sobre lineas_pedido, sin portes, todos los estados |
727,95 € |
| Ingresos totales | Facturación de producto + gastos de envío | 846,20 € |
| Ventas netas de cancelaciones | Facturación de producto excluyendo los pedidos cancelado |
701,20 € |
| Pedidos | Filas de pedidos, todos los estados |
20 |
| Ticket medio | Facturación de producto / número de pedidos | 36,40 € |
| Unidades por pedido | SUM(cantidad) / número de pedidos |
5,65 |
| Cliente comprador / recurrente | Con ≥ 1 pedido / con ≥ 2 pedidos | 12 de 15 / 7 |
| Pedido nuevo / recurrente | El primero de ese cliente / los siguientes | 12 / 8 |
| Cohorte | Mes de fecha_registro del cliente |
8 cohortes |
| Tasa de devolución (pedidos) | Pedidos con devolución / pedidos | 15,00 % |
| Tasa de devolución (importe) | Importe devuelto / facturación de producto | 11,07 % |
Y las tres decisiones que hay detrás:
- Los portes no son ventas. Son un servicio repercutido, no margen comercial. Si los incluyeras, la facturación sería 846,20 € y el ticket medio 42,31 €: cifras igual de "verdaderas" que responden a otra pregunta. Lo grave no es elegir mal, es mezclar las dos en el mismo informe.
- El pedido cancelado (el 6) cuenta en la facturación bruta y no en la neta. Sus 26,75 € se pidieron de verdad y se devolvieron enteros: un informe de demanda debe incluirlo, uno de ingresos no. Y "todos los estados" significa que los pedidos aún no entregados (2
pagado, 1pendiente, 2enviado) cuentan; para ingresos cobrados habría que filtrar por estado y el número sería otro.
- Métricas fundamentales
Las siete primeras, en una consulta con FILTER (04-04):
SELECT COUNT(DISTINCT pe.id) AS pedidos,
COUNT(DISTINCT pe.cliente_id) AS compradores,
SUM(lp.cantidad) AS unidades,
ROUND(SUM(lp.cantidad * lp.precio_unitario * (1 - lp.descuento)), 2) AS facturacion,
ROUND(SUM(lp.cantidad * lp.precio_unitario * (1 - lp.descuento))
FILTER (WHERE pe.estado <> 'cancelado'), 2) AS facturacion_neta,
ROUND(SUM(lp.cantidad * lp.precio_unitario * (1 - lp.descuento))
/ COUNT(DISTINCT pe.id), 2) AS ticket_medio,
ROUND(SUM(lp.cantidad)::numeric / COUNT(DISTINCT pe.id), 2) AS uds_por_pedido
FROM pedidos AS pe JOIN lineas_pedido AS lp ON lp.pedido_id = pe.id;| pedidos | compradores | unidades | facturacion | facturacion_neta | ticket_medio | uds_por_pedido |
|---|---|---|---|---|---|---|
| 20 | 12 | 113 | 727.95 | 701.20 | 36.40 | 5.65 |
La validación inmediata: 20 pedidos y 113 unidades son las cifras que el curso arrastra desde 01-06, y 727,95 − 26,75 = 701,20 cuadra con la devolución del pedido cancelado. Si alguno de los tres no diera, habría que parar.
Tasa de devolución
SELECT COUNT(*) AS devoluciones, ROUND(SUM(d.importe), 2) AS importe_devuelto,
ROUND(100.0 * COUNT(DISTINCT d.pedido_id) / (SELECT COUNT(*) FROM pedidos), 2) AS tasa_pedidos_pct,
ROUND(100 * SUM(d.importe) / 727.95, 2) AS tasa_importe_pct
FROM devoluciones AS d;| devoluciones | importe_devuelto | tasa_pedidos_pct | tasa_importe_pct |
|---|---|---|---|
| 3 | 80.57 | 15.00 | 11.07 |
Las dos tasas dicen cosas distintas y hay que publicar cuál es: el 15 % de los pedidos tuvo alguna devolución, pero solo se devolvió el 11 % del dinero, porque dos de las tres son parciales —del pedido 10 se devolvió la crema (34,02 €) de 48,27 €, y del 13 las bolsas (19,80 €) de 30,30 €—. Solo el pedido 6, cancelado, se devolvió entero.
Productos activos sin ventas y clientes nuevos frente a recurrentes
SELECT p.id, p.nombre, p.stock FROM productos AS p
WHERE p.activo AND NOT EXISTS (SELECT 1 FROM lineas_pedido AS lp WHERE lp.producto_id = p.id)
ORDER BY p.id;| id | nombre | stock |
|---|---|---|
| 13 | Velas de cera de soja (pack 2) | 0 |
| 19 | Desodorante natural en barra 50 g | 75 |
Dos productos activos que nunca se han vendido, con lecturas de negocio opuestas: las velas tienen stock 0 —quizá nunca llegaron a estar disponibles— y el desodorante tiene 75 unidades esperando. El tercero sin ventas, las cápsulas de espirulina, no aparece porque está descatalogado: ese WHERE p.activo es una decisión de definición, no un detalle.
WITH primero AS (SELECT cliente_id, MIN(fecha_pedido) AS primer_pedido FROM pedidos GROUP BY cliente_id)
SELECT to_char(pe.fecha_pedido, 'YYYY-MM') AS mes,
COUNT(*) FILTER (WHERE pe.fecha_pedido = pr.primer_pedido) AS pedidos_nuevos,
COUNT(*) FILTER (WHERE pe.fecha_pedido > pr.primer_pedido) AS pedidos_recurrentes
FROM pedidos AS pe JOIN primero AS pr ON pr.cliente_id = pe.cliente_id
GROUP BY 1 ORDER BY 1;| mes | pedidos_nuevos | pedidos_recurrentes |
|---|---|---|
| 2025-03 | 2 | 0 |
| 2025-10 | 2 | 0 |
| 2025-12 | 0 | 2 |
| 2026-02 | 0 | 2 |
(4 de 12 filas; el total es 12 nuevos y 8 recurrentes.) La lectura es la que espera cualquier negocio joven: hasta noviembre casi todos los pedidos son de clientes nuevos, y desde diciembre todos son de clientes que repiten. Es una señal buena —hay retención— y otra preocupante: la captación se ha parado. Ninguna de las dos se ve mirando solo la facturación total.
- Análisis temporal
Las tres columnas que pide cualquier cuadro de mando, sobre la serie mensual de 10-03:
WITH mensual AS (
SELECT date_trunc('month', pe.fecha_pedido)::date AS mes,
ROUND(SUM(lp.cantidad * lp.precio_unitario * (1 - lp.descuento)), 2) AS facturacion
FROM pedidos AS pe JOIN lineas_pedido AS lp ON lp.pedido_id = pe.id GROUP BY 1)
SELECT to_char(mes, 'YYYY-MM') AS mes, facturacion,
SUM(facturacion) OVER w AS acumulado,
ROUND(AVG(facturacion) OVER (ORDER BY mes
ROWS BETWEEN 2 PRECEDING AND CURRENT ROW), 2) AS media_movil_3,
ROUND(100 * (facturacion - LAG(facturacion) OVER w)
/ LAG(facturacion) OVER w, 2) AS var_pct
FROM mensual WINDOW w AS (ORDER BY mes) ORDER BY mes;| mes | facturacion | acumulado | media_movil_3 | var_pct |
|---|---|---|---|---|
| 2025-03 | 68.80 | 68.80 | 68.80 | (null) |
| 2025-09 | 32.76 | 410.04 | 41.88 | -32.13 |
| 2025-10 | 97.20 | 507.24 | 59.41 | 196.70 |
| 2025-12 | 64.58 | 603.52 | 64.49 | 103.72 |
| 2026-02 | 49.33 | 727.95 | 63.00 | -34.31 |
(5 de 12 filas; la serie completa está en 10-03.) Tres cosas que un informe debe decir y que la tabla sola no dice:
- El acumulado cierra en 727,95 € y pasa por 603,52 € en diciembre: son las dos cifras canónicas, y su coincidencia valida la serie entera.
- La media móvil de 3 meses es lo que hay que enseñar en la gráfica, no la serie cruda: la facturación real oscila entre 31,70 € y 97,20 € y la media móvil entre 41,88 € y 71,87 €. Con volúmenes pequeños, la serie cruda es sobre todo ruido.
- El
+196,70 %de octubre no es una noticia: es que septiembre tuvo un solo pedido, y publicar esa variación sin la base sobre la que se calcula es engañar de buena fe. Regla: no publiques una variación porcentual si el denominador es pequeño; publica la cifra absoluta y el número de pedidos al lado.
La comparación interanual que no se puede hacer
La pregunta "¿vendemos más que el año pasado por estas fechas?" es la más frecuente del mundo, y en TiendaVerde no tiene respuesta: la serie empieza en marzo de 2025 y termina en febrero de 2026, así que enero y febrero de 2026 no tienen contra qué compararse. Lo correcto es decirlo, no calcular un NULL y dejar que alguien lo interprete. Y lo que sí se puede hacer, con la misma honestidad: comparar los dos años parciales, dejando claro que no son comparables en duración.
| año | meses con datos | pedidos | facturacion |
|---|---|---|---|
| 2025 | 10 (mar-dic) | 16 | 603.52 |
| 2026 | 2 (ene-feb) | 4 | 124.43 |
Los 124,43 € de 2026 no son "una caída del 79 %": son dos meses frente a diez. Lo comparable es la media mensual —60,35 € en 2025 frente a 62,22 € en 2026— o los mismos meses de calendario, que aquí no existen.
- Segmentación
Un mismo total, cortado por cuatro dimensiones. El patrón es siempre el mismo GROUP BY, y lo importante es que los cuatro desgloses suman 727,95 €:
SELECT cat.nombre AS categoria, COUNT(DISTINCT pe.id) AS pedidos,
ROUND(SUM(lp.cantidad * lp.precio_unitario * (1 - lp.descuento)), 2) AS facturacion,
ROUND(100 * SUM(lp.cantidad * lp.precio_unitario * (1 - lp.descuento))
/ SUM(SUM(lp.cantidad * lp.precio_unitario * (1 - lp.descuento))) OVER (), 2) AS pct
FROM lineas_pedido AS lp
JOIN productos AS p ON p.id = lp.producto_id
JOIN categorias AS cat ON cat.id = p.categoria_id
JOIN pedidos AS pe ON pe.id = lp.pedido_id
GROUP BY cat.nombre ORDER BY facturacion DESC;| categoria | pedidos | facturacion | pct |
|---|---|---|---|
| Alimentación | 10 | 256.27 | 35.20 |
| Bebidas | 8 | 195.28 | 26.83 |
| Cosmética natural | 6 | 156.32 | 21.47 |
| Hogar sostenible | 4 | 88.58 | 12.17 |
| Higiene personal | 3 | 31.50 | 4.33 |
Complementos no aparece, y eso también es un resultado: su único producto está descatalogado. Un informe honesto lo dice; uno que enseña cinco filas deja creer que hay cinco categorías. Los otros tres cortes:
| País del cliente | Pedidos | Facturación | Ticket medio |
|---|---|---|---|
| España | 14 | 433.70 | 30.98 |
| Portugal | 3 | 156.48 | 52.16 |
| Francia | 3 | 137.77 | 45.92 |
| Método de pago | Pedidos | Facturación | Canal | Pedidos | Facturación |
|---|---|---|---|---|---|
| tarjeta | 11 | 398.00 | Teléfono (con comercial) | 10 | 378.83 |
| paypal | 4 | 155.05 | Web (sin comercial) | 10 | 349.12 |
| transferencia | 3 | 121.70 | — | — | — |
| contrareembolso | 2 | 53.20 | — | — | — |
Tres lecturas que ningún total general daba. España aporta el 60 % de la facturación pero tiene el ticket medio más bajo (30,98 € frente a los 52,16 € de Portugal): muchos pedidos pequeños contra pocos grandes, lo que cambia por completo la estrategia de portes. La tarjeta concentra el 55 % de la facturación en 11 de los 20 pedidos. Y los dos canales están empatados en número, con el teléfono ligeramente por delante en importe (378,83 € contra 349,12 €): que el canal atendido facture más por pedido es lo que justificaría tener comerciales, pero es una hipótesis contrastable, no una conclusión.
- Análisis ABC / Pareto
El principio de Pareto —"pocos elementos explican la mayor parte del total"— se calcula con un acumulado de ventana (10-03) y se clasifica con CASE:
WITH ventas AS (
SELECT c.id, c.nombre || ' ' || c.apellidos AS cliente, c.pais,
ROUND(SUM(lp.cantidad * lp.precio_unitario * (1 - lp.descuento)), 2) AS facturacion
FROM pedidos AS pe
JOIN clientes AS c ON c.id = pe.cliente_id
JOIN lineas_pedido AS lp ON lp.pedido_id = pe.id
GROUP BY c.id, c.nombre, c.apellidos, c.pais),
acum AS (SELECT *, ROUND(100 * SUM(facturacion) OVER (ORDER BY facturacion DESC, id
ROWS UNBOUNDED PRECEDING)
/ SUM(facturacion) OVER (), 2) AS pct_acum FROM ventas)
SELECT cliente, pais, facturacion, pct_acum,
CASE WHEN pct_acum <= 80 THEN 'A' WHEN pct_acum <= 95 THEN 'B' ELSE 'C' END AS clase
FROM acum ORDER BY facturacion DESC;| cliente | pais | facturacion | pct_acum | clase |
|---|---|---|---|---|
| Sofia Moreira Costa | Portugal | 111.88 | 15.37 | A |
| Lucía Martínez Soler | España | 107.60 | 30.15 | A |
| Camille Dubois | Francia | 70.87 | 39.89 | A |
| Carlos Ferrer Ibáñez | España | 59.46 | 65.89 | A |
| Pau Llorens Vidal | España | 57.33 | 73.76 | A |
(5 de las 7 filas de clase A —faltan Julien Moreau, 4.º con 66,90 €, y Javier Ortega Ruiz, 5.º con 62,93 €—. Después vienen Ana con 54,85 €, Tiago con 44,60 € y Diego con 31,70 € en clase B, y Elena con 30,30 € y Marta con 29,53 € en clase C.) El resumen por clase, para clientes y para productos:
| Clase | Clientes | % facturación | Productos | % facturación |
|---|---|---|---|---|
| A (hasta el 80 % acumulado) | 7 | 73.76 % | 10 | 77.41 % |
| B (hasta el 95 %) | 3 | 18.02 % | 4 | 14.66 % |
| C (el resto) | 2 | 8.22 % | 3 | 7.93 % |
El Pareto de TiendaVerde es suave: los 6 primeros clientes explican el 65,89 % y hacen falta 7 para llegar al 73,76 %; en el clásico 80/20 bastarían 2 o 3 de 12. Es un dato de negocio: la tienda no depende de un cliente grande, lo que reduce el riesgo y a la vez indica que no hay cuentas clave que cultivar. Y una advertencia metodológica: el corte 80/95 es una convención, y hay que escribir cuál usas —incluyendo o no la fila que cruza el umbral— porque cambia quién entra en cada grupo.
- Cohortes y retención
Una cohorte agrupa clientes por su momento de entrada y los sigue en el tiempo. Sobre TiendaVerde, con el mes de fecha_registro:
WITH v AS (SELECT pe.cliente_id, COUNT(DISTINCT pe.id) AS pedidos,
SUM(lp.cantidad * lp.precio_unitario * (1 - lp.descuento)) AS facturacion
FROM pedidos AS pe JOIN lineas_pedido AS lp ON lp.pedido_id = pe.id GROUP BY 1)
SELECT to_char(c.fecha_registro, 'YYYY-MM') AS cohorte,
COUNT(*) AS clientes,
COUNT(v.cliente_id) AS compradores,
ROUND(100.0 * COUNT(v.cliente_id) / COUNT(*), 1) AS conversion_pct,
COALESCE(SUM(v.pedidos), 0) AS pedidos,
COALESCE(ROUND(SUM(v.facturacion), 2), 0.00) AS facturacion
FROM clientes AS c LEFT JOIN v ON v.cliente_id = c.id
GROUP BY 1 ORDER BY 1;| cohorte | clientes | compradores | conversion_pct | pedidos | facturacion |
|---|---|---|---|---|---|
| 2025-01 | 2 | 2 | 100.0 | 5 | 167.06 |
| 2025-02 | 3 | 3 | 100.0 | 5 | 147.31 |
| 2025-03 | 2 | 2 | 100.0 | 4 | 169.21 |
| 2025-04 | 2 | 2 | 100.0 | 3 | 115.47 |
| 2025-05 | 2 | 2 | 100.0 | 2 | 97.20 |
| 2025-06 | 2 | 1 | 50.0 | 1 | 31.70 |
| 2025-09 | 1 | 0 | 0.0 | 0 | 0.00 |
| 2026-01 | 1 | 0 | 0.0 | 0 | 0.00 |
La consulta es correcta y el análisis sería una tontería. Las cohortes tienen uno, dos o tres clientes: un solo cliente que no compra convierte la cohorte de 2025-09 en un "0 % de conversión" que no significa nada. Y las cohortes antiguas ganan por definición, porque llevan más tiempo comprando: comparar los 5 pedidos de la de enero con el 1 de la de junio es comparar diez meses con ocho. Y esa es la lección de análisis, no la de SQL. Un resultado con muestras de tamaño 1 o 2 no es un resultado: es una anécdota con formato de tabla. Lo que hay que hacer es (a) decirlo en el informe, (b) agrupar en cohortes más grandes —por trimestre en lugar de por mes— y (c) comparar siempre a la misma edad: "pedidos en los 90 días siguientes al alta", que pone a todas las cohortes en igualdad y es la forma estándar de hacer retención. Con 15 clientes ni eso salvaría el análisis; con 15.000, es exactamente el informe que pedirá dirección.
Cuándo un desglose deja de tener sentido: cuando algún grupo baja de unas decenas de observaciones, el porcentaje que calcules oscilará más que la señal que buscas. Antes de partir un total en veinte trozos, mira cuántas filas quedan en el trozo más pequeño.
- Presentación: pivotar con
CASE y con crosstab
CASE y con crosstabUn informe de dirección casi nunca quiere filas: quiere una matriz, con las categorías en las filas y los años en las columnas. La forma portable es la de 06-05, un agregado condicional por columna (SUM(CASE …), o su forma moderna con FILTER):
SELECT cat.nombre AS categoria,
ROUND(SUM(lp.cantidad * lp.precio_unitario * (1 - lp.descuento))
FILTER (WHERE pe.fecha_pedido < '2026-01-01'), 2) AS a2025,
ROUND(COALESCE(SUM(lp.cantidad * lp.precio_unitario * (1 - lp.descuento))
FILTER (WHERE pe.fecha_pedido >= '2026-01-01'), 0), 2) AS a2026,
ROUND(SUM(lp.cantidad * lp.precio_unitario * (1 - lp.descuento)), 2) AS total
FROM lineas_pedido AS lp
JOIN productos AS p ON p.id = lp.producto_id
JOIN categorias AS cat ON cat.id = p.categoria_id
JOIN pedidos AS pe ON pe.id = lp.pedido_id
GROUP BY cat.nombre ORDER BY total DESC;| categoria | a2025 | a2026 | total |
|---|---|---|---|
| Alimentación | 215.67 | 40.60 | 256.27 |
| Bebidas | 146.55 | 48.73 | 195.28 |
| Cosmética natural | 128.22 | 28.10 | 156.32 |
| Hogar sostenible | 88.58 | 0.00 | 88.58 |
| Higiene personal | 24.50 | 7.00 | 31.50 |
Las columnas suman 603,52 € y 124,43 €: las cifras canónicas de 2025 y 2026. Y Hogar sostenible sale con 0,00 € en 2026 gracias al COALESCE, lo cual es informativo: dejó de venderse.
crosstab de la extensión tablefunc
Aquí se cierra la promesa de 06-05. PostgreSQL trae la extensión tablefunc, con una función crosstab() que pivota a partir de una consulta de tres columnas: fila, columna y valor.
CREATE EXTENSION IF NOT EXISTS tablefunc;
SELECT * FROM crosstab(
$$SELECT cat.nombre, to_char(pe.fecha_pedido, 'YYYY'),
ROUND(SUM(lp.cantidad * lp.precio_unitario * (1 - lp.descuento)), 2)
FROM lineas_pedido AS lp
JOIN productos AS p ON p.id = lp.producto_id
JOIN categorias AS cat ON cat.id = p.categoria_id
JOIN pedidos AS pe ON pe.id = lp.pedido_id
GROUP BY 1, 2 ORDER BY 1, 2$$,
$$SELECT unnest(ARRAY['2025','2026'])$$ -- 2.ª consulta: las columnas, en orden
) AS t(categoria text, a2025 numeric, a2026 numeric); -- ⬅️ hay que declararlas a mano| categoria | a2025 | a2026 |
|---|---|---|
| Alimentación | 215.67 | 40.60 |
| Bebidas | 146.55 | 48.73 |
| Cosmética natural | 128.22 | 28.10 |
| Higiene personal | 24.50 | 7.00 |
| Hogar sostenible | 88.58 | (null) |
Las mismas cifras, con dos diferencias que importan. El orden es alfabético por la columna de fila, porque crosstab exige ORDER BY 1, 2 en la consulta de origen y no admite otro. Y Hogar sostenible sale NULL en 2026, no 0,00, porque para crosstab "no hay fila" y "hay una fila con cero" son cosas distintas — y en una gráfica, NULL es un hueco. Se arregla envolviendo con COALESCE(a2026, 0) fuera.
SUM(CASE …) (06-05) |
crosstab |
|
|---|---|---|
| Portabilidad | Total: SQL estándar | Solo PostgreSQL, y hay que instalar la extensión |
| Escribir 12 columnas | 12 CASE: tedioso |
Una consulta corta |
| Columnas dinámicas | Hay que conocerlas de antemano | También: la lista de salida se declara a mano |
| Ausencia de datos | 0 (con el ELSE 0) |
NULL |
| Legibilidad | Verbosa pero evidente | Compacta y críptica: dos consultas anidadas en $$ |
El criterio: para dos, tres o cuatro columnas, SUM(CASE …) gana por claridad y portabilidad; crosstab compensa a partir de ocho o diez columnas fijas y conocidas. Y para columnas de verdad dinámicas —"una por mes, los que haya"— ninguna de las dos sirve: SQL devuelve un número fijo de columnas decidido al planificar. Ese pivote se hace en la capa de presentación (BI, hoja de cálculo, pandas.pivot_table), y ese es el reparto natural del trabajo.
- Reproducibilidad y dónde encaja SQL
Un análisis que no se puede repetir dentro de tres meses no es un análisis, es una captura de pantalla:
- Las consultas viven en el repositorio, en ficheros
.sqlcon un comentario que diga qué pregunta responden y qué definición usan ("ventas = producto, sin portes, todos los estados"). Un cuadro de mando con el SQL escondido dentro de la herramienta es un cuadro de mando que nadie puede auditar. - Vistas y vistas materializadas como capa semántica (10-01):
v_detalle_ventasdefine una vez qué es el importe de una línea, y a partir de ahí nadie vuelve a escribircantidad * precio_unitario * (1 - descuento)— que es exactamente donde alguien se olvidará del descuento.mv_ventas_mensualeshace lo mismo con la serie, y además evita recalcularla en cada consulta. - Números que se validan solos. Si cada informe incluye un total que ya conoces, un desglose mal hecho se delata al instante. Y el reparto con las otras herramientas, que es la pregunta que todo analista se hace:
| Herramienta | Hace bien | Hace mal |
|---|---|---|
| SQL | Filtrar, unir, agregar, ordenar, ventanas; trabajar donde están los datos sin moverlos; volúmenes que no caben en memoria | Estadística avanzada, modelos, gráficas, bucles, texto libre complejo |
| Python / pandas / R | Modelos, series, limpieza compleja, gráficas, reproducibilidad en cuadernos | Escala: si tienes que traer 50 millones de filas para agrupar, agrúpalas en SQL |
| BI (Power BI, Metabase, Looker, Superset) | Publicar, explorar, filtrar interactivamente, distribuir | Definir métricas: si cada panel define "ventas" a su manera, tendrás cinco cifras distintas |
El criterio del curso: agrega en SQL, modela y dibuja fuera. La regla operativa: lo que reduzca filas, hazlo lo más cerca posible de la base de datos. Traer 2 millones de filas a pandas para un groupby que devuelve 12 es tirar red, memoria y tiempo, y es la versión analítica del antipatrón de 08-04.
- Errores de análisis frecuentes
| Error | Cómo se manifiesta | Cómo se evita |
|---|---|---|
Contar filas duplicadas por un JOIN |
20 pedidos se convierten en 47; el ticket medio se divide por 2,35 | COUNT(DISTINCT pe.id); comprobar el recuento tras cada JOIN |
| Media de medias | Promediar los tickets medios de los tres países da 43,02 €, no 36,40 € | Sumar numeradores y denominadores |
Ignorar los NULL |
AVG los omite; COUNT(columna) no los cuenta; un NOT IN con nulos devuelve 0 filas |
Decidir explícitamente: COALESCE, FILTER, NOT EXISTS (04-03) |
| Comparar periodos incompletos | "Febrero cae un 34 %" cuando febrero aún no ha terminado | Comparar periodos cerrados, o el mismo número de días |
| Publicar un porcentaje sobre pocos casos | El +196,70 % de octubre sobre un único pedido de septiembre |
Publicar la cifra absoluta y el tamaño de muestra al lado |
| Confundir correlación con causalidad | "Los pedidos con comercial facturan más → pongamos más comerciales" | Los comerciales atienden llamadas, que ya suelen ser pedidos mayores. Para afirmar causa hace falta un experimento |
| Cambiar la definición a mitad de informe | Una tabla con portes y la siguiente sin ellos | Escribir la definición una vez y encapsularla en una vista |
La penúltima fila es la más peligrosa por lo razonable que suena: el canal telefónico factura 378,83 € frente a los 349,12 € de la web, pero eso no demuestra que el comercial genere más venta — con 10 pedidos por canal la diferencia es de 3 € por pedido. La forma de saberlo es un experimento, no una consulta.
Errores Comunes y Consejos
- Empezar por la consulta y no por la definición. "Dame las ventas del mes" tiene al menos cuatro respuestas correctas. Pregunta antes de escribir. Y no validar contra un total conocido: es la comprobación más barata y detecta el 90 % de los errores de
JOIN. - Redondear en cada paso. Redondea solo al presentar: redondear un intermedio y luego sumar acumula el error. Y usar
AVGde una columna ya promediada: la media de medias solo coincide con la global si todos los grupos tienen el mismo tamaño. - Presentar una gráfica sin los meses vacíos. El calendario con
generate_series(11-01) no es un adorno: sin él la tendencia es otra. Y tratar un porcentaje sobre 1 o 2 casos como información: con muestras pequeñas, los porcentajes mienten más que informan. - Consejo: escribe la definición en un comentario dentro de la propia consulta. El informe y su definición viajan juntos, y quien lo herede sabrá qué está mirando.
- Consejo: guarda la cifra de control. Cada informe recurrente debería llevar una fila o columna que delate que algo se ha roto — el equivalente analítico de una prueba automática. Y si un resultado te sorprende, sospecha del SQL antes que del negocio: nueve de cada diez sorpresas son un
JOINque multiplica o un filtro que faltaba.
Ejercicios
Ejercicio 1
Dirección pide "el margen por categoría". (1) Enumera tres decisiones de definición que hay que tomar antes de escribir nada. (2) Escribe la consulta usando productos.coste y el importe de línea del curso. (3) ¿Por qué el margen calculado así puede estar mal aunque la consulta sea correcta?
Ejercicio 2
Calcula, para cada mes, la facturación, el número de clientes distintos que compraron y la facturación media por cliente, y ordénalo por mes. (1) Escríbelo. (2) ¿Por qué la suma de "clientes distintos por mes" no da 12? (3) ¿Qué mes tiene la facturación media por cliente más alta y qué precaución hay que tomar antes de destacarlo?
Ejercicio 3
Un compañero presenta esta conclusión: "Portugal es nuestro mejor mercado: su ticket medio es un 68 % superior al de España". (1) ¿Es cierto el dato? (2) Da tres razones por las que la conclusión no se sostiene. (3) ¿Qué análisis propondrías en su lugar?
Soluciones
Solución 1
1. (a) ¿Margen sobre el precio real de venta (con descuento) o sobre el de tarifa? El descuento sale del margen, así que del importe de línea. (b) ¿El coste es el actual (productos.coste) o el del momento de la venta? El esquema solo guarda el actual: hay que decirlo. (c) ¿Se incluyen los pedidos cancelados? Un margen sobre ventas que se devolvieron no es margen.
-- 2
SELECT cat.nombre AS categoria,
ROUND(SUM(lp.cantidad * lp.precio_unitario * (1 - lp.descuento)), 2) AS ingresos,
ROUND(SUM(lp.cantidad * p.coste), 2) AS coste,
ROUND(SUM(lp.cantidad * lp.precio_unitario * (1 - lp.descuento))
- SUM(lp.cantidad * p.coste), 2) AS margen
FROM lineas_pedido AS lp
JOIN productos AS p ON p.id = lp.producto_id
JOIN categorias AS cat ON cat.id = p.categoria_id
JOIN pedidos AS pe ON pe.id = lp.pedido_id
WHERE pe.estado <> 'cancelado'
GROUP BY cat.nombre ORDER BY margen DESC;3. Porque coste es el coste actual, no el de la venta: es el problema que precio_unitario sí resuelve para el precio (01-06) y que el esquema no resuelve para el coste, así que si los costes han subido el margen histórico saldrá subestimado. La solución de diseño sería guardar coste_unitario en lineas_pedido; mientras no exista, el número se publica como estimación y se dice por qué.
Solución 2
SELECT to_char(pe.fecha_pedido, 'YYYY-MM') AS mes,
ROUND(SUM(lp.cantidad * lp.precio_unitario * (1 - lp.descuento)), 2) AS facturacion,
COUNT(DISTINCT pe.cliente_id) AS clientes,
ROUND(SUM(lp.cantidad * lp.precio_unitario * (1 - lp.descuento))
/ COUNT(DISTINCT pe.cliente_id), 2) AS media_por_cliente
FROM pedidos AS pe JOIN lineas_pedido AS lp ON lp.pedido_id = pe.id GROUP BY 1 ORDER BY 1;2. Porque un cliente puede comprar en varios meses y se cuenta en cada uno: COUNT(DISTINCT cliente_id) es distinto dentro de cada grupo, y los grupos no son disjuntos por cliente — sumar esa columna cuenta a Lucía tres veces. El total de compradores distintos, 12, solo sale de una consulta sin GROUP BY por mes; es el mismo error que sumar "usuarios activos diarios" para obtener los mensuales. 3. El más alto es 2025-10, con 48,60 € por cliente (97,20 € entre 2 clientes), y la precaución es la del apartado 7: son dos clientes, así que el "récord" lo explica un solo pedido grande.
Solución 3
1. El dato es cierto: 52,16 € frente a 30,98 € son un 68,4 % más; la aritmética está bien. 2. (a) Tamaño de muestra: Portugal son 3 pedidos de 2 clientes. Un solo pedido grande mueve el ticket medio decenas de euros; no hay base para una conclusión. (b) "Mejor mercado" no es "ticket medio": España aporta 433,70 €, casi el triple que Portugal (156,48 €), con 14 pedidos y 8 clientes. Si "mejor" es volumen, la conclusión se invierte. (c) Faltan los costes: enviar a Portugal cuesta 9,90 € frente a los 4,95 € o 0 € nacionales, y ese porte se lleva parte de la ventaja; el margen podría ser menor. Y una cuarta, de método: el ticket medio más alto puede deberse a que los portes internacionales empujan al cliente a juntar más artículos por pedido —el envío gratuito a partir de cierto importe—, que es un efecto del propio esquema de precios y no una propiedad del mercado.
3. Comparar margen por cliente y por periodo, no ticket medio: facturación menos coste de producto menos coste real de envío, dividido entre clientes activos, con el número de observaciones publicado junto a cada cifra. Y si la pregunta real es "¿dónde invertimos en captación?", la respuesta necesita el coste de adquisición y la repetición de compra por país, no una media de tres pedidos.
Conclusión
SQL es una herramienta analítica de pleno derecho, y el oficio va menos de sintaxis que de criterio:
- El flujo es pregunta → definición → consulta → validación → presentación, y los pasos que todo el mundo se salta son el segundo y el cuarto. Definir obliga a decidir si "ventas" incluye portes (727,95 € frente a 846,20 €) y si el pedido cancelado cuenta (727,95 € frente a 701,20 €); validar es comprobar contra un total que ya conocías. Las métricas fundamentales de TiendaVerde: 20 pedidos, 12 compradores, 113 unidades, 36,40 € de ticket medio, 5,65 unidades por pedido, 15,00 % de tasa de devolución por pedidos y 11,07 % por importe —dos números distintos que responden a preguntas distintas—, 2 productos activos sin ventas y un reparto de 12 pedidos nuevos frente a 8 recurrentes que revela que la captación se ha parado.
- El análisis temporal con acumulado (que cierra en 727,95 €), media móvil de 3 meses (la que hay que enseñar) y variación mensual (el
+196,70 %de octubre que no es una noticia). Y la comparación interanual que no se puede hacer, porque decirlo es parte del trabajo. La segmentación por categoría, país, método de pago y canal, con los cuatro desgloses sumando 727,95 €, y el hallazgo que ningún total daba: España factura más pero con el ticket medio más bajo. El Pareto es suave —6 clientes explican el 65,89 %—, con clases A/B/C de 7/3/2 clientes y 10/4/3 productos. - Las cohortes salen bien escritas y mal fundadas: con uno o dos clientes por cohorte, el resultado es una anécdota con formato de tabla. Decirlo, agrupar más grueso y comparar a la misma edad. El pivote con
SUM(CASE …)—portable, con ceros— y concrosstabdetablefunc—compacto, conNULLdonde no hay datos y con las columnas declaradas a mano—, cerrando la promesa de 06-05. Ninguno de los dos hace columnas realmente dinámicas: eso es de la capa de presentación. - Reproducibilidad: consultas en el repositorio con su definición escrita, vistas y materializadas como capa semántica, y el reparto: agrega en SQL, modela y dibuja fuera.
Todo esto se ejecuta en psql o en una herramienta de BI. Pero el SQL que de verdad se ejecuta más veces al día no lo escribe un analista: lo lanza una aplicación, cientos de veces por segundo, desde un proceso web que abre conexiones, ejecuta consultas y las cierra. En la lección siguiente, SQL en desarrollo web, cierras el módulo: la conexión y el pool; cómo se ejecuta una consulta parametrizada desde el código y cómo se maneja la transacción; ORM frente a SQL a mano con el criterio del curso; los antipatrones que matan una web, empezando por el N+1 que 08-04 dejó pendiente; los patrones útiles —paginación por cursor, LIMIT defensivo, colas con SKIP LOCKED, JSON directo desde PostgreSQL—; y la lista de comprobación para cuando alguien dice que "la web va lenta".
Curso de SQL
Módulo 1: Introducción a SQL
- ¿Qué es SQL?
- Configurando tu entorno SQL
- Sintaxis básica de SQL
- Entendiendo bases de datos y tablas
- El modelo relacional: claves primarias y foráneas
- La base de datos del curso: TiendaVerde
Módulo 2: Consultas básicas de SQL
- Instrucción SELECT
- Alias, expresiones y columnas calculadas
- Filtrando datos con WHERE
- DISTINCT y eliminación de duplicados
- Ordenando datos con ORDER BY
- Limitando resultados con LIMIT
Módulo 3: Trabajando con múltiples tablas
- Operaciones JOIN
- INNER JOIN
- LEFT JOIN
- RIGHT JOIN
- FULL OUTER JOIN
- SELF JOIN y CROSS JOIN
- Uniones de conjuntos: UNION, INTERSECT y EXCEPT
Módulo 4: Filtrado avanzado de datos
- Usando LIKE para coincidencia de patrones
- Operadores IN y BETWEEN
- Valores NULL y IS NULL
- Funciones de agregación: COUNT, SUM, AVG, MIN y MAX
- Agregando datos con GROUP BY
- Cláusula HAVING
Módulo 5: Manipulación de datos
- Creando tablas y restricciones con CREATE TABLE
- Instrucción INSERT
- Instrucción UPDATE
- Instrucción DELETE
- Instrucción UPSERT (MERGE)
- Modificando el esquema: ALTER TABLE y migraciones seguras
Módulo 6: Funciones avanzadas de SQL
- Funciones de cadena
- Funciones numéricas
- Funciones de fecha y hora
- Conversión de tipos y manejo de NULL: CAST y COALESCE
- Expresiones condicionales
Módulo 7: Subconsultas y consultas anidadas
- Introducción a subconsultas
- Subconsultas correlacionadas
- EXISTS y NOT EXISTS
- Usando subconsultas en cláusulas SELECT, FROM y WHERE
- Subconsultas o JOIN: cuál elegir
Módulo 8: Índices y optimización de rendimiento
- Entendiendo los índices
- Creación y gestión de índices
- Tipos de índice y cuándo no indexar
- Técnicas de optimización de consultas
- Análisis del rendimiento de consultas
Módulo 9: Transacciones y concurrencia
- Introducción a las transacciones
- Propiedades ACID
- Instrucciones de control de transacciones
- Niveles de aislamiento y anomalías de concurrencia
- Manejo de concurrencia: bloqueos e interbloqueos
Módulo 10: Temas avanzados
- Vistas
- Expresiones de tabla comunes (CTE)
- Funciones de ventana
- Procedimientos almacenados
- Triggers
- JSON y datos semiestructurados
Módulo 11: SQL en la práctica
- Casos de uso en el mundo real
- Mejores prácticas
- Seguridad: inyección SQL, permisos y roles
- SQL para análisis de datos
- SQL en desarrollo web
