Sotirios Goulas All projects

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.

Role
Analyst (solo)
Tools
DuckDB 1.4, SQL
Dataset
43,891 log rows
Source
Serious SQL course
554
unique users in the logs
79 vs 2
mean vs median measurements per user
62.4%
of all logs come from 5 users (0.9%)
16
bugs fixed in the original script

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
1How many unique users exist in the logs dataset?
2How many total measurements do we have per user on average?
3What is the median number of measurements per user?
4How many users have 3 or more measurements?
5How many users have 1,000 or more measurements?
6How many users have logged blood glucose measurements?
7How many users have at least 2 types of measurements?
8How many users have all 3 measures: blood glucose, weight and blood pressure?
9For 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.

ColumnTypeWhat it holds
idtextAnonymous user ID (40-character hash)
log_datedateDay of the measurement, 2015-01-11 to 2020-11-15
measuretextblood_glucose, weight or blood_pressure
measure_valuenumberThe reading for glucose and weight
systolic, diastolicnumberBlood pressure. 0 or empty on other rows

03Approach

  1. DebugRead the script against the real schema, then fix syntax, wrong columns, wrong values and wrong logic, one query at a time.
  2. RunExecute the fixed script read-only against the database and save the raw output as the record of the result.
  3. 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

Before
PERCENTILE_CONTINUOUS(0.5)
  WITHIN GROUP (ORDER BY id)
After
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

Before
WHERE measure is 'blood_sugar'
After
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

Before
SELECT SUM(id)
After
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

Before
WHERE COUNT(DISTINCT measures) >= 2
After
WHERE unique_measures >= 2

WHERE 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)
QueryBugFix
Q1COUNT DISTINCT without bracketsCOUNT(DISTINCT ...)
Q1Column user_id doesn't existid
Temp tableName typo user_measure_coutuser_measure_count
Temp tableMissing AS before SELECTCREATE TEMP TABLE ... AS SELECT
Q2MEAN() isn't standard SQLAVG()
Q2ROUND() without decimals hides precisionROUND(..., 2)
Q3PERCENTILE_CONTINUOUSPERCENTILE_CONT
Q3Median ordered by idORDER BY measure_count
Q4HAVING measure >= 3 with no grouping and a wrong columnWHERE measure_count >= 3
Q5SUM(id)COUNT(*)
Q6COUNT DISTINCT without bracketsCOUNT(DISTINCT id)
Q6is 'blood_sugar'= 'blood_glucose'
Q7Aggregate in WHEREWHERE unique_measures >= 2
Q8Table typo usr_measure_countuser_measure_count
Q9WITHIN (ORDER BY ...)WITHIN GROUP (ORDER BY ...)
Q9Missing comma and unquoted is blood_pressure, ... = 'blood_pressure'

05Results

#QuestionAnswerShare of users
1Unique users554100%
2Mean measurements per user79.23
3Median measurements per user2
4Users with 3+ measurements20937.73%
5Users with 1,000+ measurements50.90%
6Users who logged blood glucose32558.66%
7Users with 2+ measure types20436.82%
8Users with all 3 measure types509.03%
9Median systolic / diastolic126 / 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:

Share of users
by number of measurements logged
  • 146.8%
  • 215.5%
  • 3–914.6%
  • 10–9913.2%
  • 100–9999.0%
  • 1,000+0.9%
Share of all logs
from the same user groups
  • 10.6%
  • 20.4%
  • 3–90.9%
  • 10–997.1%
  • 100–99928.7%
  • 1,000+62.4%
Chart data as a table
Measurements per userUsersShare of usersLogsShare of logs
125946.75%2590.59%
28615.52%1720.39%
3–98114.62%3810.87%
10–997313.18%3,1037.07%
100–999509.03%12,59728.70%
1,000+50.90%27,37962.38%
Total554100%43,891100%

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:

AnswerIndependent checkResult
Mean 79.2343,891 rows ÷ 554 users = 79.23Match
Median 2259 users log once and 86 twice, so users 277 and 278 of 554 both sit in the "2" groupMatch
209 users with 3+554 − 259 − 86 = 209Match
325 glucose usersDistinct users per measure type with GROUP BY measureMatch
204 and 50 by measure typesUsers with 1, 2 and 3 types: 350 + 154 + 50 = 554Match
Median BP 126 / 79DuckDB MEDIAN() instead of PERCENTILE_CONTMatch

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

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';