Saltar al contenido
Gontzal Bilbao
← cheatsheets

sql · analítica

SQL para Data & Analítica

Consultas SQL para analizar datos y acelerar informes: agregados, JOINs, CTEs, funciones de ventana, upserts e índices.

11 secciones · 32 comandos · revisado 6 oct 2026

Ábrela cuando tengas que sacar una métrica, cruzar tablas o acelerar una consulta lenta de un informe. Si una consulta va lenta o un total no cuadra y no sabes por dónde empezar, ve a los escenarios del final: cada uno da el orden en que mirar.

1. Filtrado y agrupación

Limpiar, filtrar y agrupar datos es la base de casi cualquier métrica.

Caso de uso: métricas por segmento

Resume clientes e ingresos pagados por país y tipo de suscripción desde una fecha, y deja solo los grupos con más de 5 pedidos. Devuelve una fila por segmento, ordenada por ingresos.

SELECT 
  pais, 
  tipo_suscripcion, 
  COUNT(DISTINCT cliente_id) AS total_clientes,
  SUM(importe_total) AS ingresos
FROM facturacion
WHERE estado = 'PAGADO'
  AND fecha_factura >= '2024-01-01'
  AND pais IN ('ES', 'MX', 'AR')
GROUP BY pais, tipo_suscripcion
HAVING COUNT(pedido_id) > 5
ORDER BY ingresos DESC
LIMIT 100;

💡 Truco: WHERE filtra fila a fila antes de agrupar. HAVING filtra los resultados después de agrupar, así que es el sitio para filtrar por totales, medias o conteos.

2. JOINs

Elegir bien entre INNER y LEFT evita duplicar o perder filas al cruzar tablas.

Caso de uso: clientes sin pedidos

Encuentra los clientes registrados que nunca han comprado, por ejemplo para una campaña. Devuelve su id, email y fecha de registro.

SELECT c.id, c.email, c.fecha_registro
FROM clientes c
LEFT JOIN pedidos p ON c.id = p.cliente_id
WHERE p.id IS NULL; -- La clave: si no hay pedido, el ID es nulo
TipoQué haceCaso de uso típico
INNER JOINDevuelve solo donde hay coincidencia.Obtener facturas de un cliente específico.
LEFT JOINDevuelve todo el lado izquierdo, coincida o no.Listar todos los usuarios, tengan o no compras.
FULL OUTERDevuelve todo de ambos lados.Conciliar dos bases de datos distintas.

3. CTEs (Common Table Expressions)

Las CTEs (WITH) ordenan una consulta compleja en pasos que se leen de arriba abajo, en lugar de subconsultas anidadas.

Caso de uso: embudo de ventas

Calcula la conversión de carrito a compra por cliente: primero cuenta los carritos, luego las compras, y cruza ambos. Devuelve carritos, compras y porcentaje de conversión de cada cliente.

WITH carritos AS (
  SELECT cliente_id, COUNT(*) AS total_creados
  FROM eventos
  WHERE tipo = 'add_to_cart'
  GROUP BY cliente_id
),
compras AS (
  SELECT cliente_id, COUNT(*) AS total_comprados
  FROM eventos
  WHERE tipo = 'purchase'
  GROUP BY cliente_id
)
SELECT 
  ca.cliente_id, 
  ca.total_creados, 
  COALESCE(co.total_comprados, 0) AS total_comprados,
  (COALESCE(co.total_comprados, 0) * 100.0 / ca.total_creados) AS pct_conversion
FROM carritos ca
LEFT JOIN compras co ON ca.cliente_id = co.cliente_id;

4. Funciones de ventana

Calculan sobre un conjunto de filas sin colapsarlas, al contrario que GROUP BY.

Caso de uso: crecimiento mensual (MoM)

Compara las ventas de cada mes con las del mes anterior. Devuelve, por mes, los ingresos, los del mes previo y el porcentaje de crecimiento.

WITH ventas_mensuales AS (
  SELECT 
    DATE_TRUNC('month', fecha) AS mes,
    SUM(importe) AS ingresos
  FROM ventas
  GROUP BY 1
)
SELECT 
  mes, 
  ingresos,
  -- LAG lee la fila anterior (ordenada por mes)
  LAG(ingresos, 1) OVER (ORDER BY mes) AS ingresos_mes_anterior,
  -- Variación porcentual
  ((ingresos - LAG(ingresos, 1) OVER (ORDER BY mes)) / LAG(ingresos, 1) OVER (ORDER BY mes)) * 100 AS crecimiento_pct
FROM ventas_mensuales;

Otros usos comunes

Numera las compras de cada cliente de la más reciente a la más antigua; filtrando por 1 te quedas con la última.

ROW_NUMBER() OVER (PARTITION BY cliente_id ORDER BY fecha_compra DESC)

Calcula el total acumulado de ingresos mes a mes, reiniciándolo cada año.

SUM(ingresos) OVER (PARTITION BY año ORDER BY mes)

5. Manipulación de datos (DML)

En scripts de mantenimiento o ETL a veces hay que actualizar o cargar datos en bloque.

Caso de uso: actualizar precios por categoría

Sube un 10 % el precio de todos los productos de la categoría «Electrónica», cruzando con la tabla de categorías.

UPDATE productos p
SET precio = precio * 1.10
FROM categorias c
WHERE p.categoria_id = c.id
  AND c.nombre = 'Electrónica';

UPSERT (insertar o actualizar)

Inserta el total de un día o, si ese día ya existe, lo sobrescribe. Útil en pipelines que pueden reprocesar la misma fecha.

INSERT INTO resumen_diario (fecha, total)
VALUES ('2024-05-03', 1500)
ON CONFLICT (fecha) DO UPDATE
  SET total = EXCLUDED.total;

6. Rendimiento e índices

Una consulta lenta se suele arreglar con el índice correcto.

Caso de uso: dashboards lentos

Crea un índice simple para acelerar los filtros y la ordenación por fecha.

CREATE INDEX idx_pedidos_fecha ON pedidos (fecha);

Crea un índice compuesto para un dashboard que filtra por cliente y ordena por fecha. Regla: primero las columnas de igualdad, luego las de rango u orden.

CREATE INDEX idx_pedidos_cliente_fecha ON pedidos (cliente_id, fecha DESC);

Crea un índice parcial que solo cubre a los usuarios activos, para no indexar filas que nunca consultas.

CREATE INDEX idx_usuarios_activos ON usuarios (fecha_ultimo_login)
WHERE estado = 'activo';

Ojo: muchos índices aceleran los SELECT, pero ralentizan los INSERT y UPDATE. Añádelos solo cuando haya un problema real en lecturas críticas.

7. Escenario: una consulta que iba bien ahora tarda mucho

situaciónUn informe que tardaba segundos ahora tarda minutos, y nadie ha cambiado la consulta.

qué hay que entender

El motor no ejecuta la consulta tal cual: el planificador elige un plan según sus estadísticas sobre la tabla. Si los datos crecen o cambian mucho y las estadísticas se quedan viejas, estima mal cuántas filas va a leer y elige un plan malo. Por eso una consulta idéntica puede empeorar sin tocarla.

La otra causa típica es un filtro no sargable: aplicar una función o una conversión a la columna (fecha::date = …, LOWER(email) = …) impide usar el índice de esa columna. Los pasos usan PostgreSQL; otros motores tienen su propio EXPLAIN.

pasos, en orden

  1. Mide el plan real: ANALYZE ejecuta la consulta y da tiempos y filas reales, no solo estimaciones.

    EXPLAIN (ANALYZE, BUFFERS)
    SELECT cliente_id, SUM(importe_total) AS ingresos
    FROM facturacion
    WHERE fecha_factura >= '2024-01-01'
    GROUP BY cliente_id;
  2. Si en el plan las filas estimadas (rows=) y las reales (actual … rows=) difieren mucho, actualiza las estadísticas de la tabla.

    ANALYZE facturacion;
  3. Mira qué índices tiene la tabla, para saber si el filtro tiene alguno que pueda usar.

    SELECT indexname, indexdef
    FROM pg_indexes
    WHERE tablename = 'pedidos';
  4. Reescribe los filtros no sargables como un rango sobre la columna original, para que el índice sirva.

    SELECT id, cliente_id, importe
    FROM pedidos
    WHERE fecha >= '2024-05-03'
      AND fecha < '2024-05-04';
  5. Si falta el índice, créalo sin bloquear las escrituras. CONCURRENTLY no se puede usar dentro de una transacción.

    CREATE INDEX CONCURRENTLY idx_pedidos_fecha ON pedidos (fecha);

cómo sé que está resuelto

Repite el EXPLAIN (ANALYZE, BUFFERS) del paso 1: el plan usa el índice (Index Scan o Bitmap Index Scan) en lugar de Seq Scan, las filas estimadas se parecen a las reales y el Execution Time vuelve a su valor normal.

si no funciona

Un Seq Scan no siempre es un error: si la consulta lee buena parte de la tabla, recorrerla entera es más rápido que ir por el índice. Mira también si la tabla acumula filas muertas (n_dead_tup en pg_stat_user_tables) y necesita un VACUUM. Si el plan es bueno y aun así tarda, puede estar esperando un bloqueo: la columna wait_event de pg_stat_activity lo dice.

en una entrevista«Una consulta que iba bien se ha vuelto lenta. ¿Cómo la diagnosticas?»
  1. Medir, no adivinar: EXPLAIN ANALYZE para ver el plan real y dónde se va el tiempo.
  2. Estimadas frente a reales: si no se parecen, las estadísticas están viejas y lo primero es ANALYZE.
  3. Sargabilidad: nada de funciones sobre la columna filtrada; mejor un rango.
  4. El índice que encaja con el filtro, con las columnas de igualdad primero, y creado con CONCURRENTLY en producción.
  5. Qué ha cambiado: volumen de datos, una carga masiva o un despliegue que tocó la consulta o el esquema.

La trampa que conviene nombrar: EXPLAIN ANALYZE ejecuta de verdad la sentencia, así que con un UPDATE o un DELETE hay que envolverlo en una transacción y deshacerla.

8. Escenario: al cruzar tablas salen filas duplicadas

situaciónTras añadir un JOIN, el informe tiene más filas de las esperadas y los totales salen inflados.

qué hay que entender

Un JOIN devuelve una fila por cada pareja que cumple la condición. Si la tabla de la derecha tiene varias filas por clave (relación 1:N), cada fila de la izquierda se repite tantas veces, y un SUM sobre una columna de la izquierda se multiplica. Con dos relaciones 1:N en la misma consulta, las repeticiones se multiplican entre sí.

Antes de unir hay que saber la granularidad de cada tabla: qué representa una fila. Si no coincide, se agrega primero la tabla más detallada y se une después.

pasos, en orden

  1. Compara el número de filas del JOIN con el de pedidos distintos: si el primero es mayor, el cruce multiplica.

    SELECT COUNT(*) AS filas, COUNT(DISTINCT p.id) AS pedidos_distintos
    FROM pedidos p
    JOIN facturacion f ON f.pedido_id = p.id;
  2. Busca qué claves se repiten en la tabla que suponías de una fila por pedido.

    SELECT pedido_id, COUNT(*) AS n
    FROM facturacion
    GROUP BY pedido_id
    HAVING COUNT(*) > 1
    ORDER BY n DESC;
  3. Mira las filas de un caso concreto para saber si es un duplicado real o una relación 1:N legítima, como pagos parciales.

    SELECT *
    FROM facturacion
    WHERE pedido_id = 12345
    ORDER BY fecha_factura;
  4. Agrega antes de unir: resume la facturación a una fila por pedido en una CTE y cruza después.

    WITH fact AS (
      SELECT pedido_id, SUM(importe_total) AS facturado
      FROM facturacion
      WHERE estado = 'PAGADO'
      GROUP BY pedido_id
    )
    SELECT p.cliente_id, SUM(p.importe) AS pedido, SUM(f.facturado) AS facturado
    FROM pedidos p
    LEFT JOIN fact f ON f.pedido_id = p.id
    GROUP BY p.cliente_id;
  5. Si la relación tiene que ser 1:1, imponla con una restricción única. Fallará si quedan duplicados: límpialos antes (escenario 10).

    ALTER TABLE facturacion ADD CONSTRAINT uq_facturacion_pedido UNIQUE (pedido_id);

cómo sé que está resuelto

El total del informe coincide con el de la tabla base calculado sin JOIN, por ejemplo SELECT SUM(importe) FROM pedidos con los mismos filtros. Y el paso 1 da el mismo número en las dos columnas.

si no funciona

Revisa la condición del JOIN: si la clave es compuesta y falta una columna, cada fila casa con varias. No tapes el problema con DISTINCT: oculta filas sin explicar por qué salen. Y SUM(DISTINCT importe) suma valores distintos, no filas, así que dos pedidos con el mismo importe cuentan una vez.

en una entrevista«Al unir dos tablas te salen duplicados y la suma no cuadra. ¿Qué está pasando?»
  1. Granularidad primero: qué representa una fila de cada tabla antes de unirlas.
  2. Medir la inflación: filas antes y después del JOIN.
  3. Encontrar la clave repetida con GROUP BY … HAVING COUNT(*) > 1.
  4. Agregar antes de unir, cada relación 1:N por separado.
  5. Proteger el dato: una restricción única si la relación debe ser 1:1.

La trampa que conviene nombrar: arreglarlo con DISTINCT hace que cuadre hoy y esconde el error.

9. Escenario: dos informes dan totales distintos

situaciónEl informe de ventas y el de finanzas dan cifras distintas para el mismo mes.

qué hay que entender

Casi nunca es un error de suma: es que los dos informes no miden lo mismo. Las causas habituales son un filtro distinto (qué estados cuentan), los NULL, las fechas y un JOIN que pierde filas. Un INNER JOIN descarta las filas sin pareja; un LEFT JOIN las conserva.

Con los NULL hay dos trampas: COUNT(columna) no cuenta los nulos, y estado <> 'CANCELADO' también deja fuera las filas con estado NULL. Con las fechas, un timestamp con zona horaria cae en un día u otro según la zona de la sesión. Los ejemplos usan PostgreSQL.

pasos, en orden

  1. Baja del total del mes al total por día, para localizar en qué días divergen los dos informes.

    SELECT fecha_factura::date AS dia, COUNT(*) AS filas, SUM(importe_total) AS total
    FROM facturacion
    WHERE fecha_factura >= '2024-05-01' AND fecha_factura < '2024-06-01'
    GROUP BY 1
    ORDER BY 1;
  2. Mira el reparto por estado, incluido el grupo NULL, para ver qué deja fuera cada filtro.

    SELECT estado, COUNT(*) AS filas, SUM(importe_total) AS total
    FROM facturacion
    GROUP BY estado
    ORDER BY filas DESC;
  3. Cuenta las filas que cambian de día según la zona horaria (con fecha_factura como timestamptz).

    SELECT (fecha_factura AT TIME ZONE 'UTC')::date AS dia_utc, COUNT(*) AS filas
    FROM facturacion
    WHERE (fecha_factura AT TIME ZONE 'UTC')::date
       <> (fecha_factura AT TIME ZONE 'Europe/Madrid')::date
    GROUP BY 1
    ORDER BY 1;
  4. Comprueba el límite del periodo: BETWEEN '2024-05-01' AND '2024-05-31' sobre un timestamp deja fuera casi todo el día 31.

    SELECT COUNT(*) AS filas_ultimo_dia, SUM(importe_total) AS total
    FROM facturacion
    WHERE fecha_factura >= '2024-05-31' AND fecha_factura < '2024-06-01';
  5. Cuenta las facturas que un INNER JOIN con clientes descartaría por no tener cliente.

    SELECT COUNT(*) AS facturas_sin_cliente, SUM(f.importe_total) AS importe
    FROM facturacion f
    LEFT JOIN clientes c ON c.id = f.cliente_id
    WHERE c.id IS NULL;

cómo sé que está resuelto

Los dos informes cuadran para el mismo periodo, o la diferencia queda explicada por una regla concreta, como «finanzas excluye los cancelados». Esa definición se escribe en un único sitio, por ejemplo una vista, para que los dos informes la compartan.

si no funciona

Baja al nivel de fila: saca los identificadores de cada informe y compáralos con EXCEPT (los que están en uno y no en el otro). Si las filas son las mismas, mira la frescura de los datos: uno puede leer de una réplica o de una carga que aún no ha corrido. También influyen el redondeo y la moneda.

en una entrevista«Dos informes dan totales distintos para lo mismo. ¿Cómo encuentras la diferencia?»
  1. Misma definición primero: periodo, filtros y unidad, antes de mirar ninguna cifra.
  2. Del total al día y del día a la fila, para acotar dónde está la diferencia.
  3. Sospechosos habituales: NULL, zona horaria, límites de fecha e INNER frente a LEFT.
  4. Frescura: cuándo se cargó cada fuente.
  5. Una sola definición de la métrica, en una vista o en el modelo, para que no vuelva a pasar.

Lo que no haría: ajustar uno de los dos informes hasta que cuadre sin saber por qué no cuadraba.

10. Escenario: limpiar registros duplicados

situaciónLa tabla de clientes tiene el mismo email repetido varias veces y hay que dejar uno solo.

qué hay que entender

Antes de borrar hay que decidir dos cosas: qué columnas definen que dos filas son la misma (aquí, el email sin distinguir mayúsculas) y cuál se queda (aquí, la más antigua). ROW_NUMBER() OVER (PARTITION BY … ORDER BY …) numera las filas de cada grupo: la 1 se queda y el resto sobra.

No perder datos significa que lo que apunta a un duplicado, como sus pedidos, pase a la fila que se queda. Todo va dentro de una transacción, para revisar cuántas filas cambian y deshacer si no cuadra. Los pasos usan PostgreSQL.

pasos, en orden

  1. Mide el problema: qué emails se repiten y cuántas veces.

    SELECT LOWER(email) AS email, COUNT(*) AS n
    FROM clientes
    GROUP BY LOWER(email)
    HAVING COUNT(*) > 1
    ORDER BY n DESC;
  2. Revisa sin borrar nada qué filas sobrarían, numerándolas por antigüedad dentro de cada email.

    SELECT id, email, fecha_registro, rn
    FROM (
      SELECT id, email, fecha_registro,
             ROW_NUMBER() OVER (PARTITION BY LOWER(email)
                                ORDER BY fecha_registro, id) AS rn
      FROM clientes
    ) t
    WHERE rn > 1;
  3. Abre una transacción: nada de lo que sigue se aplica hasta el COMMIT.

    BEGIN;
  4. Guarda en una tabla temporal cada id que sobra junto al id de la fila que se queda.

    CREATE TEMP TABLE duplicados AS
    SELECT id, id_bueno
    FROM (
      SELECT id,
             FIRST_VALUE(id) OVER w AS id_bueno,
             ROW_NUMBER() OVER w AS rn
      FROM clientes
      WINDOW w AS (PARTITION BY LOWER(email) ORDER BY fecha_registro, id)
    ) t
    WHERE rn > 1;
  5. Pasa los pedidos de los duplicados a la fila que se queda, para no perderlos al borrar.

    UPDATE pedidos p
    SET cliente_id = d.id_bueno
    FROM duplicados d
    WHERE p.cliente_id = d.id;
  6. Borra los duplicados y compara el número que devuelve DELETE con el del paso 2: si cuadra, COMMIT; si no, ROLLBACK.

    DELETE FROM clientes c
    USING duplicados d
    WHERE c.id = d.id;

cómo sé que está resuelto

La consulta del paso 1 no devuelve filas y el total de pedidos es el mismo que antes. Para que no vuelva a pasar, crea un índice único sobre el email normalizado: CREATE UNIQUE INDEX uq_clientes_email ON clientes (LOWER(email));.

si no funciona

Si el DELETE falla por una clave foránea, otra tabla (por ejemplo facturacion) también apunta a los duplicados: haz ROLLBACK, añade su UPDATE y repite. En una tabla muy grande, borra por lotes para no bloquearla mucho rato. Y si quieres una red extra, copia antes la tabla con CREATE TABLE clientes_copia AS SELECT * FROM clientes;.

en una entrevista«Tienes registros duplicados en una tabla. ¿Cómo los limpias sin perder datos?»
  1. Definir el duplicado y el superviviente: qué columnas lo identifican y qué fila se queda.
  2. Revisar antes de borrar, con un SELECT y ROW_NUMBER().
  3. Mover las referencias de los duplicados a la fila buena, para no perder datos.
  4. Todo en una transacción, comparando el número de filas afectadas antes del COMMIT.
  5. Prevenir: una restricción o un índice único para que no vuelva a entrar.

Lo que no haría: lanzar el DELETE directamente, sin haber visto antes qué filas iba a borrar.

11. Relacionado