Skip to content

SQL Hoja de referencia

Lenguaje estándar para gestionar y consultar bases de datos relacionales.

01

SELECT y fundamentos de consultas

SELECT, WHERE y ORDER BY

SELECT recupera filas de una o más tablas. Siempre especifica columnas explícitamente en lugar de * para rendimiento y claridad (los cambios de esquema no romperán tu app). WHERE filtra filas antes de agrupar. ORDER BY ordena resultados (ASC default, DESC descendente). LIMIT/OFFSET implementan paginación — para datasets grandes, prefiere keyset pagination (WHERE id > last_id).

sql
-- basic query: select specific columns
SELECT id, name, email
FROM users
WHERE age >= 18 AND status = 'active'
ORDER BY name ASC, created_at DESC
LIMIT 10 OFFSET 0;

-- select all columns (avoid in production)
SELECT * FROM products;

-- column aliases with AS
SELECT name AS product_name, price * 1.1 AS price_with_tax
FROM products;

DISTINCT y aliases

DISTINCT elimina filas duplicadas del result set. Opera sobre la fila entera, no columnas individuales — SELECT DISTINCT city, country devuelve pares city+country únicos. Los aliases de tabla (u, o) acortan consultas y se requieren al unir una tabla consigo misma. Los aliases de columna renombran columnas de salida para legibilidad.

sql
-- unique values only
SELECT DISTINCT country FROM users;
SELECT DISTINCT city, country FROM users;  -- unique combos

-- table aliases (essential for joins)
SELECT u.name, o.total
FROM users AS u
JOIN orders AS o ON u.id = o.user_id;

-- column alias (AS is optional)
SELECT name product_name, COUNT(*) count
FROM products
GROUP BY name;

Filtrado: BETWEEN, IN, IS NULL

BETWEEN es inclusivo en ambos extremos. IN coincide cualquier valor en una lista o subquery. NULL requiere IS NULL / IS NOT NULL (no se puede usar = NULL). Ten cuidado con NOT IN y subqueries — si la subquery devuelve algún NULL, NOT IN no devuelve filas. Usa NOT EXISTS en su lugar, que maneja NULLs correctamente y a menudo es más rápido.

sql
SELECT * FROM products
WHERE price BETWEEN 10 AND 100        -- inclusive range
  AND category IN ('tech', 'home')    -- match any value
  AND stock IS NOT NULL               -- exclude NULLs
  AND discount IS NULL;               -- only NULLs

-- NOT IN with NULL caveat: returns nothing if subquery has NULL!
SELECT * FROM users
WHERE id NOT IN (SELECT user_id FROM orders);  -- risky if NULLs

-- safer: NOT EXISTS
SELECT * FROM users u
WHERE NOT EXISTS (
  SELECT 1 FROM orders o WHERE o.user_id = u.id
);

LIKE y pattern matching

LIKE usa % (cero o más caracteres) y _ (exactamente un carácter) como wildcards. LIKE es case-sensitive en la mayoría de bases de datos excepto MySQL (case-insensitive por defecto). Usa ILIKE en PostgreSQL para matching case-insensitive. Para patrones complejos, usa regex (~ en PostgreSQL, REGEXP en MySQL). LIKE con un % inicial no puede usar índices — considera full-text search para rendimiento.

sql
-- LIKE: basic pattern matching
SELECT * FROM users WHERE name LIKE 'A%';     -- starts with A
SELECT * FROM users WHERE name LIKE '%son';    -- ends with son
SELECT * FROM users WHERE name LIKE '%a%';     -- contains a
SELECT * FROM users WHERE name LIKE '_a%';     -- second char is a

-- ILIKE (PostgreSQL): case-insensitive
SELECT * FROM users WHERE name ILIKE 'a%';

-- SIMILAR TO (PostgreSQL): regex-like
SELECT * FROM users WHERE name SIMILAR TO '[AB]%';

-- full regex (PostgreSQL)
SELECT * FROM users WHERE name ~ '^A[a-z]+$';

Expresiones CASE

CASE es el if-then-else de SQL, evaluado por fila. Puede aparecer en SELECT, WHERE, ORDER BY y HAVING. El patrón 'pivot' (SUM de CASE) transforma filas en columnas — útil para reportes. CASE devuelve NULL si ningún WHEN coincide y no hay ELSE. Siempre incluye ELSE para resultados predecibles.

sql
-- conditional logic in queries
SELECT
  name,
  price,
  CASE
    WHEN price < 10 THEN 'cheap'
    WHEN price < 50 THEN 'moderate'
    WHEN price < 100 THEN 'expensive'
    ELSE 'luxury'
  END AS price_category
FROM products;

-- CASE in aggregation (pivot table)
SELECT
  category,
  COUNT(*) AS total,
  SUM(CASE WHEN status = 'active' THEN 1 ELSE 0 END) AS active_count,
  SUM(CASE WHEN status = 'inactive' THEN 1 ELSE 0 END) AS inactive_count
FROM products
GROUP BY category;
02

JOINs

INNER JOIN

INNER JOIN devuelve solo filas que tienen coincidencias en ambas tablas. JOIN es abreviatura de INNER JOIN. Para consultas multi-tabla, une tablas paso a paso. ON especifica la condición de join; USING(columna) es abreviatura cuando ambas tablas tienen la misma columna. Los inner joins excluyen filas no coincidentes de ambos lados.

sql
-- only matching rows from both tables
SELECT u.name, o.total, o.created_at
FROM users u
INNER JOIN orders o ON u.id = o.user_id
WHERE o.total > 100
ORDER BY o.total DESC;

-- multiple joins
SELECT u.name, o.total, p.product_name
FROM users u
JOIN orders o ON u.id = o.user_id
JOIN order_items oi ON o.id = oi.order_id
JOIN products p ON oi.product_id = p.id;

-- USING (when column names match)
SELECT * FROM users
JOIN profiles ON users.id = profiles.user_id;

LEFT JOIN (LEFT OUTER JOIN)

LEFT JOIN devuelve TODAS las filas de la tabla izquierda, con NULLs para filas derechas no coincidentes. Esto es esencial para consultas 'include everything'. El patrón anti-join (WHERE right.id IS NULL) encuentra filas en la tabla izquierda sin coincidencia en la derecha — útil para 'usuarios que no han pedido'. COUNT(right.id) cuenta valores no-NULL, así que devuelve 0 para usuarios sin pedidos.

sql
-- all users, with their orders (NULL if no orders)
SELECT u.name, o.total
FROM users u
LEFT JOIN orders o ON u.id = o.user_id
ORDER BY u.name;

-- find users with NO orders (anti-join pattern)
SELECT u.name
FROM users u
LEFT JOIN orders o ON u.id = o.user_id
WHERE o.id IS NULL;

-- count orders per user (including zero)
SELECT u.name, COUNT(o.id) AS order_count
FROM users u
LEFT JOIN orders o ON u.id = o.user_id
GROUP BY u.id, u.name;

RIGHT y FULL OUTER JOIN

RIGHT JOIN devuelve todas las filas de la tabla derecha; es equivalente a intercambiar tablas y usar LEFT JOIN (que es más legible). FULL OUTER JOIN devuelve todas las filas de ambas tablas, con NULLs donde no hay coincidencia — útil para reconciliación de datos. MySQL no soporta FULL OUTER JOIN directamente; emúlalo con LEFT JOIN UNION RIGHT JOIN.

sql
-- RIGHT JOIN: all rows from right table
SELECT u.name, o.total
FROM users u
RIGHT JOIN orders o ON u.id = o.user_id;
-- returns all orders, even orphaned ones (user_id = NULL)

-- FULL OUTER JOIN: all rows from both tables
SELECT u.name, o.total
FROM users u
FULL OUTER JOIN orders o ON u.id = o.user_id;
-- returns all users AND all orders, matching where possible

-- Note: RIGHT JOIN is rarely used (just swap tables and use LEFT)
-- FULL OUTER JOIN is useful for finding mismatches between tables
SELECT u.name, o.total
FROM users u
FULL OUTER JOIN orders o ON u.id = o.user_id
WHERE u.id IS NULL OR o.user_id IS NULL;

CROSS JOIN y self join

CROSS JOIN produce un producto cartesiano — cada fila de A emparejada con cada fila de B. Úsalo para generar combinaciones (tamaños × colores). Los self joins (unir una tabla consigo misma) son comunes para datos jerárquicos (empleado-gerente), encontrar duplicados o comparar filas dentro de la misma tabla. Siempre usa aliases de tabla en self joins para distinguir las dos 'copias'.

sql
-- CROSS JOIN: Cartesian product (every combination)
SELECT s.size, c.color
FROM sizes s
CROSS JOIN colors c;
-- produces all size+color combinations

-- Self join: join a table to itself
SELECT e.name AS employee, m.name AS manager
FROM employees e
LEFT JOIN employees m ON e.manager_id = m.id;

-- self join for hierarchical data
SELECT c.name AS child, p.name AS parent
FROM categories c
JOIN categories p ON c.parent_id = p.id;

-- self join to find duplicates
SELECT a.id, a.email, b.id AS dup_id
FROM users a
JOIN users b ON a.email = b.email AND a.id < b.id;

NATURAL JOIN y resumen de tipos de JOIN

NATURAL JOIN une automáticamente sobre columnas con el mismo nombre — conveniente pero peligroso porque los cambios de esquema pueden cambiar silenciosamente el comportamiento del join. Evítalo en producción. Los LATERAL joins permiten que una subquery referencie columnas de la query externa — potente para consultas 'top N por grupo'. La sintaxis de coma (FROM a, b) es equivalente a CROSS JOIN.

sql
-- NATURAL JOIN: joins on all matching column names
-- (rarely recommended — implicit, fragile)
SELECT * FROM users NATURAL JOIN profiles;
-- joins on any column that exists in BOTH tables

-- Summary of join types:
-- INNER JOIN : matching rows only
-- LEFT JOIN  : all left + matching right
-- RIGHT JOIN : all right + matching left
-- FULL JOIN  : all from both sides
-- CROSS JOIN : Cartesian product
-- SELF JOIN  : table joined to itself

-- LATERAL JOIN (PostgreSQL): subquery can reference outer query
SELECT u.name, recent.*
FROM users u,
LATERAL (
  SELECT * FROM orders o
  WHERE o.user_id = u.id
  ORDER BY o.created_at DESC
  LIMIT 3
) recent;
03

GROUP BY y agregación

GROUP BY y HAVING

GROUP BY colapsa filas en grupos, una fila por grupo. Las funciones agregadas (COUNT, SUM, AVG, MIN, MAX) operan en cada grupo. WHERE filtra filas individuales ANTES de agrupar; HAVING filtra grupos DESPUÉS de la agregación. Las columnas no agregadas en SELECT deben aparecer en GROUP BY (SQL estándar). MySQL es permisivo pero impredecible — siempre incluye todas las columnas no agregadas en GROUP BY.

sql
-- aggregate per group
SELECT category, COUNT(*) AS cnt, AVG(price) AS avg_price
FROM products
GROUP BY category
HAVING COUNT(*) > 5
ORDER BY cnt DESC;

-- HAVING filters groups (after aggregation)
-- WHERE filters rows (before aggregation)
SELECT dept, AVG(salary) AS avg_sal
FROM employees
WHERE status = 'active'       -- filter rows first
GROUP BY dept
HAVING AVG(salary) > 50000;   -- then filter groups

Funciones agregadas

COUNT(*) cuenta todas las filas incluyendo NULLs; COUNT(columna) cuenta solo valores no-NULL. COUNT(DISTINCT col) cuenta valores únicos. SUM/AVG ignoran NULLs. AVG = SUM/COUNT(no-NULL), así que los NULLs afectan el promedio. STRING_AGG (PostgreSQL) / GROUP_CONCAT (MySQL) concatenan strings por grupo. BOOL_OR/BOOL_AND devuelven true si alguno/todos los valores son true.

sql
SELECT
  COUNT(*) AS total_rows,           -- counts all rows
  COUNT(email) AS emails_filled,    -- counts non-NULL emails
  COUNT(DISTINCT country) AS countries,
  SUM(amount) AS total_revenue,
  AVG(amount) AS avg_order,
  MIN(amount) AS smallest_order,
  MAX(amount) AS largest_order,
  -- string aggregation (PostgreSQL)
  STRING_AGG(name, ', ') AS all_names,
  -- boolean aggregation
  BOOL_OR(is_active) AS any_active,
  BOOL_AND(is_active) AS all_active
FROM orders;

GROUP BY con múltiples columnas

Agrupar por múltiples columnas crea una jerarquía de grupos. WITH ROLLUP añade filas de subtotal y total general (NULL en la columna agrupada). GROUPING SETS te permiten especificar exactamente qué combinaciones de agrupación quieres — más flexible que ROLLUP. CUBE genera todas las combinaciones de agrupación posibles. Estos son esenciales para reportes y consultas OLAP.

sql
-- multi-level grouping
SELECT
  EXTRACT(YEAR FROM created_at) AS yr,
  EXTRACT(MONTH FROM created_at) AS mon,
  category,
  COUNT(*) AS cnt,
  SUM(total) AS revenue
FROM orders
GROUP BY yr, mon, category
ORDER BY yr DESC, mon DESC, cnt DESC;

-- GROUP BY with ROLLUP (subtotals + grand total)
SELECT category, COUNT(*) AS cnt
FROM products
GROUP BY category WITH ROLLUP;
-- last row has NULL category = grand total

-- GROUPING SETS (PostgreSQL): specify multiple groupings
SELECT category, status, COUNT(*)
FROM products
GROUP BY GROUPING SETS ((category, status), (category), ());

HAVING vs WHERE

La distinción clave: WHERE filtra filas individuales antes de la agregación (no puede usar SUM, COUNT, etc.), mientras que HAVING filtra grupos después de la agregación (puede usar funciones agregadas). Usa WHERE para reducir los datos temprano (mejor rendimiento), luego HAVING para filtrar los resultados agregados. Ambos pueden aparecer en la misma consulta — WHERE primero, luego GROUP BY, luego HAVING.

sql
-- WHERE: filters rows BEFORE grouping
-- Cannot use aggregates
SELECT category, COUNT(*) AS cnt
FROM products
WHERE price > 10          -- OK: filter on raw column
GROUP BY category;

-- HAVING: filters groups AFTER grouping
-- Can use aggregates
SELECT category, COUNT(*) AS cnt
FROM products
GROUP BY category
HAVING COUNT(*) > 5       -- OK: filter on aggregate
   AND AVG(price) > 20;   -- OK: multiple aggregate filters

-- combining both
SELECT category, COUNT(*) AS cnt
FROM products
WHERE price > 10          -- filter rows
GROUP BY category         -- group
HAVING COUNT(*) > 5;      -- filter groups

Agregación de fecha/hora

La truncación de fecha es esencial para reportes de series temporales. DATE(col) extrae solo la fecha; EXTRACT/TIME_PART obtiene componentes específicos (año, mes, hora). TO_CHAR formatea fechas para agrupación y visualización. Para análisis de series temporales, considera DATE_TRUNC('month', col) que mantiene el tipo timestamp. Indexa columnas de fecha para rendimiento en tablas grandes.

sql
-- group by date parts
SELECT
  DATE(created_at) AS order_date,
  COUNT(*) AS orders,
  SUM(total) AS revenue
FROM orders
GROUP BY DATE(created_at)
ORDER BY order_date DESC;

-- group by hour
SELECT
  EXTRACT(HOUR FROM created_at) AS hr,
  COUNT(*) AS cnt
FROM orders
WHERE created_at >= CURRENT_DATE
GROUP BY hr
ORDER BY hr;

-- monthly revenue trend
SELECT
  TO_CHAR(created_at, 'YYYY-MM') AS month,
  SUM(total) AS revenue,
  COUNT(*) AS orders,
  AVG(total) AS avg_order
FROM orders
WHERE created_at >= '2024-01-01'
GROUP BY month
ORDER BY month;
04

Subconsultas y CTEs

Subconsultas escalares y de columna

Las subqueries escalares devuelven un solo valor y pueden usarse en cualquier lugar donde se espere un valor. Las subqueries de columna devuelven una columna y se usan con IN, ANY, ALL. Las subqueries en SELECT (correlacionadas) se ejecutan una vez por fila externa — pueden ser lentas en datasets grandes. Considera reescribir como un JOIN con GROUP BY para mejor rendimiento.

sql
-- scalar subquery (returns single value)
SELECT name, age
FROM users
WHERE age > (SELECT AVG(age) FROM users);

-- column subquery (returns one column, multiple rows)
SELECT name
FROM users
WHERE id IN (SELECT user_id FROM orders WHERE total > 100);

-- subquery in SELECT
SELECT
  u.name,
  (SELECT COUNT(*) FROM orders o WHERE o.user_id = u.id) AS order_count
FROM users u;

Subconsultas correlacionadas y EXISTS

Las subqueries correlacionadas referencian la query externa y se ejecutan una vez por fila externa — potencialmente lentas. EXISTS/NOT EXISTS son eficientes porque short-circuit (se detienen en la primera coincidencia). NOT EXISTS es la forma preferida de encontrar 'filas sin filas coincidentes' — maneja NULLs correctamente y a menudo es más rápido que NOT IN. La base de datos puede optimizar subqueries correlacionadas en joins.

sql
-- correlated: subquery references outer query
SELECT u.name
FROM users u
WHERE EXISTS (
  SELECT 1 FROM orders o
  WHERE o.user_id = u.id
    AND o.total > 1000
);

-- NOT EXISTS: users without any orders
SELECT u.name
FROM users u
WHERE NOT EXISTS (
  SELECT 1 FROM orders o WHERE o.user_id = u.id
);

-- correlated subquery in SELECT (runs per row)
SELECT
  u.name,
  (SELECT MAX(o.total)
   FROM orders o
   WHERE o.user_id = u.id) AS max_order
FROM users u;

Common Table Expressions (CTE)

Las CTEs (cláusula WITH) crean conjuntos de resultados temporales con nombre que hacen las consultas complejas legibles. A diferencia de las subqueries, las CTEs pueden referenciarse múltiples veces y leerse de arriba a abajo. En la mayoría de bases de datos, las CTEs se inlinean (la optimización ocurre a nivel de query). PostgreSQL 12+ soporta hints MATERIALIZED/NOT MATERIALIZED. Las CTEs también se requieren para consultas recursivas.

sql
-- CTE: named temporary result set
WITH active_users AS (
  SELECT id, name FROM users WHERE status = 'active'
),
user_orders AS (
  SELECT user_id, COUNT(*) AS cnt, SUM(total) AS revenue
  FROM orders
  GROUP BY user_id
)
SELECT au.name, uo.cnt, uo.revenue
FROM active_users au
LEFT JOIN user_orders uo ON au.id = uo.user_id
ORDER BY uo.revenue DESC NULLS LAST;

-- CTEs improve readability for complex queries
-- They are NOT materialized (just syntactic sugar) in most DBs
-- (PostgreSQL 12+ can materialize with MATERIALIZED keyword)

CTE recursiva

Las CTEs recursivas se referencian a sí mismas, habilitando recorrido de árbol/grafos y generación de secuencias. Estructura: caso base UNION ALL caso recursivo. El caso recursivo referencia la CTE y debe terminar (añade un WHERE para prevenir loops infinitos). Usos comunes: organigramas, árboles de categorías, grafos de dependencias, secuencias de fechas. Cada base de datos tiene sintaxis ligeramente diferente — revisa los docs de tu DBMS.

sql
-- hierarchical data: org chart
WITH RECURSIVE org_tree AS (
  -- base case: top-level managers
  SELECT id, name, manager_id, 1 AS level
  FROM employees
  WHERE manager_id IS NULL

  UNION ALL

  -- recursive case: direct reports
  SELECT e.id, e.name, e.manager_id, ot.level + 1
  FROM employees e
  JOIN org_tree ot ON e.manager_id = ot.id
)
SELECT level, name FROM org_tree ORDER BY level, name;

-- generate a series of dates
WITH RECURSIVE dates AS (
  SELECT DATE '2024-01-01' AS d
  UNION ALL
  SELECT d + 1 FROM dates WHERE d < '2024-01-31'
)
SELECT d FROM dates;

-- factorial
WITH RECURSIVE fact(n, result) AS (
  SELECT 1, 1
  UNION ALL
  SELECT n + 1, result * (n + 1) FROM fact WHERE n < 10
)
SELECT * FROM fact;

Operadores de subconsulta: ANY, ALL

ANY y ALL comparan un valor contra un result set de subquery. > ANY significa 'mayor que al menos uno'. > ALL significa 'mayor que todos'. = ANY es equivalente a IN. <> ALL es equivalente a NOT IN pero maneja NULLs de forma más segura. Estos operadores se usan menos comúnmente que IN/EXISTS pero pueden expresar ciertas consultas más naturalmente.

sql
-- ANY: greater than ANY of the values (= at least one)
SELECT * FROM products
WHERE price > ANY (
  SELECT price FROM products WHERE category = 'tech'
);
-- true if price exceeds at least one tech product's price

-- ALL: greater than ALL values (= every one)
SELECT * FROM products
WHERE price > ALL (
  SELECT price FROM products WHERE category = 'tech'
);
-- true if price exceeds every tech product's price

-- = ANY is equivalent to IN
SELECT * FROM users
WHERE id = ANY (SELECT user_id FROM orders);

-- <> ALL is equivalent to NOT IN (but NULL-safe)
SELECT * FROM users
WHERE id <> ALL (SELECT user_id FROM orders WHERE total < 0);
05

Funciones de ventana

ROW_NUMBER, RANK, DENSE_RANK

ROW_NUMBER asigna números secuenciales únicos (1, 2, 3...). RANK da el mismo rank a los empates pero salta números subsiguientes (1, 1, 3). DENSE_RANK da el mismo rank a los empates sin saltar (1, 1, 2). PARTITION BY divide filas en grupos; la función se resetea por partición. El patrón 'top N por grupo' (ROW_NUMBER + WHERE rn <= N) es extremadamente común en analítica.

sql
SELECT
  name,
  salary,
  dept,
  ROW_NUMBER() OVER (ORDER BY salary DESC) AS row_num,
  RANK() OVER (ORDER BY salary DESC) AS rank,
  DENSE_RANK() OVER (ORDER BY salary DESC) AS dense_rank
FROM employees;

-- top 3 earners per department
SELECT * FROM (
  SELECT
    name,
    dept,
    salary,
    ROW_NUMBER() OVER (PARTITION BY dept ORDER BY salary DESC) AS rn
  FROM employees
) ranked
WHERE rn <= 3;

LAG y LEAD

LAG accede al valor de una fila anterior; LEAD accede al valor de una fila futura. Ambos aceptan un offset opcional (default 1) y un valor default (default NULL). Esenciales para análisis de series temporales: cambios día a día, comparaciones móviles, detección de gaps. NULLIF previene división por cero en cálculos de porcentaje. Siempre especifica ORDER BY en la cláusula OVER para resultados deterministas.

sql
-- compare each row to previous/next
SELECT
  date,
  revenue,
  LAG(revenue) OVER (ORDER BY date) AS prev_day,
  LEAD(revenue) OVER (ORDER BY date) AS next_day,
  revenue - LAG(revenue) OVER (ORDER BY date) AS daily_change,
  ROUND(
    (revenue - LAG(revenue) OVER (ORDER BY date)) * 100.0
    / NULLIF(LAG(revenue) OVER (ORDER BY date), 0),
    2
  ) AS pct_change
FROM daily_sales
ORDER BY date;

-- LAG with offset and default
SELECT
  date,
  revenue,
  LAG(revenue, 7) OVER (ORDER BY date) AS revenue_7_days_ago
FROM daily_sales;

Totales acumulados y medias móviles

Los window frames definen sobre qué filas opera la función. ROWS BETWEEN usa offsets de filas físicas; RANGE usa rangos de valores lógicos (mejor para gaps de fechas). UNBOUNDED PRECEDING significa 'desde el inicio'. Los totales acumulados (SUM acumulativo) y las medias móviles son los patrones analíticos más comunes. Sin un frame, las funciones agregadas de ventana usan el default: RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW.

sql
-- cumulative sum (running total)
SELECT
  date,
  revenue,
  SUM(revenue) OVER (ORDER BY date) AS running_total,
  SUM(revenue) OVER (ORDER BY date
    ROWS BETWEEN 6 PRECEDING AND CURRENT ROW) AS last_7_days_sum,
  AVG(revenue) OVER (ORDER BY date
    ROWS BETWEEN 6 PRECEDING AND CURRENT ROW) AS moving_avg_7d
FROM daily_sales
ORDER BY date;

-- window frame options:
-- ROWS BETWEEN n PRECEDING AND n FOLLOWING  -- physical rows
-- RANGE BETWEEN INTERVAL '7' DAY PRECEDING AND CURRENT ROW -- logical range
-- ROWS UNBOUNDED PRECEDING = from start to current
-- ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING = all rows

NTILE y PERCENT_RANK

NTILE(n) divide filas ordenadas en n grupos aproximadamente iguales (cuartiles, deciles, percentiles). PERCENT_RANK da el rank relativo (0 a 1). CUME_DIST da la distribución acumulada. FIRST_VALUE/LAST_VALUE devuelven valores de la primera/última fila en el frame — nota que LAST_VALUE necesita un frame explícito (UNBOUNDED FOLLOWING) porque el frame default termina en la fila actual.

sql
-- divide into quartiles
SELECT
  name,
  salary,
  NTILE(4) OVER (ORDER BY salary DESC) AS quartile,
  PERCENT_RANK() OVER (ORDER BY salary) AS pct_rank,
  CUME_DIST() OVER (ORDER BY salary) AS cumulative_dist
FROM employees;

-- first/last value in a partition
SELECT
  dept,
  name,
  salary,
  FIRST_VALUE(name) OVER (PARTITION BY dept ORDER BY salary DESC) AS top_earner,
  LAST_VALUE(name) OVER (
    PARTITION BY dept ORDER BY salary DESC
    ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
  ) AS lowest_earner
FROM employees;

Funciones agregadas de ventana

Los agregados de ventana (SUM, AVG, COUNT, etc. con OVER) computan valores agregados SIN colapsar filas — cada fila obtiene el agregado adjunto. Esta es la diferencia clave con GROUP BY: mantienes todas las filas de detalle mientras también ves el resumen. Perfecto para comparar valores individuales con promedios de grupo, calcular porcentajes y añadir columnas de contexto a reportes de detalle.

sql
-- aggregates over windows (no row collapse!)
SELECT
  name,
  dept,
  salary,
  -- compare to department average
  AVG(salary) OVER (PARTITION BY dept) AS dept_avg,
  salary - AVG(salary) OVER (PARTITION BY dept) AS diff_from_avg,
  -- percentage of department total
  salary * 100.0 / SUM(salary) OVER (PARTITION BY dept) AS pct_of_dept,
  -- count per department
  COUNT(*) OVER (PARTITION BY dept) AS dept_size
FROM employees
ORDER BY dept, salary DESC;

-- key advantage: aggregates without GROUP BY
-- every row is preserved, with the aggregate value attached
06

DDL: tablas y esquema

CREATE TABLE y tipos de datos

CREATE TABLE define el esquema. SERIAL (PostgreSQL) / AUTO_INCREMENT (MySQL) auto-genera IDs. VARCHAR(n) tiene un límite; TEXT es ilimitado. DECIMAL(p,s) es exacto (¡úsalo para dinero!), FLOAT es aproximado. Las restricciones CHECK aplican reglas de negocio. DEFAULT proporciona valores cuando no se especifica. JSONB (PostgreSQL) habilita consultas JSON indexadas. Siempre usa TIMESTAMP WITH TIME ZONE para timestamps que cruzan zonas horarias.

sql
CREATE TABLE users (
  id          SERIAL PRIMARY KEY,          -- auto-increment (PostgreSQL)
  -- MySQL: id INT AUTO_INCREMENT PRIMARY KEY
  username    VARCHAR(50) UNIQUE NOT NULL,
  email       VARCHAR(255) UNIQUE NOT NULL,
  password    VARCHAR(255) NOT NULL,
  age         INT CHECK (age >= 0 AND age <= 150),
  salary      DECIMAL(10, 2) DEFAULT 0.00, -- precision, scale
  bio         TEXT,                        -- unlimited length
  avatar      BYTEA,                       -- binary (PostgreSQL)
  metadata    JSONB,                       -- JSON (PostgreSQL)
  status      VARCHAR(20) DEFAULT 'active',
  created_at  TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
  updated_at  TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);

-- common types:
-- INT, BIGINT, SMALLINT, DECIMAL(p,s), NUMERIC
-- VARCHAR(n), CHAR(n), TEXT
-- DATE, TIME, TIMESTAMP, INTERVAL
-- BOOLEAN, UUID, JSON/JSONB, BYTEA/BLOB

Restricciones: PRIMARY, FOREIGN, UNIQUE, CHECK

Las restricciones aplican integridad de datos a nivel de base de datos. PRIMARY KEY identifica filas únicamente (implica NOT NULL + UNIQUE). FOREIGN KEY mantiene integridad referencial — ON DELETE CASCADE elimina hijos cuando el padre se borra. UNIQUE previene duplicados. CHECK aplica reglas personalizadas. Definir restricciones en la base de datos (no solo en código de app) asegura integridad independientemente de cómo se accedan los datos.

sql
CREATE TABLE orders (
  id          SERIAL PRIMARY KEY,
  user_id     INT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  total       DECIMAL(10,2) NOT NULL CHECK (total >= 0),
  status      VARCHAR(20) NOT NULL DEFAULT 'pending',
  created_at  TIMESTAMP DEFAULT CURRENT_TIMESTAMP,

  -- table-level constraints
  CONSTRAINT valid_status CHECK (status IN ('pending','paid','shipped','cancelled')),
  CONSTRAINT unique_user_order UNIQUE (user_id, created_at)
);

-- foreign key actions:
-- ON DELETE CASCADE  : delete child rows when parent deleted
-- ON DELETE SET NULL : set FK to NULL (column must be nullable)
-- ON DELETE RESTRICT : prevent parent deletion (default)
-- ON UPDATE CASCADE  : update FK when parent PK changes

-- add constraint to existing table
ALTER TABLE orders ADD CONSTRAINT fk_user FOREIGN KEY (user_id) REFERENCES users(id);

ALTER TABLE

ALTER TABLE modifica la estructura de tabla existente. Añadir columnas con defaults suele ser rápido (PostgreSQL 11+ no reescribe la tabla). Eliminar columnas puede bloquear la tabla. Cambiar tipos de columna puede requerir una reescritura completa de tabla y puede fallar si los datos no convierten. Siempre prueba migraciones de esquema en una copia primero. Usa herramientas de migración (Flyway, Alembic, Rails migrations) para cambios de esquema versionados.

sql
-- add column
ALTER TABLE users ADD COLUMN phone VARCHAR(20);
ALTER TABLE users ADD COLUMN verified BOOLEAN DEFAULT false;

-- drop column
ALTER TABLE users DROP COLUMN avatar;

-- rename column/table
ALTER TABLE users RENAME COLUMN username TO login;
ALTER TABLE users RENAME TO accounts;

-- change column type
ALTER TABLE users ALTER COLUMN age TYPE BIGINT;
-- MySQL: ALTER TABLE users MODIFY COLUMN age BIGINT;

-- add/drop constraints
ALTER TABLE users ADD CONSTRAINT email_unique UNIQUE (email);
ALTER TABLE users DROP CONSTRAINT email_unique;

-- set default
ALTER TABLE users ALTER COLUMN status SET DEFAULT 'active';

DROP, TRUNCATE e índices

DROP TABLE elimina la tabla completamente; TRUNCATE la vacía pero mantiene la estructura (mucho más rápido que DELETE, resetea identity). Los índices aceleran consultas pero ralentizan escrituras — indexa estratégicamente. Los índices compuestos funcionan izquierda-a-derecha: idx(a,b,c) ayuda WHERE a=?, WHERE a=? AND b=?, pero NO WHERE b=?. Los índices GIN habilitan full-text search. Los partial indexes ahorran espacio indexando solo filas coincidentes.

sql
-- DROP: permanently remove table (structure + data)
DROP TABLE IF EXISTS old_logs CASCADE;
-- CASCADE drops dependent objects (views, FKs)

-- TRUNCATE: remove all data, keep structure (faster than DELETE)
TRUNCATE TABLE logs;
TRUNCATE TABLE logs RESTART IDENTITY;  -- reset SERIAL counter
TRUNCATE TABLE orders, order_items CASCADE;  -- multiple tables

-- CREATE INDEX for query performance
CREATE INDEX idx_users_email ON users(email);
CREATE UNIQUE INDEX idx_users_username ON users(username);
CREATE INDEX idx_orders_user_date ON orders(user_id, created_at);
CREATE INDEX idx_products_name ON products USING gin(to_tsvector('english', name));

-- partial index (PostgreSQL)
CREATE INDEX idx_active_users ON users(last_login)
WHERE status = 'active';

-- DROP INDEX
DROP INDEX IF EXISTS idx_users_email;

Views y materialized views

Las views son consultas guardadas que actúan como tablas virtuales — ejecutan la query subyacente cada vez. Usa views para simplificar consultas complejas, aplicar seguridad (acceso a nivel de columna) y proporcionar APIs estables. Las materialized views almacenan los resultados reales — más rápidas de consultar pero deben refrescarse. Usa materialized views para agregaciones costosas que no necesitan datos en tiempo real. CONCURRENTLY refresca sin locking (PostgreSQL).

sql
-- VIEW: stored query (virtual table, runs on access)
CREATE VIEW active_users AS
SELECT id, name, email FROM users WHERE status = 'active';

SELECT * FROM active_users WHERE name LIKE 'A%';

-- updatable view (simple views can be INSERTed/UPDATEd)
CREATE VIEW user_summary AS
SELECT id, name, email, age FROM users;

-- MATERIALIZED VIEW: stored result (must refresh)
CREATE MATERIALIZED VIEW monthly_stats AS
SELECT
  DATE_TRUNC('month', created_at) AS month,
  COUNT(*) AS orders,
  SUM(total) AS revenue
FROM orders
GROUP BY month;

REFRESH MATERIALIZED VIEW monthly_stats;
REFRESH MATERIALIZED VIEW CONCURRENTLY monthly_stats; -- no lock

-- drop
DROP VIEW IF EXISTS active_users;
DROP MATERIALIZED VIEW IF EXISTS monthly_stats;
07

DML: insert, update, delete

INSERT

INSERT añade filas. Múltiples VALUES en un statement son más eficientes que inserts separados. INSERT...SELECT copia datos entre tablas. RETURNING (PostgreSQL/Oracle) recupera valores auto-generados (como SERIAL ids) en un round trip — esencial para código de aplicación. Usa DEFAULT VALUES para insertar una fila con todos los defaults. Siempre especifica nombres de columna para hacer tu código resiliente a cambios de esquema.

sql
-- single row
INSERT INTO users (name, email, age)
VALUES ('Alice', '[email protected]', 30);

-- multiple rows
INSERT INTO users (name, email) VALUES
  ('Bob', '[email protected]'),
  ('Carol', '[email protected]'),
  ('Dave', '[email protected]');

-- INSERT ... SELECT (copy data between tables)
INSERT INTO archive_users (name, email, deleted_at)
SELECT name, email, NOW()
FROM users
WHERE status = 'deleted';

-- INSERT with RETURNING (PostgreSQL)
INSERT INTO users (name, email)
VALUES ('Eve', '[email protected]')
RETURNING id, created_at;  -- returns the generated id

-- DEFAULT values
INSERT INTO users DEFAULT VALUES;

UPDATE

UPDATE modifica filas existentes. SIEMPRE incluye una cláusula WHERE a menos que pretendas actualizar cada fila. La cláusula FROM (PostgreSQL) permite joins en updates. RETURNING muestra qué filas fueron modificadas. Usa transacciones para updates multi-paso para poder ROLLBACK si algo sale mal. Un error común es olvidar WHERE — considera ejecutar un SELECT con el mismo WHERE primero para verificar las filas afectadas.

sql
-- basic update
UPDATE users
SET age = 31, status = 'verified', updated_at = NOW()
WHERE id = 1;

-- update based on another table
UPDATE products p
SET price = p.price * 1.1
FROM categories c
WHERE p.category_id = c.id AND c.name = 'electronics';

-- update with subquery
UPDATE users
SET status = 'premium'
WHERE id IN (
  SELECT user_id FROM orders
  GROUP BY user_id HAVING SUM(total) > 1000
);

-- UPDATE with RETURNING (PostgreSQL)
UPDATE users SET status = 'inactive'
WHERE last_login < '2023-01-01'
RETURNING id, name;

-- WARNING: UPDATE without WHERE affects ALL rows!

DELETE y TRUNCATE

DELETE elimina filas una a la vez (logueado, puede hacer rollback, más lento). TRUNCATE elimina todas las filas a la vez (logging mínimo, mucho más rápido, resetea auto-increment, no puede hacer rollback en algunas DBs). Para audit trails, usa soft deletes (un timestamp deleted_at) en lugar de hard deletes. Siempre usa WHERE con DELETE. Considera las restricciones de foreign key — ON DELETE CASCADE maneja filas hijas automáticamente.

sql
-- delete specific rows
DELETE FROM users WHERE status = 'inactive';

-- delete with subquery
DELETE FROM orders
WHERE user_id IN (
  SELECT id FROM users WHERE status = 'deleted'
);

-- delete with RETURNING (PostgreSQL)
DELETE FROM users
WHERE last_login < '2020-01-01'
RETURNING id, name;

-- delete all rows (slow, logged, can be rolled back)
DELETE FROM logs;

-- TRUNCATE (fast, minimal logging, resets identity)
TRUNCATE TABLE logs;
TRUNCATE TABLE logs RESTART IDENTITY CASCADE;

-- soft delete pattern (preferred for audit)
UPDATE users SET deleted_at = NOW() WHERE id = 1;
SELECT * FROM users WHERE deleted_at IS NULL; -- active users

UPSERT (INSERT ... ON CONFLICT)

UPSERT (update o insert) maneja conflictos de clave duplicada atómicamente. PostgreSQL usa ON CONFLICT (columna) DO UPDATE/DO NOTHING. MySQL usa ON DUPLICATE KEY UPDATE. EXCLUDED (PostgreSQL) / VALUES() (MySQL) se refiere a los valores de inserción propuestos. Esto es esencial para operaciones idempotentes y evitar race conditions. Sin upsert, necesitarías SELECT-then-INSERT/UPDATE que es propenso a race conditions.

sql
-- PostgreSQL: ON CONFLICT (upsert)
INSERT INTO users (id, name, email)
VALUES (1, 'Alice', '[email protected]')
ON CONFLICT (id)
DO UPDATE SET email = EXCLUDED.email, updated_at = NOW()
RETURNING *;

-- DO NOTHING on conflict
INSERT INTO users (id, name, email)
VALUES (1, 'Alice', '[email protected]')
ON CONFLICT (id) DO NOTHING;

-- MySQL: ON DUPLICATE KEY UPDATE
INSERT INTO users (id, name, email)
VALUES (1, 'Alice', '[email protected]')
ON DUPLICATE KEY UPDATE email = VALUES(email);

-- SQLite: ON CONFLICT
INSERT INTO users (id, name)
VALUES (1, 'Alice')
ON CONFLICT(id) DO UPDATE SET name = excluded.name;

Statement MERGE

MERGE (aka UPSERT on steroids) combina INSERT, UPDATE y DELETE en un solo statement atómico basado en si las filas coinciden. Es la forma más eficiente de sincronizar datos entre fuentes. WHEN MATCHED dispara UPDATE/DELETE para filas existentes; WHEN NOT MATCHED dispara INSERT para nuevas filas. Disponible en SQL Server, Oracle, PostgreSQL 15+ y DB2. MySQL no soporta MERGE — usa INSERT...ON DUPLICATE KEY.

sql
-- MERGE: conditional insert/update/delete in one statement
-- (SQL Server, Oracle, PostgreSQL 15+)
MERGE INTO products AS target
USING (VALUES
  (1, 'Widget', 9.99),
  (2, 'Gadget', 19.99),
  (3, 'Gizmo', 29.99)
) AS source (id, name, price)
ON target.id = source.id
WHEN MATCHED THEN
  UPDATE SET name = source.name, price = source.price
WHEN NOT MATCHED THEN
  INSERT (id, name, price) VALUES (source.id, source.name, source.price)
WHEN MATCHED AND source.price < 0 THEN
  DELETE;

-- useful for:
-- - syncing data from external sources
-- - bulk upsert with conditional logic
-- - ETL operations
08

Transacciones y ACID

BEGIN, COMMIT, ROLLBACK

Las transacciones agrupan operaciones en una unidad atómica — todas tienen éxito (COMMIT) o todas fallan (ROLLBACK). Esta es la 'A' de ACID. BEGIN/START TRANSACTION inicia una transacción. SAVEPOINT crea un punto de rollback con nombre dentro de una transacción — puedes hacer rollback a él sin abortar toda la transacción. Siempre commit o rollback — dejar una transacción abierta mantiene locks y puede causar deadlocks.

sql
-- basic transaction
BEGIN;
  UPDATE accounts SET balance = balance - 100 WHERE id = 1;
  UPDATE accounts SET balance = balance + 100 WHERE id = 2;
COMMIT;
-- both updates succeed or both fail (atomicity)

-- rollback on error
BEGIN;
  INSERT INTO orders (user_id, total) VALUES (1, 50.00);
  -- oops, something went wrong
  ROLLBACK;
-- the insert is undone

-- transaction with savepoints
BEGIN;
  INSERT INTO logs (msg) VALUES ('step 1');
  SAVEPOINT my_savepoint;
  INSERT INTO logs (msg) VALUES ('step 2');
  ROLLBACK TO my_savepoint;  -- undo step 2, keep step 1
  INSERT INTO logs (msg) VALUES ('step 3');
COMMIT;  -- commits step 1 and step 3

Niveles de aislamiento

Los niveles de aislamiento balancean consistencia vs concurrencia. READ COMMITTED (default en PostgreSQL/Oracle) previene dirty reads pero permite non-repeatable reads. REPEATABLE READ previene non-repeatable reads pero permite phantom reads. SERIALIZABLE previene todas las anomalías pero reduce concurrencia. Mayor aislamiento = más locks = menos concurrencia. Elige el nivel más bajo que cumpla tus requisitos de corrección.

sql
-- set isolation level for transaction
BEGIN TRANSACTION ISOLATION LEVEL READ COMMITTED;
  -- can see committed data from other transactions
  SELECT balance FROM accounts WHERE id = 1;
COMMIT;

-- isolation levels (from weakest to strongest):
-- READ UNCOMMITTED: can read uncommitted (dirty) data
-- READ COMMITTED: only committed data (PostgreSQL default)
-- REPEATABLE READ: same query returns same results within txn
-- SERIALIZABLE: transactions appear to run sequentially

-- PostgreSQL: set per transaction
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;

-- MySQL: set per session
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;

-- check current level
SHOW TRANSACTION ISOLATION LEVEL;

Locking y SELECT FOR UPDATE

SELECT FOR UPDATE bloquea filas para que otras transacciones no puedan modificarlas hasta que hagas commit. Esto implementa control de concurrencia pesimista. SKIP LOCKED es esencial para job queues — múltiples workers pueden tomar jobs sin bloquearse mutuamente. NOWAIT falla rápido en lugar de esperar. Usa locking con moderación — reduce concurrencia y puede causar deadlocks. Prefiere concurrencia optimista (columnas de versión) para la mayoría de casos.

sql
-- pessimistic locking: lock rows for update
BEGIN;
  SELECT balance FROM accounts WHERE id = 1 FOR UPDATE;
  -- row is locked; other transactions must wait
  UPDATE accounts SET balance = balance - 100 WHERE id = 1;
COMMIT;  -- lock released

-- NOWAIT: don't wait if locked, error immediately
SELECT * FROM accounts WHERE id = 1 FOR UPDATE NOWAIT;

-- SKIP LOCKED: skip locked rows (useful for job queues)
SELECT * FROM jobs WHERE status = 'pending'
ORDER BY created_at
FOR UPDATE SKIP LOCKED
LIMIT 10;

-- SHARE LOCK: allow reads but prevent updates
SELECT * FROM products WHERE id = 1 FOR SHARE;

Deadlocks y manejo de errores

Los deadlocks ocurren cuando dos transacciones mantienen locks que la otra necesita. La base de datos detecta deadlocks y aborta una transacción (la víctima). Previene deadlocks adquiriendo locks en un orden consistente en todas las transacciones. Siempre prepárate para reintentar transacciones que fallen por deadlocks o fallos de serialización. Mantén las transacciones cortas para reducir contención de locks. El código de aplicación debería capturar SQLSTATE 40P01 (deadlock) y reintentar.

sql
-- deadlock example:
-- Transaction A:
BEGIN;
  UPDATE accounts SET balance = balance - 50 WHERE id = 1; -- locks row 1
  UPDATE accounts SET balance = balance + 50 WHERE id = 2; -- waits for row 2

-- Transaction B (concurrent):
BEGIN;
  UPDATE accounts SET balance = balance - 30 WHERE id = 2; -- locks row 2
  UPDATE accounts SET balance = balance + 30 WHERE id = 1; -- waits for row 1
-- DEADLOCK! Database detects and kills one transaction

-- prevention: always lock in consistent order
-- Transaction A and B both lock id=1 first, then id=2

-- PostgreSQL: error codes for handling
-- 40P01: deadlock_detected
-- 40001: serialization_failure
-- 40P02: transaction_integrity_constraint_violation

-- retry pattern (pseudocode):
-- for attempt in range(3):
--     try:
--         BEGIN; ... COMMIT; break
--     except deadlock:
--         ROLLBACK; continue

Propiedades ACID

ACID es la base de las transacciones de base de datos confiables. Atomicity: todas las operaciones en una transacción tienen éxito o fallan juntas. Consistency: las transacciones mueven la base de datos de un estado válido a otro (las restricciones se aplican). Isolation: las transacciones concurrentes no interfieren (controlado por nivel de aislamiento). Durability: una vez commiteado, los datos sobreviven crashes (logrado vía write-ahead logging). Las bases de datos NoSQL a menudo sacrifican algunas propiedades ACID por escalabilidad.

sql
-- ACID guarantees for transactions:

-- A: Atomicity (all or nothing)
BEGIN;
  INSERT INTO orders (id, total) VALUES (1, 100);
  INSERT INTO order_items (order_id, product_id) VALUES (1, 5);
  -- if either fails, both are rolled back
COMMIT;

-- C: Consistency (valid state to valid state)
-- constraints are checked at commit
ALTER TABLE accounts ADD CONSTRAINT balance_non_negative
  CHECK (balance >= 0);
-- a transaction that would make balance negative fails

-- I: Isolation (concurrent transactions don't interfere)
-- controlled by isolation level (see previous section)

-- D: Durability (committed data survives crashes)
-- achieved via WAL (Write-Ahead Logging) + fsync
-- synchronous_commit = on (default) ensures durability
09

Índices, views y stored procedures

Tipos de índice y estrategias

Los índices B-tree (default) manejan consultas de igualdad (=) y rango (<, >, BETWEEN). Los índices compuestos siguen la regla del prefijo más a la izquierda — un índice (a,b,c) ayuda WHERE a=?, WHERE a=? AND b=?, pero no WHERE b=?. Los partial indexes ahorran espacio indexando solo un subconjunto. Los expression indexes habilitan consultas indexadas en funciones (LOWER, columnas computadas). Usa EXPLAIN ANALYZE para verificar que los índices se usan — un índice no usado desperdicia espacio y ralentiza escrituras.

sql
-- B-tree index (default): equality and range queries
CREATE INDEX idx_users_email ON users(email);
CREATE INDEX idx_orders_date ON orders(created_at);

-- composite index (order matters!)
CREATE INDEX idx_orders_user_status ON orders(user_id, status);
-- helps: WHERE user_id = 1
-- helps: WHERE user_id = 1 AND status = 'paid'
-- does NOT help: WHERE status = 'paid' (leftmost prefix rule)

-- partial index: smaller, faster for common filters
CREATE INDEX idx_active_users ON users(email)
WHERE status = 'active';

-- expression index
CREATE INDEX idx_lower_email ON users(LOWER(email));
SELECT * FROM users WHERE LOWER(email) = '[email protected]';

-- unique index
CREATE UNIQUE INDEX idx_unique_email ON users(email);

-- EXPLAIN: see if index is used
EXPLAIN SELECT * FROM users WHERE email = '[email protected]';
EXPLAIN ANALYZE SELECT * FROM users WHERE email = '[email protected]';

Stored procedures y funciones

Las funciones devuelven valores y pueden usarse en SELECT; los procedures realizan acciones y se llaman con CALL. Los stored procedures encapsulan lógica de negocio en la base de datos — reduciendo round trips de red y centralizando lógica. Sin embargo, pueden hacer el escalado más difícil (lógica dividida entre app y DB) y son específicos de la base de datos. Úsalos para operaciones data-intensive que se benefician de la proximidad a los datos. PostgreSQL usa PL/pgSQL; MySQL usa su propio SQL procedural.

sql
-- PostgreSQL function
CREATE OR REPLACE FUNCTION get_user_orders(p_user_id INT)
RETURNS TABLE(order_id INT, total DECIMAL) AS $$
BEGIN
  RETURN QUERY
  SELECT id, total FROM orders WHERE user_id = p_user_id;
END;
$$ LANGUAGE plpgsql;

-- call function
SELECT * FROM get_user_orders(1);

-- PostgreSQL procedure (can manage transactions, PostgreSQL 11+)
CREATE PROCEDURE transfer_money(
  from_id INT, to_id INT, amount DECIMAL
) LANGUAGE plpgsql AS $$
BEGIN
  UPDATE accounts SET balance = balance - amount WHERE id = from_id;
  UPDATE accounts SET balance = balance + amount WHERE id = to_id;
  COMMIT;
END;
$$;

CALL transfer_money(1, 2, 100.00);

-- MySQL stored procedure
DELIMITER //
CREATE PROCEDURE GetActiveUsers()
BEGIN
  SELECT * FROM users WHERE status = 'active';
END //
DELIMITER ;
CALL GetActiveUsers();

Triggers

Los triggers se ejecutan automáticamente en cambios de datos. Los BEFORE triggers pueden modificar los datos entrantes (ej., establecer timestamps, validar). Los AFTER triggers realizan side effects (ej., audit logging, desnormalización). Usa triggers con moderación — están ocultos del código de aplicación, haciendo la depuración más difícil. Casos de uso comunes: audit trails, columnas computadas, aplicar restricciones complejas y sincronizar datos desnormalizados. Siempre documenta los triggers claramente.

sql
-- PostgreSQL trigger: audit log on update
CREATE OR REPLACE FUNCTION audit_user_change()
RETURNS TRIGGER AS $$
BEGIN
  INSERT INTO user_audit (user_id, old_name, new_name, changed_at)
  VALUES (OLD.id, OLD.name, NEW.name, NOW());
  RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER trg_user_audit
AFTER UPDATE ON users
FOR EACH ROW
EXECUTE FUNCTION audit_user_change();

-- trigger timing: BEFORE / AFTER / INSTEAD OF
-- trigger events: INSERT / UPDATE / DELETE / TRUNCATE
-- granularity: FOR EACH ROW / FOR EACH STATEMENT

-- MySQL trigger
CREATE TRIGGER before_user_insert
BEFORE INSERT ON users
FOR EACH ROW
SET NEW.created_at = NOW();

-- drop trigger
DROP TRIGGER IF EXISTS trg_user_audit ON users;

Operaciones JSON (PostgreSQL)

JSONB (PostgreSQL) almacena JSON en formato binario, habilitando indexación y consultas eficientes. -> devuelve JSON, ->> devuelve texto. @> verifica containment (¿el JSON contiene esto?). Los índices GIN hacen las consultas JSON rápidas. Usa columnas JSON para datos flexibles/semi-estructurados (event logs, respuestas de API, configuración) mientras mantienes datos relacionales en columnas normales. JSONB es preferible a JSON (más rápido, indexable, sin claves duplicadas).

sql
-- JSONB columns (PostgreSQL)
CREATE TABLE events (
  id SERIAL PRIMARY KEY,
  data JSONB NOT NULL
);

INSERT INTO events (data) VALUES
  ('{"type": "click", "user": {"id": 1, "name": "Alice"}, "tags": ["web", "mobile"]}');

-- extract fields (-> for JSON, ->> for text)
SELECT data->'type' AS type,           -- "click" (JSON)
       data->'user'->>'name' AS name,  -- Alice (text)
       data->'tags'->0 AS first_tag    -- "web"
FROM events;

-- filter by JSON field
SELECT * FROM events WHERE data->>'type' = 'click';
SELECT * FROM events WHERE data @> '{"type": "click"}';  -- containment

-- GIN index for JSON queries
CREATE INDEX idx_events_data ON events USING gin(data);
SELECT * FROM events WHERE data @> '{"user": {"id": 1}}';

-- modify JSON
UPDATE events SET data = jsonb_set(data, '{user,name}', '"Bob"');

Full-text search

La búsqueda de texto completo habilita consultas de lenguaje natural (stemming, ranking, stopwords). to_tsvector convierte texto a tokens buscables; to_tsquery crea una consulta de búsqueda; @@ coincide. ts_rank puntúa resultados; ts_headline resalta coincidencias. Los índices GIN hacen esto rápido. Para búsqueda a gran escala, considera motores dedicados (Elasticsearch, Solr), pero PostgreSQL FTS es excelente para datasets moderados y evita complejidad de infraestructura.

sql
-- PostgreSQL full-text search
CREATE TABLE articles (
  id SERIAL PRIMARY KEY,
  title VARCHAR(200),
  body TEXT
);

-- create a full-text search index
CREATE INDEX idx_articles_search ON articles
USING gin(to_tsvector('english', title || ' ' || body));

-- search with ranking
SELECT
  title,
  ts_rank(to_tsvector('english', body), query) AS rank,
  ts_headline('english', body, query) AS snippet
FROM articles, to_tsquery('english', 'database & performance') query
WHERE to_tsvector('english', title || ' ' || body) @@ query
ORDER BY rank DESC
LIMIT 10;

-- simplified with generated column
ALTER TABLE articles ADD COLUMN search_vector tsvector
  GENERATED ALWAYS AS (to_tsvector('english', title || ' ' || body)) STORED;
CREATE INDEX idx_articles_search_vec ON articles USING gin(search_vector);

SELECT * FROM articles WHERE search_vector @@ to_tsquery('database');
10

Rendimiento y optimización de consultas

EXPLAIN y query plans

EXPLAIN muestra el query plan — cómo la base de datos ejecutará tu consulta. EXPLAIN ANALYZE realmente la ejecuta y muestra timings reales. Busca Sequential Scans en tablas grandes (añade índices), Sorts costosos (añade índices) y desajustes de estimación de filas (ejecuta ANALYZE para actualizar estadísticas). Los números de costo son relativos, no absolutos. Entender los query plans es la habilidad #1 para SQL performance tuning.

sql
-- EXPLAIN: show query plan without running
EXPLAIN SELECT * FROM users WHERE email = '[email protected]';

-- EXPLAIN ANALYZE: run the query and show actual timing
EXPLAIN ANALYZE SELECT * FROM users WHERE email = '[email protected]';

-- EXPLAIN ANALYZE with buffers (I/O stats)
EXPLAIN (ANALYZE, BUFFERS) SELECT * FROM users
JOIN orders ON users.id = orders.user_id;

-- key things to look for in the plan:
-- Seq Scan: full table scan (bad for large tables — add index)
-- Index Scan: using an index (good)
-- Bitmap Index Scan: index + heap lookup (good for many rows)
-- Hash Join: builds hash table (good for large joins)
-- Nested Loop: good for small result sets
-- Sort: explicit sort (consider index to avoid)
-- cost: estimated cost (first row, all rows)
-- rows: estimated vs actual rows (big mismatch = stale stats)

Pitfalls comunes de rendimiento

Sargability (Search Argument Able) significa que la base de datos puede usar índices. Las funciones en columnas (DATE(col), UPPER(col)) impiden el uso de índices — reescríbelas como range queries o usa expression indexes. SELECT * desperdicia I/O e impide covering indexes. La paginación OFFSET es O(n) — usa keyset pagination (WHERE id > last_id) para O(1). Las operaciones batch grandes deberían trocearse para evitar locks largos y replication lag.

sql
-- 1. Sargability: avoid functions on indexed columns
-- BAD: function prevents index usage
SELECT * FROM orders WHERE DATE(created_at) = '2024-01-01';
-- GOOD: range query uses index
SELECT * FROM orders
WHERE created_at >= '2024-01-01' AND created_at < '2024-01-02';

-- 2. Avoid SELECT * (more I/O, prevents covering indexes)
-- BAD
SELECT * FROM users WHERE status = 'active';
-- GOOD
SELECT id, name, email FROM users WHERE status = 'active';

-- 3. Use LIMIT with ORDER BY for pagination
-- BAD: loads all rows
SELECT * FROM products ORDER BY id;
-- GOOD: keyset pagination
SELECT * FROM products WHERE id > 1000 ORDER BY id LIMIT 20;

-- 4. Batch large updates
-- BAD: one giant transaction
DELETE FROM logs WHERE date < '2023-01-01';
-- GOOD: batch in chunks
DELETE FROM logs WHERE date < '2023-01-01' AND id <= 10000;
DELETE FROM logs WHERE date < '2023-01-01' AND id <= 20000;

UNION, INTERSECT y EXCEPT

Las operaciones set combinan result sets. UNION elimina duplicados (sort costoso); UNION ALL los mantiene (más rápido — prefiere cuando sabes que no hay duplicados o los quieres). INTERSECT devuelve filas en ambos. EXCEPT devuelve filas en el primero pero no en el segundo. Todos requieren tipos de columna compatibles. UNION ALL puede reemplazar condiciones OR complejas y a menudo funciona mejor porque puede usar diferentes índices para cada branch.

sql
-- UNION: combine results, remove duplicates
SELECT name FROM customers
UNION
SELECT name FROM suppliers;
-- UNION ALL: faster, keeps duplicates
SELECT name FROM customers
UNION ALL
SELECT name FROM suppliers;

-- INTERSECT: rows in BOTH results
SELECT product_id FROM sales_2023
INTERSECT
SELECT product_id FROM sales_2024;

-- EXCEPT (MINUS in Oracle): rows in first but not second
SELECT product_id FROM all_products
EXCEPT
SELECT product_id FROM discontinued_products;

-- rules:
-- - same number of columns
-- - compatible types
-- - column names come from first query
-- - UNION is often faster than OR conditions

Tips específicos de base de datos

VACUUM (PostgreSQL) reclama espacio de filas eliminadas (MVCC deja 'dead tuples'). ANALYZE actualiza estadísticas de tabla para el query planner — ejecútalo después de bulk loads. OPTIMIZE TABLE (MySQL) desfragmenta tablas. Indexa foreign keys explícitamente (PostgreSQL no las auto-indexa). Monitorea el uso de índices con pg_stat_user_indexes y elimina los no usados. El connection pooling (PgBouncer, ProxySQL) es esencial para apps de alto tráfico — abrir conexiones es costoso.

sql
-- PostgreSQL: VACUUM to reclaim space
VACUUM ANALYZE users;  -- update stats, reclaim dead rows
VACUUM FULL users;     -- rewrites table (locks, but reclaims all space)

-- PostgreSQL: ANALYZE to update statistics
ANALYZE users;  -- helps query planner make better decisions

-- MySQL: OPTIMIZE TABLE
OPTIMIZE TABLE users;

-- Common indexing rules across databases:
-- 1. Index foreign keys (not automatic in all DBs)
-- 2. Index columns used in WHERE, JOIN, ORDER BY, GROUP BY
-- 3. Composite indexes: high selectivity column first
-- 4. Don't over-index (slows writes, uses disk)
-- 5. Drop unused indexes (check with pg_stat_user_indexes)

-- Connection pooling (application level):
-- - Use PgBouncer (PostgreSQL) or ProxySQL (MySQL)
-- - Reuse connections instead of reconnecting per request
-- - Set appropriate pool size (not too high!)

Tipos de datos y manejo de NULL

NULL representa datos desconocidos/ausentes, no cero o vacío. Las comparaciones NULL siempre dan NULL (desconocido), lo cual es falsy en WHERE. Usa IS NULL / IS NOT NULL para testear. COALESCE proporciona fallbacks. NULLIF convierte valores específicos a NULL (útil para división por cero). Los agregados saltan NULLs — COUNT(col) cuenta no-NULLs, COUNT(*) cuenta todas las filas. En LEFT JOINs, usa COUNT(right_table.col) para obtener 0 en filas no coincidentes.

sql
-- NULL is not zero or empty string — it's "unknown"
SELECT NULL = NULL;   -- NULL (not true!)
SELECT NULL IS NULL;  -- true
SELECT NULL <> 1;     -- NULL (unknown)

-- COALESCE: first non-NULL value
SELECT COALESCE(nickname, first_name, 'Anonymous') FROM users;

-- NULLIF: return NULL if two values are equal
SELECT NULLIF(score, 0) FROM tests;  -- NULL instead of 0
-- useful for avoiding division by zero:
SELECT total / NULLIF(count, 0) FROM stats;

-- aggregate functions ignore NULL
SELECT AVG(score) FROM tests;  -- avg of non-NULL scores only
SELECT COUNT(score) FROM tests;  -- count of non-NULL
SELECT COUNT(*) FROM tests;  -- count of all rows

-- use LEFT JOIN + COUNT carefully
SELECT u.name, COUNT(o.id) AS orders  -- COUNT(o.id) = 0 for no orders
FROM users u LEFT JOIN orders o ON u.id = o.user_id
GROUP BY u.id;
11

CTEs recursivas

Estructura básica de CTE recursiva

Una CTE recursiva se referencia a sí misma para generar datos jerárquicos o secuenciales. Tiene dos partes unidas por UNION ALL: una query ancla (el caso base/punto de partida) y una query recursiva (que referencia la CTE y añade al resultado). La recursión continúa hasta que la query recursiva no devuelve filas. Usa CTEs recursivas para recorrido de árbol (organigramas, sistemas de archivos), generación de secuencias y pathfinding en grafos. Siempre incluye una condición de terminación en la cláusula WHERE para prevenir loops infinitos.

sql
-- Recursive CTE: a CTE that references itself
-- Three parts: anchor, UNION ALL, recursive member
WITH RECURSIVE countdown(n) AS (
    -- Anchor: starting point
    SELECT 1 AS n
    UNION ALL
    -- Recursive member: references the CTE itself
    SELECT n + 1 FROM countdown WHERE n < 10
)
SELECT n FROM countdown;
-- Result: 1, 2, 3, 4, 5, 6, 7, 8, 9, 10

-- PostgreSQL uses WITH RECURSIVE
-- SQL Server/MySQL 8+: WITH (RECURSIVE optional in MySQL)
-- SQLite: WITH RECURSIVE

Datos jerárquicos (organigrama)

Las CTEs recursivas destacan en recorrer datos jerárquicos como organigramas, árboles de categorías o sistemas de archivos. El ancla selecciona el nodo raíz; el miembro recursivo une la tabla con la CTE en la relación padre-hijo (manager_id = id). Añadir una columna depth rastrea cuántos niveles de profundidad está cada fila, y una columna path (concatenación de strings) muestra la cadena de ancestros completa. Esto reemplaza la necesidad de múltiples self-joins o recursión en el lado de la aplicación. El CAST en path previene errores de tipo durante la recursión.

sql
-- Employee hierarchy with manager relationships
CREATE TABLE employees (
    id INT PRIMARY KEY,
    name VARCHAR(100),
    manager_id INT REFERENCES employees(id)
);

-- Find all direct and indirect reports of employee 1 (CEO)
WITH RECURSIVE org_chain AS (
    -- Anchor: the starting employee
    SELECT id, name, manager_id, 0 AS depth, CAST(name AS VARCHAR(500)) AS path
    FROM employees WHERE id = 1
    UNION ALL
    -- Recursive: find employees whose manager is in the chain
    SELECT e.id, e.name, e.manager_id, oc.depth + 1,
           CAST(oc.path || ' > ' || e.name AS VARCHAR(500))
    FROM employees e
    JOIN org_chain oc ON e.manager_id = oc.id
)
SELECT id, name, depth, path FROM org_chain
ORDER BY depth, name;

-- depth shows hierarchy level, path shows the management chain

Generación de secuencias y fechas

Las CTEs recursivas pueden generar secuencias y rangos de fechas — útiles para llenar gaps en reportes de series temporales. Al generar todas las fechas en un rango y LEFT JOINear a tus datos, aseguras que cada fecha aparezca en la salida incluso cuando no hay registros. Este es un patrón común para dashboards y charts. PostgreSQL también tiene generate_series() como una alternativa más simple. Siempre establece una condición de terminación (WHERE n < 100) para prevenir recursión infinita. Algunas bases de datos limitan la profundidad de recursión (ej., 100 por defecto en MySQL vía cte_max_recursion_depth).

sql
-- Generate a series of numbers
WITH RECURSIVE numbers(n) AS (
    SELECT 1
    UNION ALL
    SELECT n + 1 FROM numbers WHERE n < 100
)
SELECT n FROM numbers;

-- Generate a date range (all days in January 2024)
WITH RECURSIVE dates(d) AS (
    SELECT DATE '2024-01-01'
    UNION ALL
    SELECT d + INTERVAL '1 day' FROM dates
    WHERE d < DATE '2024-01-31'
)
SELECT d FROM dates;

-- Generate a time series for gap-filling in reports
-- (ensures every date appears even if no data exists)
SELECT d.date, COALESCE(SUM(o.amount), 0) AS daily_total
FROM dates d
LEFT JOIN orders o ON o.order_date = d.date
GROUP BY d.date ORDER BY d.date;

Pathfinding en grafos (BFS)

Las CTEs recursivas pueden realizar breadth-first search (BFS) en estructuras de grafos. El ancla encuentra edges desde el nodo inicial; el miembro recursivo extiende paths uniendo edges al endpoint del path actual. La prevención de ciclos es crítica en grafos cíclicos — verifica que el nodo destino no esté ya en el path (usando LIKE o una búsqueda de string). El límite de hops es una red de seguridad contra recursión infinita. Este enfoque funciona para route finding, resolución de dependencias y análisis de redes. Para caminos más cortos ponderados, considera el algoritmo de Dijkstra en código de aplicación.

sql
-- Find all paths in a directed graph
CREATE TABLE edges (src VARCHAR(10), dst VARCHAR(10));

-- Find all reachable nodes from 'A' with the path taken
WITH RECURSIVE paths AS (
    -- Anchor: start from node A
    SELECT src, dst, CAST(src || '->' || dst AS VARCHAR(1000)) AS path,
           1 AS hops
    FROM edges WHERE src = 'A'
    UNION ALL
    -- Recursive: extend the path
    SELECT p.src, e.dst,
           CAST(p.path || '->' || e.dst AS VARCHAR(1000)),
           p.hops + 1
    FROM paths p
    JOIN edges e ON p.dst = e.src
    WHERE p.hops < 10  -- prevent infinite loops in cyclic graphs
      AND p.path NOT LIKE '%' || e.dst || '%'
)
SELECT DISTINCT path, hops FROM paths ORDER BY hops;

-- Cycle prevention: check the path doesn't already contain the node

Factorial y agregación con recursión

Las CTEs recursivas pueden realizar cálculos matemáticos como factoriales llevando estado (n, fact) a través de cada iteración. El ancla establece el caso base (0! = 1), y el miembro recursivo computa el siguiente valor del anterior. Los totales acumulados también pueden computarse así, aunque las window functions (SUM(amount) OVER (ORDER BY id)) son más eficientes e idiomáticas para agregados acumulativos. Las CTEs recursivas para computación son principalmente educativas — úsalas cuando las window functions o código procedural no pueden expresar la lógica. Cada nivel de recursión añade una fila, así que el result set crece con la profundidad.

sql
-- Compute factorial using recursive CTE
WITH RECURSIVE factorial(n, fact) AS (
    -- Anchor: 0! = 1
    SELECT 0, 1
    UNION ALL
    -- Recursive: n! = n * (n-1)!
    SELECT n + 1, fact * (n + 1) FROM factorial WHERE n < 10
)
SELECT n, fact FROM factorial;

-- Running accumulation: cumulative sum
WITH RECURSIVE running_total AS (
    SELECT id, amount, amount AS cumulative
    FROM transactions WHERE id = 1
    UNION ALL
    SELECT t.id, t.amount, rt.cumulative + t.amount
    FROM transactions t
    JOIN running_total rt ON t.id = rt.id + 1
)
SELECT * FROM running_total ORDER BY id;

-- Note: window functions (SUM OVER) are usually better for this
12

PIVOT y UNPIVOT

PIVOT (filas a columnas)

PIVOT transforma filas en columnas — perfecto para reportes cross-tab donde quieres categorías como encabezados de columna. La lista IN especifica qué valores se convierten en columnas. SQL Server y Oracle tienen sintaxis PIVOT nativa. La query interna proporciona los datos fuente, y PIVOT aplica un agregado (SUM, AVG, COUNT) para cada grupo de columna. Esto es equivalente a agregación condicional pero más legible para pivots anchos. Usa PIVOT cuando tienes un conjunto fijo y conocido de valores sobre los que pivotar.

sql
-- Convert rows to columns (cross-tabulation)
-- Source: sales data with rows per quarter
CREATE TABLE sales (quarter VARCHAR(10), region VARCHAR(50), amount DECIMAL(10,2));

-- SQL Server PIVOT syntax:
SELECT region, [Q1], [Q2], [Q3], [Q4]
FROM (
    SELECT quarter, region, amount FROM sales
) AS src
PIVOT (
    SUM(amount) FOR quarter IN ([Q1], [Q2], [Q3], [Q4])
) AS pvt;

-- Result:
-- region  | Q1    | Q2    | Q3    | Q4
-- North   | 1000  | 1500  | 1200  | 1800
-- South   | 800   | 1100  | 900   | 1300

Agregación condicional (PIVOT universal)

La agregación condicional (SUM + CASE) es la técnica de pivot universal que funciona en cada base de datos SQL. Cada expresión CASE filtra para una categoría, y SUM agrega los valores coincidentes. Esto a menudo es más rápido que PIVOT y más flexible. El ELSE 0 asegura que las filas no coincidentes contribuyan cero. La función crosstab() de PostgreSQL (de la extensión tablefunc) es más concisa pero requiere columnas de salida fijas. Usa agregación condicional cuando necesitas compatibilidad cross-database o cuando la sintaxis PIVOT no está disponible.

sql
-- Works in ALL databases (MySQL, PostgreSQL, SQLite, etc.)
SELECT
    region,
    SUM(CASE WHEN quarter = 'Q1' THEN amount ELSE 0 END) AS Q1,
    SUM(CASE WHEN quarter = 'Q2' THEN amount ELSE 0 END) AS Q2,
    SUM(CASE WHEN quarter = 'Q3' THEN amount ELSE 0 END) AS Q3,
    SUM(CASE WHEN quarter = 'Q4' THEN amount ELSE 0 END) AS Q4,
    SUM(amount) AS total
FROM sales
GROUP BY region
ORDER BY region;

-- PostgreSQL-specific: crosstab() from tablefunc extension
-- SELECT * FROM crosstab('SELECT region, quarter, amount FROM sales ORDER BY 1,2')
-- AS ct(region VARCHAR, Q1 DECIMAL, Q2 DECIMAL, Q3 DECIMAL, Q4 DECIMAL);

Dynamic PIVOT (SQL dinámico)

El SQL dinámico construye un string de query en runtime cuando las columnas pivot no se conocen de antemano (ej., pivotar por mes cuando los meses varían). El proceso: consulta valores distinct, construye una lista de columnas, construye el statement PIVOT y ejecuta con sp_executesql (SQL Server) o PREPARE/EXECUTE (MySQL). Siempre sanitiza con QUOTENAME() o quote_ident() para prevenir SQL injection. El SQL dinámico es potente pero añade complejidad y riesgos de seguridad — úsalo con moderación y prefiere pivots fijos cuando sea posible. El pivotado en el lado de la aplicación a menudo es una alternativa más segura.

sql
-- When pivot columns are unknown at write time, use dynamic SQL
-- SQL Server example:
DECLARE @cols AS NVARCHAR(MAX),
        @query AS NVARCHAR(MAX);

-- Build the column list dynamically
SELECT @cols = STUFF((
    SELECT DISTINCT ',' + QUOTENAME(quarter)
    FROM sales FOR XML PATH(''), TYPE
).value('.', 'NVARCHAR(MAX)'), 1, 1, '');

-- Build and execute the pivot query
SET @query = 'SELECT region, ' + @cols + '
FROM (SELECT quarter, region, amount FROM sales) x
PIVOT (SUM(amount) FOR quarter IN (' + @cols + ')) p';

EXEC sp_executesql @query;

-- WARNING: dynamic SQL is vulnerable to SQL injection
-- — always use QUOTENAME() to sanitize column names

UNPIVOT (columnas a filas)

UNPIVOT invierte PIVOT — transforma columnas en filas. Esto es útil para normalizar datos desnormalizados, convertir archivos de importación anchos a formato largo o preparar datos para charting. SQL Server tiene sintaxis UNPIVOT nativa. El enfoque UNION ALL funciona en todas partes: cada SELECT extrae una columna y la etiqueta con un valor fijo. UNION ALL (no UNION) preserva duplicados y es más rápido. UNPIVOT es común en pipelines ETL cuando los datos fuente llegan en formato spreadsheet (ancho) pero necesitan almacenarse normalizados (largo).

sql
-- Convert columns back to rows
-- Source: wide table with Q1-Q4 columns
-- Target: narrow table with quarter/amount rows

-- SQL Server UNPIVOT:
SELECT region, quarter, amount
FROM quarterly_sales
UNPIVOT (
    amount FOR quarter IN (Q1, Q2, Q3, Q4)
) AS unpvt;

-- Universal UNION ALL approach (all databases):
SELECT region, 'Q1' AS quarter, Q1 AS amount FROM quarterly_sales
UNION ALL
SELECT region, 'Q2' AS quarter, Q2 AS amount FROM quarterly_sales
UNION ALL
SELECT region, 'Q3' AS quarter, Q3 AS amount FROM quarterly_sales
UNION ALL
SELECT region, 'Q4' AS quarter, Q4 AS amount FROM quarterly_sales;

-- Result: one row per region/quarter combination

Ejemplo práctico de reporte pivot

Este reporte pivot del mundo real combina desgloses mensuales con comparación year-over-year en una sola consulta. La agregación condicional (SUM + CASE) crea tanto columnas mensuales como totales anuales. La columna yoy_change computa la diferencia inline. HAVING filtra productos sin ventas. Este patrón es común en dashboards BI y reportes financieros. La función EXTRACT funciona en la mayoría de bases de datos (usa DATEPART en SQL Server, strftime en SQLite). Para columnas verdaderamente dinámicas, combina con SQL dinámico o maneja el pivotado en la capa de aplicación.

sql
-- Monthly sales pivot with year-over-year comparison
SELECT
    product_name,
    SUM(CASE WHEN EXTRACT(MONTH FROM order_date) = 1  THEN amount ELSE 0 END) AS jan,
    SUM(CASE WHEN EXTRACT(MONTH FROM order_date) = 2  THEN amount ELSE 0 END) AS feb,
    SUM(CASE WHEN EXTRACT(MONTH FROM order_date) = 3  THEN amount ELSE 0 END) AS mar,
    SUM(CASE WHEN EXTRACT(MONTH FROM order_date) = 4  THEN amount ELSE 0 END) AS apr,
    SUM(CASE WHEN EXTRACT(MONTH FROM order_date) = 5  THEN amount ELSE 0 END) AS may,
    SUM(CASE WHEN EXTRACT(MONTH FROM order_date) = 6  THEN amount ELSE 0 END) AS jun,
    SUM(CASE WHEN EXTRACT(YEAR  FROM order_date) = 2024 THEN amount ELSE 0 END) AS total_2024,
    SUM(CASE WHEN EXTRACT(YEAR  FROM order_date) = 2023 THEN amount ELSE 0 END) AS total_2023,
    SUM(CASE WHEN EXTRACT(YEAR  FROM order_date) = 2024 THEN amount ELSE 0 END) -
    SUM(CASE WHEN EXTRACT(YEAR  FROM order_date) = 2023 THEN amount ELSE 0 END) AS yoy_change
FROM orders o
JOIN products p ON o.product_id = p.id
WHERE order_date BETWEEN '2023-01-01' AND '2024-12-31'
GROUP BY product_name
HAVING SUM(amount) > 0
ORDER BY total_2024 DESC;
14

Triggers

Fundamentos de triggers (AFTER/BEFORE)

Los triggers son código a nivel de base de datos que se ejecuta automáticamente cuando cambian los datos. Los AFTER triggers loguean o propagan cambios (no pueden modificar NEW). Los BEFORE triggers validan o transforman datos antes de escribirse (pueden modificar NEW). FOR EACH ROW se dispara una vez por fila afectada; FOR EACH STATEMENT se dispara una vez por statement. Usa triggers para audit logging, aplicar restricciones complejas y auto-actualizar columnas derivadas. Evita triggers para lógica de negocio — están ocultos, difíciles de depurar y pueden causar efectos en cascada. Cada base de datos tiene sintaxis de trigger diferente; PostgreSQL usa funciones como cuerpos de trigger.

sql
-- A trigger fires automatically on INSERT/UPDATE/DELETE
-- MySQL syntax:
DELIMITER //
CREATE TRIGGER audit_log
AFTER INSERT ON employees
FOR EACH ROW
BEGIN
    INSERT INTO audit_table (table_name, action, row_id, changed_at)
    VALUES ('employees', 'INSERT', NEW.id, NOW());
END //
DELIMITER ;

-- BEFORE triggers can modify the NEW values:
CREATE TRIGGER validate_email
BEFORE INSERT ON users
FOR EACH ROW
BEGIN
    IF NEW.email NOT LIKE '%@%.%' THEN
        SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Invalid email';
    END IF;
END //

-- PostgreSQL uses CREATE FUNCTION + CREATE TRIGGER:
-- CREATE TRIGGER audit AFTER INSERT ON employees
-- FOR EACH ROW EXECUTE FUNCTION audit_func();

Trigger de audit logging

Los triggers de audit capturan cada cambio de datos para compliance y depuración. La tabla de audit almacena el tipo de acción, valores old y new, quién hizo el cambio (CURRENT_USER) y cuándo (CURRENT_TIMESTAMP). Necesitas triggers separados para INSERT, UPDATE y DELETE. OLD referencia valores pre-cambio (disponible en UPDATE/DELETE), NEW referencia valores post-cambio (disponible en INSERT/UPDATE). Las tablas de audit crecen indefinidamente — particiona por fecha o archiva datos antiguos. Este patrón satisface requisitos SOX, HIPAA y GDPR para tracking de cambios de datos.

sql
-- Track all changes to a critical table
CREATE TABLE employee_audit (
    audit_id INT AUTO_INCREMENT PRIMARY KEY,
    action VARCHAR(10),       -- INSERT, UPDATE, DELETE
    employee_id INT,
    old_name VARCHAR(100),
    new_name VARCHAR(100),
    old_salary DECIMAL(10,2),
    new_salary DECIMAL(10,2),
    changed_by VARCHAR(50) DEFAULT CURRENT_USER,
    changed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

CREATE TRIGGER trg_audit_insert
AFTER INSERT ON employees
FOR EACH ROW
INSERT INTO employee_audit (action, employee_id, new_name, new_salary)
VALUES ('INSERT', NEW.id, NEW.name, NEW.salary);

CREATE TRIGGER trg_audit_update
AFTER UPDATE ON employees
FOR EACH ROW
INSERT INTO employee_audit (action, employee_id, old_name, new_name, old_salary, new_salary)
VALUES ('UPDATE', NEW.id, OLD.name, NEW.name, OLD.salary, NEW.salary);

CREATE TRIGGER trg_audit_delete
AFTER DELETE ON employees
FOR EACH ROW
INSERT INTO employee_audit (action, employee_id, old_name, old_salary)
VALUES ('DELETE', OLD.id, OLD.name, OLD.salary);

Trigger de columna computada/derivada

Los triggers pueden auto-computar columnas derivadas, asegurando consistencia sin código de aplicación. Los BEFORE INSERT/UPDATE triggers establecen NEW.final_price basándose en otras columnas. Sin embargo, las bases de datos modernas soportan columnas GENERATED (computadas) nativamente — estas siempre son correctas, no pueden sobrescribirse manualmente y pueden indexarse. Prefiere columnas GENERATED sobre triggers para valores computados. Usa triggers solo cuando la computación involucra datos externos, lógica condicional o dependencias cross-table que las columnas GENERATED no pueden manejar. Recuerda que los triggers añaden overhead a cada operación de escritura.

sql
-- Auto-update a derived column when source data changes
CREATE TABLE products (
    id INT PRIMARY KEY,
    price DECIMAL(10,2),
    discount_percent DECIMAL(5,2),
    final_price DECIMAL(10,2)  -- computed: price * (1 - discount/100)
);

-- BEFORE INSERT: compute final_price
CREATE TRIGGER calc_final_price_insert
BEFORE INSERT ON products
FOR EACH ROW
SET NEW.final_price = NEW.price * (1 - NEW.discount_percent / 100);

-- BEFORE UPDATE: recompute if price or discount changes
CREATE TRIGGER calc_final_price_update
BEFORE UPDATE ON products
FOR EACH ROW
SET NEW.final_price = NEW.price * (1 - NEW.discount_percent / 100);

-- Alternative: use GENERATED columns (MySQL 5.7+, PostgreSQL 12+)
-- final_price DECIMAL(10,2) GENERATED ALWAYS AS
--     (price * (1 - discount_percent / 100)) STORED

Prevención de deletes con triggers

Los triggers pueden aplicar reglas de protección de datos que las restricciones CHECK no pueden expresar. Los BEFORE DELETE triggers pueden bloquear eliminaciones completamente (usando SIGNAL/RAISE) o implementar soft deletes (marcando registros como eliminados en lugar de removerlos). SIGNAL SQLSTATE '45000' es la forma de MySQL de lanzar un error definido por el usuario. PostgreSQL usa RAISE EXCEPTION. Esto es útil para proteger datos de referencia, prevenir eliminación de registros padre con hijos, o implementar audit trails inmutables. Ten cuidado: los triggers que previenen operaciones pueden sorprender a desarrolladores — documéntalos claramente y considera checks a nivel de aplicación en su lugar.

sql
-- Prevent deletion of critical records
CREATE TRIGGER prevent_delete_admin
BEFORE DELETE ON users
FOR EACH ROW
BEGIN
    IF OLD.role = 'admin' THEN
        SIGNAL SQLSTATE '45000'
        SET MESSAGE_TEXT = 'Cannot delete admin users';
    END IF;
END //

-- Soft delete instead of hard delete
CREATE TRIGGER soft_delete
BEFORE DELETE ON articles
FOR EACH ROW
BEGIN
    -- Prevent actual deletion, mark as deleted instead
    INSERT INTO articles (id, title, body, deleted_at)
    VALUES (OLD.id, OLD.title, OLD.body, NOW())
    ON DUPLICATE KEY UPDATE deleted_at = NOW();
    -- Still need to prevent the DELETE — use SIGNAL or a flag
END //

-- PostgreSQL: raise exception in trigger function
-- IF OLD.role = 'admin' THEN
--     RAISE EXCEPTION 'Cannot delete admin users';
-- END IF;

Gestión y depuración de triggers

Gestionar triggers es esencial para el mantenimiento. SHOW TRIGGERS (MySQL) y las vistas information_schema listan todos los triggers. Elimina triggers con DROP TRIGGER IF EXISTS. Deshabilitar triggers temporalmente es útil para bulk data loads (que dispararían audit/logging costoso para cada fila). PostgreSQL usa ALTER TABLE ... DISABLE/ENABLE TRIGGER; SQL Server usa DISABLE/ENABLE TRIGGER. Siempre re-habilita los triggers después del mantenimiento. Depurar triggers es difícil — se ejecutan silenciosamente. Añade logging a una tabla de debug, o testea la lógica del trigger en aislamiento primero. Los triggers excesivos crean complejidad oculta y problemas de rendimiento.

sql
-- View existing triggers
-- MySQL:
SHOW TRIGGERS;
SELECT * FROM information_schema.TRIGGERS WHERE TRIGGER_SCHEMA = 'mydb';

-- PostgreSQL:
SELECT tgname, tgrelid::regclass, tgtype FROM pg_trigger;

-- SQL Server:
SELECT name, type_desc FROM sys.triggers WHERE parent_id = OBJECT_ID('employees');

-- Drop a trigger
DROP TRIGGER IF EXISTS audit_log;  -- MySQL
DROP TRIGGER IF EXISTS audit_log ON employees;  -- PostgreSQL

-- Disable/enable triggers (PostgreSQL)
ALTER TABLE employees DISABLE TRIGGER ALL;
ALTER TABLE employees ENABLE TRIGGER ALL;

-- Disable for bulk operations (SQL Server)
DISABLE TRIGGER trg_audit ON employees;
-- ... bulk operations ...
ENABLE TRIGGER trg_audit ON employees;
15

Funciones definidas por el usuario (UDFs)

Funciones escalares (devuelven un solo valor)

Las UDFs escalares devuelven un solo valor y pueden usarse en SELECT, WHERE y columnas computadas. DETERMINISTIC significa que la salida depende solo de entradas (habilita caching). READS SQL DATA declara que la función lee de tablas. Las UDFs encapsulan lógica reutilizable (descuentos, formateo, cálculos) para que sea consistente entre consultas. Sin embargo, las UDFs escalares en SQL Server pueden causar problemas de rendimiento (ejecución fila por fila) — usa inline table-valued functions o columnas computadas en su lugar cuando sea posible. MySQL 8.0+ optimiza las funciones deterministic mejor. Siempre documenta el propósito y parámetros de la función.

sql
-- MySQL: a function that returns one value
CREATE FUNCTION calculate_discount(
    price DECIMAL(10,2),
    customer_tier VARCHAR(20)
) RETURNS DECIMAL(10,2)
DETERMINISTIC
READS SQL DATA
BEGIN
    DECLARE discount_rate DECIMAL(5,2);
    SET discount_rate = CASE customer_tier
        WHEN 'gold'   THEN 0.20
        WHEN 'silver' THEN 0.10
        WHEN 'bronze' THEN 0.05
        ELSE 0.00
    END;
    RETURN price * (1 - discount_rate);
END //

-- Usage in queries:
SELECT name, price, calculate_discount(price, tier) AS final_price
FROM orders;

-- DETERMINISTIC: same inputs always give same output (cacheable)
-- READS SQL DATA: function reads but doesn't modify tables

Funciones table-valued (devuelven filas)

Las table-valued functions (TVFs) devuelven un result set (filas) que puedes consultar como una tabla. Las inline TVFs (SQL Server) son tan rápidas como views — el query optimizer las inlinea. Las multi-statement TVFs materializan resultados en una temp table primero, lo que puede ser más lento. Las funciones de PostgreSQL que devuelven TABLE o SETOF son equivalentes. Las TVFs son views parametrizadas — úsalas cuando necesitas un view con parámetros. Son geniales para encapsular JOINs y filtros complejos. Prefiere inline TVFs sobre multi-statement TVFs para rendimiento. En PostgreSQL, también considera usar views parametrizados con cláusulas WHERE.

sql
-- SQL Server: Inline table-valued function (fast, like a view)
CREATE FUNCTION fn_OrdersByCustomer(@cust_id INT)
RETURNS TABLE
AS
RETURN (
    SELECT o.id, o.order_date, o.total
    FROM orders o
    WHERE o.customer_id = @cust_id
);
-- Usage: SELECT * FROM fn_OrdersByCustomer(42);

-- PostgreSQL: function returning a table
CREATE OR REPLACE FUNCTION get_orders_by_customer(cust_id INT)
RETURNS TABLE(order_id INT, order_date DATE, total DECIMAL) AS $$
    SELECT id, order_date, total
    FROM orders
    WHERE customer_id = cust_id;
$$ LANGUAGE SQL;

-- Usage:
SELECT * FROM get_orders_by_customer(42);

-- Multi-statement TVF (SQL Server) — slower, materializes result
-- CREATE FUNCTION fn_ComplexReport(@date DATE)
-- RETURNS @result TABLE (...)
-- AS BEGIN ... INSERT INTO @result ... RETURN END

Funciones de manipulación de strings

Las funciones de string personalizadas encapsulan lógica de procesamiento de texto que las funciones integradas no cubren. La función get_first_name usa LOCATE y SUBSTRING para extraer la primera palabra. La función make_slug encadena LOWER, REPLACE y REGEXP_REPLACE para crear slugs URL-friendly. Márcalas DETERMINISTIC ya que la misma entrada siempre produce la misma salida. Las funciones de string en SQL son específicas de la base de datos — PostgreSQL tiene split_part(), MySQL tiene SUBSTRING_INDEX(). Crear UDFs estandariza el comportamiento a través de tu aplicación. Ten en cuenta que la manipulación compleja de strings en SQL a menudo es más limpia en código de aplicación.

sql
-- Create a function to split full name into parts
CREATE FUNCTION get_first_name(full_name VARCHAR(200))
RETURNS VARCHAR(100)
DETERMINISTIC
BEGIN
    DECLARE space_pos INT;
    SET space_pos = LOCATE(' ', full_name);
    IF space_pos > 0 THEN
        RETURN SUBSTRING(full_name, 1, space_pos - 1);
    ELSE
        RETURN full_name;
    END IF;
END //

-- Function to generate slug from a title
CREATE FUNCTION make_slug(title VARCHAR(500))
RETURNS VARCHAR(500)
DETERMINISTIC
BEGIN
    DECLARE slug VARCHAR(500);
    SET slug = LOWER(title);
    SET slug = REPLACE(slug, ' ', '-');
    SET slug = REGEXP_REPLACE(slug, '[^a-z0-9-]', '');
    RETURN slug;
END //

-- Usage:
SELECT get_first_name('John Doe Smith') AS first_name;  -- John
SELECT make_slug('Hello World! 2024') AS slug;           -- hello-world-2024

Funciones agregadas (personalizadas)

Las funciones agregadas personalizadas te permiten definir nueva lógica de agregación más allá de SUM, AVG, COUNT. CREATE AGGREGATE de PostgreSQL requiere una función de transición de estado (SFUNC, llamada por fila) y una función final (FINALFUNC, llamada una vez al final). Este ejemplo computa la media geométrica (la raíz n-ésima del producto). Los agregados personalizados son potentes para cálculos estadísticos, financieros o de dominio específico. El estado acumula a través de filas; la función final computa el resultado. MySQL y SQL Server no soportan agregados personalizados directamente — usa stored procedures o computación en el lado de la aplicación en su lugar.

sql
-- PostgreSQL: custom aggregate function
-- Step 1: Define a state transition function
CREATE OR REPLACE FUNCTION geom_mean_state(state numeric[], val numeric)
RETURNS numeric[] AS $$
BEGIN
    IF val IS NULL THEN RETURN state; END IF;
    IF state IS NULL THEN
        RETURN ARRAY[val];
    ELSE
        RETURN state || val;
    END IF;
END;
$$ LANGUAGE plpgsql;

-- Step 2: Define the final function
CREATE OR REPLACE FUNCTION geom_mean_final(vals numeric[])
RETURNS numeric AS $$
BEGIN
    IF vals IS NULL OR array_length(vals, 1) IS NULL THEN
        RETURN NULL;
    END IF;
    RETURN exp(avg(ln(v)) FROM unnest(vals) AS v);
END;
$$ LANGUAGE plpgsql;

-- Step 3: Create the aggregate
CREATE AGGREGATE geometric_mean(numeric) (
    SFUNC = geom_mean_state,
    STYPE = numeric[],
    FINALFUNC = geom_mean_final,
    INITCOND = '{}'
);

-- Usage:
SELECT category, geometric_mean(price) FROM products GROUP BY category;

Función vs stored procedure

Las funciones y stored procedures sirven diferentes propósitos. Las funciones devuelven un valor y pueden embebirse en SELECT/WHERE — deben ser deterministic-ish (sin side effects en la mayoría de bases de datos). Los stored procedures pueden modificar datos, gestionar transacciones y devolver múltiples result sets — pero no pueden usarse dentro de consultas (llama con CALL/EXEC). Usa funciones para computaciones y recuperación de datos; usa procedures para operaciones multi-paso (transferencias, batch processing, ETL). Las funciones son componibles; los procedures son imperativos. En PostgreSQL, las funciones pueden hacer casi todo lo que los procedures pueden (incluyendo modificación de datos), difuminando la distinción.

sql
-- FUNCTIONS: return values, usable in queries, no side effects
CREATE FUNCTION get_full_name(fname VARCHAR, lname VARCHAR)
RETURNS VARCHAR(200)
DETERMINISTIC
RETURN CONCAT(fname, ' ', lname);

-- Can be used in SELECT:
SELECT get_full_name(first, last) FROM users;  -- OK
SELECT * FROM users WHERE get_full_name(first, last) LIKE 'J%';  -- OK

-- STORED PROCEDURES: can modify data, use transactions, return result sets
CREATE PROCEDURE transfer_funds(
    IN from_acct INT, IN to_acct INT, IN amount DECIMAL(10,2)
)
BEGIN
    DECLARE EXIT HANDLER FOR SQLEXCEPTION
    BEGIN
        ROLLBACK;
        RESIGNAL;
    END;
    START TRANSACTION;
    UPDATE accounts SET balance = balance - amount WHERE id = from_acct;
    UPDATE accounts SET balance = balance + amount WHERE id = to_acct;
    INSERT INTO transfers (from_id, to_id, amount) VALUES (from_acct, to_acct, amount);
    COMMIT;
END //

-- Call procedure (can't use in SELECT):
CALL transfer_funds(1, 2, 100.00);
16

Diseño de base de datos y normalización

Primera forma normal (1NF)

La Primera Forma Normal requiere valores atómicos — cada celda contiene una pieza de datos, no listas o arrays. Los valores separados por comas en una columna violan 1NF porque no puedes consultar, indexar o actualizar elementos individuales. La solución: crea una fila por elemento (con una primary key compuesta) o divide en una tabla de detalle separada. 1NF también requiere una primary key para identificar únicamente cada fila. Violar 1NF hace que consultas como 'encontrar todos los pedidos que contienen un mouse' requieran parsing de strings — lento y propenso a errores. Siempre comienza con cumplimiento de 1NF.

sql
-- 1NF: each column contains atomic (indivisible) values
--       no repeating groups, each row is unique

-- VIOLATION: comma-separated values in one column
CREATE TABLE bad_orders (
    id INT,
    customer_name VARCHAR(100),
    products VARCHAR(500)  -- "laptop, mouse, keyboard" — BAD!
);

-- 1NF COMPLIANT: separate row per product
CREATE TABLE orders (
    order_id INT,
    customer_name VARCHAR(100),
    product_name VARCHAR(100),  -- one product per row
    PRIMARY KEY (order_id, product_name)  -- composite key for uniqueness
);

-- Better: normalize further with separate tables
CREATE TABLE orders (order_id INT PRIMARY KEY, customer_name VARCHAR(100));
CREATE TABLE order_items (
    order_id INT REFERENCES orders(order_id),
    product_name VARCHAR(100),
    quantity INT,
    PRIMARY KEY (order_id, product_name)
);

Segunda y tercera forma normal (2NF, 3NF)

2NF elimina dependencias parciales — cada columna no clave debe depender de la primary key ENTERA, no solo parte de ella. Esto solo importa con claves compuestas. 3NF elimina dependencias transitivas — las columnas no clave deben depender solo de la primary key, no de otras columnas no clave. Por ejemplo, customer_name depende de customer_id, que depende de order_id (transitiva). La normalización reduce redundancia de datos (almacena cada hecho una vez) y anomalías (actualiza el nombre del cliente en un lugar, no en cada pedido). La mayoría de bases de datos prácticas apuntan a 3NF o BCNF.

sql
-- 2NF: 1NF + no partial dependencies (non-key attrs depend on FULL key)
-- Problem: order_items has composite key (order_id, product_id)
-- but product_name depends only on product_id (partial dependency)

-- VIOLATION of 2NF:
CREATE TABLE bad_order_items (
    order_id INT,
    product_id INT,
    product_name VARCHAR(100),  -- depends on product_id only!
    quantity INT,
    PRIMARY KEY (order_id, product_id)
);

-- 2NF COMPLIANT: move product_name to products table
CREATE TABLE products (
    product_id INT PRIMARY KEY,
    product_name VARCHAR(100)
);
CREATE TABLE order_items (
    order_id INT,
    product_id INT REFERENCES products(product_id),
    quantity INT,
    PRIMARY KEY (order_id, product_id)
);

-- 3NF: 2NF + no transitive dependencies
-- (non-key attrs don't depend on other non-key attrs)
-- Problem: orders table has customer_name that depends on customer_id
-- Solution: separate customers table (see above)

Desnormalización (cuándo romper reglas)

La desnormalización viola intencionalmente formas normales para mejorar el rendimiento de lectura a costa de complejidad de escritura y almacenamiento. En bases de datos normalizadas, recuperar un pedido completo requiere 4 JOINs — costoso para dashboards de alto tráfico. Las tablas desnormalizadas pre-une y pre-computa datos para lecturas rápidas. El trade-off: las escrituras deben actualizar múltiples lugares (riesgo de inconsistencia) y el almacenamiento aumenta. Usa desnormalización para sistemas de lectura pesada (analítica, reportes, data warehouses). Las materialized views proporcionan desnormalización gestionada — la base de datos maneja el refresh. Los sistemas OLTP deberían mantenerse normalizados; los sistemas OLAP típicamente están desnormalizados (esquemas star/snowflake).

sql
-- Denormalization: intentionally adding redundancy for performance
-- Normalized (3NF): requires JOINs to get full order info
SELECT o.order_id, c.name, p.product_name, oi.quantity, p.price
FROM orders o
JOIN customers c ON o.customer_id = c.id
JOIN order_items oi ON o.order_id = oi.order_id
JOIN products p ON oi.product_id = p.id;

-- Denormalized: store redundant data for read speed
CREATE TABLE order_summary (
    order_id INT PRIMARY KEY,
    customer_id INT,
    customer_name VARCHAR(100),    -- redundant (also in customers)
    customer_email VARCHAR(200),   -- redundant
    total_amount DECIMAL(10,2),    -- pre-calculated
    item_count INT,                -- pre-calculated
    order_date TIMESTAMP
);

-- Trade-off: faster reads, slower writes, risk of inconsistency
-- Use for: reporting tables, read-heavy dashboards, data warehouses

-- Materialized views are a managed form of denormalization:
CREATE MATERIALIZED VIEW sales_summary AS
SELECT date, region, SUM(amount) AS total FROM sales GROUP BY date, region;
REFRESH MATERIALIZED VIEW sales_summary;

Primary keys, foreign keys y restricciones

Las restricciones aplican integridad de datos a nivel de base de datos. PRIMARY KEY identifica filas únicamente y crea un índice clustered. UNIQUE previene duplicados (permite múltiples NULLs en la mayoría de bases de datos). CHECK aplica reglas personalizadas (salary > 0). FOREIGN KEY mantiene integridad referencial — ON DELETE SET NULL/CASCADE/RESTRICT controla qué pasa cuando se elimina una fila padre. ON UPDATE CASCADE propaga cambios PK a FKs. Las restricciones son la última línea de defensa contra datos malos — incluso si el código de aplicación tiene bugs, la base de datos rechaza datos inválidos. Siempre define restricciones; son documentación y aplicación combinadas.

sql
CREATE TABLE departments (
    dept_id INT PRIMARY KEY AUTO_INCREMENT,
    dept_name VARCHAR(100) NOT NULL UNIQUE,
    budget DECIMAL(12,2) CHECK (budget >= 0),
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

CREATE TABLE employees (
    emp_id INT PRIMARY KEY AUTO_INCREMENT,
    emp_name VARCHAR(100) NOT NULL,
    email VARCHAR(200) UNIQUE,
    dept_id INT,
    salary DECIMAL(10,2) CHECK (salary > 0 AND salary < 1000000),
    hire_date DATE NOT NULL,
    manager_id INT REFERENCES employees(emp_id),  -- self-reference
    FOREIGN KEY (dept_id) REFERENCES departments(dept_id)
        ON DELETE SET NULL      -- don't delete dept if employees exist
        ON UPDATE CASCADE,      -- update FK if dept_id changes
    INDEX idx_dept (dept_id),
    INDEX idx_email (email)
);

-- Constraint types:
-- PRIMARY KEY: unique + not null (clustered index)
-- UNIQUE: no duplicates (allows NULLs)
-- NOT NULL: required field
-- CHECK: custom condition
-- FOREIGN KEY: referential integrity
-- DEFAULT: value when not specified

Estrategia de indexación

Los índices aceleran dramáticamente las lecturas pero ralentizan las escrituras (cada índice debe actualizarse en INSERT/UPDATE/DELETE). Los índices B-tree soportan igualdad, rango y ordenamiento. Los índices compuestos siguen la regla del prefijo más a la izquierda — puedes usar (a, b) para consultas en a o a+b, pero no b solo. Los covering indexes (cláusula INCLUDE) almacenan columnas extra para que la consulta nunca toque la tabla — extremadamente rápido. Los partial indexes indexan solo un subconjunto de filas, ahorrando espacio. Monitorea el uso de índices (pg_stat_user_indexes en PostgreSQL) y elimina los no usados. Una buena regla: indexa foreign keys y columnas en cláusulas WHERE/JOIN. Sobre-indexar daña el rendimiento de escritura y desperdicia almacenamiento.

sql
-- B-tree index (default): good for =, <, >, BETWEEN, ORDER BY
CREATE INDEX idx_last_name ON employees(last_name);

-- Composite index: order matters! (leftmost prefix rule)
CREATE INDEX idx_dept_salary ON employees(dept_id, salary);
-- Usable for: WHERE dept_id = 5
-- Usable for: WHERE dept_id = 5 AND salary > 50000
-- NOT usable for: WHERE salary > 50000 (skips dept_id)

-- Covering index: includes all columns a query needs
CREATE INDEX idx_covering ON orders(customer_id, order_date)
    INCLUDE (total_amount, status);
-- Query can be satisfied from index alone (no table lookup)

-- Partial/partial index: index only matching rows
CREATE INDEX idx_active_users ON users(last_login)
    WHERE active = true;  -- PostgreSQL

-- Don't over-index: every index slows writes
-- Index columns used in: WHERE, JOIN, ORDER BY, GROUP BY
-- Drop unused indexes: SELECT * FROM pg_stat_user_indexes;
17

EXPLAIN y optimización de consultas

Lectura de output de EXPLAIN

EXPLAIN revela cómo la base de datos ejecuta una consulta — qué índices se usan, cómo se unen las tablas y cuántas filas se examinan. EXPLAIN ANALYZE (PostgreSQL) o EXPLAIN con ejecución (MySQL 8.0+) realmente ejecuta la consulta y muestra timings reales. Busca: Seq Scan / ALL (full table scan — malo para tablas grandes), Index Scan (bueno), estimación de filas (alta = costoso). 'Using filesort' o 'Using temporary' en MySQL indica trabajo extra. Si EXPLAIN muestra un full table scan en una tabla grande, necesitas un índice. Siempre EXPLAIN antes de optimizar — no adivines.

sql
-- EXPLAIN shows the query execution plan
EXPLAIN SELECT * FROM orders WHERE customer_id = 42;

-- EXPLAIN ANALYZE actually runs the query (PostgreSQL)
EXPLAIN ANALYZE SELECT * FROM orders WHERE customer_id = 42;

-- PostgreSQL output:
-- Index Scan using idx_customer on orders  (cost=0.29..8.31 rows=1 width=74)
--   Index Cond: (customer_id = 42)
--   Execution Time: 0.042 ms

-- Key columns to check:
-- - type: scan method (const > eq_ref > ref > range > index > ALL)
-- - rows: estimated rows examined (lower is better)
-- - key: which index is used (NULL = no index, bad!)
-- - Extra: "Using filesort" or "Using temporary" = warning signs

-- MySQL: EXPLAIN FORMAT=JSON for detailed output
-- SQL Server: SET SHOWPLAN_TEXT ON; or Actual Execution Plan in SSMS

Problemas comunes de rendimiento

Varios patrones comunes impiden el uso de índices y causan full table scans. Las funciones en columnas indexadas (YEAR(date), UPPER(name)) impiden el uso de índices — reescríbelas como condiciones de rango. Los wildcards iniciales en LIKE ('%pattern') no pueden usar índices B-tree — usa full-text search en su lugar. SELECT * desperdicia ancho de banda e impide la optimización de covering index. Las conversiones de tipo implícitas (comparar columna string a entero) pueden deshabilitar índices. Las condiciones OR a veces son menos eficientes que IN. Siempre verifica con EXPLAIN que tus índices se estén usando realmente — un índice no usado es almacenamiento desperdiciado y overhead de escritura.

sql
-- 1. Missing index → full table scan
-- Bad: SELECT * FROM orders WHERE customer_id = 42; (no index)
-- Fix: CREATE INDEX idx_customer ON orders(customer_id);

-- 2. Index not used due to function on column
-- Bad: WHERE YEAR(order_date) = 2024  (function prevents index use)
-- Good: WHERE order_date >= '2024-01-01' AND order_date < '2025-01-01'

-- 3. SELECT * instead of specific columns
-- Bad: SELECT * FROM large_table WHERE id = 1;
-- Good: SELECT id, name, email FROM large_table WHERE id = 1;

-- 4. OR conditions preventing index use
-- Bad: WHERE dept = 'A' OR dept = 'B' OR dept = 'C'
-- Good: WHERE dept IN ('A', 'B', 'C')

-- 5. LIKE with leading wildcard
-- Bad: WHERE name LIKE '%son'  (can't use index)
-- OK:  WHERE name LIKE 'John%'  (can use index)

-- 6. Implicit type conversion
-- Bad: WHERE string_column = 123  (converts to string, skips index)
-- Good: WHERE string_column = '123'

Optimización de JOIN

La optimización de JOIN es crítica para consultas multi-tabla. Asegúrate de que las columnas de join (usualmente foreign keys) estén indexadas — los joins no indexados causan nested loop scans (O(n*m)). El query optimizer usualmente elige el mejor orden de join, pero puedes ayudar filtrando temprano (WHERE antes de JOIN conceptualmente). INNER JOIN es más rápido que OUTER JOIN cuando no necesitas filas no coincidentes. EXISTS a menudo es más eficiente que IN para subqueries correlacionadas porque short-circuit en la primera coincidencia. Evita unir tablas que no necesitas — cada join multiplica el trabajo. Para reportes complejos, considera materialized views o tablas de resumen pre-agregadas.

sql
-- Join order matters: smallest table first (optimizer usually handles this)
-- Ensure join columns are indexed (usually foreign keys)
CREATE INDEX idx_orders_customer ON orders(customer_id);
CREATE INDEX idx_order_items_order ON order_items(order_id);

-- Use INNER JOIN when you don't need unmatched rows (faster than OUTER)
SELECT c.name, COUNT(o.order_id) AS order_count
FROM customers c
INNER JOIN orders o ON c.id = o.customer_id
GROUP BY c.name;

-- Avoid joining unnecessary tables — fetch details lazily if needed
-- Bad: join 5 tables when you only need 2 columns
-- Good: split into simpler queries or use a covering index

-- EXISTS vs IN for subqueries
-- EXISTS is often faster for large subquery results:
SELECT name FROM customers c
WHERE EXISTS (
    SELECT 1 FROM orders o WHERE o.customer_id = c.id
);
-- IN is better for small lists:
SELECT * FROM orders WHERE customer_id IN (1, 2, 3);

Optimización de paginación

La paginación basada en OFFSET (LIMIT 10 OFFSET 10000) es O(n) — la base de datos debe escanear y descartar todas las filas saltadas, haciendo las páginas profundas extremadamente lentas. La paginación keyset (cursor) usa WHERE last_value < cursor para buscar directamente — O(1) independientemente de la profundidad de página. Esto requiere un índice en la columna de ordenamiento. Para empates (mismo timestamp), usa un cursor compuesto (created_at, id). Evita COUNT(*) para conteos totales en tablas grandes — escanea la tabla entera. Usa conteos aproximados (pg_class.reltuples en PostgreSQL) o no muestres conteos totales (scroll infinito). La paginación keyset es el estándar para APIs de alto rendimiento.

sql
-- Bad: OFFSET pagination (slow for large offsets)
SELECT * FROM orders ORDER BY created_at DESC LIMIT 10 OFFSET 10000;
-- Must scan and discard 10000 rows — gets slower as you page deeper

-- Good: Keyset (cursor) pagination using WHERE
SELECT * FROM orders
WHERE created_at < '2024-01-15 10:30:00'  -- last seen value
ORDER BY created_at DESC
LIMIT 10;
-- Uses index efficiently — constant time regardless of page depth

-- For composite keyset pagination (handles ties):
SELECT * FROM orders
WHERE (created_at, id) < ('2024-01-15 10:30:00', 12345)
ORDER BY created_at DESC, id DESC
LIMIT 10;

-- Count total (expensive on large tables — avoid if possible)
-- Instead of COUNT(*), use an approximate count:
SELECT reltuples::bigint FROM pg_class WHERE relname = 'orders';

Reescritura de consultas y checklist de optimización

La optimización de consultas es un proceso iterativo: EXPLAIN, identifica bottlenecks, reescribe, repite. Técnicas clave: reemplaza subqueries IN con JOINs (a menudo más rápido), usa UNION ALL en lugar de UNION (salta el sort de deduplicación), batch INSERTs (1 query vs 1000), y usa prepared statements (cachea el query plan). Las CTEs mejoran legibilidad pero en versiones antiguas de PostgreSQL se materializan (no pueden optimizarse) — PostgreSQL 12+ las inlinea. Mantén las estadísticas de tabla actualizadas (ANALYZE) para que el planner tome buenas decisiones. La regla de oro: mide con EXPLAIN ANALYZE, no adivines. Lo que es rápido en una base de datos/versión puede ser lento en otra.

sql
-- 1. Replace subqueries with JOINs when possible
-- Slow: SELECT * FROM orders WHERE customer_id IN
--       (SELECT id FROM customers WHERE active = true);
-- Fast: SELECT o.* FROM orders o
--       JOIN customers c ON o.customer_id = c.id WHERE c.active = true;

-- 2. Use UNION ALL instead of UNION (avoids dedup sort)
SELECT 'A' UNION ALL SELECT 'B';  -- fast
SELECT 'A' UNION SELECT 'B';       -- sorts to remove duplicates

-- 3. Batch operations instead of row-by-row
-- Slow: 1000 individual INSERTs
-- Fast: INSERT INTO t VALUES (1,'a'), (2,'b'), (3,'c'), ...;

-- 4. Use CTEs for readability, but know they may not optimize well
-- (PostgreSQL 12+ inlines CTEs; older versions materialize them)

-- 5. Avoid SELECT DISTINCT when you can use GROUP BY or EXISTS
-- 6. Use prepared statements for repeated queries (plan caching)
PREPARE get_user AS SELECT * FROM users WHERE id = $1;
EXECUTE get_user(42);

-- 7. Analyze tables for up-to-date statistics
ANALYZE orders;  -- PostgreSQL: updates planner statistics
18

Comparación NoSQL vs SQL

SQL vs NoSQL: cuándo usar qué

La elección SQL vs NoSQL depende de tu modelo de datos, requisitos de consistencia y escala. Las bases de datos SQL aplican esquema, soportan transacciones ACID y destacan en consultas complejas con JOINs — ideales para sistemas financieros y cualquier app donde la integridad de datos sea primordial. Las bases de datos NoSQL intercambian consistencia por escalabilidad y flexibilidad: document stores (MongoDB) para esquemas evolutivos, key-value stores (Redis) para caching, column-family (Cassandra) para throughput masivo de escritura, y bases de datos de grafos (Neo4j) para datos ricos en relaciones. Las bases de datos SQL modernas ahora soportan JSON, full-text search y escalado, reduciendo la necesidad de NoSQL en muchos casos.

sql
-- SQL (Relational): structured, consistent, queryable
-- Best for: financial systems, e-commerce, CRM, any ACID-requiring app
-- Examples: PostgreSQL, MySQL, SQL Server, Oracle

-- NoSQL: flexible schema, horizontal scaling, specific data models
-- Document: MongoDB, CouchDB — JSON-like documents, flexible schema
-- Key-Value: Redis, DynamoDB — fast lookups, caching, sessions
-- Column-Family: Cassandra, HBase — wide-column, time-series, write-heavy
-- Graph: Neo4j, ArangoDB — relationships, social networks, recommendations

-- Decision factors:
-- 1. Data structure: fixed → SQL, evolving/varied → NoSQL
-- 2. Consistency: strict ACID → SQL, eventual consistency OK → NoSQL
-- 3. Scale: vertical (bigger server) → SQL, horizontal (more servers) → NoSQL
-- 4. Queries: complex JOINs → SQL, simple lookups → NoSQL
-- 5. Team expertise: SQL is universal, NoSQL varies

-- Many modern databases blur the line:
-- PostgreSQL: JSON columns, full-text search, pub/sub
-- MongoDB: transactions (multi-document ACID since 4.0)

Patrones de document store (estilo MongoDB)

Los document stores embeben datos relacionados en un solo documento en lugar de normalizar a través de tablas. Esto elimina JOINs para patrones de acceso de lectura pesada pero duplica datos (info del cliente en cada pedido). El embedding funciona cuando los datos se acceden juntos y tienen un tamaño acotado. Para relaciones no acotadas (un cliente con miles de pedidos), usa referencing (almacena customer_id, fetch por separado). Las columnas JSONB de PostgreSQL te dan flexibilidad de document store dentro de una base de datos relacional — obtienes transacciones ACID, indexación (GIN) y consultas SQL en JSON. Este enfoque híbrido es cada vez más popular, reduciendo la necesidad de una base de datos NoSQL separada.

sql
-- In SQL, you'd normalize this into 3 tables:
-- customers, orders, order_items

-- In a document store (MongoDB), you might embed everything:
-- (pseudo-code, not SQL)
// db.orders.insertOne({
//   _id: 1,
//   customer: { name: "Alice", email: "[email protected]" },
//   items: [
//     { product: "Laptop", price: 999, qty: 1 },
//     { product: "Mouse", price: 25, qty: 2 }
//   ],
//   total: 1049,
//   status: "shipped",
//   created_at: ISODate("2024-01-15")
// })

-- SQL equivalent with JSON column (PostgreSQL):
CREATE TABLE orders (
    id SERIAL PRIMARY KEY,
    data JSONB NOT NULL  -- stores the entire document
);
INSERT INTO orders (data) VALUES ('{
    "customer": {"name": "Alice", "email": "[email protected]"},
    "items": [{"product": "Laptop", "price": 999, "qty": 1}],
    "total": 999
}');

-- Query JSON data:
SELECT data->'customer'->>'name' AS name FROM orders
WHERE data->'total' > 500;

Patrones de key-value store (estilo Redis)

Los key-value stores como Redis destacan en lookups ultra-rápidos (sub-milisegundo) porque los datos viven en memoria. Casos de uso comunes: caching de resultados de consultas costosas, session storage (con expiración TTL), contadores en tiempo real (INCR atómico) y leaderboards (sorted sets). Las estructuras de datos de Redis (lists, sets, sorted sets, hashes) van más allá del simple key-value. El trade-off: los datos están en memoria (limitados por RAM) y la persistencia es opcional. Usa Redis como capa de cache frente a SQL — patrones write-through o cache-aside. Para datos de sesión, la expiración automática de Redis (TTL) es ideal. Las bases de datos SQL pueden emular caching con una tabla de cache, pero no pueden igualar la velocidad de Redis para datos calientes.

sql
-- Redis is an in-memory key-value store — not SQL
-- Common patterns (Redis commands, not SQL):

-- Caching: store expensive query results
// SET user:42:profile '{"name":"Alice","age":30}' EX 3600
// GET user:42:profile  -- returns cached data, expires in 1 hour

-- Session storage: fast, ephemeral
// SET session:abc123 '{"user_id":42}' EX 1800  -- 30 min TTL

-- Counters & leaderboards:
// INCR page:home:views           -- atomic counter
// ZADD leaderboard 1500 "alice"  -- sorted set for rankings
// ZREVRANGE leaderboard 0 9      -- top 10 players

-- SQL equivalent for caching (materialized/precomputed):
CREATE TABLE cache (
    cache_key VARCHAR(200) PRIMARY KEY,
    cache_value TEXT,
    expires_at TIMESTAMP,
    INDEX idx_expires (expires_at)
);
-- Periodically: DELETE FROM cache WHERE expires_at < NOW();

-- When to use Redis vs SQL cache:
-- Redis: sub-millisecond reads, data structures (sets, sorted sets)
-- SQL: when you need ACID or already have a database connection

Persistencia polyglot (mezclando bases de datos)

La persistencia polyglot usa diferentes bases de datos para diferentes necesidades de datos dentro de una aplicación. PostgreSQL maneja transacciones, Redis maneja caching, Elasticsearch maneja búsqueda, S3 maneja archivos. El desafío es mantener los datos consistentes entre stores — la solución es arquitectura event-driven: escribe a la base de datos primaria (source of truth), luego propaga cambios asíncronamente a otros stores vía Change Data Capture (CDC) o message queues (Kafka, RabbitMQ). Esto da eventual consistency — las lecturas de stores secundarios pueden retrasarse un poco. El beneficio: cada store está optimizado para su workload. El costo: complejidad operacional. Comienza con una sola base de datos SQL; añade stores especializados solo cuando alcances límites de rendimiento claros.

sql
-- Modern applications often use MULTIPLE database types:
-- Each data store handles what it does best

-- Typical architecture:
-- 1. PostgreSQL: core transactional data (users, orders, payments)
--    ACID guarantees, complex queries, foreign keys
CREATE TABLE users (id SERIAL PRIMARY KEY, email VARCHAR UNIQUE);
CREATE TABLE orders (id SERIAL PRIMARY KEY, user_id INT REFERENCES users(id));

-- 2. Redis: session storage, caching, rate limiting
--    Fast in-memory access, auto-expiring keys

-- 3. Elasticsearch: full-text search, log analytics
--    Inverted index, faceted search, aggregations

-- 4. S3/Object storage: files, images, backups
--    Cheap, unlimited, HTTP-accessible

-- 5. TimescaleDB/InfluxDB: time-series metrics
--    Optimized for timestamped data, downsampling

-- Challenge: data consistency across stores
-- Solution: event-driven architecture (CDC, message queues)
--   1. Write to PostgreSQL (source of truth)
--   2. Publish event to Kafka
--   3. Consumers update Redis cache, Elasticsearch index, etc.
--   4. eventual consistency — reads may be slightly stale

Modelos de consistencia ACID vs BASE

ACID (Atomicity, Consistency, Isolation, Durability) garantiza consistencia estricta — las transacciones son all-or-nothing y los datos siempre satisfacen restricciones. Esto es esencial para sistemas financieros donde updates parciales causarían errores. BASE (Basically Available, Soft state, Eventually consistent) intercambia consistencia inmediata por disponibilidad y partition tolerance — los datos pueden estar temporalmente inconsistentes pero convergen con el tiempo. El teorema CAP establece que no puedes tener los tres (Consistency, Availability, Partition tolerance) simultáneamente durante network partitions. Las bases de datos SQL priorizan C+A (nodo único) o C+P (distribuido). Muchas bases de datos NoSQL priorizan A+P (Cassandra, DynamoDB). Elige ACID cuando la corrección es crítica; BASE cuando la disponibilidad y escala importan más.

sql
-- ACID (SQL databases):
-- Atomicity: all operations in a transaction succeed or fail together
-- Consistency: data always satisfies constraints (FK, CHECK, etc.)
-- Isolation: concurrent transactions don't interfere
-- Durability: committed data survives crashes

-- SQL transaction example:
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
-- If either fails, ROLLBACK undoes both
COMMIT;  -- both updates are permanent

-- BASE (NoSQL databases):
-- Basically Available: system responds (may be stale)
-- Soft state: state changes without input (eventual consistency)
-- Eventually consistent: data converges over time

-- NoSQL trade-off: higher availability & partition tolerance
-- at the cost of immediate consistency

-- CAP Theorem: in a network partition, choose:
-- - CP (consistency): refuse writes (some NoSQL: HBase, MongoDB)
-- - AP (availability): accept writes, reconcile later (Cassandra, DynamoDB)

-- SQL databases are typically CA (consistent + available, no partition tolerance
-- in single-node setups; distributed SQL like CockroachDB adds P)
19

CTE y CTE recursiva

CTE básica

CTE (Common Table Expression) es un conjunto de resultados temporal con nombre. Mejora la legibilidad rompiendo consultas complejas. Múltiples CTEs pueden encadenarse con comas. Las CTEs solo son válidas para el statement único.

sql
WITH high_earners AS (
  SELECT * FROM employees WHERE salary > 80000
), by_dept AS (
  SELECT department, COUNT(*) AS cnt FROM high_earners GROUP BY department
)
SELECT * FROM by_dept ORDER BY cnt DESC;

CTE recursiva

Las CTEs recursivas se referencian a sí mismas. El ancla es el caso base. UNION ALL conecta con la parte recursiva. Usada para datos jerárquicos: organigramas, sistemas de archivos, recorrido de grafos. Debe tener una condición de terminación.

sql
WITH RECURSIVE org_chart AS (
  -- Anchor: top-level managers
  SELECT id, name, manager_id, 1 AS level
  FROM employees WHERE manager_id IS NULL
  UNION ALL
  -- Recursive: subordinates
  SELECT e.id, e.name, e.manager_id, oc.level + 1
  FROM employees e
  JOIN org_chart oc ON e.manager_id = oc.id
)
SELECT * FROM org_chart;

Fibonacci con CTE

Las CTEs recursivas pueden generar secuencias. El ancla proporciona el primer valor. Cada iteración computa el siguiente. La cláusula WHERE previene recursión infinita. Útil para secuencias matemáticas.

sql
WITH RECURSIVE fib(n, a, b) AS (
  SELECT 1, 0, 1
  UNION ALL
  SELECT n + 1, b, a + b FROM fib WHERE n < 10
)
SELECT n, a FROM fib;
-- Result: 0, 1, 1, 2, 3, 5, 8, 13, 21, 34

Recorrido de árbol

Construye paths concatenando nombres en cada recursión. El CAST asegura que la columna path sea lo suficientemente amplia. Útil para breadcrumbs, file paths y jerarquías de categorías. ORDER BY path ordena jerárquicamente.

sql
WITH RECURSIVE category_tree AS (
  SELECT id, name, parent_id, CAST(name AS VARCHAR(1000)) AS path
  FROM categories WHERE parent_id IS NULL
  UNION ALL
  SELECT c.id, c.name, c.parent_id, ct.path || ' > ' || c.name
  FROM categories c JOIN category_tree ct ON c.parent_id = ct.id
)
SELECT id, path FROM category_tree ORDER BY path;

CTE vs subquery

Las CTEs mejoran la legibilidad y pueden referenciarse múltiples veces. Las subqueries son inline y no pueden reusarse. Las CTEs no siempre se materializan; el optimizer puede inlinearlas. Usa CTEs para claridad.

sql
-- CTE: more readable, can be referenced multiple times
WITH active_users AS (SELECT * FROM users WHERE active = 1)
SELECT * FROM active_users WHERE age > 18
UNION ALL
SELECT * FROM active_users WHERE age <= 18;
-- Subquery: inline, cannot be reused
SELECT * FROM (SELECT * FROM users WHERE active = 1) au WHERE au.age > 18;
20

Índices en profundidad

Índice B-Tree

B-Tree es el tipo de índice default. Los índices compuestos siguen la regla del prefijo más a la izquierda: una consulta puede usar el índice si filtra en columnas iniciales. Ordena columnas por selectividad y patrones de consulta.

sql
CREATE INDEX idx_email ON users(email);
CREATE INDEX idx_name_age ON users(last_name, first_name, age);
-- Composite index: useful for
-- WHERE last_name = 'Smith' AND first_name = 'John'
-- WHERE last_name = 'Smith' (leftmost prefix)

Partial index

Los partial indexes solo incluyen filas que coinciden con la cláusula WHERE. Más pequeños y más rápidos que índices completos. Ideales para consultas que siempre filtran en una condición. Reduce el overhead de escritura.

sql
CREATE INDEX idx_active_users ON users(last_login) WHERE active = 1;
-- Only indexes active users, saving space
-- Useful when queries always filter on active = 1

Covering index

Un covering index incluye todas las columnas necesitadas por una consulta, habilitando index-only scans. PostgreSQL usa INCLUDE para columnas no clave. Acelera dramáticamente consultas SELECT evitando lookups a tabla.

sql
-- PostgreSQL: INCLUDE clause
CREATE INDEX idx_users_covering ON users(last_name) INCLUDE (first_name, email);
-- The query is "covered" if all columns are in the index:
SELECT first_name, email FROM users WHERE last_name = 'Smith';
-- No table lookup needed (index-only scan)

Tipos de índice

Diferentes tipos de índice sirven diferentes necesidades. B-Tree para uso general. Hash solo para igualdad. GIN para full-text y JSON. GiST para datos geométricos. Elige basándote en patrones de consulta.

sql
-- B-Tree: default, good for equality and range
CREATE INDEX idx_btree ON users(email);
-- Hash: equality only (PostgreSQL)
CREATE INDEX idx_hash ON users(email) USING HASH;
-- GIN: full-text search, arrays, JSON
CREATE INDEX idx_gin ON docs USING GIN (tsv);
-- GiST: geometric, range types
CREATE INDEX idx_gist ON places USING GIST (location);

Mantenimiento de índices

Monitorea el uso de índices para remover índices no usados que ralentizan escrituras. REINDEX reconstruye índices fragmentados. ANALYZE actualiza estadísticas para el query planner. El mantenimiento regular mantiene el rendimiento óptimo.

sql
-- Check index usage (PostgreSQL)
SELECT * FROM pg_stat_user_indexes;
-- Find unused indexes
SELECT relname, indexrelname FROM pg_stat_user_indexes WHERE idx_scan = 0;
-- Rebuild fragmented index
REINDEX INDEX idx_users_email;
-- Analyze for query planner
ANALYZE users;
21

Transacciones

Propiedades ACID

ACID: Atomicity (todo o nada), Consistency (estado válido), Isolation (transacciones concurrentes no interfieren), Durability (los datos commiteados persisten). BEGIN inicia, COMMIT guarda, ROLLBACK deshace.

sql
BEGIN TRANSACTION;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
COMMIT;
-- Or: ROLLBACK to undo

Savepoints

Los savepoints crean puntos de rollback parcial dentro de una transacción. ROLLBACK TO deshace hasta el savepoint sin terminar la transacción. Útil para manejar errores en operaciones multi-paso sin reiniciar.

sql
BEGIN;
INSERT INTO orders VALUES (1);
SAVEPOINT sp1;
INSERT INTO orders VALUES (2);
-- Oops, rollback to savepoint
ROLLBACK TO sp1;
-- Only order 1 is inserted
INSERT INTO orders VALUES (3);
COMMIT;

Niveles de aislamiento

Los niveles de aislamiento balancean consistencia vs rendimiento. READ COMMITTED (default) previene dirty reads. REPEATABLE READ previene non-repeatable reads. SERIALIZABLE previene phantom reads pero es el más lento.

sql
SET TRANSACTION ISOLATION LEVEL READ COMMITTED;
-- Levels (increasing isolation):
-- READ UNCOMMITTED: dirty reads
-- READ COMMITTED: no dirty reads (default)
-- REPEATABLE READ: no non-repeatable reads
-- SERIALIZABLE: full isolation

Deadlocks

Los deadlocks ocurren cuando las transacciones mantienen locks que la otra necesita. Las bases de datos detectan deadlocks y abortan una transacción. Prevén accediendo tablas en orden consistente. Mantén las transacciones cortas.

sql
-- Transaction 1
BEGIN;
UPDATE accounts SET balance = 0 WHERE id = 1;
UPDATE accounts SET balance = 0 WHERE id = 2;  -- Waits
-- Transaction 2
BEGIN;
UPDATE accounts SET balance = 0 WHERE id = 2;
UPDATE accounts SET balance = 0 WHERE id = 1;  -- Waits
-- Deadlock! Database aborts one transaction

Optimistic locking

El optimistic locking asume que los conflictos son raros. La columna version rastrea cambios. Si el UPDATE afecta 0 filas, los datos fueron modificados por otra transacción. Reintenta o notifica al usuario. Evita mantener locks largos.

sql
-- Add version column
ALTER TABLE products ADD COLUMN version INT DEFAULT 0;
-- Update with version check
UPDATE products SET price = 100, version = version + 1
WHERE id = 1 AND version = 5;
-- If 0 rows affected, someone else updated first
22

JSON en SQL

PostgreSQL JSONB

JSONB almacena JSON en formato binario, habilitando indexación y consultas rápidas. ->> extrae como texto, -> extrae como JSON. JSONB es preferible a JSON para consultas. Usa índices GIN para columnas JSONB.

sql
CREATE TABLE events (id SERIAL, data JSONB);
INSERT INTO events (data) VALUES ('{"user": "alice", "action": "login"}');
SELECT data->>'user' AS user_name FROM events;
SELECT * FROM events WHERE data->>'action' = 'login';

Consultas JSON

-> navega JSON, ->> devuelve texto. @> verifica containment. jsonb_set actualiza valores anidados. jsonb_object_keys devuelve claves de nivel superior. Estos operadores habilitan consultas JSON potentes.

sql
SELECT data->'address'->'city' AS city FROM users;
SELECT * FROM users WHERE data @> '{"role": "admin"}';
SELECT jsonb_object_keys(data) FROM users;
-- Update JSON
UPDATE users SET data = jsonb_set(data, '{last_login}', '"2024-01-01"');

Agregación JSON

json_agg agrega filas en un array JSON. json_build_object construye objetos JSON desde columnas. Útil para generar respuestas de API directamente desde SQL. Combina datos relacionales y de documento.

sql
SELECT department,
  json_agg(json_build_object('name', name, 'salary', salary)) AS employees
FROM employees
GROUP BY department;
-- Result: {"department": "Eng", "employees": [{"name": "Alice", "salary": 90000}, ...]}

MySQL JSON

MySQL usa sintaxis $.path para JSON. JSON_EXTRACT obtiene valores, JSON_SET actualiza. ->> es abreviatura de JSON_EXTRACT con resultado de texto. MySQL JSON se valida en insert.

sql
CREATE TABLE config (id INT, settings JSON);
INSERT INTO config VALUES (1, '{"theme": "dark", "lang": "en"}');
SELECT settings->>'$.theme' FROM config;
SELECT * FROM config WHERE JSON_EXTRACT(settings, '$.lang') = 'en';
-- Update
UPDATE config SET settings = JSON_SET(settings, '$.theme', 'light');

Índices JSON

Los índices GIN en JSONB habilitan consultas rápidas de cualquier clave. Los expression indexes en paths específicos son más pequeños y más rápidos para consultas dirigidas. Indexa paths JSON consultados frecuentemente para rendimiento.

sql
-- PostgreSQL GIN index on JSONB
CREATE INDEX idx_events_data ON events USING GIN (data);
-- Index specific path
CREATE INDEX idx_events_user ON events ((data->>'user'));
-- MySQL functional index
CREATE INDEX idx_theme ON config ((CAST(settings->>'$.theme' AS CHAR(50))));
23

Performance tuning

EXPLAIN ANALYZE

EXPLAIN muestra el query plan; ANALYZE lo ejecuta con timing. Seq Scan indica índice faltante. Index Scan es ideal. Busca números de costo altos y operaciones lentas. Siempre EXPLAIN antes de optimizar.

sql
EXPLAIN ANALYZE
SELECT * FROM users WHERE email = '[email protected]';
-- Shows: scan type, cost, rows, actual time
-- Seq Scan: full table scan (slow)
-- Index Scan: uses index (fast)
-- Bitmap Heap Scan: index + table lookup

Optimización de consultas

Selecciona solo las columnas necesitadas para reducir I/O. Evita funciones en columnas indexadas (non-sargable). Las consultas sargable (Search Argument Able) pueden usar índices. Usa condiciones de rango en lugar de funciones.

sql
-- BAD: SELECT * fetches all columns
SELECT * FROM users;
-- GOOD: select only needed columns
SELECT id, name FROM users;
-- BAD: function on indexed column
WHERE YEAR(created_at) = 2024
-- GOOD: sargable
WHERE created_at >= '2024-01-01' AND created_at < '2025-01-01'

Optimización de JOIN

Indexa todas las columnas de join. El optimizer elige el orden de join basándose en estadísticas. INNER JOIN usualmente es el más rápido. Evita unir en expresiones. Para datasets grandes, considera desnormalización o materialized views.

sql
-- Use INNER JOIN for required relationships
SELECT u.name, o.total FROM users u
INNER JOIN orders o ON u.id = o.user_id;
-- Index join columns
CREATE INDEX idx_orders_user_id ON orders(user_id);
-- For large joins, ensure both columns are indexed

Paginación

La paginación OFFSET es O(n) - escanea todas las filas saltadas. La paginación keyset (cursor) es O(1) - usa un índice. Usa una comparación de tupla para ordenamiento estable. Mucho más rápida para paginación profunda.

sql
-- BAD: OFFSET scans all skipped rows
SELECT * FROM users ORDER BY id LIMIT 10 OFFSET 10000;
-- GOOD: keyset pagination
SELECT * FROM users WHERE id > 10000 ORDER BY id LIMIT 10;
-- Stable pagination with cursor
SELECT * FROM users WHERE (created_at, id) > ('2024-01-01', 100) ORDER BY created_at, id LIMIT 10;

Materialized views

Las materialized views almacenan resultados de consulta físicamente. Más rápidas que views para agregaciones costosas. REFRESH actualiza los datos (concurrentemente con la opción CONCURRENTLY). Indexa para consultas rápidas.

sql
CREATE MATERIALIZED VIEW sales_summary AS
SELECT product_id, SUM(quantity) AS total, AVG(price) AS avg_price
FROM sales GROUP BY product_id;
-- Refresh periodically
REFRESH MATERIALIZED VIEW sales_summary;
-- Create index on materialized view
CREATE INDEX ON sales_summary (total);
24

JOINs avanzados

Self join

Un self join consulta una tabla contra sí misma. Usa aliases para distinguir. Común para datos jerárquicos (empleado-gerente) y encontrar pares. El truco a.id < b.id evita pares duplicados.

sql
SELECT e.name AS employee, m.name AS manager
FROM employees e
LEFT JOIN employees m ON e.manager_id = m.id;
-- Find pairs in same department
SELECT a.name, b.name FROM employees a, employees b
WHERE a.department = b.department AND a.id < b.id;

Cross join

CROSS JOIN produce un producto cartesiano: cada fila en A combinada con cada fila en B. Útil para generar combinaciones. Ten cuidado: puede producir result sets enormes. A menudo usado implícitamente con sintaxis de coma.

sql
-- Cartesian product: every combination
SELECT s.size, c.color FROM sizes s CROSS JOIN colors c;
-- Generate all size/color combinations
-- Useful for generating test data or matrices

FULL OUTER JOIN

FULL OUTER JOIN devuelve todas las filas de ambas tablas. NULLs llenan los lados no coincidentes. Útil para encontrar registros no coincidentes en ambas direcciones. No soportado en MySQL (emula con UNION de LEFT y RIGHT joins).

sql
SELECT u.name, o.order_id
FROM users u
FULL OUTER JOIN orders o ON u.id = o.user_id;
-- Returns all users and all orders
-- NULLs where there is no match

Anti-join

Anti-join encuentra filas en A que no coinciden con B. NOT EXISTS usualmente es el más claro y a menudo el más rápido. LEFT JOIN con IS NULL es una alternativa. Úsalo para encontrar relaciones faltantes.

sql
-- Users who have never ordered
SELECT u.* FROM users u
WHERE NOT EXISTS (
    SELECT 1 FROM orders o WHERE o.user_id = u.id
);
-- Alternative: LEFT JOIN ... WHERE IS NULL
SELECT u.* FROM users u
LEFT JOIN orders o ON u.id = o.user_id
WHERE o.id IS NULL;

Semi-join

Semi-join devuelve filas de A que coinciden con al menos una fila en B. EXISTS es eficiente porque se detiene en la primera coincidencia. IN es equivalente pero puede rendir diferente. Usa EXISTS para subqueries correlacionadas.

sql
-- Users who have at least one order
SELECT u.* FROM users u
WHERE EXISTS (
    SELECT 1 FROM orders o WHERE o.user_id = u.id
);
-- Alternative: IN
SELECT * FROM users WHERE id IN (SELECT user_id FROM orders);
25

Pitfalls comunes

Comparaciones NULL

NULL es desconocido, no un valor. = NULL siempre devuelve NULL (tratado como false). Usa IS NULL y IS NOT NULL. NULL se propaga a través de aritmética. Usa COALESCE para proporcionar defaults.

sql
-- NULL = NULL is NULL (not true!)
SELECT * FROM users WHERE phone = NULL;  -- Returns nothing
SELECT * FROM users WHERE phone IS NULL;  -- Correct
SELECT * FROM users WHERE phone IS NOT NULL;
-- NULL in arithmetic yields NULL
SELECT 5 + NULL;  -- NULL

SQL injection

SQL injection permite a atacantes ejecutar SQL arbitrario. Nunca concatenes input de usuario en consultas. Siempre usa consultas parametrizadas/prepared statements. Valida y sanitiza toda entrada. Usa parameter binding de ORM.

sql
-- BAD: string concatenation
query = "SELECT * FROM users WHERE name = '" + input + "'"
-- GOOD: parameterized queries
SELECT * FROM users WHERE name = ?;
-- PostgreSQL: $1
-- MySQL: ?
-- Always use parameters, never concatenate

Pitfalls de GROUP BY

Al usar GROUP BY, todas las columnas no agregadas en SELECT deben estar en GROUP BY. De lo contrario, el resultado es ambiguo. MySQL permite esto (devuelve valor arbitrario) pero es incorrecto. Siempre sigue el estándar.

sql
-- BAD: non-aggregated column not in GROUP BY
SELECT department, name, COUNT(*) FROM employees GROUP BY department;
-- Error: which name to show?
-- GOOD: aggregate or include in GROUP BY
SELECT department, COUNT(*) FROM employees GROUP BY department;
SELECT department, MAX(name) FROM employees GROUP BY department;

Floating point

FLOAT y DOUBLE son tipos aproximados. Usa DECIMAL/NUMERIC para precisión exacta (dinero, medidas). DECIMAL(10,2) permite 10 dígitos con 2 después del decimal. Nunca uses FLOAT para datos financieros.

sql
-- Floating point precision issues
SELECT 0.1 + 0.2;  -- 0.30000000000000004
-- Use DECIMAL for money
CREATE TABLE accounts (balance DECIMAL(10, 2));
SELECT 0.10 + 0.20;  -- 0.30 (exact)

Conversión implícita de tipo

La conversión implícita de tipo puede deshabilitar índices y causar full table scans. Siempre compara tipos coincidentes. Si es necesario, haz cast explícito. Verifica tipos de columna y asegúrate de que los parámetros de consulta coincidan.

sql
-- BAD: comparing string to number
SELECT * FROM users WHERE phone = 1234567890;
-- May cause full table scan due to type conversion
-- GOOD: compare same types
SELECT * FROM users WHERE phone = '1234567890';
-- Or cast explicitly
SELECT * FROM users WHERE CAST(phone AS BIGINT) = 1234567890;

Was this helpful?