Á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:
WHEREfiltra fila a fila antes de agrupar.HAVINGfiltra 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
| Tipo | Qué hace | Caso de uso típico |
|---|---|---|
INNER JOIN | Devuelve solo donde hay coincidencia. | Obtener facturas de un cliente específico. |
LEFT JOIN | Devuelve todo el lado izquierdo, coincida o no. | Listar todos los usuarios, tengan o no compras. |
FULL OUTER | Devuelve 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 losINSERTyUPDATE. 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
-
Mide el plan real:
ANALYZEejecuta 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; -
Si en el plan las filas estimadas (
rows=) y las reales (actual … rows=) difieren mucho, actualiza las estadísticas de la tabla.ANALYZE facturacion; -
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'; -
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'; -
Si falta el índice, créalo sin bloquear las escrituras.
CONCURRENTLYno 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?»
- Medir, no adivinar:
EXPLAIN ANALYZEpara ver el plan real y dónde se va el tiempo. - Estimadas frente a reales: si no se parecen, las estadísticas están viejas y lo primero es
ANALYZE. - Sargabilidad: nada de funciones sobre la columna filtrada; mejor un rango.
- El índice que encaja con el filtro, con las columnas de igualdad primero, y creado con
CONCURRENTLYen producción. - 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
-
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; -
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; -
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; -
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; -
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?»
- Granularidad primero: qué representa una fila de cada tabla antes de unirlas.
- Medir la inflación: filas antes y después del JOIN.
- Encontrar la clave repetida con
GROUP BY … HAVING COUNT(*) > 1. - Agregar antes de unir, cada relación 1:N por separado.
- 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
-
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; -
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; -
Cuenta las filas que cambian de día según la zona horaria (con
fecha_facturacomotimestamptz).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; -
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'; -
Cuenta las facturas que un
INNER JOINcon 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?»
- Misma definición primero: periodo, filtros y unidad, antes de mirar ninguna cifra.
- Del total al día y del día a la fila, para acotar dónde está la diferencia.
- Sospechosos habituales:
NULL, zona horaria, límites de fecha eINNERfrente aLEFT. - Frescura: cuándo se cargó cada fuente.
- 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
-
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; -
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; -
Abre una transacción: nada de lo que sigue se aplica hasta el
COMMIT.BEGIN; -
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; -
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; -
Borra los duplicados y compara el número que devuelve
DELETEcon 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?»
- Definir el duplicado y el superviviente: qué columnas lo identifican y qué fila se queda.
- Revisar antes de borrar, con un
SELECTyROW_NUMBER(). - Mover las referencias de los duplicados a la fila buena, para no perder datos.
- Todo en una transacción, comparando el número de filas afectadas antes del
COMMIT. - 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
- Arquitectura ETL de 3 capas en AWS con Glue y Athena
- De Glue a QuickSight: dashboards que sí se usan
Ningún bloque coincide con el filtro.