📘 SQL Basics & Joins
Query structure & clauses
The core clause order every query follows, plus common NULL-handling functions.
Finding duplicates
COUNT(*) vs COUNT(column) — a subtle but important distinction.
Left Join — Anti-join pattern
Find rows in A whose key does NOT exist in B.
Joins & Subqueries overview
Quick reference for the join family.
🧮 Window Functions
Window function basics
The building blocks: PARTITION BY, ORDER BY, and the functions that use them.
💰 Aggregation, Spend & Averages
Case 1: Average DAILY spend exceeds 3
Two-step aggregation: sum per day first, then average the daily sums.
Sample data| customer_id | txn_date | txn_amount |
|---|---|---|
| 1 | 2025-08-01 | 1.00 |
| 1 | 2025-08-01 | 2.00 |
| 1 | 2025-08-02 | 5.00 |
| 2 | 2025-08-01 | 4.00 |
| 2 | 2025-08-02 | 2.00 |
| customer_id | daily sums | average | included? |
|---|---|---|---|
| 1 | {3.00, 5.00} | 4.00 | ✅ included |
| 2 | {4.00, 2.00} | 3.00 | ❌ excluded (not > 3) |
Case 2: Average PER TRANSACTION spend
Compare this to Case 1 — no need to group by day first.
Average days between transactions
A classic three-step LAG pattern for measuring the gap between events.
Sample data| customer_id | txn_date | txn_amount |
|---|---|---|
| C1 | 2024-01-10 | 20 |
| C1 | 2024-01-01 | 10 |
| C1 | 2024-01-05 | 15 |
| C2 | 2024-01-04 | 7 |
| C2 | 2024-01-02 | 5 |
| customer_id | avg_days_transactions |
|---|---|
| C1 | 4.5 |
| C2 | 2.0 |
Top 10 spenders in last 30 days
A simple filter + aggregate + rank pattern.
🔥 Consecutive Days
Consecutive 3-day logins (exact gap-of-1 method)
The classic 'row number minus date' trick to detect consecutive sequences.
Sample data| user_id | login_date |
|---|---|
| A | 2025-01-01 |
| A | 2025-01-02 |
| A | 2025-01-03 |
| A | 2025-01-05 |
| user_id | login_date | rk | flag_date |
|---|---|---|---|
| A | 2025-01-01 | 1 | 2024-12-31 |
| A | 2025-01-02 | 2 | 2024-12-31 |
| A | 2025-01-03 | 3 | 2024-12-31 |
| A | 2025-01-05 | 4 | 2025-01-01 |
▶ Show execution process
Variant: allow a 1-day gap
A more flexible version — tolerates small gaps instead of requiring exact consecutive dates.
Sample data| user_id | login_date |
|---|---|
| A | 2025-01-01 |
| A | 2025-01-03 |
| A | 2025-01-05 |
| A | 2025-01-06 |
WITH login_diffs AS (
SELECT
user_id,
login_date,
DATEDIFF(
login_date,
LAG(login_date) OVER (
PARTITION BY user_id
ORDER BY login_date
)
) AS diff_days
FROM table1
),
login_flags AS (
SELECT
user_id,
login_date,
CASE
WHEN diff_days IS NULL OR diff_days > 2 THEN 1
ELSE 0
END AS gap_flag
FROM login_diffs
),
segments AS (
SELECT
user_id,
login_date,
SUM(gap_flag) OVER (
PARTITION BY user_id
ORDER BY login_date
ROWS BETWEEN UNBOUNDED PRECEDING
AND CURRENT ROW
) AS segment_id
FROM login_flags
)
SELECT
user_id,
segment_id,
MIN(login_date) AS min_login_date,
MAX(login_date) AS max_login_date,
COUNT(*) AS login_days,
DATEDIFF(MAX(login_date), MIN(login_date)) + 1 AS calendar_days
FROM segments
GROUP BY
user_id,
segment_id
HAVING COUNT(*) >= 3
ORDER BY
user_id,
min_login_date;Result| user_id | segment_id | min_login_date | max_login_date | login_days | calendar_days |
|---|---|---|---|---|---|
| A | 1 | 2025-01-01 | 2025-01-06 | 4 | 6 |
▶ Show execution process
🧩 Merge Date Ranges — Merge Intervals
Problem — Merge overlapping date ranges
Given cmp_id, start_date, and end_date, merge overlapping intervals for each campaign and return one row per merged interval.
If two ranges do not overlap, keep them as separate output rows.
This follows the same overlap rule as LeetCode 56: January 1–5 and January 6–8 do not overlap, so they remain separate.
Assume valid, non-null campaign IDs and dates, with start_date no later than end_date; validate or quarantine invalid rows first.
Memory: Sort → Previous MAX → Mark non-overlap → Running SUM → Merge.
This gaps-and-islands problem extends the preceding Consecutive Days chapter from individual dates to date ranges.
A1 · January 2026
A2 · February 2026
A1 outputs two merged intervals: January 1–10 and January 15–17.
A2 outputs one merged interval: February 1–10.
| cmp_id | start_date | end_date |
|---|---|---|
| A1 | 2026-01-01 | 2026-01-05 |
| A1 | 2026-01-04 | 2026-01-10 |
| A1 | 2026-01-15 | 2026-01-17 |
| A2 | 2026-02-01 | 2026-02-05 |
| A2 | 2026-02-04 | 2026-02-08 |
| A2 | 2026-02-07 | 2026-02-10 |
Sample order is only for readability. In the real case, table storage order is not guaranteed; the solution defines ORDER BY explicitly inside each window.
-- Sample is sorted only for readability.
-- Real table row order is not guaranteed.
CREATEORREPLACETEMPVIEWcampaignAS
SELECT
cmp_id,
start_date,
end_date
FROMVALUES
('A1',DATE'2026-01-01',DATE'2026-01-05'),
('A1',DATE'2026-01-04',DATE'2026-01-10'),
('A1',DATE'2026-01-15',DATE'2026-01-17'),
('A2',DATE'2026-02-01',DATE'2026-02-05'),
('A2',DATE'2026-02-04',DATE'2026-02-08'),
('A2',DATE'2026-02-07',DATE'2026-02-10')
ASdemo(cmp_id,start_date,end_date);Expected final result| cmp_id | start_date | end_date | interval_days |
|---|---|---|---|
| A1 | 2026-01-01 | 2026-01-10 | 10 |
| A1 | 2026-01-15 | 2026-01-17 | 3 |
| A2 | 2026-02-01 | 2026-02-10 | 10 |
A1 must output two rows. January 15–17 does not overlap January 1–10, so it stays as a separate merged interval.
Run Steps 1–6 in order in the same Spark session, or run the complete CTE query at the end.
How to Think About It — SQL vs Python
SQL describes the grouping rules and lets the database derive the groups. Python keeps the current merged state and scans the intervals one by one.
| Memory Point | SQL | Python |
|---|---|---|
| 1. Group |
PARTITION BY cmp_idGroup rows by campaign. |
defaultdict(list)Group intervals by cmp_id. |
| 2. Sort |
ORDER BY start_dateSort inside each partition. |
intervals.sort()Sort each campaign's intervals. |
| 3. Previous coverage |
previous_max_endFurthest end already covered by earlier rows. |
merged[-1][1]End of the current merged interval. |
| 4. New group? |
CASEIf start_date > previous_max_end → new group.
|
if start > merged[-1][1]→ append a new interval. |
| 5. Keep state |
SUM(flag) → group_idSQL stores state as derived columns. |
merged[-1]Python keeps mutable state directly. |
| 6. Merge |
MIN(start) + MAX(end)Merge after group_id is created. |
Update merged[-1][1]Merge while scanning. |
SQL: create group_id first, then merge.
Python: merge while scanning.
SQL previous_max_end ≈ Python merged[-1][1]
Both mean: the furthest end covered so far.
Two interview versions — SQL + Python / LeetCode-style
The core idea is the same: sort intervals, decide whether the current interval connects to existing coverage, then either merge it or start a new group.
Data Warehouse / Spark SQL
Use the running maximum end date because SQL must handle nested ranges, not only the immediately previous row.
WITHbaseAS(
SELECTDISTINCT
cmp_id,
start_date,
end_date
FROMcampaign
),
orderedAS(
SELECT
cmp_id,
start_date,
end_date,
-- Step 1: previous_max_end = furthest end_date from EARLIER rows.
MAX(end_date)OVER(
PARTITIONBYcmp_id
ORDERBYstart_date,end_date
ROWSBETWEENUNBOUNDEDPRECEDINGAND1PRECEDING
)ASprevious_max_end
FROMbase
),
markedAS(
SELECT
cmp_id,
start_date,
end_date,
-- Step 2: no overlap -> start a new group.
CASE
WHENprevious_max_endISNULL
ORstart_date>previous_max_end
THEN1
ELSE0
ENDASis_new_group
FROMordered
),
groupedAS(
SELECT
cmp_id,
start_date,
end_date,
-- Step 3: cumulative flags create group_id.
-- ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW can be omitted here.
SUM(is_new_group)OVER(
PARTITIONBYcmp_id
ORDERBYstart_date,end_date
)ASgroup_id
FROMmarked
)
SELECT
cmp_id,
MIN(start_date)ASmerged_start,
MAX(end_date)ASmerged_end
FROMgrouped
GROUPBY
cmp_id,
group_id
ORDERBY
cmp_id,
merged_start;
A1 | 2026-01-01 | 2026-01-10
A1 | 2026-01-15 | 2026-01-17
A2 | 2026-02-01 | 2026-02-10
This SQL version matches LeetCode 56 semantics: merge overlapping ranges only. A non-overlapping range starts a new output row.
Grouped Merge Intervals — with cmp_id
LeetCode 56 core + cmp_id grouping.
First group by cmp_id, then run the standard merge logic inside each group.
records = [
["A1", 1, 5],
["A1", 4, 10],
["A1", 15, 17],
["A2", 1, 5],
["A2", 4, 8],
["A2", 7, 10],
]
Output
[
["A1", 1, 10],
["A1", 15, 17],
["A2", 1, 10],
]
fromcollectionsimport defaultdict
fromtypingimport List
classSolution:
defmergeByCampaign(self, records: List[List]) -> List[List]:
groups = defaultdict(list)
# Step 1: group intervals by cmp_id.
for cmp_id, start, end in records:
groups[cmp_id].append([start, end])
# groups now looks like:
# {
# "A1": [[1, 5], [4, 10], [15, 17]],
# "A2": [[1, 5], [4, 8], [7, 10]]
# }
result = []
# Step 2: merge intervals inside each cmp_id.
for cmp_id, intervals in groups.items():
intervals.sort(key=lambda x: x[0])
merged = []
for start, end in intervals:
if not merged or start > merged[-1][1]:
merged.append([start, end])
else:
merged[-1][1] = max(merged[-1][1], end)
# Step 3: add cmp_id back.
for start, end in merged:
result.append([cmp_id, start, end])
result.sort(key=lambda x: (x[0], x[1], x[2]))
return result
📊 Sales Ranking & Top-N
Daily city sales ranking
Three window functions in one query, each with a different PARTITION/ORDER combination.
Sample data| order_id | city | order_date | sales |
|---|---|---|---|
| 1 | Beijing | 2025-01-01 | 100 |
| 2 | Shanghai | 2025-01-01 | 150 |
| 3 | Guangzhou | 2025-01-01 | 120 |
| 4 | Beijing | 2025-01-02 | 300 |
| 5 | Shanghai | 2025-01-02 | 250 |
| 6 | Guangzhou | 2025-01-02 | 180 |
| 7 | Beijing | 2025-01-02 | 50 |
| order_date | city | daily_sales | rank | total | cumulative |
|---|---|---|---|---|---|
| 2025-01-01 | Shanghai | 150 | 1 | 370 | 150 |
| 2025-01-01 | Guangzhou | 120 | 2 | 370 | 270 |
| 2025-01-01 | Beijing | 100 | 3 | 370 | 370 |
| 2025-01-02 | Beijing | 350 | 1 | 780 | 350 |
| 2025-01-02 | Shanghai | 250 | 2 | 780 | 600 |
| 2025-01-02 | Guangzhou | 180 | 3 | 780 | 780 |
▶ Show execution process
Top 3 operators per city per day
Same ranking pattern as before, but partitioned by two columns instead of one.
Sample data| order_id | city | order_date | operator_id | sales |
|---|---|---|---|---|
| 1 | Beijing | 2025-01-01 | 101 | 200 |
| 2 | Beijing | 2025-01-01 | 101 | 300 |
| 3 | Beijing | 2025-01-01 | 102 | 300 |
| 4 | Beijing | 2025-01-01 | 103 | 250 |
| 5 | Beijing | 2025-01-01 | 104 | 100 |
| 6 | Shanghai | 2025-01-01 | 201 | 400 |
| 7 | Shanghai | 2025-01-01 | 202 | 300 |
| 8 | Shanghai | 2025-01-01 | 203 | 200 |
| 9 | Shanghai | 2025-01-01 | 204 | 100 |
| city | order_date | operator_id | total_sales | rn |
|---|---|---|---|---|
| Beijing | 2025-01-01 | 101 | 500 | 1 |
| Beijing | 2025-01-01 | 102 | 300 | 2 |
| Beijing | 2025-01-01 | 103 | 250 | 3 |
| Shanghai | 2025-01-01 | 201 | 400 | 1 |
| Shanghai | 2025-01-01 | 202 | 300 | 2 |
| Shanghai | 2025-01-01 | 203 | 200 | 3 |