Skip to content
AgileLogics
7 min read

Analytical SQL Patterns

Prerequisites: Window functions: LAG, LEAD, and NTILE, Recursive queries

A few patterns answer a large share of real analytics questions. They combine CTEs, joins, and window functions from earlier lessons.

Pivoting: rows into columns

SELECT
  p.category,
  SUM(CASE WHEN o.status = 'delivered' THEN oi.quantity ELSE 0 END) AS delivered,
  SUM(CASE WHEN o.status = 'shipped'   THEN oi.quantity ELSE 0 END) AS shipped,
  SUM(CASE WHEN o.status = 'pending'   THEN oi.quantity ELSE 0 END) AS pending
FROM order_items oi
JOIN orders o   ON o.id = oi.order_id
JOIN products p ON p.id = oi.product_id
GROUP BY p.category
ORDER BY p.category;
+-------------+-----------+---------+---------+
|  category   | delivered | shipped | pending |
+-------------+-----------+---------+---------+
| Accessories |         5 |       2 |       1 |
| Audio       |         0 |       2 |       0 |
| Displays    |         3 |       0 |       0 |
| Video       |         0 |       0 |       3 |
+-------------+-----------+---------+---------+

Conditional aggregation turns status values into columns — a cross-tabulation, like a spreadsheet pivot table.

Cohorts: customers by signup month

WITH first_orders AS (
  SELECT customer_id, MIN(ordered_on) AS first_order
  FROM orders
  GROUP BY customer_id
)
SELECT
  STRFTIME('%Y-%m', c.joined_on) AS signup_month,
  COUNT(*)                       AS customers,
  COUNT(f.customer_id)           AS ordered,
  ROUND(100.0 * COUNT(f.customer_id) / COUNT(*), 0) AS pct_ordered
FROM customers c
LEFT JOIN first_orders f ON f.customer_id = c.id
GROUP BY signup_month
ORDER BY signup_month;
+--------------+-----------+---------+-------------+
| signup_month | customers | ordered | pct_ordered |
+--------------+-----------+---------+-------------+
| 2024-01      |         1 |       1 |       100.0 |
| 2024-03      |         2 |       2 |       100.0 |
| 2024-06      |         1 |       1 |       100.0 |
| 2024-09      |         1 |       1 |       100.0 |
| 2025-01      |         1 |       0 |         0.0 |
+--------------+-----------+---------+-------------+

Grouping customers by when they joined, then measuring what they did later, is the basis of retention analysis.

Gaps and islands: consecutive streaks

Find runs of consecutive days — for example, a user's login streaks:

-- standalone
CREATE TABLE logins (user_id INTEGER, day DATE);
INSERT INTO logins VALUES
  (1, '2025-03-01'), (1, '2025-03-02'), (1, '2025-03-03'),
  (1, '2025-03-07'), (1, '2025-03-08'),
  (1, '2025-03-15');

WITH numbered AS (
  SELECT user_id, day,
    JULIANDAY(day) - ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY day) AS grp
  FROM logins
)
SELECT user_id, MIN(day) AS streak_start, MAX(day) AS streak_end, COUNT(*) AS days
FROM numbered
GROUP BY user_id, grp
ORDER BY streak_start;
+---------+--------------+------------+------+
| user_id | streak_start | streak_end | days |
+---------+--------------+------------+------+
|       1 | 2025-03-01   | 2025-03-03 |    3 |
|       1 | 2025-03-07   | 2025-03-08 |    2 |
|       1 | 2025-03-15   | 2025-03-15 |    1 |
+---------+--------------+------------+------+

The trick: for consecutive days, the date and the row number increase together, so their difference stays constant within a streak. Grouping by that difference groups each streak.

Deduplication: keep the latest row per key

-- standalone
CREATE TABLE customer_updates (email TEXT, city TEXT, updated_at TEXT);
INSERT INTO customer_updates VALUES
  ('ada@example.com', 'London',    '2025-01-01'),
  ('ada@example.com', 'Cambridge', '2025-03-10'),
  ('linus@example.com', 'Helsinki', '2025-02-02');

WITH ranked AS (
  SELECT *, ROW_NUMBER() OVER (PARTITION BY email ORDER BY updated_at DESC) AS rn
  FROM customer_updates
)
SELECT email, city, updated_at FROM ranked WHERE rn = 1 ORDER BY email;
+-------------------+-----------+------------+
|       email       |   city    | updated_at |
+-------------------+-----------+------------+
| ada@example.com   | Cambridge | 2025-03-10 |
| linus@example.com | Helsinki  | 2025-02-02 |
+-------------------+-----------+------------+

This "latest record per key" pattern cleans up duplicated imports and event logs.

Percent of total

SELECT category, COUNT(*) AS products,
  ROUND(100.0 * COUNT(*) / SUM(COUNT(*)) OVER (), 1) AS pct
FROM products
GROUP BY category
ORDER BY products DESC, category;
+-------------+----------+------+
|  category   | products | pct  |
+-------------+----------+------+
| Accessories |        3 | 50.0 |
| Audio       |        1 | 16.7 |
| Displays    |        1 | 16.7 |
| Video       |        1 | 16.7 |
+-------------+----------+------+

SUM(COUNT(*)) OVER () — a window over the grouped result — gives the grand total on every row.