Split View: SQL 실무 치트시트 — 매일 쓰는 명령어 총정리
SQL 실무 치트시트 — 매일 쓰는 명령어 총정리
- 기본 CRUD
- JOIN (결합)
- GROUP BY + 집계 함수
- 서브쿼리 (Subquery)
- CTE (Common Table Expression) — 가독성의 왕
- 윈도우 함수 (Window Functions)
- 인덱스 (Index) 전략
- 실행 계획 (EXPLAIN)
- 실무 패턴 모음

기본 CRUD
SELECT (조회)
-- 기본 조회
SELECT * FROM users WHERE age >= 20 ORDER BY created_at DESC LIMIT 10;
-- 특정 컬럼만
SELECT id, name, email FROM users WHERE status = 'active';
-- 별칭 (Alias)
SELECT
u.name AS user_name,
COUNT(o.id) AS order_count,
SUM(o.amount) AS total_spent
FROM users u
JOIN orders o ON u.id = o.user_id
GROUP BY u.name;
-- DISTINCT (중복 제거)
SELECT DISTINCT department FROM employees;
-- BETWEEN, IN, LIKE
SELECT * FROM products
WHERE price BETWEEN 10000 AND 50000
AND category IN ('electronics', 'books')
AND name LIKE '%갤럭시%';
-- NULL 처리
SELECT name, COALESCE(phone, '미등록') AS phone
FROM users
WHERE email IS NOT NULL;
INSERT (삽입)
-- 단건 삽입
INSERT INTO users (name, email, age) VALUES ('김영주', 'yj@example.com', 30);
-- 다건 삽입
INSERT INTO users (name, email, age) VALUES
('홍길동', 'hong@example.com', 25),
('이순신', 'lee@example.com', 35),
('세종대왕', 'sejong@example.com', 45);
-- SELECT 결과를 INSERT (테이블 복사)
INSERT INTO users_backup (name, email, age)
SELECT name, email, age FROM users WHERE status = 'active';
-- UPSERT (있으면 UPDATE, 없으면 INSERT)
-- PostgreSQL
INSERT INTO users (email, name, login_count)
VALUES ('yj@example.com', '김영주', 1)
ON CONFLICT (email)
DO UPDATE SET
login_count = users.login_count + 1,
last_login = NOW();
-- MySQL
INSERT INTO users (email, name, login_count)
VALUES ('yj@example.com', '김영주', 1)
ON DUPLICATE KEY UPDATE
login_count = login_count + 1,
last_login = NOW();
UPDATE (수정)
-- 기본 수정
UPDATE users SET status = 'inactive' WHERE last_login < '2025-01-01';
-- 여러 컬럼 동시 수정
UPDATE products
SET price = price * 1.1, -- 10% 인상
updated_at = NOW()
WHERE category = 'electronics';
-- JOIN UPDATE (다른 테이블 참조하여 수정)
-- PostgreSQL
UPDATE orders o
SET status = 'cancelled'
FROM users u
WHERE o.user_id = u.id
AND u.status = 'banned';
-- MySQL
UPDATE orders o
JOIN users u ON o.user_id = u.id
SET o.status = 'cancelled'
WHERE u.status = 'banned';
-- CASE를 사용한 조건부 UPDATE
UPDATE employees
SET salary = CASE
WHEN department = 'engineering' THEN salary * 1.15
WHEN department = 'sales' THEN salary * 1.10
ELSE salary * 1.05
END
WHERE hire_date < '2024-01-01';
-- ⚠️ 실수 방지: WHERE 없이 UPDATE하면 전체 수정!
-- 항상 SELECT로 먼저 확인!
SELECT * FROM users WHERE last_login < '2025-01-01'; -- 먼저 확인
UPDATE users SET status = 'inactive' WHERE last_login < '2025-01-01';
DELETE (삭제)
-- 기본 삭제
DELETE FROM sessions WHERE expired_at < NOW();
-- JOIN DELETE
-- PostgreSQL
DELETE FROM orders
USING users
WHERE orders.user_id = users.id AND users.status = 'deleted';
-- MySQL
DELETE o FROM orders o
JOIN users u ON o.user_id = u.id
WHERE u.status = 'deleted';
-- TRUNCATE (전체 삭제, 빠름, AUTO_INCREMENT 리셋)
TRUNCATE TABLE logs;
-- ⚠️ 소프트 삭제 패턴 (권장)
UPDATE users SET deleted_at = NOW() WHERE id = 123;
-- 조회 시:
SELECT * FROM users WHERE deleted_at IS NULL;
JOIN (결합)
-- INNER JOIN (양쪽 모두 있는 것만)
SELECT u.name, o.amount
FROM users u
INNER JOIN orders o ON u.id = o.user_id;
-- LEFT JOIN (왼쪽 전부 + 오른쪽 매칭)
SELECT u.name, COALESCE(COUNT(o.id), 0) AS order_count
FROM users u
LEFT JOIN orders o ON u.id = o.user_id
GROUP BY u.name;
-- → 주문이 없는 사용자도 포함 (order_count = 0)
-- RIGHT JOIN (오른쪽 전부 + 왼쪽 매칭)
-- 잘 안 씀, LEFT JOIN을 뒤집는 게 가독성 좋음
-- FULL OUTER JOIN (양쪽 전부)
SELECT u.name, o.amount
FROM users u
FULL OUTER JOIN orders o ON u.id = o.user_id;
-- CROSS JOIN (모든 조합, 카테시안 곱)
SELECT s.size, c.color
FROM sizes s CROSS JOIN colors c;
-- 3 sizes × 4 colors = 12 combinations
-- SELF JOIN (자기 자신과 결합)
SELECT e.name AS employee, m.name AS manager
FROM employees e
LEFT JOIN employees m ON e.manager_id = m.id;
JOIN 다이어그램:
INNER JOIN: A ∩ B (교집합만)
LEFT JOIN: A + (A ∩ B)
RIGHT JOIN: (A ∩ B) + B
FULL OUTER: A ∪ B (합집합)
GROUP BY + 집계 함수
-- 기본 집계
SELECT
department,
COUNT(*) AS emp_count,
AVG(salary) AS avg_salary,
MAX(salary) AS max_salary,
MIN(salary) AS min_salary,
SUM(salary) AS total_salary
FROM employees
GROUP BY department
HAVING AVG(salary) > 50000000 -- 평균 연봉 5천만 이상 부서만
ORDER BY avg_salary DESC;
-- ROLLUP (소계 + 총계)
SELECT
COALESCE(department, '=== 전체 ===') AS department,
COALESCE(position, '--- 소계 ---') AS position,
COUNT(*) AS count,
AVG(salary) AS avg_salary
FROM employees
GROUP BY ROLLUP(department, position);
서브쿼리 (Subquery)
-- WHERE 서브쿼리
SELECT * FROM users
WHERE id IN (
SELECT user_id FROM orders
WHERE amount > 1000000
);
-- FROM 서브쿼리 (인라인 뷰)
SELECT dept_name, avg_salary
FROM (
SELECT department AS dept_name, AVG(salary) AS avg_salary
FROM employees
GROUP BY department
) sub
WHERE avg_salary > 60000000;
-- EXISTS (존재 여부 확인, IN보다 대량 데이터에서 빠름)
SELECT u.name
FROM users u
WHERE EXISTS (
SELECT 1 FROM orders o
WHERE o.user_id = u.id AND o.status = 'completed'
);
-- 스칼라 서브쿼리 (SELECT 절)
SELECT
name,
salary,
salary - (SELECT AVG(salary) FROM employees) AS diff_from_avg
FROM employees;
CTE (Common Table Expression) — 가독성의 왕
-- 기본 CTE
WITH active_users AS (
SELECT id, name, email
FROM users
WHERE status = 'active' AND last_login > NOW() - INTERVAL '30 days'
),
user_orders AS (
SELECT user_id, COUNT(*) AS order_count, SUM(amount) AS total
FROM orders
WHERE created_at > NOW() - INTERVAL '30 days'
GROUP BY user_id
)
SELECT
au.name,
au.email,
COALESCE(uo.order_count, 0) AS orders,
COALESCE(uo.total, 0) AS total_spent
FROM active_users au
LEFT JOIN user_orders uo ON au.id = uo.user_id
ORDER BY total_spent DESC;
-- 재귀 CTE (조직도, 카테고리 트리)
WITH RECURSIVE org_tree AS (
-- 기저: CEO (manager_id가 NULL)
SELECT id, name, manager_id, 1 AS level
FROM employees WHERE manager_id IS NULL
UNION ALL
-- 재귀: 부하 직원 탐색
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 REPEAT(' ', level - 1) || name AS org_chart, level
FROM org_tree
ORDER BY level, name;
윈도우 함수 (Window Functions)
-- ROW_NUMBER (순번)
SELECT
name, department, salary,
ROW_NUMBER() OVER (ORDER BY salary DESC) AS rank_all,
ROW_NUMBER() OVER (PARTITION BY department ORDER BY salary DESC) AS rank_dept
FROM employees;
-- RANK vs DENSE_RANK
-- RANK: 1, 2, 2, 4 (공동 2등 후 4등)
-- DENSE_RANK: 1, 2, 2, 3 (공동 2등 후 3등)
-- LAG / LEAD (이전/다음 행 참조)
SELECT
date,
revenue,
LAG(revenue) OVER (ORDER BY date) AS prev_day,
revenue - LAG(revenue) OVER (ORDER BY date) AS daily_change,
ROUND(
(revenue - LAG(revenue) OVER (ORDER BY date))
/ LAG(revenue) OVER (ORDER BY date) * 100, 1
) AS change_pct
FROM daily_sales;
-- 누적합 (Running Total)
SELECT
date, amount,
SUM(amount) OVER (ORDER BY date) AS running_total,
AVG(amount) OVER (ORDER BY date ROWS BETWEEN 6 PRECEDING AND CURRENT ROW) AS moving_avg_7d
FROM daily_sales;
-- NTILE (분위 나누기)
SELECT
name, salary,
NTILE(4) OVER (ORDER BY salary DESC) AS quartile
-- 1=상위25%, 2=25~50%, 3=50~75%, 4=하위25%
FROM employees;
인덱스 (Index) 전략
-- 인덱스 생성
CREATE INDEX idx_users_email ON users(email);
CREATE INDEX idx_orders_user_date ON orders(user_id, created_at DESC);
CREATE UNIQUE INDEX idx_users_email_unique ON users(email);
-- 복합 인덱스 순서가 중요!
-- idx(a, b, c) 는:
-- WHERE a = 1 ✅ 사용
-- WHERE a = 1 AND b = 2 ✅ 사용
-- WHERE a = 1 AND b = 2 AND c = 3 ✅ 사용
-- WHERE b = 2 ❌ 미사용! (선두 컬럼 없음)
-- WHERE a = 1 AND c = 3 △ a만 사용 (b 건너뜀)
-- 부분 인덱스 (PostgreSQL)
CREATE INDEX idx_active_users ON users(email) WHERE status = 'active';
-- 커버링 인덱스 (테이블 접근 없이 인덱스만으로 조회)
CREATE INDEX idx_covering ON orders(user_id, status, amount);
SELECT status, SUM(amount) FROM orders WHERE user_id = 123 GROUP BY status;
-- → 인덱스만 읽으면 됨! (테이블 I/O 없음)
실행 계획 (EXPLAIN)
-- PostgreSQL
EXPLAIN ANALYZE
SELECT u.name, COUNT(o.id)
FROM users u
LEFT JOIN orders o ON u.id = o.user_id
WHERE u.status = 'active'
GROUP BY u.name;
-- 읽는 법:
-- Seq Scan: 풀 테이블 스캔 (인덱스 필요?)
-- Index Scan: 인덱스 사용 ✅
-- Index Only Scan: 커버링 인덱스 ✅✅
-- Nested Loop: 소량 데이터 JOIN
-- Hash Join: 대량 데이터 JOIN
-- Sort: ORDER BY (메모리 초과 시 디스크)
-- Bitmap Heap Scan: 여러 인덱스 조합
-- MySQL
EXPLAIN FORMAT=JSON
SELECT * FROM orders WHERE user_id = 123 AND status = 'completed';
실무 패턴 모음
페이지네이션
-- OFFSET 방식 (간단하지만 대량 데이터에서 느림)
SELECT * FROM posts ORDER BY id DESC LIMIT 20 OFFSET 40;
-- 커서 기반 (대량 데이터 추천!)
SELECT * FROM posts
WHERE id < 12345 -- 마지막으로 본 id
ORDER BY id DESC
LIMIT 20;
중복 제거
-- 중복 행 찾기
SELECT email, COUNT(*) as cnt
FROM users
GROUP BY email
HAVING COUNT(*) > 1;
-- 중복 중 최신만 남기고 삭제
DELETE FROM users
WHERE id NOT IN (
SELECT MIN(id) FROM users GROUP BY email
);
-- 또는 CTE로
WITH ranked 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 ranked WHERE rn > 1);
날짜 관련
-- 오늘/이번달/올해
SELECT * FROM orders WHERE created_at::date = CURRENT_DATE;
SELECT * FROM orders WHERE DATE_TRUNC('month', created_at) = DATE_TRUNC('month', NOW());
-- 최근 7일 일별 통계
SELECT
DATE(created_at) AS date,
COUNT(*) AS orders,
SUM(amount) AS revenue
FROM orders
WHERE created_at >= NOW() - INTERVAL '7 days'
GROUP BY DATE(created_at)
ORDER BY date;
-- 시간대별 분포
SELECT
EXTRACT(HOUR FROM created_at) AS hour,
COUNT(*) AS count
FROM orders
GROUP BY hour
ORDER BY hour;
락(Lock) 주의
-- SELECT FOR UPDATE (비관적 락)
BEGIN;
SELECT * FROM products WHERE id = 1 FOR UPDATE; -- 다른 트랜잭션 대기
UPDATE products SET stock = stock - 1 WHERE id = 1;
COMMIT;
-- 낙관적 락 (version 컬럼)
UPDATE products
SET stock = stock - 1, version = version + 1
WHERE id = 1 AND version = 5; -- version이 맞을 때만 수정
-- 영향 받은 행 = 0이면 → 다른 사람이 먼저 수정!
📝 퀴즈 — SQL 실무 (클릭해서 확인!)
Q1. LEFT JOIN과 INNER JOIN의 차이는? ||LEFT JOIN: 왼쪽 테이블의 모든 행 포함, 오른쪽 매칭 없으면 NULL. INNER JOIN: 양쪽 모두 매칭되는 행만 반환||
Q2. UPSERT를 PostgreSQL과 MySQL에서 각각 어떻게 쓰나? ||PostgreSQL: INSERT ... ON CONFLICT (key) DO UPDATE SET ... MySQL: INSERT ... ON DUPLICATE KEY UPDATE ...||
Q3. 복합 인덱스 idx(a, b, c)에서 WHERE b = 2만 쓰면? ||인덱스를 사용하지 못함. 복합 인덱스는 왼쪽부터 순서대로 사용. 선두 컬럼(a) 없이는 작동하지 않음||
Q4. ROW_NUMBER와 DENSE_RANK의 차이는? ||ROW_NUMBER: 항상 연속 번호 (동점 없음). DENSE_RANK: 동점 시 같은 순위, 다음 순위는 바로 다음 번호 (1,2,2,3). RANK는 동점 후 건너뜀 (1,2,2,4)||
Q5. OFFSET 페이지네이션이 대량 데이터에서 느린 이유는? ||OFFSET N은 N개를 읽고 버림. OFFSET 100만이면 100만 행을 읽은 후 결과 반환. 커서 기반은 인덱스로 바로 시작점 접근||
Q6. 커버링 인덱스란? ||쿼리에 필요한 모든 컬럼이 인덱스에 포함되어 테이블 접근 없이 인덱스만으로 결과를 반환하는 것. Index Only Scan||
Q7. SELECT FOR UPDATE의 용도와 주의점은? ||비관적 락 — 선택한 행을 다른 트랜잭션이 수정하지 못하게 잠금. 주의: 트랜잭션이 길어지면 다른 트랜잭션이 대기하므로 데드락 위험||
Q8. WHERE 없이 UPDATE를 실행하면? ||테이블의 모든 행이 수정됨! 항상 SELECT로 대상을 먼저 확인한 후 UPDATE 실행. 프로덕션에서는 트랜잭션으로 감싸고 확인 후 COMMIT||
SQL Practical Cheatsheet — Complete Reference for Everyday Commands
- Basic CRUD
- JOIN
- GROUP BY + Aggregate Functions
- Subquery
- CTE (Common Table Expression) — The King of Readability
- Window Functions
- Index Strategy
- Execution Plan (EXPLAIN)
- Practical Pattern Collection
- Quiz

Basic CRUD
SELECT (Query)
-- Basic query
SELECT * FROM users WHERE age >= 20 ORDER BY created_at DESC LIMIT 10;
-- Specific columns only
SELECT id, name, email FROM users WHERE status = 'active';
-- Alias
SELECT
u.name AS user_name,
COUNT(o.id) AS order_count,
SUM(o.amount) AS total_spent
FROM users u
JOIN orders o ON u.id = o.user_id
GROUP BY u.name;
-- DISTINCT (remove duplicates)
SELECT DISTINCT department FROM employees;
-- BETWEEN, IN, LIKE
SELECT * FROM products
WHERE price BETWEEN 10000 AND 50000
AND category IN ('electronics', 'books')
AND name LIKE '%Galaxy%';
-- NULL handling
SELECT name, COALESCE(phone, 'Not registered') AS phone
FROM users
WHERE email IS NOT NULL;
INSERT
-- Single insert
INSERT INTO users (name, email, age) VALUES ('Kim Youngju', 'yj@example.com', 30);
-- Multiple inserts
INSERT INTO users (name, email, age) VALUES
('Hong Gildong', 'hong@example.com', 25),
('Lee Sunsin', 'lee@example.com', 35),
('King Sejong', 'sejong@example.com', 45);
-- INSERT from SELECT (table copy)
INSERT INTO users_backup (name, email, age)
SELECT name, email, age FROM users WHERE status = 'active';
-- UPSERT (UPDATE if exists, INSERT if not)
-- PostgreSQL
INSERT INTO users (email, name, login_count)
VALUES ('yj@example.com', 'Kim Youngju', 1)
ON CONFLICT (email)
DO UPDATE SET
login_count = users.login_count + 1,
last_login = NOW();
-- MySQL
INSERT INTO users (email, name, login_count)
VALUES ('yj@example.com', 'Kim Youngju', 1)
ON DUPLICATE KEY UPDATE
login_count = login_count + 1,
last_login = NOW();
UPDATE
-- Basic update
UPDATE users SET status = 'inactive' WHERE last_login < '2025-01-01';
-- Update multiple columns at once
UPDATE products
SET price = price * 1.1, -- 10% increase
updated_at = NOW()
WHERE category = 'electronics';
-- JOIN UPDATE (update referencing another table)
-- PostgreSQL
UPDATE orders o
SET status = 'cancelled'
FROM users u
WHERE o.user_id = u.id
AND u.status = 'banned';
-- MySQL
UPDATE orders o
JOIN users u ON o.user_id = u.id
SET o.status = 'cancelled'
WHERE u.status = 'banned';
-- Conditional UPDATE with CASE
UPDATE employees
SET salary = CASE
WHEN department = 'engineering' THEN salary * 1.15
WHEN department = 'sales' THEN salary * 1.10
ELSE salary * 1.05
END
WHERE hire_date < '2024-01-01';
-- Warning: UPDATE without WHERE modifies ALL rows!
-- Always verify with SELECT first!
SELECT * FROM users WHERE last_login < '2025-01-01'; -- Verify first
UPDATE users SET status = 'inactive' WHERE last_login < '2025-01-01';
DELETE
-- Basic delete
DELETE FROM sessions WHERE expired_at < NOW();
-- JOIN DELETE
-- PostgreSQL
DELETE FROM orders
USING users
WHERE orders.user_id = users.id AND users.status = 'deleted';
-- MySQL
DELETE o FROM orders o
JOIN users u ON o.user_id = u.id
WHERE u.status = 'deleted';
-- TRUNCATE (delete all, fast, resets AUTO_INCREMENT)
TRUNCATE TABLE logs;
-- Soft delete pattern (recommended)
UPDATE users SET deleted_at = NOW() WHERE id = 123;
-- When querying:
SELECT * FROM users WHERE deleted_at IS NULL;
JOIN
-- INNER JOIN (only rows that exist in both)
SELECT u.name, o.amount
FROM users u
INNER JOIN orders o ON u.id = o.user_id;
-- LEFT JOIN (all from left + matching from right)
SELECT u.name, COALESCE(COUNT(o.id), 0) AS order_count
FROM users u
LEFT JOIN orders o ON u.id = o.user_id
GROUP BY u.name;
-- → Includes users with no orders (order_count = 0)
-- RIGHT JOIN (all from right + matching from left)
-- Rarely used, flipping LEFT JOIN is more readable
-- FULL OUTER JOIN (all from both sides)
SELECT u.name, o.amount
FROM users u
FULL OUTER JOIN orders o ON u.id = o.user_id;
-- CROSS JOIN (all combinations, Cartesian product)
SELECT s.size, c.color
FROM sizes s CROSS JOIN colors c;
-- 3 sizes x 4 colors = 12 combinations
-- SELF JOIN (join with itself)
SELECT e.name AS employee, m.name AS manager
FROM employees e
LEFT JOIN employees m ON e.manager_id = m.id;
JOIN Diagram:
INNER JOIN: A intersection B (intersection only)
LEFT JOIN: A + (A intersection B)
RIGHT JOIN: (A intersection B) + B
FULL OUTER: A union B (union)
GROUP BY + Aggregate Functions
-- Basic aggregation
SELECT
department,
COUNT(*) AS emp_count,
AVG(salary) AS avg_salary,
MAX(salary) AS max_salary,
MIN(salary) AS min_salary,
SUM(salary) AS total_salary
FROM employees
GROUP BY department
HAVING AVG(salary) > 50000000 -- Only departments with average salary above 50M
ORDER BY avg_salary DESC;
-- ROLLUP (subtotals + grand total)
SELECT
COALESCE(department, '=== Total ===') AS department,
COALESCE(position, '--- Subtotal ---') AS position,
COUNT(*) AS count,
AVG(salary) AS avg_salary
FROM employees
GROUP BY ROLLUP(department, position);
Subquery
-- WHERE subquery
SELECT * FROM users
WHERE id IN (
SELECT user_id FROM orders
WHERE amount > 1000000
);
-- FROM subquery (inline view)
SELECT dept_name, avg_salary
FROM (
SELECT department AS dept_name, AVG(salary) AS avg_salary
FROM employees
GROUP BY department
) sub
WHERE avg_salary > 60000000;
-- EXISTS (check existence, faster than IN for large datasets)
SELECT u.name
FROM users u
WHERE EXISTS (
SELECT 1 FROM orders o
WHERE o.user_id = u.id AND o.status = 'completed'
);
-- Scalar subquery (in SELECT clause)
SELECT
name,
salary,
salary - (SELECT AVG(salary) FROM employees) AS diff_from_avg
FROM employees;
CTE (Common Table Expression) — The King of Readability
-- Basic CTE
WITH active_users AS (
SELECT id, name, email
FROM users
WHERE status = 'active' AND last_login > NOW() - INTERVAL '30 days'
),
user_orders AS (
SELECT user_id, COUNT(*) AS order_count, SUM(amount) AS total
FROM orders
WHERE created_at > NOW() - INTERVAL '30 days'
GROUP BY user_id
)
SELECT
au.name,
au.email,
COALESCE(uo.order_count, 0) AS orders,
COALESCE(uo.total, 0) AS total_spent
FROM active_users au
LEFT JOIN user_orders uo ON au.id = uo.user_id
ORDER BY total_spent DESC;
-- Recursive CTE (org charts, category trees)
WITH RECURSIVE org_tree AS (
-- Base case: CEO (manager_id is NULL)
SELECT id, name, manager_id, 1 AS level
FROM employees WHERE manager_id IS NULL
UNION ALL
-- Recursive case: traverse subordinates
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 REPEAT(' ', level - 1) || name AS org_chart, level
FROM org_tree
ORDER BY level, name;
Window Functions
-- ROW_NUMBER (sequential numbering)
SELECT
name, department, salary,
ROW_NUMBER() OVER (ORDER BY salary DESC) AS rank_all,
ROW_NUMBER() OVER (PARTITION BY department ORDER BY salary DESC) AS rank_dept
FROM employees;
-- RANK vs DENSE_RANK
-- RANK: 1, 2, 2, 4 (ties at 2nd, then 4th)
-- DENSE_RANK: 1, 2, 2, 3 (ties at 2nd, then 3rd)
-- LAG / LEAD (reference previous/next row)
SELECT
date,
revenue,
LAG(revenue) OVER (ORDER BY date) AS prev_day,
revenue - LAG(revenue) OVER (ORDER BY date) AS daily_change,
ROUND(
(revenue - LAG(revenue) OVER (ORDER BY date))
/ LAG(revenue) OVER (ORDER BY date) * 100, 1
) AS change_pct
FROM daily_sales;
-- Running Total
SELECT
date, amount,
SUM(amount) OVER (ORDER BY date) AS running_total,
AVG(amount) OVER (ORDER BY date ROWS BETWEEN 6 PRECEDING AND CURRENT ROW) AS moving_avg_7d
FROM daily_sales;
-- NTILE (divide into quantiles)
SELECT
name, salary,
NTILE(4) OVER (ORDER BY salary DESC) AS quartile
-- 1=top 25%, 2=25~50%, 3=50~75%, 4=bottom 25%
FROM employees;
Index Strategy
-- Create index
CREATE INDEX idx_users_email ON users(email);
CREATE INDEX idx_orders_user_date ON orders(user_id, created_at DESC);
CREATE UNIQUE INDEX idx_users_email_unique ON users(email);
-- Composite index order matters!
-- idx(a, b, c):
-- WHERE a = 1 Used
-- WHERE a = 1 AND b = 2 Used
-- WHERE a = 1 AND b = 2 AND c = 3 Used
-- WHERE b = 2 NOT used! (leading column missing)
-- WHERE a = 1 AND c = 3 Partially used (a only, b skipped)
-- Partial index (PostgreSQL)
CREATE INDEX idx_active_users ON users(email) WHERE status = 'active';
-- Covering index (query resolved from index alone, no table access)
CREATE INDEX idx_covering ON orders(user_id, status, amount);
SELECT status, SUM(amount) FROM orders WHERE user_id = 123 GROUP BY status;
-- → Only reads from index! (no table I/O)
Execution Plan (EXPLAIN)
-- PostgreSQL
EXPLAIN ANALYZE
SELECT u.name, COUNT(o.id)
FROM users u
LEFT JOIN orders o ON u.id = o.user_id
WHERE u.status = 'active'
GROUP BY u.name;
-- How to read:
-- Seq Scan: Full table scan (need an index?)
-- Index Scan: Index used
-- Index Only Scan: Covering index
-- Nested Loop: JOIN for small datasets
-- Hash Join: JOIN for large datasets
-- Sort: ORDER BY (disk if memory exceeded)
-- Bitmap Heap Scan: Combining multiple indexes
-- MySQL
EXPLAIN FORMAT=JSON
SELECT * FROM orders WHERE user_id = 123 AND status = 'completed';
Practical Pattern Collection
Pagination
-- OFFSET method (simple but slow for large datasets)
SELECT * FROM posts ORDER BY id DESC LIMIT 20 OFFSET 40;
-- Cursor-based (recommended for large datasets!)
SELECT * FROM posts
WHERE id < 12345 -- Last seen id
ORDER BY id DESC
LIMIT 20;
Deduplication
-- Find duplicate rows
SELECT email, COUNT(*) as cnt
FROM users
GROUP BY email
HAVING COUNT(*) > 1;
-- Delete duplicates, keep only the oldest
DELETE FROM users
WHERE id NOT IN (
SELECT MIN(id) FROM users GROUP BY email
);
-- Or using CTE
WITH ranked 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 ranked WHERE rn > 1);
Date-Related
-- Today / this month / this year
SELECT * FROM orders WHERE created_at::date = CURRENT_DATE;
SELECT * FROM orders WHERE DATE_TRUNC('month', created_at) = DATE_TRUNC('month', NOW());
-- Daily stats for last 7 days
SELECT
DATE(created_at) AS date,
COUNT(*) AS orders,
SUM(amount) AS revenue
FROM orders
WHERE created_at >= NOW() - INTERVAL '7 days'
GROUP BY DATE(created_at)
ORDER BY date;
-- Hourly distribution
SELECT
EXTRACT(HOUR FROM created_at) AS hour,
COUNT(*) AS count
FROM orders
GROUP BY hour
ORDER BY hour;
Lock Considerations
-- SELECT FOR UPDATE (pessimistic locking)
BEGIN;
SELECT * FROM products WHERE id = 1 FOR UPDATE; -- Other transactions wait
UPDATE products SET stock = stock - 1 WHERE id = 1;
COMMIT;
-- Optimistic locking (version column)
UPDATE products
SET stock = stock - 1, version = version + 1
WHERE id = 1 AND version = 5; -- Only updates if version matches
-- Affected rows = 0 means someone else updated first!
Quiz — Practical SQL (Click to check!)
Q1. What is the difference between LEFT JOIN and INNER JOIN? ||LEFT JOIN: Includes all rows from the left table; NULL if no match on right. INNER JOIN: Returns only rows that match in both tables.||
Q2. How do you write UPSERT in PostgreSQL and MySQL respectively? ||PostgreSQL: INSERT ... ON CONFLICT (key) DO UPDATE SET ... MySQL: INSERT ... ON DUPLICATE KEY UPDATE ...||
Q3. What happens with composite index idx(a, b, c) when you use only WHERE b = 2? ||The index is not used. Composite indexes are used from left to right. It does not work without the leading column (a).||
Q4. What is the difference between ROW_NUMBER and DENSE_RANK? ||ROW_NUMBER: Always sequential numbers (no ties). DENSE_RANK: Same rank for ties, next rank is the immediate next number (1,2,2,3). RANK skips after ties (1,2,2,4).||
Q5. Why is OFFSET pagination slow for large datasets? ||OFFSET N reads and discards N rows. OFFSET 1,000,000 reads 1 million rows before returning results. Cursor-based pagination uses an index to jump directly to the starting point.||
Q6. What is a covering index? ||An index that contains all columns needed by the query, so results can be returned from the index alone without accessing the table. This is called Index Only Scan.||
Q7. What is SELECT FOR UPDATE used for and what should you watch out for? ||Pessimistic locking — locks the selected rows so other transactions cannot modify them. Caution: If the transaction takes too long, other transactions will wait, risking deadlock.||
Q8. What happens if you run UPDATE without WHERE? ||All rows in the table are modified! Always verify the target with SELECT before running UPDATE. In production, wrap in a transaction and COMMIT after verification.||
Quiz
Q1: What is the main topic covered in "SQL Practical Cheatsheet — Complete Reference for
Everyday Commands"?
From SELECT, UPDATE, INSERT, DELETE to subqueries, window functions, CTEs, index strategies, and execution plan analysis. All the SQL patterns you use daily in one place. Copy and use right away.
Q2: What is Basic CRUD?
SELECT (Query) INSERT UPDATE DELETE
Q3: Explain the core concept of Practical Pattern Collection.
Pagination Deduplication Date-Related Lock Considerations Q1. What is the difference between LEFT
JOIN and INNER JOIN? Q2. How do you write UPSERT in PostgreSQL and MySQL respectively? Q3. What
happens with composite index idx(a, b, c) when you use only WHERE b = 2? Q4.