Debugging a health app's SQL before the board meeting
A fictional client's analytics team handed over a broken SQL script and nine questions about their active users. I fixed 16 bugs, checked every answer with a second query, and found the number that changes the story: five users produce 62% of all activity.
01The brief
Health Co's General Manager of Analytics needed figures on the active user base for a board meeting. Their team's SQL script against the health.user_logs table wouldn't run. My job was to debug it, run it, and answer nine questions:
| # | Question |
|---|---|
| 1 | How many unique users exist in the logs dataset? |
| 2 | How many total measurements do we have per user on average? |
| 3 | What is the median number of measurements per user? |
| 4 | How many users have 3 or more measurements? |
| 5 | How many users have 1,000 or more measurements? |
| 6 | How many users have logged blood glucose measurements? |
| 7 | How many users have at least 2 types of measurements? |
| 8 | How many users have all 3 measures: blood glucose, weight and blood pressure? |
| 9 | For users with blood pressure readings, what are the median systolic and diastolic values? |
02The data
One table, one row per measurement a user logs in the app. Three measure types share the table, so the blood pressure columns are only meaningful on blood pressure rows.
| Column | Type | What it holds |
|---|---|---|
id | text | Anonymous user ID (40-character hash) |
log_date | date | Day of the measurement, 2015-01-11 to 2020-11-15 |
measure | text | blood_glucose, weight or blood_pressure |
measure_value | number | The reading for glucose and weight |
systolic, diastolic | number | Blood pressure. 0 or empty on other rows |
03Approach
- DebugRead the script against the real schema, then fix syntax, wrong columns, wrong values and wrong logic, one query at a time.
- RunExecute the fixed script read-only against the database and save the raw output as the record of the result.
- Cross-checkProve each answer with a second, independent query before it goes in front of a board.
Questions 2 to 8 all depend on one per-user summary, so the script builds it once as a temporary table:
CREATE TEMP TABLE user_measure_count AS
SELECT
id,
COUNT(*) AS measure_count,
COUNT(DISTINCT measure) AS unique_measures
FROM health.user_logs
GROUP BY 1;
04The bugs that mattered
Most of the 16 bugs were syntax and typos. Four would have produced wrong numbers, or no numbers, in front of the board.
The median was taken over user IDs
PERCENTILE_CONTINUOUS(0.5)
WITHIN GROUP (ORDER BY id)PERCENTILE_CONT(0.5)
WITHIN GROUP (ORDER BY measure_count)Besides the wrong function name, it ordered by the user ID, which says nothing about activity. The median has to be over the per-user counts.
A filter on a value that doesn't exist
WHERE measure is 'blood_sugar'WHERE measure = 'blood_glucose'IS is for NULL and true/false checks. Even with =, 'blood_sugar' matches no rows, so the board would have seen zero glucose users.
Summing IDs to count users
SELECT SUM(id)SELECT COUNT(*)The question is how many users, and the IDs are text hashes. Counting rows in the one-row-per-user table answers it.
An aggregate inside WHERE
WHERE COUNT(DISTINCT measures) >= 2WHERE unique_measures >= 2WHERE filters rows before anything is counted, so it can't hold an aggregate. The temp table already stores the count, so the fix is to filter on that column.
Full bug log (16 fixes)
| Query | Bug | Fix |
|---|---|---|
| Q1 | COUNT DISTINCT without brackets | COUNT(DISTINCT ...) |
| Q1 | Column user_id doesn't exist | id |
| Temp table | Name typo user_measure_cout | user_measure_count |
| Temp table | Missing AS before SELECT | CREATE TEMP TABLE ... AS SELECT |
| Q2 | MEAN() isn't standard SQL | AVG() |
| Q2 | ROUND() without decimals hides precision | ROUND(..., 2) |
| Q3 | PERCENTILE_CONTINUOUS | PERCENTILE_CONT |
| Q3 | Median ordered by id | ORDER BY measure_count |
| Q4 | HAVING measure >= 3 with no grouping and a wrong column | WHERE measure_count >= 3 |
| Q5 | SUM(id) | COUNT(*) |
| Q6 | COUNT DISTINCT without brackets | COUNT(DISTINCT id) |
| Q6 | is 'blood_sugar' | = 'blood_glucose' |
| Q7 | Aggregate in WHERE | WHERE unique_measures >= 2 |
| Q8 | Table typo usr_measure_count | user_measure_count |
| Q9 | WITHIN (ORDER BY ...) | WITHIN GROUP (ORDER BY ...) |
| Q9 | Missing comma and unquoted is blood_pressure | , ... = 'blood_pressure' |
05Results
| # | Question | Answer | Share of users |
|---|---|---|---|
| 1 | Unique users | 554 | 100% |
| 2 | Mean measurements per user | 79.23 | |
| 3 | Median measurements per user | 2 | |
| 4 | Users with 3+ measurements | 209 | 37.73% |
| 5 | Users with 1,000+ measurements | 5 | 0.90% |
| 6 | Users who logged blood glucose | 325 | 58.66% |
| 7 | Users with 2+ measure types | 204 | 36.82% |
| 8 | Users with all 3 measure types | 50 | 9.03% |
| 9 | Median systolic / diastolic | 126 / 79 |
Where the activity really is
The mean says the average user logs 79 times. The median says the typical user logs twice. Grouping users by how often they log shows why:
Chart data as a table
| Measurements per user | Users | Share of users | Logs | Share of logs |
|---|---|---|---|---|
| 1 | 259 | 46.75% | 259 | 0.59% |
| 2 | 86 | 15.52% | 172 | 0.39% |
| 3–9 | 81 | 14.62% | 381 | 0.87% |
| 10–99 | 73 | 13.18% | 3,103 | 7.07% |
| 100–999 | 50 | 9.03% | 12,597 | 28.70% |
| 1,000+ | 5 | 0.90% | 27,379 | 62.38% |
| Total | 554 | 100% | 43,891 | 100% |
06How I know the numbers are right
A fixed script that runs is not the same as a correct one. Each answer was checked by a different route:
| Answer | Independent check | Result |
|---|---|---|
| Mean 79.23 | 43,891 rows ÷ 554 users = 79.23 | Match |
| Median 2 | 259 users log once and 86 twice, so users 277 and 278 of 554 both sit in the "2" group | Match |
| 209 users with 3+ | 554 − 259 − 86 = 209 | Match |
| 325 glucose users | Distinct users per measure type with GROUP BY measure | Match |
| 204 and 50 by measure types | Users with 1, 2 and 3 types: 350 + 154 + 50 = 554 | Match |
| Median BP 126 / 79 | DuckDB MEDIAN() instead of PERCENTILE_CONT | Match |
A data trap worth flagging
On rows that aren't blood pressure, systolic and diastolic hold a mix of 0 and empty values. Glucose rows alone have 13,111 zeros. Aggregates skip empty values but count zeros, so without the measure = 'blood_pressure' filter the median blood pressure comes out as 0 / 0. One blood pressure row also has a systolic of 0. It doesn't move the median, but it belongs in a data-quality note.
07What I'd tell the board
- Report the median next to the mean. "79 measurements per user" describes almost nobody. The typical user logs twice.
- Engagement is concentrated. 5 users (0.9%) produce 62.4% of all logs, so any average-based KPI mostly tracks those five people.
- Retention is the open question. 46.8% of users logged only once, and only 9% use all three measurement types.
- Fix the data at the source. Use empty values, not zeros, for readings that don't apply, so a missing filter can't quietly produce a median of 0.
08The final script
Show the complete debugged SQL
-- 1. Unique users
SELECT COUNT(DISTINCT id) AS unique_users
FROM health.user_logs;
-- Per-user summary for questions 2-8
DROP TABLE IF EXISTS user_measure_count;
CREATE TEMP TABLE user_measure_count AS
SELECT
id,
COUNT(*) AS measure_count,
COUNT(DISTINCT measure) AS unique_measures
FROM health.user_logs
GROUP BY 1;
-- 2. Mean measurements per user
SELECT ROUND(AVG(measure_count), 2) FROM user_measure_count;
-- 3. Median measurements per user
SELECT PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY measure_count)
FROM user_measure_count;
-- 4. Users with 3 or more measurements
SELECT COUNT(*) FROM user_measure_count WHERE measure_count >= 3;
-- 5. Users with 1,000 or more measurements
SELECT COUNT(*) FROM user_measure_count WHERE measure_count >= 1000;
-- 6. Users who logged blood glucose
SELECT COUNT(DISTINCT id) FROM health.user_logs
WHERE measure = 'blood_glucose';
-- 7. Users with at least 2 measure types
SELECT COUNT(*) FROM user_measure_count WHERE unique_measures >= 2;
-- 8. Users with all 3 measure types
SELECT COUNT(*) FROM user_measure_count WHERE unique_measures = 3;
-- 9. Median systolic / diastolic
SELECT
PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY systolic) AS median_systolic,
PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY diastolic) AS median_diastolic
FROM health.user_logs
WHERE measure = 'blood_pressure';