La lección anterior cerró el módulo 10 con una frase: "lo que falta no son más funciones, sino contexto". Este módulo es ese contexto, y empieza por lo más concreto que existe: los problemas que te van a pedir resolver. Porque la caja de herramientas ya está llena —JOIN, agregados, subconsultas, CTE, funciones de ventana, JSON— pero en un proyecto real nadie te pide "un LEFT JOIN": te piden "un listado de productos con filtros que el usuario elige", "el cuadro de mando de dirección", "los clientes que ya no compran" o "exportar esto para el gestor".
La buena noticia es que esos encargos se repiten. Cambia el negocio, cambian los nombres de las tablas, y el patrón es el mismo. Esta lección es el catálogo de esos patrones: once casos de uso, cada uno con su planteamiento de negocio, su solución sobre TiendaVerde y una nota de qué herramienta del curso lo resuelve. No hay sintaxis nueva. Hay reconocimiento de patrones, que es una cosa distinta y mucho más útil.
Contenido
- Listado paginado con filtros opcionales
- Búsqueda: prefijo, contenido y full-text
- Informe de KPI en una sola fila
- Top N y ranking
- Detección de huecos: el anti-join
- Series temporales sin huecos
- Cohortes de clientes
- Detección y limpieza de duplicados
- Auditoría: quién cambió qué y cuándo
- Exportación a otro sistema
- Carga desde fichero con tabla de staging
- Tabla resumen: caso de uso → herramienta → lección
- Errores Comunes y Consejos
- Ejercicios
- Conclusión
- Listado paginado con filtros opcionales
El encargo. "La pantalla de catálogo tiene cuatro filtros —categoría, precio máximo, texto y solo con stock—, y el usuario puede rellenar los que quiera. Además tiene paginación." Es el caso de uso número uno del mundo, y el que más veces se escribe mal. El problema no es el WHERE: es que no sabes cuál será el WHERE hasta que el usuario pulse "buscar". Hay dos enfoques, y conviene conocer los dos.
El patrón (:param IS NULL OR columna = :param)
Una sola consulta, con un parámetro por filtro que se neutraliza cuando llega nulo:
SELECT p.id, p.nombre, cat.nombre AS categoria, p.precio, p.stock
FROM productos AS p
JOIN categorias AS cat ON cat.id = p.categoria_id
WHERE p.activo
AND (:categoria_id IS NULL OR p.categoria_id = :categoria_id)
AND (:precio_max IS NULL OR p.precio <= :precio_max)
AND (:texto IS NULL OR p.nombre ILIKE '%' || :texto || '%')
AND (NOT :solo_stock OR p.stock > 0)
ORDER BY p.precio DESC, p.id
LIMIT 5;Con los cuatro parámetros a NULL/false (primera página del catálogo, sin filtrar):
| id | nombre | categoria | precio | stock |
|---|---|---|---|---|
| 15 | Té verde matcha ceremonial 30 g | Bebidas | 22.00 | 40 |
| 6 | Crema facial de aloe vera 50 ml | Cosmética natural | 18.90 | 60 |
| 8 | Aceite corporal de almendras 200 ml | Cosmética natural | 14.25 | 45 |
| 13 | Velas de cera de soja (pack 2) | Hogar sostenible | 13.75 | 0 |
| 1 | Aceite de oliva virgen extra 500 ml | Alimentación | 12.50 | 120 |
(5 primeras de 19 filas: los productos activos; el 20, descatalogado, no aparece.) Con :categoria_id = 2 y :precio_max = 15.00 la misma consulta devuelve 3 filas —el aceite corporal (14,25 €), el champú sólido (8,40 €) y el bálsamo labial (4,60 €)—, y con :solo_stock = true desaparecerían las velas de cera, las únicas con stock 0. Sus ventajas son reales: una sola consulta que mantener, cero riesgo de inyección (11-03) y un plan cacheable. Y su coste también: (:p IS NULL OR col = :p) es no sargable (08-04) —el motor no sabe de antemano si la columna estará filtrada—, así que el plan tiene que servir para los dieciséis casos posibles y no es óptimo para ninguno. Con 19 productos da igual; con 19 millones y un filtro muy selectivo, es un Seq Scan donde había un Index Scan.
Mitigación en PostgreSQL: una sentencia preparada pasa a plan genérico a partir de la sexta ejecución. Con
SET plan_cache_mode = force_custom_planel motor replanifica con los valores concretos y descarta las ramas neutralizadas. Es la salida cuando el patrón funciona bien salvo en un filtro concreto.
La alternativa: construir el SQL en la aplicación
El otro enfoque es componer el WHERE en código, añadiendo solo las condiciones que el usuario ha rellenado:
sql, params = ["SELECT p.id, p.nombre, p.precio FROM productos AS p WHERE p.activo"], {}
if categoria_id is not None:
sql.append("AND p.categoria_id = %(categoria_id)s"); params["categoria_id"] = categoria_id
if precio_max is not None:
sql.append("AND p.precio <= %(precio_max)s"); params["precio_max"] = precio_max
cur.execute(" ".join(sql), params) # ✅ los VALORES siguen siendo parámetrosLa línea roja está en el último renglón: se construye el texto de las condiciones, nunca los valores, que siguen viajando como parámetros. Concatenar f"AND p.precio <= {precio_max}" es exactamente la vulnerabilidad de 11-03, y no deja de serlo porque el dato "parezca" un número.
Patrón :param IS NULL OR … |
SQL construido en la aplicación | |
|---|---|---|
| Consultas que mantener | Una | Una plantilla + la lógica de composición |
| Calidad del plan | Genérica: mediocre para todos | Óptima para cada combinación |
| Riesgo de inyección | Ninguno | Bajo si solo compones condiciones; alto si compones valores |
| Legibilidad / depuración | Alta / registra siempre el mismo SQL | Menor / hay que registrar el SQL final |
| Cuándo elegirlo | Pocos filtros, tablas medianas, equipos que quieren SQL fijo | Muchos filtros, tablas grandes, rendimiento crítico |
El criterio del curso: empieza por el patrón de una sola consulta; pásate a la composición solo cuando midas (08-05) que el plan genérico te está costando. Y para la paginación, lo de 08-04: LIMIT/OFFSET para paginadores numerados pequeños, keyset para scroll infinito y APIs.
- Búsqueda: prefijo, contenido y full-text
El encargo. "Que el buscador de la tienda encuentre el producto aunque el cliente escriba media palabra." Hay tres niveles, en orden de coste creciente, y elegir el más barato que resuelva el problema es la decisión:
-- Nivel 1: por PREFIJO. Usa un B-tree normal si la colación es la adecuada.
SELECT id, nombre FROM productos WHERE nombre ILIKE 'aceite%' ORDER BY id;
-- Nivel 2: por CONTENIDO. No usa B-tree: necesita pg_trgm + GIN (08-03).
SELECT id, nombre FROM productos WHERE nombre ILIKE '%oliva%' ORDER BY id;| Consulta | Filas | Resultado |
|---|---|---|
ILIKE 'aceite%' |
2 | Aceite de oliva virgen extra 500 ml · Aceite corporal de almendras 200 ml |
ILIKE '%oliva%' |
1 | Aceite de oliva virgen extra 500 ml |
ILIKE 'oliva%' |
0 | — |
Ahí está resumido el problema entero: buscar "oliva" por prefijo no encuentra el aceite de oliva, porque la palabra está en medio. Y buscar por contenido sí lo encuentra, pero un LIKE '%…%' no puede usar un índice B-tree (08-04): la única forma de acelerarlo es un índice GIN con pg_trgm, de 08-03. El nivel 3 aparece cuando el usuario escribe frases, quiere que "infusiones" encuentre "infusión", o espera resultados ordenados por relevancia: eso ya no es LIKE, es búsqueda de texto completo con to_tsvector/to_tsquery y un índice GIN sobre el vector.
| Necesidad | Herramienta | Índice |
|---|---|---|
| Autocompletar, códigos, prefijos | LIKE 'x%' |
B-tree (con text_pattern_ops si la colación no es C) |
| Subcadena en un catálogo pequeño o mediano | ILIKE '%x%' |
GIN con pg_trgm |
| Tolerancia a erratas ("acite") | similarity() de pg_trgm |
GIN con pg_trgm |
| Frases, raíces de palabra, relevancia | to_tsvector @@ to_tsquery |
GIN sobre el tsvector |
| Catálogos enormes, sinónimos, facetas, corrección | Motor externo (Elasticsearch, OpenSearch, Meilisearch) | — |
El criterio: no montes full-text para 19 productos, ni resuelvas un buscador de un millón de artículos con ILIKE '%…%'. Y cuando uses ILIKE, escapa siempre % y _ en la entrada del usuario: si alguien busca 100%, ese % es un comodín.
- Informe de KPI en una sola fila
El encargo. "Dirección quiere una tira de números arriba del panel: ventas del mes, pedidos del mes, pedidos por enviar, facturación total y ticket medio." La tentación es lanzar cinco consultas; la solución es una, con la cláusula FILTER de 04-04, que aplica una condición distinta a cada agregado:
SELECT COUNT(DISTINCT pe.id) AS pedidos_total,
COUNT(DISTINCT pe.id) FILTER (WHERE pe.fecha_pedido >= DATE '2026-02-01') AS pedidos_mes,
ROUND(SUM(lp.cantidad * lp.precio_unitario * (1 - lp.descuento)), 2) AS facturacion_total,
ROUND(SUM(lp.cantidad * lp.precio_unitario * (1 - lp.descuento))
FILTER (WHERE pe.fecha_pedido >= DATE '2026-02-01'), 2) AS facturacion_mes,
COUNT(DISTINCT pe.id) FILTER (WHERE pe.estado IN ('pendiente','pagado')) AS por_enviar,
ROUND(SUM(lp.cantidad * lp.precio_unitario * (1 - lp.descuento))
/ COUNT(DISTINCT pe.id), 2) AS ticket_medio
FROM pedidos AS pe
JOIN lineas_pedido AS lp ON lp.pedido_id = pe.id;| pedidos_total | pedidos_mes | facturacion_total | facturacion_mes | por_enviar | ticket_medio |
|---|---|---|---|---|---|
| 20 | 2 | 727.95 | 49.33 | 3 | 36.40 |
Una consulta, una fila, seis indicadores, y todos son cifras canónicas del curso: los 20 pedidos, los 727,95 € de facturación de producto, los 49,33 € de febrero de 2026 y el ticket medio de 36,40 €. Los 3 "por enviar" son los pedidos 18 y 19 (pagado) y el 20 (pendiente).
El COUNT(DISTINCT pe.id) es obligatorio: el JOIN con lineas_pedido multiplica cada pedido por sus líneas, y un COUNT(*) devolvería 47. Y FILTER es el estándar SQL para esto; el equivalente portable es SUM(CASE WHEN … THEN … END) de 06-05, más verboso y con la trampa de que COUNT(CASE …) cuenta también los NULL si no se escribe con cuidado.
- Top N y ranking
El encargo. "Los cinco productos que más facturan, con su puesto." Es el patrón de 10-03 en su forma más simple, ROW_NUMBER calculado en una CTE y filtrado fuera:
WITH ventas AS (
SELECT p.id AS producto_id, p.nombre AS producto,
ROUND(SUM(lp.cantidad * lp.precio_unitario * (1 - lp.descuento)), 2) AS facturacion
FROM lineas_pedido AS lp
JOIN productos AS p ON p.id = lp.producto_id
GROUP BY p.id, p.nombre)
SELECT puesto, producto, facturacion FROM (
SELECT *, ROW_NUMBER() OVER (ORDER BY facturacion DESC, producto_id) AS puesto
FROM ventas) AS r
WHERE puesto <= 5 ORDER BY puesto;| puesto | producto | facturacion |
|---|---|---|
| 1 | Aceite de oliva virgen extra 500 ml | 109.53 |
| 2 | Té verde matcha ceremonial 30 g | 88.00 |
| 3 | Crema facial de aloe vera 50 ml | 70.42 |
| 4 | Kombucha de jengibre 750 ml | 56.43 |
| 5 | Arroz integral ecológico 1 kg | 54.60 |
Recuerda las tres decisiones de 10-03, porque en producción importan: ROW_NUMBER para cortar exactamente N filas, RANK si los empatados en el último puesto deben salir todos, y un desempate explícito en el ORDER BY de la ventana —aquí producto_id— para que el resultado sea reproducible. Si solo necesitas los 5 primeros y no la columna de puesto, un ORDER BY … LIMIT 5 sobre un índice es más barato (regla 4 de 08-04).
- Detección de huecos: el anti-join
El encargo. "¿Qué clientes no han comprado nunca? ¿Qué productos no se venden? ¿Cuántos pedidos entran sin comercial?" Todas las preguntas de negocio que empiezan por "qué no" son el mismo patrón: un anti-join, que en PostgreSQL se escribe con NOT EXISTS (07-03). Los tres huecos deliberados de TiendaVerde, en una consulta:
SELECT (SELECT COUNT(*) FROM clientes AS c
WHERE NOT EXISTS (SELECT 1 FROM pedidos AS pe WHERE pe.cliente_id = c.id)) AS clientes_sin_pedidos,
(SELECT COUNT(*) FROM productos AS p
WHERE NOT EXISTS (SELECT 1 FROM lineas_pedido AS lp WHERE lp.producto_id = p.id)) AS nunca_vendidos,
(SELECT COUNT(*) FROM productos AS p
WHERE NOT EXISTS (SELECT 1 FROM resenas AS r WHERE r.producto_id = p.id)) AS sin_resena,
(SELECT COUNT(*) FROM empleados AS e
WHERE NOT EXISTS (SELECT 1 FROM pedidos AS pe WHERE pe.empleado_id = e.id)) AS empleados_sin_pedidos,
(SELECT COUNT(*) FROM pedidos WHERE empleado_id IS NULL) AS pedidos_web;| clientes_sin_pedidos | nunca_vendidos | sin_resena | empleados_sin_pedidos | pedidos_web |
|---|---|---|---|---|
| 3 | 3 | 11 | 5 | 10 |
Los nombres detrás de los números: Núria, Hugo e Inés nunca han comprado; las velas de cera (stock 0), el desodorante y las cápsulas de espirulina (descatalogadas) nunca se han vendido; y los cinco empleados sin pedidos son toda la dirección, logística, almacén y análisis. Una variante que se pide constantemente: el cliente inactivo, que no es el que nunca compró sino el que dejó de comprar — un anti-join con ventana temporal:
SELECT c.id, c.nombre || ' ' || c.apellidos AS cliente, MAX(pe.fecha_pedido) AS ultimo_pedido
FROM clientes AS c JOIN pedidos AS pe ON pe.cliente_id = c.id
GROUP BY c.id, c.nombre, c.apellidos
HAVING MAX(pe.fecha_pedido) < DATE '2026-03-01' - INTERVAL '6 months'
ORDER BY ultimo_pedido;| id | cliente | ultimo_pedido |
|---|---|---|
| 3 | Marta Sanchis Gil | 2025-04-02 |
| 8 | Tiago Almeida Nunes | 2025-07-15 |
Dos clientes llevan más de seis meses sin comprar a fecha del 1 de marzo de 2026. Fíjate en la diferencia de negocio: Núria, Hugo e Inés (que nunca compraron) son un problema de activación; Marta y Tiago son un problema de retención. La misma tabla, dos campañas distintas.
Ojo con
NOT IN.WHERE id NOT IN (SELECT empleado_id FROM pedidos)devuelve cero filas aquí, porqueempleado_idtiene nulos yNOT INcon unNULLen la lista nunca es cierto (04-03, 07-03). Es el error clásico de esta familia de consultas: usaNOT EXISTS.
- Series temporales sin huecos
El encargo. "La gráfica de ventas mensuales se salta los meses sin pedidos y el eje sale torcido." Un GROUP BY solo produce filas para los datos que existen. Si un mes no tuvo pedidos, ese mes no aparece — y una gráfica que une directamente el punto anterior con el siguiente miente. La solución es generar el calendario y unir los datos contra él con generate_series (06-03) y un LEFT JOIN:
WITH calendario AS (
SELECT generate_series(DATE '2025-01-01', DATE '2026-02-01', INTERVAL '1 month')::date AS mes),
ventas AS (
SELECT date_trunc('month', pe.fecha_pedido)::date AS mes,
COUNT(DISTINCT pe.id) AS pedidos,
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(cal.mes, 'YYYY-MM') AS mes,
COALESCE(v.pedidos, 0) AS pedidos,
COALESCE(v.facturacion, 0.00) AS facturacion
FROM calendario AS cal
LEFT JOIN ventas AS v ON v.mes = cal.mes
ORDER BY cal.mes;| mes | pedidos | facturacion |
|---|---|---|
| 2025-01 | 0 | 0.00 |
| 2025-02 | 0 | 0.00 |
| 2025-03 | 2 | 68.80 |
| 2025-04 | 2 | 61.28 |
| 2026-01 | 2 | 75.10 |
| 2026-02 | 2 | 49.33 |
(6 de 14 filas; entre abril de 2025 y enero de 2026 la serie es la canónica del curso: 58,85 · 95,48 · 44,60 · 48,27 · 32,76 · 97,20 · 31,70 · 64,58.) Enero y febrero de 2025 aparecen con 0,00 € aunque no exista ni un pedido: la tienda ya tenía catálogo y clientes, pero su primer pedido es del 4 de marzo. Sin el calendario, la serie empezaría en marzo y nadie vería los dos meses sin ventas.
Las dos piezas obligatorias son el LEFT JOIN en esa dirección (calendario a la izquierda, datos a la derecha) y el COALESCE, porque el LEFT JOIN produce NULL, no cero — y NULL en una gráfica es un agujero, no un valor bajo.
- Cohortes de clientes
El encargo. "¿Los clientes que captamos en primavera compran más que los de otoño?" Una cohorte es un grupo de clientes que comparten el momento de entrada; el análisis consiste en seguir a cada grupo en el tiempo. La forma mínima —cuántos de cada mes de registro llegaron a comprar— es un LEFT JOIN agregado:
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 pe.cliente_id)
SELECT to_char(c.fecha_registro, 'YYYY-MM') AS cohorte,
COUNT(*) AS clientes,
COUNT(v.cliente_id) AS compradores,
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 | pedidos | facturacion |
|---|---|---|---|---|
| 2025-01 | 2 | 2 | 5 | 167.06 |
| 2025-02 | 3 | 3 | 5 | 147.31 |
| 2025-03 | 2 | 2 | 4 | 169.21 |
| 2025-04 | 2 | 2 | 3 | 115.47 |
| 2025-05 | 2 | 2 | 2 | 97.20 |
| 2025-06 | 2 | 1 | 1 | 31.70 |
| 2025-09 | 1 | 0 | 0 | 0.00 |
| 2026-01 | 1 | 0 | 0 | 0.00 |
Ocho cohortes, 15 clientes, 20 pedidos y 727,95 €: las columnas cuadran con el total del curso, que es la primera comprobación que hay que hacer siempre. Y la lectura es la esperada: las cohortes antiguas tienen más pedidos por cliente (las de enero a marzo de 2025 llevan entre 2 y 2,5 pedidos por persona) y las recientes aún no han comprado — sencillamente porque han tenido menos tiempo. Ese sesgo es el motivo de que las cohortes se comparen siempre a la misma edad: "pedidos en los 90 días siguientes al alta", no "pedidos totales". En 11-04 se retoma con la advertencia honesta sobre el tamaño de muestra.
- Detección y limpieza de duplicados
El encargo. "Marketing dice que hay clientes repetidos." En TiendaVerde no los hay, y la razón está en el CREATE TABLE de 01-06: clientes.email es UNIQUE. Esa es la primera lección del caso de uso — el duplicado se previene con una restricción, no se limpia con una consulta:
SELECT lower(trim(c.email)) AS email, COUNT(*) AS veces, string_agg(c.id::text, ', ') AS ids
FROM clientes AS c GROUP BY 1 HAVING COUNT(*) > 1;El duplicado aparece cuando no hay restricción: en una tabla de importación, en un formulario sin validar, o cuando la clave natural es "el mismo cliente" y no "el mismo email" —Lucía dada de alta dos veces con dos correos distintos. Para eso, dos técnicas: la clave normalizada, GROUP BY lower(unaccent(trim(nombre || ' ' || apellidos))) con su HAVING COUNT(*) > 1, y el parecido en lugar de la igualdad, con similarity() de pg_trgm (08-03) sobre un autojoin a.id < b.id y un umbral de partida de 0.6. Las dos devuelven 0 filas sobre los 15 clientes del curso, que es lo que debe pasar en una tabla sana.
Para la limpieza, el patrón canónico es ROW_NUMBER (10-03): numerar dentro de cada grupo de duplicados por un criterio de "cuál es el bueno" —el más antiguo, el que tiene pedidos— y borrar los demás. Ejecuta primero el SELECT, revisa las filas y solo entonces conviértelo en DELETE (05-04):
WITH d AS (SELECT id, ROW_NUMBER() OVER (PARTITION BY lower(trim(email)) ORDER BY id) AS n FROM clientes)
SELECT * FROM d WHERE n > 1;Antes de borrar hay que repuntar las referencias: si el cliente duplicado tiene pedidos, hay que moverlos al superviviente con un UPDATE, o la FK ON DELETE RESTRICT lo impedirá — y menos mal.
- Auditoría: quién cambió qué y cuándo
El encargo. "El precio del aceite cambió el martes y nadie sabe quién lo tocó." Una base de datos, por omisión, no recuerda: un UPDATE sustituye el valor anterior y no deja rastro. Registrar los cambios es una decisión de diseño explícita, y hay tres formas:
| Enfoque | Cómo | Ventaja | Coste |
|---|---|---|---|
| Tabla de auditoría con trigger | AFTER INSERT/UPDATE/DELETE que escribe en auditoria_* (10-05) |
Nadie la puede saltar: captura también los cambios hechos a mano en psql |
Escritura extra en cada cambio |
| Columnas de rastro | creado_en, creado_por, modificado_en, modificado_por |
Baratísimo | Solo guarda el último cambio |
| Versionado temporal | Una fila por versión con valido_desde/valido_hasta |
Historia completa y consultable "a fecha de" | Complica todas las consultas |
El de 10-05 es el primero, con auditoria_precios capturando el valor anterior, el nuevo, current_user y now(). Dos advertencias que solo se aprenden en producción. current_user es el usuario de la base de datos, no el de la aplicación: si la web se conecta con un único rol tv_app (11-03), todas las filas dirán tv_app; para saber qué persona fue hay que propagar el usuario con SET LOCAL app.usuario = '...' al abrir la transacción y leerlo con current_setting('app.usuario', true). Y una tabla de auditoría crece sin parar: planifica su purga o su archivado desde el primer día.
- Exportación a otro sistema
El encargo. "Mándame las ventas del año en un CSV para el gestor."
-- \copy en psql: lee y escribe en TU máquina, sin permisos especiales (05-02)
\copy (SELECT pe.id AS pedido, pe.fecha_pedido, c.nombre || ' ' || c.apellidos AS cliente, c.pais, pe.estado, ROUND(SUM(lp.cantidad * lp.precio_unitario * (1 - lp.descuento)), 2) AS importe FROM pedidos pe JOIN clientes c ON c.id = pe.cliente_id JOIN lineas_pedido lp ON lp.pedido_id = pe.id WHERE pe.fecha_pedido >= '2025-01-01' AND pe.fecha_pedido < '2026-01-01' GROUP BY pe.id, pe.fecha_pedido, c.nombre, c.apellidos, c.pais, pe.estado ORDER BY pe.id) TO 'ventas_2025.csv' WITH (FORMAT csv, HEADER true, DELIMITER ';', ENCODING 'UTF8')Los 16 pedidos de 2025. Y ahora las trampas, todas del mundo real y ninguna de SQL:
- Separador decimal y de campos. PostgreSQL escribe
109.53con punto; un Excel en español espera coma y lo leerá como ciento nueve mil quinientos cincuenta y tres. Se arregla en destino, o en origen conreplace(importe::text, '.', ',')— y entonces elDELIMITERno puede ser la coma. De ahí el';', que es lo que Excel espera en el ámbito europeo. - Codificación.
UTF8es lo correcto, pero Excel para Windows puede necesitar el BOM o inclusoWIN1252para no destrozar las tildes de "Alimentación". Pruébalo con una fila con acentos, nunca con datos limpios. - Formato de fecha y nulos.
2025-03-04es ISO 8601 y es lo correcto entre sistemas; si el destino exige04/03/2025, conviértelo conto_char(fecha, 'DD/MM/YYYY')y no confíes enDateStyle. UnNULLsale como campo vacío: si el destino no distingue vacío de nulo, usaNULL 'NULO'oCOALESCE. - Datos personales. Un CSV con nombres y correos deja de estar protegido por los permisos de la base. Exporta lo mínimo y consulta 11-03 antes de mandar nada con datos de clientes.
COPY ... TO (sin barra) se ejecuta en el servidor y requiere el rol pg_write_server_files; \copy es el de psql, el que usarás casi siempre. Y para consumo por otro programa, devolver JSON desde la base (10-06) suele ser mejor que un CSV.
- Carga desde fichero con tabla de staging
El encargo. "Nos han pasado un CSV con 600 clientes de una feria. Cárgalo." Nunca cargues directamente sobre la tabla buena: carga en una tabla de staging sin restricciones, valida allí y mueve solo lo que pasa.
flowchart LR
A["fichero.csv"] -->|"\copy"| B["stg_clientes<br/>sin restricciones"] --> C{"validar: formato · duplicados<br/>catálogos · ya existentes"}
C -->|"válidas"| D["INSERT ... SELECT<br/>en clientes"]
C -->|"rechazadas"| E["informe de errores"]
CREATE TEMP TABLE stg_clientes (id INTEGER, nombre TEXT, apellidos TEXT, email TEXT, pais TEXT);
-- \copy stg_clientes FROM 'feria.csv' WITH (FORMAT csv, HEADER true, DELIMITER ';')
SELECT s.id, s.email, s.pais,
CASE WHEN s.email NOT LIKE '%_@_%.%' THEN 'email con formato inválido'
WHEN s.pais NOT IN ('España','Portugal','Francia') THEN 'país fuera del catálogo'
WHEN EXISTS (SELECT 1 FROM clientes AS c
WHERE lower(c.email) = lower(s.email)) THEN 'ya existe en clientes'
WHEN COUNT(*) OVER (PARTITION BY lower(trim(s.email))) > 1 THEN 'duplicado dentro del fichero'
END AS motivo_rechazo
FROM stg_clientes AS s ORDER BY s.id;Sobre un fichero de prueba de seis filas —dos de ellas la misma Lucía escrita de dos maneras, un cliente que ya existe, un correo sin @, un país fuera de catálogo y una alta buena—:
| id | pais | motivo_rechazo | |
|---|---|---|---|
| 1 | [email protected] | España | ya existe en clientes |
| 2 | [email protected] | España | ya existe en clientes |
| 3 | [email protected] | España | ya existe en clientes |
| 4 | [email protected] | Francia | (null) |
| 5 | bruno.silva-example.pt | Portugal | email con formato inválido |
| 6 | [email protected] | Italia | país fuera del catálogo |
De seis filas se carga una. Ese informe es el entregable: quien te mandó el fichero necesita saber qué no ha entrado y por qué, y una carga que falla con ERROR: duplicate key value en la fila 400 no se lo dice a nadie. La inserción final es un INSERT ... SELECT con WHERE motivo_rechazo IS NULL, o un INSERT ... ON CONFLICT DO NOTHING de 05-05 si prefieres que el UNIQUE haga de última red — todo dentro de una transacción (09-03) para que la carga sea todo o nada.
- Tabla resumen: caso de uso → herramienta → lección
| Caso de uso | Herramienta del curso | Lección |
|---|---|---|
| Listado con filtros opcionales y paginación | (:p IS NULL OR col = :p); keyset en lugar de OFFSET |
02-03, 02-06, 08-04 |
| Búsqueda por contenido | ILIKE + GIN con pg_trgm; full-text si hay frases |
04-01, 08-03 |
| Cuadro de mando de una fila | COUNT/SUM con FILTER, COUNT(DISTINCT …) |
04-04, 06-05 |
| Top N y ranking | ROW_NUMBER/RANK en CTE, filtrado fuera |
10-02, 10-03 |
| Clientes inactivos, productos sin vender | NOT EXISTS (anti-join), LEFT JOIN … IS NULL |
03-03, 07-03 |
| Serie temporal sin huecos / cohortes | generate_series + LEFT JOIN + COALESCE; date_trunc |
04-05, 06-03, 10-02 |
| Duplicados: detección y limpieza | GROUP BY … HAVING COUNT(*) > 1, ROW_NUMBER, UNIQUE |
04-06, 05-01, 10-03 |
| Auditoría de cambios / exportación a CSV | Trigger AFTER + tabla auditoria_* / \copy … TO |
05-02, 06-03, 10-05 |
| Carga desde fichero | Tabla de staging + validación + INSERT … SELECT |
05-02, 05-05, 09-03 |
| Informe caro que se repite / respuesta anidada para una API | Vista materializada / jsonb_agg |
10-01, 10-06 |
Errores Comunes y Consejos
- Usar
NOT INcon una subconsulta que puede devolverNULL. Devuelve cero filas y parece que "no hay huecos": usaNOT EXISTS. Y olvidarCOUNT(DISTINCT …)en un informe conJOINa las líneas: elJOINmultiplica, 20 pedidos se convierten en 47 filas y el ticket medio queda dividido por 2,35. - Pintar una serie temporal sin calendario. Los meses sin datos no se saltan: valen cero, y la gráfica que los omite dibuja una tendencia que no existe. Y comparar cohortes de distinta edad: la de enero lleva un año comprando y la de diciembre, un mes.
- Cargar un CSV directamente sobre la tabla de producción. Una fila mala aborta la carga entera o, peor, entra a medias. Staging, validación e informe de rechazos. Y concatenar valores del usuario al construir un
WHEREdinámico: compón condiciones si hace falta, los valores siempre son parámetros (11-03). - Consejo: comprueba los totales de cada informe contra una cifra que ya conozcas. Las ocho cohortes de este texto suman 727,95 € porque tienen que sumarlo; si no cuadra, el error está en el
JOIN, no en los datos. Y guarda cada consulta de informe en el repositorio con un comentario que diga qué pregunta responde. Es el germen de la capa semántica de 11-04. Y cuando un encargo suene nuevo, búscalo en la tabla del apartado 12: casi siempre es uno de estos doce con otro nombre.
Ejercicios
Ejercicio 1
La pantalla de administración de pedidos necesita un listado con tres filtros opcionales —estado, país del cliente y rango de fechas— ordenado por fecha descendente y paginado de 10 en 10. (1) Escríbelo con el patrón de una sola consulta. (2) ¿Cuántas filas devuelve sin ningún filtro y cuántas con estado = 'entregado'? (3) Reescribe la paginación en modo keyset y explica qué columna necesitas para que sea estable.
Ejercicio 2
Dirección quiere una alerta de catálogo: los productos activos que no se han vendido nunca o que no tienen ninguna reseña, con una columna que diga cuál de los dos problemas tiene (o los dos). (1) Escríbela. (2) ¿Cuántas filas salen? (3) ¿Por qué el resultado cambia si usas INNER JOIN en lugar de NOT EXISTS?
Ejercicio 3
Te pasan un fichero de altas de proveedores con estas columnas: nombre, pais, email. (1) Diseña la tabla de staging y di por qué no debe tener restricciones. (2) Escribe las cuatro validaciones que aplicarías antes de insertar en proveedores. (3) ¿Qué harías con una fila cuyo nombre ya existe pero con el email distinto?
Soluciones
Solución 1
-- 1
SELECT pe.id, pe.fecha_pedido, c.nombre || ' ' || c.apellidos AS cliente, c.pais, pe.estado
FROM pedidos AS pe
JOIN clientes AS c ON c.id = pe.cliente_id
WHERE (:estado IS NULL OR pe.estado = :estado)
AND (:pais IS NULL OR c.pais = :pais)
AND (:desde IS NULL OR pe.fecha_pedido >= :desde)
AND (:hasta IS NULL OR pe.fecha_pedido < :hasta)
ORDER BY pe.fecha_pedido DESC, pe.id DESC
LIMIT 10;2. Sin filtros, 10 filas (la primera página de los 20 pedidos); con estado = 'entregado', también 10, porque hay 14 entregados. El total sin LIMIT sería 20 y 14. Fíjate en < :hasta y no <=: el patrón de rango de 08-04. 3. Keyset: WHERE (pe.fecha_pedido, pe.id) < (:ultima_fecha, :ultimo_id) ORDER BY pe.fecha_pedido DESC, pe.id DESC LIMIT 10. Hace falta pe.id como desempate, porque fecha_pedido no es única: sin él, dos pedidos del mismo día podrían repetirse o perderse entre páginas. Por eso el ORDER BY de un keyset debe terminar siempre en una columna única.
Solución 2
SELECT p.id, p.nombre,
CASE WHEN sin_venta AND sin_resena THEN 'sin ventas y sin reseñas'
WHEN sin_venta THEN 'nunca vendido'
ELSE 'sin reseñas' END AS problema
FROM (SELECT p.*,
NOT EXISTS (SELECT 1 FROM lineas_pedido AS lp WHERE lp.producto_id = p.id) AS sin_venta,
NOT EXISTS (SELECT 1 FROM resenas AS r WHERE r.producto_id = p.id) AS sin_resena
FROM productos AS p WHERE p.activo) AS p
WHERE sin_venta OR sin_resena
ORDER BY p.id;2. Diez filas. Los productos activos sin reseña son 10 (los 11 sin reseña menos las cápsulas de espirulina, que están descatalogadas y quedan fuera por WHERE p.activo), y los dos activos nunca vendidos —las velas de cera (id 13) y el desodorante (id 19)— están dentro de esos diez, porque tampoco tienen reseña. De ahí que la respuesta no sea 12: la unión de los dos conjuntos, no la suma. 3. Un INNER JOIN con lineas_pedido o con resenas responde a la pregunta contraria: devuelve los productos que sí tienen ventas o reseñas. Y un LEFT JOIN … WHERE lp.id IS NULL sí funcionaría, pero produce filas intermedias que luego hay que descartar y obliga a un DISTINCT; NOT EXISTS expresa la pregunta directamente y el motor la resuelve como anti-join (07-03, 07-05).
Solución 3
1. CREATE TEMP TABLE stg_proveedores (nombre TEXT, pais TEXT, email TEXT); — todo texto y sin restricciones, ni NOT NULL, ni UNIQUE, ni CHECK. El motivo: si el staging rechaza filas, la carga falla y pierdes precisamente la información que necesitas (qué filas venían mal y por qué). El staging acepta la basura para poder inventariarla; la tabla buena es la que la rechaza. 2. (a) Obligatorios: nombre y pais no nulos ni vacíos tras trim. (b) Formato: email LIKE '%_@_%.%' o NULL —en proveedores el email es opcional—. (c) Catálogo: pais IN ('España','Portugal','Francia','Alemania'), o mejor contra una tabla de países. (d) Duplicados: dentro del fichero con COUNT(*) OVER (PARTITION BY lower(trim(nombre))), y contra la tabla real con EXISTS. 3. Es una decisión de negocio, no técnica: puede ser el mismo proveedor que cambió de correo (→ upsert de 05-05) o dos empresas distintas con nombre parecido (→ INSERT). Lo que no debe hacer el programa es elegir en silencio: marca la fila como "revisión manual" y que decida una persona. Y si el nombre debe ser único de verdad, esa regla va en un UNIQUE sobre proveedores, no en el guion de carga.
Conclusión
Este era el catálogo de encargos, y ya lo tienes completo:
- El listado con filtros opcionales se resuelve con
(:param IS NULL OR columna = :param)a cambio de un plan genérico, o construyendo el SQL en la aplicación componiendo condiciones, nunca valores. La búsqueda tiene tres niveles: prefijo con B-tree, contenido conpg_trgmy GIN, y full-text cuando hay frases y relevancia — buscar "oliva" por prefijo no encuentra el aceite de oliva. - El cuadro de mando cabe en una fila y una consulta con
FILTER: 20 pedidos, 727,95 €, 36,40 € de ticket medio y 3 pedidos por enviar; el top N esROW_NUMBERen una CTE filtrado fuera. Los huecos son siempre unNOT EXISTS—3 clientes sin comprar, 3 productos sin vender, 11 sin reseña, 10 pedidos web—, y nunca unNOT INsi puede haber nulos. Las series temporales se generan congenerate_seriesy se unen conLEFT JOIN+COALESCE, para que enero de 2025 aparezca con 0,00 € en lugar de desaparecer. Las cohortes agrupan por mes de alta y solo se comparan a la misma edad. - Los duplicados se previenen con
UNIQUEy se detectan conGROUP BY … HAVING; la auditoría vive en un trigger, con la advertencia de quecurrent_userno es el usuario de tu aplicación. La exportación falla por el separador decimal, la codificación y el formato de fecha, no por el SQL; y la carga pasa siempre por una tabla de staging que acepta la basura para poder inventariarla: de seis filas de ejemplo, solo una era cargable.
Todas estas consultas funcionan. La pregunta que viene ahora es distinta: ¿podrá otra persona mantenerlas dentro de un año? En la lección siguiente, Mejores prácticas, se trata el oficio: la nomenclatura de tablas, columnas, claves e índices, y por qué lo importante es ser coherente; el formato que hace legible una consulta de cuarenta líneas; las decisiones de diseño —normalizar, tipos restrictivos, NOT NULL por defecto, restricciones nombradas— que separan una base con la que se puede trabajar de una que da miedo tocar; el proceso, con migraciones, revisiones de código y copias de seguridad probadas; y el catálogo de antipatrones, del SELECT * en producción al "lo arreglo directamente en producción".
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
