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

  1. El flujo de trabajo del analista
  2. Las definiciones de TiendaVerde
  3. Métricas fundamentales
  4. Análisis temporal
  5. Segmentación
  6. Análisis ABC / Pareto
  7. Cohortes y retención
  8. Presentación: pivotar con CASE y con crosstab
  9. Reproducibilidad y dónde encaja SQL
  10. Errores de análisis frecuentes
  11. Errores Comunes y Consejos
  12. Ejercicios
  13. Conclusión

  1. 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.

  1. 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, 1 pendiente, 2 enviado) cuentan; para ingresos cobrados habría que filtrar por estado y el número sería otro.

  1. 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.

  1. 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.

  1. 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.

  1. 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.

  1. 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.

  1. Presentación: pivotar con CASE y con crosstab

Un 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.

  1. 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 .sql con 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_ventas define una vez qué es el importe de una línea, y a partir de ahí nadie vuelve a escribir cantidad * precio_unitario * (1 - descuento) — que es exactamente donde alguien se olvidará del descuento. mv_ventas_mensuales hace 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.

  1. 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 AVG de 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 JOIN que 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 con crosstab de tablefunc —compacto, con NULL donde 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

Módulo 2: Consultas básicas de SQL

Módulo 3: Trabajando con múltiples tablas

Módulo 4: Filtrado avanzado de datos

Módulo 5: Manipulación de datos

Módulo 6: Funciones avanzadas de SQL

Módulo 7: Subconsultas y consultas anidadas

Módulo 8: Índices y optimización de rendimiento

Módulo 9: Transacciones y concurrencia

Módulo 10: Temas avanzados

Módulo 11: SQL en la práctica

Módulo 12: Proyecto final

© Copyright 2026. Todos los derechos reservados