Describing data honestly
Before any test or model, someone asks "what does a typical order look like?", and the first number you give shapes every decision after it. This lesson teaches you to describe a column of numbers so that the summary matches the data: the right centre, the right spread, the outliers, the shape, and how many values it rests on.
By the end you can
- Choose between mean and median, and explain how skew pulls them apart
- Read and compute percentiles (p50, p90, p99), the IQR and the standard deviation in a spreadsheet, Python and SQL
- Find outliers with IQR fences, and explain why z-scores mislead on skewed data
- Read a histogram, change its bin width and scale, and spot two groups mixed together
- Write a one-paragraph summary that always states n
The everyday picture
Ten people sit in a small café. Their average yearly income is about $30,000. Then a famous footballer walks in and orders a coffee. The average income in the room jumps to over $1 million. Nobody in the room got richer. The middle person, the one with five people poorer and five richer, earns about the same as before.
That is the whole lesson in one scene. One summary number (the average) moved because of one unusual value. Another (the middle value) barely moved. A good description says which number you used, shows the footballer instead of hiding him, and says how many people were in the room.
Mean, median and skew
The mean is the sum divided by the count. Picture each value as a weight on a ruler: the mean is the point where the ruler balances. A weight far out on one side moves the balance point a lot. The median is the middle value after sorting. It only cares about order, so a far-out value moves it at most one place.
Here are ten Mangrove app baskets, in dollars: 7, 9, 10, 12, 13, 15, 17, 20, 23 and 58. The $58 order is someone's big monthly shop. The ten add up to $184, so the mean is $18.40. The median sits between the fifth and sixth values, 13 and 15, so it is $14. Seven of the ten baskets are below the mean. Remove the $58 order and the mean drops to $14.00, while the median only moves from $14 to $13.
When most values are small and a few are very large, the data is right-skewed: it has a long tail to the right. Money data is almost always like this: basket sizes, incomes, order values, time spent on a task. On right-skewed data the mean is above the median, and most values are below the mean. So "the average customer" describes a basket that most customers don't buy.
The same thing happens across all of Mangrove's app orders. On a normal day there are about 60,000. Their mean basket is $18.40 and their median is about $14. About two orders in three are below the mean.
Which one should you report? Ask what the decision needs. If you need a total, such as how much cash riders will collect tomorrow, the mean is right: mean × count = total, and the big orders really do add up. If you need the typical customer, such as what most people put in a basket, the median is right. When in doubt, report both, and treat a big gap between them as a signal about shape.
Quick check
Finance wants to forecast next week's app revenue from 420,000 expected orders. Which basket number should they multiply by?
A total is the mean times the count, and the big orders are real money. The median would undercount revenue by about $4 an order, roughly $1.8 million on 420,000 orders. Averaging the two has no meaning.
Percentiles and spread
A percentile answers "how big is the value that X% of orders stay under?" The median is p50. p90 is the value 90% of orders are below, and p99 the value 99% are below. For Mangrove's baskets, p90 is about $38 and p99 about $52. Percentiles describe the tail, which a mean hides.
Percentiles are how service promises work. When 91% of "Now" orders arrive within 30 minutes, that is the same fact as "the 91st percentile of delivery time is 30 minutes". A median delivery time of about 21 minutes says nothing about the 9% who waited longer. That is why people who run a service watch p90 and p99, the slow end, and not only the middle.
How is a percentile computed when it falls between two values? Most tools sort the values and use the position (n − 1) × p, counting from 0, and draw a straight line between the two neighbours. For the ten baskets, p90 sits at position 9 × 0.9 = 8.1, a tenth of the way from $23 to $58: $26.50. Excel's and Google Sheets' PERCENTILE.INC, numpy's np.percentile and pandas' quantile (with their default settings), and Postgres's percentile_cont all give $26.50. Other methods exist. PERCENTILE.EXC uses position (n + 1) × p and gives $54.50 here, and Postgres's percentile_disc returns a real value from the data, $23. With ten values the method changes the answer a lot; with 60,000 it barely matters. Pick one, and use the same one every week.
Small samples also make the extreme percentiles fragile. A p99 from 50 orders is decided by the two largest orders. With 60,000 orders a day, about 600 orders are above p99, so the number is stable. A rough rule: only report p99 when you have at least a few hundred values, and always state n beside it.
Spread is how far values sit from each other. Three measures are common:
- Range: maximum minus minimum. For the ten baskets, 58 − 7 = $51. It depends only on the two most extreme values, so one odd order decides it.
- IQR: p75 minus p25, the width of the middle half. For the ten baskets, Q1 = $10.50 and Q3 = $19.25, so the IQR is $8.75. For all 60,000 orders it is about $15 (from about $9.60 to $24.60). The extreme values don't touch it.
- Standard deviation (SD): take each value's distance from the mean, square it, add them up, divide by n − 1, and take the square root. For the ten baskets it is $14.77.
Squaring gives big distances a big voice. The $58 basket is 39.6 from the mean; squared, that is 1,568 of the total 1,964, about 80% of the sum. Without it the SD of the other nine is $5.27. One order nearly triples the SD. On skewed data, an SD describes the tail more than the typical spread.
Two versions of the SD exist. Dividing by n − 1 gives the sample SD, the right choice when your data is a sample from something bigger (the reason is part of Samples, uncertainty and confidence intervals, later in this course). Dividing by n gives the population SD. Spreadsheets have STDEV.S and STDEV.P. Watch the Python defaults: pandas' .std() divides by n − 1, while numpy's np.std divides by n unless you pass ddof=1. Postgres has stddev_samp and stddev_pop. With thousands of values the two barely differ; with ten values they differ by about 5% ($14.77 against $14.02).
A quick test for skew: compute mean − 2 × SD. For the ten baskets it is 18.40 − 29.55 = −$11.15, and for all app orders about −$6. No basket is negative. When "two SDs below the mean" is impossible, the data is skewed and the rules you may know for bell-shaped data, such as "95% of values within 2 SDs", do not apply.
Quick check
A report says "p90 delivery time: 29 minutes (n = 40)". A colleague recomputes it from the same 40 orders and gets 31 minutes. Neither made a typing error. What is the most likely reason?
With 40 values, p90 falls between the 36th and 37th sorted values, and methods such as PERCENTILE.INC and PERCENTILE.EXC put it at different points between them. With 60,000 values they would agree almost exactly. A mean would usually be much further off than 2 minutes.
Finding outliers: IQR fences and z-scores
An outlier is a value far from the rest. Two methods are common for finding them, and on skewed data one of them fails.
IQR fences, from John Tukey's box plot, set the fences at Q1 − 1.5 × IQR and Q3 + 1.5 × IQR. Anything outside is flagged. For the ten baskets: 10.50 − 13.13 = −$2.63 and 19.25 + 13.13 = $32.38. The $58 order is outside the upper fence and gets flagged. The fences are built from quartiles, so the outlier can't move them. Tukey also used 3 × IQR for values he called "far out".
A z-score is (value − mean) ÷ SD, and a common rule flags |z| > 3. For the $58 order: (58 − 18.40) ÷ 14.77 = 2.68. Not flagged. The outlier inflated both the mean and the SD it is measured against, so it hid itself. This is called masking. On skewed data the z-rule is also lopsided: the lower fence (mean − 3 SD) is far below zero, so it can only ever flag large values.
Fences have their own trap at scale. On all 60,000 app orders the upper fence is about $47, and about 2.5% of orders, roughly 1,500 a day, are above it. Nearly all of them are ordinary big scheduled shops. On right-skewed data, run the fences on the logarithm of the values (on a log scale the baskets look roughly symmetric); there they flag almost nothing, which is the truth.
A flag is a question, never a verdict. For each flagged value, find out which kind it is:
- An error: a test order, a duplicate, a price typed in dollars where the system expects cents. Fix it at the source if you can, remove it if you can't, and write down what you removed and why.
- Real but rare: a customer buying for a wedding. Keep it. If it distorts a summary, report a median or the result with and without it. Deleting real customers to make a chart prettier is how analyses start to lie.
Quick check
In a sample of 10 values, can any value have a z-score above 3 (using the sample SD)?
The most extreme value also pulls the mean and SD towards itself, so |z| has a ceiling of (n − 1) ÷ √n, which is 9 ÷ 3.16 ≈ 2.85 for n = 10. The ten baskets' $58 order, at 2.68, is close to that ceiling. Small samples need fences, not z-scores.
Shape: bins, log scales and two groups in one column
A histogram splits the values into equal ranges, called bins, and draws how many fall in each. The bin width changes what you see. Too wide and real features disappear; too narrow and every bar is noise. Always try at least two widths. In most tools you can set the bin width directly (in Python, plt.hist(x, bins=...) takes a number of bins or a list of edges).
With $20 bins the app baskets look like one smooth slope: 69% of orders under $20, 23% from $20 to $40, 8% from $40 to $60. With $5 bins (the first diagram) the bars fall, then rise again around $35 to $40. A dip followed by a second rise is a sign of bimodal data: two groups mixed in one column.
Here the two groups are the two kinds of app order. About four orders in five are "Now" orders, 30-minute top-ups: a few items, median about $12, mean about $13.30. One in five is a "Later" order, a scheduled big shop: median about $38, mean about $38.80. Mixed together they give the overall mean of $18.40 (0.8 × 13.30 + 0.2 × 38.80), a number that describes neither group well. Most of the long tail in the first diagram is Later orders.
A log scale helps with right-skewed data. On a log axis, $5, $10, $20 and $40 sit at equal distances, because each is double the one before. The long tail gets squeezed and the bulk gets room, so you can see the shape of both. Basket sizes, incomes and response times often look roughly symmetric on a log scale. In a spreadsheet you can add a column with =LN(A2) and chart that; in matplotlib, ax.set_xscale("log") with bins spaced by np.geomspace.
The fix for mixed groups is to split before you summarise. Describe Now and Later orders separately, and only quote an overall number when the decision really is about all orders together. The same goes for any column that mixes stores and the app, weekdays and festival days, or new and returning customers.
Finally, always report n, the number of values a summary rests on. A ranking of 320 stores by average app basket once put a new store at the top with $31. It had 11 orders, two of them big monthly shops. An average of 11 orders and an average of 2,000 orders are different kinds of evidence, and the reader can only tell them apart if you write the n. How much an average from a small n can move is the subject of the next lesson.
Do it
Describe one numeric column of your own data the same way three times: spreadsheet, Python and SQL.
Count first
Write down n, how many values are missing, and the minimum and maximum. A negative basket or a $9,999 order is found here, before any average.
Compute the summary
Mean, median, Q1, Q3, p90, p99, SD and the IQR fences. Pick a tab.
OutputBaskets in dollars in A2:A60001 (Excel and Google Sheets) n =COUNT(A2:A60001) mean =AVERAGE(A2:A60001) median =MEDIAN(A2:A60001) Q1 =QUARTILE.INC(A2:A60001, 1) (put it in H2) Q3 =QUARTILE.INC(A2:A60001, 3) (put it in H3) p90 =PERCENTILE.INC(A2:A60001, 0.9) p99 =PERCENTILE.INC(A2:A60001, 0.99) SD =STDEV.S(A2:A60001) upper fence =H3 + 1.5 * (H3 - H2) (put it in H4) flagged =COUNTIF(A2:A60001, ">" & H4)Pythonimport numpy as np import pandas as pd orders = pd.read_csv("app_orders.csv") # one row per order basket = orders["total_cents"] / 100 # cents to dollars print(len(basket), basket.isna().sum()) # n and missing values print(basket.describe(percentiles=[0.25, 0.5, 0.75, 0.9, 0.99])) print(basket.std()) # divides by n - 1, like STDEV.S print(np.std(basket.to_numpy())) # divides by n, like STDEV.P q1, q3 = basket.quantile([0.25, 0.75]) # same method as PERCENTILE.INC upper = q3 + 1.5 * (q3 - q1) print((basket > upper).sum(), "orders above the upper fence") # the same summary for each channel, one row each print(orders.groupby("channel")["total_cents"].describe())SQLSELECT channel, count(*) AS n, round(avg(total_cents) / 100.0, 2) AS mean_dollars, percentile_cont(ARRAY[0.25, 0.5, 0.75, 0.9, 0.99]) WITHIN GROUP (ORDER BY total_cents) AS q1_med_q3_p90_p99_cents, round(stddev_samp(total_cents) / 100.0, 2) AS sd_dollars FROM "order" -- quoted: ORDER is an SQL keyword GROUP BY channel;Draw it twice
Make a histogram with two bin widths, and one on a log scale. Look for a long tail, a second bump, and values piled up at a round number or at zero.
Pythonimport matplotlib.pyplot as plt fig, (left, right) = plt.subplots(1, 2, figsize=(10, 4)) left.hist(basket, bins=np.arange(0, 105, 5)) # $5 bins from $0 to $100 right.hist(basket, bins=np.geomspace(1, 200, 40)) # bins evenly spaced on a log axis right.set_xscale("log") plt.show()SQL-- $5 bins in SQL: bucket 1 is $0 to $4.99, bucket 21 is $100 and over SELECT width_bucket(total_cents, 0, 10000, 20) AS bucket, count(*) AS orders FROM "order" GROUP BY bucket ORDER BY bucket;Split, check, write
If the shape has two bumps, find the column that separates them and repeat step 2 per group. Check every flagged value. Then write the summary.
Markdown## App baskets, Monday 19 October 2026 - n = 60,000 orders (3 staff test orders removed, listed in the notes) - Typical basket: median $14 (Now $12, Later $38). Mean $18.40, pulled up by Later orders. - Spread: middle half from $9.60 to $24.60 (IQR $15); 9 in 10 orders under $38. - Shape: right-skewed, two groups (Now 80%, Later 20%). Summaries are split by channel. - Outliers: almost none once the test orders are removed and the fences run on the log scale.
Bring it to your team
Ask these whenever a single summary number is on a slide.
- Is that a mean or a median, and how far apart are the two?
- What is n? How many values did we remove, and why?
- What happens at the slow or expensive end: what are p90 and p99?
- Are two groups mixed in this number, such as Now and Later orders, or stores and the app?
- Have we looked at a histogram, at more than one bin width?
Red flags
- "The average customer" used for a skewed money column, with no median beside it.
- A mean ± SD on data that can't be negative, where mean − 2 SD is below zero.
- Outliers removed with a z-score rule and no list of what was removed.
- A ranking of stores or riders by average with no n, where the top entries have a handful of values.
- A percentile quoted with no note of the method, compared with one from another tool.
Practice
Summarise baskets per channel in SQL
The table "order"(id, channel, total_cents) holds 18 app orders. channel is now or later. Return one row per channel with the columns channel, orders (n), mean_cents, median_cents and p90_cents. Use percentile_cont for the median and p90, and round each money value to whole cents with round(x::numeric). Then compare the mean and median of Now orders: what pulled them apart?
CREATE TABLE "order" (id int PRIMARY KEY, channel text NOT NULL, total_cents int NOT NULL); INSERT INTO "order" VALUES (1, 'now', 640), (2, 'now', 820), (3, 'now', 900), (4, 'now', 1050), (5, 'now', 1120), (6, 'now', 1180), (7, 'now', 1290), (8, 'now', 1400), (9, 'now', 1550), (10, 'now', 1760), (11, 'now', 2380), (12, 'now', 9850), (13, 'later', 2900), (14, 'later', 3350), (15, 'later', 3720), (16, 'later', 3980), (17, 'later', 4410), (18, 'later', 5600);
-- Basket summary per channel ('now' and 'later')
-- The starter gives one average for all orders. Change it so that it:
-- 1. returns one row per channel (add channel and GROUP BY channel)
-- 2. counts the orders as orders
-- 3. adds median_cents and p90_cents:
-- percentile_cont(0.5) WITHIN GROUP (ORDER BY total_cents)
-- Round every money value with round(x::numeric)
SELECT round(avg(total_cents)::numeric) AS mean_cents
FROM "order";
[{"channel": "now", "orders": 12, "mean_cents": 1995, "median_cents": 1235, "p90_cents": 2318}, {"channel": "later", "orders": 6, "mean_cents": 3993, "median_cents": 3850, "p90_cents": 5005}]
Show a solution
SELECT channel,
count(*) AS orders,
round(avg(total_cents)::numeric) AS mean_cents,
round((percentile_cont(0.5) WITHIN GROUP (ORDER BY total_cents))::numeric) AS median_cents,
round((percentile_cont(0.9) WITHIN GROUP (ORDER BY total_cents))::numeric) AS p90_cents
FROM "order"
GROUP BY channel;Now orders have a mean of $19.95 and a median of $12.35. One $98.50 Now order pulls the mean up by more than $7; without it the other eleven average $12.81. The median doesn't notice it. Later orders are more even: mean $39.93, median $38.50. percentile_cont interpolates like PERCENTILE.INC: Now's p90 sits at position 11 × 0.9 = 9.9, nine tenths of the way from $17.60 to $23.80, which is $23.18.
Write a percentile function and IQR fences in JavaScript
Write percentile(values, p) that works like PERCENTILE.INC: sort a copy of the values, find position h = (n − 1) × p, and interpolate between the values at Math.floor(h) and the next one. p is a fraction from 0 to 1. Then write iqrFences(values) that returns { low, high }: Q1 − 1.5 × IQR and Q3 + 1.5 × IQR. The tests use the ten baskets from this lesson.
function percentile(values, p) {
// 1. copy and sort ascending: [...values].sort((a, b) => a - b)
// 2. h = (n - 1) * p, lo = Math.floor(h)
// 3. return sorted[lo] + (h - lo) * (sorted[lo + 1] - sorted[lo])
// (careful when lo is the last index)
return values[Math.floor(values.length * p)];
}
function iqrFences(values) {
// Q1 = percentile(values, 0.25), Q3 = percentile(values, 0.75), IQR = Q3 - Q1
return { low: 0, high: 0 };
}
const baskets = [7, 9, 10, 12, 13, 15, 17, 20, 23, 58];
test('median of the ten baskets is 14', () => {
expect(percentile(baskets, 0.5)).toBeCloseTo(14);
});
test('p90 interpolates between 23 and 58', () => {
expect(percentile(baskets, 0.9)).toBeCloseTo(26.5);
});
test('works on unsorted input and does not change it', () => {
const mixed = [58, 7, 20, 13, 9, 23, 10, 17, 12, 15];
expect(percentile(mixed, 0.25)).toBeCloseTo(10.5);
expect(mixed[0]).toBe(58);
});
test('p = 0 is the minimum and p = 1 is the maximum', () => {
expect(percentile(baskets, 0)).toBe(7);
expect(percentile(baskets, 1)).toBe(58);
});
test('fences flag the $58 basket', () => {
const f = iqrFences(baskets);
expect(f.low).toBeCloseTo(-2.625);
expect(f.high).toBeCloseTo(32.375);
});
Show a solution
function percentile(values, p) {
const sorted = [...values].sort((a, b) => a - b); // copy, so the input is unchanged
const h = (sorted.length - 1) * p;
const lo = Math.floor(h);
if (lo + 1 >= sorted.length) return sorted[lo]; // p = 1: the maximum
return sorted[lo] + (h - lo) * (sorted[lo + 1] - sorted[lo]);
}
function iqrFences(values) {
const q1 = percentile(values, 0.25);
const q3 = percentile(values, 0.75);
const iqr = q3 - q1;
return { low: q1 - 1.5 * iqr, high: q3 + 1.5 * iqr };
}Q1 is $10.50, Q3 is $19.25 and the IQR is $8.75, so the fences are −$2.63 and $32.38: the $58 basket is outside. Note the copy: sort changes an array in place, and a summary function should never reorder the caller's data.
Check yourself
Rider waiting time at stores has a median of 4 minutes and a mean of 9 minutes. The operations lead wants one number for "how long a typical rider waits". What do you report?
A mean more than double the median means a long right tail; most riders wait less than 9 minutes, so the mean misdescribes the typical wait. The median answers "typical", and p90 shows the tail that the mean was hinting at. Subtracting an SD has no meaning here, and the range depends on two extreme waits.
You flag outliers in 50,000 order values with "|z| > 3" and find almost none, though the histogram has a long right tail with some orders 20 times the median. Why?
This is masking: the tail inflates the mean and SD that each value is measured against. Lowering the cut-off to 2 would flag large but ordinary orders without fixing the cause. Quartile-based fences aren't moved by the tail, and on a log scale they match the shape of skewed money data.
A histogram of app delivery times, with 10-minute bins, shows one wide hump. With 2-minute bins it shows two peaks, around 22 minutes and around 95 minutes. What should you do next?
Two peaks usually mean two kinds of thing in one column. Here a scheduled Later order can be delivered an hour or more after it is placed, by design. A mean between the peaks describes neither group, wider bins only hide the structure, and the second peak is a real group, not a set of errors.
Flashcards
Key takeaways
- Choose the centre by the decision: the mean for totals, the median for the typical case. On skewed data they differ, and the gap tells you about shape.
- Describe spread with percentiles and the IQR; the SD is dominated by the tail on skewed data, and n − 1 versus n matters in your tool's defaults.
- Flag outliers with IQR fences (on a log scale for money data), never with z-scores alone, and check each flag before removing anything.
- Look at histograms at two bin widths and on a log scale; two peaks mean two groups to split before summarising.
- Always write n, and what you removed. The $18.40 "average basket" is honest only beside its $14 median, its two channels and its 60,000 orders.
Sources and further reading
- Box plot, NIST/SEMATECH e-Handbook of Statistical Methods
- Histogram, NIST/SEMATECH e-Handbook of Statistical Methods
- Aggregate functions (percentile_cont, percentile_disc, stddev_samp), PostgreSQL documentation
- numpy.percentile (the interpolation methods), NumPy documentation
- pandas.Series.describe, pandas documentation
- John W. Tukey, Exploratory Data Analysis, Addison-Wesley, 1977