1934. Confirmation Rate
On LeetCode ->Problem¶
Given all signed-up users and their confirmation attempts, return each user's confirmation rate:
- Rate = confirmed attempts / all attempts.
- If a user has no attempts, rate =
0.00. - Round to 2 decimals.
- Output can be in any order.
Input:
# Signups
--------+---------------------
user_id | time_stamp
--------+---------------------
3 | 2020-03-21 10:16:13
7 | 2020-01-04 13:57:59
6 | 2020-12-09 10:39:37
# Confirmations
--------+---------------------+-----------
user_id | time_stamp | action
--------+---------------------+-----------
3 | 2021-01-06 03:30:46 | timeout
7 | 2021-06-12 11:57:29 | confirmed
7 | 2021-06-13 12:58:28 | confirmed
Output:
--------+------------------
user_id | confirmation_rate
--------+------------------
3 | 0.00
7 | 1.00
6 | 0.00
Key trick¶
Use a LEFT JOIN from Signups to Confirmations, then average a boolean-like value:
1forconfirmed0fortimeoutNULLfor no joined confirmation rows
Then replace the final NULL with 0.
Trap¶
Common mistakes:
- Using an
INNER JOIN, which drops users with no confirmations. - Dividing two integer counts without forcing decimal arithmetic in SQL.
- Counting signup rows as confirmation attempts after a
LEFT JOIN. - Forgetting to round to exactly 2 decimals.
- In Pandas, using
countafter merge in a way that counts the artificial missing row.
Why is it interesting?¶
It tests whether you understand aggregation after outer joins, null handling, conditional aggregation, and the clean equivalence between SQL AVG(CASE ...) and Pandas boolean means.
SQL solution¶
SELECT
s.user_id,
ROUND(
COALESCE(
AVG(
CASE
WHEN c.action = 'confirmed' THEN 1.0
WHEN c.action = 'timeout' THEN 0.0
END
),
0.0
),
2
) AS confirmation_rate
FROM Signups AS s
LEFT JOIN Confirmations AS c
ON c.user_id = s.user_id
GROUP BY s.user_id;
Pandas solution¶
import pandas as pd
def confirmation_rate(signups: pd.DataFrame, confirmations: pd.DataFrame) -> pd.DataFrame:
# A confirmed action is 1, timeout is 0; the mean is the confirmation rate.
rates = confirmations.assign(
confirmation_rate=confirmations["action"].eq("confirmed").astype(float)
)
# Aggregate before joining to avoid counting users with no attempts.
rates = (
rates.groupby("user_id", as_index=False)["confirmation_rate"]
.mean()
)
# Keep all signed-up users, fill missing rates with 0, then round.
result = signups[["user_id"]].merge(rates, on="user_id", how="left")
result["confirmation_rate"] = result["confirmation_rate"].fillna(0).round(2)
return result[["user_id", "confirmation_rate"]]
Pytest test¶
import sqlite3
import pandas as pd
import pytest
from pandas.testing import assert_frame_equal
SQL_QUERY = """
SELECT
s.user_id,
ROUND(
COALESCE(
AVG(
CASE
WHEN c.action = 'confirmed' THEN 1.0
WHEN c.action = 'timeout' THEN 0.0
END
),
0.0
),
2
) AS confirmation_rate
FROM Signups AS s
LEFT JOIN Confirmations AS c
ON c.user_id = s.user_id
GROUP BY s.user_id;
"""
def confirmation_rate(signups: pd.DataFrame, confirmations: pd.DataFrame) -> pd.DataFrame:
rates = confirmations.assign(
confirmation_rate=confirmations["action"].eq("confirmed").astype(float)
)
rates = (
rates.groupby("user_id", as_index=False)["confirmation_rate"]
.mean()
)
result = signups[["user_id"]].merge(rates, on="user_id", how="left")
result["confirmation_rate"] = result["confirmation_rate"].fillna(0).round(2)
return result[["user_id", "confirmation_rate"]]
def run_sql(signups_data, confirmations_data):
con = sqlite3.connect(":memory:")
con.execute("CREATE TABLE Signups (user_id INTEGER, time_stamp TEXT)")
con.execute(
"CREATE TABLE Confirmations (user_id INTEGER, time_stamp TEXT, action TEXT)"
)
con.executemany(
"INSERT INTO Signups (user_id, time_stamp) VALUES (?, ?)",
signups_data,
)
con.executemany(
"INSERT INTO Confirmations (user_id, time_stamp, action) VALUES (?, ?, ?)",
confirmations_data,
)
return pd.read_sql_query(SQL_QUERY, con)
def make_pandas_inputs(signups_data, confirmations_data):
signups = pd.DataFrame(signups_data, columns=["user_id", "time_stamp"])
confirmations = pd.DataFrame(
confirmations_data,
columns=["user_id", "time_stamp", "action"],
)
return signups, confirmations
def normalize(df):
return (
df[["user_id", "confirmation_rate"]]
.sort_values("user_id")
.reset_index(drop=True)
.astype({"user_id": "int64", "confirmation_rate": "float64"})
)
@pytest.mark.parametrize(
"signups_data, confirmations_data, expected_data",
[
(
[
(3, "2020-03-21 10:16:13"),
(7, "2020-01-04 13:57:59"),
(2, "2020-07-29 23:09:44"),
(6, "2020-12-09 10:39:37"),
],
[
(3, "2021-01-06 03:30:46", "timeout"),
(3, "2021-07-14 14:00:00", "timeout"),
(7, "2021-06-12 11:57:29", "confirmed"),
(7, "2021-06-13 12:58:28", "confirmed"),
(7, "2021-06-14 13:59:27", "confirmed"),
(2, "2021-01-22 00:00:00", "confirmed"),
(2, "2021-02-28 23:59:59", "timeout"),
],
[
(2, 0.50),
(3, 0.00),
(6, 0.00),
(7, 1.00),
],
),
(
[
(1, "2020-01-01 00:00:00"),
(2, "2020-01-02 00:00:00"),
],
[],
[
(1, 0.00),
(2, 0.00),
],
),
(
[
(1, "2020-01-01 00:00:00"),
(2, "2020-01-02 00:00:00"),
],
[
(1, "2021-01-01 00:00:00", "confirmed"),
(1, "2021-01-02 00:00:00", "confirmed"),
(1, "2021-01-03 00:00:00", "timeout"),
(2, "2021-01-04 00:00:00", "timeout"),
],
[
(1, 0.67),
(2, 0.00),
],
),
],
)
def test_confirmation_rate_sql_and_pandas(
signups_data,
confirmations_data,
expected_data,
):
expected = pd.DataFrame(
expected_data,
columns=["user_id", "confirmation_rate"],
)
sql_result = run_sql(signups_data, confirmations_data)
signups, confirmations = make_pandas_inputs(signups_data, confirmations_data)
pandas_result = confirmation_rate(signups, confirmations)
assert_frame_equal(normalize(sql_result), normalize(expected))
assert_frame_equal(normalize(pandas_result), normalize(expected))
Comment on my solution¶
Your solution is correct and interview-ready.
- The SQL approach is good because it aggregates first, then
LEFT JOINs to keep users with no confirmations. - The Pandas approach mirrors the SQL logic clearly.
- A slightly shorter SQL alternative is to aggregate directly after the
LEFT JOIN, but your CTE version is often more readable. - In Pandas, naming the temporary boolean column
countis a bit misleading because it stores0or1, not a count.
WITH confirmation_rates AS (
SELECT
user_id,
ROUND(1.0 * AVG(
CASE WHEN action = 'confirmed' THEN 1 ELSE 0 END
), 2) AS confirmation_rate
FROM Confirmations
GROUP BY user_id
)
SELECT
s.user_id,
COALESCE(c.confirmation_rate, 0.00) AS confirmation_rate
FROM Signups AS s
LEFT JOIN confirmation_rates AS c
ON c.user_id = s.user_id;
import pandas as pd
def confirmation_rate(signups: pd.DataFrame, confirmations: pd.DataFrame) -> pd.DataFrame:
confirmation_rates = confirmations.copy()
confirmation_rates["count"] = (confirmation_rates["action"].eq("confirmed")).astype(int)
confirmation_rates = (
confirmation_rates.groupby("user_id", as_index=False)
.agg(confirmation_rate=("count","mean"))
)
result = (
signups.merge(
confirmation_rates,
on="user_id",
how="left"
)
)
result["confirmation_rate"] = result["confirmation_rate"].fillna(0).round(2)
return result[["user_id", "confirmation_rate"]]
Extra¶
CASE returns NULL when no ELSE clause¶
In the following SQL snippet, does the CASE return NULL value when
c.actionis neither one of'confirmed'nor'timeout'?
Yes.
If no WHEN condition matches and there is no ELSE clause, the CASE expression returns NULL.