Medians, percentiles and buckets
When a few big values skew the average, use the median, percentiles and NTILE buckets for a more honest picture.
In this lesson 8 sections
The average deal is worth $40,929. But only 24 of the 70 deals are worth that much or more. A handful of $120,000 and $150,000 deals pull the average up, so it describes almost nobody. The median, the middle value, is $25,000, which is much closer to a typical deal.
Mean vs. median
| Measure | What it is | Sample data |
|---|---|---|
Mean (AVG) |
Total divided by count | $40,929 |
| Median | The middle value once sorted. Half are above, half below. | $25,000 |
| Mode | The most common value | $25,000 |
When the mean is well above the median, a few large values are skewing it. Money, durations and counts of things are almost always skewed like this. Report the median, or both side by side.
Median in the sandbox
The sandbox’s SQLite build includes median(), used like any aggregate:
SELECT ROUND(AVG(amount)) AS mean_amount,
median(amount) AS median_amount
FROM opportunities;
It also has lower_quartile() and upper_quartile() for the 25th and 75th percentiles: $12,000 and $60,000 here. Half of all deals fall between those two values.
These are not standard SQL, and most databases spell them differently (see the dialect notes). The ideas carry over, the names don’t.
Median without a median function
On a database with no median function, number the sorted rows and take the middle one, or the average of the two middle ones when there’s an even count:
WITH ordered AS (
SELECT amount,
ROW_NUMBER() OVER (ORDER BY amount) AS rn,
COUNT(*) OVER () AS n
FROM opportunities
)
SELECT AVG(amount) AS median_amount
FROM ordered
WHERE rn IN ((n + 1) / 2, (n + 2) / 2);
With 70 deals, that keeps rows 35 and 36 and averages them: $25,000. With an odd count, both expressions point at the same middle row. The trick depends on whole-number division (lesson 10), which is one of the few times it helps.
Why this matters: skewed groups
Here’s the mean and median days to close for each lead source:
| source | closed_deals | mean_days | median_days |
|---|---|---|---|
| Inbound | 4 | 71.8 | 73 |
| Referral | 7 | 67 | 64 |
| Partner | 9 | 57.4 | 64 |
| Unknown | 7 | 73.7 | 62 |
| Outbound | 8 | 63.1 | 56 |
| Webinar | 12 | 63.7 | 53 |
By the mean, Unknown looks like the slowest source. By the median, it’s mid-pack: a few very slow deals pull its average up. A report built on the mean would send someone chasing the wrong problem. The count matters too: Inbound’s 4 deals are too few to say much.
Buckets with NTILE
NTILE(4) splits sorted rows into four groups of nearly equal size, numbered 1 to 4:
WITH bucketed AS (
SELECT amount,
NTILE(4) OVER (ORDER BY amount) AS quartile
FROM opportunities
)
SELECT quartile,
COUNT(*) AS deals,
MIN(amount) AS smallest,
MAX(amount) AS largest
FROM bucketed
GROUP BY quartile;
| quartile | deals | smallest | largest |
|---|---|---|---|
| 1 | 18 | 1000 | 12000 |
| 2 | 18 | 15000 | 25000 |
| 3 | 17 | 25000 | 60000 |
| 4 | 17 | 60000 | 150000 |
Use it to label deals small to large, or customers by spend, without picking cutoffs by hand. Note that $25,000 shows up in two buckets: NTILE keeps the groups even, even if that splits tied values.
Common mistakes
| Mistake | Fix |
|---|---|
| Reporting only the average for money or time | Show the median beside it |
| Comparing medians from tiny groups | Show the count, so readers know which numbers to trust |
Assuming median() exists everywhere |
Check the dialect, or use the ROW_NUMBER method |
Expecting NTILE to respect ties |
It splits rows evenly, ties or not |
Dialect notes
| Database | Median | Any percentile |
|---|---|---|
| PostgreSQL | PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY x) |
PERCENTILE_CONT(0.9) WITHIN GROUP (ORDER BY x) |
| SQL Server | PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY x) OVER (PARTITION BY ...) |
Same, but only as a window function, so wrap it in DISTINCT or a CTE |
| MySQL | None built in | Use the ROW_NUMBER method above |
| SQLite (the sandbox) | median(x) |
lower_quartile(x), upper_quartile(x) only |
PERCENTILE_CONT interpolates between values, so the median of 10 and 20 is 15. PERCENTILE_DISC returns an actual value from the data.
Snowflake notes
Snowflake has MEDIAN(x), MODE(x), and PERCENTILE_CONT(0.9) WITHIN GROUP (ORDER BY x) as a regular aggregate, so it works with GROUP BY. APPROX_PERCENTILE(x, 0.9) is much faster on very large tables and usually within a fraction of a percent.
More in the field guide: Median, percentiles and mode and Percentiles & NTILE. Exercises A5 and M8 are the Snowflake versions.
Try it
For each lead source, show the number of closed deals and the mean and median whole days to close, both rounded to 1 decimal place, slowest median first. Deals with no lead source go under Unknown. Your result should match the table above.
Write it in the sandbox below, then press Check answer.
SQL sandbox
Run queries against the sample CRM tables (accounts, opportunities,users, contacts and opportunity_history), right in your browser. Nothing you run leaves your computer.