Preguntas de entrevista de SQL con ejercicios y respuestas (45+)
Si estás preparando una entrevista técnica y saben que te van a evaluar SQL, este artículo es para vos. Acá encontrás más de 45 preguntas reales — las que aparecen en entrevistas de analista de datos, data engineer, backend y BI — con respuestas detalladas, código que podés correr y explicaciones de por qué cada solución funciona.
No hay preguntas de relleno. Cada una tiene un nivel de dificultad, el contexto donde aparece y código SQL real.
Cómo usar este recurso
Las preguntas están organizadas en cuatro bloques:
- 1Fundamentos (preguntas 1-12) — las bases que todo el mundo da por sentado que sabés
- 2Consultas intermedias (preguntas 13-25) — JOINs, agregaciones, subconsultas
- 3SQL avanzado (preguntas 26-38) — window functions, CTEs, optimización
- 4Diseño y escenarios reales (preguntas 39-47) — modelado, performance, casos prácticos
Bloque 1: Fundamentos
1. ¿Qué diferencia hay entre WHERE y HAVING?
Nivel: Junior | Aparece en: casi toda entrevista técnica
WHERE filtra filas antes de que se aplique el GROUP BY. HAVING filtra los grupos resultantes después de la agregación.
-- WHERE filtra filas individuales antes de agrupar
SELECT department, COUNT(*) AS total
FROM employees
WHERE salary > 50000
GROUP BY department;
-- HAVING filtra el resultado del GROUP BY
SELECT department, COUNT(*) AS total
FROM employees
GROUP BY department
HAVING COUNT(*) > 10;Por qué importa en la entrevista: muchos candidatos confunden los dos. La regla es simple: si tu condición usa una función de agregación (SUM, COUNT, AVG), necesitás HAVING. Si no, usá WHERE.
2. ¿Qué es un índice y cuándo conviene crearlo?
Nivel: Junior-Medio | Aparece en: backend, data engineering
Un índice es una estructura auxiliar que acelera las búsquedas a costa de espacio en disco y mayor tiempo de escritura.
-- Crear un índice simple
CREATE INDEX idx_employees_email ON employees(email);
-- Índice compuesto (el orden importa)
CREATE INDEX idx_orders_user_date ON orders(user_id, created_at);Conviene crear índices cuando:
- La columna aparece frecuentemente en cláusulas
WHERE,JOIN ON,ORDER BY - La tabla tiene muchas filas y la selectividad es alta (pocos valores repetidos)
No conviene cuando:
- La tabla se escribe muy seguido (los inserts y updates son más lentos)
- La columna tiene baja cardinalidad (ej: una columna
statuscon 3 valores posibles)
3. Explicá los tipos de JOIN con un ejemplo
Nivel: Junior | Aparece en: toda entrevista SQL
-- Datos de ejemplo
-- tabla users: id, name
-- tabla orders: id, user_id, amount
-- INNER JOIN: solo los usuarios que tienen pedidos
SELECT u.name, o.amount
FROM users u
INNER JOIN orders o ON u.id = o.user_id;
-- LEFT JOIN: todos los usuarios, tengan pedido o no
SELECT u.name, o.amount
FROM users u
LEFT JOIN orders o ON u.id = o.user_id;
-- Los usuarios sin pedidos aparecen con NULL en o.amount
-- RIGHT JOIN: todos los pedidos, aunque el usuario no exista
SELECT u.name, o.amount
FROM users u
RIGHT JOIN orders o ON u.id = o.user_id;
-- FULL OUTER JOIN: todos los registros de ambas tablas
SELECT u.name, o.amount
FROM users u
FULL OUTER JOIN orders o ON u.id = o.user_id;Tip de entrevista: si te preguntan "¿cuándo usarías un LEFT JOIN?", la respuesta es cuando querés mantener todos los registros de la tabla izquierda aunque no haya coincidencia. Ejemplo clásico: "todos los usuarios y, si tienen pedidos, mostrarlos."
4. ¿Qué es NULL en SQL y cómo se maneja?
Nivel: Junior | Aparece en: analítica, QA de datos
NULL no es cero ni cadena vacía. Es la ausencia de valor. Toda comparación con NULL devuelve NULL (no TRUE ni FALSE).
-- INCORRECTO: esto nunca devuelve filas
SELECT * FROM users WHERE phone = NULL;
-- CORRECTO
SELECT * FROM users WHERE phone IS NULL;
SELECT * FROM users WHERE phone IS NOT NULL;
-- COALESCE: devuelve el primer valor no-NULL
SELECT name, COALESCE(phone, 'sin teléfono') AS phone_display
FROM users;
-- NULLIF: devuelve NULL si los dos valores son iguales (útil para evitar división por cero)
SELECT revenue / NULLIF(visits, 0) AS revenue_per_visit
FROM campaigns;5. ¿Qué diferencia hay entre UNION y UNION ALL?
Nivel: Junior | Aparece en: analítica, reporting
-- UNION: elimina duplicados (más lento, hace un DISTINCT implícito)
SELECT email FROM users_argentina
UNION
SELECT email FROM users_colombia;
-- UNION ALL: mantiene duplicados (más rápido)
SELECT email FROM users_argentina
UNION ALL
SELECT email FROM users_colombia;Usá UNION ALL siempre que puedas, especialmente en tablas grandes. Si sabés que los conjuntos son disjuntos (sin filas repetidas), UNION ALL es la elección correcta. UNION implica un paso extra de deduplicación que puede ser muy costoso.
6. ¿Qué son las constraints y cuáles conocés?
Nivel: Junior | Aparece en: diseño de bases de datos, backend
CREATE TABLE employees (
id SERIAL PRIMARY KEY, -- PRIMARY KEY: unicidad + NOT NULL
email VARCHAR(255) UNIQUE NOT NULL, -- UNIQUE: sin duplicados
department_id INT REFERENCES departments(id), -- FOREIGN KEY: integridad referencial
salary NUMERIC CHECK (salary > 0), -- CHECK: validación de dominio
status VARCHAR(20) DEFAULT 'active' -- DEFAULT: valor por defecto
);En entrevistas suelen preguntar: "¿qué pasa si intentás insertar un registro con una foreign key que no existe?" → La base lanza un error de violación de integridad referencial (a menos que la FK esté marcada como ON DELETE CASCADE o SET NULL).
7. ¿Qué es una transacción y para qué sirve ACID?
Nivel: Junior-Medio | Aparece en: backend, ingeniería de datos
Una transacción agrupa operaciones para que se ejecuten todas juntas o ninguna.
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
COMMIT;
-- Si algo falla entre las dos líneas, hacemos ROLLBACKACID son las propiedades que garantiza una base de datos transaccional:
- Atomicidad: todo o nada
- Consistencia: la base pasa de un estado válido a otro válido
- Aislamiento: las transacciones concurrentes no se interfieren
- Durabilidad: lo que fue commiteado sobrevive a fallas
8. Diferencia entre DELETE, TRUNCATE y DROP
Nivel: Junior | Aparece en: backend, administración
-- DELETE: borra filas que cumplan la condición (se puede hacer ROLLBACK)
DELETE FROM logs WHERE created_at < '2025-01-01';
-- TRUNCATE: borra TODAS las filas, más rápido que DELETE, no hace log por fila
-- En PostgreSQL, se puede hacer ROLLBACK. En MySQL, no.
TRUNCATE TABLE logs;
-- DROP: elimina la tabla completamente (estructura + datos)
DROP TABLE logs;La diferencia clave en entrevista: DELETE es DML (Data Manipulation Language) y se puede deshacer dentro de una transacción. TRUNCATE es DDL en la mayoría de los motores. DROP elimina el objeto entero.
9. ¿Qué es un subquery correlacionado?
Nivel: Medio | Aparece en: analítica avanzada
Un subquery correlacionado hace referencia a la consulta exterior. Se ejecuta una vez por cada fila del resultado exterior.
-- Para cada empleado, encontrar si su salario es mayor al promedio de su departamento
SELECT name, salary, department_id
FROM employees e1
WHERE salary > (
SELECT AVG(salary)
FROM employees e2
WHERE e2.department_id = e1.department_id -- referencia a la consulta exterior
);Advertencia de performance: los subqueries correlacionados pueden ser lentos en tablas grandes porque se re-ejecutan por cada fila. En muchos casos conviene reescribirlos con un JOIN o una window function.
10. ¿Cómo encontrás duplicados en una tabla?
Nivel: Junior-Medio | Aparece en: QA de datos, ingeniería
-- Encontrar emails duplicados
SELECT email, COUNT(*) AS cantidad
FROM users
GROUP BY email
HAVING COUNT(*) > 1;
-- Ver los registros completos de los duplicados
SELECT *
FROM users
WHERE email IN (
SELECT email
FROM users
GROUP BY email
HAVING COUNT(*) > 1
)
ORDER BY email;11. ¿Qué diferencia hay entre una vista (VIEW) y una tabla?
Nivel: Junior | Aparece en: BI, reporting, backend
-- Una vista es una consulta guardada, no almacena datos
CREATE VIEW active_users AS
SELECT id, name, email
FROM users
WHERE status = 'active';
-- Se usa igual que una tabla
SELECT * FROM active_users;Diferencias clave:
- Una vista no almacena datos (salvo vistas materializadas)
- Siempre refleja el estado actual de las tablas subyacentes
- No podés indexar una vista regular (sí una materializada)
- Las vistas materializadas almacenan el resultado y hay que refrescarlas
12. ¿Qué es una vista materializada?
Nivel: Medio | Aparece en: data engineering, BI
-- PostgreSQL
CREATE MATERIALIZED VIEW monthly_revenue AS
SELECT
DATE_TRUNC('month', created_at) AS month,
SUM(amount) AS total
FROM orders
GROUP BY 1;
-- Refrescar manualmente
REFRESH MATERIALIZED VIEW monthly_revenue;
-- Refrescar sin bloquear lecturas (PostgreSQL)
REFRESH MATERIALIZED VIEW CONCURRENTLY monthly_revenue;Una vista materializada guarda el resultado de la consulta en disco. Es mucho más rápida de leer que calcular todo de vuelta, pero los datos no son en tiempo real — hay que refrescarlos.
Bloque 2: Consultas intermedias
13. Encontrá el segundo salario más alto
Nivel: Medio | Aparece en: casi toda entrevista técnica de SQL
Esta es una de las preguntas más clásicas. Hay varias formas de resolverla.
-- Opción 1: con subconsulta
SELECT MAX(salary)
FROM employees
WHERE salary < (SELECT MAX(salary) FROM employees);
-- Opción 2: con OFFSET (más limpio)
SELECT DISTINCT salary
FROM employees
ORDER BY salary DESC
LIMIT 1 OFFSET 1;
-- Opción 3: con window function (la más robusta para el N-ésimo)
SELECT salary
FROM (
SELECT salary, DENSE_RANK() OVER (ORDER BY salary DESC) AS rnk
FROM employees
) ranked
WHERE rnk = 2;Por qué la opción 3 es la mejor: DENSE_RANK maneja correctamente los empates. Si dos empleados tienen el mismo salario más alto, DENSE_RANK le asigna el mismo rango a ambos y el siguiente valor diferente recibe el rango 2.
14. Escribí una consulta para calcular el total acumulado de ventas por día
Nivel: Medio | Aparece en: analítica, data engineering
SELECT
order_date,
daily_revenue,
SUM(daily_revenue) OVER (ORDER BY order_date) AS cumulative_revenue
FROM (
SELECT
DATE(created_at) AS order_date,
SUM(amount) AS daily_revenue
FROM orders
GROUP BY DATE(created_at)
) daily_totals
ORDER BY order_date;15. ¿Cómo encontrás usuarios que no hicieron ningún pedido?
Nivel: Medio | Aparece en: analítica, CRM, marketing
Hay tres formas equivalentes. Cada entrevistador prefiere una diferente.
-- Opción 1: LEFT JOIN con filtro NULL
SELECT u.id, u.name
FROM users u
LEFT JOIN orders o ON u.id = o.user_id
WHERE o.id IS NULL;
-- Opción 2: NOT EXISTS (generalmente más eficiente en tablas grandes)
SELECT id, name
FROM users u
WHERE NOT EXISTS (
SELECT 1 FROM orders o WHERE o.user_id = u.id
);
-- Opción 3: NOT IN (cuidado con NULLs)
SELECT id, name
FROM users
WHERE id NOT IN (
SELECT DISTINCT user_id FROM orders WHERE user_id IS NOT NULL
);Truco de entrevista: mencioná que NOT IN tiene una trampa con NULL. Si la subconsulta devuelve algún NULL, la consulta entera no devuelve ningún resultado. NOT EXISTS no tiene ese problema.
16. Escribí una consulta que calcule el porcentaje de cada categoría sobre el total
Nivel: Medio | Aparece en: BI, reporting
SELECT
category,
COUNT(*) AS quantity,
ROUND(
COUNT(*) * 100.0 / SUM(COUNT(*)) OVER (),
2
) AS percentage
FROM products
GROUP BY category
ORDER BY percentage DESC;SUM(COUNT(*)) OVER () es una window function sin partición: suma el total de todos los grupos, lo que permite calcular el porcentaje sin una subconsulta adicional.
17. Encontrá los clientes que compraron en todos los meses del último año
Nivel: Medio-Avanzado | Aparece en: analítica de retención
SELECT user_id
FROM orders
WHERE created_at >= DATE_TRUNC('year', CURRENT_DATE - INTERVAL '1 year')
AND created_at < DATE_TRUNC('year', CURRENT_DATE)
GROUP BY user_id
HAVING COUNT(DISTINCT DATE_TRUNC('month', created_at)) = 12;18. ¿Cómo calculás la mediana en SQL?
Nivel: Medio | Aparece en: analítica, ciencia de datos
AVG es fácil, pero la mediana no tiene una función estándar en todos los motores.
-- PostgreSQL tiene PERCENTILE_CONT
SELECT PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY salary) AS mediana
FROM employees;
-- Para MySQL o SQLite (sin esa función):
SELECT AVG(salary) AS mediana
FROM (
SELECT salary
FROM (
SELECT salary, ROW_NUMBER() OVER (ORDER BY salary) AS rn, COUNT(*) OVER () AS total
FROM employees
) ranked
WHERE rn IN (FLOOR((total + 1) / 2.0), CEIL((total + 1) / 2.0))
) median_rows;19. Escribí una consulta para encontrar el top 3 de productos más vendidos por categoría
Nivel: Medio-Avanzado | Aparece en: e-commerce, analítica
WITH ranked_products AS (
SELECT
p.category,
p.name,
SUM(oi.quantity) AS total_sold,
RANK() OVER (PARTITION BY p.category ORDER BY SUM(oi.quantity) DESC) AS rnk
FROM order_items oi
JOIN products p ON oi.product_id = p.id
GROUP BY p.category, p.name
)
SELECT category, name, total_sold
FROM ranked_products
WHERE rnk <= 3
ORDER BY category, rnk;20. ¿Cómo harías un self-join y para qué sirve?
Nivel: Medio | Aparece en: estructuras jerárquicas, HR, organización
-- Estructura de empleados con managers
-- tabla: employees (id, name, manager_id)
SELECT
e.name AS empleado,
m.name AS manager
FROM employees e
LEFT JOIN employees m ON e.manager_id = m.id;Los self-joins aparecen en:
- Organigramas y jerarquías
- Relaciones de "amigos" o "seguidores"
- Comparaciones de filas dentro de la misma tabla (ej: precio de hoy vs. precio de ayer)
21. Detectá sesiones de usuario: agrupa eventos consecutivos del mismo usuario separados por más de 30 minutos
Nivel: Avanzado | Aparece en: analítica de producto, data engineering
WITH events_with_prev AS (
SELECT
user_id,
event_time,
LAG(event_time) OVER (PARTITION BY user_id ORDER BY event_time) AS prev_event_time
FROM user_events
),
session_starts AS (
SELECT
user_id,
event_time,
CASE
WHEN prev_event_time IS NULL
OR event_time - prev_event_time > INTERVAL '30 minutes'
THEN 1
ELSE 0
END AS is_new_session
FROM events_with_prev
),
sessions AS (
SELECT
user_id,
event_time,
SUM(is_new_session) OVER (PARTITION BY user_id ORDER BY event_time) AS session_id
FROM session_starts
)
SELECT user_id, session_id, MIN(event_time) AS session_start, MAX(event_time) AS session_end
FROM sessions
GROUP BY user_id, session_id;22. ¿Cómo pivotás una tabla en SQL (rows a columns)?
Nivel: Medio-Avanzado | Aparece en: reporting, BI
-- Convertir filas de categorías en columnas
-- Tabla: sales (month, category, amount)
SELECT
month,
SUM(CASE WHEN category = 'electronics' THEN amount ELSE 0 END) AS electronics,
SUM(CASE WHEN category = 'clothing' THEN amount ELSE 0 END) AS clothing,
SUM(CASE WHEN category = 'food' THEN amount ELSE 0 END) AS food
FROM sales
GROUP BY month
ORDER BY month;En PostgreSQL también podés usar la extensión tablefunc con crosstab(), pero el CASE WHEN es más portable y más fácil de leer en una entrevista.
23. Calculá el churn mensual de usuarios
Nivel: Avanzado | Aparece en: SaaS, analytics de producto
WITH monthly_users AS (
SELECT DISTINCT
DATE_TRUNC('month', event_date) AS month,
user_id
FROM user_activity
),
user_months AS (
SELECT
user_id,
month,
LAG(month) OVER (PARTITION BY user_id ORDER BY month) AS prev_month
FROM monthly_users
)
SELECT
month,
COUNT(DISTINCT CASE WHEN prev_month IS NULL OR
prev_month < month - INTERVAL '1 month' THEN user_id END) AS new_or_returned,
COUNT(DISTINCT user_id) AS active_users
FROM user_months
GROUP BY month
ORDER BY month;24. Encontrá las filas duplicadas y eliminá todas menos una
Nivel: Medio | Aparece en: limpieza de datos, data engineering
-- En PostgreSQL, usando ctid (identificador interno de fila)
DELETE FROM users
WHERE ctid NOT IN (
SELECT MIN(ctid)
FROM users
GROUP BY email -- o los campos que definen el duplicado
);
-- Alternativa con CTE (más legible)
WITH duplicates AS (
SELECT id,
ROW_NUMBER() OVER (PARTITION BY email ORDER BY created_at DESC) AS rn
FROM users
)
DELETE FROM users
WHERE id IN (
SELECT id FROM duplicates WHERE rn > 1
);25. Escribí una consulta para calcular la retención de cohortes (cohort retention)
Nivel: Avanzado | Aparece en: analítica de producto, growth
WITH cohorts AS (
SELECT
user_id,
DATE_TRUNC('month', MIN(created_at)) AS cohort_month
FROM users
GROUP BY user_id
),
activity AS (
SELECT
e.user_id,
c.cohort_month,
DATE_TRUNC('month', e.event_date) AS activity_month,
EXTRACT(EPOCH FROM (DATE_TRUNC('month', e.event_date) - c.cohort_month)) / (30 * 86400) AS months_since_signup
FROM events e
JOIN cohorts c ON e.user_id = c.user_id
)
SELECT
cohort_month,
months_since_signup::INT AS month_number,
COUNT(DISTINCT user_id) AS retained_users,
FIRST_VALUE(COUNT(DISTINCT user_id)) OVER (
PARTITION BY cohort_month ORDER BY months_since_signup
) AS cohort_size,
ROUND(
COUNT(DISTINCT user_id) * 100.0 /
FIRST_VALUE(COUNT(DISTINCT user_id)) OVER (
PARTITION BY cohort_month ORDER BY months_since_signup
), 1
) AS retention_rate
FROM activity
GROUP BY cohort_month, months_since_signup
ORDER BY cohort_month, month_number;Bloque 3: SQL avanzado
26. Explicá las window functions y sus componentes
Nivel: Medio-Avanzado | Aparece en: toda entrevista de datos senior
Una window function realiza un cálculo sobre un conjunto de filas relacionadas con la fila actual, sin colapsar el resultado (a diferencia de GROUP BY).
SELECT
name,
department,
salary,
-- OVER () sin PARTITION: toda la tabla es la ventana
AVG(salary) OVER () AS company_avg,
-- PARTITION BY: ventana por departamento
AVG(salary) OVER (PARTITION BY department) AS dept_avg,
-- ORDER BY dentro de la ventana: acumulado
SUM(salary) OVER (PARTITION BY department ORDER BY hire_date) AS cumulative_payroll,
-- Funciones de ranking
ROW_NUMBER() OVER (PARTITION BY department ORDER BY salary DESC) AS row_num,
RANK() OVER (PARTITION BY department ORDER BY salary DESC) AS rank_num,
DENSE_RANK() OVER (PARTITION BY department ORDER BY salary DESC) AS dense_rank_num
FROM employees;Diferencias entre funciones de ranking:
| Salarios | ROW_NUMBER | RANK | DENSE_RANK |
|----------|-----------|------|-----------|
| 100, 100, 90 | 1, 2, 3 | 1, 1, 3 | 1, 1, 2 |
RANK deja "huecos" después de un empate. DENSE_RANK no.
27. ¿Qué es un CTE y cuándo lo usás en lugar de una subconsulta?
Nivel: Medio | Aparece en: toda entrevista SQL media-avanzada
Un CTE (Common Table Expression) define una consulta temporal con nombre que se puede referenciar una o más veces.
-- Sin CTE: difícil de leer
SELECT *
FROM (
SELECT user_id, COUNT(*) AS orders, SUM(amount) AS revenue
FROM orders
GROUP BY user_id
) user_stats
WHERE revenue > 1000;
-- Con CTE: mucho más legible
WITH user_stats AS (
SELECT user_id, COUNT(*) AS orders, SUM(amount) AS revenue
FROM orders
GROUP BY user_id
)
SELECT *
FROM user_stats
WHERE revenue > 1000;Ventajas de CTE sobre subconsulta:
- Legibilidad (se lee de arriba a abajo)
- Se puede referenciar múltiples veces (vs. repetir la subconsulta)
- Permite CTEs recursivos (para jerarquías y grafos)
28. Escribí un CTE recursivo para recorrer una jerarquía de empleados
Nivel: Avanzado | Aparece en: data engineering, backend con estructuras de árbol
WITH RECURSIVE org_chart AS (
-- Caso base: el CEO (sin manager)
SELECT id, name, manager_id, 0 AS level, name::TEXT AS path
FROM employees
WHERE manager_id IS NULL
UNION ALL
-- Caso recursivo: los reportes de cada manager
SELECT e.id, e.name, e.manager_id, oc.level + 1, oc.path || ' > ' || e.name
FROM employees e
JOIN org_chart oc ON e.manager_id = oc.id
)
SELECT level, name, path
FROM org_chart
ORDER BY path;29. ¿Qué es el EXPLAIN ANALYZE y cómo lo usás?
Nivel: Avanzado | Aparece en: backend, data engineering, DBA
EXPLAIN ANALYZE
SELECT u.name, COUNT(o.id) AS total_orders
FROM users u
LEFT JOIN orders o ON u.id = o.user_id
GROUP BY u.id, u.name;El output muestra:
- El plan de ejecución (qué pasos sigue la base)
- Costo estimado vs. costo real
- Tiempo de ejecución real
- Cantidad de filas por paso
Lo que buscás cuando optimizás:
Seq Scanen tablas grandes → falta un índiceHash JoinvsNested Loop→ el join puede ser ineficiente- Diferencia grande entre filas estimadas y reales → estadísticas desactualizadas (correr
ANALYZE)
30. ¿Qué son los isolation levels y qué problemas resuelven?
Nivel: Avanzado | Aparece en: backend, entrevistas de sistemas distribuidos
Los niveles de aislamiento controlan qué puede ver una transacción de los cambios de otras transacciones concurrentes.
| Nivel | Dirty Read | Non-repeatable Read | Phantom Read |
|-------|-----------|---------------------|--------------|
| READ UNCOMMITTED | posible | posible | posible |
| READ COMMITTED | no | posible | posible |
| REPEATABLE READ | no | no | posible |
| SERIALIZABLE | no | no | no |
-- Cambiar el nivel de aislamiento en PostgreSQL
BEGIN;
SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;
-- ... tus queries
COMMIT;31. ¿Cómo funcionan los índices compuestos? ¿Qué es el "leftmost prefix rule"?
Nivel: Avanzado | Aparece en: performance, backend
-- Índice compuesto
CREATE INDEX idx_orders_user_date_status ON orders(user_id, created_at, status);
-- USA el índice (empieza desde la izquierda)
SELECT * FROM orders WHERE user_id = 42;
SELECT * FROM orders WHERE user_id = 42 AND created_at > '2025-01-01';
SELECT * FROM orders WHERE user_id = 42 AND created_at > '2025-01-01' AND status = 'paid';
-- NO usa el índice (salta el primer campo)
SELECT * FROM orders WHERE created_at > '2025-01-01';
SELECT * FROM orders WHERE status = 'paid';La regla: un índice compuesto (A, B, C) solo ayuda si la condición incluye A. Puede incluir A+B o A+B+C, pero no B solo ni C solo.
32. Escribí una consulta para detectar gaps en una secuencia numérica
Nivel: Avanzado | Aparece en: auditoría, facturación, ingeniería
-- Encontrar números faltantes en una secuencia
WITH consecutive AS (
SELECT
id,
LEAD(id) OVER (ORDER BY id) AS next_id
FROM invoices
)
SELECT
id + 1 AS gap_start,
next_id - 1 AS gap_end
FROM consecutive
WHERE next_id > id + 1;33. ¿Qué es el problema N+1 en SQL y cómo lo evitás?
Nivel: Avanzado | Aparece en: backend, ORM, performance
El problema N+1 ocurre cuando ejecutás 1 query para obtener N registros y luego N queries adicionales para obtener datos relacionados de cada uno.
-- MALO: 1 query para usuarios, N queries para los pedidos de cada uno
-- SELECT * FROM users;
-- Para cada usuario: SELECT * FROM orders WHERE user_id = ?
-- BUENO: un solo JOIN
SELECT u.id, 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;En ORMs como ActiveRecord, Sequelize o SQLAlchemy, esto se resuelve con includes, eager_load o joinedload.
34. ¿Cómo calculás el promedio móvil de 7 días?
Nivel: Avanzado | Aparece en: analítica, finanzas, growth
SELECT
order_date,
daily_revenue,
AVG(daily_revenue) OVER (
ORDER BY order_date
ROWS BETWEEN 6 PRECEDING AND CURRENT ROW
) AS moving_avg_7d
FROM (
SELECT DATE(created_at) AS order_date, SUM(amount) AS daily_revenue
FROM orders
GROUP BY DATE(created_at)
) daily
ORDER BY order_date;ROWS BETWEEN 6 PRECEDING AND CURRENT ROW define el frame de la ventana: la fila actual más las 6 anteriores = 7 días.
35. Escribí una consulta para encontrar usuarios activos en los últimos 30 días que no lo estuvieron en los 30 días anteriores (usuarios reactivados)
Nivel: Avanzado | Aparece en: analítica de producto, growth
WITH recent AS (
SELECT DISTINCT user_id
FROM events
WHERE event_date >= CURRENT_DATE - INTERVAL '30 days'
),
previous AS (
SELECT DISTINCT user_id
FROM events
WHERE event_date >= CURRENT_DATE - INTERVAL '60 days'
AND event_date < CURRENT_DATE - INTERVAL '30 days'
)
SELECT r.user_id
FROM recent r
LEFT JOIN previous p ON r.user_id = p.user_id
WHERE p.user_id IS NULL;36. ¿Qué diferencia hay entre ROW_NUMBER(), RANK() y DENSE_RANK() con un caso concreto?
Nivel: Medio | Aparece en: toda entrevista de SQL analítico
SELECT
name,
score,
ROW_NUMBER() OVER (ORDER BY score DESC) AS row_num,
RANK() OVER (ORDER BY score DESC) AS rank,
DENSE_RANK() OVER (ORDER BY score DESC) AS dense_rank
FROM results;
/*
name | score | row_num | rank | dense_rank
--------|-------|---------|------|------------
Ana | 95 | 1 | 1 | 1
Carlos | 95 | 2 | 1 | 1
Lucia | 87 | 3 | 3 | 2
Pedro | 80 | 4 | 4 | 3
*/ROW_NUMBER: siempre único, aunque haya empatesRANK: empate = mismo número, luego salta (1, 1, 3...)DENSE_RANK: empate = mismo número, sin saltos (1, 1, 2...)
37. ¿Cómo funciona PARTITION BY dentro de una window function?
Nivel: Medio-Avanzado | Aparece en: analítica, reporting
SELECT
department,
name,
salary,
-- Promedio POR departamento (no de toda la empresa)
ROUND(AVG(salary) OVER (PARTITION BY department), 2) AS dept_avg,
-- Posición dentro de su departamento
RANK() OVER (PARTITION BY department ORDER BY salary DESC) AS dept_rank,
-- Posición en la empresa entera
RANK() OVER (ORDER BY salary DESC) AS company_rank
FROM employees;PARTITION BY es como un GROUP BY pero para la ventana: calcula el resultado dentro de cada grupo sin colapsar las filas.
38. Escribí una consulta para calcular el LTV (lifetime value) promedio por canal de adquisición
Nivel: Avanzado | Aparece en: marketing analytics, growth
WITH user_revenue AS (
SELECT
u.id AS user_id,
u.acquisition_source,
SUM(o.amount) AS total_revenue
FROM users u
LEFT JOIN orders o ON u.id = o.user_id
GROUP BY u.id, u.acquisition_source
)
SELECT
acquisition_source,
COUNT(*) AS total_users,
ROUND(AVG(total_revenue), 2) AS avg_ltv,
ROUND(PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY total_revenue), 2) AS median_ltv,
SUM(total_revenue) AS total_revenue
FROM user_revenue
GROUP BY acquisition_source
ORDER BY avg_ltv DESC;Bloque 4: Diseño y escenarios reales
39. ¿Cómo diseñarías el esquema de una app de e-commerce?
Nivel: Avanzado | Aparece en: entrevistas de sistema design + SQL
CREATE TABLE users (
id SERIAL PRIMARY KEY,
email VARCHAR(255) UNIQUE NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE products (
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
price NUMERIC(10,2) NOT NULL CHECK (price >= 0),
stock INT NOT NULL DEFAULT 0,
category_id INT REFERENCES categories(id)
);
CREATE TABLE orders (
id SERIAL PRIMARY KEY,
user_id INT NOT NULL REFERENCES users(id),
status VARCHAR(50) DEFAULT 'pending',
created_at TIMESTAMPTZ DEFAULT NOW(),
total NUMERIC(10,2)
);
CREATE TABLE order_items (
id SERIAL PRIMARY KEY,
order_id INT NOT NULL REFERENCES orders(id) ON DELETE CASCADE,
product_id INT NOT NULL REFERENCES products(id),
quantity INT NOT NULL CHECK (quantity > 0),
unit_price NUMERIC(10,2) NOT NULL -- precio al momento de la compra
);Puntos a mencionar en la entrevista:
unit_priceenorder_itemsguarda el precio histórico (el precio del producto puede cambiar)ON DELETE CASCADEenorder_itemspara mantener consistencia- Los índices que agregarías:
orders(user_id),orders(status, created_at),order_items(order_id),order_items(product_id)
40. ¿Qué es la normalización y cuándo desnormalizás?
Nivel: Avanzado | Aparece en: diseño de bases, data warehousing
Normalización es el proceso de organizar datos para reducir redundancia. Las tres primeras formas normales son:
- 1NF: sin grupos repetidos, cada celda tiene un valor atómico
- 2NF: 1NF + sin dependencias parciales (cada columna depende de la clave completa, no de parte de ella)
- 3NF: 2NF + sin dependencias transitivas (las columnas no-clave dependen solo de la clave)
Cuándo desnormalizás (intencionalmente):
- Data warehouses / OLAP (las lecturas masivas son más lentas con muchos JOINs)
- Caches y tablas de reporting pre-agregadas
- Cuando el costo de los JOINs supera el costo de la redundancia
41. ¿Cómo manejarías el historial de precios de un producto?
Nivel: Avanzado | Aparece en: e-commerce, finanzas, auditoría
-- Tabla de historial de precios
CREATE TABLE product_prices (
id SERIAL PRIMARY KEY,
product_id INT NOT NULL REFERENCES products(id),
price NUMERIC(10,2) NOT NULL,
valid_from TIMESTAMPTZ NOT NULL DEFAULT NOW(),
valid_to TIMESTAMPTZ -- NULL significa "precio actual"
);
-- Consultar el precio en un momento dado
SELECT price
FROM product_prices
WHERE product_id = 42
AND valid_from <= '2025-06-15 10:00:00'
AND (valid_to IS NULL OR valid_to > '2025-06-15 10:00:00');
-- Actualizar el precio (dejar rastro)
UPDATE product_prices
SET valid_to = NOW()
WHERE product_id = 42 AND valid_to IS NULL;
INSERT INTO product_prices (product_id, price, valid_from)
VALUES (42, 29.99, NOW());42. ¿Qué es un deadlock y cómo lo prevenís?
Nivel: Avanzado | Aparece en: backend, sistemas distribuidos
Un deadlock ocurre cuando dos transacciones se bloquean mutuamente esperando recursos que la otra tiene.
-- Transacción A | Transacción B
-- UPDATE accounts SET... |
-- WHERE id = 1; | UPDATE accounts SET...
-- | WHERE id = 2;
-- UPDATE accounts SET... | -- espera a A
-- WHERE id = 2; -- espera a B |Cómo prevenirlo:
- 1Bloquear recursos siempre en el mismo orden (ej: siempre actualizar
idmás bajo primero) - 2Mantener transacciones cortas
- 3Usar
SELECT ... FOR UPDATE SKIP LOCKEDpara procesamiento de colas
-- Adquirir el lock en orden ascendente de id
BEGIN;
SELECT * FROM accounts WHERE id IN (1, 2) ORDER BY id FOR UPDATE;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
COMMIT;43. ¿Cómo implementarías soft delete?
Nivel: Medio | Aparece en: casi todo producto con datos de usuario
-- Agregar columna de soft delete
ALTER TABLE users ADD COLUMN deleted_at TIMESTAMPTZ;
-- Eliminar (marcar como borrado)
UPDATE users SET deleted_at = NOW() WHERE id = 42;
-- Consultar solo activos
SELECT * FROM users WHERE deleted_at IS NULL;
-- Índice parcial para que las queries de activos sean rápidas
CREATE INDEX idx_users_active ON users(id) WHERE deleted_at IS NULL;
-- Vista para simplificar las queries
CREATE VIEW active_users AS
SELECT * FROM users WHERE deleted_at IS NULL;44. Escribí una consulta para encontrar el "nth" registro de cada grupo
Nivel: Avanzado | Aparece en: reporting, analítica
-- El tercer pedido de cada cliente (por fecha)
WITH ranked_orders AS (
SELECT
user_id,
id AS order_id,
amount,
created_at,
ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY created_at) AS order_number
FROM orders
)
SELECT user_id, order_id, amount, created_at
FROM ranked_orders
WHERE order_number = 3;45. ¿Cómo optimizarías una query lenta?
Nivel: Avanzado | Aparece en: toda entrevista de performance
El proceso paso a paso que vas a mencionar en la entrevista:
- 1Correr
EXPLAIN ANALYZEpara ver el plan de ejecución real - 2Buscar Seq Scans en tablas grandes — agregar índice en la columna del WHERE/JOIN
- 3Verificar selectividad — un índice en una columna con 2 valores distintos no ayuda
- 4Revisar los JOINs — asegurarse de que las columnas del ON estén indexadas en ambos lados
- 5Evitar funciones en columnas indexadas en el WHERE
-- MALO: la función impide usar el índice
SELECT * FROM orders WHERE DATE(created_at) = '2025-01-15';
-- BUENO: rango que puede usar el índice
SELECT * FROM orders
WHERE created_at >= '2025-01-15'
AND created_at < '2025-01-16';
-- MALO: cálculo en columna indexada
SELECT * FROM users WHERE LOWER(email) = 'test@example.com';
-- BUENO: índice funcional
CREATE INDEX idx_users_email_lower ON users(LOWER(email));
SELECT * FROM users WHERE LOWER(email) = 'test@example.com';46. ¿Qué diferencia hay entre OLTP y OLAP?
Nivel: Avanzado | Aparece en: data engineering, system design
| | OLTP | OLAP |
|---|---|---|
| Propósito | Operacional (transacciones) | Analítico (reportes, BI) |
| Consultas | Muchas, cortas, por PK | Pocas, largas, sobre millones de filas |
| Escrituras | Frecuentes | Pocas (batch loads) |
| Esquema | Normalizado (3NF) | Desnormalizado (star schema, snowflake) |
| Ejemplo | PostgreSQL, MySQL | Redshift, BigQuery, Snowflake |
| Índices | B-tree por clave | Columnar, particionado |
47. Explicá el modelo estrella (star schema) con un ejemplo
Nivel: Avanzado | Aparece en: data warehousing, BI, data engineering
-- Tabla de hechos (fact table): las métricas
CREATE TABLE fact_sales (
sale_id BIGINT PRIMARY KEY,
date_id INT REFERENCES dim_date(date_id),
product_id INT REFERENCES dim_product(product_id),
customer_id INT REFERENCES dim_customer(customer_id),
store_id INT REFERENCES dim_store(store_id),
quantity INT,
revenue NUMERIC(12,2)
);
-- Dimensiones: el contexto
CREATE TABLE dim_date (
date_id INT PRIMARY KEY,
date DATE,
year INT,
quarter INT,
month INT,
week INT,
day_name VARCHAR(10)
);
CREATE TABLE dim_product (
product_id INT PRIMARY KEY,
name VARCHAR(255),
category VARCHAR(100),
brand VARCHAR(100)
);Por qué el star schema? Las queries analíticas se simplifican (un join a cada dimensión desde el fact table) y los motores columnar lo pueden optimizar mejor que un esquema normalizado.
Bonus: preguntas cortas para revisión rápida
Estas aparecen como preguntas de calentamiento o cierre:
¿Qué hace COALESCE(a, b, c)? — Devuelve el primer valor no-NULL de la lista.
¿Qué diferencia hay entre CHAR y VARCHAR? — CHAR(n) tiene longitud fija y rellena con espacios. VARCHAR(n) es variable hasta n caracteres. En la práctica, usás VARCHAR o TEXT.
¿Cuándo usás EXISTS en lugar de IN? — EXISTS es más eficiente cuando la subconsulta devuelve muchas filas, porque se detiene al encontrar la primera coincidencia.
¿Qué es una clave compuesta? — Una clave primaria formada por más de una columna. Útil en tablas de relación muchos-a-muchos.
¿Qué hace GROUP BY ROLLUP? — Genera subtotales y el gran total en una sola consulta.
SELECT region, category, SUM(revenue)
FROM sales
GROUP BY ROLLUP(region, category);
-- Devuelve: total por (region, category), total por region, y gran total¿Qué es un full table scan? — Cuando la base lee todas las filas de una tabla porque no puede usar un índice. Inofensivo en tablas pequeñas, costoso en tablas grandes.
¿Qué hace DISTINCT ON en PostgreSQL? — Devuelve la primera fila de cada grupo definido por las columnas del DISTINCT ON, ordenado por el ORDER BY.
-- El pedido más reciente de cada cliente
SELECT DISTINCT ON (user_id) user_id, id, created_at
FROM orders
ORDER BY user_id, created_at DESC;Cómo prepararte para la parte práctica de la entrevista
En la mayoría de las entrevistas técnicas de datos vas a tener un editor SQL en vivo. Estos son los hábitos que separan a los candidatos que pasan de los que no:
Pensá en voz alta. Antes de escribir, explicá qué querés lograr. "Primero voy a encontrar los usuarios activos del último mes, después los voy a cruzar con los de dos meses atrás para ver cuáles son nuevos." Los entrevistadores quieren ver el proceso, no solo el resultado.
Empezá simple. Escribí la query más básica que funcione y después agregale complejidad. No intentés hacer todo en una sola query compleja desde el inicio.
Verificá con datos de ejemplo. Si podés, creá un par de filas mentales y trazá tu query paso a paso.
Conocé los errores comunes. El NULL en NOT IN, el orden de WHERE vs HAVING, los empates en RANK() — son las trampas que los entrevistadores ponen a propósito.
Preguntá por el motor. PostgreSQL, MySQL, BigQuery y Spark SQL tienen diferencias. Preguntar "¿estamos en Postgres?" es una señal de madurez, no de inseguridad.
*Seguí practicando con casos reales. Las entrevistas de SQL evalúan tanto la sintaxis como la capacidad de modelar un problema de negocio en una query — eso solo se mejora resolviendo ejercicios reales.*