OTP Verification Rate of Every Account — SQL Medium Problem
A payments app sends a one-time password whenever someone signs in from a new device.
- Difficulty: Medium
- Topics: Joins, Aggregation, Conditional Logic
- Dialect: MySQL
- Problem: #14
Problem statement
A payments app sends a one-time password whenever someone signs in from a new device. Each request ends either verified (the code was typed in time) or expired.
For every account in Account, return account_id and verify_rate: the number of its verified requests divided by the number of all its requests, rounded to 2 decimal places. An account that has never requested an OTP has a verify_rate of 0. Return the rows in any order.
Tables
Table: Account
| Column | Type |
|---|---|
| account_id | int |
| signed_up_on | date |
Primary key: account_id.
One row per account.
Table: OtpRequest
| Column | Type |
|---|---|
| account_id | int |
| requested_at | datetime |
| result | enum(verified, expired) |
Primary key: account_id, requested_at.
One row per OTP sent. Every account_id here is in Account.
Examples
Example 1
Account
| account_id | signed_up_on |
|---|---|
| 1 | 2025-01-02 |
| 2 | 2025-01-05 |
| 3 | 2025-01-07 |
| 4 | 2025-01-10 |
OtpRequest
| account_id | requested_at | result |
|---|---|---|
| 1 | 2025-01-02 09:15:00 | verified |
| 1 | 2025-01-03 18:40:12 | expired |
| 1 | 2025-01-04 08:01:55 | verified |
| 3 | 2025-01-08 12:00:00 | expired |
| 3 | 2025-01-08 12:03:30 | expired |
| 4 | 2025-01-11 21:10:05 | verified |
| 4 | 2025-01-12 07:45:00 | verified |
Output
| account_id | verify_rate |
|---|---|
| 1 | 0.67 |
| 2 | 0 |
| 3 | 0 |
| 4 | 1 |
How to solve OTP Verification Rate of Every Account
Every account must appear, including those that never requested a code, so the query starts from Account and LEFT JOINs the requests. Grouping by account then gives one group per account: its requests, or a single row of NULLs if there were none.
The rate is the share of requests that were verified, and a share is the average of a flag: IF(result = 'verified', 1, 0) is 1 for a verified request and 0 otherwise, so its average is verified ÷ total. The flag also settles the empty case neatly — for an account with no requests the one padded row has result NULL, the IF yields 0, and the average is 0, exactly what the statement asks for.
Counting explicitly works too: the verified count divided by COUNT(o.account_id), the number of real requests. For an account with none that is 0 ÷ 0, which SQL answers with NULL rather than an error, so the expression needs IFNULL(…, 0). An aggregate that skips NULLs, such as AVG(o.result = 'verified'), has the same gap: over a group holding only the padded row it averages nothing and returns NULL. A correlated subquery per account computes the same average and needs the same IFNULL.
Round only the final ratio. Both forms read each request once, so the cost is one pass over OtpRequest plus the grouping.
Reference solution (MySQL)
SELECT a.account_id,
ROUND(AVG(IF(o.result = 'verified', 1, 0)), 2) AS verify_rate
FROM Account a
LEFT JOIN OtpRequest o ON o.account_id = a.account_id
GROUP BY a.account_idAnother way
SELECT a.account_id, IFNULL(ROUND(SUM(CASE WHEN o.result = 'verified' THEN 1 ELSE 0 END) / COUNT(o.account_id), 2), 0) AS verify_rate FROM Account a LEFT JOIN OtpRequest o ON o.account_id = a.account_id GROUP BY a.account_idAnother way
SELECT a.account_id, ROUND(IFNULL((SELECT AVG(o.result = 'verified') FROM OtpRequest o WHERE o.account_id = a.account_id), 0), 2) AS verify_rate FROM Account a← Costliest Auction Buy of Each Cricket Team · Mock Test Sittings for Every Learner and Track →