Content Session Counts
A medium SQL interview practice problem on DataDriven. Write the query and run it against a real database, with instant feedback.
- Domain
- SQL
- Difficulty
- medium
- Seniority
- L3
- Asked in
- Meta
Problem
The content team's session-engagement report shows '/Home' and '/home' as two separate pages, because page_views logs the same URL in more than one letter case. Rebuild it with each page counted once regardless of case, showing how many unique sessions sit behind it, most sessions first. A view records only its user and never its session, so a page is credited with every session in user_sessions belonging to a user who viewed it.
Session metrics, content item by item.
Tables
page_views
Columns: view_id INTEGER, page_url TEXT, user_id INTEGER, referrer TEXT, dur_ms INTEGER, device TEXT, viewed_at TEXT
| view_id | page_url | user_id | referrer | dur_ms | device | viewed_at |
|---|---|---|---|---|---|---|
| 5053 | /products | 1555 | amazon.com | 637 | desktop | 2026-09-02 01:09:00 |
| 5106 | /checkout | 1652 | twitter.com | 774 | tablet | 2026-09-03 02:18:00 |
| 5159 | /profile | 1749 | github.com | 911 | Mobile | 2026-09-04 03:27:00 |
| 5212 | /search | 1846 | NULL | 1048 | DESKTOP | 2026-09-05 04:36:00 |
| 5265 | /about | 1943 | reddit.com | 1185 | mobile | 2026-09-06 05:45:00 |
| 5318 | /blog/intro | 1555 | NULL | NULL | desktop | 2026-09-07 06:54:00 |
| 5371 | /pricing | 1652 | bing.com | 1459 | tablet | 2026-09-08 07:03:00 |
| 5424 | /docs/api | 1749 | news.ycombinator.com | 1596 | Mobile | 2026-09-09 08:12:00 |
| 5477 | /settings | 1846 | NULL | 1733 | DESKTOP | 2026-09-10 09:21:00 |
| 5530 | /Home | 1943 | bing.com | 1870 | mobile | 2026-09-11 10:30:00 |
user_sessions
Columns: session_id INTEGER, user_id INTEGER, device_id INTEGER, session_start TEXT, session_duration_sec INTEGER, pages_viewed INTEGER
| session_id | user_id | device_id | session_start | session_duration_sec | pages_viewed |
|---|---|---|---|---|---|
| 7061 | 197 | 230 | 2026-02-02 01:00:00 | 53 | 3 |
| 7122 | 294 | 259 | 2026-03-03 02:00:00 | 76 | 6 |
| 7183 | 391 | 288 | 2026-04-04 03:00:00 | 99 | 9 |
| 7244 | 488 | 317 | 2026-05-05 04:00:00 | 122 | 12 |
| 7305 | 585 | 346 | 2026-06-06 05:00:00 | 145 | 15 |
| 7366 | 682 | 375 | 2026-07-07 06:00:00 | 168 | 18 |
| 7427 | 779 | 404 | 2026-08-08 07:00:00 | 191 | 21 |
| 7488 | 876 | NULL | 2026-09-09 08:00:00 | 214 | 24 |
| 7549 | 973 | 462 | 2026-01-10 09:00:00 | 237 | 27 |
| 7610 | 1070 | 491 | 2026-02-11 10:00:00 | 260 | 30 |
Expected output
| page_url | unique_sessions |
|---|---|
| /products | 58 |
| /about | 38 |
| /profile | 35 |
| /home | 35 |
| classic_editor | 30 |
Worked solution and explanation
What this really is
This is a many-to-many fan-out in a content-analytics costume, with a dirty grouping key on top. page_views has no session_id, so the only road to sessions is user_id, and that key repeats on both sides of the join. Anyone can write the join. What separates candidates is what they count after it: COUNT(*) multiplies every session by each view its user left on the page, so tens of sessions turn into hundreds and the order tilts toward heavy viewers. The second trap is quieter. '/Home' and '/home' are one page, and a candidate who fixes the case after counting double-counts every session that reached both spellings.
Step 1: Normalize the page before you group
page_url holds '/Home' and '/home', '/PRODUCTS' and '/products'. Group on LOWER(pv.page_url) so each page lands in one bucket before anything is counted. Lowercasing only the output column is too late, because the groups have already split.
Step 2: Bridge through user_id
Inner-join user_sessions on user_id. A view records no session and the statement promises no time match, so every session of a viewer is credited to the page. Views with a NULL user_id drop out, which is right, since an anonymous view belongs to nobody's session.
Step 3: Count each session once per page
COUNT(DISTINCT us.session_id) collapses the fan-out. Because both spellings now share a group, a session reached through '/PRODUCTS' and '/products' is counted once, not twice.
Step 4: Sort by the session count
ORDER BY unique_sessions DESC puts the strongest pages on top. Pages tied on a count may come back in any order, and the grader accepts that.
SELECT
LOWER(pv.page_url) AS page_url,
COUNT(DISTINCT us.session_id) AS unique_sessions
FROM page_views pv
JOIN user_sessions us ON us.user_id = pv.user_id
GROUP BY LOWER(pv.page_url)
ORDER BY unique_sessions DESC- Summing the spellings afterwards
- '/products' has 35 sessions and '/PRODUCTS' has 38, so adding the two finished rows reports 73.
- Normalizing before counting
- Grouping on
LOWER(pv.page_url)letsCOUNT(DISTINCT us.session_id)see both spellings at once. Three users viewed both, so the page really has 58 sessions.
SELECT
LOWER(pv.page_url) AS page_url,
COUNT(*) AS unique_sessions
FROM page_views pv
JOIN user_sessions us ON us.user_id = pv.user_id
GROUP BY LOWER(pv.page_url)
ORDER BY unique_sessions DESCRuns, looks plausible, and counts joined rows, which are not sessions.
Common follow-up questions
- What changes if `page_views` gained a `session_id` column? (The bridge approximation goes away, but you still need unique counting because one session views a page many times.)
- How would you also report average `dur_ms` per page? (Compute it over `page_views` before the join, since the fan-out weights each view by its user's session count.)
- What if a handful of `user_id` values carry most of the traffic? (Tests recognizing join-key skew: pre-aggregating to page-user pairs, salting, or adaptive skew handling.)