User Engagement Summary

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
IBM

Problem

With no master user list to start from, an analyst's engagement roll-up needs one row for every identified user who appears in user_sessions, search_queries or the daily datelist snapshots in user_activity_cumulated, with a zero for any stream a user never touched. Report each user's total sessions, total search queries and the most recent last_active_date the datelist has recorded for them, blank when the datelist never tracked them, and leave out searches logged without a user attached.

Sessions plus searches. The full engagement picture.

Tables

user_sessions

Columns: session_id INTEGER, user_id INTEGER, device_id INTEGER, session_start TEXT, session_duration_sec INTEGER, pages_viewed INTEGER

user_sessions: 10 of 200 rows
session_iduser_iddevice_idsession_startsession_duration_secpages_viewed
70611972302026-02-02 01:00:00533
71222942592026-03-03 02:00:00766
71833912882026-04-04 03:00:00999
72444883172026-05-05 04:00:0012212
73055853462026-06-06 05:00:0014515
73666823752026-07-07 06:00:0016818
74277794042026-08-08 07:00:0019121
7488876NULL2026-09-09 08:00:0021424
75499734622026-01-10 09:00:0023727
761010704912026-02-11 10:00:0026030

search_queries

Columns: query_id INTEGER, user_id INTEGER, search_term TEXT, results_count INTEGER, clicked_result INTEGER, query_time TEXT

search_queries: 10 of 200 rows
query_iduser_idsearch_termresults_countclicked_resultquery_time
2071100laptop deals1722026-02-02 01:11:00
2142197phone case iphone 153432026-03-03 02:22:00
2213294usb-c cable 6ft51NULL2026-04-04 03:33:00
2284NULLgaming mouse6852026-05-05 04:44:00
2355488mechanical keyboard8562026-06-06 05:55:00
24265854k monitor102NULL2026-07-07 06:06:00
2497682standing desk11982026-08-08 07:17:00
2568NULLwebcam 1080p13692026-09-09 08:28:00
2639876bluetooth speaker153NULL2026-01-10 09:39:00
2710973smart watch17012026-02-11 10:50:00

user_activity_cumulated

Columns: cum_row_id INTEGER, user_id INTEGER, ds TEXT, first_active_date TEXT, last_active_date TEXT, activity_bits INTEGER

user_activity_cumulated: 10 of 200 rows
cum_row_iduser_iddsfirst_active_datelast_active_dateactivity_bits
732000157262026-09-092024-07-282025-12-290
732000262112026-09-092025-03-142026-07-260
732000324282026-09-092025-04-242026-09-09341
732000449502026-09-092025-06-042025-12-170
732000541742026-09-092023-12-102025-10-230
732000652412026-09-092025-03-162026-02-240
732000712642026-09-092025-12-252026-09-0633556504
732000818462026-09-092025-03-102026-09-0915
732000921372026-09-092025-03-152026-09-093
732001058232026-09-092023-10-242025-12-250

Expected output

First 5 of 98 rows
user_idtotal_sessionstotal_querieslast_active_date
10082NULL
19752NULL
294522026-09-12
39150NULL
779502026-09-12

Worked solution and explanation

What this really is

This is a three-way merge of sources at different grains dressed up as an engagement report. There is no master users table, so the roster has to be built from user_sessions, search_queries and the datelist user_activity_cumulated. Anyone can count rows per user. The trick is the order of operations: collapse each source to one row per user on its own, take the union of the ids, then attach. Join the raw tables first and every survivor's counts multiply by the other sources' row counts, and every user missing from the first table vanishes.

The second trap hides in search_queries: 50 searches carry no user_id. Leave them in and they collapse into one phantom user with a pile of searches and zero sessions, a row nobody on the product side can act on.


Building it

Step 1: Count sessions per user

GROUP BY user_id over user_sessions into total_sessions. Every session there is tied to a user, so there is nothing to filter on this side.

Step 2: Count identified searches per user

Same shape over search_queries, with WHERE user_id IS NOT NULL ahead of the GROUP BY. Filtering before grouping is what stops the anonymous searches from forming a group of their own.

Step 3: Read the datelist at its latest word

user_activity_cumulated is a cumulative datelist: every daily snapshot rewrites the user's whole history, so a user has one row per ds, not one row per activity. Counting those rows means nothing, and joining them raw fans out the other counts. MAX(last_active_date) per user_id keeps only the newest thing the datelist knows about each user.

Step 4: Build the roster from all three sides

UNION the three id lists into all_users. Set semantics matter: a user present in several sources appears once, so the joins that follow cannot double them, and a user known only to the datelist still gets a row.

Step 5: Attach the per-user rows and default the gaps

LEFT JOIN all three CTEs onto all_users. A missing count comes back NULL and COALESCE(..., 0) turns it into the zero the report means. last_active_date stays NULL on purpose: a user the datelist never tracked has no date, and inventing one would be a lie.

Count first, merge second
WITH session_counts AS (
  SELECT user_id, COUNT(*) AS total_sessions
  FROM user_sessions
  GROUP BY user_id
),
query_counts AS (
  SELECT user_id, COUNT(*) AS total_queries
  FROM search_queries
  WHERE user_id IS NOT NULL
  GROUP BY user_id
),
datelist_latest AS (
  SELECT user_id, MAX(last_active_date) AS last_active_date
  FROM user_activity_cumulated
  GROUP BY user_id
),
all_users AS (
  SELECT user_id FROM session_counts
  UNION
  SELECT user_id FROM query_counts
  UNION
  SELECT user_id FROM datelist_latest
)
SELECT au.user_id,
       COALESCE(sc.total_sessions, 0) AS total_sessions,
       COALESCE(qc.total_queries,  0) AS total_queries,
       dl.last_active_date
FROM all_users au
LEFT JOIN session_counts  sc ON au.user_id = sc.user_id
LEFT JOIN query_counts    qc ON au.user_id = qc.user_id
LEFT JOIN datelist_latest dl ON au.user_id = dl.user_id;
Join the raw tables
user_sessions LEFT JOIN search_queries LEFT JOIN user_activity_cumulated ON user_id, then COUNT each side. A user with 4 sessions, 2 searches and 3 snapshots reports 24 and 24, and every search-only or datelist-only user vanishes because the roster starts from sessions.
Collapse first, then merge
Each source shrinks to one row per user_id before any join, so every join is one-to-one and all_users covers all three sides.

Where the interviewer goes next

Common follow-up questions