Top 2 Callers per Endpoint

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
L4

Problem

For every endpoint in api_calls, we want the users behind its two highest call volumes and the place that volume holds, so users tied on a volume share a place. List the endpoints alphabetically, top place first, and treat calls logged without a user ID as one unattributed caller.

Two top callers per endpoint.

Tables

api_calls

Columns: call_id INTEGER, endpoint TEXT, method TEXT, status INTEGER, latency REAL, user_id INTEGER, call_time TEXT, err_msg TEXT

api_calls: 10 of 670 rows
call_idendpointmethodstatuslatencyuser_idcall_timeerr_msg
8079/api/v1/ordersPOST2004.21002026-02-02 01:07:00NULL
8158/api/v2/productsPUT2007.91972026-03-03 02:14:00NULL
8237/api/v1/searchDELETE20111.62942026-04-04 03:21:00NULL
8316/api/v1/auth/loginPATCH20415.33912026-05-05 04:28:00NULL
8395/api/v1/auth/logoutget30119NULL2026-06-06 05:35:00NULL
8474/api/v2/analyticspost40022.75852026-07-07 06:42:00Not found
8553/api/v1/paymentsGET40126.46822026-08-08 07:49:00Unauthorized
8632/api/v1/users/POST40330.17792026-09-09 08:56:00Rate limit exceeded
8711/api/v2/products/PUT40433.88762026-01-10 09:03:00Internal server error
8790/api/v1/usersDELETE50037.5NULL2026-02-11 10:10:00Bad gateway

users

Columns: user_id INTEGER, username TEXT, email TEXT, signup_date TEXT, account_status TEXT, age_bucket TEXT

users: 10 of 200 rows
user_idusernameemailsignup_dateaccount_statusage_bucket
100alicealice@example.com2025-02-02inactive25-34
197aaron42aaron42@example.com2026-03-03suspended35-44
294 ameliaamelia@example.com2024-04-04pending_verification45-54
391arjun arjun@example.com2025-05-05active55-64
488ava99ava99@example.com2026-06-06inactive65+
585andrew_kandrew_k@example.com2024-07-07suspendedNULL
682anikaanika@example.com2025-08-08 16:34:00pending_verification18-24
779aidenaiden@example.com2026-09-09 14:53:00active25-34
876aria_baria_b@example.com2024-10-10inactive35-44
973brianbrian@example.com2025-11-11suspended45-54

Expected output

First 5 of 253 rows
endpointuser_idrnk
/api/v2/products21371
/api/v1/auth/login1971
/api/v1/auth/login94122
/api/v1/auth/login88302
/api/v1/auth/logoutNULL1

Worked solution and explanation

What this really is

This is a per-group top-N where N counts call volumes, not users, wearing an endpoint-audit costume. Counting calls per endpoint and user_id is the easy part; the cut is where candidates split. On '/api/v1/auth/login', user 197 leads and users 294 and 391 tie for second. Take exactly two rows per endpoint and one of the tied pair vanishes, with the engine choosing which. Number with gaps instead, and a shared first place labels the next volume 3, which falls out of the cut entirely.

The data hides a second trap: calls logged without a user_id. Grouping keeps them together as one unattributed caller, and on '/api/v1/users' and '/api/v1/auth/logout' that caller makes more calls than anyone else. Filter the NULLs out and both endpoints report a different leader.

Walk to the answer

Step 1: Count calls per endpoint and user_id

GROUP BY endpoint, user_id with COUNT(*) gives one row per caller per endpoint. COUNT(*) counts rows, so the NULL user_id group gets its real call count; COUNT(user_id) would score it zero. The users table adds nothing because the answer is the ID, not the username, so skip the join.

Step 2: Number the volumes inside each endpoint

DENSE_RANK() OVER (PARTITION BY endpoint ORDER BY COUNT(*) DESC) restarts at 1 on every endpoint, gives equal volumes the same place, and hands the next volume the next number. The window runs after the grouping, which is why COUNT(*) can sit inside its ORDER BY.

Step 3: Cut at place 2, one level up

The window value only exists once the SELECT is evaluated, so the rnk <= 2 filter lives in the outer query. Sort by endpoint, then rnk. Users sharing a place can come in any order, so no extra sort key is owed.

Top two call volumes per endpoint, ties kept
SELECT endpoint, user_id, rnk
FROM (
  SELECT
    endpoint,
    user_id,
    DENSE_RANK() OVER (
      PARTITION BY endpoint
      ORDER BY COUNT(*) DESC
    ) AS rnk
  FROM api_calls
  GROUP BY endpoint, user_id
) ranked
WHERE rnk <= 2
ORDER BY endpoint, rnk;
Two rows per endpoint
ROW_NUMBER() or a per-endpoint LIMIT 2 hands out 1 and 2 once each. On '/api/v1/auth/login' that keeps 197 and only one of 294 or 391, and a rerun can swap which. RANK() fails the other way: a tie at 1 jumps to 3.
Two volumes per endpoint
DENSE_RANK() numbers volume levels, so 294 and 391 both get 2 and both survive rnk <= 2. The row count per endpoint follows the ties in the data, not a fixed quota.
The version that looks right
SELECT endpoint, user_id, rnk
FROM (
  SELECT
    endpoint,
    user_id,
    ROW_NUMBER() OVER (
      PARTITION BY endpoint
      ORDER BY COUNT(*) DESC
    ) AS rnk
  FROM api_calls
  GROUP BY endpoint, user_id
) numbered
WHERE rnk <= 2
ORDER BY endpoint, rnk;

Contrast: exactly two rows per endpoint, so one of 294 and 391 is lost on '/api/v1/auth/login'.

Common follow-up questions