SQL & PostgreSQL
Curso para principiantes

Curso de SQL y PostgreSQL

Aprende a consultar bases de datos desde cero. Un curso practico y progresivo que te llevara desde tu primera query SELECT hasta optimizar consultas con indices y EXPLAIN.

Enfocado en pensar en SQL y en el rendimiento de tus queries. No necesitas experiencia previa con bases de datos.

~15 horas
18 modulos
Desde cero

El timer Pomodoro te ayuda a estudiar en bloques cortos de concentracion (25 min) con descansos. Esta demostrado que mejora el aprendizaje y reduce la fatiga mental.

📊

Parte de Hazla con Datos

Este curso es un recurso complementario de Hazla con Datos, una comunidad enfocada en programacion y ciencia de datos en salud. Encuentra mas cursos, recursos y herramientas para tu camino profesional.

Visitar hazlacondatos.com →
01

Introduccion a SQL y PostgreSQL

¿Que es una base de datos?

Imagina que tienes un negocio y llevas el registro de tus clientes en una libreta. Al principio funciona bien, pero cuando tienes 500 clientes y necesitas encontrar a todos los de Santiago que compraron el mes pasado… la libreta se queda corta.

Una base de datos es exactamente eso: un sistema organizado para almacenar, buscar y manipular informacion de forma eficiente. En lugar de una libreta, tienes un software optimizado que puede buscar entre millones de registros en milisegundos.

Las bases de datos relacionales (las que vamos a usar en este curso) organizan la informacion en tablas con filas y columnas, como una hoja de calculo pero con superpoderes:

  • Pueden manejar millones de registros sin perder velocidad
  • Garantizan que los datos sean consistentes
  • Permiten relacionar datos entre distintas tablas
  • Multiples usuarios pueden acceder a la vez sin problemas

¿Que es SQL?

SQL (Structured Query Language) es el lenguaje que usamos para comunicarnos con bases de datos relacionales. Nacio en los anos 70 en IBM y hoy en dia es uno de los lenguajes mas usados del mundo.

💡

SQL se pronuncia...

Hay dos formas aceptadas: “ese-cu-ele” (letra por letra) o “sequel” (como en ingles). Ambas estan bien. En este curso usaremos “SQL” como sigla.

El paradigma declarativo

Antes de ver codigo, necesitas entender algo fundamental: SQL pertenece al paradigma declarativo. Esto cambia completamente la forma en que piensas al programar.

En programacion existen dos grandes familias de paradigmas:

  • Imperativo: tu escribes los pasos exactos que la computadora debe seguir. Le dices como hacer algo. Es lo que hacen lenguajes como Python, JavaScript, Java o C. Tu controlas el flujo: bucles, condicionales, variables que van cambiando paso a paso.

  • Declarativo: tu describes el resultado que quieres y el sistema decide como obtenerlo. No te preocupas por el orden de ejecucion, ni por los algoritmos internos, ni por como se recorren los datos. Solo defines que necesitas.

SQL es declarativo. Esto significa que cuando escribes una consulta, no estas dando instrucciones paso a paso. Estas haciendo una declaracion de lo que quieres obtener:

-- Tu describes lo que quieres:
SELECT nombre, email
FROM clientes
WHERE ciudad = 'Santiago';

Con esa consulta le estas diciendo: “dame el nombre y email de los clientes que viven en Santiago”. No le dices como buscar, en que orden recorrer los datos ni que algoritmo usar. La base de datos se encarga de eso.

¿Por que esto importa? Porque libera al programador de pensar en la implementacion. PostgreSQL tiene un planificador de consultas (query planner) que analiza tu consulta y decide la estrategia mas eficiente para ejecutarla. Puede usar indices, paralelismo, distintos algoritmos de ordenamiento… todo sin que tu lo pidas. Tu trabajo es describir bien lo que necesitas; el trabajo del motor es encontrar la mejor forma de darlo.

Otros lenguajes declarativos

SQL no es el unico lenguaje declarativo. HTML describe la estructura de una pagina (no como renderizarla). CSS describe estilos (no como pintarlos). Las expresiones regulares describen patrones (no como buscarlos). Si ya usaste alguno de estos, ya tienes experiencia con el paradigma declarativo.

¿Que es PostgreSQL?

PostgreSQL (o simplemente “Postgres”) es un sistema de gestion de bases de datos relacional, gratuito y de codigo abierto. Es uno de los mas potentes y populares del mundo, usado por empresas como Apple, Instagram, Spotify y Netflix.

¿Por que aprender PostgreSQL?

  • Es gratis y open source: sin licencias ni costos ocultos
  • Es potente: soporta consultas complejas, datos JSON, busqueda de texto completo y mucho mas
  • Es el estandar de la industria: si aprendes Postgres, puedes trabajar con casi cualquier base de datos relacional
  • Gran comunidad: documentacion excelente y millones de desarrolladores que lo usan
  • Cumple con el estandar SQL: lo que aprendas aqui aplica a MySQL, SQL Server, SQLite y otros

¿Y otras bases de datos?

El 90% de lo que vas a aprender en este curso funciona igual en MySQL, SQL Server, SQLite y otros motores SQL. PostgreSQL tiene algunas funciones extra, pero las bases son las mismas.

Declarativo vs imperativo: un ejemplo concreto

Para que la diferencia quede clara, veamos el mismo problema resuelto en ambos paradigmas.

En Python (imperativo), para obtener los clientes de Santiago escribirias algo asi:

# Enfoque imperativo (paso a paso)
clientes_santiago = []
for cliente in todos_los_clientes:
if cliente.ciudad == "Santiago":
clientes_santiago.append(cliente.nombre)

En SQL, simplemente describes lo que quieres:

-- Enfoque declarativo (describes el resultado)
SELECT nombre
FROM clientes
WHERE ciudad = 'Santiago';

No le dices a la base de datos “recorre todos los clientes uno por uno y fijate si la ciudad es Santiago”. Le dices “quiero los nombres donde la ciudad sea Santiago” y ella decide la forma mas eficiente de hacerlo.

Por que importa esta distincion

Si vienes de Python, R, JavaScript o cualquier lenguaje de proposito general, tu cerebro esta entrenado para pensar en pasos: “primero recorro, luego filtro, luego guardo”. Esa logica no sirve en SQL. Intentar escribir SQL como si fuera un bucle es una de las principales fuentes de frustacion (y de consultas lentas) en principiantes. La forma de abordar el problema es fundamentalmente distinta: en SQL no controlas el como, solo defines el que.

La buena noticia es que no estas solo en esta transicion. Existen frameworks en lenguajes imperativos que adoptan un estilo declarativo muy similar al de SQL, lo que facilita mucho el salto mental:

  • Python: pandas permite encadenar operaciones como .query(), .groupby(), .merge() que se parecen mucho a WHERE, GROUP BY y JOIN. SQLAlchemy va un paso mas alla y te deja construir consultas SQL con sintaxis Python.
  • R: dplyr (parte del tidyverse) fue disenado explicitamente para imitar la logica de SQL. Funciones como filter(), select(), group_by() y summarise() son practicamente traducciones directas de clausulas SQL.

Si ya usas alguno de estos, vas a notar que aprender SQL se siente familiar. Y si aun no los conoces, aprender SQL primero te va a hacer mucho mas productivo cuando los uses despues.

Todo tiene un costo

Cada operacion SQL tiene un costo computacional. Cuando escribes SELECT * FROM clientes, la base de datos tiene que leer todas las columnas de cada fila, transferirlas por red y formatearlas para mostrartelas — aunque solo necesites el nombre y el email. Por eso, en aplicaciones reales siempre se especifican las columnas exactas que se necesitan (SELECT nombre, email FROM clientes). El SELECT * es perfecto para explorar datos o dar tus primeros pasos con una tabla, pero en produccion es un desperdicio de recursos. A lo largo de este curso vamos a ir aprendiendo como escribir consultas que hagan solo el trabajo necesario.


Ejercicios

Estos ejercicios no requieren tener PostgreSQL instalado todavia. Son para reforzar los conceptos de este modulo. En el Modulo 2 vas a preparar tu entorno y cargar la base de datos tienda que usaremos en todo el curso.

💡

La base de datos tienda

A lo largo del curso trabajaremos con una base de datos llamada tienda que tiene 5 tablas: productos (id, nombre, precio, categoria, stock, fecha_creacion), clientes (id, nombre, email, ciudad, fecha_registro), pedidos (id, cliente_id, fecha, total, estado), detalle_pedidos (id, pedido_id, producto_id, cantidad, precio_unitario) y empleados (id, nombre, departamento, salario, fecha_contratacion, jefe_id).

Ejercicio 1: Imagina que tienes la tabla productos con las columnas: id, nombre, precio, categoria, stock y fecha_creacion. Escribe en palabras que le pedirias a SQL para obtener: (a) todos los nombres de productos, y (b) los productos con precio mayor a 100.

Ver solucion

(a) “Dame la columna nombre de la tabla productos” → SELECT nombre FROM productos;

(b) “Dame todos los productos donde el precio sea mayor a 100” → SELECT * FROM productos WHERE precio > 100;

Lo importante es que pienses en que quieres, no en como obtenerlo. SQL es declarativo: describes el resultado, no los pasos.

Ejercicio 2: Identifica cual de estos enfoques es declarativo y cual es imperativo:

  • Opcion A: “Recorre cada fila de la tabla clientes. Si la ciudad es ‘Santiago’, guarda el nombre en una lista.”
  • Opcion B: “Dame los nombres de la tabla clientes donde la ciudad sea Santiago.”
Ver solucion
  • Opcion A es imperativa: describes los pasos (recorrer, verificar, guardar). Es como lo harias en Python o JavaScript con un bucle.
  • Opcion B es declarativa: describes el resultado que quieres. Es como lo haces en SQL:
SELECT nombre FROM clientes WHERE ciudad = 'Santiago';

En SQL siempre usamos el enfoque B. Le decimos a la base de datos que queremos y ella decide como obtenerlo.

Ejercicio 3: Sin ejecutar nada, lee esta consulta SQL y describe en palabras que resultado esperas:

SELECT nombre, precio
FROM productos
WHERE categoria = 'Electronica'
ORDER BY precio DESC;
Ver solucion

“Dame el nombre y precio de todos los productos que son de la categoria Electronica, ordenados de mayor a menor precio.”

El resultado seria una lista de productos electronicos mostrando solo dos columnas (nombre y precio), con el mas caro arriba y el mas barato abajo. Desglose:

  • SELECT nombre, precio → solo esas dos columnas
  • FROM productos → de la tabla productos
  • WHERE categoria = 'Electronica' → solo los de esa categoria
  • ORDER BY precio DESC → ordenados de mayor a menor

Ejercicio 4: ¿Por que SELECT * FROM productos es menos eficiente que SELECT nombre, precio FROM productos? ¿En que situacion usarias SELECT *?

Ver solucion

SELECT * pide todas las columnas de la tabla (id, nombre, precio, categoria, stock, fecha_creacion), aunque solo necesites dos. Esto significa:

  • Mas datos leidos del disco
  • Mas datos transferidos por la red
  • Mas memoria usada para procesar los resultados

SELECT nombre, precio solo pide las columnas que necesitas, lo cual es mas rapido y consume menos recursos.

¿Cuando usar SELECT *? Es util para explorar una tabla que no conoces, para ver su estructura y datos. Pero en consultas de produccion (aplicaciones, reportes automaticos), siempre es mejor especificar las columnas exactas.


¿Que sigue?

En el proximo modulo vas a preparar tu entorno de trabajo: instalar PostgreSQL, configurar DBeaver y cargar la base de datos de practica que usaremos a lo largo de todo el curso.

02

Preparando el entorno

En este modulo vas a preparar tu entorno de trabajo. Al final tendras PostgreSQL funcionando, un cliente SQL listo para usar y la base de datos de practica cargada con datos. Todo lo que necesitas para seguir el resto del curso.


Que necesitas

Dos cosas:

  1. Un servidor PostgreSQL — donde viven las bases de datos
  2. Un cliente SQL — la herramienta donde escribes y ejecutas consultas

Vamos a instalar ambos paso a paso.

💡

Quieres probar antes de instalar?

Existen playgrounds online donde puedes ejecutar SQL directamente en el navegador sin instalar nada. Mira la seccion “Playgrounds online” mas abajo. Son utiles como primer acercamiento, pero para seguir todo el curso necesitaras un entorno local.


Paso 1: Instalar PostgreSQL

Descarga PostgreSQL desde postgresql.org/download y sigue el instalador para tu sistema operativo.

Durante la instalacion:

  • Te pedira crear una contrasena para el usuario postgres. Anotala en algun lugar seguro, la necesitaras para conectarte.
  • Deja el puerto en 5432 (es el puerto por defecto).
  • No necesitas instalar componentes adicionales como Stack Builder.

Verificar la instalacion

En Windows, busca “SQL Shell (psql)” en el menu de inicio. En macOS/Linux, abre una terminal y escribe psql --version. Si ves un numero de version, la instalacion fue exitosa.


Paso 2: Instalar DBeaver Community

DBeaver es un cliente SQL gratuito y visual. Es el que vamos a usar a lo largo del curso porque tiene una interfaz amigable, soporta multiples bases de datos y funciona en Windows, macOS y Linux.

Descargalo desde dbeaver.io/download e instalalo normalmente.

💡

Otras opciones de cliente SQL

Si ya tienes experiencia, puedes usar pgAdmin (viene con PostgreSQL), psql (terminal), o cualquier otro cliente SQL. Todo lo que veremos funciona en cualquier herramienta que pueda conectarse a PostgreSQL.


Paso 3: Conectarte a PostgreSQL desde DBeaver

Abre DBeaver y sigue estos pasos para crear tu primera conexion:

  1. Click en Nueva Conexion (el icono de enchufe con un signo +, arriba a la izquierda)
  2. Selecciona PostgreSQL en la lista y click en Siguiente
  3. Completa los datos de conexion:
    • Host: localhost
    • Puerto: 5432
    • Database: postgres (la base de datos por defecto)
    • Username: postgres
    • Password: la contrasena que creaste durante la instalacion
  4. Click en Test Connection para verificar que funciona
  5. Si el test es exitoso, click en Finalizar
⚠️

Si la conexion falla

Verifica que: (1) PostgreSQL esta corriendo como servicio, (2) el puerto es 5432, (3) la contrasena es correcta. En Windows, busca “Servicios” y verifica que “postgresql” este en estado “Ejecutando”.


Paso 4: Crear la base de datos de practica

A lo largo de todo el curso vamos a trabajar con la base de datos de una tienda ficticia. Tiene 5 tablas que simulan un sistema real de pedidos:

  • productos: los articulos que vende la tienda
  • clientes: las personas que compran
  • pedidos: cada compra realizada
  • detalle_pedidos: los productos dentro de cada pedido
  • empleados: el equipo de la tienda (con jerarquia de jefes)

Diagrama de relaciones

clientes ──< pedidos ──< detalle_pedidos >── productos
empleados (jefe_id -> empleados.id)

Un cliente puede tener muchos pedidos. Cada pedido puede tener muchos detalles. Cada detalle referencia a un producto. Y los empleados pueden tener un jefe que tambien es empleado.

Crear la base de datos tienda

En DBeaver:

  1. Click derecho en tu conexion (en el panel izquierdo) → CrearDatabase
  2. Escribe tienda como nombre
  3. Click en OK
  4. Doble click en tienda para conectarte a ella

Crear las tablas

Abre un nuevo editor SQL en DBeaver (Ctrl+Enter en Windows o Cmd+Enter en macOS, o usa el menu SQL → Nuevo editor SQL). Asegurate de estar conectado a la base de datos tienda (verifica en la barra superior del editor).

Copia y ejecuta este SQL:

CREATE TABLE productos (
id SERIAL PRIMARY KEY,
nombre VARCHAR(100) NOT NULL,
precio DECIMAL(10,2) NOT NULL,
categoria VARCHAR(50),
stock INTEGER DEFAULT 0,
fecha_creacion DATE DEFAULT CURRENT_DATE
);
CREATE TABLE clientes (
id SERIAL PRIMARY KEY,
nombre VARCHAR(100) NOT NULL,
email VARCHAR(100) UNIQUE,
ciudad VARCHAR(50),
fecha_registro DATE DEFAULT CURRENT_DATE
);
CREATE TABLE pedidos (
id SERIAL PRIMARY KEY,
cliente_id INTEGER REFERENCES clientes(id),
fecha DATE DEFAULT CURRENT_DATE,
total DECIMAL(10,2),
estado VARCHAR(20) DEFAULT 'pendiente'
);
CREATE TABLE detalle_pedidos (
id SERIAL PRIMARY KEY,
pedido_id INTEGER REFERENCES pedidos(id),
producto_id INTEGER REFERENCES productos(id),
cantidad INTEGER NOT NULL,
precio_unitario DECIMAL(10,2) NOT NULL
);
CREATE TABLE empleados (
id SERIAL PRIMARY KEY,
nombre VARCHAR(100) NOT NULL,
departamento VARCHAR(50),
salario DECIMAL(10,2),
fecha_contratacion DATE DEFAULT CURRENT_DATE,
jefe_id INTEGER REFERENCES empleados(id)
);

Insertar los datos de prueba

Ahora ejecuta este SQL para cargar los datos de ejemplo:

INSERT INTO productos (nombre, precio, categoria, stock) VALUES
('Laptop Pro 15', 1299.99, 'Electronica', 45),
('Mouse Inalambrico', 29.99, 'Electronica', 150),
('Teclado Mecanico', 89.99, 'Electronica', 80),
('Monitor 27 pulgadas', 449.99, 'Electronica', 30),
('Auriculares Bluetooth', 59.99, 'Electronica', 200),
('Escritorio Ajustable', 399.99, 'Muebles', 25),
('Silla Ergonomica', 299.99, 'Muebles', 40),
('Lampara LED', 34.99, 'Muebles', 100),
('Mochila para Laptop', 49.99, 'Accesorios', 120),
('Hub USB-C', 39.99, 'Accesorios', 90),
('Webcam HD', 79.99, 'Electronica', 60),
('Cable HDMI 2m', 12.99, 'Accesorios', 300),
('Soporte Monitor', 44.99, 'Muebles', 55),
('Mousepad XL', 19.99, 'Accesorios', 180),
('Cargador Rapido', 24.99, 'Accesorios', 250);
INSERT INTO clientes (nombre, email, ciudad) VALUES
('Maria Garcia', 'maria@email.com', 'Santiago'),
('Carlos Lopez', 'carlos@email.com', 'Valparaiso'),
('Ana Martinez', 'ana@email.com', 'Santiago'),
('Pedro Sanchez', 'pedro@email.com', 'Concepcion'),
('Laura Torres', 'laura@email.com', 'Santiago'),
('Diego Rivera', 'diego@email.com', 'Valparaiso'),
('Carmen Ruiz', 'carmen@email.com', 'Temuco'),
('Roberto Diaz', 'roberto@email.com', 'Santiago'),
('Isabel Moreno', 'isabel@email.com', 'Antofagasta'),
('Andres Vargas', 'andres@email.com', 'Concepcion');
INSERT INTO pedidos (cliente_id, fecha, total, estado) VALUES
(1, '2024-01-15', 1389.98, 'completado'),
(2, '2024-01-20', 89.99, 'completado'),
(3, '2024-02-01', 499.98, 'completado'),
(1, '2024-02-14', 59.99, 'completado'),
(4, '2024-02-28', 1749.98, 'enviado'),
(5, '2024-03-05', 149.97, 'completado'),
(6, '2024-03-10', 299.99, 'pendiente'),
(3, '2024-03-15', 839.98, 'enviado'),
(7, '2024-03-20', 34.99, 'completado'),
(8, '2024-03-25', 479.98, 'pendiente'),
(2, '2024-04-01', 129.98, 'completado'),
(9, '2024-04-05', 1299.99, 'enviado'),
(10, '2024-04-10', 69.98, 'pendiente'),
(1, '2024-04-15', 449.99, 'completado'),
(5, '2024-04-20', 89.99, 'completado');
INSERT INTO detalle_pedidos (pedido_id, producto_id, cantidad, precio_unitario) VALUES
(1, 1, 1, 1299.99),
(1, 2, 3, 29.99),
(2, 3, 1, 89.99),
(3, 7, 1, 299.99),
(3, 8, 2, 34.99),
(3, 14, 1, 19.99),
(4, 5, 1, 59.99),
(5, 1, 1, 1299.99),
(5, 4, 1, 449.99),
(6, 9, 3, 49.99),
(7, 7, 1, 299.99),
(8, 4, 1, 449.99),
(8, 10, 2, 39.99),
(8, 3, 1, 89.99),
(9, 8, 1, 34.99),
(10, 11, 1, 79.99),
(10, 6, 1, 399.99),
(11, 2, 1, 29.99),
(11, 14, 5, 19.99),
(12, 1, 1, 1299.99),
(13, 12, 2, 12.99),
(13, 15, 1, 24.99),
(14, 4, 1, 449.99),
(15, 3, 1, 89.99);
INSERT INTO empleados (nombre, departamento, salario, fecha_contratacion, jefe_id) VALUES
('Sofia Mendez', 'Gerencia', 85000, '2020-01-15', NULL),
('Juan Perez', 'Ventas', 45000, '2021-03-01', 1),
('Valentina Castro', 'Ventas', 42000, '2021-06-15', 1),
('Miguel Angel Torres', 'Tecnologia', 62000, '2020-08-01', 1),
('Camila Rojas', 'Tecnologia', 55000, '2022-01-10', 4),
('Alejandro Vega', 'Tecnologia', 58000, '2021-11-20', 4),
('Fernanda Luna', 'Ventas', 40000, '2023-02-01', 2),
('Ricardo Soto', 'Soporte', 38000, '2022-07-15', 4),
('Patricia Herrera', 'Soporte', 36000, '2023-05-01', 8),
('Daniela Flores', 'Ventas', 41000, '2023-08-10', 2);

Atajo: usa el script del repositorio

Si clonaste el repositorio del curso, el script completo esta en db/init.sql. Puedes abrirlo directamente en DBeaver con Archivo → Abrir archivo y ejecutarlo. Es mas rapido que copiar y pegar.

Verificar que todo funciona

Ejecuta estas consultas para confirmar que los datos se cargaron correctamente:

SELECT COUNT(*) FROM productos; -- Deberia devolver: 15
SELECT COUNT(*) FROM clientes; -- Deberia devolver: 10
SELECT COUNT(*) FROM pedidos; -- Deberia devolver: 15
SELECT COUNT(*) FROM detalle_pedidos; -- Deberia devolver: 24
SELECT COUNT(*) FROM empleados; -- Deberia devolver: 10

Si todos los numeros coinciden, estas listo para empezar.


Playgrounds online: practica sin instalar nada

Si quieres un primer acercamiento a SQL antes de instalar software, existen playgrounds online donde puedes escribir y ejecutar consultas directamente en el navegador:

  • aprendesql.dev/playground — Playground en espanol con soporte para PostgreSQL. Ideal para empezar a experimentar.
  • sqlfiddle.com — Uno de los playgrounds mas conocidos. Soporta PostgreSQL y otros motores.
  • db-fiddle.com — Similar a SQL Fiddle, con interfaz limpia y soporte para PostgreSQL.

Puedes copiar el SQL de creacion de tablas e insercion de datos de la seccion anterior, pegarlo en cualquiera de estos playgrounds y empezar a practicar de inmediato.

⚠️

Limitaciones de los playgrounds

Los playgrounds son excelentes para practicar consultas basicas (SELECT, WHERE, JOINs, agregaciones), pero tienen limitaciones:

  • No soportan todas las funcionalidades de PostgreSQL (transacciones, vistas materializadas, permisos, etc.)
  • Las sesiones son temporales: los datos no se guardan entre visitas
  • No puedes usar herramientas como EXPLAIN ANALYZE o gestionar indices realmente

Para efectos profesionales y para cubrir todo el contenido del curso (especialmente los modulos avanzados), es necesario instalar PostgreSQL en tu maquina.


Conociendo DBeaver (opcional)

Si instalaste DBeaver, estas son las funciones que mas vas a usar:

  • Editor SQL: donde escribes y ejecutas consultas. Atajo: Ctrl+Enter (ejecutar todo) o selecciona una parte y presiona Ctrl+Enter para ejecutar solo esa parte.
  • Panel de resultados: debajo del editor, muestra los resultados de tu ultima consulta en formato de tabla.
  • Panel de navegacion (izquierda): muestra tus conexiones, bases de datos, tablas y columnas. Puedes explorar la estructura de la base de datos sin escribir SQL.
  • Multiples pestanas: puedes tener varios editores SQL abiertos al mismo tiempo.

Atajo util

Selecciona solo una parte del SQL y presiona Ctrl+Enter para ejecutar unicamente esa porcion. Esto es muy util cuando tienes varias consultas en el mismo editor y quieres probar una a la vez.


¿Que sigue?

Con el entorno listo y la base de datos cargada, en el proximo modulo vas a escribir tu primera consulta con SELECT. Vamos a explorar como pedirle datos a las tablas, eligiendo columnas, creando alias y haciendo calculos simples. ¡Vamos!


Ejercicios

Ejercicio 1: Verifica que tu entorno esta correctamente configurado ejecutando:

SELECT COUNT(*) FROM clientes;
SELECT COUNT(*) FROM productos;
SELECT COUNT(*) FROM pedidos;

¿Cuantos registros tiene cada tabla?

Ejercicio 2: Ejecuta esta consulta y observa el resultado. No necesitas entenderla completamente aun, solo ejecutala:

SELECT nombre, precio
FROM productos
WHERE categoria = 'Electronica'
ORDER BY precio DESC;

¿Que crees que hace? Intenta describirlo con tus propias palabras.

Ejercicio 3: Modifica la consulta anterior cambiando 'Electronica' por 'Muebles'. ¿Que resultado obtienes?

03

SELECT: Tu primera consulta

⚠️

Prerequisito: base de datos de practica

Todos los ejemplos y ejercicios de este modulo (y los siguientes) se ejecutan sobre la base de datos tienda que creamos en el Modulo 2. Si aun no la tienes configurada, ve al Modulo 2 y sigue los pasos para crear las tablas y cargar los datos de ejemplo antes de continuar.

Tu primera consulta SQL

Llegamos al momento que estabas esperando: vas a hablar con la base de datos por primera vez. La instruccion mas fundamental de SQL es SELECT, y la vas a usar en absolutamente todo lo que hagas.

La forma mas basica de SELECT es:

SELECT columnas
FROM tabla;

Asi de simple. Le dices que columnas quieres y de que tabla. Probemos con nuestra base de datos:

SELECT nombre, precio
FROM productos;

Esta consulta devuelve el nombre y precio de todos los productos. El resultado se ve algo asi:

nombre | precio
---------------------+---------
Laptop Pro 15 | 1299.99
Mouse Inalambrico | 29.99
Teclado Mecanico | 89.99
Monitor 27 pulgadas | 449.99
Auriculares Bluetooth| 59.99
...

Felicidades, acabas de hacer tu primera consulta SQL.

SELECT * vs columnas especificas

Existe un atajo para pedir todas las columnas de una tabla:

SELECT *
FROM productos;

El asterisco (*) significa “dame todas las columnas”. Es muy comodo para explorar datos rapidamente, pero tiene un problema importante en aplicaciones reales.

-- Esto trae TODAS las columnas: id, nombre, precio, categoria, stock, fecha_creacion
SELECT *
FROM productos;
-- Esto trae solo lo que necesitas
SELECT nombre, precio
FROM productos;

¿Por que evitar SELECT * en produccion?

Cuando usas SELECT *, la base de datos tiene que leer y transferir todas las columnas de cada fila, incluso las que no necesitas. Si tu tabla tiene 20 columnas pero solo necesitas 3, estas transfiriendo casi 7 veces mas datos de los necesarios.

Piensalo asi: el trabajo por cada fila es proporcional al numero de columnas que pides. Si pides 3 columnas, es O(3) por fila. Si pides 20, es O(20) por fila. En una tabla con un millon de filas, esa diferencia se nota.

Ademas, SELECT * hace que tu codigo sea fragil: si alguien agrega una columna nueva a la tabla, tu consulta de repente devuelve datos que no esperabas.

¿Cuando si usar SELECT *?

Usa SELECT * libremente cuando estes explorando datos, aprendiendo la estructura de una tabla, o haciendo consultas rapidas en desarrollo. La recomendacion de evitarlo aplica sobre todo a consultas en aplicaciones en produccion.

Seleccionando columnas especificas

Puedes pedir una, dos o todas las columnas que quieras. El orden en que las pides es el orden en que aparecen en el resultado:

-- Solo el nombre
SELECT nombre
FROM clientes;
-- Nombre y ciudad (en ese orden)
SELECT nombre, ciudad
FROM clientes;
-- Ciudad primero, luego nombre (cambio de orden)
SELECT ciudad, nombre
FROM clientes;
-- Varias columnas de empleados
SELECT nombre, departamento, salario
FROM empleados;

Observa que en el tercer ejemplo, la ciudad aparece antes que el nombre en el resultado. Tu controlas el orden de las columnas en la salida.

Alias de columnas con AS

A veces los nombres de las columnas no son muy descriptivos o quieres darles un nombre mas claro en el resultado. Para eso usamos AS:

SELECT
nombre AS producto,
precio AS precio_unitario
FROM productos;

Resultado:

producto | precio_unitario
----------------------+----------------
Laptop Pro 15 | 1299.99
Mouse Inalambrico | 29.99
Teclado Mecanico | 89.99
...

Los alias son especialmente utiles cuando haces calculos o cuando el nombre original de la columna no es claro:

SELECT
nombre AS empleado,
departamento AS area,
salario AS sueldo_mensual
FROM empleados;
💡

AS es opcional (pero usalo)

Tecnicamente puedes omitir la palabra AS y simplemente poner el alias:

SELECT nombre producto, precio costo FROM productos;

Funciona, pero es mucho menos legible. Recomiendo siempre usar AS para que sea claro que estas creando un alias.

Alias con espacios

Si necesitas un alias con espacios, usa comillas dobles:

SELECT
nombre AS "Nombre del Producto",
precio AS "Precio en USD"
FROM productos;

DISTINCT: eliminando duplicados

Si una consulta devuelve valores repetidos y quieres ver solo los valores unicos, usa DISTINCT:

-- Sin DISTINCT: puede repetir ciudades
SELECT ciudad
FROM clientes;
ciudad
-----------
Santiago
Valparaiso
Santiago
Concepcion
Santiago
Valparaiso
Temuco
Santiago
Antofagasta
Concepcion
-- Con DISTINCT: cada ciudad aparece una sola vez
SELECT DISTINCT ciudad
FROM clientes;
ciudad
-----------
Santiago
Valparaiso
Concepcion
Temuco
Antofagasta

Puedes usar DISTINCT con multiples columnas. En ese caso, elimina filas donde la combinacion de columnas sea duplicada:

-- Combinaciones unicas de departamento y jefe_id
SELECT DISTINCT departamento, jefe_id
FROM empleados;

Esto devuelve cada combinacion unica de departamento y jefe, no cada departamento unico y cada jefe unico por separado.

-- ¿Que categorias de productos tenemos?
SELECT DISTINCT categoria
FROM productos;
categoria
------------
Electronica
Muebles
Accesorios
-- ¿En que estados pueden estar los pedidos?
SELECT DISTINCT estado
FROM pedidos;
estado
-----------
completado
enviado
pendiente

Costo de DISTINCT

DISTINCT no es gratis. Para eliminar duplicados, la base de datos necesita ordenar o agrupar todos los resultados y luego compararlos entre si. En tablas grandes esto puede ser costoso. Usalo cuando realmente necesites valores unicos, no “por si acaso”.

Expresiones y calculos en SELECT

SELECT no solo sirve para leer columnas tal cual estan. Puedes hacer calculos directamente en la consulta:

Aritmetica

-- Precio con IVA (19%)
SELECT
nombre,
precio,
precio * 1.19 AS precio_con_iva
FROM productos;
nombre | precio | precio_con_iva
---------------------+----------+---------------
Laptop Pro 15 | 1299.99 | 1547.39
Mouse Inalambrico | 29.99 | 35.69
Teclado Mecanico | 89.99 | 107.09
...
-- Valor total del inventario por producto
SELECT
nombre,
precio,
stock,
precio * stock AS valor_inventario
FROM productos;
-- Salario anual de empleados (asumiendo 12 meses)
SELECT
nombre,
salario AS salario_mensual,
salario * 12 AS salario_anual
FROM empleados;
-- Descuento del 10% en productos
SELECT
nombre,
precio AS precio_original,
precio * 0.10 AS descuento,
precio * 0.90 AS precio_final
FROM productos;

Concatenacion de texto

En PostgreSQL, usamos el operador || para unir texto:

SELECT
nombre || ' - ' || ciudad AS cliente_info
FROM clientes;
cliente_info
----------------------------
Maria Garcia - Santiago
Carlos Lopez - Valparaiso
Ana Martinez - Santiago
...
-- Descripcion del producto con precio
SELECT
nombre || ' ($' || precio || ')' AS descripcion
FROM productos;
descripcion
-----------------------------
Laptop Pro 15 ($1299.99)
Mouse Inalambrico ($29.99)
Teclado Mecanico ($89.99)
...

Valores literales

Puedes incluir valores fijos (literales) en tu SELECT:

SELECT
nombre,
precio,
'CLP' AS moneda,
2024 AS anio
FROM productos;

Cada fila tendra las columnas moneda con el valor ‘CLP’ y anio con el valor 2024. Esto puede parecer inutil ahora, pero es muy practico cuando combinas datos de distintas fuentes.

SELECT sin FROM

Dato curioso: en PostgreSQL puedes usar SELECT sin una tabla, como una calculadora:

SELECT 2 + 2;
-- Resultado: 4
SELECT 100 * 1.19 AS con_iva;
-- Resultado: 119.00
SELECT 'Hola' || ' ' || 'Mundo' AS saludo;
-- Resultado: Hola Mundo
SELECT NOW() AS fecha_actual;
-- Resultado: 2024-04-20 15:30:00 (la fecha y hora actual)

Es util para probar expresiones rapidamente sin necesidad de consultar una tabla.

El orden de escritura vs. el orden de ejecucion

Este es un concepto que te va a ahorrar muchos dolores de cabeza. El orden en que escribes una consulta SQL no es el orden en que la base de datos la ejecuta.

Tu escribes:

SELECT columnas -- 1° en escribir
FROM tabla -- 2° en escribir
WHERE condicion -- 3° en escribir

Pero la base de datos ejecuta:

FROM tabla -- 1° en ejecutar (primero encuentra la tabla)
WHERE condicion -- 2° en ejecutar (luego filtra filas)
SELECT columnas -- 3° en ejecutar (al final elige columnas)
💡

¿Por que importa esto?

Esto explica, por ejemplo, por que no puedes usar un alias definido en SELECT dentro del WHERE: cuando el WHERE se ejecuta, el SELECT todavia no ha corrido.

-- Esto NO funciona:
SELECT precio * 1.19 AS precio_con_iva
FROM productos
WHERE precio_con_iva > 100; -- Error: precio_con_iva no existe aun
-- Esto SI funciona:
SELECT precio * 1.19 AS precio_con_iva
FROM productos
WHERE precio * 1.19 > 100; -- Repites la expresion

En modulos posteriores veremos el orden completo de ejecucion cuando agreguemos mas clausulas.

Buenas practicas al escribir SELECT

Antes de pasar a los ejercicios, algunas recomendaciones:

-- MAL: todo en una linea, dificil de leer
SELECT nombre,precio,stock,precio*stock AS total FROM productos;
-- BIEN: cada columna en su linea, con indentacion clara
SELECT
nombre,
precio,
stock,
precio * stock AS total
FROM productos;
-- MAL: columnas ambiguas, sin alias
SELECT nombre, precio * 1.19, stock * precio
FROM productos;
-- BIEN: alias descriptivos para cada calculo
SELECT
nombre,
precio * 1.19 AS precio_con_iva,
stock * precio AS valor_inventario
FROM productos;

SQL no distingue mayusculas

Las palabras clave de SQL (SELECT, FROM, WHERE, etc.) funcionan en mayusculas o minusculas. select, SELECT y Select son lo mismo. La convencion mas comun es escribir las palabras clave en MAYUSCULAS para distinguirlas de los nombres de tablas y columnas. En este curso usamos esa convencion.


Ejercicios

Ejercicio 1: Escribe una consulta que muestre el nombre y la categoria de todos los productos.

Ver solucion
SELECT nombre, categoria
FROM productos;

Ejercicio 2: Muestra el nombre de cada producto junto con su precio con IVA (19%) y ponle un alias descriptivo a la nueva columna.

Ver solucion
SELECT
nombre,
precio,
precio * 1.19 AS precio_con_iva
FROM productos;

Ejercicio 3: Obtener una lista de todas las ciudades unicas donde tenemos clientes.

Ver solucion
SELECT DISTINCT ciudad
FROM clientes;

Ejercicio 4: Muestra el nombre completo de cada cliente concatenado con su email entre parentesis. Por ejemplo: “Maria Garcia (maria@email.com)”. Usa un alias llamado contacto.

Ver solucion
SELECT
nombre || ' (' || email || ')' AS contacto
FROM clientes;

Ejercicio 5: Para cada producto, muestra su nombre, stock actual, precio y el valor total del inventario (stock multiplicado por precio). Usa alias descriptivos.

Ver solucion
SELECT
nombre,
stock AS unidades_disponibles,
precio AS precio_unitario,
stock * precio AS valor_total_inventario
FROM productos;

Ejercicio 6: Obtener los departamentos unicos de la tabla empleados. ¿Cuantos departamentos distintos hay?

Ver solucion
SELECT DISTINCT departamento
FROM empleados;
-- Resultado: Gerencia, Ventas, Tecnologia, Soporte (4 departamentos)

Ejercicio 7: Muestra el nombre de cada empleado, su salario mensual y su salario anual (12 meses). Ademas agrega una columna literal que diga ‘CLP’ como moneda.

Ver solucion
SELECT
nombre,
salario AS salario_mensual,
salario * 12 AS salario_anual,
'CLP' AS moneda
FROM empleados;

Ejercicio 8 (desafio): Sin ejecutar la consulta, ¿esta consulta va a funcionar? ¿Por que si o por que no?

SELECT
nombre,
precio * stock AS valor
FROM productos
WHERE valor > 1000;
Ver solucion

No funciona. El alias valor se define en el SELECT, pero el WHERE se ejecuta antes que el SELECT. La base de datos no conoce el alias valor cuando evalua el WHERE. La forma correcta seria:

SELECT
nombre,
precio * stock AS valor
FROM productos
WHERE precio * stock > 1000;
04

WHERE: Filtrando datos

Filtrando datos con WHERE

En el modulo anterior aprendimos a pedir columnas con SELECT. Pero SELECT sin WHERE es como ir al supermercado y traer todo lo que hay en la tienda. En la vida real, casi siempre quieres solo una parte de los datos.

La clausula WHERE te permite filtrar filas segun condiciones:

SELECT nombre, precio
FROM productos
WHERE categoria = 'Electronica';

Esta consulta dice: “dame el nombre y precio de los productos, pero solo los que tienen categoria Electronica”. El resultado:

nombre | precio
-----------------------+---------
Laptop Pro 15 | 1299.99
Mouse Inalambrico | 29.99
Teclado Mecanico | 89.99
Monitor 27 pulgadas | 449.99
Auriculares Bluetooth | 59.99
Webcam HD | 79.99

De los 15 productos en la tabla, solo 6 cumplen la condicion. El WHERE descarto los otros 9 antes de que llegaran al resultado.

WHERE: tu mejor aliado en rendimiento

WHERE es probablemente la clausula mas importante para el rendimiento de tus consultas. Filtrar filas temprano significa que el resto de la consulta (ordenar, agrupar, calcular) trabaja con menos datos. Si tu tabla tiene un millon de filas y el WHERE las reduce a 100, todo lo demas es 10,000 veces mas rapido. Siempre filtra lo mas posible, lo antes posible.

Operadores de comparacion

Los operadores basicos de comparacion son los que ya conoces de matematicas:

-- Igual a
SELECT nombre, precio
FROM productos
WHERE precio = 29.99;
-- Diferente de (no igual)
SELECT nombre, estado
FROM pedidos
WHERE estado <> 'completado';
-- Mayor que
SELECT nombre, precio
FROM productos
WHERE precio > 100;
-- Menor que
SELECT nombre, salario
FROM empleados
WHERE salario < 45000;
-- Mayor o igual que
SELECT nombre, stock
FROM productos
WHERE stock >= 100;
-- Menor o igual que
SELECT nombre, precio
FROM productos
WHERE precio <= 50;

Veamos un ejemplo mas detallado. Queremos los productos que cuestan mas de $100:

SELECT nombre, precio, categoria
FROM productos
WHERE precio > 100;
nombre | precio | categoria
-----------------------+----------+------------
Laptop Pro 15 | 1299.99 | Electronica
Monitor 27 pulgadas | 449.99 | Electronica
Escritorio Ajustable | 399.99 | Muebles
Silla Ergonomica | 299.99 | Muebles

Los operadores de comparacion tambien funcionan con texto y fechas:

-- Clientes registrados despues de una fecha
SELECT nombre, fecha_registro
FROM clientes
WHERE fecha_registro > '2024-01-01';
-- Pedidos con total exacto
SELECT id, total, estado
FROM pedidos
WHERE total = 89.99;
-- Empleados que NO son del departamento de Ventas
SELECT nombre, departamento
FROM empleados
WHERE departamento <> 'Ventas';
💡

Comparacion de texto

Cuando comparas texto con =, la comparacion es exacta. 'Santiago' no es igual a 'santiago' ni a 'Santiago ' (con un espacio extra). Mas adelante veremos ILIKE para busquedas mas flexibles.

Operadores logicos: AND, OR, NOT

¿Que pasa si necesitas combinar varias condiciones? Usas los operadores logicos.

AND: ambas condiciones deben cumplirse

-- Productos de Electronica que cuesten menos de $100
SELECT nombre, precio, categoria
FROM productos
WHERE categoria = 'Electronica'
AND precio < 100;
nombre | precio | categoria
-----------------------+--------+------------
Mouse Inalambrico | 29.99 | Electronica
Teclado Mecanico | 89.99 | Electronica
Auriculares Bluetooth | 59.99 | Electronica
Webcam HD | 79.99 | Electronica

Ambas condiciones deben ser verdaderas: el producto tiene que ser de Electronica y costar menos de 100.

-- Empleados de Tecnologia con salario mayor a 55000
SELECT nombre, departamento, salario
FROM empleados
WHERE departamento = 'Tecnologia'
AND salario > 55000;
nombre | departamento | salario
---------------------+--------------+---------
Miguel Angel Torres | Tecnologia | 62000
Alejandro Vega | Tecnologia | 58000

OR: al menos una condicion debe cumplirse

-- Productos que sean de Electronica O de Muebles
SELECT nombre, precio, categoria
FROM productos
WHERE categoria = 'Electronica'
OR categoria = 'Muebles';

Esto devuelve los productos que cumplan cualquiera de las dos condiciones: si es de Electronica, entra. Si es de Muebles, tambien entra.

-- Pedidos que esten pendientes o enviados
SELECT id, fecha, total, estado
FROM pedidos
WHERE estado = 'pendiente'
OR estado = 'enviado';
id | fecha | total | estado
----+------------+---------+-----------
5 | 2024-02-28 | 1749.98 | enviado
7 | 2024-03-10 | 299.99 | pendiente
8 | 2024-03-15 | 839.98 | enviado
10 | 2024-03-25 | 479.98 | pendiente
12 | 2024-04-05 | 1299.99 | enviado
13 | 2024-04-10 | 69.98 | pendiente

NOT: invierte la condicion

-- Productos que NO son de Electronica
SELECT nombre, categoria
FROM productos
WHERE NOT categoria = 'Electronica';
-- Equivalente a: WHERE categoria <> 'Electronica'

NOT es mas util cuando lo combinas con otros operadores como IN, BETWEEN o LIKE, que veremos en un momento.

Precedencia: AND antes que OR

Aqui viene un error clasico que atrapa a muchos principiantes. AND tiene mayor precedencia que OR, igual que la multiplicacion tiene mayor precedencia que la suma en matematicas.

-- CUIDADO: ¿que hace realmente esta consulta?
SELECT nombre, precio, categoria
FROM productos
WHERE categoria = 'Electronica'
OR categoria = 'Muebles'
AND precio < 50;

Esto NO dice “productos de Electronica o Muebles que cuesten menos de 50”. Lo que realmente dice es:

-- Asi lo interpreta la base de datos:
WHERE categoria = 'Electronica'
OR (categoria = 'Muebles' AND precio < 50)

Es decir: “dame TODOS los de Electronica (sin importar precio) O los de Muebles que cuesten menos de 50”. El AND se evalua primero y solo se aplica a Muebles.

La solucion: usa parentesis para ser explicito:

-- Esto SI hace lo que quieres:
SELECT nombre, precio, categoria
FROM productos
WHERE (categoria = 'Electronica' OR categoria = 'Muebles')
AND precio < 50;
nombre | precio | categoria
-------------------+--------+------------
Mouse Inalambrico | 29.99 | Electronica
Lampara LED | 34.99 | Muebles
Soporte Monitor | 44.99 | Muebles
⚠️

Siempre usa parentesis

Cuando combines AND y OR en la misma consulta, siempre usa parentesis. Incluso si recuerdas las reglas de precedencia, los parentesis hacen que tu intencion sea clara para cualquier persona que lea la consulta (incluyendo tu yo del futuro).

BETWEEN: rangos de valores

BETWEEN es un atajo elegante para filtrar por un rango (incluyendo los extremos):

-- Productos entre $30 y $100 (incluye 30 y 100)
SELECT nombre, precio
FROM productos
WHERE precio BETWEEN 30 AND 100;
nombre | precio
-----------------------+--------
Teclado Mecanico | 89.99
Auriculares Bluetooth | 59.99
Lampara LED | 34.99
Mochila para Laptop | 49.99
Hub USB-C | 39.99
Webcam HD | 79.99
Soporte Monitor | 44.99

Es equivalente a escribir:

WHERE precio >= 30 AND precio <= 100

Pero BETWEEN es mas legible. Funciona tambien con fechas:

-- Pedidos realizados en febrero 2024
SELECT id, cliente_id, fecha, total
FROM pedidos
WHERE fecha BETWEEN '2024-02-01' AND '2024-02-28';
id | cliente_id | fecha | total
----+------------+------------+---------
3 | 3 | 2024-02-01 | 499.98
4 | 1 | 2024-02-14 | 59.99
5 | 4 | 2024-02-28 | 1749.98
-- Empleados con salario entre 40000 y 60000
SELECT nombre, departamento, salario
FROM empleados
WHERE salario BETWEEN 40000 AND 60000;

Y puedes invertirlo con NOT:

-- Productos que NO cuestan entre 30 y 100
SELECT nombre, precio
FROM productos
WHERE precio NOT BETWEEN 30 AND 100;
💡

BETWEEN incluye los extremos

BETWEEN 30 AND 100 incluye tanto 30 como 100. Si necesitas excluir los extremos, usa operadores de comparacion: WHERE precio > 30 AND precio < 100.

IN: listas de valores

Cuando necesitas comparar contra varios valores posibles, IN es mucho mas limpio que encadenar varios OR:

-- Sin IN (funciona pero es verboso)
SELECT nombre, ciudad
FROM clientes
WHERE ciudad = 'Santiago'
OR ciudad = 'Valparaiso'
OR ciudad = 'Concepcion';
-- Con IN (mismo resultado, mucho mas limpio)
SELECT nombre, ciudad
FROM clientes
WHERE ciudad IN ('Santiago', 'Valparaiso', 'Concepcion');
nombre | ciudad
-----------------+------------
Maria Garcia | Santiago
Carlos Lopez | Valparaiso
Ana Martinez | Santiago
Pedro Sanchez | Concepcion
Laura Torres | Santiago
Diego Rivera | Valparaiso
Roberto Diaz | Santiago
Andres Vargas | Concepcion

IN funciona con cualquier tipo de dato:

-- Productos de ciertas categorias
SELECT nombre, categoria, precio
FROM productos
WHERE categoria IN ('Electronica', 'Accesorios');
-- Pedidos especificos por ID
SELECT id, total, estado
FROM pedidos
WHERE id IN (1, 5, 10, 15);
-- Pedidos que no estan completados ni enviados
SELECT id, total, estado
FROM pedidos
WHERE estado NOT IN ('completado', 'enviado');

IN vs OR: mismo resultado

WHERE ciudad IN ('Santiago', 'Valparaiso') produce exactamente el mismo resultado que WHERE ciudad = 'Santiago' OR ciudad = 'Valparaiso'. Internamente PostgreSQL puede incluso convertir uno al otro. La ventaja de IN es puramente de legibilidad, especialmente con listas largas.

LIKE e ILIKE: busqueda por patrones

A veces no sabes el valor exacto que buscas. LIKE te permite buscar usando patrones con dos caracteres especiales:

  • % representa cualquier cantidad de caracteres (cero o mas)
  • _ representa exactamente un caracter
-- Productos cuyo nombre empieza con "M"
SELECT nombre
FROM productos
WHERE nombre LIKE 'M%';
nombre
-------------------
Mouse Inalambrico
Monitor 27 pulgadas
Mochila para Laptop
Mousepad XL
-- Productos cuyo nombre termina en "o"
SELECT nombre
FROM productos
WHERE nombre LIKE '%o';
nombre
-------------------
Mouse Inalambrico
Teclado Mecanico
-- Productos que contienen "USB" en el nombre
SELECT nombre
FROM productos
WHERE nombre LIKE '%USB%';
nombre
-----------
Hub USB-C
-- Emails que contienen un texto especifico
SELECT nombre, email
FROM clientes
WHERE email LIKE '%@email.com';

El guion bajo (_) es para un solo caracter:

-- Nombres de exactamente 4 letras (patron: 4 guiones bajos)
-- Esto buscaria nombres de exactamente 4 caracteres
SELECT nombre
FROM clientes
WHERE nombre LIKE '____';

ILIKE: busqueda sin importar mayusculas/minusculas

LIKE es sensible a mayusculas. Si buscas LIKE 'laptop%', no encontrara “Laptop Pro 15”. Para buscar sin distinguir mayusculas, PostgreSQL tiene ILIKE:

-- LIKE: sensible a mayusculas (no encuentra nada)
SELECT nombre
FROM productos
WHERE nombre LIKE 'laptop%';
-- Resultado: 0 filas
-- ILIKE: ignora mayusculas/minusculas
SELECT nombre
FROM productos
WHERE nombre ILIKE 'laptop%';
-- Resultado: Laptop Pro 15
-- Buscar clientes cuyo nombre contiene "garcia" (sin importar mayusculas)
SELECT nombre, email
FROM clientes
WHERE nombre ILIKE '%garcia%';
nombre | email
---------------+----------------
Maria Garcia | maria@email.com
⚠️

ILIKE es exclusivo de PostgreSQL

ILIKE no existe en el estandar SQL ni en otros motores como MySQL o SQL Server. Es una extension de PostgreSQL. En otros motores se logra de forma distinta (por ejemplo, MySQL compara texto sin distinguir mayusculas por defecto).

LIKE y el uso de indices

Cuando usas LIKE con un patron que empieza con % (como '%laptop%'), la base de datos no puede usar un indice y tiene que revisar todas las filas de la tabla. Esto es lo que llamamos un full table scan.

En cambio, si el patron empieza con texto fijo (como 'Laptop%'), PostgreSQL puede usar un indice para saltar directamente a las filas que empiezan con “Laptop”, lo cual es mucho mas rapido.

Regla general: LIKE 'texto%' puede ser rapido con un indice. LIKE '%texto%' siempre revisa toda la tabla.

IS NULL / IS NOT NULL

En bases de datos, NULL es un valor especial que significa “no hay dato” o “desconocido”. No es lo mismo que cero, no es lo mismo que una cadena vacia, y no es lo mismo que false. Es la ausencia de valor.

Un detalle importante: no puedes comparar con NULL usando =. Tienes que usar IS NULL o IS NOT NULL:

-- INCORRECTO: esto no funciona como esperas
SELECT nombre
FROM empleados
WHERE jefe_id = NULL;
-- Resultado: 0 filas (siempre, incluso si hay NULLs)
-- CORRECTO: usa IS NULL
SELECT nombre
FROM empleados
WHERE jefe_id IS NULL;
-- Resultado: Sofia Mendez (la gerente, no tiene jefe)

¿Por que = NULL no funciona? Porque en SQL, cualquier comparacion con NULL devuelve NULL (ni verdadero ni falso). Es como preguntar “¿es lo desconocido igual a lo desconocido?” La respuesta es: no se sabe.

-- Empleados que SI tienen jefe
SELECT nombre, jefe_id
FROM empleados
WHERE jefe_id IS NOT NULL;
nombre | jefe_id
---------------------+---------
Juan Perez | 1
Valentina Castro | 1
Miguel Angel Torres | 1
Camila Rojas | 4
Alejandro Vega | 4
Fernanda Luna | 2
Ricardo Soto | 4
Patricia Herrera | 8
Daniela Flores | 2
⚠️

La trampa del NULL

Este es uno de los errores mas comunes en SQL. Recuerda estas reglas:

  • NULL = NULL devuelve NULL (no TRUE)
  • NULL <> NULL devuelve NULL (no TRUE)
  • NULL > 5 devuelve NULL
  • Para verificar NULL, siempre usa IS NULL o IS NOT NULL

Combinando todo

Ahora que conoces todas las herramientas de filtrado, puedes combinarlas para hacer consultas precisas:

-- Productos de Electronica entre $50 y $500 con stock mayor a 30
SELECT nombre, precio, stock
FROM productos
WHERE categoria = 'Electronica'
AND precio BETWEEN 50 AND 500
AND stock > 30;
nombre | precio | stock
-----------------------+--------+-------
Teclado Mecanico | 89.99 | 80
Auriculares Bluetooth | 59.99 | 200
Webcam HD | 79.99 | 60
-- Clientes de Santiago o Valparaiso cuyos nombres empiezan con 'C' o 'M'
SELECT nombre, ciudad, email
FROM clientes
WHERE ciudad IN ('Santiago', 'Valparaiso')
AND (nombre LIKE 'C%' OR nombre LIKE 'M%');
nombre | ciudad | email
-----------------+-------------+----------------
Maria Garcia | Santiago | maria@email.com
Carlos Lopez | Valparaiso | carlos@email.com
-- Pedidos completados de mas de $100 en el primer trimestre de 2024
SELECT id, cliente_id, fecha, total
FROM pedidos
WHERE estado = 'completado'
AND total > 100
AND fecha BETWEEN '2024-01-01' AND '2024-03-31';
-- Empleados que no son de Ventas ni de Soporte y ganan mas de 50000
SELECT nombre, departamento, salario
FROM empleados
WHERE departamento NOT IN ('Ventas', 'Soporte')
AND salario > 50000;

Filtrar temprano, filtrar mucho

Imagina una tabla con 10 millones de filas. Si tu WHERE reduce eso a 1000 filas, cualquier operacion posterior (ordenar, agrupar, hacer JOINs) trabaja con 1000 filas en vez de 10 millones.

Conceptualmente, una consulta sin WHERE sobre una tabla de N filas tiene que procesar las N filas completas. Un WHERE con una condicion sobre una columna indexada puede encontrar las filas relevantes en tiempo logaritmico, es decir O(log N) en vez de O(N). Veremos indices mas adelante, pero la leccion por ahora es: cada condicion en WHERE que reduce filas mejora el rendimiento de toda la consulta.

Resumen de operadores

Aqui tienes una referencia rapida de todo lo que vimos:

OperadorDescripcionEjemplo
=Igual aWHERE precio = 29.99
<> o !=Diferente deWHERE estado <> 'pendiente'
>Mayor queWHERE precio > 100
<Menor queWHERE stock < 50
>=Mayor o igualWHERE salario >= 45000
<=Menor o igualWHERE precio <= 50
ANDAmbas condicionesWHERE precio > 10 AND stock > 0
ORAl menos una condicionWHERE ciudad = 'Santiago' OR ciudad = 'Temuco'
NOTInvierte la condicionWHERE NOT categoria = 'Muebles'
BETWEENRango inclusivoWHERE precio BETWEEN 10 AND 100
INLista de valoresWHERE ciudad IN ('Santiago', 'Temuco')
LIKEPatron (sensible a mayusculas)WHERE nombre LIKE 'M%'
ILIKEPatron (sin distinguir mayusculas)WHERE nombre ILIKE '%laptop%'
IS NULLEs nuloWHERE jefe_id IS NULL
IS NOT NULLNo es nuloWHERE email IS NOT NULL

Ejercicios

Ejercicio 1: Encuentra todos los productos que cuestan menos de $50.

Ver solucion
SELECT nombre, precio
FROM productos
WHERE precio < 50;

Ejercicio 2: Muestra los pedidos con estado ‘enviado’ que tengan un total mayor a $500.

Ver solucion
SELECT id, cliente_id, fecha, total, estado
FROM pedidos
WHERE estado = 'enviado'
AND total > 500;

Ejercicio 3: Encuentra todos los clientes que NO viven en Santiago.

Ver solucion
SELECT nombre, ciudad
FROM clientes
WHERE ciudad <> 'Santiago';
-- Tambien valido: WHERE NOT ciudad = 'Santiago'
-- Tambien valido: WHERE ciudad NOT IN ('Santiago')

Ejercicio 4: Busca los productos cuyo nombre contiene la palabra “Monitor” (sin importar mayusculas o minusculas).

Ver solucion
SELECT nombre, precio, categoria
FROM productos
WHERE nombre ILIKE '%monitor%';

Ejercicio 5: Encuentra empleados del departamento de Tecnologia o Ventas que ganen entre 40000 y 60000.

Ver solucion
SELECT nombre, departamento, salario
FROM empleados
WHERE departamento IN ('Tecnologia', 'Ventas')
AND salario BETWEEN 40000 AND 60000;

Ejercicio 6: Muestra los pedidos realizados en marzo de 2024 que esten pendientes o enviados.

Ver solucion
SELECT id, cliente_id, fecha, total, estado
FROM pedidos
WHERE fecha BETWEEN '2024-03-01' AND '2024-03-31'
AND estado IN ('pendiente', 'enviado');

Ejercicio 7: Encuentra al empleado (o empleados) que no tiene jefe. ¿Que cargo crees que tiene esa persona?

Ver solucion
SELECT nombre, departamento, salario
FROM empleados
WHERE jefe_id IS NULL;
-- Resultado: Sofia Mendez, del departamento Gerencia.
-- Es la gerente general, la unica sin jefe.

Ejercicio 8 (desafio): Escribe una consulta que encuentre productos de la categoria ‘Accesorios’ que cuesten menos de $30 O productos de cualquier categoria con stock mayor a 200. Asegurate de que los parentesis esten correctos.

Ver solucion
SELECT nombre, precio, categoria, stock
FROM productos
WHERE (categoria = 'Accesorios' AND precio < 30)
OR stock > 200;

Los parentesis son clave aqui. Sin ellos, el AND se evaluaria primero y el resultado seria diferente. Los productos que cumplen son:

  • Cable HDMI 2m ($12.99, Accesorios) - cumple la primera condicion
  • Mousepad XL ($19.99, Accesorios) - cumple la primera condicion
  • Cargador Rapido ($24.99, Accesorios) - cumple la primera condicion
  • Cable HDMI 2m tambien cumple la segunda (stock: 300)
  • Cargador Rapido tambien cumple la segunda (stock: 250)
05

Ordenar y paginar resultados

Hasta ahora sabemos seleccionar columnas y filtrar filas. Pero los resultados nos llegan en cualquier orden. En este modulo vamos a aprender a ordenar los resultados y a paginarlos para no traer todo de golpe.


ORDER BY: Ordenando resultados

Por defecto, SQL no garantiza ningun orden en los resultados. Si quieres un orden especifico, debes pedirlo explicitamente con ORDER BY.

SELECT nombre, precio
FROM productos
ORDER BY precio;

Esto te devuelve los productos ordenados por precio de menor a mayor (ascendente). Es el comportamiento por defecto.

ASC y DESC

Puedes ser explicito con la direccion del ordenamiento:

-- Ascendente (por defecto)
SELECT nombre, precio
FROM productos
ORDER BY precio ASC;
-- Descendente: del mas caro al mas barato
SELECT nombre, precio
FROM productos
ORDER BY precio DESC;

Un ejemplo practico: quieres ver los clientes mas recientes primero.

SELECT nombre, email, fecha_registro
FROM clientes
ORDER BY fecha_registro DESC;

Consejo practico

ORDER BY columna DESC es muy comun cuando quieres ver “lo mas reciente primero” o “lo mas grande primero”. Lo vas a usar todo el tiempo.


Ordenar por multiples columnas

Puedes ordenar por varias columnas. SQL ordena primero por la primera columna, y cuando hay empates, usa la segunda columna para desempatar.

SELECT nombre, categoria, precio
FROM productos
ORDER BY categoria ASC, precio DESC;

Esto agrupa los productos por categoria (alfabeticamente) y dentro de cada categoria los ordena del mas caro al mas barato. Piensa en ello como: “primero ordena por categoria, y si dos productos tienen la misma categoria, desempata por precio.”

Otro ejemplo: ordenar pedidos por estado y luego por fecha.

SELECT id, cliente_id, estado, fecha, total
FROM pedidos
ORDER BY estado ASC, fecha DESC;

Asi ves todos los pedidos “completado” juntos (los mas recientes primero), luego los “pendiente”, etc.


ORDER BY con numero de posicion

En vez de escribir el nombre de la columna, puedes usar el numero de posicion en el SELECT:

SELECT nombre, precio, categoria
FROM productos
ORDER BY 3, 2 DESC;

Aqui 3 se refiere a categoria (la tercera columna en el SELECT) y 2 se refiere a precio.

⚠️

Cuidado con los numeros de posicion

Esto funciona, pero hace tu consulta mas dificil de leer. Si alguien cambia el orden de las columnas en el SELECT, el ORDER BY se rompe silenciosamente. Prefiere usar nombres de columnas siempre que puedas.


LIMIT: Restringir la cantidad de resultados

No siempre quieres todos los resultados. LIMIT te permite decir “dame solo los primeros N”.

-- Los 5 productos mas caros
SELECT nombre, precio
FROM productos
ORDER BY precio DESC
LIMIT 5;
-- Los 3 clientes mas recientes
SELECT nombre, email, fecha_registro
FROM clientes
ORDER BY fecha_registro DESC
LIMIT 3;
💡

Nota

LIMIT sin ORDER BY te da N filas “cualesquiera”. Como SQL no garantiza orden, no sabes cuales te va a dar. Casi siempre quieres usar LIMIT junto con ORDER BY.


OFFSET: Saltarse filas

OFFSET le dice a la base de datos “saltate las primeras N filas”.

-- Saltate los primeros 5 productos y dame los siguientes 5
SELECT nombre, precio
FROM productos
ORDER BY precio DESC
LIMIT 5 OFFSET 5;

Esto te da los productos del puesto 6 al 10 (ordenados por precio descendente).


El patron LIMIT + OFFSET para paginacion

Este es uno de los patrones mas usados en aplicaciones web. Imagina que tienes una pagina de productos con 10 productos por pagina:

-- Pagina 1 (productos 1-10)
SELECT nombre, precio, categoria
FROM productos
ORDER BY nombre
LIMIT 10 OFFSET 0;
-- Pagina 2 (productos 11-20)
SELECT nombre, precio, categoria
FROM productos
ORDER BY nombre
LIMIT 10 OFFSET 10;
-- Pagina 3 (productos 21-30)
SELECT nombre, precio, categoria
FROM productos
ORDER BY nombre
LIMIT 10 OFFSET 20;

La formula general es: OFFSET = (numero_pagina - 1) * elementos_por_pagina.

Formula de paginacion

Para la pagina P mostrando N elementos: LIMIT N OFFSET (P - 1) * N


NULLS FIRST y NULLS LAST

Cuando ordenas una columna que tiene valores NULL, PostgreSQL los pone al final por defecto en orden ascendente. Puedes controlar esto:

-- NULLs primero
SELECT nombre, precio
FROM productos
ORDER BY precio ASC NULLS FIRST;
-- NULLs al final (explicitamente)
SELECT nombre, precio
FROM productos
ORDER BY precio ASC NULLS LAST;
-- Con DESC, por defecto los NULLs van primero.
-- Si quieres cambiar eso:
SELECT nombre, precio
FROM productos
ORDER BY precio DESC NULLS LAST;
💡

Comportamiento por defecto

En PostgreSQL: ASC pone los NULL al final, DESC los pone al principio. Con NULLS FIRST / NULLS LAST puedes forzar la posicion que prefieras.


Combinando todo

Veamos un ejemplo completo que combina lo que hemos aprendido:

-- Los 10 pedidos mas recientes de estado 'completado',
-- ordenados por total descendente
SELECT p.id, c.nombre AS cliente, p.fecha, p.total
FROM pedidos p
JOIN clientes c ON p.cliente_id = c.id
WHERE p.estado = 'completado'
ORDER BY p.total DESC, p.fecha DESC
LIMIT 10;

No te preocupes por el JOIN todavia (lo veremos en el Modulo 7). Lo importante aqui es ver como WHERE, ORDER BY y LIMIT trabajan juntos.


Rendimiento y complejidad

Vamos a hablar de algo que muchos cursos ignoran: que tan costosas son estas operaciones.

Costo de ORDER BY

ORDER BY requiere ordenar todos los resultados. La complejidad de un ordenamiento es O(n log n) donde n es el numero de filas a ordenar. En tablas pequenas no lo notas, pero si tienes millones de filas, ordenar puede ser muy lento.

LIMIT ayuda… pero tiene limites

Cuando usas LIMIT, la base de datos puede hacer una optimizacion: no necesita ordenar todo, solo encontrar los primeros N elementos. Esto es mas rapido.

-- PostgreSQL puede optimizar esto: no ordena todo,
-- solo busca los 10 mas grandes
SELECT nombre, precio
FROM productos
ORDER BY precio DESC
LIMIT 10;

El problema de la paginacion profunda con OFFSET

Aqui viene el problema serio. Mira estas dos consultas:

-- Pagina 1: rapida
SELECT * FROM productos ORDER BY id LIMIT 10 OFFSET 0;
-- Pagina 100,000: MUY lenta
SELECT * FROM productos ORDER BY id LIMIT 10 OFFSET 999990;

La segunda consulta parece inocente, pero la base de datos tiene que leer y descartar 999,990 filas antes de darte las 10 que pides. El OFFSET es O(n) - tiene que recorrer todas esas filas aunque no las devuelva.

OFFSET profundo es lento

OFFSET 1000000 significa que la base de datos lee un millon de filas para descartarlas. No importa que tu LIMIT sea solo 10. Cuanto mas profunda la paginacion, mas lenta la consulta.

Alternativa: paginacion por keyset (cursor)

La solucion profesional para paginacion profunda es usar keyset pagination (paginacion por cursor). En vez de decir “saltate N filas”, dices “dame filas despues de este valor”:

-- En vez de OFFSET, usamos WHERE con el ultimo ID visto
-- Supongamos que el ultimo producto de la pagina anterior tenia id = 500
SELECT id, nombre, precio
FROM productos
WHERE id > 500
ORDER BY id
LIMIT 10;

Esto es O(log n) si id tiene un indice (casi siempre lo tiene). No importa si estas en la pagina 1 o en la pagina 100,000, la velocidad es la misma.

Cuando usar cada patron

  • LIMIT + OFFSET: Perfecto para pocas paginas (menos de ~100 paginas). Simple de implementar.
  • Keyset pagination: Necesario cuando tienes muchos datos y el usuario puede navegar a paginas profundas. Mas complejo pero mucho mas eficiente.

Ejercicios

Practica con estas consultas usando la base de datos del curso.

Ejercicio 1: Muestra todos los productos ordenados por precio de mayor a menor. Muestra nombre, precio y categoria.

Ver solucion
SELECT nombre, precio, categoria
FROM productos
ORDER BY precio DESC;

Ejercicio 2: Muestra los 5 clientes mas antiguos (los que se registraron primero). Muestra su nombre, email y fecha de registro.

Ver solucion
SELECT nombre, email, fecha_registro
FROM clientes
ORDER BY fecha_registro ASC
LIMIT 5;

Ejercicio 3: Muestra los productos ordenados por categoria (ascendente) y dentro de cada categoria por precio (descendente). Limita a 20 resultados.

Ver solucion
SELECT nombre, categoria, precio
FROM productos
ORDER BY categoria ASC, precio DESC
LIMIT 20;

Ejercicio 4: Simula la pagina 3 de un listado de pedidos (10 pedidos por pagina), ordenados por fecha descendente. Muestra id, fecha, total y estado.

Ver solucion
SELECT id, fecha, total, estado
FROM pedidos
ORDER BY fecha DESC
LIMIT 10 OFFSET 20;

Pagina 3 con 10 elementos por pagina: OFFSET = (3 - 1) * 10 = 20

Ejercicio 5: Muestra los 3 pedidos con el total mas alto que esten en estado ‘pendiente’.

Ver solucion
SELECT id, cliente_id, fecha, total
FROM pedidos
WHERE estado = 'pendiente'
ORDER BY total DESC
LIMIT 3;

Ejercicio 6 (Desafio): Escribe una consulta con keyset pagination. Supongamos que ya viste productos con id hasta el 50. Trae los siguientes 10 productos ordenados por id.

Ver solucion
SELECT id, nombre, precio, categoria
FROM productos
WHERE id > 50
ORDER BY id ASC
LIMIT 10;

Esto es mucho mas eficiente que OFFSET 50 para tablas grandes, ya que usa el indice de la columna id directamente.

06

Funciones de agregacion

Hasta ahora hemos trabajado con filas individuales: seleccionar, filtrar, ordenar. Pero muchas veces necesitas resumenes: cuantos productos hay, cual es el precio promedio, cuanto se vendio en total. Para eso existen las funciones de agregacion.


Las funciones basicas de agregacion

COUNT: Contar filas

COUNT cuenta el numero de filas. Tiene varias formas:

-- Contar TODAS las filas de la tabla
SELECT COUNT(*) FROM productos;
-- Contar filas donde la columna NO es NULL
SELECT COUNT(email) FROM clientes;
-- Contar valores DISTINTOS
SELECT COUNT(DISTINCT categoria) FROM productos;
💡

COUNT(*) vs COUNT(columna)

COUNT(*) cuenta todas las filas, incluyendo las que tienen NULLs. COUNT(columna) solo cuenta filas donde esa columna no es NULL. COUNT(DISTINCT columna) cuenta valores unicos (sin repetir).

Veamos ejemplos mas concretos:

-- Cuantos clientes tenemos?
SELECT COUNT(*) AS total_clientes FROM clientes;
-- Cuantas ciudades distintas tienen nuestros clientes?
SELECT COUNT(DISTINCT ciudad) AS ciudades FROM clientes;
-- Cuantos pedidos estan pendientes?
SELECT COUNT(*) AS pendientes
FROM pedidos
WHERE estado = 'pendiente';

SUM: Sumar valores

SUM suma todos los valores de una columna numerica.

-- Total de ingresos por pedidos completados
SELECT SUM(total) AS ingresos_totales
FROM pedidos
WHERE estado = 'completado';
-- Stock total de todos los productos
SELECT SUM(stock) AS stock_total FROM productos;

AVG: Promedio

AVG calcula el promedio (la media aritmetica).

-- Precio promedio de los productos
SELECT AVG(precio) AS precio_promedio FROM productos;
-- Salario promedio de los empleados
SELECT AVG(salario) AS salario_promedio FROM empleados;
-- Promedio del total de los pedidos
SELECT AVG(total) AS pedido_promedio FROM pedidos;

Redondear el resultado

AVG suele devolver muchos decimales. Puedes usar ROUND para limitar: ROUND(AVG(precio), 2) te da 2 decimales.

MIN y MAX: Extremos

-- Producto mas barato y mas caro
SELECT
MIN(precio) AS precio_minimo,
MAX(precio) AS precio_maximo
FROM productos;
-- Primer y ultimo registro de clientes
SELECT
MIN(fecha_registro) AS primer_cliente,
MAX(fecha_registro) AS ultimo_cliente
FROM clientes;
-- El pedido mas grande
SELECT MAX(total) AS pedido_mas_grande FROM pedidos;

Combinando varias funciones

Puedes usar varias funciones de agregacion en el mismo SELECT:

SELECT
COUNT(*) AS total_productos,
ROUND(AVG(precio), 2) AS precio_promedio,
MIN(precio) AS mas_barato,
MAX(precio) AS mas_caro,
SUM(stock) AS stock_total
FROM productos;

Esto te da un resumen completo de tu tabla de productos en una sola consulta.


GROUP BY: Agrupando filas

Las funciones de agregacion se vuelven realmente poderosas cuando las combinas con GROUP BY. En vez de un solo resumen para toda la tabla, obtienes un resumen por grupo.

-- Cuantos productos hay en cada categoria?
SELECT categoria, COUNT(*) AS cantidad
FROM productos
GROUP BY categoria;

El resultado seria algo como:

categoriacantidad
Electronica6
Accesorios5
Muebles4

Mas ejemplos:

-- Precio promedio por categoria
SELECT
categoria,
ROUND(AVG(precio), 2) AS precio_promedio,
COUNT(*) AS cantidad
FROM productos
GROUP BY categoria;
-- Total de ventas por estado del pedido
SELECT
estado,
COUNT(*) AS cantidad_pedidos,
SUM(total) AS suma_totales,
ROUND(AVG(total), 2) AS promedio_total
FROM pedidos
GROUP BY estado;
-- Cuantos clientes por ciudad?
SELECT ciudad, COUNT(*) AS total_clientes
FROM clientes
GROUP BY ciudad
ORDER BY total_clientes DESC;
⚠️

Regla importante de GROUP BY

Cuando usas GROUP BY, todas las columnas en el SELECT deben ser: (a) columnas por las que agrupas, o (b) funciones de agregacion. No puedes poner una columna suelta que no este en el GROUP BY ni dentro de una funcion de agregacion.

Esto es un error comun:

-- ERROR: "nombre" no esta en GROUP BY ni es una funcion de agregacion
SELECT nombre, categoria, COUNT(*)
FROM productos
GROUP BY categoria;

PostgreSQL te va a dar un error porque nombre no sabe cual devolver: hay muchos nombres por categoria. Tienes que elegir: o lo agregas al GROUP BY, o usas una funcion de agregacion como MIN(nombre), MAX(nombre), etc.


GROUP BY con multiples columnas

Puedes agrupar por mas de una columna. Esto crea grupos mas granulares:

-- Cuantos pedidos por estado Y por mes
SELECT
estado,
EXTRACT(MONTH FROM fecha) AS mes,
COUNT(*) AS cantidad,
SUM(total) AS total_ventas
FROM pedidos
GROUP BY estado, EXTRACT(MONTH FROM fecha)
ORDER BY mes, estado;
-- Salario promedio por departamento
SELECT
departamento,
COUNT(*) AS empleados,
ROUND(AVG(salario), 2) AS salario_promedio,
MIN(salario) AS salario_minimo,
MAX(salario) AS salario_maximo
FROM empleados
GROUP BY departamento
ORDER BY salario_promedio DESC;

HAVING: Filtrar grupos

Ya conoces WHERE para filtrar filas individuales. Pero WHERE se ejecuta antes de agrupar, asi que no puedes usarlo para filtrar basandote en el resultado de una funcion de agregacion.

Para eso existe HAVING: filtra grupos despues de la agregacion.

-- Categorias que tienen mas de 5 productos
SELECT categoria, COUNT(*) AS cantidad
FROM productos
GROUP BY categoria
HAVING COUNT(*) > 5;
-- Ciudades con al menos 3 clientes
SELECT ciudad, COUNT(*) AS total_clientes
FROM clientes
GROUP BY ciudad
HAVING COUNT(*) >= 3
ORDER BY total_clientes DESC;
-- Clientes que han gastado mas de 1000 en total
SELECT
cliente_id,
COUNT(*) AS num_pedidos,
SUM(total) AS gasto_total
FROM pedidos
GROUP BY cliente_id
HAVING SUM(total) > 1000
ORDER BY gasto_total DESC;

WHERE vs HAVING

Esta es una duda muy comun. La diferencia es cuando se aplica cada filtro:

-- WHERE filtra filas ANTES de agrupar
-- HAVING filtra grupos DESPUES de agrupar
SELECT categoria, AVG(precio) AS precio_promedio
FROM productos
WHERE stock > 0 -- solo productos con stock
GROUP BY categoria
HAVING AVG(precio) > 50 -- solo categorias con promedio > 50
ORDER BY precio_promedio DESC;

WHERE vs HAVING en resumen

  • WHERE: Filtra filas individuales ANTES del GROUP BY. No puede usar funciones de agregacion.
  • HAVING: Filtra grupos DESPUES del GROUP BY. SI puede usar funciones de agregacion.
  • Usa WHERE siempre que puedas: es mas eficiente porque reduce filas antes de agrupar.

El orden de ejecucion de una consulta SQL

Este es un concepto clave que te va a ayudar a entender por que las cosas funcionan como funcionan. SQL no se ejecuta en el orden en que lo escribes. El orden real es:

-- Asi lo ESCRIBES:
SELECT categoria, COUNT(*) AS cantidad -- 5
FROM productos -- 1
WHERE precio > 10 -- 2
GROUP BY categoria -- 3
HAVING COUNT(*) > 2 -- 4
ORDER BY cantidad DESC -- 6
LIMIT 5; -- 7

El orden de ejecucion es:

  1. FROM - Determina de que tabla leer
  2. WHERE - Filtra filas individuales
  3. GROUP BY - Agrupa las filas que pasaron el WHERE
  4. HAVING - Filtra los grupos
  5. SELECT - Elige que columnas/expresiones mostrar
  6. ORDER BY - Ordena los resultados
  7. LIMIT - Corta los resultados
💡

Por que importa este orden?

Esto explica por que no puedes usar un alias del SELECT en el WHERE (el SELECT se ejecuta despues), pero SI puedes usarlo en el ORDER BY (se ejecuta despues del SELECT). Tambien explica por que HAVING puede usar funciones de agregacion pero WHERE no.


Rendimiento y complejidad

Costo de las funciones de agregacion

Las funciones de agregacion como COUNT, SUM, AVG deben recorrer todas las filas que coinciden con el filtro. Esto es O(n) donde n es el numero de filas. No hay forma de evitarlo: para sumar, hay que ver cada valor.

GROUP BY y el costo de agrupar

GROUP BY necesita organizar las filas en grupos. PostgreSQL tiene dos estrategias principales:

-- Si hay pocos grupos distintos, PostgreSQL puede usar
-- una tabla hash en memoria: O(n)
SELECT categoria, COUNT(*)
FROM productos
GROUP BY categoria;
-- Si hay muchos grupos o poca memoria, puede necesitar
-- ordenar las filas primero: O(n log n)
SELECT fecha, COUNT(*)
FROM pedidos
GROUP BY fecha;

Agrupar es O(n) o O(n log n)

GROUP BY usa hashing (O(n), mas rapido) o sorting (O(n log n)). PostgreSQL elige la estrategia automaticamente. En general, mientras menos grupos distintos haya, mas eficiente es la operacion.

Tip: filtra con WHERE antes de agrupar

Siempre que puedas, reduce las filas con WHERE antes del GROUP BY. Esto es mucho mas eficiente que agrupar todo y luego filtrar con HAVING:

-- MEJOR: filtra primero, luego agrupa
SELECT categoria, AVG(precio)
FROM productos
WHERE stock > 0 -- reduce filas ANTES de agrupar
GROUP BY categoria;
-- PEOR (si lo que filtras NO depende del grupo):
-- no filtres con HAVING lo que puedes filtrar con WHERE

Ejercicios

Ejercicio 1: Cuenta cuantos productos hay en total y cuantas categorias distintas existen.

Ver solucion
SELECT
COUNT(*) AS total_productos,
COUNT(DISTINCT categoria) AS total_categorias
FROM productos;

Ejercicio 2: Calcula el precio minimo, maximo y promedio de los productos. Redondea el promedio a 2 decimales.

Ver solucion
SELECT
MIN(precio) AS precio_minimo,
MAX(precio) AS precio_maximo,
ROUND(AVG(precio), 2) AS precio_promedio
FROM productos;

Ejercicio 3: Muestra cuantos pedidos hay en cada estado, junto con el total facturado por estado. Ordena por total facturado descendente.

Ver solucion
SELECT
estado,
COUNT(*) AS cantidad_pedidos,
SUM(total) AS total_facturado
FROM pedidos
GROUP BY estado
ORDER BY total_facturado DESC;

Ejercicio 4: Encuentra las categorias de productos que tienen un precio promedio mayor a 100. Muestra la categoria, el precio promedio (redondeado) y la cantidad de productos.

Ver solucion
SELECT
categoria,
ROUND(AVG(precio), 2) AS precio_promedio,
COUNT(*) AS cantidad
FROM productos
GROUP BY categoria
HAVING AVG(precio) > 100
ORDER BY precio_promedio DESC;

Ejercicio 5: Para cada ciudad, muestra cuantos clientes hay, pero solo las ciudades con 2 o mas clientes. Ordena de mayor a menor cantidad.

Ver solucion
SELECT
ciudad,
COUNT(*) AS total_clientes
FROM clientes
GROUP BY ciudad
HAVING COUNT(*) >= 2
ORDER BY total_clientes DESC;

Ejercicio 6: Muestra el departamento, numero de empleados y salario promedio para cada departamento que tenga un salario promedio mayor a 40000.

Ver solucion
SELECT
departamento,
COUNT(*) AS num_empleados,
ROUND(AVG(salario), 2) AS salario_promedio
FROM empleados
GROUP BY departamento
HAVING AVG(salario) > 40000
ORDER BY salario_promedio DESC;

Ejercicio 7 (Desafio): Encuentra los 3 clientes que mas pedidos han hecho. Muestra el cliente_id, el numero de pedidos, y el gasto total. Solo incluye pedidos con estado ‘completado’.

Ver solucion
SELECT
cliente_id,
COUNT(*) AS num_pedidos,
SUM(total) AS gasto_total
FROM pedidos
WHERE estado = 'completado'
GROUP BY cliente_id
ORDER BY num_pedidos DESC
LIMIT 3;

Nota que usamos WHERE para filtrar por estado (es una condicion sobre filas individuales, no sobre grupos), y ORDER BY + LIMIT para quedarnos con los top 3.

07

JOINs: Conectando tablas

Hasta ahora hemos consultado una tabla a la vez. Pero la informacion real vive repartida en varias tablas. Los pedidos estan en una tabla, los clientes en otra, los productos en otra. Para obtener respuestas utiles, necesitamos conectar esas tablas. Eso es exactamente lo que hacen los JOIN.


Por que los datos estan en tablas separadas?

Imagina que guardas todo en una sola tabla gigante: cada pedido tendria el nombre del cliente, su email, su ciudad, el nombre del producto, el precio… repetido una y otra vez. Eso es un desastre:

  • Datos duplicados: el nombre del cliente se repite en cada pedido que hace.
  • Inconsistencia: si el cliente cambia de email, hay que actualizarlo en cientos de filas.
  • Desperdicio: ocupas mucho mas espacio del necesario.

La solucion es la normalizacion: separar los datos en tablas relacionadas. Cada tabla tiene un tema (clientes, productos, pedidos) y se conectan mediante claves foraneas (como cliente_id en la tabla pedidos).

Nuestra base de datos tiene esta estructura:

clientes (id, nombre, email, ciudad, fecha_registro)
|
| cliente_id
v
pedidos (id, cliente_id, fecha, total, estado)
|
| pedido_id
v
detalle_pedidos (id, pedido_id, producto_id, cantidad, precio_unitario)
^
| producto_id
|
productos (id, nombre, precio, categoria, stock, fecha_creacion)
empleados (id, nombre, departamento, salario, fecha_contratacion, jefe_id)
| |
+<-------- jefe_id apunta a otro empleado.id ------------------+

Los JOIN nos permiten reconstruir la informacion completa combinando estas tablas.


INNER JOIN: Solo las coincidencias

INNER JOIN devuelve solo las filas que tienen coincidencia en ambas tablas. Si un cliente no tiene pedidos, no aparece. Si un pedido no tiene cliente valido, tampoco aparece.

Veamos como funciona con un ejemplo visual. Supongamos que tenemos estos datos:

tabla clientes tabla pedidos
┌────┬──────────┐ ┌────┬────────────┬───────┐
│ id │ nombre │ │ id │ cliente_id │ total │
├────┼──────────┤ ├────┼────────────┼───────┤
│ 1 │ Ana │ │ 10 │ 1 │ 500 │
│ 2 │ Bruno │ │ 11 │ 1 │ 300 │
│ 3 │ Carla │ │ 12 │ 3 │ 150 │
└────┴──────────┘ └────┴────────────┴───────┘
Ana tiene 2 pedidos, Carla tiene 1, Bruno no tiene ninguno.

Al hacer INNER JOIN:

SELECT c.nombre, p.id AS pedido_id, p.total
FROM clientes c
INNER JOIN pedidos p ON c.id = p.cliente_id;
Resultado INNER JOIN
┌──────────┬───────────┬───────┐
│ nombre │ pedido_id │ total │
├──────────┼───────────┼───────┤
│ Ana │ 10 │ 500 │
│ Ana │ 11 │ 300 │
│ Carla │ 12 │ 150 │
└──────────┴───────────┴───────┘
Bruno NO aparece (no tiene pedidos).
Ana aparece 2 veces (tiene 2 pedidos).

Ahora con nuestra base de datos de practica:

SELECT
c.nombre AS cliente,
p.id AS pedido_id,
p.fecha,
p.total
FROM clientes c
INNER JOIN pedidos p ON c.id = p.cliente_id;

Desmenucemos la sintaxis:

  • FROM clientes c — tabla principal (o “izquierda”)
  • INNER JOIN pedidos p — tabla que queremos conectar (la “derecha”)
  • ON c.id = p.cliente_id — la condicion de conexion
💡

Nota

INNER JOIN y JOIN son lo mismo. La palabra INNER es opcional, pero es buena practica escribirla para ser explicito.

Paso a paso: como PostgreSQL ejecuta un INNER JOIN

Para que entiendas exactamente que ocurre, sigamos el proceso fila por fila:

Paso 1: PostgreSQL toma la PRIMERA fila de clientes
→ Ana (id = 1)
Paso 2: Busca en pedidos TODAS las filas donde cliente_id = 1
→ Pedido 10 (cliente_id = 1, total = 500) ✓ MATCH
→ Pedido 11 (cliente_id = 1, total = 300) ✓ MATCH
→ Pedido 12 (cliente_id = 3, total = 150) ✗ No coincide
Paso 3: Combina Ana con cada pedido que coincidio
→ Fila resultado: Ana | 10 | 500
→ Fila resultado: Ana | 11 | 300
Paso 4: Toma la SIGUIENTE fila de clientes
→ Bruno (id = 2)
Paso 5: Busca en pedidos donde cliente_id = 2
→ Pedido 10 (cliente_id = 1) ✗
→ Pedido 11 (cliente_id = 1) ✗
→ Pedido 12 (cliente_id = 3) ✗
→ No hay coincidencias → Bruno NO aparece en el resultado
Paso 6: Toma la SIGUIENTE fila de clientes
→ Carla (id = 3)
Paso 7: Busca en pedidos donde cliente_id = 3
→ Pedido 12 (cliente_id = 3, total = 150) ✓ MATCH
→ Fila resultado: Carla | 12 | 150

La regla es simple: sin match, no hay fila. Un INNER JOIN solo produce resultados cuando ambos lados tienen datos que coinciden.

Puedes encadenar multiples JOINs. Aqui conectamos detalle_pedidos con pedidos y con productos:

SELECT
p.id AS pedido_id,
pr.nombre AS producto,
dp.cantidad,
dp.precio_unitario
FROM detalle_pedidos dp
INNER JOIN pedidos p ON dp.pedido_id = p.id
INNER JOIN productos pr ON dp.producto_id = pr.id;

Aliases de tablas: Escribir menos

Escribir clientes.nombre y pedidos.fecha una y otra vez es tedioso. Usa aliases para abreviar:

SELECT
c.nombre AS cliente,
p.id AS pedido_id,
p.fecha,
p.total
FROM clientes c
INNER JOIN pedidos p ON c.id = p.cliente_id;

clientes c le da el alias c a la tabla clientes. Ahora puedes escribir c.nombre en vez de clientes.nombre. Esto no cambia el resultado, solo hace la consulta mas corta y legible.

Convencion de aliases

Una convencion comun es usar la primera letra de la tabla: c para clientes, p para pedidos, pr o prod para productos, dp para detalle_pedidos, e para empleados. Lo importante es que sea consistente y claro.


LEFT JOIN: Todo de la tabla izquierda

LEFT JOIN (o LEFT OUTER JOIN) devuelve todas las filas de la tabla izquierda, incluso si no tienen coincidencia en la tabla derecha. Donde no hay coincidencia, las columnas de la derecha seran NULL.

Usando los mismos datos del ejemplo anterior:

tabla clientes tabla pedidos
┌────┬──────────┐ ┌────┬────────────┬───────┐
│ id │ nombre │ │ id │ cliente_id │ total │
├────┼──────────┤ ├────┼────────────┼───────┤
│ 1 │ Ana │ │ 10 │ 1 │ 500 │
│ 2 │ Bruno │ │ 11 │ 1 │ 300 │
│ 3 │ Carla │ │ 12 │ 3 │ 150 │
└────┴──────────┘ └────┴────────────┴───────┘
SELECT c.nombre, p.id AS pedido_id, p.total
FROM clientes c
LEFT JOIN pedidos p ON c.id = p.cliente_id;
Resultado LEFT JOIN
┌──────────┬───────────┬───────┐
│ nombre │ pedido_id │ total │
├──────────┼───────────┼───────┤
│ Ana │ 10 │ 500 │
│ Ana │ 11 │ 300 │
│ Bruno │ NULL │ NULL │ ← Bruno aparece con NULLs
│ Carla │ 12 │ 150 │
└──────────┴───────────┴───────┘
TODOS los clientes aparecen, tengan o no pedidos.

Paso a paso: como PostgreSQL ejecuta un LEFT JOIN

La diferencia con INNER JOIN es sutil pero crucial:

Paso 1: PostgreSQL toma la PRIMERA fila de clientes
→ Ana (id = 1)
Paso 2: Busca en pedidos donde cliente_id = 1
→ Pedido 10 ✓ MATCH → Fila: Ana | 10 | 500
→ Pedido 11 ✓ MATCH → Fila: Ana | 11 | 300
Paso 3: Toma la SIGUIENTE fila de clientes
→ Bruno (id = 2)
Paso 4: Busca en pedidos donde cliente_id = 2
→ No hay coincidencias...
→ Pero es LEFT JOIN, asi que Bruno NO se descarta.
→ En vez de eliminarlo, PostgreSQL rellena con NULLs:
→ Fila: Bruno | NULL | NULL ← ¡Esta es la diferencia!
Paso 5: Toma la SIGUIENTE fila de clientes
→ Carla (id = 3)
Paso 6: Busca en pedidos donde cliente_id = 3
→ Pedido 12 ✓ MATCH → Fila: Carla | 12 | 150

La diferencia clave: donde INNER JOIN descarta las filas sin match, LEFT JOIN las conserva rellenando con NULLs. Esto garantiza que nunca pierdas datos de la tabla izquierda.

Ahora con nuestra base de datos:

-- TODOS los clientes, tengan o no pedidos
SELECT
c.nombre AS cliente,
c.email,
p.id AS pedido_id,
p.total
FROM clientes c
LEFT JOIN pedidos p ON c.id = p.cliente_id;

Patron: encontrar registros sin relacion

LEFT JOIN + WHERE tabla_derecha.id IS NULL es un patron clasico para encontrar registros huerfanos:

-- Clientes que NUNCA han hecho un pedido
SELECT c.nombre, c.email
FROM clientes c
LEFT JOIN pedidos p ON c.id = p.cliente_id
WHERE p.id IS NULL;
-- Productos que nunca se han vendido
SELECT pr.nombre, pr.precio, pr.categoria
FROM productos pr
LEFT JOIN detalle_pedidos dp ON pr.id = dp.producto_id
WHERE dp.id IS NULL;
-- Todos los productos con su cantidad total vendida (0 si nunca se vendieron)
SELECT
pr.nombre,
pr.precio,
COALESCE(SUM(dp.cantidad), 0) AS total_vendido
FROM productos pr
LEFT JOIN detalle_pedidos dp ON pr.id = dp.producto_id
GROUP BY pr.id, pr.nombre, pr.precio
ORDER BY total_vendido DESC;
💡

COALESCE para manejar NULLs

COALESCE(valor, 0) devuelve valor si no es NULL, o 0 si es NULL. Muy util con LEFT JOIN para convertir NULLs en ceros.


RIGHT JOIN: Todo de la tabla derecha

RIGHT JOIN (o RIGHT OUTER JOIN) es el espejo del LEFT JOIN: mantiene todas las filas de la tabla derecha, incluso si no tienen coincidencia en la izquierda. Donde no hay coincidencia, las columnas de la izquierda seran NULL.

Veamoslo con un ejemplo donde hay un pedido con un cliente_id que no existe:

tabla clientes tabla pedidos
┌────┬──────────┐ ┌────┬────────────┬───────┐
│ id │ nombre │ │ id │ cliente_id │ total │
├────┼──────────┤ ├────┼────────────┼───────┤
│ 1 │ Ana │ │ 10 │ 1 │ 500 │
│ 2 │ Bruno │ │ 11 │ 1 │ 300 │
│ 3 │ Carla │ │ 12 │ 3 │ 150 │
└────┴──────────┘ │ 13 │ 99 │ 200 │ ← cliente_id 99 no existe
└────┴────────────┴───────┘
SELECT c.nombre, p.id AS pedido_id, p.total
FROM clientes c
RIGHT JOIN pedidos p ON c.id = p.cliente_id;
Resultado RIGHT JOIN
┌──────────┬───────────┬───────┐
│ nombre │ pedido_id │ total │
├──────────┼───────────┼───────┤
│ Ana │ 10 │ 500 │
│ Ana │ 11 │ 300 │
│ Carla │ 12 │ 150 │
│ NULL │ 13 │ 200 │ ← pedido 13 aparece, pero sin cliente
└──────────┴───────────┴───────┘
TODOS los pedidos aparecen. El pedido 13 tiene un cliente_id
invalido, asi que nombre es NULL.
Bruno NO aparece (no tiene pedidos y no es la tabla "protegida").

Compara con LEFT JOIN para ver la diferencia:

LEFT JOIN protege la tabla IZQUIERDA (clientes):
Ana ✓ Bruno ✓ (con NULLs) Carla ✓ Pedido 13 ✗ (desaparece)
RIGHT JOIN protege la tabla DERECHA (pedidos):
Pedido 10 ✓ Pedido 11 ✓ Pedido 12 ✓ Pedido 13 ✓ (con NULLs)
Bruno ✗ (desaparece)

Un RIGHT JOIN siempre se puede reescribir como LEFT JOIN invirtiendo el orden de las tablas:

-- Esto con RIGHT JOIN...
SELECT c.nombre, p.id, p.total
FROM clientes c
RIGHT JOIN pedidos p ON c.id = p.cliente_id;
-- ...es IDENTICO a esto con LEFT JOIN (tablas invertidas)
SELECT c.nombre, p.id, p.total
FROM pedidos p
LEFT JOIN clientes c ON c.id = p.cliente_id;

Tip

En la practica, casi nadie usa RIGHT JOIN. Simplemente inviertes el orden de las tablas y usas LEFT JOIN. Es mas facil de leer y mas consistente. Si ves un RIGHT JOIN en codigo existente, ya sabes que es lo mismo que un LEFT JOIN al reves.


FULL OUTER JOIN: Todo de ambos lados

FULL OUTER JOIN devuelve todas las filas de ambas tablas. Donde hay coincidencia, las combina. Donde no, pone NULLs en las columnas de la tabla que no tiene coincidencia.

tabla clientes tabla pedidos
┌────┬──────────┐ ┌────┬────────────┬───────┐
│ id │ nombre │ │ id │ cliente_id │ total │
├────┼──────────┤ ├────┼────────────┼───────┤
│ 1 │ Ana │ │ 10 │ 1 │ 500 │
│ 2 │ Bruno │ │ 11 │ 1 │ 300 │
│ 3 │ Carla │ │ 12 │ 3 │ 150 │
└────┴──────────┘ │ 13 │ 99 │ 200 │ ← cliente_id 99 no existe
└────┴────────────┴───────┘
SELECT c.nombre, p.id AS pedido_id, p.total
FROM clientes c
FULL OUTER JOIN pedidos p ON c.id = p.cliente_id;
Resultado FULL OUTER JOIN
┌──────────┬───────────┬───────┐
│ nombre │ pedido_id │ total │
├──────────┼───────────┼───────┤
│ Ana │ 10 │ 500 │
│ Ana │ 11 │ 300 │
│ Bruno │ NULL │ NULL │ ← cliente sin pedidos
│ Carla │ 12 │ 150 │
│ NULL │ 13 │ 200 │ ← pedido sin cliente valido
└──────────┴───────────┴───────┘
Aparece TODO: clientes sin pedidos Y pedidos sin cliente.

Con nuestra base de datos:

SELECT
c.nombre AS cliente,
p.id AS pedido_id,
p.total
FROM clientes c
FULL OUTER JOIN pedidos p ON c.id = p.cliente_id;

En la practica, FULL OUTER JOIN se usa poco comparado con INNER JOIN o LEFT JOIN, pero es muy util para auditorias o para encontrar inconsistencias en los datos.


CROSS JOIN: Todas las combinaciones posibles

CROSS JOIN genera el producto cartesiano: combina cada fila de la tabla izquierda con todas las filas de la tabla derecha. No usa clausula ON porque no necesita condicion de match — simplemente combina todo con todo.

tabla colores tabla tallas
┌───────────┐ ┌──────────┐
│ color │ │ talla │
├───────────┤ ├──────────┤
│ Rojo │ │ S │
│ Azul │ │ M │
└───────────┘ │ L │
└──────────┘
SELECT color, talla
FROM colores
CROSS JOIN tallas;
Resultado CROSS JOIN (2 × 3 = 6 filas)
┌───────────┬──────────┐
│ color │ talla │
├───────────┼──────────┤
│ Rojo │ S │
│ Rojo │ M │
│ Rojo │ L │
│ Azul │ S │
│ Azul │ M │
│ Azul │ L │
└───────────┴──────────┘
Cada color se combina con CADA talla. Sin excepciones.
⚠️

Cuidado con el tamaño

Si la tabla A tiene 1,000 filas y la tabla B tiene 1,000 filas, un CROSS JOIN genera 1,000,000 de filas. Usalo solo con tablas pequenas o con un proposito claro.

Ejemplo practico con nuestra BD

Un uso comun es generar combinaciones para reportes. Por ejemplo, queremos asegurar que todas las categorias aparezcan para cada ciudad, incluso si no hubo ventas:

-- Generar todas las combinaciones categoria × ciudad
SELECT DISTINCT pr.categoria, c.ciudad
FROM productos pr
CROSS JOIN clientes c
ORDER BY pr.categoria, c.ciudad;

Otro ejemplo practico: comparar cada producto contra el promedio de su categoria:

-- Promedio por categoria usando CROSS JOIN con una subconsulta
SELECT
p.nombre,
p.precio,
p.categoria,
avg_cat.promedio,
ROUND(p.precio - avg_cat.promedio, 2) AS diferencia
FROM productos p
INNER JOIN (
SELECT categoria, ROUND(AVG(precio), 2) AS promedio
FROM productos
GROUP BY categoria
) avg_cat ON p.categoria = avg_cat.categoria
ORDER BY p.categoria, diferencia DESC;
💡

Sintaxis alternativa

CROSS JOIN tambien se puede escribir con coma en el FROM: FROM tabla_a, tabla_b. Son equivalentes, pero CROSS JOIN es mas explicito y claro sobre tu intencion.


Resumen visual: todos los JOINs de un vistazo

Imagina dos tablas como circulos. La zona de interseccion son las filas que coinciden. Cada JOIN “ilumina” una parte distinta:

INNER JOIN

table1table2

LEFT JOIN

table1table2

RIGHT JOIN

table1table2

FULL OUTER JOIN

table1table2

Cuando usar cada JOIN

Comando Descripcion
INNER JOIN Necesitas solo los datos que tienen match en ambas tablas. Es el JOIN mas comun. Ejemplo: 'clientes que SI tienen pedidos'.
LEFT JOIN Necesitas TODOS los registros de la tabla principal, tengan o no match. Ejemplo: 'todos los clientes, con o sin pedidos'. Tambien para encontrar registros sin relacion (con WHERE ... IS NULL).
RIGHT JOIN Casi nunca. Puedes invertir las tablas y usar LEFT JOIN, que es mas legible.
FULL OUTER JOIN Para auditorias: encontrar registros huerfanos en AMBAS tablas. Poco comun en consultas del dia a dia.
CROSS JOIN Genera TODAS las combinaciones posibles (producto cartesiano). Util para generar combinaciones, calendarios, o tablas de referencia. Rara vez se usa con datos transaccionales.

Regla rapida

Si la pregunta incluye la palabra “todos” (todos los clientes, todos los productos), probablemente necesitas LEFT JOIN. Si solo te interesan los que tienen datos relacionados, usa INNER JOIN.


Self JOIN: Una tabla consigo misma

A veces necesitas conectar una tabla consigo misma. El ejemplo clasico es la tabla empleados donde jefe_id apunta al id de otro empleado.

-- Ver cada empleado junto con el nombre de su jefe
SELECT
e.nombre AS empleado,
e.departamento,
j.nombre AS jefe
FROM empleados e
LEFT JOIN empleados j ON e.jefe_id = j.id;

Aqui usamos la tabla empleados dos veces: una como e (el empleado) y otra como j (el jefe). El LEFT JOIN asegura que aparezcan todos los empleados, incluso el jefe superior que no tiene jefe (su jefe_id seria NULL).

-- Empleados que ganan mas que su jefe
SELECT
e.nombre AS empleado,
e.salario AS salario_empleado,
j.nombre AS jefe,
j.salario AS salario_jefe
FROM empleados e
INNER JOIN empleados j ON e.jefe_id = j.id
WHERE e.salario > j.salario;

Self JOIN requiere aliases

Cuando haces un self JOIN, los aliases son obligatorios. Sin ellos, PostgreSQL no sabe a cual de las dos “copias” de la tabla te refieres.


JOIN con multiples condiciones y ON vs WHERE

La clausula ON puede tener mas de una condicion, conectadas con AND:

SELECT
p.id AS pedido_id,
p.fecha,
pr.nombre AS producto,
dp.cantidad,
dp.precio_unitario
FROM pedidos p
INNER JOIN detalle_pedidos dp ON dp.pedido_id = p.id
INNER JOIN productos pr ON dp.producto_id = pr.id
AND pr.categoria = 'Electronica'
WHERE p.fecha >= '2024-01-01';
⚠️

ON vs WHERE: diferencia critica con LEFT JOIN

Con INNER JOIN, poner la condicion en el ON o en el WHERE da el mismo resultado. Pero con LEFT JOIN, es muy diferente: una condicion en el ON filtra antes de hacer el join (sin perder filas de la izquierda), mientras que en el WHERE filtra despues (y puede eliminar filas de la izquierda).

Este es uno de los errores mas frecuentes con JOINs. Veamos la diferencia:

-- Condicion en ON:
-- Muestra TODOS los clientes, pero solo sus pedidos completados
SELECT c.nombre, p.id, p.estado
FROM clientes c
LEFT JOIN pedidos p ON c.id = p.cliente_id
AND p.estado = 'completado';
-- Clientes sin pedidos completados aparecen con NULLs ✓
-- Condicion en WHERE:
-- Solo muestra clientes que TENGAN pedidos completados
SELECT c.nombre, p.id, p.estado
FROM clientes c
LEFT JOIN pedidos p ON c.id = p.cliente_id
WHERE p.estado = 'completado';
-- Clientes sin pedidos completados DESAPARECEN ✗
-- (porque p.estado seria NULL, y NULL != 'completado')

¿Por que la segunda consulta pierde filas? Porque WHERE se aplica despues del JOIN. Para un cliente sin pedidos completados, p.estado es NULL, y NULL = 'completado' es falso. Entonces esa fila se descarta, y el LEFT JOIN se comporta como si fuera un INNER JOIN.

⚠️

LEFT JOIN que se convierte en INNER JOIN

Si filtras por una columna de la tabla derecha en el WHERE (por ejemplo, WHERE p.estado = 'completado'), estas eliminando todas las filas donde esa columna es NULL, lo que cancela el efecto del LEFT JOIN. Si necesitas filtrar la tabla derecha sin perder filas de la izquierda, pon la condicion en el ON.


La trampa de los duplicados

Esta es probablemente la fuente de errores mas frustrante para quienes empiezan con JOINs. Cuando haces un JOIN, las filas se pueden multiplicar, y si no lo tienes en cuenta, tus conteos y sumas seran incorrectos.

Por que los JOINs generan filas repetidas

Un JOIN combina cada fila de la tabla izquierda con cada fila que coincida en la tabla derecha. Si un cliente tiene 3 pedidos, ese cliente aparece 3 veces en el resultado:

SELECT c.nombre, p.id AS pedido_id, p.total
FROM clientes c
INNER JOIN pedidos p ON c.id = p.cliente_id
WHERE c.nombre = 'Ana Torres';
┌─────────────┬───────────┬────────┐
│ nombre │ pedido_id │ total │
├─────────────┼───────────┼────────┤
│ Ana Torres │ 1 │ 150.50 │
│ Ana Torres │ 4 │ 89.99 │
│ Ana Torres │ 7 │ 320.00 │
└─────────────┴───────────┴────────┘
Ana aparece 3 veces porque tiene 3 pedidos.
Esto es CORRECTO — cada fila es un pedido distinto.

Hasta aqui todo bien. El problema aparece cuando agregas otro JOIN que multiplica las filas aun mas.

El efecto multiplicador con multiples JOINs

Supongamos que Ana tiene 3 pedidos, y cada pedido tiene 2 productos en detalle_pedidos. Si hacemos JOIN de pedidos con detalle_pedidos:

SELECT
c.nombre,
p.id AS pedido_id,
p.total,
dp.producto_id,
dp.cantidad
FROM clientes c
INNER JOIN pedidos p ON c.id = p.cliente_id
INNER JOIN detalle_pedidos dp ON p.id = dp.pedido_id
WHERE c.nombre = 'Ana Torres';
┌─────────────┬───────────┬────────┬─────────────┬──────────┐
│ nombre │ pedido_id │ total │ producto_id │ cantidad │
├─────────────┼───────────┼────────┼─────────────┼──────────┤
│ Ana Torres │ 1 │ 150.50 │ 3 │ 2 │
│ Ana Torres │ 1 │ 150.50 │ 7 │ 1 │ ← pedido 1: 2 lineas
│ Ana Torres │ 4 │ 89.99 │ 1 │ 3 │
│ Ana Torres │ 4 │ 89.99 │ 5 │ 1 │ ← pedido 4: 2 lineas
│ Ana Torres │ 7 │ 320.00 │ 2 │ 1 │
│ Ana Torres │ 7 │ 320.00 │ 8 │ 2 │ ← pedido 7: 2 lineas
└─────────────┴───────────┴────────┴─────────────┴──────────┘
3 pedidos × 2 detalles cada uno = 6 filas.
El total de cada pedido se REPITE por cada linea de detalle.

Observa que p.total aparece repetido. El pedido 1 tiene total = 150.50, pero esa fila aparece 2 veces (una por cada producto en ese pedido). Esto es correcto en el resultado del JOIN, pero se convierte en problema cuando haces calculos.

SUM inflado: el error clasico

¿Que pasa si sumas p.total en la consulta anterior?

-- INCORRECTO: SUM inflada por duplicados
SELECT
c.nombre,
SUM(p.total) AS gasto_total
FROM clientes c
INNER JOIN pedidos p ON c.id = p.cliente_id
INNER JOIN detalle_pedidos dp ON p.id = dp.pedido_id
WHERE c.nombre = 'Ana Torres'
GROUP BY c.nombre;
Resultado INCORRECTO
┌─────────────┬─────────────┐
│ nombre │ gasto_total │
├─────────────┼─────────────┤
│ Ana Torres │ 1120.98 │ ← INCORRECTO!
└─────────────┴─────────────┘
Lo que SUM esta sumando:
150.50 + 150.50 + 89.99 + 89.99 + 320.00 + 320.00 = 1120.98
El valor correcto seria:
150.50 + 89.99 + 320.00 = 560.49

Cada total se sumo tantas veces como lineas de detalle tiene ese pedido. El resultado esta duplicado (en este caso, exactamente el doble porque cada pedido tenia 2 detalles).

⚠️

Regla de oro

Cuando haces JOIN entre tablas con relacion uno-a-muchos y luego usas SUM o COUNT, verifica que no estas sumando valores de la tabla ‘uno’ que se repiten por la tabla ‘muchos’. Si un pedido aparece N veces en el resultado (por tener N detalles), SUM(p.total) sumara ese total N veces.

Como corregirlo

Opcion 1: Quitar el JOIN que causa la multiplicacion (si no lo necesitas)

-- CORRECTO: si solo necesitas el total por cliente, no necesitas detalle_pedidos
SELECT
c.nombre,
SUM(p.total) AS gasto_total
FROM clientes c
INNER JOIN pedidos p ON c.id = p.cliente_id
WHERE c.nombre = 'Ana Torres'
GROUP BY c.nombre;

Opcion 2: Usar COUNT(DISTINCT) y SUM sobre valores unicos

-- CORRECTO: contar pedidos unicos con DISTINCT
SELECT
c.nombre,
COUNT(DISTINCT p.id) AS num_pedidos,
-- Para SUM necesitamos otra estrategia (ver opcion 3)
COUNT(*) AS filas_en_resultado
FROM clientes c
INNER JOIN pedidos p ON c.id = p.cliente_id
INNER JOIN detalle_pedidos dp ON p.id = dp.pedido_id
GROUP BY c.nombre;
⚠️

SUM(DISTINCT) no es la solucion

Podrias pensar que SUM(DISTINCT p.total) resuelve el problema. No siempre. Si dos pedidos tienen el mismo total (por ejemplo, ambos son 150.50), SUM(DISTINCT) contaria ese valor una sola vez en lugar de dos, dandote un resultado menor al correcto. DISTINCT elimina duplicados de valor, no duplicados de fila.

Opcion 3: Pre-agregar con una subconsulta o CTE (la solucion mas segura)

-- CORRECTO: primero calcula totales por cliente, luego haz JOIN
WITH totales_cliente AS (
SELECT
cliente_id,
COUNT(*) AS num_pedidos,
SUM(total) AS gasto_total
FROM pedidos
GROUP BY cliente_id
)
SELECT
c.nombre,
tc.num_pedidos,
tc.gasto_total
FROM clientes c
INNER JOIN totales_cliente tc ON c.id = tc.cliente_id
ORDER BY tc.gasto_total DESC;

Al pre-agregar en el CTE, cada cliente aparece una sola vez en totales_cliente, y el JOIN posterior no genera duplicados.

Como detectar que tienes duplicados

Antes de confiar en cualquier SUM o COUNT, haz estas verificaciones:

-- Verificacion rapida: ¿hay mas filas de las esperadas?
-- Si tienes 20 pedidos pero tu JOIN devuelve 50 filas, hay multiplicacion.
SELECT COUNT(*) AS filas_totales
FROM clientes c
INNER JOIN pedidos p ON c.id = p.cliente_id
INNER JOIN detalle_pedidos dp ON p.id = dp.pedido_id;
-- Compara con COUNT(DISTINCT)
SELECT
COUNT(*) AS filas_totales,
COUNT(DISTINCT p.id) AS pedidos_unicos,
COUNT(DISTINCT c.id) AS clientes_unicos
FROM clientes c
INNER JOIN pedidos p ON c.id = p.cliente_id
INNER JOIN detalle_pedidos dp ON p.id = dp.pedido_id;
-- Si filas_totales > pedidos_unicos, hay multiplicacion
-- Revisa el resultado crudo con LIMIT antes de agregar
SELECT c.nombre, p.id AS pedido_id, p.total, dp.id AS detalle_id
FROM clientes c
INNER JOIN pedidos p ON c.id = p.cliente_id
INNER JOIN detalle_pedidos dp ON p.id = dp.pedido_id
ORDER BY c.nombre, p.id
LIMIT 20;
-- Busca filas donde pedido_id y total se repiten

Habito de verificacion

Antes de agregar SUM() o COUNT(*) a una consulta con multiples JOINs, siempre ejecuta primero el SELECT sin agregacion y con LIMIT para ver las filas crudas. Asi puedes detectar visualmente si hay filas multiplicadas.


Errores comunes con JOINs

1. Olvidar la clausula ON

-- PELIGRO: sin ON, esto genera un producto cartesiano
-- Si tienes 100 clientes y 500 pedidos, obtienes 50,000 filas!
SELECT c.nombre, p.total
FROM clientes c
INNER JOIN pedidos p;
-- Falta: ON c.id = p.cliente_id
⚠️

Producto cartesiano accidental

Si olvidas el ON, cada fila de una tabla se combina con TODAS las filas de la otra. Con tablas de miles de filas, esto puede colgar tu base de datos. Siempre verifica que tu JOIN tenga una clausula ON.

2. Columnas ambiguas

-- ERROR: "id" existe en ambas tablas. Cual quieres?
SELECT id, nombre
FROM clientes c
INNER JOIN pedidos p ON c.id = p.cliente_id;
-- CORRECTO: especifica la tabla
SELECT c.id, c.nombre, p.id AS pedido_id
FROM clientes c
INNER JOIN pedidos p ON c.id = p.cliente_id;

3. JOIN en la columna equivocada

-- INCORRECTO: esto compara IDs que no estan relacionados
SELECT *
FROM clientes c
INNER JOIN productos pr ON c.id = pr.id;
-- Esto "funciona" pero no tiene sentido logico
-- CORRECTO: usa las relaciones reales de tu modelo
SELECT *
FROM clientes c
INNER JOIN pedidos p ON c.id = p.cliente_id;

4. LEFT JOIN que se convierte en INNER JOIN

-- INCORRECTO: el WHERE anula el LEFT JOIN
SELECT c.nombre, p.total, p.estado
FROM clientes c
LEFT JOIN pedidos p ON c.id = p.cliente_id
WHERE p.estado = 'completado';
-- Clientes sin pedidos completados desaparecen
-- porque p.estado es NULL para ellos, y NULL != 'completado'
-- CORRECTO: pon la condicion en el ON
SELECT c.nombre, p.total, p.estado
FROM clientes c
LEFT JOIN pedidos p ON c.id = p.cliente_id
AND p.estado = 'completado';
-- Clientes sin pedidos completados aparecen con NULLs

Ejemplo completo: consulta multi-JOIN

Vamos a armar una consulta que conecte varias tablas para obtener un informe completo:

-- Informe detallado de ventas: que compro cada cliente
SELECT
c.nombre AS cliente,
c.ciudad,
p.id AS pedido_id,
p.fecha,
pr.nombre AS producto,
pr.categoria,
dp.cantidad,
dp.precio_unitario,
(dp.cantidad * dp.precio_unitario) AS subtotal
FROM clientes c
INNER JOIN pedidos p ON c.id = p.cliente_id
INNER JOIN detalle_pedidos dp ON p.id = dp.pedido_id
INNER JOIN productos pr ON dp.producto_id = pr.id
WHERE p.estado = 'completado'
ORDER BY c.nombre, p.fecha DESC;

Y un resumen por cliente usando un CTE para evitar el problema de duplicados:

-- Total gastado por cada cliente (sin riesgo de duplicados)
WITH resumen_pedidos AS (
SELECT
cliente_id,
COUNT(*) AS num_pedidos,
SUM(total) AS gasto_total,
ROUND(AVG(total), 2) AS pedido_promedio
FROM pedidos
WHERE estado = 'completado'
GROUP BY cliente_id
)
SELECT
c.nombre AS cliente,
c.ciudad,
rp.num_pedidos,
rp.gasto_total,
rp.pedido_promedio
FROM clientes c
INNER JOIN resumen_pedidos rp ON c.id = rp.cliente_id
WHERE rp.gasto_total > 500
ORDER BY rp.gasto_total DESC;

Rendimiento de los JOINs

Complejidad de los JOINs

En el peor caso, un JOIN entre una tabla de n filas y otra de m filas es O(n * m) (nested loop: comparar cada fila con cada fila). Con tablas de 10,000 filas cada una, eso podria ser 100 millones de comparaciones. Por eso los indices son tan importantes.

Estrategias de JOIN que usa PostgreSQL

PostgreSQL elige automaticamente entre tres estrategias:

1. Nested Loop Join — Para cada fila de la tabla A, busca coincidencias en la tabla B.

  • Sin indice: O(n * m) — lento
  • Con indice: O(n * log m) — mucho mejor
  • Bueno cuando una tabla es pequena

2. Hash Join — Construye una tabla hash con una tabla y busca las coincidencias de la otra.

  • Complejidad: O(n + m) — muy eficiente
  • Necesita memoria para la tabla hash
  • Bueno cuando ambas tablas son grandes

3. Merge Join — Ordena ambas tablas y las recorre en paralelo.

  • Complejidad: O(n log n + m log m) para ordenar, luego O(n + m) para el merge
  • Bueno cuando los datos ya estan ordenados (por un indice)
-- Puedes ver que estrategia elige PostgreSQL con EXPLAIN
EXPLAIN SELECT c.nombre, p.total
FROM clientes c
INNER JOIN pedidos p ON c.id = p.cliente_id;

Lo mas importante para JOINs rapidos

Asegurate de que las columnas en tu clausula ON tengan indices. Las claves primarias (id) ya tienen indice automaticamente. Las claves foraneas (cliente_id, pedido_id, producto_id) deberias agregarles un indice si no lo tienen. Esto puede hacer la diferencia entre un JOIN que tarda milisegundos y uno que tarda minutos.

Consejo practico: minimiza las filas antes del JOIN

-- PostgreSQL generalmente optimiza esto automaticamente,
-- pero reducir las filas antes del JOIN siempre ayuda.
SELECT c.nombre, p.total
FROM pedidos p
INNER JOIN clientes c ON p.cliente_id = c.id
WHERE p.fecha >= '2024-01-01'
AND p.estado = 'completado';

Ejercicios

Ejercicio 1: Muestra el nombre de cada cliente junto con la fecha y total de sus pedidos. Usa INNER JOIN.

Ver solucion
SELECT
c.nombre AS cliente,
p.fecha,
p.total
FROM clientes c
INNER JOIN pedidos p ON c.id = p.cliente_id
ORDER BY c.nombre, p.fecha;

Ejercicio 2: Muestra TODOS los clientes y cuantos pedidos tiene cada uno (incluyendo los que tienen 0 pedidos).

Ver solucion
SELECT
c.nombre AS cliente,
COUNT(p.id) AS num_pedidos
FROM clientes c
LEFT JOIN pedidos p ON c.id = p.cliente_id
GROUP BY c.id, c.nombre
ORDER BY num_pedidos DESC;

Usamos LEFT JOIN para incluir clientes sin pedidos, y COUNT(p.id) en vez de COUNT(*) para que los clientes sin pedidos tengan 0 (porque p.id sera NULL y COUNT no cuenta NULLs).

Ejercicio 3: Encuentra todos los productos que nunca se han vendido (no aparecen en detalle_pedidos).

Ver solucion
SELECT pr.nombre, pr.precio, pr.categoria
FROM productos pr
LEFT JOIN detalle_pedidos dp ON pr.id = dp.producto_id
WHERE dp.id IS NULL;

Ejercicio 4: Muestra cada empleado junto con el nombre de su jefe. Incluye empleados sin jefe (mostrando NULL).

Ver solucion
SELECT
e.nombre AS empleado,
e.departamento,
e.salario,
j.nombre AS jefe
FROM empleados e
LEFT JOIN empleados j ON e.jefe_id = j.id
ORDER BY j.nombre NULLS FIRST, e.nombre;

Ejercicio 5: Muestra un informe con: nombre del cliente, nombre del producto, cantidad y precio unitario, para todos los pedidos con estado ‘completado’. Necesitas conectar 4 tablas.

Ver solucion
SELECT
c.nombre AS cliente,
pr.nombre AS producto,
dp.cantidad,
dp.precio_unitario,
(dp.cantidad * dp.precio_unitario) AS subtotal
FROM clientes c
INNER JOIN pedidos p ON c.id = p.cliente_id
INNER JOIN detalle_pedidos dp ON p.id = dp.pedido_id
INNER JOIN productos pr ON dp.producto_id = pr.id
WHERE p.estado = 'completado'
ORDER BY c.nombre, pr.nombre;

Ejercicio 6: Para cada categoria de producto, muestra la cantidad total de unidades vendidas. Incluye categorias con 0 ventas.

Ver solucion
SELECT
pr.categoria,
COALESCE(SUM(dp.cantidad), 0) AS unidades_vendidas
FROM productos pr
LEFT JOIN detalle_pedidos dp ON pr.id = dp.producto_id
GROUP BY pr.categoria
ORDER BY unidades_vendidas DESC;

Ejercicio 7: Encuentra los clientes que han gastado mas de 1000 en total, mostrando su nombre, ciudad, numero de pedidos y gasto total. Solo incluye pedidos completados.

Ver solucion
SELECT
c.nombre,
c.ciudad,
COUNT(p.id) AS num_pedidos,
SUM(p.total) AS gasto_total
FROM clientes c
INNER JOIN pedidos p ON c.id = p.cliente_id
WHERE p.estado = 'completado'
GROUP BY c.id, c.nombre, c.ciudad
HAVING SUM(p.total) > 1000
ORDER BY gasto_total DESC;

Esta consulta combina JOIN, WHERE, GROUP BY, HAVING y ORDER BY.

Ejercicio 8 (Desafio): Quieres saber cuantos productos distintos ha comprado cada cliente y el total gastado. Pero cuidado: si haces JOIN de pedidos con detalle_pedidos, los totales se pueden inflar. Usa un CTE para evitar el problema.

Ver solucion
-- Paso 1: total gastado por cliente (sin duplicados)
WITH gasto_cliente AS (
SELECT
cliente_id,
SUM(total) AS gasto_total
FROM pedidos
WHERE estado = 'completado'
GROUP BY cliente_id
),
-- Paso 2: productos distintos por cliente
productos_cliente AS (
SELECT
p.cliente_id,
COUNT(DISTINCT dp.producto_id) AS productos_distintos
FROM pedidos p
INNER JOIN detalle_pedidos dp ON p.id = dp.pedido_id
WHERE p.estado = 'completado'
GROUP BY p.cliente_id
)
SELECT
c.nombre,
c.ciudad,
pc.productos_distintos,
gc.gasto_total
FROM clientes c
INNER JOIN gasto_cliente gc ON c.id = gc.cliente_id
INNER JOIN productos_cliente pc ON c.id = pc.cliente_id
ORDER BY gc.gasto_total DESC;

Al pre-agregar en CTEs separados, cada cliente aparece una sola vez en cada CTE. El JOIN final no genera duplicados y los totales son correctos.

08

Subconsultas y CTEs

Hasta ahora, todas nuestras consultas han sido “planas”: un solo SELECT que lee de una o varias tablas. Pero SQL es mucho mas poderoso que eso. Podemos anidar consultas dentro de otras consultas. A esto le llamamos subconsultas (o subqueries).

Y cuando las subconsultas se vuelven complicadas de leer, tenemos una herramienta elegante: las CTEs (Common Table Expressions), que nos permiten escribir consultas complejas de forma clara y organizada.


Que es una subconsulta?

Una subconsulta es simplemente un SELECT dentro de otro SELECT. Va entre parentesis y puede aparecer en diferentes partes de tu consulta: en el WHERE, en el FROM, o incluso en el SELECT.

Pensalo asi: en lugar de escribir un valor fijo, le decis a SQL “calcula este valor por mi con otra consulta”.


Subconsultas en WHERE

Este es el uso mas comun. Vamos a ver las diferentes formas.

Subconsulta escalar (devuelve un solo valor)

Quiero ver todos los productos que cuestan mas que el precio promedio:

SELECT nombre, precio
FROM productos
WHERE precio > (SELECT AVG(precio) FROM productos);

Lo que pasa aqui es que PostgreSQL primero ejecuta la subconsulta interna (SELECT AVG(precio) FROM productos), obtiene un numero (digamos 45.50), y luego ejecuta la consulta externa como si dijera WHERE precio > 45.50.

💡

Nota

Una subconsulta escalar es la que devuelve exactamente una fila y una columna (un solo valor). Si devuelve mas de una fila, PostgreSQL va a tirar un error.

Subconsulta con IN (devuelve una lista)

Quiero ver los clientes que han hecho al menos un pedido:

SELECT nombre, email
FROM clientes
WHERE id IN (SELECT DISTINCT cliente_id FROM pedidos);

La subconsulta devuelve una lista de IDs, y IN verifica si el id del cliente esta en esa lista.

Otro ejemplo: productos que nunca se han vendido:

SELECT nombre, precio
FROM productos
WHERE id NOT IN (
SELECT DISTINCT producto_id
FROM detalle_pedidos
);
⚠️

Cuidado con NOT IN y NULLs

Si la subconsulta dentro de NOT IN devuelve algun valor NULL, el resultado completo sera vacio. Esto es una trampa clasica de SQL. Si hay riesgo de NULLs, usa NOT EXISTS en su lugar (lo vemos a continuacion).

Subconsulta con EXISTS

EXISTS verifica si una subconsulta devuelve al menos una fila. No le importa que datos devuelve, solo si hay resultados o no.

Clientes que tienen al menos un pedido:

SELECT c.nombre, c.email
FROM clientes c
WHERE EXISTS (
SELECT 1
FROM pedidos p
WHERE p.cliente_id = c.id
);

Y lo contrario: clientes sin ningun pedido:

SELECT c.nombre, c.email
FROM clientes c
WHERE NOT EXISTS (
SELECT 1
FROM pedidos p
WHERE p.cliente_id = c.id
);

Tip

El SELECT 1 dentro de EXISTS es una convencion. Podrias poner SELECT * o SELECT 42 y funcionaria igual. PostgreSQL solo verifica si hay filas, no le importa que columnas selecciones.


Subconsultas en FROM (tablas derivadas)

Podemos usar una subconsulta como si fuera una tabla temporal. Esto se llama tabla derivada y siempre necesita un alias.

Ejemplo: quiero ver el total gastado por cliente, pero solo los que gastaron mas de 500:

SELECT sub.nombre, sub.total_gastado
FROM (
SELECT c.nombre, SUM(p.total) AS total_gastado
FROM clientes c
JOIN pedidos p ON c.id = p.cliente_id
GROUP BY c.nombre
) AS sub
WHERE sub.total_gastado > 500
ORDER BY sub.total_gastado DESC;
💡

Nota

Si, esto tambien se puede hacer con HAVING. Pero las tablas derivadas son utiles cuando necesitas hacer operaciones adicionales sobre resultados ya agrupados.


Subconsultas en SELECT (subconsultas escalares)

Podemos calcular un valor en cada fila usando una subconsulta en la lista de columnas:

SELECT
p.nombre,
p.precio,
(SELECT AVG(precio) FROM productos) AS precio_promedio,
p.precio - (SELECT AVG(precio) FROM productos) AS diferencia
FROM productos p
ORDER BY diferencia DESC;

Esto agrega dos columnas calculadas: el precio promedio general y cuanto se aleja cada producto de ese promedio.

Otro ejemplo mas util: para cada cliente, cuantos pedidos tiene:

SELECT
c.nombre,
c.ciudad,
(SELECT COUNT(*)
FROM pedidos p
WHERE p.cliente_id = c.id) AS total_pedidos
FROM clientes c
ORDER BY total_pedidos DESC;

Subconsultas correlacionadas vs no correlacionadas

Esta distincion es clave para entender el rendimiento.

No correlacionada: la subconsulta se ejecuta una sola vez y su resultado se reutiliza.

-- Se ejecuta UNA vez: calcula el promedio y listo
SELECT nombre, precio
FROM productos
WHERE precio > (SELECT AVG(precio) FROM productos);

Correlacionada: la subconsulta se ejecuta una vez por cada fila de la consulta externa, porque depende de un valor de esa fila.

-- Se ejecuta UNA VEZ POR CADA CLIENTE
SELECT c.nombre,
(SELECT COUNT(*) FROM pedidos p WHERE p.cliente_id = c.id) AS total_pedidos
FROM clientes c;

Cuidado con subconsultas correlacionadas

Una subconsulta correlacionada puede tener complejidad O(n * m) donde n es el numero de filas de la consulta externa y m el costo de la subconsulta interna. Si la tabla clientes tiene 10,000 filas y pedidos tiene 100,000 filas, esta ejecutando la subconsulta interna 10,000 veces. Muchas veces un JOIN con GROUP BY logra el mismo resultado de forma mucho mas eficiente.

El mismo resultado con JOIN (generalmente mas rapido):

SELECT c.nombre, COUNT(p.id) AS total_pedidos
FROM clientes c
LEFT JOIN pedidos p ON c.id = p.cliente_id
GROUP BY c.nombre
ORDER BY total_pedidos DESC;

Common Table Expressions (CTEs)

Las CTEs son una forma de darle nombre temporal a una subconsulta para usarla mas adelante. Se definen con la palabra clave WITH.

Sintaxis basica

WITH nombre_cte AS (
SELECT ...
)
SELECT * FROM nombre_cte;

Veamos un ejemplo real. Quiero encontrar los clientes VIP (los que gastaron mas de 1000) y ver sus datos:

WITH clientes_vip AS (
SELECT cliente_id, SUM(total) AS total_gastado
FROM pedidos
GROUP BY cliente_id
HAVING SUM(total) > 1000
)
SELECT c.nombre, c.email, cv.total_gastado
FROM clientes c
JOIN clientes_vip cv ON c.id = cv.cliente_id
ORDER BY cv.total_gastado DESC;

Comparalo con la version sin CTE:

SELECT c.nombre, c.email, sub.total_gastado
FROM clientes c
JOIN (
SELECT cliente_id, SUM(total) AS total_gastado
FROM pedidos
GROUP BY cliente_id
HAVING SUM(total) > 1000
) sub ON c.id = sub.cliente_id
ORDER BY sub.total_gastado DESC;

Ambas hacen lo mismo, pero la version con CTE es mas facil de leer porque le diste un nombre descriptivo (clientes_vip) a la subconsulta.

Por que las CTEs mejoran la legibilidad?

Imagina que tenes una consulta compleja con subconsultas anidadas tres niveles. Es una pesadilla de leer. Con CTEs, podes descomponer la logica paso a paso:

-- Paso 1: calcular ventas por producto
WITH ventas_por_producto AS (
SELECT
dp.producto_id,
SUM(dp.cantidad) AS total_vendido,
SUM(dp.cantidad * dp.precio_unitario) AS ingresos
FROM detalle_pedidos dp
GROUP BY dp.producto_id
),
-- Paso 2: clasificar productos
productos_clasificados AS (
SELECT
p.nombre,
p.categoria,
vp.total_vendido,
vp.ingresos,
CASE
WHEN vp.total_vendido > 100 THEN 'Estrella'
WHEN vp.total_vendido > 50 THEN 'Popular'
ELSE 'Normal'
END AS clasificacion
FROM productos p
JOIN ventas_por_producto vp ON p.id = vp.producto_id
)
-- Paso 3: consulta final
SELECT clasificacion, COUNT(*) AS cantidad, SUM(ingresos) AS ingresos_totales
FROM productos_clasificados
GROUP BY clasificacion
ORDER BY ingresos_totales DESC;

Cada CTE es un “paso” con nombre claro. Es como escribir un programa paso a paso en lugar de meter todo en una sola linea.

Multiples CTEs en una consulta

Como viste en el ejemplo anterior, podes definir varias CTEs separadas por comas. Cada una puede referenciar a las anteriores:

WITH
pedidos_recientes AS (
SELECT * FROM pedidos
WHERE fecha >= CURRENT_DATE - INTERVAL '30 days'
),
clientes_activos AS (
SELECT DISTINCT cliente_id
FROM pedidos_recientes
),
resumen AS (
SELECT
c.ciudad,
COUNT(*) AS clientes_activos
FROM clientes c
JOIN clientes_activos ca ON c.id = ca.cliente_id
GROUP BY c.ciudad
)
SELECT * FROM resumen ORDER BY clientes_activos DESC;

Regla de oro

Si tu consulta tiene mas de 2 subconsultas anidadas, probablemente deberia usar CTEs. Tu yo del futuro (y tus companeros) te lo van a agradecer.


Rendimiento de CTEs en PostgreSQL

CTEs materializadas vs no materializadas

Antes de PostgreSQL 12, las CTEs siempre se materializaban: PostgreSQL ejecutaba la CTE, guardaba el resultado en memoria, y luego la consulta principal leia de ahi. Esto podia ser mas lento porque el optimizador no podia “empujar” filtros dentro de la CTE.

Desde PostgreSQL 12, si la CTE se usa solo una vez y no es recursiva, PostgreSQL puede hacer inline de la CTE (no materializarla), permitiendo al optimizador aplicar filtros y optimizaciones.

Podes controlar esto manualmente:

-- Forzar materializacion (util si la CTE se usa muchas veces)
WITH datos AS MATERIALIZED (
SELECT * FROM productos WHERE precio > 100
)
SELECT * FROM datos WHERE categoria = 'Electronica';
-- Forzar inline (util para que el optimizador pueda optimizar)
WITH datos AS NOT MATERIALIZED (
SELECT * FROM productos WHERE precio > 100
)
SELECT * FROM datos WHERE categoria = 'Electronica';

Cuando usar subconsulta vs JOIN?

Comando Descripcion
Filtrar por un valor calculado Subconsulta escalar en WHERE
Verificar existencia EXISTS (generalmente mas rapido que IN)
Combinar datos de varias tablas JOIN
Consulta compleja con multiples pasos CTEs
Necesitas el resultado en cada fila JOIN o subconsulta en SELECT
Subconsulta correlacionada costosa Reescribir como JOIN + GROUP BY

Tip

No hay una regla absoluta. En muchos casos, PostgreSQL optimiza subconsultas y JOINs de forma similar. Pero como regla general: si podes escribirlo con JOIN y es legible, preferi el JOIN.


Ejercicios

Ejercicio 1: Encontra todos los productos cuyo precio es mayor al precio promedio de su misma categoria. (Pista: necesitas una subconsulta correlacionada).

SELECT nombre, precio, categoria
FROM productos p
WHERE precio > (
SELECT AVG(precio)
FROM productos
WHERE categoria = p.categoria
);

Ejercicio 2: Usa EXISTS para encontrar las categorias que tienen al menos un producto con stock menor a 10.

SELECT DISTINCT p.categoria
FROM productos p
WHERE EXISTS (
SELECT 1
FROM productos p2
WHERE p2.categoria = p.categoria
AND p2.stock < 10
);

Ejercicio 3: Usando una CTE, calcula el total de ventas por cliente y muestra solo los que estan por encima del promedio de todos los clientes.

WITH ventas_cliente AS (
SELECT cliente_id, SUM(total) AS total_ventas
FROM pedidos
GROUP BY cliente_id
)
SELECT c.nombre, vc.total_ventas
FROM clientes c
JOIN ventas_cliente vc ON c.id = vc.cliente_id
WHERE vc.total_ventas > (SELECT AVG(total_ventas) FROM ventas_cliente)
ORDER BY vc.total_ventas DESC;

Ejercicio 4: Escribe una consulta con multiples CTEs que: (1) calcule las ventas totales por producto, (2) identifique los productos del top 3, y (3) muestre los clientes que compraron esos productos.

WITH ventas_producto AS (
SELECT producto_id, SUM(cantidad * precio_unitario) AS total_ventas
FROM detalle_pedidos
GROUP BY producto_id
),
top_productos AS (
SELECT producto_id
FROM ventas_producto
ORDER BY total_ventas DESC
LIMIT 3
),
clientes_top AS (
SELECT DISTINCT p.cliente_id
FROM pedidos p
JOIN detalle_pedidos dp ON p.id = dp.pedido_id
WHERE dp.producto_id IN (SELECT producto_id FROM top_productos)
)
SELECT c.nombre, c.email
FROM clientes c
JOIN clientes_top ct ON c.id = ct.cliente_id;

Ejercicio 5: Reescribi la siguiente subconsulta correlacionada como un JOIN con GROUP BY. Compara mentalmente cual seria mas eficiente:

-- Version original (subconsulta correlacionada)
SELECT e.nombre, e.departamento,
(SELECT COUNT(*) FROM empleados e2 WHERE e2.departamento = e.departamento) AS total_depto
FROM empleados e;
-- Tu version con JOIN:
SELECT e.nombre, e.departamento, d.total_depto
FROM empleados e
JOIN (
SELECT departamento, COUNT(*) AS total_depto
FROM empleados
GROUP BY departamento
) d ON e.departamento = d.departamento;

Ejercicio 6: Encontra los empleados cuyo salario es el mas alto dentro de su departamento. Resolverlo de dos formas: con subconsulta correlacionada y con CTE.

-- Con subconsulta correlacionada
SELECT nombre, departamento, salario
FROM empleados e
WHERE salario = (
SELECT MAX(salario) FROM empleados
WHERE departamento = e.departamento
);
-- Con CTE
WITH max_por_depto AS (
SELECT departamento, MAX(salario) AS max_salario
FROM empleados
GROUP BY departamento
)
SELECT e.nombre, e.departamento, e.salario
FROM empleados e
JOIN max_por_depto m
ON e.departamento = m.departamento
AND e.salario = m.max_salario;
09

Funciones y expresiones

SQL no es solo para filtrar y unir tablas. Tambien tiene un monton de funciones incorporadas que te permiten transformar texto, trabajar con fechas, hacer calculos matematicos y manejar casos especiales. En este modulo vamos a explorar las funciones y expresiones mas utiles de PostgreSQL.


Funciones de texto (strings)

PostgreSQL tiene un arsenal completo de funciones para manipular texto. Estas son las mas usadas:

Comando Descripcion
UPPER(texto) Convierte a mayusculas
LOWER(texto) Convierte a minusculas
TRIM(texto) Elimina espacios al inicio y final
LENGTH(texto) Devuelve la cantidad de caracteres
SUBSTRING(texto, inicio, largo) Extrae una porcion del texto
CONCAT(a, b, ...) Concatena textos (ignora NULLs)
a || b Concatena textos (NULL si alguno es NULL)
REPLACE(texto, buscar, reemplazo) Reemplaza ocurrencias
LEFT(texto, n) Primeros n caracteres
RIGHT(texto, n) Ultimos n caracteres
POSITION(buscar IN texto) Posicion de un subtexto

Veamos ejemplos con nuestra base de datos:

-- Nombre del cliente en mayusculas
SELECT UPPER(nombre) AS nombre_mayusculas FROM clientes;
-- Email en minusculas (por si acaso)
SELECT LOWER(email) AS email_normalizado FROM clientes;
-- Largo del nombre de cada producto
SELECT nombre, LENGTH(nombre) AS caracteres FROM productos;
-- Primeras 3 letras de la ciudad
SELECT nombre, LEFT(ciudad, 3) AS codigo_ciudad FROM clientes;
-- Concatenar nombre y ciudad
SELECT CONCAT(nombre, ' - ', ciudad) AS cliente_info FROM clientes;
-- Lo mismo con el operador ||
SELECT nombre || ' (' || ciudad || ')' AS cliente_info FROM clientes;
⚠️

Diferencia entre CONCAT y ||

CONCAT ignora valores NULL (los trata como texto vacio), mientras que || devuelve NULL si cualquiera de los operandos es NULL. Usa CONCAT cuando pueda haber NULLs.

SELECT CONCAT('Hola', NULL, ' mundo'); -- Resultado: 'Hola mundo'
SELECT 'Hola' || NULL || ' mundo'; -- Resultado: NULL

Un ejemplo mas practico con SUBSTRING y REPLACE:

-- Extraer el dominio del email
SELECT
nombre,
email,
SUBSTRING(email FROM POSITION('@' IN email) + 1) AS dominio
FROM clientes;
-- Reemplazar texto en nombres de productos
SELECT REPLACE(nombre, 'Premium', 'Pro') AS nombre_nuevo
FROM productos
WHERE nombre LIKE '%Premium%';

STRING_AGG: concatenar valores de multiples filas

STRING_AGG combina valores de varias filas en un solo texto, separados por un delimitador. Es el equivalente de GROUP_CONCAT en MySQL.

-- Ciudades de todos los clientes en una sola linea
SELECT STRING_AGG(DISTINCT ciudad, ', ') AS ciudades
FROM clientes;
-- Resultado: 'Antofagasta, Concepcion, Santiago, Temuco, Valparaiso'

Combinado con GROUP BY, es muy util para crear listas por grupo:

-- Productos que compro cada cliente (una linea por cliente)
SELECT
c.nombre,
STRING_AGG(DISTINCT pr.nombre, ', ' ORDER BY pr.nombre) AS productos_comprados
FROM clientes c
INNER JOIN pedidos p ON c.id = p.cliente_id
INNER JOIN detalle_pedidos dp ON p.id = dp.pedido_id
INNER JOIN productos pr ON dp.producto_id = pr.id
GROUP BY c.id, c.nombre;
-- Empleados por departamento
SELECT
departamento,
STRING_AGG(nombre, ', ' ORDER BY nombre) AS empleados
FROM empleados
GROUP BY departamento;
-- Ejemplo: 'Ventas' | 'Ana Torres, Carlos Diaz, Maria Lopez'

ORDER BY dentro de STRING_AGG

Puedes usar ORDER BY dentro de STRING_AGG para controlar el orden de los valores concatenados. Tambien puedes usar DISTINCT para eliminar duplicados.

-- Categorias de productos que ha comprado cada cliente, sin repetir
SELECT
c.nombre,
STRING_AGG(DISTINCT pr.categoria, ' | ' ORDER BY pr.categoria) AS categorias
FROM clientes c
INNER JOIN pedidos p ON c.id = p.cliente_id
INNER JOIN detalle_pedidos dp ON p.id = dp.pedido_id
INNER JOIN productos pr ON dp.producto_id = pr.id
GROUP BY c.id, c.nombre;
-- Ejemplo: 'Ana Torres' | 'Accesorios | Electronica | Muebles'

Funciones de fecha y hora

Trabajar con fechas es algo que vas a hacer constantemente. PostgreSQL tiene funciones excelentes para esto.

Comando Descripcion
NOW() Fecha y hora actual (con timezone)
CURRENT_DATE Solo la fecha de hoy
CURRENT_TIME Solo la hora actual
AGE(fecha) Diferencia entre hoy y esa fecha
AGE(fecha1, fecha2) Diferencia entre dos fechas
EXTRACT(campo FROM fecha) Extrae una parte (year, month, day, etc.)
DATE_TRUNC(precision, fecha) Trunca a una precision (month, year, etc.)
fecha + INTERVAL '...' Suma un intervalo a una fecha

Vamos a ver esto en accion:

-- Fecha y hora actual
SELECT NOW(); -- 2026-03-28 14:30:00.123456-03
SELECT CURRENT_DATE; -- 2026-03-28
SELECT CURRENT_TIME; -- 14:30:00.123456-03
-- Hace cuanto se registro cada cliente?
SELECT nombre, fecha_registro, AGE(fecha_registro) AS antiguedad
FROM clientes;
-- Resultado: '2 years 3 mons 15 days'
-- Extraer partes de una fecha
SELECT
nombre,
fecha_registro,
EXTRACT(YEAR FROM fecha_registro) AS anio,
EXTRACT(MONTH FROM fecha_registro) AS mes,
EXTRACT(DOW FROM fecha_registro) AS dia_semana -- 0=domingo, 6=sabado
FROM clientes;
-- Ventas por mes usando DATE_TRUNC
SELECT
DATE_TRUNC('month', fecha) AS mes,
COUNT(*) AS cantidad_pedidos,
SUM(total) AS ventas_totales
FROM pedidos
GROUP BY DATE_TRUNC('month', fecha)
ORDER BY mes;

DATE_TRUNC es tu mejor amigo

DATE_TRUNC es increiblemente util para agrupar por periodos de tiempo. Trunca la fecha a la precision que le indiques:

DATE_TRUNC('year', '2026-03-28') -- 2026-01-01
DATE_TRUNC('month', '2026-03-28') -- 2026-03-01
DATE_TRUNC('week', '2026-03-28') -- 2026-03-23 (inicio de semana)
DATE_TRUNC('day', NOW()) -- 2026-03-28 00:00:00

Intervalos

Los intervalos te permiten sumar o restar tiempo de forma muy expresiva:

-- Pedidos de los ultimos 30 dias
SELECT * FROM pedidos
WHERE fecha >= CURRENT_DATE - INTERVAL '30 days';
-- Pedidos de los ultimos 3 meses
SELECT * FROM pedidos
WHERE fecha >= CURRENT_DATE - INTERVAL '3 months';
-- Clientes registrados en el ultimo anio
SELECT nombre, fecha_registro
FROM clientes
WHERE fecha_registro >= CURRENT_DATE - INTERVAL '1 year';
-- Sumar 2 semanas a una fecha
SELECT fecha, fecha + INTERVAL '2 weeks' AS fecha_entrega
FROM pedidos;

Funciones matematicas

Para cuando necesitas hacer calculos numericos:

Comando Descripcion
ROUND(n, decimales) Redondea al numero de decimales
CEIL(n) / CEILING(n) Redondea hacia arriba
FLOOR(n) Redondea hacia abajo
ABS(n) Valor absoluto
MOD(a, b) Resto de la division (a % b)
POWER(base, exp) Potencia
SQRT(n) Raiz cuadrada
-- Redondear precios a 2 decimales
SELECT nombre, ROUND(precio, 2) AS precio_redondeado FROM productos;
-- Precio con IVA (21%) redondeado
SELECT
nombre,
precio,
ROUND(precio * 1.21, 2) AS precio_con_iva
FROM productos;
-- Redondear hacia arriba (para calcular cajas necesarias, por ejemplo)
SELECT nombre, stock, CEIL(stock::numeric / 12) AS cajas_necesarias
FROM productos;
-- Valor absoluto de la diferencia con el precio promedio
SELECT
nombre,
precio,
ROUND(ABS(precio - (SELECT AVG(precio) FROM productos)), 2) AS diferencia_abs
FROM productos;

CASE WHEN: la expresion condicional

CASE WHEN es como un if/else dentro de SQL. Es una de las expresiones mas utiles que vas a usar.

Forma buscada (searched CASE)

SELECT
nombre,
precio,
CASE
WHEN precio < 20 THEN 'Economico'
WHEN precio < 50 THEN 'Medio'
WHEN precio < 100 THEN 'Premium'
ELSE 'Lujo'
END AS rango_precio
FROM productos
ORDER BY precio;

Se pueden usar en cualquier parte de la consulta, incluyendo ORDER BY y GROUP BY:

-- Agrupar por rango de precio
SELECT
CASE
WHEN precio < 20 THEN 'Economico'
WHEN precio < 50 THEN 'Medio'
WHEN precio < 100 THEN 'Premium'
ELSE 'Lujo'
END AS rango,
COUNT(*) AS cantidad,
ROUND(AVG(precio), 2) AS precio_promedio
FROM productos
GROUP BY rango
ORDER BY precio_promedio;

Forma simple (simple CASE)

Cuando comparas contra un solo campo con valores exactos:

SELECT
id,
total,
CASE estado
WHEN 'pendiente' THEN 'En espera'
WHEN 'enviado' THEN 'En camino'
WHEN 'completado' THEN 'Finalizado'
ELSE 'Desconocido'
END AS estado_legible
FROM pedidos;

CASE WHEN para pivotear datos

Un truco muy util es usar CASE con funciones de agregacion para crear reportes tipo tabla cruzada:

SELECT
categoria,
COUNT(*) AS total_productos,
SUM(CASE WHEN stock > 50 THEN 1 ELSE 0 END) AS con_buen_stock,
SUM(CASE WHEN stock BETWEEN 10 AND 50 THEN 1 ELSE 0 END) AS stock_medio,
SUM(CASE WHEN stock < 10 THEN 1 ELSE 0 END) AS stock_bajo
FROM productos
GROUP BY categoria;

COALESCE: manejo de NULLs

COALESCE devuelve el primer valor que no sea NULL de una lista de argumentos. Es la funcion estrella para manejar valores nulos.

-- Si la ciudad es NULL, mostrar 'Sin ciudad'
SELECT nombre, COALESCE(ciudad, 'Sin ciudad') AS ciudad
FROM clientes;
-- Encadenar varios valores de respaldo
SELECT COALESCE(telefono, email, 'Sin contacto') AS contacto
FROM clientes;

Un uso muy comun es en calculos donde podria haber NULLs:

-- Asegurar que el total nunca sea NULL
SELECT
c.nombre,
COALESCE(SUM(p.total), 0) AS total_compras
FROM clientes c
LEFT JOIN pedidos p ON c.id = p.cliente_id
GROUP BY c.nombre;
💡

Nota

Sin COALESCE, los clientes sin pedidos mostrarian NULL en total_compras. Con COALESCE(..., 0), muestran 0, que es mucho mas claro.


NULLIF

NULLIF(a, b) devuelve NULL si a es igual a b. De lo contrario, devuelve a. Es el opuesto logico de COALESCE.

El uso mas comun es evitar divisiones por cero:

-- Sin NULLIF: si total_vendido es 0, esto da error
SELECT nombre, ingresos / total_vendido AS precio_promedio
FROM resumen_ventas;
-- Con NULLIF: si total_vendido es 0, devuelve NULL en lugar de error
SELECT nombre, ingresos / NULLIF(total_vendido, 0) AS precio_promedio
FROM resumen_ventas;

Ejemplo con nuestra base de datos:

-- Ratio de pedidos completados vs totales (evitando division por cero)
SELECT
c.nombre,
COUNT(p.id) AS total_pedidos,
COUNT(CASE WHEN p.estado = 'completado' THEN 1 END) AS completados,
ROUND(
COUNT(CASE WHEN p.estado = 'completado' THEN 1 END)::numeric /
NULLIF(COUNT(p.id), 0) * 100, 1
) AS porcentaje_completado
FROM clientes c
LEFT JOIN pedidos p ON c.id = p.cliente_id
GROUP BY c.nombre;

CAST y :: para conversion de tipos

A veces necesitas convertir un valor de un tipo a otro. PostgreSQL ofrece dos sintaxis:

-- Sintaxis estandar SQL
SELECT CAST('42' AS INTEGER);
SELECT CAST(precio AS INTEGER) FROM productos;
-- Sintaxis de PostgreSQL (mas corta, mas comun)
SELECT '42'::INTEGER;
SELECT precio::INTEGER FROM productos;

Conversiones comunes:

-- Texto a numero
SELECT '123.45'::NUMERIC;
SELECT '42'::INTEGER;
-- Numero a texto
SELECT 42::TEXT;
SELECT precio::TEXT FROM productos;
-- Texto a fecha
SELECT '2026-03-28'::DATE;
-- Division entera vs decimal
SELECT 7 / 2; -- Resultado: 3 (division entera!)
SELECT 7::NUMERIC / 2; -- Resultado: 3.5 (division decimal)
-- Porcentajes correctos
SELECT
categoria,
COUNT(*) AS cantidad,
ROUND(COUNT(*)::NUMERIC / (SELECT COUNT(*) FROM productos) * 100, 1) AS porcentaje
FROM productos
GROUP BY categoria;
⚠️

La trampa de la division entera

En PostgreSQL, dividir dos enteros da un entero: 5 / 2 = 2, no 2.5. Si necesitas decimales, convierte al menos uno de los operandos: 5::NUMERIC / 2 = 2.5.


Rendimiento: funciones en WHERE

Funciones que matan tus indices

Cuando aplicas una funcion a una columna en el WHERE, PostgreSQL no puede usar el indice de esa columna. Esto se llama un predicado “no sargable”.

-- MAL: no usa el indice en 'nombre' (escaneo completo de tabla)
SELECT * FROM clientes WHERE UPPER(nombre) = 'MARIA GARCIA';
-- BIEN: usa ILIKE que puede aprovechar indices con pg_trgm
SELECT * FROM clientes WHERE nombre ILIKE 'maria garcia';
-- MAL: no usa el indice en 'fecha_registro'
SELECT * FROM clientes WHERE EXTRACT(YEAR FROM fecha_registro) = 2025;
-- BIEN: usa un rango (puede usar el indice)
SELECT * FROM clientes
WHERE fecha_registro >= '2025-01-01'
AND fecha_registro < '2026-01-01';
-- MAL: funcion sobre la columna
SELECT * FROM productos WHERE ROUND(precio, 0) = 50;
-- BIEN: rango equivalente
SELECT * FROM productos WHERE precio >= 49.5 AND precio < 50.5;

La regla general: mantene las columnas indexadas “limpias” en el WHERE. Aplica funciones al otro lado de la comparacion, o reestructura la condicion para no necesitarlas.


Ejercicios

Ejercicio 1: Muestra los productos con su nombre en mayusculas, el largo del nombre, y el precio redondeado sin decimales.

SELECT
UPPER(nombre) AS nombre_mayusculas,
LENGTH(nombre) AS largo_nombre,
ROUND(precio, 0) AS precio_redondeado
FROM productos;

Ejercicio 2: Calcula cuantos dias hace que se registro cada cliente y muestra solo los que tienen mas de 365 dias.

SELECT
nombre,
fecha_registro,
CURRENT_DATE - fecha_registro AS dias_registrado
FROM clientes
WHERE CURRENT_DATE - fecha_registro > 365
ORDER BY dias_registrado DESC;

Ejercicio 3: Crea un reporte de pedidos por mes y anio, mostrando la cantidad y el total. Usa DATE_TRUNC o EXTRACT.

SELECT
EXTRACT(YEAR FROM fecha) AS anio,
EXTRACT(MONTH FROM fecha) AS mes,
COUNT(*) AS cantidad_pedidos,
ROUND(SUM(total), 2) AS total_ventas
FROM pedidos
GROUP BY anio, mes
ORDER BY anio, mes;

Ejercicio 4: Clasifica a los empleados en rangos salariales usando CASE WHEN: “Junior” (menos de 40000), “Semi-Senior” (40000-70000), “Senior” (mas de 70000). Muestra cuantos hay en cada rango.

SELECT
CASE
WHEN salario < 40000 THEN 'Junior'
WHEN salario <= 70000 THEN 'Semi-Senior'
ELSE 'Senior'
END AS rango,
COUNT(*) AS cantidad,
ROUND(AVG(salario), 2) AS salario_promedio
FROM empleados
GROUP BY rango
ORDER BY salario_promedio;

Ejercicio 5: Muestra cada cliente con su total de compras. Si nunca compro nada, debe aparecer 0 (no NULL). Usa COALESCE.

SELECT
c.nombre,
c.ciudad,
COALESCE(SUM(p.total), 0) AS total_compras
FROM clientes c
LEFT JOIN pedidos p ON c.id = p.cliente_id
GROUP BY c.nombre, c.ciudad
ORDER BY total_compras DESC;

Ejercicio 6: Genera un reporte que muestre para cada categoria: nombre, cantidad de productos, precio promedio, y el producto mas caro y mas barato (concatenados como texto). Usa varias de las funciones que aprendimos.

WITH stats AS (
SELECT
categoria,
COUNT(*) AS cantidad,
ROUND(AVG(precio), 2) AS precio_promedio,
MIN(precio) AS min_precio,
MAX(precio) AS max_precio
FROM productos
GROUP BY categoria
)
SELECT
UPPER(s.categoria) AS categoria,
s.cantidad,
s.precio_promedio,
CONCAT(
'Mas barato: $', s.min_precio::TEXT,
' | Mas caro: $', s.max_precio::TEXT
) AS rango_precios
FROM stats s
ORDER BY s.precio_promedio DESC;
10

Modificando datos

Hasta ahora nos enfocamos en leer datos con SELECT. Pero en la vida real tambien necesitas agregar, modificar y eliminar datos. Estos son los comandos DML (Data Manipulation Language): INSERT, UPDATE y DELETE.

Este curso se enfoca en consultas, asi que vamos a ser concisos. Pero es fundamental que conozcas estas operaciones y, sobre todo, que aprendas a usarlas de forma segura.

⚠️

Este modulo modifica datos

Los ejemplos y ejercicios de este modulo insertan, modifican y eliminan filas de tu base de datos. Para experimentar con seguridad, usa transacciones: ejecuta BEGIN; antes de cada ejemplo y ROLLBACK; si quieres deshacer los cambios. Si en algun momento tu base queda en un estado raro, vuelve a ejecutar el script db/init.sql del Modulo 2 para resetearla.


INSERT: agregando datos

Insertar una sola fila

La sintaxis basica:

INSERT INTO productos (nombre, precio, categoria, stock, fecha_creacion)
VALUES ('Base para Laptop', 45.99, 'Accesorios', 25, CURRENT_DATE);
💡

Nota

La columna id no la incluimos porque es SERIAL (auto-incremental). PostgreSQL le asigna el siguiente valor automaticamente.

Insertar multiples filas

En lugar de ejecutar un INSERT por cada fila, podes insertar varias de una vez:

INSERT INTO productos (nombre, precio, categoria, stock, fecha_creacion)
VALUES
('Alfombrilla Escritorio', 35.50, 'Accesorios', 100, CURRENT_DATE),
('Organizador Cables', 14.99, 'Accesorios', 15, CURRENT_DATE),
('Reposamuñecas Teclado', 22.99, 'Muebles', 200, CURRENT_DATE);

Inserciones en bulk

Insertar 1000 filas con un solo INSERT ... VALUES (...) de multiples filas es muchisimo mas rapido que ejecutar 1000 sentencias INSERT individuales. Cada INSERT individual tiene overhead de red, parsing y transaccion. Un INSERT masivo hace todo de una vez.

Para volumenes muy grandes (millones de filas), investiga el comando COPY de PostgreSQL, que es aun mas rapido que INSERT masivo.

INSERT con SELECT (copiar datos)

Podes insertar datos que vienen del resultado de una consulta. Primero creamos una tabla destino:

-- Crear una tabla para productos premium
CREATE TABLE productos_premium (
id SERIAL PRIMARY KEY,
nombre VARCHAR(100),
precio DECIMAL(10,2),
categoria VARCHAR(50)
);
-- Copiar productos con precio mayor a 100
INSERT INTO productos_premium (nombre, precio, categoria)
SELECT nombre, precio, categoria
FROM productos
WHERE precio > 100;

Esto es muy util para crear tablas de resumen, copiar datos entre tablas, o poblar tablas temporales.

Copiar una tabla completa

Si quieres crear una copia completa de una tabla (estructura + datos) en un solo paso, usa CREATE TABLE ... AS:

-- Copiar tabla completa con datos
CREATE TABLE productos_backup AS
SELECT * FROM productos;
-- Copiar solo algunas columnas o filas
CREATE TABLE electronica AS
SELECT nombre, precio, stock
FROM productos
WHERE categoria = 'Electronica';
⚠️

CREATE TABLE AS no copia restricciones

CREATE TABLE ... AS copia los datos y los tipos de columna, pero no copia PRIMARY KEY, FOREIGN KEY, NOT NULL, DEFAULT ni indices. Si necesitas esas restricciones, crea la tabla manualmente con CREATE TABLE y luego usa INSERT INTO ... SELECT para copiar los datos.

Tambien existe SELECT INTO, que hace lo mismo pero con otra sintaxis:

-- Equivalente a CREATE TABLE ... AS
SELECT * INTO productos_backup
FROM productos
WHERE stock > 50;

INSERT con RETURNING

Una funcionalidad muy util de PostgreSQL: podes obtener los datos insertados de vuelta, incluyendo los valores generados automaticamente como el id.

INSERT INTO clientes (nombre, email, ciudad, fecha_registro)
VALUES ('Felipe Rojas', 'felipe@email.com', 'Santiago', CURRENT_DATE)
RETURNING id, nombre;

Esto devuelve:

id | nombre
----+--------------
11 | Felipe Rojas

Muy practico cuando necesitas el id recien generado para usarlo en otra operacion (por ejemplo, crear un pedido para ese cliente).

-- Tambien funciona para devolver todas las columnas
INSERT INTO productos (nombre, precio, categoria, stock, fecha_creacion)
VALUES ('Parlante Portatil', 49.99, 'Electronica', 30, CURRENT_DATE)
RETURNING *;

UPDATE: modificando datos

Sintaxis basica

UPDATE productos
SET precio = 79.99
WHERE id = 5;

Podes actualizar varias columnas a la vez:

UPDATE productos
SET precio = 79.99,
stock = stock - 1,
nombre = 'Teclado Mecanico RGB'
WHERE id = 5;
⚠️

SIEMPRE usa WHERE con UPDATE

Un UPDATE sin WHERE modifica TODAS las filas de la tabla. Esto es casi siempre un error catastrofico:

-- PELIGRO: esto cambia el precio de TODOS los productos!
UPDATE productos SET precio = 0;
-- CORRECTO: solo cambia un producto especifico
UPDATE productos SET precio = 0 WHERE id = 42;

Antes de ejecutar un UPDATE, siempre preguntate: “cuantas filas va a afectar esto?”

UPDATE con expresiones

Podes usar expresiones y calculos en el SET:

-- Aumentar todos los precios un 10%
UPDATE productos
SET precio = ROUND(precio * 1.10, 2)
WHERE categoria = 'Electronica';
-- Reabastecer productos con stock bajo
UPDATE productos
SET stock = stock + 50
WHERE stock < 10;

UPDATE con subconsultas

Podes usar subconsultas para calcular el nuevo valor:

-- Igualar el precio al promedio de su categoria
UPDATE productos p
SET precio = (
SELECT ROUND(AVG(precio), 2)
FROM productos
WHERE categoria = p.categoria
)
WHERE precio < 10;

UPDATE con FROM (JOIN)

PostgreSQL permite usar FROM en un UPDATE para hacer JOINs, algo que no todos los motores soportan:

-- Marcar como completados los pedidos pendientes de clientes de Santiago
UPDATE pedidos
SET estado = 'completado'
FROM clientes
WHERE pedidos.cliente_id = clientes.id
AND clientes.ciudad = 'Santiago'
AND pedidos.estado = 'pendiente';

UPDATE con RETURNING

Al igual que INSERT, UPDATE tambien soporta RETURNING:

UPDATE productos
SET precio = ROUND(precio * 0.90, 2)
WHERE categoria = 'Accesorios'
RETURNING id, nombre, precio;

Esto te muestra exactamente que filas fueron actualizadas y con que valores.

Actualizar varios registros con valores distintos

A veces necesitas actualizar muchos registros a la vez, pero cada uno con un valor diferente. Por ejemplo, actualizar los precios de varios productos de una sola vez.

Opcion 1: UPDATE con CASE

La forma mas comun es usar CASE dentro del SET:

UPDATE productos
SET precio = CASE id
WHEN 1 THEN 299.99
WHEN 2 THEN 149.50
WHEN 3 THEN 89.99
WHEN 5 THEN 199.00
END
WHERE id IN (1, 2, 3, 5);
⚠️

No olvides el WHERE

El WHERE id IN (...) es fundamental. Sin el, las filas que no estan en el CASE reciben valor NULL porque el CASE no tiene ELSE. Siempre filtra solo los IDs que quieres modificar.

Tambien podes actualizar varias columnas a la vez:

UPDATE productos
SET
precio = CASE id
WHEN 1 THEN 299.99
WHEN 2 THEN 149.50
WHEN 3 THEN 89.99
END,
stock = CASE id
WHEN 1 THEN 50
WHEN 2 THEN 30
WHEN 3 THEN 100
END
WHERE id IN (1, 2, 3);

Opcion 2: UPDATE con FROM VALUES

Para muchos registros, esta sintaxis es mas limpia. Usas una lista de valores como si fuera una tabla temporal:

UPDATE productos
SET precio = nuevos.precio
FROM (VALUES
(1, 299.99),
(2, 149.50),
(3, 89.99),
(5, 199.00)
) AS nuevos(id, precio)
WHERE productos.id = nuevos.id;

Tambien funciona con varias columnas:

UPDATE productos
SET
precio = nuevos.precio,
stock = nuevos.stock
FROM (VALUES
(1, 299.99, 50),
(2, 149.50, 30),
(3, 89.99, 100)
) AS nuevos(id, precio, stock)
WHERE productos.id = nuevos.id;

¿CASE o FROM VALUES?

Ambos logran lo mismo. CASE es mas facil de entender para principiantes. FROM VALUES es mas limpio cuando tienes muchos registros o varias columnas que actualizar, y se parece mas a un JOIN.


DELETE: eliminando datos

Sintaxis basica

DELETE FROM productos
WHERE id = 42;
⚠️

SIEMPRE usa WHERE con DELETE

Igual que con UPDATE, un DELETE sin WHERE elimina TODAS las filas de la tabla:

-- PELIGRO: esto borra TODOS los productos!
DELETE FROM productos;
-- CORRECTO: solo borra productos sin stock
DELETE FROM productos WHERE stock = 0;

No hay “Ctrl+Z” en SQL. Una vez que eliminas datos, se fueron (a menos que estes en una transaccion que puedas revertir).

DELETE con subconsultas

-- Eliminar pedidos de clientes que ya no existen
DELETE FROM pedidos
WHERE cliente_id NOT IN (
SELECT id FROM clientes
);
-- Mejor con NOT EXISTS (mas seguro con NULLs)
DELETE FROM pedidos p
WHERE NOT EXISTS (
SELECT 1 FROM clientes c WHERE c.id = p.cliente_id
);

DELETE con RETURNING

DELETE FROM productos
WHERE stock = 0 AND fecha_creacion < CURRENT_DATE - INTERVAL '1 year'
RETURNING id, nombre;

Esto te muestra que datos eliminaste, util para llevar un log o confirmar la operacion.


TRUNCATE vs DELETE

Si necesitas vaciar una tabla completa, TRUNCATE es mucho mas eficiente que DELETE:

-- Lento: recorre fila por fila, genera logs por cada una
DELETE FROM logs_temporales;
-- Rapido: elimina todas las filas de golpe
TRUNCATE TABLE logs_temporales;
Comando Descripcion
Velocidad DELETE es lento (fila por fila). TRUNCATE es instantaneo.
Se puede filtrar? DELETE acepta WHERE. TRUNCATE no.
Dispara triggers? DELETE si. TRUNCATE no (por defecto).
Se puede revertir? Ambos se pueden revertir dentro de una transaccion.
Reinicia secuencias? DELETE no. TRUNCATE puede con RESTART IDENTITY.
-- Truncar y reiniciar el contador de IDs
TRUNCATE TABLE logs_temporales RESTART IDENTITY;

Rendimiento y seguridad

UPDATE/DELETE sin WHERE = desastre de rendimiento

Un UPDATE o DELETE sin WHERE hace un full table scan y modifica cada fila. En una tabla con millones de filas, esto puede tomar minutos u horas, bloquear otras consultas y llenar el log de transacciones. Siempre verifica tu WHERE.

La regla de oro: SELECT antes de UPDATE/DELETE

Antes de ejecutar cualquier UPDATE o DELETE, primero ejecuta la misma consulta como SELECT para verificar que afecta las filas correctas:

-- Paso 1: verificar que vamos a modificar
SELECT id, nombre, precio FROM productos
WHERE precio < 20;
-- Resultado: ver cuantas filas y cuales son. OK, es lo que esperaba.
-- Paso 2: ahora si, ejecutar el DELETE
DELETE FROM productos
WHERE precio < 20;
-- DELETE N (confirma cuantas filas se borraron)

Este habito te va a salvar de errores costosos. Todos hemos escuchado (o vivido) historias de un DELETE que borro la tabla entera de produccion.

Otro consejo practico: usa transacciones cuando hagas operaciones peligrosas:

BEGIN; -- Iniciar transaccion
DELETE FROM pedidos WHERE estado = 'pendiente' AND fecha < '2024-06-01';
-- Verificar cuantas filas se borraron...
-- Si algo salio mal:
-- ROLLBACK;
-- Si todo esta bien:
COMMIT;

ALTER TABLE: modificando la estructura

Los comandos anteriores (INSERT, UPDATE, DELETE) modifican datos. Pero a veces necesitas modificar la estructura de una tabla: agregar columnas, eliminarlas o cambiar sus propiedades. Para eso existe ALTER TABLE.

Agregar una columna

ALTER TABLE productos
ADD COLUMN descripcion TEXT;

La nueva columna se agrega con valor NULL en todas las filas existentes. Podes agregar un valor por defecto:

ALTER TABLE productos
ADD COLUMN activo BOOLEAN DEFAULT TRUE;

Ahora todas las filas existentes tendran activo = TRUE, y las nuevas filas tambien (a menos que especifiques otro valor).

NOT NULL: columnas obligatorias

NOT NULL significa que la columna siempre debe tener un valor — no puede quedar vacia. Es una restriccion fundamental para proteger la integridad de tus datos:

-- Al crear la tabla
CREATE TABLE ejemplo (
id SERIAL PRIMARY KEY,
nombre VARCHAR(100) NOT NULL, -- obligatorio
email VARCHAR(100), -- opcional (acepta NULL)
activo BOOLEAN NOT NULL DEFAULT TRUE -- obligatorio, con default
);

Si intentas insertar una fila sin darle valor a una columna NOT NULL (y no tiene default), PostgreSQL da error:

INSERT INTO ejemplo (email) VALUES ('test@email.com');
-- ERROR: null value in column "nombre" violates not-null constraint

Agregar columna NOT NULL a tabla existente

Si agregas una columna NOT NULL a una tabla que ya tiene datos, debes incluir un DEFAULT. De lo contrario PostgreSQL no sabra que valor asignar a las filas existentes y dara error:

-- ERROR: la tabla tiene filas, no puede ser NULL sin default
ALTER TABLE productos ADD COLUMN codigo VARCHAR(20) NOT NULL;
-- CORRECTO: con default funciona
ALTER TABLE productos ADD COLUMN codigo VARCHAR(20) NOT NULL DEFAULT 'SIN-CODIGO';

SERIAL: columnas auto-incrementales

SERIAL es un tipo especial que genera un numero entero auto-incremental. Cada vez que insertas una fila, PostgreSQL asigna automaticamente el siguiente numero. Es la forma mas comun de crear IDs:

CREATE TABLE categorias (
id SERIAL PRIMARY KEY, -- 1, 2, 3, 4, ...
nombre VARCHAR(50) NOT NULL
);
-- No necesitas especificar el id al insertar
INSERT INTO categorias (nombre) VALUES ('Electronica'); -- id = 1
INSERT INTO categorias (nombre) VALUES ('Muebles'); -- id = 2
INSERT INTO categorias (nombre) VALUES ('Accesorios'); -- id = 3
💡

¿Que es SERIAL realmente?

SERIAL no es un tipo de dato verdadero. Es un atajo de PostgreSQL que hace 3 cosas por ti:

  1. Crea la columna como INTEGER NOT NULL
  2. Crea una secuencia (un contador) asociada a la columna
  3. Establece el default como nextval('nombre_secuencia')

Para tablas nuevas, PostgreSQL recomienda usar GENERATED ALWAYS AS IDENTITY como alternativa mas moderna:

CREATE TABLE categorias (
id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
nombre VARCHAR(50) NOT NULL
);

Eliminar una columna

ALTER TABLE productos
DROP COLUMN descripcion;
⚠️

Eliminar columnas es irreversible

DROP COLUMN elimina la columna y todos sus datos de forma permanente. No hay forma de recuperarla (salvo un backup). Siempre verifica antes de ejecutar.

Si la columna tiene dependencias (foreign keys, vistas, indices), PostgreSQL te dara un error. Podes forzar la eliminacion con CASCADE, pero usalo con cuidado:

-- Eliminar la columna y todo lo que dependa de ella
ALTER TABLE productos
DROP COLUMN categoria CASCADE;

Renombrar una columna

ALTER TABLE productos
RENAME COLUMN stock TO cantidad_disponible;

Renombrar una tabla

ALTER TABLE productos_backup
RENAME TO productos_respaldo;

Esto cambia el nombre de la tabla en toda la base de datos. Las foreign keys que apuntan a esta tabla se actualizan automaticamente, pero las consultas guardadas o vistas que la referencian por el nombre antiguo dejaran de funcionar.

Cambiar el tipo de dato

ALTER TABLE productos
ALTER COLUMN nombre TYPE VARCHAR(200);
💡

Conversiones de tipo

No todas las conversiones son posibles. Por ejemplo, podes ampliar un VARCHAR(50) a VARCHAR(200), pero convertir un TEXT con letras a INTEGER va a fallar si hay datos que no son numeros. PostgreSQL te avisara con un error antes de hacer nada.

Resumen de ALTER TABLE

Comando Descripcion
ADD COLUMN nombre tipo Agrega una nueva columna a la tabla
DROP COLUMN nombre Elimina una columna y todos sus datos
RENAME COLUMN viejo TO nuevo Cambia el nombre de una columna
RENAME TO nuevo_nombre Cambia el nombre de la tabla
ALTER COLUMN nombre TYPE tipo Cambia el tipo de dato de una columna
ALTER COLUMN nombre SET DEFAULT valor Establece un valor por defecto
ALTER COLUMN nombre SET NOT NULL Hace la columna obligatoria (no acepta NULL)
ALTER COLUMN nombre DROP NOT NULL Permite NULL en la columna

DROP TABLE: eliminando tablas

Eliminar una tabla

DROP TABLE productos_premium;

Esto elimina la tabla completa: estructura, datos, indices, todo. Es irreversible.

DROP TABLE IF EXISTS

Si intentas eliminar una tabla que no existe, PostgreSQL da error. Para evitarlo, usa IF EXISTS:

-- Sin IF EXISTS: da error si la tabla no existe
DROP TABLE productos_premium; -- ERROR: table "productos_premium" does not exist
-- Con IF EXISTS: no da error, simplemente no hace nada
DROP TABLE IF EXISTS productos_premium; -- NOTICE: table does not exist, skipping

Cuando usar IF EXISTS

IF EXISTS es especialmente util en scripts de inicializacion. Es comun ver este patron al inicio de un script que crea tablas:

-- Borrar tablas si existen (para empezar limpio)
DROP TABLE IF EXISTS detalle_pedidos;
DROP TABLE IF EXISTS pedidos;
DROP TABLE IF EXISTS productos;
DROP TABLE IF EXISTS clientes;
DROP TABLE IF EXISTS empleados;
-- Crear las tablas desde cero
CREATE TABLE clientes ( ... );
CREATE TABLE productos ( ... );
-- etc.

Asi el script se puede ejecutar multiples veces sin errores.

⚠️

Cuidado con el orden de eliminacion

Si una tabla tiene foreign keys que la referencian, no la podes eliminar directamente. Debes eliminar primero las tablas que dependen de ella, o usar CASCADE:

-- Error: pedidos referencia a clientes
DROP TABLE clientes;
-- Opcion 1: eliminar en orden correcto
DROP TABLE IF EXISTS detalle_pedidos;
DROP TABLE IF EXISTS pedidos;
DROP TABLE IF EXISTS clientes;
-- Opcion 2: forzar con CASCADE (elimina las FK automaticamente)
DROP TABLE clientes CASCADE;

CREATE TABLE IF NOT EXISTS

El complemento de DROP TABLE IF EXISTS. Crea la tabla solo si no existe:

CREATE TABLE IF NOT EXISTS logs (
id SERIAL PRIMARY KEY,
mensaje TEXT,
fecha TIMESTAMP DEFAULT NOW()
);

Esto es util para scripts que pueden ejecutarse multiples veces sin fallar.


Ejercicios

Ejercicio 1: Inserta 3 nuevos productos en la tabla productos usando un solo INSERT. Usa RETURNING para ver los IDs generados.

INSERT INTO productos (nombre, precio, categoria, stock, fecha_creacion)
VALUES
('Adaptador DisplayPort', 18.99, 'Accesorios', 60, CURRENT_DATE),
('Reposamuñecas Gel', 22.50, 'Accesorios', 80, CURRENT_DATE),
('Parlante Portatil', 65.00, 'Electronica', 35, CURRENT_DATE)
RETURNING id, nombre, precio;

Ejercicio 2: Aumenta un 15% el precio de todos los productos de la categoria ‘Electronica’. Muestra los productos afectados con RETURNING.

UPDATE productos
SET precio = ROUND(precio * 1.15, 2)
WHERE categoria = 'Electronica'
RETURNING id, nombre, precio;

Ejercicio 3: Escribe el SELECT que usarias para verificar antes de eliminar todos los pedidos pendientes anteriores a marzo 2024. Luego escribe el DELETE correspondiente.

-- Paso 1: verificar
SELECT id, cliente_id, fecha, estado
FROM pedidos
WHERE estado = 'pendiente' AND fecha < '2024-03-01';
-- Paso 2: eliminar (solo despues de verificar)
DELETE FROM pedidos
WHERE estado = 'pendiente' AND fecha < '2024-03-01'
RETURNING id;

Ejercicio 4: Usa UPDATE para marcar como ‘completado’ todos los pedidos con estado ‘enviado’ cuyo total supere el promedio. (Primero escribe el SELECT de verificacion).

-- Verificacion
SELECT p.id, p.total, p.estado
FROM pedidos p
WHERE p.estado = 'enviado'
AND p.total > (SELECT AVG(total) FROM pedidos);
-- Update
UPDATE pedidos
SET estado = 'completado'
WHERE estado = 'enviado'
AND total > (SELECT AVG(total) FROM pedidos)
RETURNING id, total, estado;

Ejercicio 5: Inserta en la tabla pedidos un nuevo pedido para el cliente con email ‘maria@email.com’, usando una subconsulta para obtener su id.

INSERT INTO pedidos (cliente_id, fecha, total, estado)
VALUES (
(SELECT id FROM clientes WHERE email = 'maria@email.com'),
CURRENT_DATE,
0,
'pendiente'
)
RETURNING *;

Ejercicio 6: Agrega una columna telefono VARCHAR(20) a la tabla clientes. Luego actualiza el telefono de Maria Garcia a ‘+56 9 1234 5678’. Finalmente, elimina la columna.

-- Agregar columna
ALTER TABLE clientes ADD COLUMN telefono VARCHAR(20);
-- Actualizar un registro
UPDATE clientes SET telefono = '+56 9 1234 5678' WHERE nombre = 'Maria Garcia';
-- Verificar
SELECT nombre, email, telefono FROM clientes WHERE nombre = 'Maria Garcia';
-- Eliminar columna (cuando ya no la necesites)
ALTER TABLE clientes DROP COLUMN telefono;

Ejercicio 7: Escribe un script que elimine la tabla productos_premium (si existe) y la vuelva a crear con las columnas: id, nombre, precio y fecha_agregado. Luego inserta los 3 productos mas caros desde la tabla productos.

DROP TABLE IF EXISTS productos_premium;
CREATE TABLE productos_premium (
id SERIAL PRIMARY KEY,
nombre VARCHAR(100),
precio DECIMAL(10,2),
fecha_agregado DATE DEFAULT CURRENT_DATE
);
INSERT INTO productos_premium (nombre, precio)
SELECT nombre, precio
FROM productos
ORDER BY precio DESC
LIMIT 3;
11

Rendimiento y optimizacion

Llegamos al modulo mas importante del curso. En serio. Puedes saber escribir SQL perfecto, pero si tus consultas tardan 30 segundos en una tabla con millones de filas, tienes un problema gordo. En este modulo vas a aprender como piensa PostgreSQL cuando ejecuta tus consultas y, mas importante, como hacer que piense mas rapido.


Como ejecuta PostgreSQL una consulta

Cuando escribes una consulta SQL y presionas Enter, PostgreSQL no la ejecuta directamente. Pasa por varias etapas:

  1. Parsing: Verifica que tu SQL sea valido sintacticamente. Si escribiste SLECT en vez de SELECT, aqui muere.
  2. Rewriting: Aplica reglas internas (como expandir vistas).
  3. Planning/Optimizing: El planificador analiza multiples estrategias para ejecutar tu consulta y elige la que estima que sera mas barata.
  4. Execution: Ejecuta el plan elegido y te devuelve los resultados.

La etapa clave es la 3. PostgreSQL tiene un optimizador basado en costos: calcula cuanto “costaria” cada estrategia posible y elige la mas barata. Y aqui es donde tu puedes influir enormemente.

💡

Analogia

Imagina que necesitas ir del punto A al punto B en una ciudad. Puedes ir caminando (lento pero seguro), en auto por la autopista (rapido si no hay trafico), o en metro (rapido pero con transbordos). El planificador de PostgreSQL hace exactamente esto: evalua las rutas posibles y elige la mejor segun las condiciones actuales.


EXPLAIN: Tu herramienta de diagnostico

EXPLAIN es el estetoscopio del desarrollador SQL. Te muestra que plan eligio PostgreSQL sin ejecutar la consulta:

EXPLAIN SELECT * FROM productos WHERE precio > 100;

Resultado tipico:

Seq Scan on productos (cost=0.00..25.00 rows=500 width=64)
Filter: (precio > 100)

Vamos a desmenuzar esto:

  • Seq Scan on productos: Va a leer la tabla completa, fila por fila (Sequential Scan).
  • cost=0.00..25.00: Costo estimado. El primer numero es el costo de arranque, el segundo es el costo total.
  • rows=500: Estima que devolvera 500 filas.
  • width=64: Cada fila ocupa aproximadamente 64 bytes.

Para ver que realmente pasa (con tiempos reales), usa EXPLAIN ANALYZE:

EXPLAIN ANALYZE SELECT * FROM productos WHERE precio > 100;
Seq Scan on productos (cost=0.00..25.00 rows=500 width=64)
(actual time=0.015..0.320 rows=487 loops=1)
Filter: (precio > 100)
Rows Removed by Filter: 513
Planning Time: 0.085 ms
Execution Time: 0.450 ms

Ahora ves datos reales:

  • actual time=0.015..0.320: Tardo 0.015 ms en devolver la primera fila y 0.320 ms en total.
  • rows=487: Devolvio 487 filas realmente (el estimado era 500, bastante cerca).
  • Rows Removed by Filter: 513: Leyo 513 filas que no cumplian el filtro. Eso es trabajo desperdiciado.
  • Planning Time: Cuanto tardo en elegir el plan.
  • Execution Time: Cuanto tardo en ejecutarlo.
⚠️

Cuidado con EXPLAIN ANALYZE

EXPLAIN ANALYZE si ejecuta la consulta. Si haces EXPLAIN ANALYZE DELETE FROM clientes;… si, borra todos los clientes. Para consultas destructivas, usa solo EXPLAIN (sin ANALYZE) o envuelve en una transaccion que luego haces rollback:

BEGIN;
EXPLAIN ANALYZE DELETE FROM clientes WHERE ciudad = 'Test';
ROLLBACK;

Tipos de escaneo: como lee PostgreSQL tus datos

Sequential Scan (Seq Scan)

Lee toda la tabla, fila por fila. Es como leer un libro de principio a fin buscando una palabra.

EXPLAIN SELECT * FROM productos;
Seq Scan on productos (cost=0.00..22.00 rows=1000 width=64)

Para tablas pequenas o cuando necesitas la mayoria de las filas, esto esta bien. El problema es cuando la tabla tiene millones de filas y solo necesitas unas pocas.

Complejidad: O(n) - Tiene que mirar cada fila.

Index Scan

Usa un indice para ir directamente a las filas que necesita. Es como usar el indice de un libro para encontrar un tema especifico.

-- Suponiendo que existe un indice en la columna precio
EXPLAIN SELECT * FROM productos WHERE precio = 99.99;
Index Scan using idx_productos_precio on productos (cost=0.28..8.30 rows=1 width=64)
Index Cond: (precio = 99.99)

Mucho mas rapido. En vez de leer 1000 filas, va directo a las que cumplen la condicion.

Complejidad: O(log n) - Busqueda en el arbol del indice.

Index Only Scan

Aun mejor que Index Scan. Si toda la informacion que necesitas esta en el indice, PostgreSQL ni siquiera necesita ir a la tabla:

-- Con un indice en (precio)
EXPLAIN SELECT precio FROM productos WHERE precio > 100;
Index Only Scan using idx_productos_precio on productos (cost=0.28..12.50 rows=500 width=8)
Index Cond: (precio > 100)

Complejidad: O(log n + k) donde k es el numero de resultados.

Bitmap Index Scan + Bitmap Heap Scan

Un enfoque hibrido. Primero usa el indice para crear un “mapa de bits” de las paginas que contienen filas relevantes, luego lee esas paginas:

EXPLAIN SELECT * FROM productos WHERE precio BETWEEN 50 AND 150;
Bitmap Heap Scan on productos (cost=5.00..20.00 rows=300 width=64)
Recheck Cond: (precio >= 50 AND precio <= 150)
-> Bitmap Index Scan on idx_productos_precio (cost=0.00..4.93 rows=300 width=0)
Index Cond: (precio >= 50 AND precio <= 150)

PostgreSQL elige esto cuando hay muchas filas que coinciden pero no tantas como para justificar un Seq Scan completo.

Cuando usa cada tipo de escaneo

  • Seq Scan: Tablas pequenas o cuando se necesita >10-15% de las filas.
  • Index Scan: Pocas filas coinciden y necesitas columnas que no estan en el indice.
  • Index Only Scan: Pocas filas coinciden y todas las columnas estan en el indice.
  • Bitmap Scan: Numero moderado de filas coinciden (entre lo que justifica Index Scan y Seq Scan).

Indices: el turbo de tus consultas

Un indice es como el indice al final de un libro. En vez de leer todo el libro para encontrar “PostgreSQL”, vas al indice, buscas la P, y te dice exactamente en que paginas aparece.

En PostgreSQL, el tipo de indice mas comun es el B-tree (arbol balanceado). Imagina un arbol donde:

  • La raiz te dice “los valores menores a 500 estan a la izquierda, los mayores a la derecha”
  • Cada nivel refina mas la busqueda
  • Las hojas contienen los valores reales y un puntero a la fila de la tabla
[500]
/ \
[200,350] [700,900]
/ | \ / | \
[filas] ... [filas] ... [filas]

Esto permite encontrar cualquier valor en O(log n). En una tabla con 1 millon de filas, un B-tree necesita solo unas 20 comparaciones para encontrar una fila, en vez de revisar el millon completo.

Crear un indice

-- Indice simple
CREATE INDEX idx_productos_precio ON productos(precio);
-- Indice en multiples columnas (compuesto)
CREATE INDEX idx_productos_cat_precio ON productos(categoria, precio);
-- Indice unico
CREATE UNIQUE INDEX idx_clientes_email ON clientes(email);
💡

Dato

PostgreSQL crea automaticamente un indice para cada PRIMARY KEY y cada restriccion UNIQUE. No necesitas crearlos manualmente.

Cuando los indices ayudan

Los indices son geniales para:

-- Busquedas por igualdad
SELECT * FROM clientes WHERE email = 'ana@mail.com';
-- Busquedas por rango
SELECT * FROM productos WHERE precio BETWEEN 10 AND 50;
-- Ordenamiento
SELECT * FROM productos ORDER BY precio LIMIT 10;
-- JOINs (en las columnas de union)
SELECT c.nombre, p.total
FROM clientes c
JOIN pedidos p ON c.id = p.cliente_id;

Cuando los indices NO ayudan

-- Tablas muy pequenas (el Seq Scan es mas rapido)
SELECT * FROM categorias; -- si tiene 10 filas, un indice estorba
-- Cuando seleccionas la mayoria de las filas
SELECT * FROM productos WHERE stock > 0; -- si el 95% tiene stock, no sirve
-- Cuando usas funciones sobre la columna indexada
SELECT * FROM clientes WHERE UPPER(nombre) = 'ANA'; -- NO usa el indice en nombre
SELECT * FROM clientes WHERE nombre = 'Ana'; -- SI usa el indice
-- Cuando usas LIKE con comodin al inicio
SELECT * FROM productos WHERE nombre LIKE '%laptop%'; -- NO usa indice
SELECT * FROM productos WHERE nombre LIKE 'Laptop%'; -- SI puede usar indice
⚠️

Regla de oro

Si aplicas una funcion a una columna indexada en el WHERE, el indice no se usa. Esto es uno de los errores mas comunes. En vez de WHERE UPPER(nombre) = 'ANA', crea un indice funcional: CREATE INDEX idx_nombre_upper ON clientes(UPPER(nombre));


Algoritmos de JOIN

Cuando haces un JOIN, PostgreSQL tiene tres estrategias principales:

Nested Loop Join

Para cada fila de la tabla A, recorre todas las filas de la tabla B buscando coincidencias.

Para cada fila en clientes: -- n filas
Para cada fila en pedidos: -- m filas
Si coincide, emitir fila

Complejidad: O(n * m) sin indice, O(n * log m) con indice en la tabla interna.

Es bueno cuando una de las tablas es muy pequena o cuando hay un indice en la columna de JOIN de la tabla interna.

EXPLAIN ANALYZE
SELECT c.nombre, p.total
FROM clientes c
JOIN pedidos p ON c.id = p.cliente_id
WHERE c.ciudad = 'Madrid';
Nested Loop (cost=0.29..45.50 rows=15 actual time=0.020..0.150)
-> Seq Scan on clientes c (cost=0.00..12.00 rows=5 actual time=0.010..0.030)
Filter: (ciudad = 'Madrid')
-> Index Scan using idx_pedidos_cliente on pedidos p (cost=0.29..6.50 rows=3 actual time=0.010..0.020)
Index Cond: (cliente_id = c.id)

Solo 5 clientes de Madrid, y para cada uno busca sus pedidos por indice. Rapido.

Hash Join

Construye una tabla hash en memoria con la tabla mas pequena, y luego recorre la tabla grande buscando en el hash:

1. Construir hash de clientes (tabla pequena)
2. Para cada fila en pedidos:
Buscar en hash si existe cliente_id

Complejidad: O(n + m) - Construir el hash es O(n), recorrer la otra tabla es O(m).

EXPLAIN ANALYZE
SELECT c.nombre, p.total
FROM clientes c
JOIN pedidos p ON c.id = p.cliente_id;
Hash Join (cost=15.00..52.00 rows=1000 actual time=0.200..1.500)
Hash Cond: (p.cliente_id = c.id)
-> Seq Scan on pedidos p (cost=0.00..30.00 rows=1000)
-> Hash (cost=12.00..12.00 rows=200)
-> Seq Scan on clientes c (cost=0.00..12.00 rows=200)

Ideal para JOINs grandes donde no hay indice pero una tabla cabe en memoria.

Merge Join

Ordena ambas tablas por la columna de JOIN y luego las recorre en paralelo (como mezclar dos mazos de cartas ya ordenados):

1. Ordenar clientes por id
2. Ordenar pedidos por cliente_id
3. Recorrer ambas listas en paralelo, emitiendo coincidencias

Complejidad: O(n log n + m log m) por la ordenacion, luego O(n + m) para el merge.

Merge Join (cost=80.00..120.00 rows=1000)
Merge Cond: (c.id = p.cliente_id)
-> Sort (cost=30.00..32.00 rows=200)
Sort Key: c.id
-> Seq Scan on clientes c
-> Sort (cost=50.00..55.00 rows=1000)
Sort Key: p.cliente_id
-> Seq Scan on pedidos p

Es eficiente cuando ambas tablas son grandes y ya estan (o pueden ser) ordenadas.

Comando Descripcion
Nested Loop Una tabla muy pequena o hay indice en la tabla interna
Hash Join Tablas medianas/grandes sin indice, una cabe en memoria
Merge Join Tablas grandes ya ordenadas o cuando el resultado debe estar ordenado

Indices compuestos y orden de columnas

Un indice compuesto tiene multiples columnas. El orden importa muchisimo:

CREATE INDEX idx_prod_cat_precio ON productos(categoria, precio);

Este indice es util para:

-- Filtra por categoria (primera columna): SI usa el indice
SELECT * FROM productos WHERE categoria = 'Electronica';
-- Filtra por categoria Y precio: SI usa el indice (ambas columnas)
SELECT * FROM productos WHERE categoria = 'Electronica' AND precio < 500;
-- Filtra SOLO por precio (segunda columna): NO usa el indice eficientemente
SELECT * FROM productos WHERE precio < 500;

Regla del prefijo izquierdo

Un indice compuesto (A, B, C) sirve para consultas que filtran por:

  • A
  • A y B
  • A, B y C

Pero no para consultas que filtran solo por B, solo por C, o por B y C sin A. Piensa en una guia telefonica ordenada por (apellido, nombre): puedes buscar por apellido, o por apellido + nombre, pero no puedes buscar eficientemente solo por nombre.

Veamos el impacto con EXPLAIN:

-- Con indice en (categoria, precio)
EXPLAIN ANALYZE SELECT * FROM productos
WHERE categoria = 'Electronica' AND precio < 500;
Index Scan using idx_prod_cat_precio on productos
(cost=0.28..15.00 rows=45 actual time=0.020..0.150)
Index Cond: (categoria = 'Electronica' AND precio < 500)
-- Consulta que NO aprovecha el indice compuesto
EXPLAIN ANALYZE SELECT * FROM productos WHERE precio < 500;
Seq Scan on productos (cost=0.00..25.00 rows=800 actual time=0.010..0.400)
Filter: (precio < 500)

La primera consulta usa el indice y escanea solo 45 filas. La segunda hace Seq Scan y lee toda la tabla.


Covering indexes (indices cubrientes)

Un covering index incluye todas las columnas que tu consulta necesita. Esto permite un Index Only Scan, que es el escaneo mas rapido posible porque nunca necesita ir a la tabla:

-- Queremos ejecutar frecuentemente esta consulta:
SELECT nombre, precio FROM productos WHERE categoria = 'Electronica';
-- Creamos un indice que "cubra" todas las columnas necesarias:
CREATE INDEX idx_prod_covering ON productos(categoria) INCLUDE (nombre, precio);
EXPLAIN ANALYZE SELECT nombre, precio FROM productos WHERE categoria = 'Electronica';
Index Only Scan using idx_prod_covering on productos
(cost=0.28..5.50 rows=50 actual time=0.015..0.080)
Index Cond: (categoria = 'Electronica')
Heap Fetches: 0

Heap Fetches: 0 significa que NO tuvo que ir a la tabla para nada. Todo estaba en el indice.

Rendimiento

Un Index Only Scan con Heap Fetches: 0 es practicamente lo mas rapido que puedes lograr. Pero ten cuidado: los covering indexes ocupan mas espacio y hacen los INSERT/UPDATE mas lentos.


El costo de los indices

Los indices no son gratis. Cada indice:

  1. Ocupa espacio en disco: Un indice puede ocupar tanto como la tabla misma.
  2. Hace mas lento el INSERT: Cada fila nueva debe insertarse en la tabla Y en cada indice.
  3. Hace mas lento el UPDATE: Si actualizas una columna indexada, el indice tambien se actualiza.
  4. Hace mas lento el DELETE: Igual, hay que actualizar el indice.
-- Ver el tamano de tus indices
SELECT
indexname,
pg_size_pretty(pg_relation_size(indexname::regclass)) AS tamano
FROM pg_indexes
WHERE tablename = 'productos';
⚠️

No indexes todo

No crees un indice en cada columna “por si acaso”. Analiza que consultas se ejecutan frecuentemente, usa EXPLAIN, y crea indices solo donde realmente se necesiten. Un indice que no se usa es puro desperdicio.


Tips de optimizacion de consultas

Aqui van las tecnicas mas efectivas para escribir consultas rapidas:

1. Selecciona solo las columnas que necesitas

-- Malo: trae TODAS las columnas
SELECT * FROM productos WHERE categoria = 'Electronica';
-- Bueno: trae solo lo necesario
SELECT nombre, precio FROM productos WHERE categoria = 'Electronica';

Menos columnas = menos datos transferidos = mas rapido. Ademas, permite usar Index Only Scan.

2. Filtra temprano con WHERE

-- Malo: primero une todo, luego filtra
SELECT c.nombre, SUM(p.total)
FROM clientes c
JOIN pedidos p ON c.id = p.cliente_id
GROUP BY c.nombre
HAVING c.nombre LIKE 'A%';
-- Bueno: filtra antes de unir
SELECT c.nombre, SUM(p.total)
FROM clientes c
JOIN pedidos p ON c.id = p.cliente_id
WHERE c.nombre LIKE 'A%'
GROUP BY c.nombre;

3. Evita funciones en columnas indexadas

-- Malo: la funcion impide usar el indice
SELECT * FROM pedidos WHERE EXTRACT(YEAR FROM fecha) = 2024;
-- Bueno: rango que SI usa el indice
SELECT * FROM pedidos
WHERE fecha >= '2024-01-01' AND fecha < '2025-01-01';

4. Usa EXISTS en vez de IN para subconsultas grandes

-- Mas lento con tablas grandes
SELECT * FROM clientes
WHERE id IN (SELECT cliente_id FROM pedidos);
-- Mas rapido: EXISTS se detiene en la primera coincidencia
SELECT * FROM clientes c
WHERE EXISTS (SELECT 1 FROM pedidos p WHERE p.cliente_id = c.id);

Por que EXISTS es mas rapido

IN necesita materializar toda la subconsulta primero. EXISTS se detiene tan pronto encuentra la primera coincidencia para cada fila. Si un cliente tiene 100 pedidos, IN los encuentra todos, EXISTS encuentra el primero y sigue con el siguiente cliente.

5. Prefiere JOINs sobre subconsultas correlacionadas

-- Malo: subconsulta se ejecuta una vez POR CADA cliente
SELECT c.nombre,
(SELECT COUNT(*) FROM pedidos p WHERE p.cliente_id = c.id) AS total_pedidos
FROM clientes c;
-- Bueno: un solo JOIN con agregacion
SELECT c.nombre, COUNT(p.id) AS total_pedidos
FROM clientes c
LEFT JOIN pedidos p ON c.id = p.cliente_id
GROUP BY c.nombre;

6. Usa LIMIT cuando no necesitas todas las filas

-- Si solo quieres los 10 productos mas caros:
SELECT nombre, precio FROM productos ORDER BY precio DESC LIMIT 10;

Con un indice en precio, esto es practicamente instantaneo porque no necesita ordenar toda la tabla.

7. Operaciones masivas vs fila por fila

-- Malo: insertar uno por uno (en un loop de aplicacion)
INSERT INTO productos (nombre, precio) VALUES ('Producto 1', 10);
INSERT INTO productos (nombre, precio) VALUES ('Producto 2', 20);
INSERT INTO productos (nombre, precio) VALUES ('Producto 3', 30);
-- Bueno: insertar todo de una vez
INSERT INTO productos (nombre, precio) VALUES
('Producto 1', 10),
('Producto 2', 20),
('Producto 3', 30);

El INSERT masivo es dramaticamente mas rapido porque reduce la sobrecarga de comunicacion con la base de datos.


Ejemplo completo: optimizando una consulta paso a paso

Supongamos que tenemos esta consulta lenta:

-- Consulta original
SELECT c.nombre, c.email, COUNT(*) AS total_pedidos, SUM(p.total) AS gasto_total
FROM clientes c, pedidos p
WHERE c.id = p.cliente_id
AND UPPER(c.ciudad) = 'MADRID'
AND EXTRACT(YEAR FROM p.fecha) = 2024
GROUP BY c.nombre, c.email
ORDER BY gasto_total DESC;

Veamos el EXPLAIN:

EXPLAIN ANALYZE SELECT c.nombre, c.email, COUNT(*), SUM(p.total)
FROM clientes c, pedidos p
WHERE c.id = p.cliente_id
AND UPPER(c.ciudad) = 'MADRID'
AND EXTRACT(YEAR FROM p.fecha) = 2024
GROUP BY c.nombre, c.email
ORDER BY SUM(p.total) DESC;
Sort (cost=150.00..151.00 rows=50)
Sort Key: (sum(p.total)) DESC
-> HashAggregate (cost=140.00..145.00 rows=50)
-> Hash Join (cost=15.00..130.00 rows=200)
Hash Cond: (p.cliente_id = c.id)
-> Seq Scan on pedidos p (cost=0.00..100.00 rows=2000)
Filter: (EXTRACT(YEAR FROM fecha) = 2024)
-> Hash (cost=12.00..12.00 rows=50)
-> Seq Scan on clientes c (cost=0.00..12.00 rows=50)
Filter: (upper(ciudad) = 'MADRID')
Planning Time: 0.200 ms
Execution Time: 5.500 ms

Problemas identificados:

  1. Seq Scan en pedidos con filtro por funcion (EXTRACT)
  2. Seq Scan en clientes con filtro por funcion (UPPER)
  3. JOIN implicito (estilo viejo con coma)

Optimizacion paso a paso:

-- Paso 1: Usar JOIN explicito y evitar funciones en WHERE
SELECT c.nombre, c.email, COUNT(*) AS total_pedidos, SUM(p.total) AS gasto_total
FROM clientes c
JOIN pedidos p ON c.id = p.cliente_id
WHERE c.ciudad = 'Madrid'
AND p.fecha >= '2024-01-01' AND p.fecha < '2025-01-01'
GROUP BY c.nombre, c.email
ORDER BY gasto_total DESC;
-- Paso 2: Crear indices utiles
CREATE INDEX idx_clientes_ciudad ON clientes(ciudad);
CREATE INDEX idx_pedidos_fecha_cliente ON pedidos(fecha, cliente_id);

Ahora el EXPLAIN muestra:

Sort (cost=25.00..25.50 rows=50)
Sort Key: (sum(p.total)) DESC
-> HashAggregate (cost=20.00..22.00 rows=50)
-> Nested Loop (cost=1.00..18.00 rows=80)
-> Index Scan using idx_clientes_ciudad on clientes c (cost=0.28..5.00 rows=15)
Index Cond: (ciudad = 'Madrid')
-> Index Scan using idx_pedidos_fecha_cliente on pedidos p (cost=0.29..0.80 rows=5)
Index Cond: (fecha >= '2024-01-01' AND fecha < '2025-01-01' AND cliente_id = c.id)
Planning Time: 0.300 ms
Execution Time: 0.450 ms

De 5.5 ms a 0.45 ms. Mas de 12 veces mas rapido. Y en tablas con millones de filas, la diferencia seria de minutos a milisegundos.


Tabla resumen de complejidad

Comando Descripcion
Busqueda por clave primaria (con indice) **O(log n)** - Muy rapido
Index Scan **O(log n + k)** - k = filas devueltas
Sequential Scan **O(n)** - Lee toda la tabla
Ordenar sin indice (Sort) **O(n log n)**
Hash Join **O(n + m)** - Ambas tablas
Merge Join **O(n log n + m log m)** - Por la ordenacion
Nested Loop (sin indice) **O(n * m)** - Producto cartesiano
Nested Loop (con indice) **O(n * log m)** - Mucho mejor

Interpretacion practica

Con 1 millon de filas:

  • O(log n) ~ 20 operaciones
  • O(n) ~ 1,000,000 operaciones
  • O(n log n) ~ 20,000,000 operaciones
  • O(n * m) con m = 1,000,000 ~ 1,000,000,000,000 operaciones

La diferencia entre O(log n) y O(n) es literalmente la diferencia entre milisegundos y minutos.


VACUUM y ANALYZE: mantenimiento de la base

PostgreSQL necesita mantenimiento periodico:

  • VACUUM: Recupera espacio de filas eliminadas o actualizadas. PostgreSQL no borra filas fisicamente al hacer DELETE; las marca como “muertas”. VACUUM limpia esas filas muertas.
  • ANALYZE: Actualiza las estadisticas que usa el planificador para estimar costos. Si las estadisticas estan desactualizadas, el planificador puede elegir planes malos.
-- Ejecutar manualmente (normalmente lo hace autovacuum automaticamente)
VACUUM ANALYZE productos;
💡

Nota

PostgreSQL tiene un proceso llamado autovacuum que ejecuta VACUUM y ANALYZE automaticamente. En la mayoria de los casos no necesitas hacerlo manualmente, pero es bueno saber que existe por si diagnosticas problemas de rendimiento.


Ejercicios

Ejercicio 1: Mira este EXPLAIN y responde: que tipo de escaneo se esta usando? Es eficiente para una tabla con 10 millones de filas?

Seq Scan on pedidos (cost=0.00..250000.00 rows=5000000 width=44)
Filter: (estado = 'completado')
Rows Removed by Filter: 5000000
Ver solucion

Se esta usando un Sequential Scan. Para una tabla con 10 millones de filas, esto es ineficiente si solo necesitamos los pedidos completados (5 millones de 10 millones = 50%). Aunque 50% es bastante, un indice podria ayudar si las consultas son frecuentes. La solucion seria:

CREATE INDEX idx_pedidos_estado ON pedidos(estado);

Sin embargo, si el 50% de los pedidos son “completado”, PostgreSQL podria seguir eligiendo Seq Scan porque tiene que leer la mitad de la tabla de todos modos. Un indice brilla mas cuando el filtro es mas selectivo (por ejemplo, estado = ‘cancelado’ si solo hay un 2%).

Ejercicio 2: Esta consulta es lenta. Identifica los problemas y reescribela de forma optimizada:

SELECT *
FROM clientes
WHERE LOWER(email) LIKE '%gmail.com'
ORDER BY fecha_registro;
Ver solucion

Problemas:

  1. SELECT * trae columnas innecesarias.
  2. LOWER(email) impide usar un indice en email.
  3. LIKE '%gmail.com' con comodin al inicio no puede usar indice.
  4. ORDER BY fecha_registro sin indice requiere ordenar toda la tabla.

Solucion:

-- Crear indice para el ordenamiento
CREATE INDEX idx_clientes_fecha ON clientes(fecha_registro);
-- Seleccionar solo lo necesario
-- El LIKE con % al inicio es dificil de optimizar con indices normales.
-- Opciones: usar pg_trgm extension o reestructurar la consulta
SELECT nombre, email, fecha_registro
FROM clientes
WHERE email LIKE '%@gmail.com'
ORDER BY fecha_registro;

Para optimizar el LIKE con % al inicio, se puede usar la extension pg_trgm:

CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE INDEX idx_clientes_email_trgm ON clientes USING gin (email gin_trgm_ops);

Ejercicio 3: Crea los indices necesarios para optimizar esta consulta:

SELECT c.nombre, COUNT(p.id) AS total_pedidos, AVG(p.total) AS promedio
FROM clientes c
JOIN pedidos p ON c.id = p.cliente_id
WHERE c.ciudad = 'Barcelona'
AND p.fecha >= '2024-01-01'
GROUP BY c.nombre
ORDER BY promedio DESC
LIMIT 10;
Ver solucion
-- Indice para filtrar clientes por ciudad
CREATE INDEX idx_clientes_ciudad ON clientes(ciudad);
-- Indice compuesto para pedidos: filtra por fecha y une por cliente_id
CREATE INDEX idx_pedidos_fecha_cliente ON pedidos(fecha, cliente_id);
-- Alternativamente, si cliente_id es mas selectivo:
CREATE INDEX idx_pedidos_cliente_fecha ON pedidos(cliente_id, fecha);

El orden del indice compuesto depende de cual columna es mas selectiva. Si hay pocos clientes de Barcelona (digamos 20), entonces idx_pedidos_cliente_fecha es mejor porque para cada cliente el Nested Loop buscara directamente por cliente_id y luego filtrara por fecha.

Ejercicio 4: Explica por que esta consulta no usa el indice en fecha_creacion de la tabla productos:

CREATE INDEX idx_prod_fecha ON productos(fecha_creacion);
SELECT * FROM productos WHERE fecha_creacion + INTERVAL '30 days' > NOW();
Ver solucion

La operacion fecha_creacion + INTERVAL '30 days' aplica una funcion/operacion a la columna indexada. PostgreSQL no puede usar el indice porque la expresion transforma el valor antes de comparar.

La solucion es mover la operacion al otro lado:

SELECT * FROM productos WHERE fecha_creacion > NOW() - INTERVAL '30 days';

Ahora fecha_creacion esta “limpia” en el WHERE y PostgreSQL puede usar el indice para buscar directamente los valores mayores a la fecha calculada.

Ejercicio 5: Analiza el siguiente plan de ejecucion y sugiere como mejorarlo:

Hash Join (cost=400.00..2500.00 rows=50000 actual time=50.000..800.000)
Hash Cond: (dp.producto_id = pr.id)
-> Seq Scan on detalle_pedidos dp (cost=0.00..1500.00 rows=100000 actual time=0.020..200.000)
-> Hash (cost=200.00..200.00 rows=10000 actual time=45.000..45.000)
-> Seq Scan on productos pr (cost=0.00..200.00 rows=10000 actual time=0.010..30.000)
Planning Time: 0.500 ms
Execution Time: 850.000 ms
Ver solucion

El plan muestra un Hash Join entre detalle_pedidos (100,000 filas) y productos (10,000 filas). Ambas tablas usan Seq Scan, y el tiempo total es de 850 ms.

Mejoras:

  1. Crear un indice en detalle_pedidos.producto_id para evitar el Seq Scan:
CREATE INDEX idx_detalle_producto ON detalle_pedidos(producto_id);
  1. Si la consulta original filtra por algun criterio adicional (como una fecha o categoria), agregar ese filtro y un indice compuesto.

  2. Si no necesitas todas las columnas de ambas tablas, selecciona solo las necesarias para reducir el volumen de datos.

Con el indice, PostgreSQL podria cambiar a un Nested Loop con Index Scan en detalle_pedidos, o al menos usar un Bitmap Scan, lo que reduciria significativamente el tiempo.

Ejercicio 6: Tienes una tabla pedidos con 5 millones de filas. Esta consulta tarda 15 segundos:

SELECT estado, COUNT(*), AVG(total)
FROM pedidos
WHERE fecha BETWEEN '2024-01-01' AND '2024-12-31'
GROUP BY estado
ORDER BY COUNT(*) DESC;

Que harias para optimizarla? Piensa en indices, y si un Index Only Scan seria posible.

Ver solucion
-- Indice compuesto que cubre las columnas del WHERE, GROUP BY y SELECT
CREATE INDEX idx_pedidos_fecha_estado_total ON pedidos(fecha, estado, total);

Este indice permite:

  1. Filtrar por fecha eficientemente (primera columna del indice).
  2. Agrupar por estado sin ordenacion adicional.
  3. Calcular AVG(total) directamente desde el indice (Index Only Scan).
EXPLAIN ANALYZE SELECT estado, COUNT(*), AVG(total)
FROM pedidos
WHERE fecha BETWEEN '2024-01-01' AND '2024-12-31'
GROUP BY estado
ORDER BY COUNT(*) DESC;

Resultado esperado: Index Only Scan seguido de un GroupAggregate, reduciendo el tiempo de 15 segundos a menos de 1 segundo.

Alternativamente, con la clausula INCLUDE:

CREATE INDEX idx_pedidos_optimizado ON pedidos(fecha, estado) INCLUDE (total);
12

Analitica con SQL

Ya sabes agrupar datos con GROUP BY y calcular promedios con AVG. Pero el analisis de datos real va mas alla: necesitas rankings, comparaciones con periodos anteriores, acumulados progresivos y medidas estadisticas como la mediana o la desviacion estandar. En este modulo vas a aprender las herramientas de SQL que convierten una base de datos en una plataforma de analisis.

⚠️

Prerequisito

Este modulo asume que dominas GROUP BY y HAVING (Modulo 6), subconsultas y CTEs (Modulo 8), y funciones basicas (Modulo 9). Si alguno de esos temas te suena nuevo, revisalos antes de continuar.


Funciones de ventana (Window Functions)

Que son y por que las necesitas

Con GROUP BY obtienes un resumen que colapsa las filas. Pero a veces necesitas el resumen y los datos individuales al mismo tiempo. Por ejemplo: ver cada empleado con su salario y el salario promedio de su departamento, en la misma fila.

Comparemos ambos enfoques:

-- GROUP BY: colapsa filas (una fila por departamento)
SELECT departamento, ROUND(AVG(salario), 2) AS promedio
FROM empleados
GROUP BY departamento;
departamento │ promedio
──────────────┼──────────
Gerencia │ 85000.00
Tecnologia │ 58333.33
Ventas │ 42000.00
Soporte │ 37000.00

Pierdes el detalle individual. Ahora con una funcion de ventana:

-- Window function: mantiene CADA fila y agrega el promedio
SELECT
nombre,
departamento,
salario,
ROUND(AVG(salario) OVER (PARTITION BY departamento), 2) AS promedio_depto
FROM empleados
ORDER BY departamento, salario DESC;
nombre │ departamento │ salario │ promedio_depto
─────────────────────┼──────────────┼─────────┼───────────────
Sofia Mendez │ Gerencia │ 85000 │ 85000.00
Ricardo Soto │ Soporte │ 38000 │ 37000.00
Patricia Herrera │ Soporte │ 36000 │ 37000.00
Miguel Angel Torres │ Tecnologia │ 62000 │ 58333.33
Alejandro Vega │ Tecnologia │ 58000 │ 58333.33
Camila Rojas │ Tecnologia │ 55000 │ 58333.33
...

Cada empleado conserva su fila individual, pero ahora tiene una columna extra con el promedio de su departamento. Eso es una funcion de ventana.

Paso a paso: como piensa PostgreSQL una funcion de ventana

Para entender bien las funciones de ventana, veamos que hace PostgreSQL internamente cuando ejecutas esta consulta:

SELECT nombre, departamento, salario,
SUM(salario) OVER (PARTITION BY departamento) AS total_depto
FROM empleados;

Paso 1: Ejecutar el FROM y WHERE (obtener todas las filas)

PostgreSQL lee todas las filas de empleados como lo haria normalmente.

Paso 2: Dividir en ventanas (PARTITION BY)

Agrupa las filas segun PARTITION BY departamento, pero no las colapsa (esa es la diferencia con GROUP BY):

Ventana "Gerencia": [Sofia – 85000]
Ventana "Soporte": [Ricardo – 38000, Patricia – 36000]
Ventana "Tecnologia": [Miguel – 62000, Alejandro – 58000, Camila – 55000]
Ventana "Ventas": [Juan – 45000, Valentina – 42000, Fernanda – 40000, Daniela – 41000]

Paso 3: Calcular la funcion dentro de cada ventana

Calcula SUM(salario) para cada ventana por separado:

  • Gerencia: 85000
  • Soporte: 74000
  • Tecnologia: 175000
  • Ventas: 168000

Paso 4: Pegar el resultado en cada fila

Cada empleado recibe el valor calculado para su ventana:

nombre │ departamento │ salario │ total_depto
──────────────────┼──────────────┼─────────┼────────────
Sofia Mendez │ Gerencia │ 85000 │ 85000 ← total de Gerencia
Ricardo Soto │ Soporte │ 38000 │ 74000 ← total de Soporte
Patricia Herrera │ Soporte │ 36000 │ 74000 ← mismo: total de Soporte
Miguel A. Torres │ Tecnologia │ 62000 │ 175000 ← total de Tecnologia
...

La clave: la fila no desaparece. Cada empleado conserva su fila individual, y el resultado de la funcion de ventana se “pega” como una columna extra.

💡

GROUP BY colapsa, OVER pega

La diferencia fundamental es esta:

  • GROUP BY departamento4 filas (una por departamento, pierdes los nombres)
  • SUM(...) OVER (PARTITION BY departamento)10 filas (una por empleado, cada una con el total de su departamento)

Piensalo asi: GROUP BY es un resumen que destruye el detalle. OVER es una etiqueta que agrega informacion a cada fila sin destruir nada.

La clausula OVER: anatomia de una funcion de ventana

La clave es OVER(). Cualquier funcion de agregacion (SUM, AVG, COUNT, etc.) se convierte en funcion de ventana al agregarle OVER():

funcion() OVER (
PARTITION BY columna -- divide en grupos (opcional)
ORDER BY columna -- ordena dentro del grupo (opcional)
)

PARTITION BY define las “ventanas” (grupos de filas sobre los que se calcula). Visualmente:

Tabla empleados con PARTITION BY departamento:
┌──────────────────────┬──────────────┬─────────┐
│ nombre │ departamento │ salario │
├──────────────────────┼──────────────┼─────────┤
│ Juan Perez │ Ventas │ 45000 │ ─┐
│ Valentina Castro │ Ventas │ 42000 │ ├─ Ventana 1 (Ventas)
│ Fernanda Luna │ Ventas │ 40000 │ │ AVG = 42000
│ Daniela Flores │ Ventas │ 41000 │ ─┘
│ Miguel Angel Torres │ Tecnologia │ 62000 │ ─┐
│ Camila Rojas │ Tecnologia │ 55000 │ ├─ Ventana 2 (Tecnologia)
│ Alejandro Vega │ Tecnologia │ 58000 │ ─┘ AVG = 58333
│ Ricardo Soto │ Soporte │ 38000 │ ─┐ Ventana 3 (Soporte)
│ Patricia Herrera │ Soporte │ 36000 │ ─┘ AVG = 37000
│ Sofia Mendez │ Gerencia │ 85000 │ ── Ventana 4 (Gerencia)
└──────────────────────┴──────────────┴─────────┘ AVG = 85000
La funcion se calcula DENTRO de cada ventana, por separado.

OVER() vacio = toda la tabla

Si usas OVER() sin nada adentro (parentesis vacios), la ventana es la tabla completa. AVG(salario) OVER() calcula el promedio de TODOS los empleados. Esto es util para calcular porcentajes del total.

Porcentaje del total con OVER()

Un patron muy comun en analisis: calcular que porcentaje representa cada fila del total.

SELECT
nombre,
departamento,
salario,
SUM(salario) OVER() AS total_empresa,
ROUND(salario::NUMERIC / SUM(salario) OVER() * 100, 2) AS porcentaje
FROM empleados
ORDER BY porcentaje DESC;

SUM(salario) OVER() suma todos los salarios (sin PARTITION BY = toda la tabla), y se repite en cada fila. Asi puedes dividir cada salario individual entre el total para obtener el porcentaje.


Rankings: ROW_NUMBER, RANK y DENSE_RANK

Las funciones de ranking asignan un numero de posicion a cada fila segun un orden.

Comando Descripcion
ROW_NUMBER() OVER(...) Numero secuencial unico (1, 2, 3, 4...). Nunca hay empates.
RANK() OVER(...) Ranking con saltos en empates (1, 2, 2, 4...). Salta posiciones.
DENSE_RANK() OVER(...) Ranking sin saltos en empates (1, 2, 2, 3...). No salta posiciones.
NTILE(n) OVER(...) Divide las filas en n grupos iguales. Util para cuartiles (n=4), deciles (n=10).

Veamos la diferencia con un ejemplo:

SELECT
nombre,
departamento,
salario,
ROW_NUMBER() OVER (ORDER BY salario DESC) AS row_num,
RANK() OVER (ORDER BY salario DESC) AS rank,
DENSE_RANK() OVER (ORDER BY salario DESC) AS dense_rank
FROM empleados;
nombre │ salario │ row_num │ rank │ dense_rank
─────────────────────┼─────────┼─────────┼──────┼───────────
Sofia Mendez │ 85000 │ 1 │ 1 │ 1
Miguel Angel Torres │ 62000 │ 2 │ 2 │ 2
Alejandro Vega │ 58000 │ 3 │ 3 │ 3
Camila Rojas │ 55000 │ 4 │ 4 │ 4
Juan Perez │ 45000 │ 5 │ 5 │ 5
Valentina Castro │ 42000 │ 6 │ 6 │ 6
Daniela Flores │ 41000 │ 7 │ 7 │ 7
Fernanda Luna │ 40000 │ 8 │ 8 │ 8
Ricardo Soto │ 38000 │ 9 │ 9 │ 9
Patricia Herrera │ 36000 │ 10 │ 10 │ 10

Sin empates las tres dan el mismo resultado. La diferencia se nota cuando hay valores iguales: RANK salta posiciones (1, 2, 2, 4) y DENSE_RANK no (1, 2, 2, 3).

⚠️

ORDER BY es obligatorio en rankings

ROW_NUMBER, RANK y DENSE_RANK requieren ORDER BY dentro del OVER(). Sin ORDER BY, no tiene sentido asignar un ranking. PARTITION BY es opcional: si lo omites, el ranking es sobre toda la tabla.

El patron Top-N: lo vas a usar todo el tiempo

Uno de los patrones mas utiles en analisis de datos: obtener los N mejores de cada grupo.

-- Producto mas vendido por categoria
WITH ventas_producto AS (
SELECT
pr.nombre,
pr.categoria,
SUM(dp.cantidad) AS total_vendido,
ROW_NUMBER() OVER (
PARTITION BY pr.categoria
ORDER BY SUM(dp.cantidad) DESC
) AS ranking
FROM productos pr
INNER JOIN detalle_pedidos dp ON pr.id = dp.producto_id
GROUP BY pr.nombre, pr.categoria
)
SELECT nombre, categoria, total_vendido
FROM ventas_producto
WHERE ranking = 1;

El truco: ROW_NUMBER dentro del CTE asigna el ranking por grupo, y luego filtras con WHERE ranking <= N en la consulta exterior.

CTE + ROW_NUMBER + WHERE ranking <= N

Este patron responde preguntas como “los 3 mejores por grupo”, “el primero de cada categoria”, “el top 5 por ciudad”. Lo vas a usar constantemente en analisis de datos.

NTILE: dividir en grupos iguales

NTILE(n) divide las filas en n grupos de tamano (aproximadamente) igual. Muy util para crear cuartiles, quintiles o deciles.

-- Dividir empleados en cuartiles salariales
SELECT
nombre,
salario,
NTILE(4) OVER (ORDER BY salario) AS cuartil
FROM empleados;
nombre │ salario │ cuartil
─────────────────────┼─────────┼────────
Patricia Herrera │ 36000 │ 1 ← 25% mas bajo
Ricardo Soto │ 38000 │ 1
Fernanda Luna │ 40000 │ 1
Daniela Flores │ 41000 │ 2
Valentina Castro │ 42000 │ 2
Juan Perez │ 45000 │ 2
Camila Rojas │ 55000 │ 3
Alejandro Vega │ 58000 │ 3
Miguel Angel Torres │ 62000 │ 4
Sofia Mendez │ 85000 │ 4 ← 25% mas alto

LAG y LEAD: comparar con filas vecinas

A veces necesitas comparar cada fila con la anterior o la siguiente. ¿Cuanto crecieron las ventas respecto al mes pasado? ¿El pedido actual es mayor que el anterior? Para eso existen LAG y LEAD.

Comando Descripcion
LAG(col, n, default) Valor de n filas ANTES (default: 1 fila). Devuelve NULL si no existe fila anterior, a menos que especifiques un valor por defecto.
LEAD(col, n, default) Valor de n filas DESPUES (default: 1 fila). Devuelve NULL si no existe fila siguiente.

Ejemplo clasico: variacion mensual de ventas.

WITH ventas_mes AS (
SELECT
DATE_TRUNC('month', fecha) AS mes,
SUM(total) AS total_mes
FROM pedidos
GROUP BY DATE_TRUNC('month', fecha)
)
SELECT
TO_CHAR(mes, 'YYYY-MM') AS mes,
total_mes,
LAG(total_mes) OVER (ORDER BY mes) AS mes_anterior,
ROUND(
(total_mes - LAG(total_mes) OVER (ORDER BY mes))
/ LAG(total_mes) OVER (ORDER BY mes) * 100,
2
) AS variacion_pct
FROM ventas_mes
ORDER BY mes;
mes │ total_mes │ mes_anterior │ variacion_pct
──────────┼───────────┼──────────────┼──────────────
2024-01 │ 1479.97 │ NULL │ NULL ← sin mes anterior
2024-02 │ 2309.95 │ 1479.97 │ 56.08
2024-03 │ 1324.93 │ 2309.95 │ -42.65 ← caida
2024-04 │ 1949.94 │ 1324.93 │ 47.17

La primera fila tiene NULL porque no hay mes anterior. Puedes usar el tercer parametro de LAG para poner un valor por defecto: LAG(total_mes, 1, 0).

💡

LAG y LEAD requieren ORDER BY

Para hablar de “la fila anterior” necesitas definir un orden. LAG y LEAD siempre necesitan ORDER BY dentro del OVER(). PARTITION BY es opcional: si lo agregas, la comparacion es dentro de cada grupo.


FIRST_VALUE, LAST_VALUE y NTH_VALUE

Estas funciones acceden a un valor especifico dentro de la ventana: el primero, el ultimo o el N-esimo.

Comando Descripcion
FIRST_VALUE(col) OVER(...) Devuelve el valor de la primera fila de la ventana.
LAST_VALUE(col) OVER(...) Devuelve el valor de la ultima fila de la ventana.
NTH_VALUE(col, n) OVER(...) Devuelve el valor de la fila N de la ventana.
-- Para cada empleado: mostrar quien gana mas y quien gana menos en su departamento
SELECT
nombre,
departamento,
salario,
FIRST_VALUE(nombre) OVER (
PARTITION BY departamento ORDER BY salario DESC
) AS mejor_pagado,
FIRST_VALUE(nombre) OVER (
PARTITION BY departamento ORDER BY salario ASC
) AS peor_pagado
FROM empleados
ORDER BY departamento, salario DESC;
⚠️

Cuidado con LAST_VALUE

Por defecto, la ventana va desde el inicio hasta la fila actual (ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW). Esto significa que LAST_VALUE devuelve la fila actual, no la ultima del grupo. Para obtener la verdadera ultima fila, necesitas especificar la ventana completa:

LAST_VALUE(nombre) OVER (
PARTITION BY departamento ORDER BY salario
ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
)

Por eso, en la practica, es mas simple usar FIRST_VALUE con el orden invertido.


Agregaciones acumulativas y promedios moviles

Cuando usas una funcion de agregacion (SUM, AVG, COUNT) con OVER(ORDER BY ...), obtienes un calculo acumulativo: cada fila incluye los datos de todas las filas anteriores.

Suma acumulada (running total)

SELECT
fecha,
total,
SUM(total) OVER (ORDER BY fecha) AS acumulado,
COUNT(*) OVER (ORDER BY fecha) AS pedidos_hasta_fecha
FROM pedidos
ORDER BY fecha;
fecha │ total │ acumulado │ pedidos_hasta_fecha
────────────┼─────────┼───────────┼────────────────────
2024-01-15 │ 1389.98 │ 1389.98 │ 1
2024-01-20 │ 89.99 │ 1479.97 │ 2
2024-02-01 │ 499.98 │ 1979.95 │ 3
2024-02-14 │ 59.99 │ 2039.94 │ 4
2024-02-28 │ 1749.98 │ 3789.92 │ 5
...

Cada fila suma su valor al total de todas las filas anteriores. Es como una suma progresiva que va creciendo fila a fila.

💡

Como funciona el acumulado

Cuando escribes SUM(total) OVER (ORDER BY fecha), PostgreSQL suma desde la primera fila hasta la fila actual (inclusive). Internamente, la ventana por defecto es ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW, que significa “desde el inicio hasta aqui”.

Promedio movil (moving average)

El promedio movil suaviza fluctuaciones y muestra tendencias. Para calcularlo, defines una “ventana deslizante” de N filas usando ROWS BETWEEN:

SELECT
fecha,
total,
ROUND(
AVG(total) OVER (
ORDER BY fecha
ROWS BETWEEN 2 PRECEDING AND CURRENT ROW
),
2
) AS promedio_movil_3
FROM pedidos
ORDER BY fecha;

ROWS BETWEEN 2 PRECEDING AND CURRENT ROW significa “desde 2 filas antes hasta la fila actual” — una ventana de 3 filas que se va deslizando.

ROWS BETWEEN

Esta clausula define el tamano de la ventana deslizante. Los valores mas comunes: ROWS BETWEEN 2 PRECEDING AND CURRENT ROW (3 filas), ROWS BETWEEN 6 PRECEDING AND CURRENT ROW (7 filas). Para promedios moviles de ventas semanales o mensuales.

Acumulado por grupo

Puedes combinar PARTITION BY con ORDER BY para calcular acumulados dentro de cada grupo:

-- Gasto acumulado de cada cliente a lo largo del tiempo
SELECT
c.nombre,
p.fecha,
p.total,
SUM(p.total) OVER (
PARTITION BY c.id
ORDER BY p.fecha
) AS gasto_acumulado
FROM clientes c
INNER JOIN pedidos p ON c.id = p.cliente_id
ORDER BY c.nombre, p.fecha;

Esto muestra como va creciendo el gasto de cada cliente pedido a pedido. PARTITION BY c.id reinicia el acumulado para cada cliente.

Marcos de ventana: ROWS vs RANGE

Cuando usas ORDER BY en una funcion de ventana, PostgreSQL define un marco que determina que filas se incluyen en el calculo. Hay dos tipos principales:

Comando Descripcion
ROWS BETWEEN Cuenta filas fisicas. '2 PRECEDING' = exactamente 2 filas antes. Es el mas predecible.
RANGE BETWEEN Usa el valor de ORDER BY. Incluye filas con el mismo valor (empates). Es el default con ORDER BY.
UNBOUNDED PRECEDING Desde la primera fila de la ventana.
CURRENT ROW Hasta la fila actual.
UNBOUNDED FOLLOWING Hasta la ultima fila de la ventana.
n PRECEDING / n FOLLOWING n filas antes/despues de la actual.

La diferencia importa cuando hay valores duplicados en el ORDER BY:

-- ROWS: exactamente 2 filas anteriores + actual = 3 filas siempre
SUM(total) OVER (ORDER BY fecha ROWS BETWEEN 2 PRECEDING AND CURRENT ROW)
-- RANGE: incluye TODAS las filas con el mismo valor de fecha
-- Si hay 3 pedidos el mismo dia, los incluye todos
SUM(total) OVER (ORDER BY fecha RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW)

¿Cual usar?

En la practica, ROWS es mas predecible y mas usado para promedios moviles y ventanas deslizantes. RANGE es util cuando quieres que los empates se traten como iguales (por ejemplo, todos los pedidos del mismo dia sumen igual).

CUME_DIST y PERCENT_RANK: distribucion

Estas funciones te dicen donde esta cada fila en la distribucion del grupo:

SELECT
nombre,
salario,
ROUND(PERCENT_RANK() OVER (ORDER BY salario)::NUMERIC, 4) AS percent_rank,
ROUND(CUME_DIST() OVER (ORDER BY salario)::NUMERIC, 4) AS cume_dist
FROM empleados
ORDER BY salario;
nombre │ salario │ percent_rank │ cume_dist
─────────────────────┼─────────┼──────────────┼──────────
Patricia Herrera │ 36000 │ 0.0000 │ 0.1000 ← 10% gana esto o menos
Ricardo Soto │ 38000 │ 0.1111 │ 0.2000
Fernanda Luna │ 40000 │ 0.2222 │ 0.3000
...
Sofia Mendez │ 85000 │ 1.0000 │ 1.0000 ← 100% gana esto o menos
  • PERCENT_RANK: posicion relativa (0 = primero, 1 = ultimo). Formula: (rank - 1) / (total - 1).
  • CUME_DIST: porcentaje acumulado. Formula: filas con valor <= actual / total filas.

Estas funciones son utiles para detectar outliers: si un valor tiene PERCENT_RANK > 0.95, esta en el top 5%.


GROUPING SETS, ROLLUP y CUBE

A veces necesitas calcular subtotales y totales generales en una sola consulta. En lugar de hacer multiples GROUP BY y unirlos con UNION ALL, puedes usar GROUPING SETS.

GROUPING SETS: multiples agrupaciones en una consulta

-- Ventas por categoria, por estado, y total general
SELECT
COALESCE(pr.categoria, 'TODAS') AS categoria,
COALESCE(pe.estado, 'TODOS') AS estado,
SUM(dp.cantidad) AS unidades,
ROUND(SUM(dp.cantidad * dp.precio_unitario), 2) AS ingresos
FROM detalle_pedidos dp
JOIN productos pr ON dp.producto_id = pr.id
JOIN pedidos pe ON dp.pedido_id = pe.id
GROUP BY GROUPING SETS (
(pr.categoria, pe.estado), -- por categoria y estado
(pr.categoria), -- subtotal por categoria
(pe.estado), -- subtotal por estado
() -- total general
)
ORDER BY categoria, estado;

Cada tupla en GROUPING SETS genera un nivel de agrupacion. La tupla vacia () genera el total general. Las filas de subtotales tienen NULL en las columnas que no participan en esa agrupacion (por eso usamos COALESCE).

ROLLUP: jerarquia de subtotales

ROLLUP es un atajo para crear subtotales jerarquicos. ROLLUP(a, b) genera los grupos (a, b), (a) y ():

-- Ventas con subtotales por categoria y total general
SELECT
COALESCE(pr.categoria, '** TOTAL **') AS categoria,
COUNT(DISTINCT pe.id) AS pedidos,
SUM(dp.cantidad) AS unidades,
ROUND(SUM(dp.cantidad * dp.precio_unitario), 2) AS ingresos
FROM detalle_pedidos dp
JOIN productos pr ON dp.producto_id = pr.id
JOIN pedidos pe ON dp.pedido_id = pe.id
GROUP BY ROLLUP(pr.categoria)
ORDER BY ingresos DESC;
categoria │ pedidos │ unidades │ ingresos
──────────────┼─────────┼──────────┼──────────
** TOTAL ** │ 13 │ 29 │ 8454.77 ← total general
Electronica │ 8 │ 12 │ 5809.83
Muebles │ 4 │ 5 │ 1534.94
Accesorios │ 5 │ 12 │ 1110.00

CUBE: todas las combinaciones

CUBE(a, b) genera TODAS las combinaciones posibles: (a, b), (a), (b) y (). Es como un GROUPING SETS con cada subconjunto:

-- Ventas cruzadas por categoria y estado (con todos los subtotales)
SELECT
COALESCE(pr.categoria, '** TOTAL **') AS categoria,
COALESCE(pe.estado, '** TOTAL **') AS estado,
SUM(dp.cantidad) AS unidades
FROM detalle_pedidos dp
JOIN productos pr ON dp.producto_id = pr.id
JOIN pedidos pe ON dp.pedido_id = pe.id
GROUP BY CUBE(pr.categoria, pe.estado)
ORDER BY categoria, estado;

¿Cual usar?

  • GROUPING SETS: cuando necesitas combinaciones especificas.
  • ROLLUP: para reportes jerarquicos con subtotales (ventas por pais → ciudad → tienda).
  • CUBE: para tablas cruzadas con todos los subtotales posibles. Mas filas, pero informacion completa.

Tablas pivote: filas a columnas

Un patron comun en reportes: convertir valores de filas en columnas. SQL no tiene un PIVOT nativo, pero puedes lograrlo con CASE + agregacion:

-- Ventas por mes como columnas (tabla pivote)
SELECT
c.nombre,
SUM(CASE WHEN EXTRACT(MONTH FROM p.fecha) = 1 THEN p.total ELSE 0 END) AS enero,
SUM(CASE WHEN EXTRACT(MONTH FROM p.fecha) = 2 THEN p.total ELSE 0 END) AS febrero,
SUM(CASE WHEN EXTRACT(MONTH FROM p.fecha) = 3 THEN p.total ELSE 0 END) AS marzo,
SUM(CASE WHEN EXTRACT(MONTH FROM p.fecha) = 4 THEN p.total ELSE 0 END) AS abril,
SUM(p.total) AS total
FROM clientes c
JOIN pedidos p ON c.id = p.cliente_id
WHERE p.fecha >= '2024-01-01' AND p.fecha < '2025-01-01'
GROUP BY c.nombre
ORDER BY total DESC;
nombre │ enero │ febrero │ marzo │ abril │ total
─────────────────┼─────────┼─────────┼─────────┼─────────┼─────────
Maria Garcia │ 1389.98 │ 59.99 │ 0.00 │ 449.99 │ 1899.96
Ana Martinez │ 0.00 │ 499.98 │ 839.98 │ 0.00 │ 1339.96
Isabel Moreno │ 0.00 │ 0.00 │ 0.00 │ 1299.99 │ 1299.99
...

Pivote de estados de pedidos

-- Cantidad de pedidos por estado, uno por columna
SELECT
c.nombre,
COUNT(*) FILTER (WHERE p.estado = 'completado') AS completados,
COUNT(*) FILTER (WHERE p.estado = 'enviado') AS enviados,
COUNT(*) FILTER (WHERE p.estado = 'pendiente') AS pendientes,
COUNT(*) AS total
FROM clientes c
JOIN pedidos p ON c.id = p.cliente_id
GROUP BY c.nombre
ORDER BY total DESC;

FILTER vs CASE para pivotes

FILTER es mas legible que CASE para pivotes en PostgreSQL. Pero si necesitas portabilidad a otros motores SQL, usa la version con CASE.


Patrones avanzados de analisis

Comparacion Year-over-Year (YoY)

Compara datos del mismo periodo en anos diferentes:

-- Comparar ventas mensuales entre periodos
WITH ventas_por_mes AS (
SELECT
EXTRACT(YEAR FROM fecha) AS anio,
EXTRACT(MONTH FROM fecha) AS mes,
SUM(total) AS total_ventas,
COUNT(*) AS num_pedidos
FROM pedidos
GROUP BY EXTRACT(YEAR FROM fecha), EXTRACT(MONTH FROM fecha)
)
SELECT
anio,
mes,
total_ventas,
num_pedidos,
LAG(total_ventas) OVER (PARTITION BY mes ORDER BY anio) AS mismo_mes_anio_anterior,
ROUND(
(total_ventas - LAG(total_ventas) OVER (PARTITION BY mes ORDER BY anio))
/ NULLIF(LAG(total_ventas) OVER (PARTITION BY mes ORDER BY anio), 0) * 100,
2
) AS variacion_yoy
FROM ventas_por_mes
ORDER BY anio, mes;

El truco: PARTITION BY mes ORDER BY anio hace que LAG compare el mismo mes en anos consecutivos.

Analisis de retencion

¿Cuantos clientes que compraron en enero volvieron a comprar en los meses siguientes?

WITH primer_compra AS (
SELECT
cliente_id,
DATE_TRUNC('month', MIN(fecha)) AS mes_primera_compra
FROM pedidos
GROUP BY cliente_id
),
actividad AS (
SELECT DISTINCT
p.cliente_id,
pc.mes_primera_compra,
DATE_TRUNC('month', p.fecha) AS mes_actividad,
(EXTRACT(YEAR FROM DATE_TRUNC('month', p.fecha)) - EXTRACT(YEAR FROM pc.mes_primera_compra)) * 12
+ (EXTRACT(MONTH FROM DATE_TRUNC('month', p.fecha)) - EXTRACT(MONTH FROM pc.mes_primera_compra))
AS meses_desde_primera
FROM pedidos p
JOIN primer_compra pc ON p.cliente_id = pc.cliente_id
)
SELECT
TO_CHAR(mes_primera_compra, 'YYYY-MM') AS cohorte,
meses_desde_primera,
COUNT(DISTINCT cliente_id) AS clientes_activos
FROM actividad
GROUP BY mes_primera_compra, meses_desde_primera
ORDER BY mes_primera_compra, meses_desde_primera;

Este patron muestra cuantos clientes de cada cohorte (mes de primera compra) siguen activos en los meses siguientes. Es la base del analisis de retencion que usan empresas SaaS y e-commerce.

Acumulado con meta (target tracking)

Compara el progreso acumulado contra una meta:

WITH ventas_diarias AS (
SELECT
fecha,
SUM(total) AS ventas_dia
FROM pedidos
WHERE EXTRACT(MONTH FROM fecha) = 1 AND EXTRACT(YEAR FROM fecha) = 2024
GROUP BY fecha
),
meta AS (
SELECT 2000.00 AS meta_mensual -- meta de ventas del mes
)
SELECT
vd.fecha,
vd.ventas_dia,
SUM(vd.ventas_dia) OVER (ORDER BY vd.fecha) AS acumulado,
m.meta_mensual,
ROUND(SUM(vd.ventas_dia) OVER (ORDER BY vd.fecha) / m.meta_mensual * 100, 1) AS pct_meta
FROM ventas_diarias vd
CROSS JOIN meta m
ORDER BY vd.fecha;

Funciones estadisticas

Para analisis de datos necesitas ir mas alla del promedio. SQL tiene funciones estadisticas que te permiten medir dispersion, encontrar percentiles y detectar patrones.

Comando Descripcion
STDDEV(col) Desviacion estandar muestral. Mide que tan dispersos estan los datos respecto al promedio.
VARIANCE(col) Varianza muestral. Es el cuadrado de la desviacion estandar.
PERCENTILE_CONT(p) WITHIN GROUP (ORDER BY col) Percentil continuo (interpola entre valores). p va de 0 a 1. 0.5 = mediana.
PERCENTILE_DISC(p) WITHIN GROUP (ORDER BY col) Percentil discreto: devuelve un valor real de los datos (el mas cercano al percentil).
MODE() WITHIN GROUP (ORDER BY col) El valor que mas se repite (moda).
💡

Que significan estas medidas

El promedio (AVG) te dice el centro de los datos. La desviacion estandar (STDDEV) te dice que tan dispersos estan. Si el promedio de salarios es $50,000 y la desviacion es $5,000, la mayoria gana entre $45,000 y $55,000. Si la desviacion es $20,000, los salarios son muy variados. La mediana es el valor del medio cuando ordenas los datos: la mitad esta arriba y la mitad abajo.

Resumen estadistico de salarios

SELECT
departamento,
COUNT(*) AS empleados,
ROUND(AVG(salario), 2) AS promedio,
ROUND(STDDEV(salario), 2) AS desviacion_std,
MIN(salario) AS minimo,
MAX(salario) AS maximo,
MAX(salario) - MIN(salario) AS rango
FROM empleados
GROUP BY departamento
HAVING COUNT(*) > 1
ORDER BY promedio DESC;

Mediana y percentiles

-- Mediana y cuartiles del precio de productos
SELECT
ROUND(PERCENTILE_CONT(0.25) WITHIN GROUP (ORDER BY precio)::NUMERIC, 2) AS percentil_25,
ROUND(PERCENTILE_CONT(0.50) WITHIN GROUP (ORDER BY precio)::NUMERIC, 2) AS mediana,
ROUND(PERCENTILE_CONT(0.75) WITHIN GROUP (ORDER BY precio)::NUMERIC, 2) AS percentil_75,
ROUND(AVG(precio), 2) AS promedio
FROM productos;

Mediana vs promedio

La mediana (PERCENTILE_CONT(0.5)) es muchas veces mejor que el promedio para describir datos, porque no se ve afectada por valores extremos. Si tienes un producto de $1,300 y el resto cuesta entre $12 y $90, el promedio sube a ~$160 pero la mediana se mantiene representativa en ~$45.

Tambien puedes calcular percentiles por grupo:

-- Mediana del precio por categoria
SELECT
categoria,
COUNT(*) AS productos,
ROUND(AVG(precio), 2) AS promedio,
ROUND(
PERCENTILE_CONT(0.50) WITHIN GROUP (ORDER BY precio)::NUMERIC,
2
) AS mediana
FROM productos
GROUP BY categoria
ORDER BY mediana DESC;

Moda

-- Categoria mas comun entre los productos
SELECT MODE() WITHIN GROUP (ORDER BY categoria) AS categoria_mas_comun
FROM productos;
-- Ciudad mas comun entre los clientes
SELECT MODE() WITHIN GROUP (ORDER BY ciudad) AS ciudad_mas_comun
FROM clientes;

STDDEV como funcion de ventana

Las funciones estadisticas tambien funcionan como funciones de ventana. Esto es util para detectar valores atipicos:

-- Detectar pedidos con total inusual para cada cliente
SELECT
c.nombre,
p.id AS pedido_id,
p.total,
ROUND(AVG(p.total) OVER (PARTITION BY c.id), 2) AS promedio_cliente,
ROUND(STDDEV(p.total) OVER (PARTITION BY c.id), 2) AS stddev_cliente
FROM clientes c
INNER JOIN pedidos p ON c.id = p.cliente_id
ORDER BY c.nombre, p.total DESC;

Si un pedido esta a mas de 2 desviaciones estandar del promedio del cliente, probablemente es un valor atipico.


FILTER: agregacion condicional

Ya conoces el truco de usar CASE WHEN dentro de una funcion de agregacion para contar o sumar condicionalmente. PostgreSQL tiene una forma mas elegante: la clausula FILTER.

-- Forma clasica con CASE WHEN
SELECT
c.nombre,
COUNT(*) AS total_pedidos,
COUNT(CASE WHEN p.estado = 'completado' THEN 1 END) AS completados,
COUNT(CASE WHEN p.estado = 'pendiente' THEN 1 END) AS pendientes
FROM clientes c
INNER JOIN pedidos p ON c.id = p.cliente_id
GROUP BY c.nombre;
-- Forma con FILTER (mas legible)
SELECT
c.nombre,
COUNT(*) AS total_pedidos,
COUNT(*) FILTER (WHERE p.estado = 'completado') AS completados,
COUNT(*) FILTER (WHERE p.estado = 'pendiente') AS pendientes,
ROUND(AVG(p.total) FILTER (WHERE p.estado = 'completado'), 2) AS promedio_completados
FROM clientes c
INNER JOIN pedidos p ON c.id = p.cliente_id
GROUP BY c.nombre
ORDER BY total_pedidos DESC;

FILTER funciona con cualquier funcion de agregacion: COUNT, SUM, AVG, MIN, MAX, etc.

⚠️

FILTER es de PostgreSQL

La clausula FILTER es una extension de PostgreSQL, no es SQL estandar. Si alguna vez necesitas migrar a MySQL o SQL Server, tendras que volver al CASE WHEN. Pero en PostgreSQL, FILTER es mas legible y ligeramente mas eficiente.


Patrones practicos para analisis de datos

Porcentaje del total por grupo

¿Que porcentaje de ventas representa cada producto dentro de su categoria?

SELECT
pr.categoria,
pr.nombre,
SUM(dp.cantidad) AS vendidos,
ROUND(
SUM(dp.cantidad)::NUMERIC /
SUM(SUM(dp.cantidad)) OVER (PARTITION BY pr.categoria) * 100,
1
) AS pct_de_categoria
FROM productos pr
INNER JOIN detalle_pedidos dp ON pr.id = dp.producto_id
GROUP BY pr.categoria, pr.nombre
ORDER BY pr.categoria, vendidos DESC;

El truco: SUM(SUM(dp.cantidad)) OVER (PARTITION BY pr.categoria) es una funcion de ventana sobre una agregacion. Primero SUM(dp.cantidad) calcula el total por producto (GROUP BY), y luego SUM(...) OVER (PARTITION BY categoria) suma esos totales dentro de cada categoria.

Diferencia con el promedio del grupo

¿Cuanto gana cada empleado por encima o debajo del promedio de su departamento?

SELECT
nombre,
departamento,
salario,
ROUND(AVG(salario) OVER (PARTITION BY departamento), 2) AS promedio_depto,
salario - ROUND(AVG(salario) OVER (PARTITION BY departamento), 2) AS diferencia
FROM empleados
ORDER BY departamento, diferencia DESC;

Reutilizar ventanas con WINDOW

Si repites la misma clausula OVER varias veces, puedes definirla una sola vez con WINDOW:

SELECT
nombre,
departamento,
salario,
AVG(salario) OVER w AS promedio,
MIN(salario) OVER w AS minimo,
MAX(salario) OVER w AS maximo,
salario - AVG(salario) OVER w AS diferencia
FROM empleados
WINDOW w AS (PARTITION BY departamento)
ORDER BY departamento, salario DESC;

WINDOW hace tu consulta mas legible

Ademas de evitar repeticion, WINDOW reduce errores: si necesitas cambiar la particion, la cambias en un solo lugar. PostgreSQL tambien puede optimizar mejor la consulta cuando detecta que varias funciones usan la misma ventana.


Rendimiento de funciones de ventana

Sorting interno

Cada clausula OVER(ORDER BY ...) distinta puede requerir una operacion de ordenamiento interna con costo O(n log n). Si tu consulta tiene 5 funciones de ventana con ORDER BY diferentes, PostgreSQL podria hacer 5 ordenamientos separados. Tips para optimizar:

  • Reutiliza la misma ventana con WINDOW cuando sea posible: PostgreSQL ordena una sola vez.
  • Un indice en las columnas del PARTITION BY y ORDER BY puede evitar el sorting.
  • Con tablas grandes (millones de filas), revisa el plan de ejecucion con EXPLAIN ANALYZE.

Ejercicios

Ejercicio 1: Muestra cada producto con su nombre, precio, categoria y el precio promedio de su categoria (usando una funcion de ventana). Agrega una columna que diga ‘Por encima’ o ‘Por debajo’ segun si el producto supera o no el promedio.

Ver solucion
SELECT
nombre,
categoria,
precio,
ROUND(AVG(precio) OVER (PARTITION BY categoria), 2) AS promedio_categoria,
CASE
WHEN precio >= AVG(precio) OVER (PARTITION BY categoria) THEN 'Por encima'
ELSE 'Por debajo'
END AS comparacion
FROM productos
ORDER BY categoria, precio DESC;

Ejercicio 2: Rankea a los empleados por salario dentro de cada departamento usando DENSE_RANK. Muestra nombre, departamento, salario y el ranking.

Ver solucion
SELECT
nombre,
departamento,
salario,
DENSE_RANK() OVER (
PARTITION BY departamento
ORDER BY salario DESC
) AS ranking
FROM empleados
ORDER BY departamento, ranking;

Ejercicio 3: Calcula las ventas totales por mes para 2024. Para cada mes, muestra el total, el total del mes anterior (con LAG) y la diferencia absoluta entre ambos.

Ver solucion
WITH ventas_mes AS (
SELECT
DATE_TRUNC('month', fecha) AS mes,
SUM(total) AS total_mes
FROM pedidos
WHERE fecha >= '2024-01-01' AND fecha < '2025-01-01'
GROUP BY DATE_TRUNC('month', fecha)
)
SELECT
TO_CHAR(mes, 'YYYY-MM') AS mes,
ROUND(total_mes, 2) AS total_mes,
ROUND(LAG(total_mes) OVER (ORDER BY mes), 2) AS mes_anterior,
ROUND(ABS(total_mes - LAG(total_mes) OVER (ORDER BY mes)), 2) AS diferencia
FROM ventas_mes
ORDER BY mes;

Ejercicio 4: Muestra los pedidos ordenados por fecha con una columna que acumule la suma total de todos los pedidos hasta esa fecha (running total).

Ver solucion
SELECT
id,
fecha,
total,
SUM(total) OVER (ORDER BY fecha, id) AS acumulado
FROM pedidos
ORDER BY fecha, id;

Agregamos id al ORDER BY para desempatar pedidos del mismo dia y obtener un acumulado determinista.

Ejercicio 5: Calcula la mediana, el percentil 25 y el percentil 75 de los salarios de los empleados. Luego, en una segunda consulta, calcula los mismos valores pero separados por departamento.

Ver solucion
-- Global
SELECT
ROUND(PERCENTILE_CONT(0.25) WITHIN GROUP (ORDER BY salario)::NUMERIC, 2) AS p25,
ROUND(PERCENTILE_CONT(0.50) WITHIN GROUP (ORDER BY salario)::NUMERIC, 2) AS mediana,
ROUND(PERCENTILE_CONT(0.75) WITHIN GROUP (ORDER BY salario)::NUMERIC, 2) AS p75
FROM empleados;
-- Por departamento
SELECT
departamento,
COUNT(*) AS empleados,
ROUND(PERCENTILE_CONT(0.25) WITHIN GROUP (ORDER BY salario)::NUMERIC, 2) AS p25,
ROUND(PERCENTILE_CONT(0.50) WITHIN GROUP (ORDER BY salario)::NUMERIC, 2) AS mediana,
ROUND(PERCENTILE_CONT(0.75) WITHIN GROUP (ORDER BY salario)::NUMERIC, 2) AS p75
FROM empleados
GROUP BY departamento
ORDER BY mediana DESC;

Ejercicio 6: Para cada categoria de productos, muestra: la cantidad total de unidades vendidas, la cantidad vendida en pedidos completados y la cantidad en pedidos pendientes. Usa la clausula FILTER.

Ver solucion
SELECT
pr.categoria,
SUM(dp.cantidad) AS total_vendidas,
SUM(dp.cantidad) FILTER (WHERE pe.estado = 'completado') AS en_completados,
SUM(dp.cantidad) FILTER (WHERE pe.estado = 'pendiente') AS en_pendientes,
SUM(dp.cantidad) FILTER (WHERE pe.estado = 'enviado') AS en_enviados
FROM productos pr
INNER JOIN detalle_pedidos dp ON pr.id = dp.producto_id
INNER JOIN pedidos pe ON dp.pedido_id = pe.id
GROUP BY pr.categoria
ORDER BY total_vendidas DESC;

Ejercicio 7 (Desafio): Para cada cliente que haya hecho al menos un pedido, muestra su nombre, gasto total, ranking general por gasto (con RANK) y que porcentaje del gasto total de la empresa representa.

Ver solucion
WITH gasto_cliente AS (
SELECT
c.nombre,
SUM(p.total) AS gasto_total
FROM clientes c
INNER JOIN pedidos p ON c.id = p.cliente_id
GROUP BY c.id, c.nombre
)
SELECT
nombre,
gasto_total,
RANK() OVER (ORDER BY gasto_total DESC) AS ranking,
ROUND(
gasto_total / SUM(gasto_total) OVER() * 100,
2
) AS porcentaje_empresa
FROM gasto_cliente
ORDER BY ranking;

Ejercicio 8 (Desafio): Identifica los meses donde las ventas cayeron respecto al mes anterior (variacion negativa). Muestra el mes, las ventas del mes, las del mes anterior y la variacion porcentual. Ordena por la peor caida primero.

Ver solucion
WITH ventas_mes AS (
SELECT
DATE_TRUNC('month', fecha) AS mes,
SUM(total) AS total_mes
FROM pedidos
GROUP BY DATE_TRUNC('month', fecha)
),
con_variacion AS (
SELECT
TO_CHAR(mes, 'YYYY-MM') AS mes,
ROUND(total_mes, 2) AS total_mes,
ROUND(LAG(total_mes) OVER (ORDER BY mes), 2) AS mes_anterior,
ROUND(
(total_mes - LAG(total_mes) OVER (ORDER BY mes))
/ LAG(total_mes) OVER (ORDER BY mes) * 100,
2
) AS variacion_pct
FROM ventas_mes
)
SELECT *
FROM con_variacion
WHERE variacion_pct < 0
ORDER BY variacion_pct ASC;

Ejercicio 9: Crea un reporte con ROLLUP que muestre las unidades vendidas por categoria, con un subtotal por categoria y un total general.

Ver solucion
SELECT
COALESCE(pr.categoria, '** TOTAL **') AS categoria,
SUM(dp.cantidad) AS unidades_vendidas,
ROUND(SUM(dp.cantidad * dp.precio_unitario), 2) AS ingresos
FROM detalle_pedidos dp
JOIN productos pr ON dp.producto_id = pr.id
GROUP BY ROLLUP(pr.categoria)
ORDER BY ingresos DESC;

Ejercicio 10: Crea una tabla pivote que muestre para cada categoria de producto, cuantas unidades se vendieron en cada estado de pedido (completado, enviado, pendiente) como columnas separadas.

Ver solucion
SELECT
pr.categoria,
SUM(dp.cantidad) FILTER (WHERE pe.estado = 'completado') AS completados,
SUM(dp.cantidad) FILTER (WHERE pe.estado = 'enviado') AS enviados,
SUM(dp.cantidad) FILTER (WHERE pe.estado = 'pendiente') AS pendientes,
SUM(dp.cantidad) AS total
FROM detalle_pedidos dp
JOIN productos pr ON dp.producto_id = pr.id
JOIN pedidos pe ON dp.pedido_id = pe.id
GROUP BY pr.categoria
ORDER BY total DESC;

Ejercicio 11 (Desafio): Para cada empleado, muestra su nombre, salario, y en que percentil se encuentra dentro de su departamento (usa PERCENT_RANK). Ademas, muestra quien es el mejor pagado de su departamento (usa FIRST_VALUE).

Ver solucion
SELECT
nombre,
departamento,
salario,
ROUND(PERCENT_RANK() OVER (
PARTITION BY departamento ORDER BY salario
)::NUMERIC, 2) AS percentil,
FIRST_VALUE(nombre) OVER (
PARTITION BY departamento ORDER BY salario DESC
) AS mejor_pagado_depto
FROM empleados
ORDER BY departamento, salario DESC;
13

Metadatos: Explorando la estructura

Cuando te conectas a una base de datos nueva — ya sea en un proyecto de ciencia de datos, en un trabajo nuevo, o en una base de datos legacy — lo primero que necesitas saber es: que tablas hay, que columnas tienen y que tipo de datos guardan. PostgreSQL almacena toda esa informacion en tablas especiales que puedes consultar con SQL normal.

💡

Base de datos de practica

Todos los ejemplos de este modulo se ejecutan sobre la base de datos tienda que creamos en el Modulo 2. Si aun no la tienes configurada, ve al Modulo 2 primero.


Atajos rapidos: psql y DBeaver

Antes de escribir consultas, vale la pena conocer los atajos que ya tienes disponibles.

En psql (terminal)

\dt -- listar todas las tablas
\d productos -- ver estructura de una tabla (columnas, tipos, constraints)
\d+ productos -- igual pero con mas detalle (tamano, comentarios)
\dn -- listar esquemas
\df -- listar funciones

En DBeaver (interfaz grafica)

En el panel de navegacion (izquierda), expande tu conexion → base de datos tienda → esquema publicTables. Ahi ves todas las tablas. Haciendo click en una tabla puedes ver sus columnas, tipos, constraints, foreign keys, indices y mas.

¿Por que aprender las consultas SQL entonces?

Los atajos de psql y DBeaver son rapidos para explorar, pero las consultas SQL te dan mucho mas control: puedes filtrar, combinar resultados, buscar en toda la base de datos y automatizar. Ademas, funcionan desde cualquier cliente SQL.


information_schema: el estandar SQL

information_schema es un esquema especial definido por el estandar SQL. Existe en PostgreSQL, MySQL, SQL Server y otras bases de datos. Si aprendes a usarlo, tus consultas de metadatos seran portables.

Listar todas las tablas

SELECT table_name, table_type
FROM information_schema.tables
WHERE table_schema = 'public'
ORDER BY table_name;

Resultado:

table_name | table_type
-----------------+------------
clientes | BASE TABLE
detalle_pedidos | BASE TABLE
empleados | BASE TABLE
pedidos | BASE TABLE
productos | BASE TABLE

Filtramos por table_schema = 'public' porque ahi viven las tablas que creamos nosotros. Sin ese filtro verias tambien las tablas internas de PostgreSQL.

Ver columnas de una tabla

SELECT
column_name,
data_type,
is_nullable,
column_default
FROM information_schema.columns
WHERE table_name = 'productos'
ORDER BY ordinal_position;

Resultado:

column_name | data_type | is_nullable | column_default
----------------+-------------------+-------------+---------------------------
id | integer | NO | nextval('productos_id_seq')
nombre | character varying | NO | NULL
precio | numeric | NO | NULL
categoria | character varying | YES | NULL
stock | integer | YES | 0
fecha_creacion | date | YES | CURRENT_DATE

ordinal_position es el orden en que fueron definidas las columnas. is_nullable te dice si la columna acepta NULL.

Tipos de datos mas comunes en PostgreSQL

Cuando explores columnas vas a encontrar estos tipos de datos frecuentemente. Aqui tienes una referencia rapida:

Comando Descripcion
INTEGER / INT Numeros enteros (-2 mil millones a 2 mil millones). Ej: stock, cantidad
SERIAL Entero auto-incremental. Se usa para IDs (PRIMARY KEY)
NUMERIC(p,s) / DECIMAL Numeros exactos con decimales. Ej: precio DECIMAL(10,2) = hasta 10 digitos, 2 decimales
VARCHAR(n) Texto de longitud variable con limite. Ej: nombre VARCHAR(100)
TEXT Texto de longitud ilimitada. Ej: descripciones largas, comentarios
BOOLEAN Verdadero (TRUE) o falso (FALSE). Ej: activo, verificado
DATE Fecha sin hora (YYYY-MM-DD). Ej: fecha_registro, fecha_nacimiento
TIMESTAMP Fecha + hora (YYYY-MM-DD HH:MI:SS). Ej: created_at, updated_at
TIMESTAMPTZ Timestamp con zona horaria. Recomendado cuando trabajas con usuarios en distintos paises
JSONB Datos JSON binario, permite consultas dentro del JSON. Muy usado en apps modernas
UUID Identificador unico universal (128 bits). Alternativa a SERIAL para IDs
💡

Documentacion oficial

PostgreSQL soporta muchos mas tipos de datos (arrays, rangos, geometricos, red, etc.). Consulta la documentacion oficial de tipos de datos para la referencia completa.

Vistas mas utiles de information_schema

Comando Descripcion
information_schema.tables Tablas y vistas de la base de datos (nombre, tipo, esquema)
information_schema.columns Columnas de cada tabla (nombre, tipo, nullable, default, posicion)
information_schema.table_constraints Restricciones: PRIMARY KEY, FOREIGN KEY, UNIQUE, CHECK
information_schema.key_column_usage Que columnas participan en cada constraint (PK, FK, UNIQUE)
information_schema.referential_constraints Detalle de foreign keys: a que tabla y constraint referencian

Encontrar columnas por tipo de dato

Supongamos que quieres saber que columnas de tipo texto (character varying) hay en toda la base de datos:

SELECT table_name, column_name, character_maximum_length
FROM information_schema.columns
WHERE table_schema = 'public'
AND data_type = 'character varying'
ORDER BY table_name, ordinal_position;

Resultado:

table_name | column_name | character_maximum_length
------------+-------------+-------------------------
clientes | nombre | 100
clientes | email | 100
clientes | ciudad | 50
empleados | nombre | 100
empleados | departamento| 50
pedidos | estado | 20
productos | nombre | 100
productos | categoria | 50

Ver restricciones de una tabla

SELECT
tc.constraint_name,
tc.constraint_type,
kcu.column_name
FROM information_schema.table_constraints tc
JOIN information_schema.key_column_usage kcu
ON tc.constraint_name = kcu.constraint_name
WHERE tc.table_name = 'pedidos';

Resultado:

constraint_name | constraint_type | column_name
--------------------------+-----------------+------------
pedidos_pkey | PRIMARY KEY | id
pedidos_cliente_id_fkey | FOREIGN KEY | cliente_id

Esto te dice que la tabla pedidos tiene una PRIMARY KEY en id y una FOREIGN KEY en cliente_id.


pg_catalog: las tablas internas de PostgreSQL

pg_catalog es el catalogo interno de PostgreSQL. Es mas detallado que information_schema pero es especifico de PostgreSQL — no funciona en MySQL ni SQL Server.

⚠️

Portabilidad

Si necesitas que tus consultas funcionen en distintas bases de datos, usa information_schema. Usa pg_catalog cuando necesites informacion extra que solo PostgreSQL proporciona (tamanos, OIDs, estadisticas internas).

Tablas principales de pg_catalog

Comando Descripcion
pg_class Tablas, indices, secuencias y vistas (nombre, tipo, tamano, OID)
pg_attribute Columnas de cada tabla (nombre, tipo OID, posicion, nullable)
pg_namespace Esquemas de la base de datos (public, pg_catalog, etc.)
pg_description Comentarios agregados con COMMENT ON a tablas y columnas
pg_constraint Constraints: PK, FK, UNIQUE, CHECK con detalle interno
pg_type Todos los tipos de datos disponibles en PostgreSQL

Listar tablas con pg_catalog

SELECT relname AS tabla, reltuples::INTEGER AS filas_estimadas
FROM pg_class
WHERE relkind = 'r' -- 'r' = tabla regular
AND relnamespace = 'public'::regnamespace
ORDER BY relname;

Resultado:

tabla | filas_estimadas
-----------------+----------------
clientes | 10
detalle_pedidos | 24
empleados | 10
pedidos | 15
productos | 15
💡

Filas estimadas vs reales

reltuples es una estimacion que PostgreSQL mantiene internamente para el planificador de consultas. Puede no ser exacta si la tabla cambio despues del ultimo ANALYZE. Para el conteo exacto necesitas SELECT COUNT(*).


Consultas practicas utiles

Estas son consultas que vas a usar frecuentemente en tu trabajo diario.

Tamano de tablas

SELECT
relname AS tabla,
pg_size_pretty(pg_total_relation_size(oid)) AS tamano_total,
pg_size_pretty(pg_relation_size(oid)) AS tamano_datos,
pg_size_pretty(pg_indexes_size(oid)) AS tamano_indices
FROM pg_class
WHERE relkind = 'r'
AND relnamespace = 'public'::regnamespace
ORDER BY pg_total_relation_size(oid) DESC;

Resultado:

tabla | tamano_total | tamano_datos | tamano_indices
-----------------+--------------+--------------+----------------
detalle_pedidos | 32 kB | 8192 bytes | 16 kB
productos | 32 kB | 8192 bytes | 16 kB
pedidos | 32 kB | 8192 bytes | 16 kB
empleados | 32 kB | 8192 bytes | 16 kB
clientes | 32 kB | 8192 bytes | 16 kB

¿Por que importa el tamano?

Con datos pequenos como los nuestros, todas las tablas son diminutas. Pero en produccion, esta consulta te ayuda a identificar tablas que crecieron demasiado, que necesitan particionamiento o que tienen indices ocupando mas espacio que los datos.

Ver las foreign keys y sus relaciones

Esta consulta te muestra como se conectan las tablas entre si:

SELECT
tc.table_name AS tabla_origen,
kcu.column_name AS columna_fk,
ccu.table_name AS tabla_destino,
ccu.column_name AS columna_destino
FROM information_schema.table_constraints tc
JOIN information_schema.key_column_usage kcu
ON tc.constraint_name = kcu.constraint_name
JOIN information_schema.constraint_column_usage ccu
ON tc.constraint_name = ccu.constraint_name
WHERE tc.constraint_type = 'FOREIGN KEY'
AND tc.table_schema = 'public'
ORDER BY tc.table_name;

Resultado:

tabla_origen | columna_fk | tabla_destino | columna_destino
------------------+-------------+---------------+-----------------
detalle_pedidos | pedido_id | pedidos | id
detalle_pedidos | producto_id | productos | id
empleados | jefe_id | empleados | id
pedidos | cliente_id | clientes | id

Este resultado es basicamente el diagrama de relaciones de tu base de datos, pero generado automaticamente.

Buscar una columna por nombre

Cuando llegas a una base de datos con muchas tablas y necesitas encontrar donde esta cierta informacion:

SELECT table_name, column_name, data_type
FROM information_schema.columns
WHERE table_schema = 'public'
AND column_name LIKE '%fecha%'
ORDER BY table_name;

Resultado:

table_name | column_name | data_type
------------+-------------------+-----------
clientes | fecha_registro | date
empleados | fecha_contratacion| date
pedidos | fecha | date
productos | fecha_creacion | date

Busqueda flexible

Cambia '%fecha%' por cualquier patron: '%id%' para encontrar claves, '%email%' para campos de correo, '%precio%' para valores monetarios. Usa ILIKE en vez de LIKE si quieres busqueda sin importar mayusculas/minusculas.

Contar columnas de cada tabla

SELECT
table_name,
(SELECT COUNT(*) FROM information_schema.columns c
WHERE c.table_name = t.table_name
AND c.table_schema = 'public') AS num_columnas
FROM information_schema.tables t
WHERE table_schema = 'public'
AND table_type = 'BASE TABLE'
ORDER BY table_name;

Resultado:

table_name | num_columnas
-----------------+--------------
clientes | 5
detalle_pedidos | 5
empleados | 6
pedidos | 5
productos | 6

Esto te da una vision rapida de la complejidad de cada tabla.


COMMENT ON: documentar tu base de datos

PostgreSQL permite agregar comentarios a tablas, columnas, funciones y otros objetos. Estos comentarios quedan almacenados en la base de datos y son visibles para cualquier persona que se conecte.

Agregar comentarios

COMMENT ON TABLE productos IS 'Catalogo de productos de la tienda';
COMMENT ON TABLE clientes IS 'Clientes registrados en el sistema';
COMMENT ON TABLE pedidos IS 'Pedidos realizados por los clientes';
COMMENT ON COLUMN productos.precio IS 'Precio unitario en pesos, sin IVA';
COMMENT ON COLUMN productos.stock IS 'Unidades disponibles en bodega';
COMMENT ON COLUMN pedidos.estado IS 'Valores posibles: pendiente, enviado, completado';

Consultar comentarios de tablas

SELECT
c.relname AS tabla,
obj_description(c.oid) AS comentario
FROM pg_class c
WHERE c.relkind = 'r'
AND c.relnamespace = 'public'::regnamespace
ORDER BY c.relname;

Resultado (despues de agregar los comentarios):

tabla | comentario
-----------------+--------------------------------------
clientes | Clientes registrados en el sistema
detalle_pedidos | NULL
empleados | NULL
pedidos | Pedidos realizados por los clientes
productos | Catalogo de productos de la tienda

Consultar comentarios de columnas

SELECT
a.attname AS columna,
col_description(a.attrelid, a.attnum) AS comentario
FROM pg_attribute a
JOIN pg_class c ON a.attrelid = c.oid
WHERE c.relname = 'productos'
AND a.attnum > 0
AND NOT a.attisdropped
ORDER BY a.attnum;

Resultado:

columna | comentario
----------------+------------------------------------
id | NULL
nombre | NULL
precio | Precio unitario en pesos, sin IVA
categoria | NULL
stock | Unidades disponibles en bodega
fecha_creacion | NULL
💡

Buena practica

Documentar tu base de datos con COMMENT ON es un habito excelente. Ayuda a que otros miembros del equipo (y tu yo del futuro) entiendan la estructura sin tener que adivinar que significa cada columna.


Patron completo: radiografia de una tabla

Esta consulta combina toda la informacion que hemos visto en una sola vista. Es tu “navaja suiza” para explorar cualquier tabla:

SELECT
c.column_name AS columna,
c.data_type AS tipo,
CASE WHEN c.character_maximum_length IS NOT NULL
THEN c.data_type || '(' || c.character_maximum_length || ')'
ELSE c.data_type
END AS tipo_completo,
c.is_nullable AS acepta_null,
c.column_default AS valor_default,
CASE
WHEN pk.column_name IS NOT NULL THEN 'PK'
WHEN fk.column_name IS NOT NULL THEN 'FK -> ' || fk.ref_table
ELSE ''
END AS constraint_info,
col_description(
(SELECT oid FROM pg_class WHERE relname = c.table_name),
c.ordinal_position
) AS comentario
FROM information_schema.columns c
LEFT JOIN (
SELECT kcu.column_name
FROM information_schema.table_constraints tc
JOIN information_schema.key_column_usage kcu
ON tc.constraint_name = kcu.constraint_name
WHERE tc.table_name = 'pedidos'
AND tc.constraint_type = 'PRIMARY KEY'
) pk ON c.column_name = pk.column_name
LEFT JOIN (
SELECT
kcu.column_name,
ccu.table_name AS ref_table
FROM information_schema.table_constraints tc
JOIN information_schema.key_column_usage kcu
ON tc.constraint_name = kcu.constraint_name
JOIN information_schema.constraint_column_usage ccu
ON tc.constraint_name = ccu.constraint_name
WHERE tc.table_name = 'pedidos'
AND tc.constraint_type = 'FOREIGN KEY'
) fk ON c.column_name = fk.column_name
WHERE c.table_name = 'pedidos'
AND c.table_schema = 'public'
ORDER BY c.ordinal_position;

Resultado:

columna | tipo | tipo_completo | acepta_null | valor_default | constraint_info | comentario
------------+---------+---------------------+-------------+-----------------------+-------------------+------------------------------------------
id | integer | integer | NO | nextval(...) | PK | NULL
cliente_id | integer | integer | YES | NULL | FK -> clientes | NULL
fecha | date | date | YES | CURRENT_DATE | | NULL
total | numeric | numeric | YES | NULL | | NULL
estado | character varying | character varying(20)| YES | 'pendiente' | | Valores posibles: pendiente, enviado, completado

Guardala como favorita

Puedes guardar esta consulta en DBeaver (SQL → Guardar como script) y cambiar solo el nombre de la tabla ('pedidos') cada vez que necesites explorar una tabla nueva. Es la forma mas rapida de entender una tabla desconocida.


¿Que sigue?

Ya sabes como explorar la estructura de cualquier base de datos PostgreSQL. En la seccion de Ejercicios integradores vas a poner en practica todo lo que aprendiste a lo largo del curso, combinando consultas de distintos modulos para resolver problemas mas complejos.


Ejercicios

Ejercicio 1: Usa information_schema.tables para listar todas las tablas de la base de datos tienda. Muestra el nombre y el tipo de tabla.

Ver solucion
SELECT table_name, table_type
FROM information_schema.tables
WHERE table_schema = 'public'
ORDER BY table_name;

Ejercicio 2: Consulta todas las columnas de la tabla detalle_pedidos mostrando: nombre de columna, tipo de dato, si acepta NULL y el valor por defecto. Ordena por la posicion original de las columnas.

Ver solucion
SELECT
column_name,
data_type,
is_nullable,
column_default
FROM information_schema.columns
WHERE table_name = 'detalle_pedidos'
AND table_schema = 'public'
ORDER BY ordinal_position;

Ejercicio 3: Encuentra todas las columnas que son FOREIGN KEY en la base de datos tienda. Muestra la tabla de origen, la columna FK, la tabla de destino y la columna de destino.

Ver solucion
SELECT
tc.table_name AS tabla_origen,
kcu.column_name AS columna_fk,
ccu.table_name AS tabla_destino,
ccu.column_name AS columna_destino
FROM information_schema.table_constraints tc
JOIN information_schema.key_column_usage kcu
ON tc.constraint_name = kcu.constraint_name
JOIN information_schema.constraint_column_usage ccu
ON tc.constraint_name = ccu.constraint_name
WHERE tc.constraint_type = 'FOREIGN KEY'
AND tc.table_schema = 'public'
ORDER BY tc.table_name;

Ejercicio 4: Calcula el tamano total de cada tabla en la base de datos, mostrando el resultado en formato legible (KB, MB). Ordena de mayor a menor tamano.

Ver solucion
SELECT
relname AS tabla,
pg_size_pretty(pg_total_relation_size(oid)) AS tamano_total,
pg_size_pretty(pg_relation_size(oid)) AS tamano_datos,
pg_size_pretty(pg_indexes_size(oid)) AS tamano_indices
FROM pg_class
WHERE relkind = 'r'
AND relnamespace = 'public'::regnamespace
ORDER BY pg_total_relation_size(oid) DESC;

Ejercicio 5: Agrega comentarios descriptivos a las 5 tablas de la base de datos y luego consultalos. Cada comentario debe describir brevemente que informacion almacena la tabla.

Ver solucion
-- Agregar comentarios
COMMENT ON TABLE productos IS 'Catalogo de productos de la tienda';
COMMENT ON TABLE clientes IS 'Clientes registrados en el sistema';
COMMENT ON TABLE pedidos IS 'Pedidos realizados por los clientes';
COMMENT ON TABLE detalle_pedidos IS 'Lineas de detalle de cada pedido (productos y cantidades)';
COMMENT ON TABLE empleados IS 'Empleados de la tienda con jerarquia de jefes';
-- Consultar comentarios
SELECT
c.relname AS tabla,
obj_description(c.oid) AS comentario
FROM pg_class c
WHERE c.relkind = 'r'
AND c.relnamespace = 'public'::regnamespace
ORDER BY c.relname;

Ejercicio 6 (Desafio): Construye una consulta “radiografia” para la tabla empleados que muestre: nombre de columna, tipo de dato completo (incluyendo longitud para VARCHAR), si acepta NULL, valor por defecto, y si la columna es PK o FK (indicando la tabla referenciada).

Ver solucion
SELECT
c.column_name AS columna,
CASE WHEN c.character_maximum_length IS NOT NULL
THEN c.data_type || '(' || c.character_maximum_length || ')'
ELSE c.data_type
END AS tipo_completo,
c.is_nullable AS acepta_null,
c.column_default AS valor_default,
CASE
WHEN pk.column_name IS NOT NULL THEN 'PK'
WHEN fk.column_name IS NOT NULL THEN 'FK -> ' || fk.ref_table
ELSE ''
END AS constraint_info
FROM information_schema.columns c
LEFT JOIN (
SELECT kcu.column_name
FROM information_schema.table_constraints tc
JOIN information_schema.key_column_usage kcu
ON tc.constraint_name = kcu.constraint_name
WHERE tc.table_name = 'empleados'
AND tc.constraint_type = 'PRIMARY KEY'
) pk ON c.column_name = pk.column_name
LEFT JOIN (
SELECT
kcu.column_name,
ccu.table_name AS ref_table
FROM information_schema.table_constraints tc
JOIN information_schema.key_column_usage kcu
ON tc.constraint_name = kcu.constraint_name
JOIN information_schema.constraint_column_usage ccu
ON tc.constraint_name = ccu.constraint_name
WHERE tc.table_name = 'empleados'
AND tc.constraint_type = 'FOREIGN KEY'
) fk ON c.column_name = fk.column_name
WHERE c.table_name = 'empleados'
AND c.table_schema = 'public'
ORDER BY c.ordinal_position;
14

Normalizacion de bases de datos

Hasta ahora trabajamos con una base de datos ya disenada (tienda). Pero en la vida real, muchas veces tu tienes que disenar la estructura de las tablas. ¿Como decides que columnas van en que tabla? ¿Cuando crear una tabla nueva en lugar de agregar columnas? La respuesta es la normalizacion.

💡

¿Por que esto importa para ciencia de datos?

Aunque como analista de datos rara vez disenas bases de datos desde cero, entender normalizacion te ayuda a: (1) comprender por que la BD tiene la estructura que tiene, (2) escribir mejores JOINs al saber como se relacionan las tablas, y (3) detectar problemas de calidad de datos causados por mala normalizacion.


¿Que es la normalizacion?

La normalizacion es un proceso de organizar las columnas y tablas de una base de datos para:

  • Eliminar datos duplicados (redundancia)
  • Evitar anomalias al insertar, actualizar o eliminar datos
  • Asegurar consistencia en toda la base de datos

Imagina que tienes toda la informacion de pedidos en una sola tabla:

pedido_id │ fecha │ cliente_nombre │ cliente_email │ cliente_ciudad │ producto_nombre │ precio │ cantidad
───────────┼────────────┼────────────────┼───────────────────┼────────────────┼─────────────────┼────────┼─────────
1 │ 2024-01-15 │ Maria Garcia │ maria@email.com │ Santiago │ Laptop Pro 15 │1299.99 │ 1
1 │ 2024-01-15 │ Maria Garcia │ maria@email.com │ Santiago │ Mouse Inalamb. │ 29.99 │ 3
2 │ 2024-01-20 │ Carlos Lopez │ carlos@email.com │ Valparaiso │ Teclado Mecanico│ 89.99 │ 1
3 │ 2024-02-01 │ Maria Garcia │ maria@email.com │ Santiago │ Silla Ergonomica│ 299.99 │ 1

El nombre, email y ciudad de Maria aparecen 3 veces. Esto genera problemas.


Las anomalias: por que la redundancia es peligrosa

Anomalia de actualizacion

Si Maria cambia su email, necesitas actualizarlo en cada fila donde aparece. Si olvidas una, tienes datos inconsistentes:

pedido_id │ cliente_nombre │ cliente_email
───────────┼────────────────┼───────────────────
1 │ Maria Garcia │ maria_new@email.com ← actualizado
3 │ Maria Garcia │ maria@email.com ← olvidaste esta!

¿Cual es el email correcto? No se sabe.

Anomalia de insercion

Si quieres agregar un nuevo cliente que aun no ha hecho pedidos, no puedes, porque necesitas un pedido_id para insertar la fila. El cliente no puede existir sin un pedido.

Anomalia de eliminacion

Si eliminas el unico pedido de Carlos Lopez, pierdes toda la informacion del cliente: su nombre, email y ciudad desaparecen.

La solucion

La normalizacion resuelve las tres anomalias separando la informacion en tablas independientes conectadas por claves. Exactamente lo que hace nuestra base de datos tienda: clientes, productos y pedidos estan en tablas separadas.


Claves: la base de la normalizacion

Antes de hablar de formas normales, necesitas entender dos conceptos fundamentales:

Clave primaria (Primary Key)

Una columna (o conjunto de columnas) que identifica de forma unica cada fila de la tabla:

-- El id identifica unicamente cada cliente
CREATE TABLE clientes (
id SERIAL PRIMARY KEY, -- clave primaria
nombre VARCHAR(100),
email VARCHAR(100)
);

Clave foranea (Foreign Key)

Una columna que hace referencia a la clave primaria de otra tabla. Crea la relacion entre tablas:

-- cliente_id referencia a clientes.id
CREATE TABLE pedidos (
id SERIAL PRIMARY KEY,
cliente_id INTEGER REFERENCES clientes(id), -- clave foranea
fecha DATE,
total DECIMAL(10,2)
);

Dependencia funcional

Decimos que la columna B depende funcionalmente de la columna A si, dado un valor de A, siempre se obtiene el mismo valor de B. Se escribe A → B.

En nuestra tabla clientes:

  • id → nombre (dado un id, siempre obtienes el mismo nombre)
  • id → email (dado un id, siempre obtienes el mismo email)
  • id → ciudad (dado un id, siempre obtienes la misma ciudad)

Este concepto es la base de todas las formas normales.


Primera Forma Normal (1FN)

Una tabla esta en 1FN si:

  1. Cada columna tiene un solo valor (valores atomicos, no listas ni conjuntos)
  2. Cada fila es unica (tiene una clave primaria)
  3. No hay grupos repetidos de columnas

Ejemplo que viola 1FN

cliente_id │ nombre │ telefonos
────────────┼──────────────┼──────────────────────
1 │ Maria Garcia │ 555-1234, 555-5678 ← dos valores en una columna!
2 │ Carlos Lopez │ 555-8888

La columna telefonos tiene multiples valores en una sola celda. Esto hace imposible buscar por un telefono especifico.

Solucion: separar en tabla aparte

CREATE TABLE clientes (
id SERIAL PRIMARY KEY,
nombre VARCHAR(100)
);
CREATE TABLE telefonos (
id SERIAL PRIMARY KEY,
cliente_id INTEGER REFERENCES clientes(id),
telefono VARCHAR(20)
);
-- clientes -- telefonos
id │ nombre id │ cliente_id │ telefono
────┼────────── ───┼────────────┼──────────
1 │ Maria Garcia 1 │ 1 │ 555-1234
2 │ Carlos Lopez 2 │ 1 │ 555-5678
3 │ 2 │ 555-8888

Ahora puedes buscar WHERE telefono = '555-1234' facilmente.

💡

Nuestra BD tienda ya cumple 1FN

Todas las tablas de tienda tienen valores atomicos y claves primarias. No hay columnas con listas de valores.


Segunda Forma Normal (2FN)

Una tabla esta en 2FN si:

  1. Esta en 1FN
  2. Cada columna no-clave depende de toda la clave primaria (no solo de una parte)

Esto solo aplica cuando la clave primaria es compuesta (formada por dos o mas columnas).

Ejemplo que viola 2FN

Imagina una tabla de detalle de pedidos mal disenada:

pedido_id │ producto_id │ cantidad │ precio_unitario │ nombre_producto │ categoria
───────────┼─────────────┼──────────┼─────────────────┼─────────────────┼───────────
1 │ 1 │ 1 │ 1299.99 │ Laptop Pro 15 │ Electronica
1 │ 2 │ 3 │ 29.99 │ Mouse Inalamb. │ Electronica
2 │ 3 │ 1 │ 89.99 │ Teclado Mecanico│ Electronica

La clave primaria es (pedido_id, producto_id). Pero:

  • nombre_producto depende solo de producto_id (no necesita pedido_id)
  • categoria depende solo de producto_id

Esto viola 2FN: hay columnas que dependen de parte de la clave.

Solucion: separar en dos tablas

-- Detalle solo guarda lo que depende de AMBAS claves
CREATE TABLE detalle_pedidos (
pedido_id INTEGER REFERENCES pedidos(id),
producto_id INTEGER REFERENCES productos(id),
cantidad INTEGER,
precio_unitario DECIMAL(10,2),
PRIMARY KEY (pedido_id, producto_id)
);
-- Productos guarda lo que depende solo del producto
CREATE TABLE productos (
id SERIAL PRIMARY KEY,
nombre VARCHAR(100),
categoria VARCHAR(50)
);

Asi es nuestra BD tienda

Nuestra tabla detalle_pedidos solo guarda pedido_id, producto_id, cantidad y precio_unitario. El nombre y la categoria del producto estan en la tabla productos. Cumple 2FN.


Tercera Forma Normal (3FN)

Una tabla esta en 3FN si:

  1. Esta en 2FN
  2. Ninguna columna no-clave depende de otra columna no-clave (no hay dependencias transitivas)

Ejemplo que viola 3FN

empleado_id │ nombre │ departamento │ ubicacion_depto
─────────────┼───────────┼──────────────┼────────────────
1 │ Sofia │ Gerencia │ Piso 5
2 │ Juan │ Ventas │ Piso 2
3 │ Valentina │ Ventas │ Piso 2
4 │ Miguel │ Tecnologia │ Piso 3

ubicacion_depto depende de departamento, no directamente de empleado_id. La cadena es: empleado_id → departamento → ubicacion_depto

Esto es una dependencia transitiva: si cambia la ubicacion de Ventas, hay que actualizarla en multiples filas.

Solucion: tabla separada para departamentos

CREATE TABLE departamentos (
id SERIAL PRIMARY KEY,
nombre VARCHAR(50),
ubicacion VARCHAR(50)
);
CREATE TABLE empleados (
id SERIAL PRIMARY KEY,
nombre VARCHAR(100),
departamento_id INTEGER REFERENCES departamentos(id),
salario DECIMAL(10,2)
);

Ahora la ubicacion se guarda una sola vez en la tabla departamentos.


Resumen de formas normales

Comando Descripcion
1FN Valores atomicos (una celda = un valor). No hay listas ni arrays en una columna. Cada fila es unica.
2FN Toda columna no-clave depende de TODA la clave primaria (no solo de una parte). Aplica a claves compuestas.
3FN No hay dependencias transitivas: cada columna no-clave depende directamente de la clave primaria, no de otra columna no-clave.
💡

¿Existen mas formas normales?

Si. Existen la BCNF (Boyce-Codd), 4FN y 5FN. Pero en la practica, llegar a 3FN es suficiente para la gran mayoria de bases de datos. Las formas superiores abordan situaciones muy especificas que rara vez se encuentran en el dia a dia.


Nuestra BD tienda: analisis de normalizacion

Veamos como nuestra base de datos cumple con las formas normales:

productos(id, nombre, precio, categoria, stock, fecha_creacion)
→ 1FN ✓ valores atomicos, id es PK
→ 2FN ✓ clave simple (id), todo depende de id
→ 3FN ✓ ninguna columna depende de otra columna no-clave
clientes(id, nombre, email, ciudad, fecha_registro)
→ 1FN ✓
→ 2FN ✓
→ 3FN ✓
pedidos(id, cliente_id, fecha, total, estado)
→ 1FN ✓
→ 2FN ✓
→ 3FN ✓ (total podria calcularse de detalle_pedidos, pero es un atajo aceptable)
detalle_pedidos(id, pedido_id, producto_id, cantidad, precio_unitario)
→ 1FN ✓
→ 2FN ✓ precio_unitario depende del momento de la compra, no solo del producto
→ 3FN ✓
empleados(id, nombre, departamento, salario, fecha_contratacion, jefe_id)
→ 1FN ✓
→ 2FN ✓
→ 3FN ⚠ departamento como texto podria separarse en una tabla departamentos

Normalizacion pragmatica

La tabla empleados tiene departamento como texto en lugar de una referencia a una tabla departamentos. En un sistema real, esto podria generar inconsistencias (alguien escribe “Tecnologia” y otro “tecnologia”). Sin embargo, para un curso introductorio, esta simplificacion es aceptable. En produccion, normalizarias esto.


Desnormalizacion: cuando romper las reglas

A veces, la normalizacion estricta genera consultas con demasiados JOINs que son lentas. En esos casos, se desnormaliza intencionalmente para mejorar el rendimiento.

Cuando desnormalizar

  • Consultas de lectura muy frecuentes donde los JOINs son costosos
  • Reportes y dashboards que necesitan respuesta rapida
  • Data warehouses (almacenes de datos) para analitica
  • Campos calculados que se consultan constantemente (ej: total en pedidos)

Ejemplo: el campo total en pedidos

En nuestra BD, pedidos.total es un dato desnormalizado: podria calcularse sumando cantidad * precio_unitario de detalle_pedidos. Pero almacenarlo evita recalcular el total cada vez que se consulta:

-- Sin desnormalizacion: calcular el total cada vez
SELECT p.id, p.fecha,
SUM(dp.cantidad * dp.precio_unitario) AS total
FROM pedidos p
JOIN detalle_pedidos dp ON p.id = dp.pedido_id
GROUP BY p.id, p.fecha;
-- Con desnormalizacion: leer directamente
SELECT id, fecha, total FROM pedidos;

La segunda consulta es mucho mas rapida y simple.

Ejemplo: nombre de categoria como texto

En productos, categoria es un VARCHAR en lugar de un categoria_id con tabla aparte. Esto es una desnormalizacion consciente:

Comando Descripcion
Normalizado productos.categoria_id → categorias(id, nombre). Requiere JOIN para mostrar el nombre.
Desnormalizado productos.categoria = 'Electronica'. No requiere JOIN, pero riesgo de inconsistencia.
⚠️

Desnormalizar con conciencia

La desnormalizacion es una decision deliberada basada en el analisis de rendimiento. Nunca desnormalices por pereza o por no saber normalizar. Primero normaliza correctamente, y luego desnormaliza donde el rendimiento lo justifique, documentando la razon.


Disenar una tabla normalizada: paso a paso

Si necesitas crear una nueva estructura, sigue estos pasos:

  1. Lista todas las entidades (sustantivos: cliente, producto, pedido, etc.)
  2. Lista los atributos de cada entidad (columnas)
  3. Identifica las claves primarias (¿que identifica unicamente a cada registro?)
  4. Identifica las relaciones (¿que entidades se conectan entre si?)
  5. Aplica 1FN: ¿hay columnas con multiples valores? → separar en tabla
  6. Aplica 2FN: ¿hay columnas que dependen de parte de la clave? → mover a otra tabla
  7. Aplica 3FN: ¿hay dependencias transitivas? → crear tabla intermedia
  8. Evalua desnormalizacion: ¿hay consultas criticas que se benefician de redundancia controlada?

Ejercicios

Ejercicio 1: Identifica que forma normal viola esta tabla y explica como la normalizarias:

orden_id │ cliente │ email │ productos
──────────┼─────────┼────────────────┼──────────────────────────
1 │ Maria │ maria@mail.com │ Laptop, Mouse, Teclado
2 │ Carlos │ carlos@mail.com│ Monitor
3 │ Maria │ maria@mail.com │ Silla
Ver solucion

Viola 1FN: la columna productos tiene multiples valores en una celda (“Laptop, Mouse, Teclado”).

Tambien hay redundancia: Maria aparece dos veces con su email repetido (potencial anomalia de actualizacion).

Normalizacion:

-- Separar en 3 tablas
CREATE TABLE clientes (
id SERIAL PRIMARY KEY,
nombre VARCHAR(100),
email VARCHAR(100)
);
CREATE TABLE ordenes (
id SERIAL PRIMARY KEY,
cliente_id INTEGER REFERENCES clientes(id),
fecha DATE
);
CREATE TABLE detalle_ordenes (
id SERIAL PRIMARY KEY,
orden_id INTEGER REFERENCES ordenes(id),
producto VARCHAR(100)
);

Ahora cada producto tiene su propia fila y los datos del cliente estan en un solo lugar.

Ejercicio 2: Mira la tabla empleados de nuestra BD. ¿Cumple estrictamente con 3FN? Si no, ¿como la normalizarias? Escribe el SQL para crear la estructura normalizada.

Ver solucion

La tabla empleados tiene departamento como texto. Si el departamento tuviera mas atributos (ubicacion, gerente, presupuesto), seria una dependencia transitiva: id → departamento → ubicacion_depto.

Normalizacion estricta:

CREATE TABLE departamentos (
id SERIAL PRIMARY KEY,
nombre VARCHAR(50) NOT NULL UNIQUE,
ubicacion VARCHAR(100)
);
CREATE TABLE empleados_normalizado (
id SERIAL PRIMARY KEY,
nombre VARCHAR(100) NOT NULL,
departamento_id INTEGER REFERENCES departamentos(id),
salario DECIMAL(10,2),
fecha_contratacion DATE,
jefe_id INTEGER REFERENCES empleados_normalizado(id)
);
-- Poblar departamentos
INSERT INTO departamentos (nombre) VALUES
('Gerencia'), ('Ventas'), ('Tecnologia'), ('Soporte');
-- Migrar empleados
INSERT INTO empleados_normalizado (id, nombre, departamento_id, salario, fecha_contratacion, jefe_id)
SELECT e.id, e.nombre, d.id, e.salario, e.fecha_contratacion, e.jefe_id
FROM empleados e
JOIN departamentos d ON e.departamento = d.nombre;

Ejercicio 3: ¿Por que detalle_pedidos guarda precio_unitario si ese dato ya existe en productos.precio? ¿Es redundancia o tiene una razon valida?

Ver solucion

No es redundancia innecesaria, es una desnormalizacion con una razon muy valida: el precio de un producto puede cambiar con el tiempo.

Si detalle_pedidos solo guardara producto_id y obtuviera el precio de productos, entonces al subir el precio de un producto, todos los pedidos historicos mostrarian el precio nuevo, no el que realmente se pago.

precio_unitario en detalle_pedidos captura el precio en el momento de la compra. Es un dato historico que no debe cambiar aunque el precio actual del producto cambie.

Este patron se llama snapshot de precio y es una practica estandar en sistemas de ventas.

Ejercicio 4: Disena una tabla para un sistema de biblioteca que almacene: libros, autores (un libro puede tener varios autores) y prestamos. Aplica al menos 3FN.

Ver solucion
-- Autores
CREATE TABLE autores (
id SERIAL PRIMARY KEY,
nombre VARCHAR(100) NOT NULL,
nacionalidad VARCHAR(50)
);
-- Libros
CREATE TABLE libros (
id SERIAL PRIMARY KEY,
titulo VARCHAR(200) NOT NULL,
isbn VARCHAR(20) UNIQUE,
anio_publicacion INTEGER,
genero VARCHAR(50)
);
-- Relacion muchos-a-muchos: un libro puede tener varios autores
CREATE TABLE libro_autores (
libro_id INTEGER REFERENCES libros(id),
autor_id INTEGER REFERENCES autores(id),
PRIMARY KEY (libro_id, autor_id)
);
-- Socios de la biblioteca
CREATE TABLE socios (
id SERIAL PRIMARY KEY,
nombre VARCHAR(100) NOT NULL,
email VARCHAR(100)
);
-- Prestamos
CREATE TABLE prestamos (
id SERIAL PRIMARY KEY,
libro_id INTEGER REFERENCES libros(id),
socio_id INTEGER REFERENCES socios(id),
fecha_prestamo DATE DEFAULT CURRENT_DATE,
fecha_devolucion DATE,
estado VARCHAR(20) DEFAULT 'prestado'
);

Justificacion:

  • 1FN: Un libro con varios autores no se resuelve con una columna “autores” (lista), sino con la tabla intermedia libro_autores.
  • 2FN: libro_autores tiene clave compuesta y no hay dependencias parciales.
  • 3FN: No hay dependencias transitivas. El genero depende del libro, la nacionalidad del autor.

Ejercicio 5: Consulta la BD tienda y verifica que no hay datos inconsistentes. Busca si algun producto tiene una categoria que no sea ‘Electronica’, ‘Muebles’ o ‘Accesorios’. ¿Que restriccion le falta a la tabla para prevenir esto?

Ver solucion
-- Verificar categorias existentes
SELECT DISTINCT categoria FROM productos ORDER BY categoria;
-- Buscar categorias inesperadas
SELECT * FROM productos
WHERE categoria NOT IN ('Electronica', 'Muebles', 'Accesorios');

La tabla productos no tiene una restriccion que limite las categorias validas. Para prevenirlo, podrias:

-- Opcion 1: restriccion CHECK
ALTER TABLE productos
ADD CONSTRAINT chk_categoria
CHECK (categoria IN ('Electronica', 'Muebles', 'Accesorios'));
-- Opcion 2: tabla de categorias (normalizacion)
CREATE TABLE categorias (
id SERIAL PRIMARY KEY,
nombre VARCHAR(50) NOT NULL UNIQUE
);
-- Y cambiar productos.categoria por productos.categoria_id con FK

La opcion 2 (normalizar) es mejor si las categorias pueden crecer o tener mas atributos. La opcion 1 (CHECK) es mas simple si las categorias son fijas y pocas.

Ejercicio 6 (Desafio): Tienes esta tabla desnormalizada de un e-commerce. Identificar las anomalias, normalizar a 3FN y escribir el SQL para crear las tablas:

venta_id │ fecha │ cliente │ email │ ciudad │ producto │ categoria │ precio │ cantidad │ vendedor │ comision_pct
Ver solucion

Anomalias:

  • Actualizacion: cambiar email de un cliente requiere actualizar multiples filas
  • Insercion: no puedes registrar un producto sin una venta
  • Eliminacion: borrar la unica venta de un cliente borra toda su informacion

Normalizacion a 3FN:

CREATE TABLE clientes_norm (
id SERIAL PRIMARY KEY,
nombre VARCHAR(100) NOT NULL,
email VARCHAR(100) UNIQUE,
ciudad VARCHAR(50)
);
CREATE TABLE categorias_norm (
id SERIAL PRIMARY KEY,
nombre VARCHAR(50) NOT NULL UNIQUE
);
CREATE TABLE productos_norm (
id SERIAL PRIMARY KEY,
nombre VARCHAR(100) NOT NULL,
categoria_id INTEGER REFERENCES categorias_norm(id),
precio DECIMAL(10,2) NOT NULL
);
CREATE TABLE vendedores (
id SERIAL PRIMARY KEY,
nombre VARCHAR(100) NOT NULL,
comision_pct DECIMAL(5,2)
);
CREATE TABLE ventas (
id SERIAL PRIMARY KEY,
fecha DATE DEFAULT CURRENT_DATE,
cliente_id INTEGER REFERENCES clientes_norm(id),
vendedor_id INTEGER REFERENCES vendedores(id)
);
CREATE TABLE detalle_ventas (
id SERIAL PRIMARY KEY,
venta_id INTEGER REFERENCES ventas(id),
producto_id INTEGER REFERENCES productos_norm(id),
cantidad INTEGER NOT NULL,
precio_unitario DECIMAL(10,2) NOT NULL -- snapshot del precio al momento
);

Entidades identificadas: clientes, categorias, productos, vendedores, ventas, detalle_ventas. Cada una solo contiene los atributos que dependen directamente de su clave primaria.

15

Modelo dimensional: hechos y dimensiones

En el modulo anterior aprendiste a normalizar una base de datos para evitar redundancia. Pero las bases de datos normalizadas estan optimizadas para escribir datos (insertar, actualizar), no para analizarlos. Cuando necesitas hacer analisis sobre grandes volumenes de datos, necesitas una estructura diferente: el modelo dimensional.

💡

¿Donde se usa esto?

El modelo dimensional es la base de los data warehouses (almacenes de datos) y herramientas de Business Intelligence como Tableau, Power BI y Looker. Si trabajas en ciencia de datos o analitica, vas a encontrarte con este modelo constantemente.


OLTP vs OLAP: dos mundos diferentes

Las bases de datos se usan para dos propositos muy distintos:

Comando Descripcion
OLTP (Online Transaction Processing) Operaciones del dia a dia: insertar pedidos, actualizar stock, registrar clientes. Muchas escrituras pequenas y rapidas. Estructura normalizada (3FN).
OLAP (Online Analytical Processing) Analisis y reportes: ventas por mes, tendencias, comparaciones. Lecturas masivas sobre grandes volumenes. Estructura desnormalizada (modelo dimensional).

Nuestra BD tienda es OLTP: esta normalizada para registrar transacciones. Pero si quisieras analizar las ventas de los ultimos 3 anos cruzando datos de productos, clientes, tiempo y vendedores, la estructura normalizada requiere muchos JOINs y es lenta para volumen.

El modelo dimensional reorganiza esos mismos datos para hacer el analisis rapido y natural.


Tablas de hechos y dimensiones

El modelo dimensional se basa en dos tipos de tablas:

Tabla de hechos (Fact table)

Contiene los eventos medibles del negocio: ventas, pedidos, transacciones, clics. Cada fila es un evento y tiene:

  • Claves foraneas que apuntan a las tablas de dimensiones
  • Medidas (metricas numericas): cantidad vendida, monto, duracion, etc.
-- Tabla de hechos: cada fila es una linea de venta
CREATE TABLE fact_ventas (
venta_id INTEGER,
fecha_id INTEGER, -- FK a dim_fecha
producto_id INTEGER, -- FK a dim_producto
cliente_id INTEGER, -- FK a dim_cliente
-- Medidas (lo que quieres analizar)
cantidad INTEGER,
monto DECIMAL(10,2),
descuento DECIMAL(10,2),
costo DECIMAL(10,2)
);

Tabla de dimensiones (Dimension table)

Contiene las descripciones y contexto de los hechos: quien compro, que producto, cuando, donde. Son tablas anchas con muchos atributos descriptivos:

-- Dimension de producto: describe cada producto
CREATE TABLE dim_producto (
producto_id INTEGER PRIMARY KEY,
nombre VARCHAR(100),
categoria VARCHAR(50),
subcategoria VARCHAR(50),
marca VARCHAR(50),
precio_lista DECIMAL(10,2)
);
-- Dimension de cliente: describe cada cliente
CREATE TABLE dim_cliente (
cliente_id INTEGER PRIMARY KEY,
nombre VARCHAR(100),
email VARCHAR(100),
ciudad VARCHAR(50),
region VARCHAR(50),
segmento VARCHAR(50) -- 'Premium', 'Regular', 'Nuevo'
);
-- Dimension de fecha: describe cada dia
CREATE TABLE dim_fecha (
fecha_id INTEGER PRIMARY KEY,
fecha DATE,
dia INTEGER,
mes INTEGER,
trimestre INTEGER,
anio INTEGER,
nombre_mes VARCHAR(20),
nombre_dia VARCHAR(20),
es_fin_de_semana BOOLEAN
);

La dimension de fecha es especial

La tabla dim_fecha siempre existe en un modelo dimensional. Se pre-carga con todos los dias de un rango (ej: 2020-2030) y tiene columnas calculadas como el nombre del mes, trimestre, si es feriado, etc. Esto permite agrupar facilmente por cualquier periodo sin funciones como EXTRACT.


Esquema estrella (Star Schema)

El esquema estrella es la forma mas comun de organizar un modelo dimensional. La tabla de hechos esta en el centro, rodeada por las dimensiones:

dim_fecha
│ fecha_id
dim_cliente ──── fact_ventas ──── dim_producto
cliente_id │ producto_id
│ vendedor_id
dim_vendedor

Se llama “estrella” porque visualmente la tabla de hechos es el centro y las dimensiones son las puntas.

Ventajas del esquema estrella

  • Consultas simples: maximo 1 JOIN por dimension
  • Rendimiento: pocas tablas, JOINs directos, facil de optimizar
  • Intuitivo: los usuarios de negocio entienden la estructura facilmente

Esquema copo de nieve (Snowflake Schema)

Es una variacion donde las dimensiones estan normalizadas: en lugar de una tabla dim_producto con todos los atributos, tienes dim_producto → dim_categoria → dim_subcategoria.

dim_fecha
dim_cliente ──── fact_ventas ──── dim_producto ──── dim_categoria
dim_vendedor ──── dim_region
Comando Descripcion
Esquema estrella Dimensiones desnormalizadas (anchas). Menos JOINs, mas rapido para consultas. Es el mas usado.
Esquema copo de nieve Dimensiones normalizadas. Ahorra espacio pero requiere mas JOINs. Menos comun en la practica.

En la practica, usa estrella

El esquema estrella es el estandar de la industria. El ahorro de espacio del copo de nieve rara vez justifica la complejidad adicional de los JOINs. Las herramientas de BI estan optimizadas para trabajar con esquemas estrella.


Transformando tienda a modelo dimensional

Veamos como convertiria nuestra BD tienda (OLTP) a un modelo dimensional (OLAP):

Paso 1: Identificar los hechos

El evento medible principal es la venta de productos. Cada linea de detalle_pedidos es un hecho.

Paso 2: Identificar las dimensiones

  • Producto: nombre, categoria, precio
  • Cliente: nombre, ciudad
  • Fecha: datos temporales del pedido
  • Estado del pedido: completado, enviado, pendiente

Paso 3: Crear las tablas dimensionales

-- Dimension fecha (pre-cargada)
CREATE TABLE dim_fecha (
fecha_id INTEGER PRIMARY KEY,
fecha DATE NOT NULL,
dia INTEGER,
mes INTEGER,
trimestre INTEGER,
anio INTEGER,
nombre_mes VARCHAR(20),
dia_semana VARCHAR(20),
es_fin_de_semana BOOLEAN
);
-- Cargar fechas para 2024
INSERT INTO dim_fecha
SELECT
(EXTRACT(YEAR FROM d) * 10000 + EXTRACT(MONTH FROM d) * 100 + EXTRACT(DAY FROM d))::INTEGER AS fecha_id,
d AS fecha,
EXTRACT(DAY FROM d)::INTEGER AS dia,
EXTRACT(MONTH FROM d)::INTEGER AS mes,
EXTRACT(QUARTER FROM d)::INTEGER AS trimestre,
EXTRACT(YEAR FROM d)::INTEGER AS anio,
TO_CHAR(d, 'Month') AS nombre_mes,
TO_CHAR(d, 'Day') AS dia_semana,
EXTRACT(DOW FROM d) IN (0, 6) AS es_fin_de_semana
FROM generate_series('2024-01-01'::DATE, '2024-12-31'::DATE, '1 day') AS d;
-- Dimension producto
CREATE TABLE dim_producto AS
SELECT
id AS producto_id,
nombre,
categoria,
precio AS precio_lista
FROM productos;
-- Dimension cliente
CREATE TABLE dim_cliente AS
SELECT
id AS cliente_id,
nombre,
email,
ciudad
FROM clientes;

Paso 4: Crear la tabla de hechos

CREATE TABLE fact_ventas AS
SELECT
dp.id AS venta_id,
(EXTRACT(YEAR FROM pe.fecha) * 10000
+ EXTRACT(MONTH FROM pe.fecha) * 100
+ EXTRACT(DAY FROM pe.fecha))::INTEGER AS fecha_id,
dp.producto_id,
pe.cliente_id,
pe.estado,
dp.cantidad,
dp.precio_unitario,
dp.cantidad * dp.precio_unitario AS monto_total
FROM detalle_pedidos dp
JOIN pedidos pe ON dp.pedido_id = pe.id;

Paso 5: Consultar el modelo dimensional

Ahora las consultas analiticas son simples y directas:

-- Ventas por categoria y mes
SELECT
dp.categoria,
df.nombre_mes,
df.anio,
SUM(fv.monto_total) AS total_ventas,
SUM(fv.cantidad) AS unidades
FROM fact_ventas fv
JOIN dim_producto dp ON fv.producto_id = dp.producto_id
JOIN dim_fecha df ON fv.fecha_id = df.fecha_id
GROUP BY dp.categoria, df.nombre_mes, df.anio, df.mes
ORDER BY df.anio, df.mes;
-- Top clientes por ciudad y trimestre
SELECT
dc.ciudad,
df.trimestre,
dc.nombre,
SUM(fv.monto_total) AS gasto_total
FROM fact_ventas fv
JOIN dim_cliente dc ON fv.cliente_id = dc.cliente_id
JOIN dim_fecha df ON fv.fecha_id = df.fecha_id
GROUP BY dc.ciudad, df.trimestre, dc.nombre
ORDER BY dc.ciudad, df.trimestre, gasto_total DESC;
💡

Nota sobre el rendimiento

Con 15 productos y 15 pedidos, no vas a notar diferencia de rendimiento. La ventaja del modelo dimensional se manifiesta con millones de filas, donde la estructura desnormalizada y los JOINs simples marcan una diferencia enorme.


Tipos de tablas de hechos

No todas las tablas de hechos son iguales. Existen tres tipos principales:

Comando Descripcion
Hechos transaccionales Un registro por evento/transaccion. Es el mas comun. Ejemplo: cada linea de venta.
Hechos periodicos (snapshot) Un registro por periodo fijo. Ejemplo: stock al final de cada dia, saldo al cierre de mes.
Hechos acumulativos Registro que se actualiza en el tiempo. Ejemplo: un pedido que pasa por estados (creado → enviado → entregado) con fechas.

Nuestra fact_ventas es transaccional: cada fila es una linea de venta individual.

Un ejemplo de hechos periodicos seria:

-- Snapshot diario de stock
CREATE TABLE fact_stock_diario (
fecha_id INTEGER,
producto_id INTEGER,
stock_al_cierre INTEGER,
unidades_vendidas_dia INTEGER,
unidades_recibidas_dia INTEGER
);

Slowly Changing Dimensions (SCD)

Un problema comun: ¿que pasa cuando los datos de una dimension cambian? Si un cliente se muda de Santiago a Valparaiso, ¿actualizas la dimension y pierdes el historial?

Comando Descripcion
SCD Tipo 1: Sobreescribir Simplemente actualizas el valor. Se pierde el historial. Util cuando no importa la historia (ej: corregir un error).
SCD Tipo 2: Nueva fila Creas una nueva fila con los datos nuevos y marcas la anterior como inactiva. Preserva el historial completo.
SCD Tipo 3: Nueva columna Agregas una columna 'valor_anterior'. Solo guarda el cambio mas reciente, no el historial completo.

SCD Tipo 2: el mas usado

-- Dimension cliente con historial (SCD Tipo 2)
CREATE TABLE dim_cliente_scd2 (
cliente_sk SERIAL PRIMARY KEY, -- surrogate key (clave sintetica)
cliente_id INTEGER, -- clave natural (id original)
nombre VARCHAR(100),
ciudad VARCHAR(50),
fecha_inicio DATE, -- desde cuando es valido
fecha_fin DATE, -- hasta cuando (NULL = activo)
es_actual BOOLEAN DEFAULT TRUE
);
cliente_sk │ cliente_id │ nombre │ ciudad │ fecha_inicio │ fecha_fin │ es_actual
────────────┼────────────┼──────────────┼─────────────┼──────────────┼────────────┼──────────
1 │ 1 │ Maria Garcia │ Santiago │ 2024-01-01 │ 2024-06-30 │ FALSE
2 │ 1 │ Maria Garcia │ Valparaiso │ 2024-07-01 │ NULL │ TRUE
  • Para analisis actual: filtrar con WHERE es_actual = TRUE
  • Para analisis historico: hacer JOIN por rango de fechas

Surrogate keys

En el modelo dimensional, las dimensiones SCD Tipo 2 usan una surrogate key (clave sintetica auto-incremental) como PK en lugar de la clave natural. Esto permite tener multiples filas para el mismo cliente (una por cada version historica).


Medidas: aditivas, semi-aditivas y no aditivas

Las medidas (metricas) en la tabla de hechos se clasifican segun como se pueden agregar:

Comando Descripcion
Aditivas Se pueden sumar en todas las dimensiones. Ejemplo: monto de venta, cantidad. SUM siempre tiene sentido.
Semi-aditivas Se pueden sumar en algunas dimensiones pero no en otras. Ejemplo: saldo de cuenta (puedes sumar por cliente, pero no por tiempo).
No aditivas No se pueden sumar. Ejemplo: porcentaje de descuento, precio unitario. Requieren AVG, MAX o calculo especial.
-- Ejemplo: medidas aditivas (se pueden sumar libremente)
SELECT
dp.categoria,
SUM(fv.monto_total) AS ventas_totales, -- aditiva: OK sumar
SUM(fv.cantidad) AS unidades_totales -- aditiva: OK sumar
FROM fact_ventas fv
JOIN dim_producto dp ON fv.producto_id = dp.producto_id
GROUP BY dp.categoria;
-- Ejemplo: medida no aditiva (NO sumar, usar AVG)
SELECT
dp.categoria,
ROUND(AVG(fv.precio_unitario), 2) AS precio_promedio -- no aditiva
FROM fact_ventas fv
JOIN dim_producto dp ON fv.producto_id = dp.producto_id
GROUP BY dp.categoria;

Modelo dimensional vs normalizado: resumen

Comando Descripcion
Proposito Normalizado: transacciones (OLTP). Dimensional: analisis (OLAP).
Estructura Normalizado: muchas tablas, sin redundancia. Dimensional: pocas tablas, redundancia controlada.
JOINs Normalizado: muchos, complejos. Dimensional: pocos, simples (estrella).
Escritura Normalizado: rapido y seguro. Dimensional: mas lento (transformaciones ETL).
Lectura/analisis Normalizado: puede ser lento. Dimensional: optimizado para grandes volumenes.
Usuarios Normalizado: desarrolladores. Dimensional: analistas, herramientas BI.
💡

No es uno u otro

En la practica, las empresas tienen ambos: una base OLTP normalizada para las operaciones diarias, y un data warehouse dimensional para analitica. Un proceso llamado ETL (Extract, Transform, Load) copia y transforma los datos del OLTP al warehouse periodicamente.


Ejercicios

Ejercicio 1: Usando la BD tienda, crea una dimension de fecha (dim_fecha) con los campos: fecha_id, fecha, dia, mes, trimestre, anio, nombre_mes. Cargala con todas las fechas de 2024.

Ver solucion
CREATE TABLE dim_fecha (
fecha_id INTEGER PRIMARY KEY,
fecha DATE NOT NULL,
dia INTEGER,
mes INTEGER,
trimestre INTEGER,
anio INTEGER,
nombre_mes VARCHAR(20)
);
INSERT INTO dim_fecha
SELECT
(EXTRACT(YEAR FROM d) * 10000 + EXTRACT(MONTH FROM d) * 100 + EXTRACT(DAY FROM d))::INTEGER,
d,
EXTRACT(DAY FROM d)::INTEGER,
EXTRACT(MONTH FROM d)::INTEGER,
EXTRACT(QUARTER FROM d)::INTEGER,
EXTRACT(YEAR FROM d)::INTEGER,
TO_CHAR(d, 'Month')
FROM generate_series('2024-01-01'::DATE, '2024-12-31'::DATE, '1 day') AS d;
-- Verificar
SELECT * FROM dim_fecha LIMIT 10;
SELECT COUNT(*) FROM dim_fecha; -- deberia dar 366 (2024 es bisiesto)

Ejercicio 2: Crea una tabla de hechos fact_ventas a partir de detalle_pedidos y pedidos. Debe incluir: venta_id, fecha_id (formato YYYYMMDD), producto_id, cliente_id, cantidad, precio_unitario y monto_total.

Ver solucion
CREATE TABLE fact_ventas AS
SELECT
dp.id AS venta_id,
(EXTRACT(YEAR FROM pe.fecha) * 10000
+ EXTRACT(MONTH FROM pe.fecha) * 100
+ EXTRACT(DAY FROM pe.fecha))::INTEGER AS fecha_id,
dp.producto_id,
pe.cliente_id,
dp.cantidad,
dp.precio_unitario,
dp.cantidad * dp.precio_unitario AS monto_total
FROM detalle_pedidos dp
JOIN pedidos pe ON dp.pedido_id = pe.id;
-- Verificar
SELECT COUNT(*) FROM fact_ventas;
SELECT * FROM fact_ventas LIMIT 5;

Ejercicio 3: Usando el modelo dimensional creado en los ejercicios anteriores, escribe una consulta que muestre las ventas totales por categoria y por trimestre. Compara la complejidad con la misma consulta usando el modelo OLTP original.

Ver solucion
-- Con modelo dimensional (simple y directo)
SELECT
dp.categoria,
df.trimestre,
SUM(fv.monto_total) AS total_ventas,
SUM(fv.cantidad) AS unidades
FROM fact_ventas fv
JOIN dim_producto dp ON fv.producto_id = dp.producto_id
JOIN dim_fecha df ON fv.fecha_id = df.fecha_id
GROUP BY dp.categoria, df.trimestre
ORDER BY dp.categoria, df.trimestre;
-- Con modelo OLTP (mas JOINs y funciones)
SELECT
pr.categoria,
EXTRACT(QUARTER FROM pe.fecha) AS trimestre,
SUM(det.cantidad * det.precio_unitario) AS total_ventas,
SUM(det.cantidad) AS unidades
FROM detalle_pedidos det
JOIN pedidos pe ON det.pedido_id = pe.id
JOIN productos pr ON det.producto_id = pr.id
GROUP BY pr.categoria, EXTRACT(QUARTER FROM pe.fecha)
ORDER BY pr.categoria, trimestre;

Ambas dan el mismo resultado, pero la version dimensional:

  • Usa df.trimestre directamente (sin EXTRACT)
  • El monto ya esta precalculado en fact_ventas
  • Con millones de filas, la diferencia de rendimiento es significativa

Ejercicio 4: ¿Cual de estas medidas es aditiva, semi-aditiva o no aditiva? Justifica.

  • (a) Cantidad vendida
  • (b) Precio unitario
  • (c) Stock al cierre del dia
  • (d) Monto total de venta
  • (e) Porcentaje de descuento
Ver solucion
  • (a) Cantidad vendida → Aditiva. Puedes sumar cantidades por producto, por cliente, por tiempo. Siempre tiene sentido: “vendimos 50 unidades en enero + 60 en febrero = 110 en el bimestre”.

  • (b) Precio unitario → No aditiva. Sumar precios no tiene sentido. Debes usar AVG (precio promedio) o no agregar.

  • (c) Stock al cierre del dia → Semi-aditiva. Puedes sumar stock de diferentes productos (stock total), pero NO sumar stock de diferentes dias (el stock de lunes + martes no es el stock real). Usa LAST o MAX por tiempo.

  • (d) Monto total de venta → Aditiva. Puedes sumar montos por cualquier dimension.

  • (e) Porcentaje de descuento → No aditiva. Sumar porcentajes no tiene sentido. Usa AVG ponderado si necesitas un descuento “promedio”.

Ejercicio 5: Disena un modelo dimensional (estrella) para un hospital que necesita analizar: numero de consultas medicas, duracion promedio de consultas, e ingresos por consulta. Las dimensiones son: medico, paciente, diagnostico y fecha. Escribe el SQL para crear las tablas.

Ver solucion
-- Dimension medico
CREATE TABLE dim_medico (
medico_id INTEGER PRIMARY KEY,
nombre VARCHAR(100),
especialidad VARCHAR(50),
departamento VARCHAR(50)
);
-- Dimension paciente
CREATE TABLE dim_paciente (
paciente_id INTEGER PRIMARY KEY,
nombre VARCHAR(100),
fecha_nacimiento DATE,
genero VARCHAR(20),
ciudad VARCHAR(50),
prevision VARCHAR(50) -- 'FONASA', 'Isapre', etc.
);
-- Dimension diagnostico
CREATE TABLE dim_diagnostico (
diagnostico_id INTEGER PRIMARY KEY,
codigo_cie10 VARCHAR(10),
nombre VARCHAR(200),
categoria VARCHAR(100),
es_cronico BOOLEAN
);
-- Dimension fecha (igual que antes)
CREATE TABLE dim_fecha_hospital (
fecha_id INTEGER PRIMARY KEY,
fecha DATE,
dia INTEGER,
mes INTEGER,
trimestre INTEGER,
anio INTEGER,
nombre_mes VARCHAR(20),
es_fin_de_semana BOOLEAN
);
-- Tabla de hechos: cada fila es una consulta medica
CREATE TABLE fact_consultas (
consulta_id INTEGER PRIMARY KEY,
fecha_id INTEGER REFERENCES dim_fecha_hospital(fecha_id),
medico_id INTEGER REFERENCES dim_medico(medico_id),
paciente_id INTEGER REFERENCES dim_paciente(paciente_id),
diagnostico_id INTEGER REFERENCES dim_diagnostico(diagnostico_id),
-- Medidas
duracion_minutos INTEGER, -- aditiva
costo_consulta DECIMAL(10,2), -- aditiva
medicamentos_recetados INTEGER -- aditiva
);
-- Ejemplo de consulta analitica
SELECT
dm.especialidad,
df.trimestre,
COUNT(*) AS total_consultas,
ROUND(AVG(fc.duracion_minutos), 1) AS duracion_promedio,
SUM(fc.costo_consulta) AS ingresos_totales
FROM fact_consultas fc
JOIN dim_medico dm ON fc.medico_id = dm.medico_id
JOIN dim_fecha_hospital df ON fc.fecha_id = df.fecha_id
GROUP BY dm.especialidad, df.trimestre
ORDER BY dm.especialidad, df.trimestre;

Ejercicio 6 (Desafio): Implementa un SCD Tipo 2 para la dimension de clientes. Crea la tabla dim_cliente_historico con surrogate key, campos de vigencia (fecha_inicio, fecha_fin) y flag es_actual. Luego simula que Maria Garcia se muda de Santiago a Valparaiso: inserta la nueva version y actualiza la anterior.

Ver solucion
-- Crear dimension con historial
CREATE TABLE dim_cliente_historico (
cliente_sk SERIAL PRIMARY KEY,
cliente_id INTEGER NOT NULL,
nombre VARCHAR(100),
email VARCHAR(100),
ciudad VARCHAR(50),
fecha_inicio DATE NOT NULL,
fecha_fin DATE,
es_actual BOOLEAN DEFAULT TRUE
);
-- Cargar version inicial desde la BD OLTP
INSERT INTO dim_cliente_historico (cliente_id, nombre, email, ciudad, fecha_inicio)
SELECT id, nombre, email, ciudad, '2024-01-01'
FROM clientes;
-- Verificar
SELECT * FROM dim_cliente_historico WHERE cliente_id = 1;
-- Maria Garcia, Santiago, fecha_inicio=2024-01-01, es_actual=TRUE
-- Simular cambio: Maria se muda a Valparaiso el 2024-07-01
BEGIN;
-- 1. Cerrar la version anterior
UPDATE dim_cliente_historico
SET fecha_fin = '2024-06-30',
es_actual = FALSE
WHERE cliente_id = 1 AND es_actual = TRUE;
-- 2. Insertar nueva version
INSERT INTO dim_cliente_historico (cliente_id, nombre, email, ciudad, fecha_inicio)
VALUES (1, 'Maria Garcia', 'maria@email.com', 'Valparaiso', '2024-07-01');
COMMIT;
-- Verificar el historial
SELECT * FROM dim_cliente_historico WHERE cliente_id = 1 ORDER BY fecha_inicio;
cliente_sk │ cliente_id │ nombre │ ciudad │ fecha_inicio │ fecha_fin │ es_actual
────────────┼────────────┼──────────────┼─────────────┼──────────────┼────────────┼──────────
1 │ 1 │ Maria Garcia │ Santiago │ 2024-01-01 │ 2024-06-30 │ FALSE
11 │ 1 │ Maria Garcia │ Valparaiso │ 2024-07-01 │ NULL │ TRUE

Para consultar ventas de Maria usando la ciudad correcta en cada momento:

SELECT dc.ciudad, SUM(fv.monto_total) AS total
FROM fact_ventas fv
JOIN dim_fecha df ON fv.fecha_id = df.fecha_id
JOIN dim_cliente_historico dc ON fv.cliente_id = dc.cliente_id
AND df.fecha BETWEEN dc.fecha_inicio AND COALESCE(dc.fecha_fin, '9999-12-31')
WHERE dc.cliente_id = 1
GROUP BY dc.ciudad;
16

Transacciones: BEGIN, COMMIT y ROLLBACK

Cuando ejecutas un INSERT, UPDATE o DELETE, PostgreSQL lo aplica inmediatamente. Pero que pasa si necesitas hacer varias operaciones que dependen entre si? Si la primera funciona pero la segunda falla, tus datos quedan en un estado inconsistente. Las transacciones resuelven exactamente este problema.


Que es una transaccion?

Una transaccion es un grupo de operaciones SQL que se ejecutan como una unidad. O se aplican todas o no se aplica ninguna. No hay estados intermedios.

Piensa en un pedido de nuestra tienda. Procesar un pedido requiere varias operaciones:

1. Insertar el pedido en la tabla pedidos
2. Insertar cada producto en detalle_pedidos
3. Actualizar el stock de cada producto
Si el paso 3 falla (por ejemplo, no hay suficiente stock),
NO queremos que los pasos 1 y 2 se queden en la base de datos.
Necesitamos deshacer todo.

Sin transacciones, si falla el paso 3, te quedarias con un pedido registrado pero sin el stock actualizado. Con transacciones, si algo falla, todo se revierte automaticamente.


BEGIN, COMMIT y ROLLBACK

Estas son las tres sentencias fundamentales:

Comando Descripcion
BEGIN Inicia una transaccion. Todo lo que ejecutes despues queda 'en espera' hasta que confirmes o reviertas.
COMMIT Confirma la transaccion. Todos los cambios se aplican de forma permanente a la base de datos.
ROLLBACK Revierte la transaccion. Todos los cambios desde el BEGIN se deshacen como si nunca hubieran ocurrido.

Ejemplo basico

-- Iniciar la transaccion
BEGIN;
-- Insertar un nuevo pedido
INSERT INTO pedidos (cliente_id, fecha, total, estado)
VALUES (1, CURRENT_DATE, 299.99, 'pendiente');
-- Ver que el pedido ya existe (dentro de la transaccion)
SELECT * FROM pedidos WHERE total = 299.99;
-- Si todo esta bien, confirmar
COMMIT;
-- Ahora el pedido es permanente

Y si algo sale mal:

BEGIN;
-- Intentamos actualizar precios
UPDATE productos SET precio = precio * 0.8
WHERE categoria = 'Electronica';
-- Ups! No queriamos aplicar 20% de descuento...
-- Deshacemos TODO lo que hicimos desde BEGIN
ROLLBACK;
-- Los precios quedan exactamente como estaban antes

Paso a paso: que ocurre internamente

BEGIN;
├─ PostgreSQL marca el inicio de la transaccion.
│ Los cambios se escriben en un "log" temporal.
├─ INSERT INTO pedidos ...
│ → El pedido existe para TI, pero otros usuarios
│ aun no lo ven (aislamiento).
├─ UPDATE productos SET stock = stock - 1 ...
│ → El stock cambia para TI, pero otros ven
│ el valor original.
└─ COMMIT; ← Aqui PostgreSQL hace permanentes
│ todos los cambios de golpe.
│ Ahora todos los usuarios los ven.
└─ (Si hubieramos hecho ROLLBACK, PostgreSQL
descarta el log y todo queda como antes)
💡

Autocommit

Por defecto, PostgreSQL funciona en modo autocommit: cada sentencia SQL individual es su propia transaccion. Cuando ejecutas UPDATE productos SET precio = 99 WHERE id = 1; sin BEGIN, PostgreSQL hace automaticamente BEGIN → UPDATE → COMMIT. Por eso los cambios son inmediatos. Cuando usas BEGIN explicitamente, desactivas el autocommit hasta el COMMIT o ROLLBACK.


Transacciones e integridad de datos

Las transacciones no son solo una conveniencia — son la herramienta fundamental para mantener la integridad de tu base de datos. Sin ellas, cualquier fallo a mitad de una operacion puede dejar tus datos en un estado corrupto e irrecuperable.

Que puede salir mal sin transacciones?

Veamos escenarios reales con nuestra tienda:

Escenario 1: Pedido a medias
Sin transaccion:
INSERT INTO pedidos (...) → ✓ Pedido creado
INSERT INTO detalle_pedidos (...) → ✓ Detalle creado
UPDATE productos SET stock = stock - 5 → ✗ ERROR (conexion se cayo)
Resultado: Hay un pedido registrado, pero el stock no se desconto.
Se vendera stock que no existe. Los datos son INCONSISTENTES.
Escenario 2: Transferencia entre cuentas
Sin transaccion:
UPDATE cuentas SET saldo = saldo - 1000 WHERE id = 1 → ✓ Se desconto
UPDATE cuentas SET saldo = saldo + 1000 WHERE id = 2 → ✗ ERROR
Resultado: Se desconto dinero de una cuenta pero no llego a la otra.
El dinero "desaparecio". Los datos son INCONSISTENTES.
Escenario 3: Eliminacion con dependencias
Sin transaccion:
DELETE FROM detalle_pedidos WHERE pedido_id = 5 → ✓ Detalles borrados
DELETE FROM pedidos WHERE id = 5 → ✗ ERROR
Resultado: Los detalles se eliminaron pero el pedido sigue.
Pedido fantasma sin productos. Los datos son INCONSISTENTES.

Con transacciones, ninguno de estos escenarios puede ocurrir. Si algo falla, todo se revierte automaticamente al estado anterior.

Restricciones y transacciones trabajan juntas

PostgreSQL verifica las restricciones de integridad (FOREIGN KEY, NOT NULL, UNIQUE, CHECK) dentro de la transaccion. Si una restriccion se viola, la operacion falla y puedes hacer ROLLBACK sin dano:

BEGIN;
-- Intentar insertar un pedido para un cliente que no existe
INSERT INTO pedidos (cliente_id, fecha, total, estado)
VALUES (9999, CURRENT_DATE, 100, 'pendiente');
-- ERROR: insert or update on table "pedidos" violates
-- foreign key constraint "pedidos_cliente_id_fkey"
-- La restriccion FOREIGN KEY protegio la integridad.
-- Ningun dato quedo a medias.
ROLLBACK;
⚠️

Regla de oro de integridad

Siempre que una operacion involucre mas de una tabla o mas de una sentencia que dependan entre si, usa una transaccion. Es la unica forma de garantizar que tus datos se mantengan consistentes ante cualquier fallo.

Propiedades ACID

Las transacciones garantizan cuatro propiedades conocidas como ACID:

Comando Descripcion
Atomicidad Todo o nada. Si una operacion falla, se revierten todas. No hay estados parciales.
Consistencia La BD pasa de un estado valido a otro estado valido. Las restricciones (NOT NULL, FOREIGN KEY, CHECK) se verifican. Nunca quedan datos a medias.
Aislamiento Las transacciones concurrentes no se interfieren. Cada una ve una 'foto' consistente de los datos hasta que se confirme.
Durabilidad Una vez que haces COMMIT, los datos sobreviven incluso a un corte de luz o un crash del servidor. PostgreSQL escribe en disco antes de confirmar.

Estas propiedades son lo que diferencia una base de datos relacional de un simple archivo de texto. Sin ACID, no puedes confiar en que tus datos sean correctos.


Ejemplo practico: procesar un pedido

Este es el caso de uso mas comun de transacciones. Procesar un pedido involucra varias tablas que deben mantenerse consistentes:

BEGIN;
-- 1. Crear el pedido
INSERT INTO pedidos (cliente_id, fecha, total, estado)
VALUES (3, CURRENT_DATE, 0, 'pendiente')
RETURNING id;
-- Supongamos que devuelve id = 25
-- 2. Agregar productos al detalle
INSERT INTO detalle_pedidos (pedido_id, producto_id, cantidad, precio_unitario)
VALUES
(25, 1, 2, 299.99), -- 2 unidades del producto 1
(25, 5, 1, 49.99); -- 1 unidad del producto 5
-- 3. Actualizar stock
UPDATE productos SET stock = stock - 2 WHERE id = 1;
UPDATE productos SET stock = stock - 1 WHERE id = 5;
-- 4. Actualizar el total del pedido
UPDATE pedidos
SET total = (
SELECT SUM(cantidad * precio_unitario)
FROM detalle_pedidos
WHERE pedido_id = 25
)
WHERE id = 25;
-- 5. Todo salio bien, confirmar
COMMIT;

Si cualquier paso falla (por ejemplo, el producto no existe o el stock queda negativo), puedes hacer ROLLBACK y la base de datos queda exactamente como estaba antes.

⚠️

Transacciones en produccion

En una aplicacion real, el codigo de tu programa (Python, Node.js, Java, etc.) captura errores y decide si hacer COMMIT o ROLLBACK. Nunca dejes transacciones abiertas sin cerrar: bloquean filas y degradan el rendimiento.


SAVEPOINT: Puntos de guardado parciales

A veces quieres revertir solo parte de una transaccion. Para eso existen los SAVEPOINT:

BEGIN;
-- Insertar pedido
INSERT INTO pedidos (cliente_id, fecha, total, estado)
VALUES (2, CURRENT_DATE, 150.00, 'pendiente');
-- Crear punto de guardado
SAVEPOINT antes_de_stock;
-- Intentar actualizar stock
UPDATE productos SET stock = stock - 100 WHERE id = 3;
-- Verificar que no quedo negativo
-- (si quedo negativo, revertir solo la actualizacion de stock)
ROLLBACK TO SAVEPOINT antes_de_stock;
-- El pedido sigue existiendo, pero el stock no se toco
-- Podemos intentar con otra cantidad
UPDATE productos SET stock = stock - 1 WHERE id = 3;
COMMIT;
Comando Descripcion
SAVEPOINT nombre Crea un punto de guardado dentro de la transaccion.
ROLLBACK TO SAVEPOINT nombre Revierte todo lo hecho DESPUES del savepoint. La transaccion sigue activa.
RELEASE SAVEPOINT nombre Elimina el savepoint (libera recursos). Los cambios permanecen.

Puedes anidar savepoints:

BEGIN;
INSERT INTO pedidos (cliente_id, fecha, total, estado)
VALUES (1, CURRENT_DATE, 500.00, 'pendiente');
SAVEPOINT sp1;
INSERT INTO detalle_pedidos (pedido_id, producto_id, cantidad, precio_unitario)
VALUES (26, 1, 5, 299.99);
SAVEPOINT sp2;
UPDATE productos SET stock = stock - 5 WHERE id = 1;
-- Si falla, podemos volver a sp2, sp1, o hacer ROLLBACK completo
ROLLBACK TO SAVEPOINT sp2;
-- Solo se deshizo el UPDATE, el INSERT de detalle sigue
ROLLBACK TO SAVEPOINT sp1;
-- Se deshizo el INSERT de detalle tambien, solo queda el pedido
COMMIT;

Errores dentro de transacciones

En PostgreSQL, si ocurre un error dentro de una transaccion, esta queda en estado abortado. No puedes ejecutar mas sentencias hasta que hagas ROLLBACK:

BEGIN;
INSERT INTO pedidos (cliente_id, fecha, total, estado)
VALUES (1, CURRENT_DATE, 100, 'pendiente');
-- Esto causa un error (supongamos que viola una restriccion)
INSERT INTO detalle_pedidos (pedido_id, producto_id, cantidad, precio_unitario)
VALUES (999, 999, 1, 10.00);
-- ERROR: insert or update on table "detalle_pedidos" violates
-- foreign key constraint
-- La transaccion esta en estado "abortado"
-- Cualquier sentencia que ejecutes ahora dara:
-- ERROR: current transaction is aborted
-- La UNICA opcion es ROLLBACK
ROLLBACK;

SAVEPOINT para manejar errores

Si quieres manejar errores sin perder toda la transaccion, usa SAVEPOINT antes de operaciones riesgosas. Si falla, haz ROLLBACK TO SAVEPOINT y continua con otra estrategia.


Transacciones y bloqueos

Cuando modificas una fila dentro de una transaccion, PostgreSQL bloquea esa fila hasta que hagas COMMIT o ROLLBACK. Otros usuarios que intenten modificar la misma fila tendran que esperar.

-- Sesion A:
BEGIN;
UPDATE productos SET stock = stock - 1 WHERE id = 5;
-- La fila id=5 esta bloqueada para escritura
-- Sesion B (al mismo tiempo):
UPDATE productos SET stock = stock + 10 WHERE id = 5;
-- Sesion B se QUEDA ESPERANDO hasta que Sesion A
-- haga COMMIT o ROLLBACK
-- Sesion A:
COMMIT;
-- Ahora Sesion B puede continuar
⚠️

Deadlocks

Si la Sesion A bloquea la fila 1 y espera la fila 2, mientras la Sesion B bloquea la fila 2 y espera la fila 1, se produce un deadlock (bloqueo mutuo). PostgreSQL detecta deadlocks automaticamente y cancela una de las transacciones con un error. La solucion es siempre bloquear las filas en el mismo orden.


Niveles de aislamiento

PostgreSQL soporta cuatro niveles de aislamiento que controlan cuanto “ve” una transaccion de los cambios hechos por otras transacciones concurrentes:

Comando Descripcion
READ UNCOMMITTED En PostgreSQL se comporta igual que READ COMMITTED (no permite lecturas sucias).
READ COMMITTED (default) Cada sentencia ve los datos confirmados hasta ese momento. Si otra transaccion hizo COMMIT entre tus sentencias, veras los nuevos datos.
REPEATABLE READ La transaccion ve una 'foto' de los datos al inicio. No importa si otros hacen COMMIT mientras tanto, siempre ves lo mismo.
SERIALIZABLE El nivel mas estricto. Simula que las transacciones se ejecutan una despues de otra. PostgreSQL puede cancelar transacciones si detecta conflictos.
-- Cambiar el nivel de aislamiento (debe ser lo primero despues de BEGIN)
BEGIN;
SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;
-- Ahora esta transaccion siempre vera la misma foto de los datos
SELECT SUM(total) FROM pedidos WHERE estado = 'completado';
-- ... otras operaciones ...
-- Aunque otro usuario agregue pedidos completados mientras tanto,
-- esta consulta siempre devuelve el mismo resultado
COMMIT;
💡

Nota

Para la mayoria de las aplicaciones, el nivel por defecto READ COMMITTED funciona bien. Usa REPEATABLE READ cuando necesites consistencia absoluta en lecturas (como generar reportes financieros). SERIALIZABLE es para casos muy especificos donde no puedes tolerar ninguna anomalia.


Patron practico: UPDATE seguro con verificacion

Un patron muy util es usar transacciones para verificar antes de confirmar:

BEGIN;
-- Verificar el estado actual
SELECT id, nombre, stock FROM productos WHERE id = 3;
-- Actualizar solo si tiene suficiente stock
UPDATE productos SET stock = stock - 5 WHERE id = 3 AND stock >= 5;
-- Verificar cuantas filas se actualizaron
-- Si 0 filas, significa que no habia suficiente stock
-- Si todo esta bien
COMMIT;
-- Si algo no salio como esperabas
-- ROLLBACK;

Y para operaciones masivas peligrosas:

BEGIN;
-- Primero ver que vamos a eliminar
SELECT COUNT(*) FROM pedidos WHERE estado = 'cancelado' AND fecha < '2024-01-01';
-- Resultado: 47 filas
-- Si el numero parece correcto, eliminar
DELETE FROM detalle_pedidos
WHERE pedido_id IN (
SELECT id FROM pedidos WHERE estado = 'cancelado' AND fecha < '2024-01-01'
);
DELETE FROM pedidos WHERE estado = 'cancelado' AND fecha < '2024-01-01';
-- Verificar que todo quedo bien
SELECT COUNT(*) FROM pedidos WHERE estado = 'cancelado' AND fecha < '2024-01-01';
-- Resultado: 0 filas ✓
COMMIT;
-- (o ROLLBACK si algo no cuadra)

EXPLAIN ANALYZE seguro

Si quieres probar el plan de ejecucion de un DELETE o UPDATE sin aplicarlo realmente, envuelvelo en una transaccion que luego reviertes:

BEGIN;
EXPLAIN ANALYZE DELETE FROM pedidos WHERE estado = 'cancelado';
ROLLBACK; -- Los datos no se eliminaron

Ejercicios

Ejercicio 1: Escribe una transaccion que inserte un nuevo cliente y un pedido para ese cliente. Si la insercion del pedido falla, ambos cambios deben revertirse.

Ver solucion
BEGIN;
INSERT INTO clientes (nombre, email, ciudad)
VALUES ('Daniela Rojas', 'daniela@email.com', 'Temuco');
-- Obtener el ID del nuevo cliente
-- (asumiendo que es el ultimo insertado)
INSERT INTO pedidos (cliente_id, fecha, total, estado)
VALUES (
(SELECT id FROM clientes WHERE email = 'daniela@email.com'),
CURRENT_DATE,
250.00,
'pendiente'
);
COMMIT;
-- Si cualquier INSERT falla, ejecutar ROLLBACK en su lugar

Ejercicio 2: Crea una transaccion que aplique un 15% de descuento a todos los productos de la categoria ‘Muebles’. Usa un SELECT antes y despues del UPDATE para verificar los cambios. No hagas COMMIT todavia — termina con ROLLBACK para que los precios no cambien realmente.

Ver solucion
BEGIN;
-- Ver precios actuales
SELECT nombre, precio, categoria
FROM productos
WHERE categoria = 'Muebles'
ORDER BY precio DESC;
-- Aplicar descuento
UPDATE productos
SET precio = ROUND(precio * 0.85, 2)
WHERE categoria = 'Muebles';
-- Verificar cambios
SELECT nombre, precio, categoria
FROM productos
WHERE categoria = 'Muebles'
ORDER BY precio DESC;
-- Revertir (no queremos aplicar el descuento realmente)
ROLLBACK;

Ejercicio 3: Escribe una transaccion con SAVEPOINT que intente actualizar el stock de dos productos. Si la segunda actualizacion dejaria el stock negativo, revierte solo esa operacion (con ROLLBACK TO SAVEPOINT), pero mantiene la primera.

Ver solucion
BEGIN;
-- Primera actualizacion (segura)
UPDATE productos SET stock = stock - 1 WHERE id = 1;
-- Punto de guardado
SAVEPOINT antes_segundo_update;
-- Segunda actualizacion (podria dejar stock negativo)
UPDATE productos SET stock = stock - 999 WHERE id = 2;
-- Verificar si quedo negativo
-- (en una app real, esto lo haria el codigo del programa)
SELECT id, nombre, stock FROM productos WHERE id = 2;
-- Si stock < 0, revertir solo la segunda operacion
ROLLBACK TO SAVEPOINT antes_segundo_update;
-- La primera actualizacion sigue en pie
COMMIT;

Ejercicio 4: Simula el procesamiento completo de un pedido: insertar el pedido, agregar 2 productos al detalle, actualizar el stock de esos productos, y calcular el total del pedido. Todo dentro de una transaccion.

Ver solucion
BEGIN;
-- 1. Crear pedido (cliente_id = 2)
INSERT INTO pedidos (cliente_id, fecha, total, estado)
VALUES (2, CURRENT_DATE, 0, 'pendiente')
RETURNING id;
-- Supongamos que devuelve id = 30
-- 2. Agregar productos
INSERT INTO detalle_pedidos (pedido_id, producto_id, cantidad, precio_unitario)
VALUES
(30, 3, 2, (SELECT precio FROM productos WHERE id = 3)),
(30, 7, 1, (SELECT precio FROM productos WHERE id = 7));
-- 3. Actualizar stock
UPDATE productos SET stock = stock - 2 WHERE id = 3;
UPDATE productos SET stock = stock - 1 WHERE id = 7;
-- 4. Calcular y actualizar total
UPDATE pedidos
SET total = (
SELECT SUM(cantidad * precio_unitario)
FROM detalle_pedidos
WHERE pedido_id = 30
)
WHERE id = 30;
-- 5. Verificar
SELECT p.id, p.total, p.estado,
dp.producto_id, dp.cantidad, dp.precio_unitario
FROM pedidos p
JOIN detalle_pedidos dp ON p.id = dp.pedido_id
WHERE p.id = 30;
-- 6. Confirmar (o ROLLBACK para practicar sin modificar datos)
ROLLBACK;

Ejercicio 5: Explica que pasa en cada paso de esta secuencia y cual es el estado final de los datos:

BEGIN;
UPDATE productos SET precio = 999 WHERE id = 1;
SAVEPOINT sp1;
UPDATE productos SET precio = 0 WHERE id = 1;
ROLLBACK TO SAVEPOINT sp1;
COMMIT;
Ver solucion

Paso a paso:

  1. BEGIN — Inicia la transaccion.
  2. UPDATE ... precio = 999 — El precio del producto 1 cambia a 999 (dentro de la transaccion).
  3. SAVEPOINT sp1 — Se crea un punto de guardado. En este punto, precio = 999.
  4. UPDATE ... precio = 0 — El precio cambia a 0 (dentro de la transaccion).
  5. ROLLBACK TO SAVEPOINT sp1 — Se deshace todo lo hecho despues de sp1. El precio vuelve a 999.
  6. COMMIT — Se confirma la transaccion. El precio final es 999.

El UPDATE ... precio = 0 se descarto con el ROLLBACK TO SAVEPOINT, pero el UPDATE ... precio = 999 (que fue antes del savepoint) se mantuvo y se confirmo con COMMIT.

17

Vistas y vistas materializadas

Cuando tienes una consulta compleja que usas una y otra vez — un reporte de ventas, un listado de clientes VIP, un resumen de inventario — copiar y pegar ese SQL en cada lugar es tedioso y propenso a errores. Las vistas te permiten guardar esa consulta con un nombre y usarla como si fuera una tabla.


Que es una vista?

Una vista es una consulta SQL guardada con un nombre. No almacena datos; cada vez que la consultas, PostgreSQL ejecuta la consulta original y te devuelve el resultado actualizado.

Sin vista: Con vista:
┌──────────────────────────────┐ ┌──────────────────────┐
│ SELECT c.nombre, SUM(p.total)│ │ SELECT * FROM │
│ FROM clientes c │ → │ resumen_clientes; │
│ JOIN pedidos p ON ... │ │ │
│ GROUP BY c.nombre; │ │ (mismo resultado) │
└──────────────────────────────┘ └──────────────────────┘
(repetir en cada reporte) (escribir una vez)

CREATE VIEW

CREATE VIEW nombre_vista AS
SELECT ...;

Creemos algunas vistas utiles con nuestra base de datos:

Vista: resumen de clientes

CREATE VIEW resumen_clientes AS
SELECT
c.id,
c.nombre,
c.ciudad,
COUNT(p.id) AS total_pedidos,
COALESCE(SUM(p.total), 0) AS gasto_total,
MAX(p.fecha) AS ultimo_pedido
FROM clientes c
LEFT JOIN pedidos p ON c.id = p.cliente_id
GROUP BY c.id, c.nombre, c.ciudad;

Ahora puedes usarla como si fuera una tabla:

-- Todos los clientes con su resumen
SELECT * FROM resumen_clientes ORDER BY gasto_total DESC;
-- Filtrar clientes VIP
SELECT nombre, ciudad, gasto_total
FROM resumen_clientes
WHERE gasto_total > 1000;
-- Clientes que nunca compraron
SELECT nombre, ciudad
FROM resumen_clientes
WHERE total_pedidos = 0;

Vista: inventario con estado

CREATE VIEW inventario_estado AS
SELECT
p.id,
p.nombre,
p.categoria,
p.precio,
p.stock,
COALESCE(SUM(dp.cantidad), 0) AS unidades_vendidas,
CASE
WHEN p.stock = 0 THEN 'Agotado'
WHEN p.stock < 10 THEN 'Stock bajo'
ELSE 'Disponible'
END AS estado_stock
FROM productos p
LEFT JOIN detalle_pedidos dp ON p.id = dp.producto_id
GROUP BY p.id, p.nombre, p.categoria, p.precio, p.stock;
-- Productos con stock bajo
SELECT nombre, categoria, stock, estado_stock
FROM inventario_estado
WHERE estado_stock = 'Stock bajo'
ORDER BY stock ASC;
-- Categoria con mas ventas
SELECT categoria, SUM(unidades_vendidas) AS total_vendido
FROM inventario_estado
GROUP BY categoria
ORDER BY total_vendido DESC;

Vista: detalle completo de pedidos

CREATE VIEW detalle_pedidos_completo AS
SELECT
p.id AS pedido_id,
p.fecha,
p.estado,
c.nombre AS cliente,
c.ciudad,
pr.nombre AS producto,
pr.categoria,
dp.cantidad,
dp.precio_unitario,
(dp.cantidad * dp.precio_unitario) AS subtotal
FROM pedidos p
INNER JOIN clientes c ON p.cliente_id = c.id
INNER JOIN detalle_pedidos dp ON p.id = dp.pedido_id
INNER JOIN productos pr ON dp.producto_id = pr.id;
-- Ahora consultas de 4 tablas se vuelven simples:
SELECT cliente, producto, cantidad, subtotal
FROM detalle_pedidos_completo
WHERE estado = 'completado'
ORDER BY fecha DESC;
-- Ventas por categoria
SELECT categoria, SUM(subtotal) AS total_ventas
FROM detalle_pedidos_completo
WHERE estado = 'completado'
GROUP BY categoria
ORDER BY total_ventas DESC;

Las vistas simplifican, no duplican

Una vista no copia los datos. Es solo un “atajo” a una consulta. Cuando insertas un nuevo pedido en la tabla pedidos, la vista detalle_pedidos_completo lo incluye automaticamente la proxima vez que la consultes.


Paso a paso: que ocurre cuando consultas una vista

Tu escribes: SELECT * FROM resumen_clientes WHERE ciudad = 'Santiago';
PostgreSQL hace:
1. Busca la definicion de resumen_clientes
2. Reemplaza la vista por su consulta original:
SELECT * FROM (
SELECT c.id, c.nombre, c.ciudad,
COUNT(p.id) AS total_pedidos, ...
FROM clientes c LEFT JOIN pedidos p ON ...
GROUP BY c.id, c.nombre, c.ciudad
) AS resumen_clientes
WHERE ciudad = 'Santiago';
3. Optimiza y ejecuta la consulta combinada
4. Te devuelve el resultado

PostgreSQL es inteligente: no ejecuta la consulta completa y luego filtra. Combina tu WHERE con la consulta de la vista y optimiza todo junto. En el ejemplo, filtra por ciudad = 'Santiago' antes de agrupar, lo que es mas eficiente.


Modificar y eliminar vistas

CREATE OR REPLACE VIEW

Si necesitas cambiar la definicion de una vista:

-- Modificar la vista (agrega email)
CREATE OR REPLACE VIEW resumen_clientes AS
SELECT
c.id,
c.nombre,
c.email, -- nueva columna
c.ciudad,
COUNT(p.id) AS total_pedidos,
COALESCE(SUM(p.total), 0) AS gasto_total,
MAX(p.fecha) AS ultimo_pedido
FROM clientes c
LEFT JOIN pedidos p ON c.id = p.cliente_id
GROUP BY c.id, c.nombre, c.email, c.ciudad;
⚠️

Limitaciones de CREATE OR REPLACE

CREATE OR REPLACE VIEW solo permite agregar columnas al final. No puedes eliminar columnas existentes ni cambiar su tipo. Si necesitas hacer eso, debes hacer DROP VIEW primero y luego CREATE VIEW.

DROP VIEW

-- Eliminar vista
DROP VIEW resumen_clientes;
-- Eliminar solo si existe (sin error)
DROP VIEW IF EXISTS resumen_clientes;
-- Si otras vistas dependen de esta, CASCADE las elimina tambien
DROP VIEW resumen_clientes CASCADE;

Vistas actualizables

En ciertos casos, puedes hacer INSERT, UPDATE y DELETE directamente sobre una vista, y los cambios se aplican a la tabla subyacente:

-- Vista simple sobre una sola tabla
CREATE VIEW productos_electronica AS
SELECT id, nombre, precio, stock
FROM productos
WHERE categoria = 'Electronica';
-- Esto funciona! Actualiza la tabla productos
UPDATE productos_electronica SET precio = 599.99 WHERE id = 1;
-- Insertar a traves de la vista
INSERT INTO productos_electronica (nombre, precio, stock)
VALUES ('Auriculares Pro', 89.99, 75);
-- Nota: la columna categoria no se establece automaticamente

Las condiciones para que una vista sea actualizable:

Comando Descripcion
Una sola tabla El FROM debe referenciar exactamente una tabla (sin JOINs).
Sin agregaciones No puede usar GROUP BY, HAVING, DISTINCT ni funciones de agregacion.
Sin subconsultas en SELECT Las columnas deben venir directamente de la tabla.
Sin LIMIT/OFFSET No debe limitar las filas.
💡

WITH CHECK OPTION

Puedes agregar WITH CHECK OPTION al crear la vista para que PostgreSQL rechace inserciones o actualizaciones que no cumplan el filtro de la vista:

CREATE VIEW productos_electronica AS
SELECT id, nombre, precio, stock
FROM productos
WHERE categoria = 'Electronica'
WITH CHECK OPTION;
-- Esto falla porque categoria seria NULL, no 'Electronica':
INSERT INTO productos_electronica (nombre, precio, stock)
VALUES ('Silla Gamer', 299.99, 20);
-- ERROR: new row violates check option for view

Vistas materializadas

Una vista normal ejecuta la consulta cada vez que la consultas. Si la consulta es lenta (por ejemplo, resume millones de filas), esto puede ser un problema. Las vistas materializadas guardan el resultado en disco, como una tabla “cache”:

Vista normal: Vista materializada:
┌──────────────┐ ┌──────────────────┐
│ Consulta → │ Cada vez que │ Consulta → │ Una vez
│ ejecuta la │ la usas │ guarda datos │ (al crear
│ query │ │ en disco │ o refrescar)
│ original │ │ │
└──────────────┘ └──────────────────┘
Siempre actualizada Rapida pero puede
Puede ser lenta estar desactualizada

Crear y refrescar

-- Crear vista materializada
CREATE MATERIALIZED VIEW mv_ventas_por_categoria AS
SELECT
pr.categoria,
COUNT(DISTINCT p.id) AS total_pedidos,
SUM(dp.cantidad) AS unidades_vendidas,
ROUND(SUM(dp.cantidad * dp.precio_unitario), 2) AS ingresos
FROM pedidos p
INNER JOIN detalle_pedidos dp ON p.id = dp.pedido_id
INNER JOIN productos pr ON dp.producto_id = pr.id
WHERE p.estado = 'completado'
GROUP BY pr.categoria;
-- Consultar (lee de disco, muy rapido)
SELECT * FROM mv_ventas_por_categoria ORDER BY ingresos DESC;

Los datos no se actualizan automaticamente. Debes refrescar manualmente:

-- Refrescar (recalcula toda la vista)
REFRESH MATERIALIZED VIEW mv_ventas_por_categoria;
-- Refrescar sin bloquear lecturas (requiere un indice UNIQUE)
REFRESH MATERIALIZED VIEW CONCURRENTLY mv_ventas_por_categoria;
💡

Cuando refrescar?

Depende de tu caso de uso. Algunas opciones:

  • Manualmente despues de cargar datos nuevos
  • Con un cron job cada hora/dia
  • Con un trigger que refresque al insertar en la tabla base (cuidado: puede ser lento si se ejecuta con cada INSERT)

Indices en vistas materializadas

Como las vistas materializadas guardan datos en disco, puedes crear indices sobre ellas:

-- Indice unico (necesario para REFRESH CONCURRENTLY)
CREATE UNIQUE INDEX idx_mv_ventas_cat ON mv_ventas_por_categoria(categoria);
-- Ahora puedes hacer REFRESH sin bloquear lecturas
REFRESH MATERIALIZED VIEW CONCURRENTLY mv_ventas_por_categoria;

Eliminar

DROP MATERIALIZED VIEW mv_ventas_por_categoria;
DROP MATERIALIZED VIEW IF EXISTS mv_ventas_por_categoria;

Cuando usar cada opcion

Comando Descripcion
Vista (VIEW) Consultas frecuentes que quieres simplificar. Siempre datos actualizados. Sin costo de almacenamiento extra.
Vista materializada Consultas lentas cuyos datos no necesitan estar al segundo. Reportes, dashboards, agregaciones pesadas.
CTE (WITH ...) Consulta temporal que solo necesitas dentro de una query. No se guarda, no se reutiliza.
Subconsulta Similar al CTE pero inline. Util para casos simples donde un CTE seria excesivo.

Regla practica

Si copias y pegas la misma consulta en mas de 2 lugares → crea una vista. Si esa vista es lenta y no necesitas datos en tiempo real → convierte a vista materializada.


Ver vistas existentes

-- Listar todas las vistas
SELECT table_name, table_type
FROM information_schema.tables
WHERE table_schema = 'public'
AND table_type = 'VIEW'
ORDER BY table_name;
-- Ver la definicion de una vista
SELECT pg_get_viewdef('resumen_clientes', true);
-- Listar vistas materializadas
SELECT matviewname, definition
FROM pg_matviews
WHERE schemaname = 'public';
-- Comando rapido en psql
\dv -- listar vistas
\dm -- listar vistas materializadas

Ejercicios

Ejercicio 1: Crea una vista llamada empleados_con_jefe que muestre el nombre del empleado, su departamento, su salario y el nombre de su jefe. Para empleados sin jefe, debe mostrar ‘Sin jefe’.

Ver solucion
CREATE VIEW empleados_con_jefe AS
SELECT
e.id,
e.nombre AS empleado,
e.departamento,
e.salario,
COALESCE(j.nombre, 'Sin jefe') AS jefe
FROM empleados e
LEFT JOIN empleados j ON e.jefe_id = j.id;
-- Uso:
SELECT * FROM empleados_con_jefe ORDER BY departamento, salario DESC;

Ejercicio 2: Crea una vista llamada ventas_mensuales que muestre: mes (en formato YYYY-MM), total de pedidos, ingresos totales y ticket promedio. Solo incluye pedidos completados.

Ver solucion
CREATE VIEW ventas_mensuales AS
SELECT
TO_CHAR(fecha, 'YYYY-MM') AS mes,
COUNT(*) AS total_pedidos,
SUM(total) AS ingresos,
ROUND(AVG(total), 2) AS ticket_promedio
FROM pedidos
WHERE estado = 'completado'
GROUP BY TO_CHAR(fecha, 'YYYY-MM');
-- Uso:
SELECT * FROM ventas_mensuales ORDER BY mes DESC;

Ejercicio 3: Crea una vista materializada llamada mv_top_productos que muestre los 10 productos mas vendidos con: nombre, categoria, total de unidades vendidas e ingresos generados. Agrega un indice unico.

Ver solucion
CREATE MATERIALIZED VIEW mv_top_productos AS
SELECT
pr.id,
pr.nombre,
pr.categoria,
SUM(dp.cantidad) AS unidades_vendidas,
ROUND(SUM(dp.cantidad * dp.precio_unitario), 2) AS ingresos
FROM productos pr
INNER JOIN detalle_pedidos dp ON pr.id = dp.producto_id
INNER JOIN pedidos p ON dp.pedido_id = p.id
WHERE p.estado = 'completado'
GROUP BY pr.id, pr.nombre, pr.categoria
ORDER BY unidades_vendidas DESC
LIMIT 10;
-- Indice unico
CREATE UNIQUE INDEX idx_mv_top_prod ON mv_top_productos(id);
-- Uso:
SELECT * FROM mv_top_productos;
-- Refrescar cuando cambien los datos
REFRESH MATERIALIZED VIEW CONCURRENTLY mv_top_productos;

Ejercicio 4: Usando la vista detalle_pedidos_completo (creada arriba en el modulo), escribe una consulta que muestre las ventas totales por ciudad y categoria. Ordena por ciudad y de mayor a menor venta.

Ver solucion
SELECT
ciudad,
categoria,
SUM(subtotal) AS total_ventas,
COUNT(*) AS lineas_vendidas
FROM detalle_pedidos_completo
WHERE estado = 'completado'
GROUP BY ciudad, categoria
ORDER BY ciudad, total_ventas DESC;

Nota como la vista simplifica esta consulta: sin la vista, necesitarias 4 JOINs. Con la vista, es un simple SELECT ... GROUP BY sobre una sola “tabla”.

Ejercicio 5: Crea una vista actualizable llamada productos_accesorios que muestre id, nombre, precio y stock de los productos de la categoria ‘Accesorios’. Agrega WITH CHECK OPTION. Luego intenta: (a) actualizar el precio de un producto, (b) insertar un producto con categoria ‘Electronica’ y explica por que falla.

Ver solucion
CREATE VIEW productos_accesorios AS
SELECT id, nombre, precio, stock
FROM productos
WHERE categoria = 'Accesorios'
WITH CHECK OPTION;
-- (a) Esto funciona: actualiza en la tabla productos
UPDATE productos_accesorios SET precio = precio * 0.9 WHERE stock > 50;
-- (b) Esto falla:
INSERT INTO productos_accesorios (nombre, precio, stock)
VALUES ('Cable HDMI', 15.99, 200);
-- ERROR: new row violates check option for view "productos_accesorios"
-- Porque la vista filtra por categoria = 'Accesorios', pero el INSERT
-- no especifica la categoria (queda NULL), que no cumple el filtro.
-- WITH CHECK OPTION impide insertar filas que no serian visibles en la vista.
18

Bases de datos vectoriales con pgvector

Las bases de datos tradicionales son excelentes para buscar coincidencias exactas: “dame el producto con id 5” o “clientes en Santiago”. Pero que pasa cuando necesitas buscar por significado? Por ejemplo, “productos similares a auriculares” o “textos que hablen de tecnologia”. Para eso existen las bases de datos vectoriales, y PostgreSQL puede hacerlo gracias a pgvector.


Que son los vectores?

Un vector es simplemente una lista ordenada de numeros. En el contexto de datos, representan una posicion en un espacio multidimensional.

Ejemplo en 2 dimensiones (facil de visualizar):
"Auriculares Bluetooth" → [0.8, 0.3] (mucha tecnologia, poco hogar)
"Laptop Pro" → [0.9, 0.2] (mucha tecnologia, poco hogar)
"Mesa de Comedor" → [0.1, 0.9] (poca tecnologia, mucho hogar)
"Lampara LED" → [0.4, 0.7] (algo de tecnologia, bastante hogar)
tecnologia
1.0 | • Laptop
| • Auriculares
0.5 |
| • Lampara
| • Mesa
0.0 +-------------------→ hogar
0.0 0.5 1.0

Productos cercanos en este espacio son similares en significado. “Auriculares” y “Laptop” estan cerca porque ambos son tecnologia.


Que son los embeddings?

En la practica, los vectores no se crean a mano. Se generan con modelos de inteligencia artificial llamados modelos de embeddings. Estos modelos convierten texto (o imagenes, audio, etc.) en vectores de cientos o miles de dimensiones.

Modelo de embeddings (ej: OpenAI, Sentence Transformers)
"Auriculares inalambricos" → [0.023, -0.841, 0.156, ..., 0.447] (1536 dimensiones)
"Audifonos Bluetooth" → [0.019, -0.838, 0.161, ..., 0.442] (muy similar!)
"Mesa de madera" → [0.712, 0.203, -0.445, ..., 0.089] (muy diferente)
💡

Modelos de embeddings populares

Cloud (APIs):

  • OpenAI (text-embedding-3-small): 1536 dimensiones
  • Google (text-embedding-004): 768 dimensiones
  • Cohere (embed-v3): 1024 dimensiones
  • Voyage AI (voyage-3): 1024 dimensiones

Locales (puedes correrlos en tu maquina con Ollama):

  • nomic-embed-text: 768 dimensiones — ligero y rapido
  • mxbai-embed-large: 1024 dimensiones — buen balance calidad/velocidad
  • snowflake-arctic-embed: 1024 dimensiones — alto rendimiento
  • all-minilm (Sentence Transformers): 384 dimensiones — el mas liviano

No necesitas entrenar estos modelos. Solo les envias texto y te devuelven el vector.

Con Ollama puedes generar embeddings localmente sin depender de APIs externas:

Terminal window
ollama pull nomic-embed-text
curl http://localhost:11434/api/embeddings -d '{"model": "nomic-embed-text", "prompt": "Auriculares Bluetooth"}'

Para que sirve la busqueda vectorial?

La busqueda vectorial te permite encontrar elementos semanticamente similares, no solo coincidencias exactas de texto:

Caso de usoBusqueda tradicionalBusqueda vectorial
Productos similaresWHERE categoria = 'Electronica'Productos con descripcion similar
Busqueda de textoWHERE nombre LIKE '%auric%'”audifonos inalambricos” encuentra “Auriculares Bluetooth”
RecomendacionesReglas manuales complejasProductos cercanos en el espacio vectorial
Deteccion de duplicadosComparacion exactaTextos con el mismo significado
RAG (IA generativa)No aplicaBuscar contexto relevante para un LLM

pgvector: vectores en PostgreSQL

pgvector es una extension de PostgreSQL que agrega soporte para almacenar y buscar vectores. No necesitas una base de datos separada — tus vectores conviven con tus datos relacionales.

Instalacion

⚠️

Instalacion del servidor

pgvector debe estar instalado a nivel del servidor PostgreSQL antes de poder usar CREATE EXTENSION. Revisa las instrucciones segun tu sistema operativo.

Linux (Debian/Ubuntu):

Terminal window
sudo apt install postgresql-17-pgvector

macOS (Homebrew):

Terminal window
brew install pgvector

Windows:

Si instalaste PostgreSQL con el instalador de EDB, puedes compilar pgvector con Visual Studio, o usar Docker que es la opcion mas simple. Tambien puedes usar pgvector para Windows siguiendo las instrucciones del repositorio oficial.

Docker (cualquier sistema operativo):

Terminal window
docker run -e POSTGRES_PASSWORD=postgres -p 5432:5432 pgvector/pgvector:pg17

Una vez instalado en el servidor, activa la extension dentro de tu base de datos:

CREATE EXTENSION IF NOT EXISTS vector;

El tipo de dato vector

pgvector agrega el tipo vector(n) donde n es el numero de dimensiones:

-- Crear una tabla con una columna vectorial
CREATE TABLE productos_embeddings (
id SERIAL PRIMARY KEY,
producto_id INTEGER REFERENCES productos(id),
descripcion TEXT,
embedding vector(3) -- vector de 3 dimensiones (ejemplo simplificado)
);

En produccion usarias vectores de 384, 768 o 1536 dimensiones, pero para aprender usaremos dimensiones pequenas.


Operaciones basicas

Insertar vectores

-- Los vectores se escriben como arrays entre corchetes
INSERT INTO productos_embeddings (producto_id, descripcion, embedding)
VALUES
(1, 'Laptop de alta gama para profesionales', '[0.8, 0.2, 0.9]'),
(2, 'Silla ergonomica para oficina', '[0.1, 0.9, 0.3]'),
(3, 'Teclado mecanico para gaming', '[0.7, 0.3, 0.8]'),
(4, 'Escritorio de madera ajustable', '[0.2, 0.8, 0.4]'),
(5, 'Monitor curvo 4K', '[0.9, 0.2, 0.7]'),
(6, 'Lampara de escritorio LED', '[0.3, 0.7, 0.5]');

Consultar vectores

SELECT producto_id, descripcion, embedding
FROM productos_embeddings;
producto_id | descripcion | embedding
-------------+----------------------------------------+-----------
1 | Laptop de alta gama para profesionales | [0.8,0.2,0.9]
2 | Silla ergonomica para oficina | [0.1,0.9,0.3]
3 | Teclado mecanico para gaming | [0.7,0.3,0.8]
...

Busqueda por similitud

El corazon de pgvector son los operadores de distancia. Cuanto menor la distancia entre dos vectores, mas similares son.

Operadores de distancia

OperadorTipo de distanciaUso tipico
<->Distancia euclidiana (L2)Distancia geometrica directa
<=>Distancia cosenoSimilitud de significado (el mas usado)
<#>Producto interno negativoCuando los vectores estan normalizados

Ejemplo: encontrar productos similares

Supongamos que un usuario esta viendo la “Laptop de alta gama” con embedding [0.8, 0.2, 0.9]. Busquemos productos similares:

-- Encontrar los 3 productos mas similares a la Laptop
SELECT
producto_id,
descripcion,
embedding <=> '[0.8, 0.2, 0.9]' AS distancia
FROM productos_embeddings
WHERE producto_id != 1 -- excluir el producto mismo
ORDER BY distancia
LIMIT 3;
producto_id | descripcion | distancia
-------------+--------------------------------+--------------------
3 | Teclado mecanico para gaming | 0.0134...
5 | Monitor curvo 4K | 0.0210...
6 | Lampara de escritorio LED | 0.2845...

El teclado y el monitor son los mas similares a la laptop — ambos son tecnologia. La lampara esta mas lejos, y la silla y escritorio estarian aun mas lejos.

Distancia coseno vs euclidiana

La distancia coseno (<=>) mide el angulo entre vectores, ignorando su magnitud. Es ideal cuando te importa la direccion (significado) y no la escala. La distancia euclidiana (<->) mide la distancia directa en el espacio. Para busqueda semantica, usa coseno.


Combinando vectores con SQL tradicional

La ventaja de pgvector es que puedes combinar busqueda vectorial con todo el SQL que ya conoces:

Busqueda con filtros

-- Productos similares a "laptop" que ademas cuesten menos de $500
-- y tengan stock disponible
SELECT
p.nombre,
p.precio,
p.stock,
pe.embedding <=> '[0.8, 0.2, 0.9]' AS similitud
FROM productos_embeddings pe
JOIN productos p ON pe.producto_id = p.id
WHERE p.precio < 500
AND p.stock > 0
ORDER BY similitud
LIMIT 5;

Busqueda con agregacion

-- Categoria con productos mas similares a un vector de busqueda
SELECT
p.categoria,
COUNT(*) AS total_similares,
AVG(pe.embedding <=> '[0.8, 0.2, 0.9]') AS distancia_promedio
FROM productos_embeddings pe
JOIN productos p ON pe.producto_id = p.id
GROUP BY p.categoria
ORDER BY distancia_promedio
LIMIT 3;

Indices para vectores

Sin un indice, pgvector compara tu vector contra todos los vectores de la tabla (busqueda exacta). Con millones de registros, esto es muy lento. Los indices vectoriales sacrifican un poco de precision por mucha velocidad.

IVFFlat (Inverted File Index)

Divide los vectores en grupos (listas) y solo busca en los grupos mas cercanos:

-- Crear un indice IVFFlat
CREATE INDEX idx_embeddings_ivfflat
ON productos_embeddings
USING ivfflat (embedding vector_cosine_ops)
WITH (lists = 100);
Sin indice: Busca en TODOS los vectores (exacto pero lento)
Con IVFFlat: Busca solo en los clusters cercanos (rapido, ~99% precision)
┌──────────────────────────────────┐
│ ● ● ○ ○ ○ ○ │
│ ● ● ○ ○ ○ │ ● = cluster cercano (se busca)
│ ● △ △ ○ │ ○ = otro cluster (se ignora)
│ △ △ △ ○ ○ │ △ = otro cluster (se ignora)
│ ★ △ ○ │ ★ = vector de busqueda
└──────────────────────────────────┘
💡

Cuantas listas usar?

Una regla general: usa sqrt(n) listas, donde n es el numero de filas. Para 1 millon de vectores, usa ~1000 listas. Mas listas = mas rapido pero necesita mas memoria y puede perder precision.

HNSW (Hierarchical Navigable Small World)

Un indice mas moderno y generalmente mas preciso que IVFFlat:

-- Crear un indice HNSW
CREATE INDEX idx_embeddings_hnsw
ON productos_embeddings
USING hnsw (embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 64);

Comparacion de indices

CaracteristicaIVFFlatHNSW
Velocidad de busquedaRapidaMuy rapida
PrecisionBuena (~95-99%)Muy buena (~99%+)
Tiempo de creacionRapidoLento
Uso de memoriaBajoAlto
Necesita datos previosSi (para entrenar clusters)No
Recomendado paraDatasets grandes, recursos limitadosMaxima calidad de busqueda

Importante sobre IVFFlat

El indice IVFFlat requiere que la tabla ya tenga datos al momento de crearse, porque necesita calcular los centroides de los clusters. Si insertas muchos datos nuevos despues, considera recrear el indice. HNSW no tiene esta limitacion.


Ejemplo practico: busqueda semantica en la tienda

Veamos un escenario completo integrando pgvector con nuestra base de datos tienda. Imagina que quieres agregar busqueda inteligente de productos:

Paso 1: Crear la tabla de embeddings

CREATE EXTENSION IF NOT EXISTS vector;
-- Tabla para almacenar embeddings de productos
CREATE TABLE producto_embeddings (
id SERIAL PRIMARY KEY,
producto_id INTEGER REFERENCES productos(id) ON DELETE CASCADE,
embedding vector(384), -- Sentence Transformers dimension
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(producto_id)
);

Paso 2: Insertar embeddings (simulados)

En produccion, generarias los embeddings con un modelo de IA. Aqui simulamos con vectores aleatorios para mostrar la mecanica:

-- Insertar embeddings simulados para los productos existentes
INSERT INTO producto_embeddings (producto_id, embedding)
SELECT
id,
-- En produccion: llamar API de embeddings con el nombre del producto
-- Aqui generamos un vector aleatorio de 384 dimensiones
(
SELECT array_agg(random())::vector(384)
FROM generate_series(1, 384)
)
FROM productos;

Paso 3: Crear indice

CREATE INDEX idx_producto_emb_hnsw
ON producto_embeddings
USING hnsw (embedding vector_cosine_ops);

Paso 4: Buscar productos similares

-- Funcion para buscar productos similares a uno dado
-- Dado el producto con id = 1, encontrar los 5 mas similares
SELECT
p.id,
p.nombre,
p.precio,
p.categoria,
pe.embedding <=> query.embedding AS distancia
FROM producto_embeddings pe
JOIN productos p ON pe.producto_id = p.id
CROSS JOIN (
SELECT embedding
FROM producto_embeddings
WHERE producto_id = 1
) query
WHERE pe.producto_id != 1
ORDER BY distancia
LIMIT 5;

Paso 5: Busqueda por texto

-- En una aplicacion real, convertirias el texto del usuario a un embedding
-- usando un modelo de IA, y luego buscarias los mas cercanos:
-- Pseudocodigo del flujo:
-- 1. Usuario escribe: "necesito algo para escuchar musica"
-- 2. Tu app convierte eso a un vector: get_embedding("necesito algo para escuchar musica")
-- 3. Buscas en PostgreSQL:
-- SELECT p.nombre, p.precio
-- FROM producto_embeddings pe
-- JOIN productos p ON pe.producto_id = p.id
-- ORDER BY pe.embedding <=> $1 -- $1 = vector del texto del usuario
-- LIMIT 5;
💡

El embedding viene de tu aplicacion

pgvector no genera embeddings. Tu aplicacion (Python, Node.js, etc.) debe llamar a un modelo de embeddings y luego enviar el vector resultante a PostgreSQL. pgvector solo se encarga de almacenar y buscar eficientemente.


Buenas practicas

  1. Elige la dimension correcta: Mas dimensiones = mas precision pero mas almacenamiento y busqueda mas lenta. 384 dimensiones suele ser un buen balance.

  2. Siempre crea un indice: Sin indice, cada busqueda recorre toda la tabla. Con mas de unos miles de filas, es imprescindible.

  3. Usa distancia coseno para texto: Para embeddings de texto, la distancia coseno (<=>) es casi siempre la mejor opcion.

  4. Normaliza si puedes: Si tus vectores estan normalizados (longitud 1), el producto interno (<#>) es equivalente al coseno pero mas rapido.

  5. Combina con filtros SQL: No hagas busqueda vectorial pura. Filtra primero por columnas tradicionales (categoria, precio, stock) para reducir el espacio de busqueda.

  6. Monitorea con EXPLAIN ANALYZE: Los indices vectoriales tambien aparecen en EXPLAIN, asi que puedes verificar que se estan usando:

EXPLAIN ANALYZE
SELECT producto_id
FROM producto_embeddings
ORDER BY embedding <=> '[0.1, 0.2, ...]'::vector(384)
LIMIT 10;

Resumen

Base de datos vectorial con pgvector:
┌──────────────────────────────────────────────────────────────┐
│ PostgreSQL + pgvector │
│ │
│ ┌─────────────────────┐ ┌─────────────────────────────┐ │
│ │ Datos relacionales │ │ Datos vectoriales │ │
│ │ │ │ │ │
│ │ productos │ │ producto_embeddings │ │
│ │ ├─ id │◄──►│ ├─ producto_id │ │
│ │ ├─ nombre │ │ ├─ embedding vector(384) │ │
│ │ ├─ precio │ │ └─ (indice HNSW) │ │
│ │ └─ categoria │ │ │ │
│ └─────────────────────┘ └─────────────────────────────┘ │
│ │
│ Puedes hacer: │
│ • JOINs entre tablas normales y vectoriales │
│ • WHERE con filtros + ORDER BY distancia vectorial │
│ • GROUP BY, agregaciones sobre resultados de similitud │
│ • Todo el SQL que ya conoces! │
└──────────────────────────────────────────────────────────────┘
Comando Descripcion
vector(n) Tipo de dato para almacenar vectores de n dimensiones
Embeddings Vectores generados por modelos de IA que capturan significado
<=> Operador de distancia coseno (el mas usado para texto)
<-> Operador de distancia euclidiana (L2)
<#> Operador de producto interno negativo
IVFFlat Indice por clusters, rapido de crear, buena precision
HNSW Indice jerarquico, mejor precision, mas uso de memoria
pgvector Extension de PostgreSQL para soporte vectorial

Ejercicios

Ejercicio 1: Crea una tabla busqueda_clientes con columnas: id SERIAL, cliente_id (FK a clientes), bio TEXT, y bio_embedding vector(3). Inserta 4 registros con vectores inventados de 3 dimensiones.

Ver solucion
CREATE TABLE busqueda_clientes (
id SERIAL PRIMARY KEY,
cliente_id INTEGER REFERENCES clientes(id),
bio TEXT,
bio_embedding vector(3)
);
INSERT INTO busqueda_clientes (cliente_id, bio, bio_embedding)
VALUES
(1, 'Amante de la tecnologia y gadgets', '[0.9, 0.2, 0.1]'),
(2, 'Aficionado al diseno de interiores', '[0.1, 0.8, 0.7]'),
(3, 'Gamer y desarrollador de software', '[0.8, 0.1, 0.3]'),
(4, 'Decoracion y muebles modernos', '[0.2, 0.9, 0.6]');

Ejercicio 2: Usando la tabla del ejercicio anterior, encuentra los 2 clientes mas similares al cliente con bio_embedding = '[0.85, 0.15, 0.2]' (un perfil tecnologico). Usa distancia coseno y muestra el nombre del cliente (JOIN con la tabla clientes).

Ver solucion
SELECT
c.nombre,
bc.bio,
bc.bio_embedding <=> '[0.85, 0.15, 0.2]' AS distancia
FROM busqueda_clientes bc
JOIN clientes c ON bc.cliente_id = c.id
ORDER BY distancia
LIMIT 2;

Ejercicio 3: Crea un indice HNSW sobre la columna bio_embedding de busqueda_clientes usando operaciones de distancia coseno. Luego verifica que el indice existe consultando pg_indexes.

Ver solucion
CREATE INDEX idx_bio_hnsw
ON busqueda_clientes
USING hnsw (bio_embedding vector_cosine_ops);
-- Verificar
SELECT indexname, indexdef
FROM pg_indexes
WHERE tablename = 'busqueda_clientes';

Ejercicio 4: Escribe una consulta que combine busqueda vectorial con filtros tradicionales: encuentra clientes similares a '[0.9, 0.2, 0.1]' que ademas vivan en Santiago (usando JOIN con la tabla clientes).

Ver solucion
SELECT
c.nombre,
c.ciudad,
bc.bio,
bc.bio_embedding <=> '[0.9, 0.2, 0.1]' AS distancia
FROM busqueda_clientes bc
JOIN clientes c ON bc.cliente_id = c.id
WHERE c.ciudad = 'Santiago'
ORDER BY distancia
LIMIT 3;

Ejercicio 5: Limpia los objetos creados en los ejercicios. Elimina el indice, la tabla busqueda_clientes, y (si la creaste) la tabla producto_embeddings.

Ver solucion
-- Eliminar en orden (el indice se elimina con la tabla)
DROP TABLE IF EXISTS busqueda_clientes;
DROP TABLE IF EXISTS producto_embeddings;
-- Si quieres remover la extension (opcional, no recomendado si la usas)
-- DROP EXTENSION vector;

Ejercicios integradores

Estos ejercicios combinan todo lo que has aprendido a lo largo del curso. Estan organizados por nivel de dificultad para que vayas progresando. Intenta resolver cada uno por tu cuenta antes de mirar la solucion.

⚠️

Prerequisito: base de datos de practica

Todos los ejercicios se ejecutan sobre la base de datos tienda creada en el Modulo 2. Si aun no la tienes configurada, ve al Modulo 2 y sigue los pasos para crear las tablas (productos, clientes, pedidos, detalle_pedidos, empleados) y cargar los datos de ejemplo. Si no recuerdas la estructura de las tablas, revisa el cheatsheet al final del curso.


Basico

Estos ejercicios integran los conceptos de los Modulos 1 al 5: SELECT, WHERE, ORDER BY, LIMIT, DISTINCT, BETWEEN, LIKE, IN e IS NULL. Solo trabajan con tablas individuales.


Ejercicio 1: Catalogo de productos por rango de precio

Lista los productos con precio entre $20 y $100. Muestra nombre, precio y categoria. Ordena por precio de mayor a menor.

Ver solucion
SELECT nombre, precio, categoria
FROM productos
WHERE precio BETWEEN 20 AND 100
ORDER BY precio DESC;

Ejercicio 2: Buscar empleados por nombre

Encuentra todos los empleados cuyo nombre contiene la letra “a” (sin importar mayusculas/minusculas). Muestra nombre, departamento y salario. Ordena por salario de mayor a menor.

Pista Usa ILIKE para busqueda case-insensitive con el patron %a%.

Ver solucion
SELECT nombre, departamento, salario
FROM empleados
WHERE nombre ILIKE '%a%'
ORDER BY salario DESC;

Ejercicio 3: Clientes fuera de las ciudades principales

Lista los clientes que no son de Santiago ni de Valparaiso. Muestra nombre, email y ciudad. Ordena alfabeticamente por nombre.

Ver solucion
-- Opcion 1: con NOT IN
SELECT nombre, email, ciudad
FROM clientes
WHERE ciudad NOT IN ('Santiago', 'Valparaiso')
ORDER BY nombre;
-- Opcion 2: con AND
SELECT nombre, email, ciudad
FROM clientes
WHERE ciudad != 'Santiago' AND ciudad != 'Valparaiso'
ORDER BY nombre;

Ejercicio 4: Pedidos mas caros completados

Muestra los 5 pedidos con mayor total que tengan estado ‘completado’. Incluye id, fecha, total y estado.

Ver solucion
SELECT id, fecha, total, estado
FROM pedidos
WHERE estado = 'completado'
ORDER BY total DESC
LIMIT 5;

Ejercicio 5: Directores y ciudades unicas

Resuelve estas dos consultas:

(a) Encuentra los empleados que no tienen jefe (son directores). Muestra nombre, departamento y salario.

(b) Lista todas las ciudades unicas donde hay clientes registrados, ordenadas alfabeticamente.

Ver solucion
-- (a) Empleados sin jefe
SELECT nombre, departamento, salario
FROM empleados
WHERE jefe_id IS NULL;
-- (b) Ciudades unicas
SELECT DISTINCT ciudad
FROM clientes
ORDER BY ciudad;

Intermedio

Estos ejercicios integran conceptos de los Modulos 1 al 9: funciones de agregacion, GROUP BY, HAVING, JOIN, subconsultas, CTEs, y funciones de texto/fecha/condiciones.


Ejercicio 6: Clientes sin pedidos

Encuentra todos los clientes que nunca han realizado un pedido. Muestra su nombre y email.

Pista Piensa en un LEFT JOIN donde la tabla derecha no tiene coincidencia, o usa NOT EXISTS.

Ver solucion
-- Opcion 1: LEFT JOIN + IS NULL
SELECT c.nombre, c.email
FROM clientes c
LEFT JOIN pedidos p ON c.id = p.cliente_id
WHERE p.id IS NULL;
-- Opcion 2: NOT EXISTS
SELECT c.nombre, c.email
FROM clientes c
WHERE NOT EXISTS (
SELECT 1 FROM pedidos p WHERE p.cliente_id = c.id
);
-- Opcion 3: NOT IN (menos eficiente con tablas grandes)
SELECT nombre, email
FROM clientes
WHERE id NOT IN (SELECT cliente_id FROM pedidos);

La opcion 2 con NOT EXISTS suele ser la mas eficiente en tablas grandes.


Ejercicio 7: Productos por encima del promedio

Lista los productos cuyo precio es mayor al precio promedio de su misma categoria. Muestra nombre, categoria, precio y el precio promedio de la categoria.

Pista Necesitas una subconsulta correlacionada o un JOIN con una subconsulta que calcule el promedio por categoria.

Ver solucion
SELECT p.nombre, p.categoria, p.precio, cat_avg.promedio
FROM productos p
JOIN (
SELECT categoria, AVG(precio) AS promedio
FROM productos
GROUP BY categoria
) cat_avg ON p.categoria = cat_avg.categoria
WHERE p.precio > cat_avg.promedio
ORDER BY p.categoria, p.precio DESC;

Ejercicio 8: Resumen mensual de pedidos

Genera un resumen de pedidos por mes del ano 2024. Muestra el mes (como numero), la cantidad de pedidos, el total facturado y el promedio por pedido. Ordena por mes.

Ver solucion
SELECT
EXTRACT(MONTH FROM fecha) AS mes,
COUNT(*) AS cantidad_pedidos,
SUM(total) AS total_facturado,
ROUND(AVG(total), 2) AS promedio_por_pedido
FROM pedidos
WHERE fecha >= '2024-01-01' AND fecha < '2025-01-01'
GROUP BY EXTRACT(MONTH FROM fecha)
ORDER BY mes;

Ejercicio 9: Top 5 productos mas vendidos

Encuentra los 5 productos mas vendidos (por cantidad total). Muestra el nombre del producto, la cantidad total vendida y el ingreso total generado.

Ver solucion
SELECT
p.nombre,
SUM(dp.cantidad) AS total_vendido,
SUM(dp.cantidad * dp.precio_unitario) AS ingreso_total
FROM productos p
JOIN detalle_pedidos dp ON p.id = dp.producto_id
GROUP BY p.nombre
ORDER BY total_vendido DESC
LIMIT 5;

Ejercicio 10: Clientes por ciudad con filtro

Muestra las ciudades que tienen mas de 3 clientes registrados, junto con la cantidad de clientes en cada una. Ordena de mayor a menor.

Ver solucion
SELECT ciudad, COUNT(*) AS total_clientes
FROM clientes
GROUP BY ciudad
HAVING COUNT(*) > 3
ORDER BY total_clientes DESC;

Ejercicio 11: Clientes VIP

Un cliente VIP es aquel que ha gastado mas de $5,000 en total O ha realizado mas de 10 pedidos. Lista los clientes VIP con su nombre, cantidad de pedidos, gasto total y la razon por la cual son VIP (puede ser ‘gasto alto’, ‘muchos pedidos’ o ‘ambos’).

Pista Usa un CASE para determinar la razon. Primero haz el JOIN y la agregacion, luego filtra con HAVING.

Ver solucion
SELECT
c.nombre,
COUNT(p.id) AS total_pedidos,
SUM(p.total) AS gasto_total,
CASE
WHEN SUM(p.total) > 5000 AND COUNT(p.id) > 10 THEN 'ambos'
WHEN SUM(p.total) > 5000 THEN 'gasto alto'
WHEN COUNT(p.id) > 10 THEN 'muchos pedidos'
END AS razon_vip
FROM clientes c
JOIN pedidos p ON c.id = p.cliente_id
GROUP BY c.nombre
HAVING SUM(p.total) > 5000 OR COUNT(p.id) > 10
ORDER BY gasto_total DESC;

Ejercicio 12: Productos que nunca se han vendido

Encuentra los productos que no aparecen en ningun detalle de pedido. Muestra nombre, precio y fecha de creacion. Ordena por fecha de creacion de mas antiguo a mas reciente.

Ver solucion
SELECT p.nombre, p.precio, p.fecha_creacion
FROM productos p
LEFT JOIN detalle_pedidos dp ON p.id = dp.producto_id
WHERE dp.id IS NULL
ORDER BY p.fecha_creacion ASC;

Ejercicio 13: Pedidos con muchos productos

Encuentra los pedidos que contienen mas de 5 productos diferentes. Muestra el id del pedido, la fecha, el nombre del cliente, la cantidad de productos diferentes y el total del pedido.

Ver solucion
SELECT
p.id AS pedido_id,
p.fecha,
c.nombre AS cliente,
COUNT(DISTINCT dp.producto_id) AS productos_diferentes,
p.total
FROM pedidos p
JOIN clientes c ON p.cliente_id = c.id
JOIN detalle_pedidos dp ON p.id = dp.pedido_id
GROUP BY p.id, p.fecha, c.nombre, p.total
HAVING COUNT(DISTINCT dp.producto_id) > 5
ORDER BY productos_diferentes DESC;

Ejercicio 14: Empleados que ganan mas que su jefe

Usando la tabla empleados con la columna jefe_id (que referencia a otro empleado), encuentra los empleados que ganan mas que su jefe. Muestra el nombre del empleado, su salario, el nombre del jefe y el salario del jefe.

Ver solucion
SELECT
e.nombre AS empleado,
e.salario AS salario_empleado,
j.nombre AS jefe,
j.salario AS salario_jefe
FROM empleados e
JOIN empleados j ON e.jefe_id = j.id
WHERE e.salario > j.salario
ORDER BY (e.salario - j.salario) DESC;

Avanzado

Estos ejercicios integran todos los conceptos del curso (Modulos 1 al 13): window functions, CTEs recursivas, optimizacion, DML, metadatos y analisis estadistico.


Ejercicio 15: Ranking de clientes por ciudad

Para cada ciudad, muestra los 3 clientes que mas han gastado. Incluye el nombre del cliente, la ciudad y el total gastado.

Pista Necesitas funciones de ventana. ROW_NUMBER() o RANK() particionado por ciudad.

Ver solucion
WITH clientes_gasto AS (
SELECT
c.nombre,
c.ciudad,
SUM(p.total) AS total_gastado,
ROW_NUMBER() OVER (PARTITION BY c.ciudad ORDER BY SUM(p.total) DESC) AS ranking
FROM clientes c
JOIN pedidos p ON c.id = p.cliente_id
GROUP BY c.nombre, c.ciudad
)
SELECT nombre, ciudad, total_gastado, ranking
FROM clientes_gasto
WHERE ranking <= 3
ORDER BY ciudad, ranking;

Ejercicio 16: Variacion de ventas mes a mes

Calcula las ventas totales por mes en 2024 y muestra la diferencia porcentual respecto al mes anterior. Muestra mes, total del mes, total del mes anterior y el porcentaje de cambio.

Pista Usa LAG() como funcion de ventana para acceder al valor del mes anterior.

Ver solucion
WITH ventas_mensuales AS (
SELECT
EXTRACT(MONTH FROM fecha)::INTEGER AS mes,
SUM(total) AS total_mes
FROM pedidos
WHERE fecha >= '2024-01-01' AND fecha < '2025-01-01'
GROUP BY EXTRACT(MONTH FROM fecha)
ORDER BY mes
)
SELECT
mes,
total_mes,
LAG(total_mes) OVER (ORDER BY mes) AS mes_anterior,
ROUND(
(total_mes - LAG(total_mes) OVER (ORDER BY mes))
/ LAG(total_mes) OVER (ORDER BY mes) * 100,
2
) AS variacion_porcentual
FROM ventas_mensuales;

Ejercicio 17: Analisis de cohortes simplificado

Agrupa a los clientes por el mes en que hicieron su primer pedido (cohorte). Para cada cohorte, muestra cuantos clientes tiene y cuanto gastaron en total en su primer mes vs. los meses siguientes.

Pista Primero identifica el primer pedido de cada cliente con MIN(). Luego clasifica los pedidos como “primer mes” o “meses siguientes”.

Ver solucion
WITH primer_pedido AS (
SELECT
cliente_id,
DATE_TRUNC('month', MIN(fecha)) AS cohorte
FROM pedidos
GROUP BY cliente_id
),
pedidos_clasificados AS (
SELECT
pp.cohorte,
p.cliente_id,
p.total,
CASE
WHEN DATE_TRUNC('month', p.fecha) = pp.cohorte THEN 'primer_mes'
ELSE 'meses_siguientes'
END AS periodo
FROM pedidos p
JOIN primer_pedido pp ON p.cliente_id = pp.cliente_id
)
SELECT
TO_CHAR(cohorte, 'YYYY-MM') AS cohorte,
COUNT(DISTINCT cliente_id) AS total_clientes,
SUM(CASE WHEN periodo = 'primer_mes' THEN total ELSE 0 END) AS gasto_primer_mes,
SUM(CASE WHEN periodo = 'meses_siguientes' THEN total ELSE 0 END) AS gasto_posterior
FROM pedidos_clasificados
GROUP BY cohorte
ORDER BY cohorte;

Ejercicio 18: Productos frecuentemente comprados juntos

Encuentra pares de productos que se compran juntos en el mismo pedido con frecuencia. Muestra los nombres de ambos productos y cuantas veces aparecen en el mismo pedido. Solo muestra pares que aparezcan al menos 3 veces.

Pista Necesitas un self-join de detalle_pedidos consigo misma, unida por pedido_id. Usa una condicion para evitar pares duplicados (producto A con B es lo mismo que B con A).

Ver solucion
SELECT
p1.nombre AS producto_1,
p2.nombre AS producto_2,
COUNT(*) AS veces_juntos
FROM detalle_pedidos dp1
JOIN detalle_pedidos dp2 ON dp1.pedido_id = dp2.pedido_id
AND dp1.producto_id < dp2.producto_id -- evita duplicados y auto-pares
JOIN productos p1 ON dp1.producto_id = p1.id
JOIN productos p2 ON dp2.producto_id = p2.id
GROUP BY p1.nombre, p2.nombre
HAVING COUNT(*) >= 3
ORDER BY veces_juntos DESC;

Ejercicio 19: Detectar pedidos anomalos

Un pedido es “anomalo” si su total es mas de 3 desviaciones estandar por encima del promedio de pedidos de ese mismo cliente. Encuentra estos pedidos y muestra el id del pedido, el cliente, el total del pedido, el promedio del cliente y cuantas desviaciones estandar representa.

Pista Calcula promedio y desviacion estandar por cliente con funciones de ventana, luego filtra.

Ver solucion
WITH stats_cliente AS (
SELECT
p.id AS pedido_id,
c.nombre AS cliente,
p.total,
AVG(p.total) OVER (PARTITION BY p.cliente_id) AS promedio_cliente,
STDDEV(p.total) OVER (PARTITION BY p.cliente_id) AS desviacion_cliente
FROM pedidos p
JOIN clientes c ON p.cliente_id = c.id
)
SELECT
pedido_id,
cliente,
total,
ROUND(promedio_cliente, 2) AS promedio,
ROUND(desviacion_cliente, 2) AS desviacion,
ROUND((total - promedio_cliente) / NULLIF(desviacion_cliente, 0), 2) AS desviaciones_sobre_media
FROM stats_cliente
WHERE desviacion_cliente > 0
AND (total - promedio_cliente) / desviacion_cliente > 3
ORDER BY desviaciones_sobre_media DESC;

Nota: usamos NULLIF(desviacion_cliente, 0) para evitar division por cero en caso de clientes con un solo pedido.


Ejercicio 20: Dashboard de resumen completo

Crea una consulta que genere un resumen ejecutivo con una sola consulta. Debe devolver una fila con:

  • Total de clientes activos (que han hecho al menos un pedido)
  • Total de ingresos del mes actual
  • Total de ingresos del mes anterior
  • Porcentaje de cambio entre ambos meses
  • El producto mas vendido del mes actual (por cantidad)
  • La ciudad con mas pedidos del mes actual

Pista Usa multiples CTEs, una para cada metrica, y luego cruza los resultados con un CROSS JOIN.

Ver solucion
WITH clientes_activos AS (
SELECT COUNT(DISTINCT cliente_id) AS total
FROM pedidos
),
ingresos_actual AS (
SELECT COALESCE(SUM(total), 0) AS total
FROM pedidos
WHERE DATE_TRUNC('month', fecha) = DATE_TRUNC('month', CURRENT_DATE)
),
ingresos_anterior AS (
SELECT COALESCE(SUM(total), 0) AS total
FROM pedidos
WHERE DATE_TRUNC('month', fecha) = DATE_TRUNC('month', CURRENT_DATE) - INTERVAL '1 month'
),
producto_top AS (
SELECT p.nombre
FROM detalle_pedidos dp
JOIN productos p ON dp.producto_id = p.id
JOIN pedidos pe ON dp.pedido_id = pe.id
WHERE DATE_TRUNC('month', pe.fecha) = DATE_TRUNC('month', CURRENT_DATE)
GROUP BY p.nombre
ORDER BY SUM(dp.cantidad) DESC
LIMIT 1
),
ciudad_top AS (
SELECT c.ciudad
FROM pedidos p
JOIN clientes c ON p.cliente_id = c.id
WHERE DATE_TRUNC('month', p.fecha) = DATE_TRUNC('month', CURRENT_DATE)
GROUP BY c.ciudad
ORDER BY COUNT(*) DESC
LIMIT 1
)
SELECT
ca.total AS clientes_activos,
ia.total AS ingresos_mes_actual,
ian.total AS ingresos_mes_anterior,
CASE
WHEN ian.total = 0 THEN NULL
ELSE ROUND((ia.total - ian.total) / ian.total * 100, 2)
END AS variacion_porcentual,
pt.nombre AS producto_top,
ct.ciudad AS ciudad_top
FROM clientes_activos ca
CROSS JOIN ingresos_actual ia
CROSS JOIN ingresos_anterior ian
CROSS JOIN producto_top pt
CROSS JOIN ciudad_top ct;

Ejercicio 21: Optimiza esta consulta

La siguiente consulta es funcional pero lenta. Reescribela para que sea mas eficiente y sugiere los indices necesarios:

SELECT *
FROM clientes c
WHERE (SELECT SUM(p.total)
FROM pedidos p
WHERE p.cliente_id = c.id
AND EXTRACT(YEAR FROM p.fecha) = 2024) > 1000
ORDER BY (SELECT SUM(p.total)
FROM pedidos p
WHERE p.cliente_id = c.id
AND EXTRACT(YEAR FROM p.fecha) = 2024) DESC;

Pista La subconsulta se ejecuta dos veces por cada cliente (una en WHERE, otra en ORDER BY). Ademas, usa EXTRACT() sobre una columna que deberia estar indexada.

Ver solucion
-- Consulta optimizada: subconsulta solo una vez con CTE, sin funciones en columnas
WITH gasto_2024 AS (
SELECT
cliente_id,
SUM(total) AS gasto_total
FROM pedidos
WHERE fecha >= '2024-01-01' AND fecha < '2025-01-01'
GROUP BY cliente_id
HAVING SUM(total) > 1000
)
SELECT c.nombre, c.email, c.ciudad, g.gasto_total
FROM clientes c
JOIN gasto_2024 g ON c.id = g.cliente_id
ORDER BY g.gasto_total DESC;
-- Indices recomendados:
CREATE INDEX idx_pedidos_fecha ON pedidos(fecha);
CREATE INDEX idx_pedidos_cliente_fecha ON pedidos(cliente_id, fecha);

Mejoras realizadas:

  1. CTE calcula el gasto una sola vez en lugar de dos subconsultas por fila.
  2. Rango de fechas en vez de EXTRACT() permite usar indices.
  3. HAVING filtra en la agregacion, no despues.
  4. Columnas especificas en vez de SELECT *.

Ejercicio 22: Jerarquia de empleados

Usando la tabla empleados, genera un reporte que muestre la jerarquia completa: para cada empleado, su nombre, el nombre de su jefe directo, y el departamento. Incluye a los empleados que no tienen jefe (son los directores). Ordena por departamento y luego por salario descendente.

Ver solucion
SELECT
e.nombre AS empleado,
e.departamento,
e.salario,
COALESCE(j.nombre, 'Sin jefe (Director)') AS jefe_directo
FROM empleados e
LEFT JOIN empleados j ON e.jefe_id = j.id
ORDER BY e.departamento, e.salario DESC;

Version mas avanzada con CTE recursiva para mostrar todos los niveles:

WITH RECURSIVE jerarquia AS (
-- Caso base: empleados sin jefe (directores)
SELECT id, nombre, departamento, salario, jefe_id, 1 AS nivel,
nombre AS cadena_jerarquia
FROM empleados
WHERE jefe_id IS NULL
UNION ALL
-- Caso recursivo: empleados con jefe
SELECT e.id, e.nombre, e.departamento, e.salario, e.jefe_id,
j.nivel + 1,
j.cadena_jerarquia || ' > ' || e.nombre
FROM empleados e
JOIN jerarquia j ON e.jefe_id = j.id
)
SELECT nombre, departamento, salario, nivel, cadena_jerarquia
FROM jerarquia
ORDER BY cadena_jerarquia;

Ejercicio 23: Analisis de stock critico

Identifica los productos cuyo stock es menor que la cantidad promedio vendida por mes en los ultimos 6 meses. Estos productos estan en riesgo de quedarse sin inventario. Muestra el nombre, stock actual, promedio mensual de ventas y para cuantos meses alcanza el stock.

Ver solucion
WITH ventas_mensuales AS (
SELECT
dp.producto_id,
EXTRACT(MONTH FROM pe.fecha) AS mes,
EXTRACT(YEAR FROM pe.fecha) AS anio,
SUM(dp.cantidad) AS vendido_en_mes
FROM detalle_pedidos dp
JOIN pedidos pe ON dp.pedido_id = pe.id
WHERE pe.fecha >= CURRENT_DATE - INTERVAL '6 months'
GROUP BY dp.producto_id, EXTRACT(MONTH FROM pe.fecha), EXTRACT(YEAR FROM pe.fecha)
),
promedio_mensual AS (
SELECT
producto_id,
ROUND(AVG(vendido_en_mes), 2) AS promedio_venta_mes
FROM ventas_mensuales
GROUP BY producto_id
)
SELECT
p.nombre,
p.stock AS stock_actual,
pm.promedio_venta_mes,
ROUND(p.stock / NULLIF(pm.promedio_venta_mes, 0), 1) AS meses_de_stock
FROM productos p
JOIN promedio_mensual pm ON p.id = pm.producto_id
WHERE p.stock < pm.promedio_venta_mes
ORDER BY meses_de_stock ASC;

Ejercicio 24: Migracion de estructura

Este ejercicio simula una migracion de base de datos. Realiza los siguientes pasos en orden, usando transacciones para seguridad:

  1. Agrega una columna descuento DECIMAL(5,2) DEFAULT 0 a la tabla productos
  2. Actualiza el descuento segun la categoria: ‘Electronica’ = 10%, ‘Muebles’ = 15%, ‘Accesorios’ = 5%
  3. Verifica los cambios con un SELECT
  4. Renombra la columna descuento a descuento_porcentaje
  5. Finalmente, elimina la columna (para dejar la tabla como estaba)

Pista Usa BEGIN y COMMIT para envolver toda la operacion en una transaccion. Si algo falla, puedes usar ROLLBACK para deshacer todo. Para el UPDATE por categoria, usa CASE.

Ver solucion
BEGIN;
-- 1. Agregar columna
ALTER TABLE productos
ADD COLUMN descuento DECIMAL(5,2) DEFAULT 0;
-- 2. Actualizar descuento por categoria
UPDATE productos
SET descuento = CASE categoria
WHEN 'Electronica' THEN 10.00
WHEN 'Muebles' THEN 15.00
WHEN 'Accesorios' THEN 5.00
ELSE 0.00
END;
-- 3. Verificar
SELECT nombre, categoria, precio, descuento,
ROUND(precio * (1 - descuento / 100), 2) AS precio_con_descuento
FROM productos
ORDER BY categoria, nombre;
-- 4. Renombrar columna
ALTER TABLE productos
RENAME COLUMN descuento TO descuento_porcentaje;
-- 5. Verificar el rename
SELECT nombre, descuento_porcentaje FROM productos LIMIT 3;
-- 6. Eliminar columna (dejar la tabla como estaba)
ALTER TABLE productos
DROP COLUMN descuento_porcentaje;
COMMIT;

Este patron de agregar → modificar → renombrar → eliminar es comun en migraciones de bases de datos reales, donde necesitas transformar la estructura de forma segura y reversible.


💡

Consejo final

Si terminaste todos los ejercicios, intenta ejecutar EXPLAIN ANALYZE en tus soluciones y piensa como podrias crear indices para mejorar el rendimiento. La optimizacion es un musculo que se entrena con practica.

Errores comunes y sus soluciones

Todos cometemos errores con SQL. La diferencia entre un principiante y alguien con experiencia no es que el segundo no cometa errores, sino que los reconoce mas rapido. Aqui tienes los errores mas comunes que vas a encontrar (o que ya encontraste) con sus soluciones.


1. Columna no encontrada (typos y alias incorrectos)

El error:

SELECT nmobre, precio FROM productos;
ERROR: column "nmobre" does not exist
HINT: Perhaps you meant to reference the column "productos.nombre".

Por que pasa: Un simple typo. Tambien pasa cuando usas un alias de tabla y te equivocas en la referencia:

SELECT c.nombre, p.total
FROM clientes AS cli
JOIN pedidos AS ped ON cli.id = ped.cliente_id;
-- Error: "c" no existe, definiste "cli"

Como solucionarlo: Revisa la ortografia. Si usas alias, se consistente. PostgreSQL te da una pista (“Perhaps you meant…”), leela.

-- Correcto
SELECT cli.nombre, ped.total
FROM clientes AS cli
JOIN pedidos AS ped ON cli.id = ped.cliente_id;

Tip

Usa alias cortos pero descriptivos. c para clientes, p para pedidos, dp para detalle_pedidos. Y una vez que defines el alias, usa solo el alias, nunca mezcles con el nombre original de la tabla.


2. Funcion de agregacion sin GROUP BY

El error:

SELECT categoria, COUNT(*)
FROM productos;
ERROR: column "productos.categoria" must appear in the GROUP BY clause
or be used in an aggregate function

Por que pasa: Estas mezclando una columna normal (categoria) con una funcion de agregacion (COUNT(*)). SQL no sabe como combinar valores individuales con valores agregados sin un GROUP BY.

Piensalo asi: COUNT(*) devuelve UN numero. Pero categoria tiene muchos valores. Como los emparejas?

Como solucionarlo: Agrega GROUP BY con todas las columnas no agregadas:

SELECT categoria, COUNT(*)
FROM productos
GROUP BY categoria;

3. Usar WHERE en vez de HAVING con agregados

El error:

SELECT categoria, AVG(precio) AS precio_promedio
FROM productos
GROUP BY categoria
WHERE AVG(precio) > 100;
ERROR: syntax error at or near "WHERE"

Por que pasa: WHERE filtra filas antes de la agregacion. No puedes usar funciones de agregacion en WHERE porque los grupos todavia no existen en ese momento. Es como intentar saber el promedio de la clase antes de recoger los examenes.

Como solucionarlo: Usa HAVING para filtrar despues de la agregacion:

SELECT categoria, AVG(precio) AS precio_promedio
FROM productos
GROUP BY categoria
HAVING AVG(precio) > 100;
Comando Descripcion
WHERE Filtra filas **antes** de agrupar. No permite funciones de agregacion.
HAVING Filtra grupos **despues** de agrupar. Si permite funciones de agregacion.

4. Olvidar la condicion de JOIN (producto cartesiano)

El error:

SELECT c.nombre, p.total
FROM clientes c, pedidos p;

No da error, pero devuelve muchas mas filas de las esperadas. Si tienes 100 clientes y 1,000 pedidos, esta consulta devuelve 100,000 filas (cada cliente combinado con cada pedido).

Por que pasa: Sin una condicion de JOIN, SQL combina cada fila de una tabla con cada fila de la otra. Es un producto cartesiano y casi nunca es lo que quieres.

Como solucionarlo: Siempre usa JOIN explicito con la condicion ON:

-- Correcto
SELECT c.nombre, p.total
FROM clientes c
JOIN pedidos p ON c.id = p.cliente_id;
⚠️

Senal de alerta

Si una consulta devuelve muchas mas filas de las esperadas, lo primero que debes revisar es si olvidaste una condicion de JOIN o si tus JOINs estan generando duplicados.


5. UPDATE o DELETE sin WHERE

El error:

-- Querias actualizar UN producto...
UPDATE productos SET precio = 99.99;
-- UPDATE 15 ← actualizo TODOS los productos
-- Querias borrar UN pedido...
DELETE FROM pedidos;
-- DELETE 15 ← borro TODOS los pedidos

Por que pasa: Sin WHERE, la operacion se aplica a toda la tabla. No hay mensaje de error ni confirmacion; SQL hace exactamente lo que le pediste. El unico indicio es el numero de filas afectadas (UPDATE 15 en vez de UPDATE 1), pero para ese momento ya es tarde.

Las consecuencias:

  • UPDATE tabla SET columna = valor;todas las filas ahora tienen el mismo valor. Perdiste los datos originales.
  • DELETE FROM tabla;todas las filas eliminadas. La tabla queda vacia.
  • En ambos casos, si no estabas dentro de una transaccion, no hay forma de deshacer el cambio (salvo restaurar un backup).

Lo mismo aplica a DELETE con WHERE incorrecto:

-- Querias borrar pedidos pendientes de enero...
DELETE FROM pedidos WHERE estado = 'pendiente';
-- ...pero olvidaste la condicion de fecha. Borraste TODOS los pendientes.

Como prevenirlo — la tecnica SELECT primero:

-- Paso 1: SIEMPRE verifica con SELECT que filas vas a afectar
SELECT id, nombre, precio FROM productos WHERE id = 42;
-- Resultado: 1 fila. Perfecto, es lo que espero.
-- Paso 2: Solo si el SELECT muestra lo correcto, ejecuta el UPDATE
UPDATE productos SET precio = 99.99 WHERE id = 42;
-- UPDATE 1 ← confirma que solo afecto 1 fila

Como prevenirlo — transacciones:

BEGIN;
-- Ejecuta tu UPDATE o DELETE
UPDATE productos SET precio = 99.99 WHERE id = 42;
-- Verifica el resultado
SELECT * FROM productos WHERE id = 42;
-- Si todo esta bien:
COMMIT;
-- Si algo salio mal (ejecuta esto EN VEZ de COMMIT):
-- ROLLBACK;
⚠️

Checklist antes de UPDATE/DELETE

  1. ¿Tiene WHERE? Si no, probablemente es un error.
  2. ¿El SELECT con el mismo WHERE devuelve las filas correctas? Ejecutalo primero.
  3. ¿Cuantas filas va a afectar? Si esperas 1 y afecta 1000, algo esta mal.
  4. ¿Estas en produccion? Usa BEGIN / COMMIT o ROLLBACK.
  5. ¿Tienes backup? Si la tabla es critica, verifica antes de ejecutar.

Truco: cuenta antes de modificar

Si no estas seguro de cuantas filas va a afectar tu UPDATE o DELETE, usa COUNT primero:

-- ¿Cuantas filas voy a modificar?
SELECT COUNT(*) FROM productos WHERE categoria = 'Electronica';
-- Resultado: 7. OK, son los que espero.
UPDATE productos SET precio = ROUND(precio * 1.10, 2)
WHERE categoria = 'Electronica';
-- UPDATE 7 ← coincide con el COUNT. Perfecto.

6. Comparar con NULL usando = en vez de IS NULL

El error:

SELECT * FROM empleados WHERE jefe_id = NULL;

Esta consulta no devuelve ninguna fila, aunque hay empleados sin jefe.

Por que pasa: En SQL, NULL no es un valor, es la ausencia de valor. Nada es “igual” a NULL, ni siquiera NULL mismo. La expresion NULL = NULL devuelve NULL (no TRUE).

Como solucionarlo: Usa IS NULL o IS NOT NULL:

-- Correcto: encontrar empleados sin jefe
SELECT * FROM empleados WHERE jefe_id IS NULL;
-- Correcto: encontrar empleados CON jefe
SELECT * FROM empleados WHERE jefe_id IS NOT NULL;

Esto tambien aplica a las comparaciones con != o <>:

-- Esto NO devuelve filas donde jefe_id es NULL
SELECT * FROM empleados WHERE jefe_id != 5;
-- Si quieres incluir los NULL, se explicito:
SELECT * FROM empleados WHERE jefe_id != 5 OR jefe_id IS NULL;

7. GROUP BY con columnas faltantes

El error:

SELECT c.nombre, c.email, c.ciudad, COUNT(p.id)
FROM clientes c
JOIN pedidos p ON c.id = p.cliente_id
GROUP BY c.nombre;
ERROR: column "c.email" must appear in the GROUP BY clause
or be used in an aggregate function

Por que pasa: Cada columna en el SELECT que no esta dentro de una funcion de agregacion debe aparecer en el GROUP BY. PostgreSQL es estricto con esto (otros motores como MySQL son mas permisivos, lo cual puede dar resultados impredecibles).

Como solucionarlo: Agrega todas las columnas no agregadas al GROUP BY:

SELECT c.nombre, c.email, c.ciudad, COUNT(p.id)
FROM clientes c
JOIN pedidos p ON c.id = p.cliente_id
GROUP BY c.nombre, c.email, c.ciudad;

O, si quieres agrupar solo por una columna pero mostrar las demas, asegurate de que las columnas extra dependan funcionalmente de la columna agrupada (por ejemplo, si agrupas por c.id que es PRIMARY KEY, PostgreSQL permite las demas columnas de esa tabla):

-- Esto SI funciona porque id es PRIMARY KEY
SELECT c.id, c.nombre, c.email, c.ciudad, COUNT(p.id)
FROM clientes c
JOIN pedidos p ON c.id = p.cliente_id
GROUP BY c.id;

8. Referencia ambigua a columnas

El error:

SELECT id, nombre, total
FROM clientes c
JOIN pedidos p ON c.id = p.cliente_id;
ERROR: column reference "id" is ambiguous

Por que pasa: Ambas tablas tienen una columna llamada id. SQL no sabe si te refieres a clientes.id o pedidos.id.

Como solucionarlo: Califica siempre las columnas con el nombre o alias de la tabla cuando haces JOIN:

SELECT c.id, c.nombre, p.total
FROM clientes c
JOIN pedidos p ON c.id = p.cliente_id;

Tip

Como regla, cuando tu consulta tiene mas de una tabla, siempre usa el alias de tabla como prefijo en todas las columnas. Aunque no sea ambigua, hace la consulta mucho mas legible.


9. Comparar strings con numeros

El error:

-- La columna 'precio' es NUMERIC, pero comparas con un string
SELECT * FROM productos WHERE precio = '100';

En PostgreSQL esto funciona la mayoria de las veces porque hace conversion implicita. Pero puede fallar con tipos mas complejos o causar problemas de rendimiento:

-- Esto puede impedir el uso de indices y dar resultados inesperados
SELECT * FROM clientes WHERE id = '42abc';
ERROR: invalid input syntax for type integer: "42abc"

Por que pasa: PostgreSQL intenta convertir el string a numero, y si el string no es un numero valido, falla.

Como solucionarlo: Usa siempre el tipo de dato correcto:

-- Correcto: numeros sin comillas
SELECT * FROM productos WHERE precio = 100;
SELECT * FROM clientes WHERE id = 42;
-- Correcto: strings con comillas simples
SELECT * FROM clientes WHERE email = 'ana@mail.com';

10. La trampa de rendimiento de OFFSET

El error:

-- Pagina 1: rapido
SELECT * FROM productos ORDER BY id LIMIT 20 OFFSET 0;
-- Pagina 500: lento
SELECT * FROM productos ORDER BY id LIMIT 20 OFFSET 10000;

Por que pasa: OFFSET 10000 no “salta” magicamente a la fila 10,000. PostgreSQL lee y descarta las primeras 10,000 filas. Cuanto mayor es el OFFSET, mas trabajo desperdiciado. En tablas grandes, las paginas finales pueden ser muy lentas.

Como solucionarlo: Usa paginacion basada en cursor (keyset pagination):

-- En vez de OFFSET, usa WHERE con el ultimo valor visto
-- Si la ultima fila de la pagina anterior tenia id = 10000:
SELECT * FROM productos
WHERE id > 10000
ORDER BY id
LIMIT 20;

Esta tecnica es O(log n) con un indice en id, mientras que OFFSET es O(n).

Rendimiento

Para APIs con paginacion, prefiere siempre keyset pagination sobre OFFSET. La diferencia se hace enorme en tablas con millones de filas. Pagina 1 y pagina 50,000 tardan lo mismo con keyset pagination.


11. Usar SELECT * en produccion

El error:

SELECT * FROM clientes c
JOIN pedidos p ON c.id = p.cliente_id
JOIN detalle_pedidos dp ON p.id = dp.pedido_id
JOIN productos pr ON dp.producto_id = pr.id;

Por que pasa: Parece comodo, pero trae TODAS las columnas de TODAS las tablas. Problemas:

  1. Rendimiento: Transfiere muchos mas datos de los necesarios.
  2. Impide Index Only Scan: No puede usar solo el indice si necesita todas las columnas.
  3. Fragilidad: Si alguien agrega una columna a la tabla, tu aplicacion puede romperse.
  4. Confuso: Cuando dos tablas tienen columnas con el mismo nombre (como id), no sabes cual es cual.

Como solucionarlo: Nombra siempre las columnas que necesitas:

SELECT
c.nombre AS cliente,
p.fecha,
pr.nombre AS producto,
dp.cantidad,
dp.precio_unitario
FROM clientes c
JOIN pedidos p ON c.id = p.cliente_id
JOIN detalle_pedidos dp ON p.id = dp.pedido_id
JOIN productos pr ON dp.producto_id = pr.id;

12. No crear indices donde se necesitan

El error:

-- Esta consulta se ejecuta 1,000 veces por minuto en tu aplicacion
SELECT * FROM pedidos WHERE cliente_id = 42 AND estado = 'pendiente';

Y nunca creaste un indice en cliente_id ni en estado. Cada ejecucion hace un Sequential Scan completo.

Por que pasa: Los indices no se crean solos (excepto para PRIMARY KEY y UNIQUE). Si no los creas explicitamente, no existen.

Como detectarlo y solucionarlo:

-- Paso 1: Verificar que hay un Seq Scan
EXPLAIN ANALYZE SELECT * FROM pedidos
WHERE cliente_id = 42 AND estado = 'pendiente';
-- Paso 2: Crear el indice apropiado
CREATE INDEX idx_pedidos_cliente_estado ON pedidos(cliente_id, estado);
-- Paso 3: Verificar la mejora
EXPLAIN ANALYZE SELECT * FROM pedidos
WHERE cliente_id = 42 AND estado = 'pendiente';
💡

Como encontrar indices faltantes

PostgreSQL puede decirte que tablas tienen mas Sequential Scans de los deseados:

SELECT
relname AS tabla,
seq_scan,
idx_scan,
seq_scan - idx_scan AS diferencia
FROM pg_stat_user_tables
WHERE seq_scan > idx_scan
ORDER BY diferencia DESC;

Las tablas con muchos mas seq_scan que idx_scan son candidatas a necesitar indices.


13. Usar DISTINCT para “arreglar” duplicados

El error:

-- "Tengo duplicados, le pongo DISTINCT y listo"
SELECT DISTINCT c.nombre, p.total
FROM clientes c
JOIN pedidos p ON c.id = p.cliente_id
JOIN detalle_pedidos dp ON p.id = dp.pedido_id;

Por que pasa: Si tu consulta devuelve duplicados inesperados, el problema casi siempre es un JOIN incorrecto o faltante, no la falta de DISTINCT. DISTINCT es como poner una curita sobre una herida que necesita puntos.

En este caso, hay duplicados porque cada pedido puede tener multiples detalles, y el JOIN multiplica las filas del pedido por cada detalle.

Como solucionarlo: Analiza por que hay duplicados y arregla la causa raiz:

-- Si quieres datos de clientes y pedidos (sin detalles), quita el JOIN extra
SELECT c.nombre, p.total
FROM clientes c
JOIN pedidos p ON c.id = p.cliente_id;
-- Si necesitas datos de detalles, agrega GROUP BY o reformula la consulta
SELECT c.nombre, p.total, SUM(dp.cantidad) AS items_totales
FROM clientes c
JOIN pedidos p ON c.id = p.cliente_id
JOIN detalle_pedidos dp ON p.id = dp.pedido_id
GROUP BY c.nombre, p.total;
⚠️

Advertencia

Si te encuentras usando DISTINCT y no puedes explicar por que hay duplicados, detente. Revisa tus JOINs. El DISTINCT esconde el problema y ademas es costoso: PostgreSQL tiene que ordenar o hacer hash de todas las filas para eliminar duplicados.


Resumen rapido

Comando Descripcion
Column not found Revisa typos y que los alias coincidan
Agregacion sin GROUP BY Agrega GROUP BY con todas las columnas no agregadas
WHERE con agregados Usa HAVING en vez de WHERE
Producto cartesiano Agrega la condicion ON al JOIN
UPDATE/DELETE sin WHERE Primero haz SELECT para verificar
Comparar con NULL Usa IS NULL o IS NOT NULL
GROUP BY incompleto Agrega todas las columnas del SELECT al GROUP BY
Columna ambigua Prefija con el alias de tabla
Tipo de dato incorrecto Numeros sin comillas, strings con comillas simples
OFFSET lento Usa keyset pagination (WHERE id > ultimo)
SELECT * Nombra las columnas que necesitas
Sin indices Usa EXPLAIN y crea indices en columnas del WHERE y JOIN
DISTINCT innecesario Arregla la causa raiz de los duplicados

Cheatsheet de SQL

Referencia rapida de todo lo que aprendiste en el curso. Guardala para consultarla cuando necesites recordar sintaxis.


Estructura basica de SELECT

SELECT columnas -- Que columnas quieres
FROM tabla -- De que tabla
WHERE condicion -- Filtrar filas (antes de agrupar)
GROUP BY columnas -- Agrupar filas
HAVING condicion_agregada -- Filtrar grupos (despues de agrupar)
ORDER BY columna [ASC|DESC] -- Ordenar resultados
LIMIT n -- Limitar cantidad de filas
OFFSET n; -- Saltar filas (evitar en produccion)

Orden de ejecucion (no es el orden en que lo escribes):

  1. FROM / JOIN — Obtener tablas
  2. WHERE — Filtrar filas
  3. GROUP BY — Agrupar
  4. HAVING — Filtrar grupos
  5. SELECT — Elegir columnas
  6. ORDER BY — Ordenar
  7. LIMIT / OFFSET — Paginar

Operadores WHERE

Comando Descripcion
=, !=, <>, <, >, <=, >= WHERE precio > 100
BETWEEN ... AND ... WHERE precio BETWEEN 10 AND 50
IN (...) WHERE ciudad IN ('Madrid', 'Lima')
NOT IN (...) WHERE estado NOT IN ('cancelado')
LIKE / ILIKE WHERE nombre LIKE 'Ana%' (ILIKE = sin mayusculas)
IS NULL / IS NOT NULL WHERE jefe_id IS NULL
AND, OR, NOT WHERE precio > 100 AND stock > 0
EXISTS (subconsulta) WHERE EXISTS (SELECT 1 FROM pedidos ...)

JOINs

-- INNER JOIN: Solo filas con coincidencia en ambas tablas
Tabla A Tabla B
+-------+ +-------+
| | | |
| [###|#####|###] | ### = resultado
| | | |
+-------+ +-------+
-- LEFT JOIN: Todas las filas de A + coincidencias de B
Tabla A Tabla B
+-------+ +-------+
| | | |
|[######|#####|###] | ### = resultado
| | | |
+-------+ +-------+
-- RIGHT JOIN: Coincidencias de A + todas las filas de B
Tabla A Tabla B
+-------+ +-------+
| | | |
| [###|#####|######]| ### = resultado
| | | |
+-------+ +-------+
-- FULL OUTER JOIN: Todas las filas de ambas tablas
Tabla A Tabla B
+-------+ +-------+
| | | |
|[######|#####|######]| ### = resultado
| | | |
+-------+ +-------+
-- INNER JOIN
SELECT c.nombre, p.total
FROM clientes c
JOIN pedidos p ON c.id = p.cliente_id;
-- LEFT JOIN (incluye clientes sin pedidos)
SELECT c.nombre, p.total
FROM clientes c
LEFT JOIN pedidos p ON c.id = p.cliente_id;
-- Self JOIN (tabla consigo misma)
SELECT e.nombre AS empleado, j.nombre AS jefe
FROM empleados e
LEFT JOIN empleados j ON e.jefe_id = j.id;

Funciones de agregacion

Comando Descripcion
COUNT(*) Cuenta todas las filas (incluyendo NULL)
COUNT(columna) Cuenta filas donde la columna no es NULL
COUNT(DISTINCT columna) Cuenta valores unicos no NULL
SUM(columna) Suma de valores
AVG(columna) Promedio (ignora NULL)
MIN(columna) Valor minimo
MAX(columna) Valor maximo
SELECT
categoria,
COUNT(*) AS total,
ROUND(AVG(precio), 2) AS precio_promedio,
MIN(precio) AS mas_barato,
MAX(precio) AS mas_caro
FROM productos
GROUP BY categoria;

STRING_AGG (concatenar filas)

-- Combinar valores de multiples filas en un texto
SELECT STRING_AGG(nombre, ', ' ORDER BY nombre) FROM empleados;
-- Agrupar por departamento
SELECT departamento, STRING_AGG(nombre, ', ') AS empleados
FROM empleados GROUP BY departamento;
-- Con DISTINCT para valores unicos
SELECT STRING_AGG(DISTINCT ciudad, ' | ' ORDER BY ciudad) FROM clientes;

Funciones de string

Comando Descripcion
UPPER('hola') HOLA
LOWER('HOLA') hola
LENGTH('hola') 4
TRIM(' hola ') hola
CONCAT('a', 'b', 'c') abc
'hola' || ' mundo' hola mundo
SUBSTRING('hola' FROM 1 FOR 2) ho
REPLACE('hola', 'o', 'a') hala
LEFT('hola', 2) ho
RIGHT('hola', 2) la

Funciones de fecha

Comando Descripcion
CURRENT_DATE Fecha de hoy (sin hora)
CURRENT_TIMESTAMP / NOW() Fecha y hora actual
EXTRACT(YEAR FROM fecha) Extrae el ano (tambien MONTH, DAY, etc.)
DATE_TRUNC('month', fecha) Trunca al inicio del mes
AGE(fecha) Intervalo desde fecha hasta hoy
fecha + INTERVAL '30 days' Suma 30 dias a la fecha
TO_CHAR(fecha, 'DD/MM/YYYY') Formatea fecha como texto
-- Pedidos de los ultimos 30 dias
SELECT * FROM pedidos
WHERE fecha >= CURRENT_DATE - INTERVAL '30 days';
-- Agrupar por mes
SELECT DATE_TRUNC('month', fecha) AS mes, COUNT(*)
FROM pedidos
GROUP BY DATE_TRUNC('month', fecha);

Subconsultas

-- En WHERE (escalar)
SELECT * FROM productos
WHERE precio > (SELECT AVG(precio) FROM productos);
-- En WHERE (lista)
SELECT * FROM clientes
WHERE id IN (SELECT cliente_id FROM pedidos WHERE total > 500);
-- En FROM (tabla derivada)
SELECT categoria, promedio
FROM (
SELECT categoria, AVG(precio) AS promedio
FROM productos GROUP BY categoria
) AS sub
WHERE promedio > 100;
-- Subconsulta correlacionada
SELECT * FROM clientes c
WHERE EXISTS (
SELECT 1 FROM pedidos p
WHERE p.cliente_id = c.id AND p.total > 1000
);

CTEs (Common Table Expressions)

-- CTE basico
WITH pedidos_grandes AS (
SELECT cliente_id, SUM(total) AS gasto
FROM pedidos
GROUP BY cliente_id
HAVING SUM(total) > 5000
)
SELECT c.nombre, pg.gasto
FROM clientes c
JOIN pedidos_grandes pg ON c.id = pg.cliente_id;
-- Multiples CTEs
WITH
ventas AS (
SELECT producto_id, SUM(cantidad) AS total_vendido
FROM detalle_pedidos
GROUP BY producto_id
),
top_productos AS (
SELECT producto_id, total_vendido
FROM ventas
ORDER BY total_vendido DESC
LIMIT 10
)
SELECT p.nombre, tp.total_vendido
FROM productos p
JOIN top_productos tp ON p.id = tp.producto_id;
-- CTE recursiva (para jerarquias)
WITH RECURSIVE jerarquia AS (
SELECT id, nombre, jefe_id, 1 AS nivel
FROM empleados WHERE jefe_id IS NULL
UNION ALL
SELECT e.id, e.nombre, e.jefe_id, j.nivel + 1
FROM empleados e
JOIN jerarquia j ON e.jefe_id = j.id
)
SELECT * FROM jerarquia ORDER BY nivel;

Modificacion de datos

-- INSERT
INSERT INTO productos (nombre, precio, categoria, stock)
VALUES ('Laptop Pro', 1299.99, 'Electronica', 50);
-- INSERT multiple
INSERT INTO productos (nombre, precio, categoria, stock) VALUES
('Mouse', 29.99, 'Electronica', 200),
('Teclado', 59.99, 'Electronica', 150);
-- UPDATE
UPDATE productos SET precio = precio * 0.9
WHERE categoria = 'Electronica' AND stock > 100;
-- DELETE (siempre con WHERE!)
DELETE FROM pedidos WHERE estado = 'cancelado' AND fecha < '2023-01-01';
-- INSERT con RETURNING
INSERT INTO clientes (nombre, email, ciudad)
VALUES ('Ana', 'ana@email.com', 'Santiago')
RETURNING id, nombre;
-- UPDATE con RETURNING
UPDATE productos SET precio = precio * 0.9
WHERE categoria = 'Electronica'
RETURNING id, nombre, precio;
-- Actualizar varios registros con valores distintos (CASE)
UPDATE productos
SET precio = CASE id
WHEN 1 THEN 299.99
WHEN 2 THEN 149.50
WHEN 3 THEN 89.99
END
WHERE id IN (1, 2, 3);
-- Actualizar varios registros con valores distintos (FROM VALUES)
UPDATE productos SET precio = v.precio
FROM (VALUES (1, 299.99), (2, 149.50), (3, 89.99)) AS v(id, precio)
WHERE productos.id = v.id;
⚠️

Regla de oro: SELECT antes de UPDATE/DELETE

Siempre convierte tu UPDATE o DELETE en SELECT primero para verificar que filas vas a afectar. Un UPDATE o DELETE sin WHERE modifica toda la tabla.


ALTER TABLE

-- Agregar columna
ALTER TABLE productos ADD COLUMN descripcion TEXT;
ALTER TABLE productos ADD COLUMN activo BOOLEAN NOT NULL DEFAULT TRUE;
-- Eliminar columna
ALTER TABLE productos DROP COLUMN descripcion;
-- Renombrar columna
ALTER TABLE productos RENAME COLUMN stock TO cantidad_disponible;
-- Renombrar tabla
ALTER TABLE productos_backup RENAME TO productos_respaldo;
-- Cambiar tipo de dato
ALTER TABLE productos ALTER COLUMN nombre TYPE VARCHAR(200);
-- Agregar/quitar NOT NULL
ALTER TABLE productos ALTER COLUMN categoria SET NOT NULL;
ALTER TABLE productos ALTER COLUMN categoria DROP NOT NULL;
-- Agregar/quitar DEFAULT
ALTER TABLE productos ALTER COLUMN stock SET DEFAULT 0;
ALTER TABLE productos ALTER COLUMN stock DROP DEFAULT;

DROP TABLE y CREATE TABLE

-- Eliminar tabla (error si no existe)
DROP TABLE nombre_tabla;
-- Eliminar tabla (sin error si no existe)
DROP TABLE IF EXISTS nombre_tabla;
-- Eliminar tabla y sus dependencias (foreign keys)
DROP TABLE nombre_tabla CASCADE;
-- Crear tabla solo si no existe
CREATE TABLE IF NOT EXISTS logs (
id SERIAL PRIMARY KEY,
mensaje TEXT,
fecha TIMESTAMP DEFAULT NOW()
);
-- SERIAL: columna auto-incremental (1, 2, 3, ...)
CREATE TABLE ejemplo (
id SERIAL PRIMARY KEY,
nombre VARCHAR(100) NOT NULL
);
-- Copiar tabla completa (estructura + datos, sin restricciones)
CREATE TABLE productos_backup AS SELECT * FROM productos;
-- Copiar solo algunas columnas/filas
CREATE TABLE electronica AS
SELECT nombre, precio FROM productos WHERE categoria = 'Electronica';
-- SELECT INTO (alternativa)
SELECT * INTO productos_copia FROM productos WHERE stock > 50;

Funciones de ventana (Window Functions)

-- Sintaxis basica
funcion() OVER (
PARTITION BY columna -- divide en grupos (opcional)
ORDER BY columna -- ordena dentro del grupo (opcional)
)
-- Promedio por departamento (sin colapsar filas)
SELECT nombre, departamento, salario,
AVG(salario) OVER (PARTITION BY departamento) AS promedio_depto
FROM empleados;
-- Porcentaje del total
SELECT nombre, salario,
ROUND(salario::NUMERIC / SUM(salario) OVER() * 100, 2) AS pct
FROM empleados;
Comando Descripcion
ROW_NUMBER() OVER(...) Numero secuencial unico (1, 2, 3, 4...)
RANK() OVER(...) Ranking con saltos en empates (1, 2, 2, 4...)
DENSE_RANK() OVER(...) Ranking sin saltos (1, 2, 2, 3...)
NTILE(n) OVER(...) Divide filas en n grupos iguales
LAG(col, n) OVER(...) Valor de n filas antes
LEAD(col, n) OVER(...) Valor de n filas despues
FIRST_VALUE(col) OVER(...) Primer valor de la ventana
LAST_VALUE(col) OVER(...) Ultimo valor (requiere ROWS UNBOUNDED FOLLOWING)
SUM(col) OVER(ORDER BY ...) Suma acumulada (running total)
PERCENT_RANK() OVER(...) Posicion relativa (0 a 1)
CUME_DIST() OVER(...) Distribucion acumulada
-- Patron Top-N por grupo (CTE + ROW_NUMBER)
WITH ranking AS (
SELECT nombre, categoria,
ROW_NUMBER() OVER (PARTITION BY categoria ORDER BY precio DESC) AS rn
FROM productos
)
SELECT * FROM ranking WHERE rn <= 3;
-- LAG: comparar con fila anterior
SELECT fecha, total,
LAG(total) OVER (ORDER BY fecha) AS anterior,
total - LAG(total) OVER (ORDER BY fecha) AS diferencia
FROM pedidos;
-- Reutilizar ventana con WINDOW
SELECT nombre, salario,
AVG(salario) OVER w AS promedio,
MIN(salario) OVER w AS minimo
FROM empleados
WINDOW w AS (PARTITION BY departamento);

Funciones estadisticas

Comando Descripcion
STDDEV(col) Desviacion estandar (dispersion de datos)
VARIANCE(col) Varianza (cuadrado de la desviacion estandar)
PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY col) Mediana (percentil 50)
PERCENTILE_DISC(p) WITHIN GROUP (ORDER BY col) Percentil discreto (valor real mas cercano)
MODE() WITHIN GROUP (ORDER BY col) Valor mas frecuente (moda)
-- Mediana y cuartiles
SELECT
PERCENTILE_CONT(0.25) WITHIN GROUP (ORDER BY precio) AS p25,
PERCENTILE_CONT(0.50) WITHIN GROUP (ORDER BY precio) AS mediana,
PERCENTILE_CONT(0.75) WITHIN GROUP (ORDER BY precio) AS p75
FROM productos;

FILTER (agregacion condicional, PostgreSQL)

-- Contar/sumar condicionalmente (mas legible que CASE WHEN)
SELECT
COUNT(*) AS total,
COUNT(*) FILTER (WHERE estado = 'completado') AS completados,
COUNT(*) FILTER (WHERE estado = 'pendiente') AS pendientes,
AVG(total) FILTER (WHERE estado = 'completado') AS promedio_completados
FROM pedidos;

GROUPING SETS, ROLLUP y CUBE

-- ROLLUP: subtotales jerarquicos + total general
SELECT COALESCE(categoria, '** TOTAL **') AS categoria,
SUM(cantidad) AS unidades
FROM detalle_pedidos dp JOIN productos pr ON dp.producto_id = pr.id
GROUP BY ROLLUP(pr.categoria);
-- CUBE: todas las combinaciones de subtotales
GROUP BY CUBE(categoria, estado)
-- GROUPING SETS: combinaciones especificas
GROUP BY GROUPING SETS ((categoria, estado), (categoria), (estado), ())

Tablas pivote (filas a columnas)

-- Ventas por cliente como columnas por mes
SELECT c.nombre,
SUM(CASE WHEN EXTRACT(MONTH FROM p.fecha) = 1 THEN p.total ELSE 0 END) AS enero,
SUM(CASE WHEN EXTRACT(MONTH FROM p.fecha) = 2 THEN p.total ELSE 0 END) AS febrero
FROM clientes c JOIN pedidos p ON c.id = p.cliente_id
GROUP BY c.nombre;
-- Version con FILTER (PostgreSQL)
SELECT c.nombre,
SUM(p.total) FILTER (WHERE EXTRACT(MONTH FROM p.fecha) = 1) AS enero,
SUM(p.total) FILTER (WHERE EXTRACT(MONTH FROM p.fecha) = 2) AS febrero
FROM clientes c JOIN pedidos p ON c.id = p.cliente_id
GROUP BY c.nombre;

Metadatos: explorar la estructura

-- Listar todas las tablas
SELECT table_name FROM information_schema.tables
WHERE table_schema = 'public' ORDER BY table_name;
-- Ver columnas de una tabla
SELECT column_name, data_type, is_nullable, column_default
FROM information_schema.columns
WHERE table_name = 'productos' ORDER BY ordinal_position;
-- Ver foreign keys
SELECT tc.table_name, kcu.column_name, ccu.table_name AS ref_table
FROM information_schema.table_constraints tc
JOIN information_schema.key_column_usage kcu ON tc.constraint_name = kcu.constraint_name
JOIN information_schema.constraint_column_usage ccu ON tc.constraint_name = ccu.constraint_name
WHERE tc.constraint_type = 'FOREIGN KEY' AND tc.table_schema = 'public';
-- Tamano de tablas
SELECT relname AS tabla, pg_size_pretty(pg_total_relation_size(oid)) AS tamano
FROM pg_class WHERE relkind = 'r' AND relnamespace = 'public'::regnamespace
ORDER BY pg_total_relation_size(oid) DESC;
-- Buscar columna por nombre
SELECT table_name, column_name, data_type
FROM information_schema.columns
WHERE table_schema = 'public' AND column_name LIKE '%fecha%';
-- Agregar comentario a tabla/columna
COMMENT ON TABLE productos IS 'Catalogo de productos';
COMMENT ON COLUMN productos.precio IS 'Precio sin IVA';

Tipos de datos mas comunes

Comando Descripcion
INTEGER / INT Numeros enteros. Ej: stock, cantidad
SERIAL Entero auto-incremental para IDs
NUMERIC(p,s) / DECIMAL Decimales exactos. Ej: DECIMAL(10,2)
VARCHAR(n) Texto con limite. Ej: VARCHAR(100)
TEXT Texto sin limite
BOOLEAN TRUE / FALSE
DATE Fecha (YYYY-MM-DD)
TIMESTAMP / TIMESTAMPTZ Fecha + hora (con o sin zona horaria)
JSONB JSON binario (permite consultas internas)
UUID Identificador unico universal

EXPLAIN: Guia rapida de lectura

EXPLAIN ANALYZE SELECT ...;
Comando Descripcion
Seq Scan Lee toda la tabla. Normal en tablas chicas, lento en grandes.
Index Scan Usa un indice para encontrar filas. Rapido.
Index Only Scan Todo esta en el indice, no va a la tabla. Lo mas rapido.
Bitmap Index Scan Crea mapa de bits con el indice, luego lee paginas.
Nested Loop Para cada fila de A, busca en B. Bueno con tablas chicas o indices.
Hash Join Crea hash de tabla chica, recorre tabla grande. Bueno sin indice.
Merge Join Ordena ambas tablas y las recorre en paralelo.
Sort Ordena resultados (ORDER BY sin indice).
HashAggregate Agrupacion usando tabla hash (GROUP BY).

Que mirar en el plan:

Seq Scan on productos (cost=0.00..25.00 rows=1000 width=64)
(actual time=0.01..0.32 rows=987 loops=1)
  • cost: Estimado por el planificador (inicio..total)
  • rows: Filas estimadas vs. reales (actual)
  • time: Tiempo real en milisegundos
  • loops: Cuantas veces se ejecuto este nodo

Indices

-- Crear indice simple
CREATE INDEX idx_nombre ON tabla(columna);
-- Indice compuesto (orden importa)
CREATE INDEX idx_nombre ON tabla(col1, col2);
-- Indice unico
CREATE UNIQUE INDEX idx_nombre ON tabla(columna);
-- Indice con INCLUDE (covering index)
CREATE INDEX idx_nombre ON tabla(col_filtro) INCLUDE (col_select);
-- Indice parcial (solo algunas filas)
CREATE INDEX idx_nombre ON tabla(columna) WHERE condicion;
-- Eliminar indice
DROP INDEX idx_nombre;
-- Ver indices existentes
SELECT indexname, indexdef
FROM pg_indexes
WHERE tablename = 'mi_tabla';

Cuando crear indices

  • Columnas frecuentes en WHERE y JOIN ON
  • Columnas en ORDER BY (si usas LIMIT)
  • Columnas en GROUP BY con tablas grandes
  • NO en tablas con pocas filas
  • NO en columnas que se actualizan constantemente

Transacciones

-- Iniciar transaccion
BEGIN;
-- Confirmar cambios (hacerlos permanentes)
COMMIT;
-- Revertir cambios (deshacerlos todos)
ROLLBACK;
-- Punto de guardado parcial
SAVEPOINT nombre;
ROLLBACK TO SAVEPOINT nombre;
RELEASE SAVEPOINT nombre;
-- Nivel de aislamiento
BEGIN;
SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;

Vistas

-- Crear vista
CREATE VIEW nombre AS SELECT ...;
-- Crear o reemplazar
CREATE OR REPLACE VIEW nombre AS SELECT ...;
-- Eliminar vista
DROP VIEW IF EXISTS nombre;
-- Vista materializada (guarda datos en disco)
CREATE MATERIALIZED VIEW nombre AS SELECT ...;
-- Refrescar datos de vista materializada
REFRESH MATERIALIZED VIEW nombre;
-- Refrescar sin bloquear lecturas (requiere indice UNIQUE)
REFRESH MATERIALIZED VIEW CONCURRENTLY nombre;
-- Ver definicion de una vista
SELECT pg_get_viewdef('nombre_vista', true);
-- Listar vistas
\dv -- en psql
\dm -- vistas materializadas en psql

pgvector (bases de datos vectoriales)

-- Activar extension
CREATE EXTENSION IF NOT EXISTS vector;
-- Crear tabla con columna vectorial
CREATE TABLE items (
id SERIAL PRIMARY KEY,
embedding vector(384) -- 384 dimensiones
);
-- Insertar vectores
INSERT INTO items (embedding) VALUES ('[0.1, 0.2, 0.3]');
-- Busqueda por similitud (distancia coseno, la mas usada)
SELECT * FROM items ORDER BY embedding <=> '[0.1, 0.2, 0.3]' LIMIT 5;
-- Distancia euclidiana (L2)
SELECT * FROM items ORDER BY embedding <-> '[0.1, 0.2, 0.3]' LIMIT 5;
-- Producto interno negativo
SELECT * FROM items ORDER BY embedding <#> '[0.1, 0.2, 0.3]' LIMIT 5;
-- Indice HNSW (mejor precision)
CREATE INDEX idx_hnsw ON items
USING hnsw (embedding vector_cosine_ops);
-- Indice IVFFlat (menos memoria)
CREATE INDEX idx_ivf ON items
USING ivfflat (embedding vector_cosine_ops)
WITH (lists = 100);

Tablas de la base de datos de practica

-- productos
-- id | nombre | precio | categoria | stock | fecha_creacion
-- clientes
-- id | nombre | email | ciudad | fecha_registro
-- pedidos
-- id | cliente_id | fecha | total | estado
-- detalle_pedidos
-- id | pedido_id | producto_id | cantidad | precio_unitario
-- empleados
-- id | nombre | departamento | salario | fecha_contratacion | jefe_id
Siguiente 1. Introduccion a SQL