Basic Statistics in SQL: Average, Median, Percentiles and Standard Deviation

Before you build a chart or a dashboard, you need a feel for your numbers. What does a typical order look like? How spread out are the values? Is one strange row pulling everything up?

You don’t need Python or Excel for this. SQL can calculate averages, medians, percentiles and standard deviation directly in the database, on millions of rows, in a single query. This guide shows each one with a small example, explains when to use which, and points out the traps that give wrong answers without any error message.

Dot plot of order amounts: most orders are 15 to 45, one order is 950, so the median is 25 while the average is 112

The sample data

A small shop has 12 orders. One of them is a bulk order from a company, and one hasn’t been priced yet:

CREATE TABLE orders (
    order_id  INT,
    city      VARCHAR(20),
    amount    DECIMAL(10,2),
    items     INT
);

INSERT INTO orders VALUES
(1, 'Manama',   20, 1), (2, 'Manama',   25, 2), (3, 'Manama',  25, 1),
(4, 'Manama',   30, 2), (5, 'Manama',   35, 3), (6, 'Riffa',   15, 1),
(7, 'Riffa',    40, 2), (8, 'Riffa',    45, 2), (9, 'Muharraq', 20, 1),
(10, 'Muharraq', 25, 2), (11, 'Muharraq', 950, 40), (12, 'Riffa', NULL, 1);

Sorted, the 11 known amounts are: 15, 20, 20, 25, 25, 25, 30, 35, 40, 45 and 950.

Count, sum, min, max and average

Start with the basic aggregate functions:

SELECT COUNT(*)                  AS orders,
       COUNT(amount)             AS priced_orders,
       SUM(amount)               AS total,
       AVG(amount)               AS average,
       MIN(amount)               AS smallest,
       MAX(amount)               AS largest,
       MAX(amount) - MIN(amount) AS value_range
FROM orders;
orderspriced_orderstotalaveragesmallestlargestvalue_range
12111230.00111.8215.00950.00935.00

Two things to notice:

  • COUNT(*) counts rows (12), but COUNT(amount) skips the NULL (11).
  • The average is 111.82, but 10 of the 11 orders are under 50. One bulk order of 950 pulls the average far above what a typical customer spends.

If these functions are new to you, start with SQL GROUP BY and Aggregate Functions Explained Simply.

Trap 1: AVG ignores NULLs

AVG(amount) divides by 11, not 12, because the unpriced order is skipped. Usually that’s what you want: an unknown value shouldn’t count as zero.

If a NULL really means zero (for example, a day with no sales), say so explicitly:

SELECT AVG(COALESCE(amount, 0)) AS average_with_zero
FROM orders;

That gives 102.50 (1230 / 12). Neither answer is wrong, but they answer different questions, so decide which one you mean. See NULL in SQL Explained Simply for more NULL behavior.

Trap 2: the average of integers in SQL Server

The items column is an INT. In SQL Server, the average of integers is also an integer, so the decimals are cut off:

-- SQL Server
SELECT AVG(items)           AS avg_int,      -- 4
       AVG(items * 1.0)     AS avg_decimal,  -- 4.833333
       AVG(CAST(items AS DECIMAL(10,2))) AS avg_cast  -- 4.833333
FROM orders;

The real average is 58 / 12 = 4.83, but AVG(items) returns 4, with no warning. Multiply by 1.0 or CAST to a decimal first. MySQL and PostgreSQL return decimals for AVG on integers, so this trap is specific to SQL Server (see SQL Data Types Explained Simply).

The median: the middle value

The median is the value in the middle when you sort the numbers. Half the orders are below it and half are above. Our sorted amounts have 11 values, so the median is the 6th one: 25.

Compare that with the average of 111.82. The median describes the typical order much better, because one extreme value can’t drag it around. That’s why house prices and salaries are usually reported as medians.

Median in SQL Server

SQL Server calculates the median with PERCENTILE_CONT(0.5). It’s a window function, so it needs OVER(), and it returns the value on every row. Add DISTINCT (or TOP (1)) to get one row:

-- SQL Server (also PostgreSQL without OVER, and MariaDB)
SELECT DISTINCT
       PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY amount) OVER () AS median
FROM orders;

Result: 25. NULLs are ignored, just like AVG.

To get the median for each city, put the city in PARTITION BY:

SELECT DISTINCT city,
       PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY amount)
           OVER (PARTITION BY city) AS median_amount
FROM orders;
citymedian_amount
Manama25
Muharraq25
Riffa40

Muharraq’s average is 331.67 because of the bulk order, but its median is 25, the same as Manama. The median tells you customers in both cities spend about the same. PARTITION BY is explained in SQL Window Functions Explained Simply.

Median in MySQL

MySQL 8 has no PERCENTILE_CONT. Instead, number the rows with ROW_NUMBER() and take the middle one (or the average of the two middle ones when the count is even):

-- MySQL 8 (works in SQL Server too)
WITH ranked AS (
    SELECT amount,
           ROW_NUMBER() OVER (ORDER BY amount) AS rn,
           COUNT(*) OVER ()                    AS n
    FROM orders
    WHERE amount IS NOT NULL
)
SELECT AVG(amount) AS median
FROM ranked
WHERE rn IN (FLOOR((n + 1) / 2), FLOOR((n + 2) / 2));

With 11 rows, both formulas give row 6, so the result is 25. With 10 rows, they give rows 5 and 6, and AVG returns the value halfway between them. Filtering out NULLs first matters, otherwise they’d be counted in n.

Percentiles and quartiles

A percentile is the value below which a given percentage of the data falls. The median is the 50th percentile. Two others you’ll see in every report:

  • Quartiles: the 25th percentile (Q1) and 75th percentile (Q3). The middle half of your data sits between them.
  • P90 or P95: “90% of orders are below this value”. Often used for response times and delivery times, where the slow tail matters most.
-- SQL Server
SELECT DISTINCT
       PERCENTILE_CONT(0.25) WITHIN GROUP (ORDER BY amount) OVER () AS q1,
       PERCENTILE_CONT(0.50) WITHIN GROUP (ORDER BY amount) OVER () AS median,
       PERCENTILE_CONT(0.75) WITHIN GROUP (ORDER BY amount) OVER () AS q3,
       PERCENTILE_CONT(0.90) WITHIN GROUP (ORDER BY amount) OVER () AS p90
FROM orders;
q1medianq3p90
22.52537.545

So half of all orders are between 22.50 and 37.50, and 90% are 45 or less.

PERCENTILE_CONT vs PERCENTILE_DISC

  • PERCENTILE_CONT (continuous) can return a value between two real values. Q1 = 22.5 doesn’t exist in the table; it’s halfway between 20 and 25.
  • PERCENTILE_DISC (discrete) always returns a real value from the table, the first one that reaches the percentile.

Use CONT for amounts and times, DISC when the answer must be an actual value (like a real product price or a whole number of items).

Mode: the most common value

The mode is the value that appears most often. SQL has no MODE() function in SQL Server or MySQL, but GROUP BY does the job:

-- SQL Server
SELECT TOP (1) WITH TIES amount, COUNT(*) AS times
FROM orders
WHERE amount IS NOT NULL
GROUP BY amount
ORDER BY COUNT(*) DESC;

-- MySQL: use LIMIT 1 instead of TOP (1)
amounttimes
25.003

WITH TIES returns every value that shares first place, so you’ll see when there’s more than one mode. The mode is most useful for categories (“most popular product”) rather than amounts.

Standard deviation and variance

The average and median tell you the center. Standard deviation tells you how spread out the values are around the average. A small standard deviation means most values are close to the average; a large one means they’re scattered.

-- SQL Server
SELECT AVG(amount)   AS average,
       STDEV(amount) AS std_dev_sample,
       STDEVP(amount) AS std_dev_population,
       VAR(amount)   AS variance_sample
FROM orders;

-- MySQL / MariaDB / PostgreSQL
SELECT AVG(amount)          AS average,
       STDDEV_SAMP(amount)  AS std_dev_sample,
       STDDEV_POP(amount)   AS std_dev_population,
       VAR_SAMP(amount)     AS variance_sample
FROM orders;
averagestd_dev_samplestd_dev_population
111.82278.14265.20

A standard deviation of 278 on orders that are mostly 15-45 is a loud signal that something unusual is in the data. Remove the bulk order and run it again: the average drops to 28.00 and the standard deviation to just 9.49.

Sample or population? Use the sample version (STDEV, STDDEV_SAMP) when your rows are a sample of something bigger, which is the usual case in analysis. Use the population version when your table holds every value there is. Watch out in MySQL: plain STDDEV() and STD() are the population versions, while in SQL Server plain STDEV() is the sample version.

Finding outliers

An outlier is a value far away from the rest. There are two common ways to find them in SQL.

Method 1: z-score

A z-score says how many standard deviations a value is from the average. Values above 3 (or below -3) are usually treated as outliers:

WITH stats AS (
    SELECT AVG(amount) AS avg_amount, STDEV(amount) AS sd   -- STDDEV_SAMP in MySQL
    FROM orders
)
SELECT o.order_id, o.amount,
       ROUND((o.amount - s.avg_amount) / s.sd, 2) AS z_score
FROM orders AS o
CROSS JOIN stats AS s
WHERE o.amount IS NOT NULL
ORDER BY z_score DESC;
order_idamountz_score
11950.003.01
845.00-0.24
740.00-0.26
………

The z-score has a weakness: the outlier itself inflates the average and the standard deviation, which makes it look less extreme. On small tables, an obvious outlier can stay below 3.

Method 2: the IQR rule

The IQR (interquartile range) method uses quartiles, which outliers can’t distort. The IQR is Q3 – Q1. Anything above Q3 + 1.5 × IQR or below Q1 – 1.5 × IQR is an outlier:

-- SQL Server
WITH q AS (
    SELECT DISTINCT
           PERCENTILE_CONT(0.25) WITHIN GROUP (ORDER BY amount) OVER () AS q1,
           PERCENTILE_CONT(0.75) WITHIN GROUP (ORDER BY amount) OVER () AS q3
    FROM orders
)
SELECT o.order_id, o.city, o.amount
FROM orders AS o
CROSS JOIN q
WHERE o.amount > q.q3 + 1.5 * (q.q3 - q.q1)
   OR o.amount < q.q1 - 1.5 * (q.q3 - q.q1);

Here Q1 = 22.5, Q3 = 37.5, so the IQR is 15 and the upper limit is 37.5 + 22.5 = 60. Order 11 (950) is the only outlier.

Finding an outlier doesn’t mean deleting it. The bulk order is real revenue. Report it separately, or show the median next to the average, so readers aren’t misled.

A simple histogram

A histogram groups values into ranges and counts each range. It shows the shape of your data at a glance. Build one with CASE:

SELECT CASE WHEN amount < 20 THEN '0-19'
            WHEN amount < 30 THEN '20-29'
            WHEN amount < 40 THEN '30-39'
            WHEN amount < 50 THEN '40-49'
            ELSE '50+'
       END AS amount_range,
       COUNT(*) AS orders
FROM orders
WHERE amount IS NOT NULL
GROUP BY CASE WHEN amount < 20 THEN '0-19'
              WHEN amount < 30 THEN '20-29'
              WHEN amount < 40 THEN '30-39'
              WHEN amount < 50 THEN '40-49'
              ELSE '50+'
         END
ORDER BY MIN(amount);
amount_rangeorders
0-191
20-295
30-392
40-492
50+1

Most orders fall in the 20-29 range, and one sits alone far to the right. Paste this result into Excel or any chart tool and you have a histogram. For fixed-width buckets, FLOOR(amount / 10) * 10 gives the start of each 10-wide range without writing out every CASE.

Putting it together: a summary per city

A good first look at any dataset is one row per group with all the key numbers:

-- SQL Server
WITH med AS (
    SELECT DISTINCT city,
           PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY amount)
               OVER (PARTITION BY city) AS median_amount
    FROM orders
)
SELECT o.city,
       COUNT(o.amount)          AS orders,
       AVG(o.amount)            AS average,
       MAX(m.median_amount)     AS median,
       MIN(o.amount)            AS smallest,
       MAX(o.amount)            AS largest
FROM orders AS o
JOIN med AS m ON m.city = o.city
GROUP BY o.city
ORDER BY o.city;
cityordersaveragemediansmallestlargest
Manama527.002520.0035.00
Muharraq3331.672520.00950.00
Riffa333.334015.0045.00

When average and median are close (Manama), the data is balanced. When the average is far above the median (Muharraq), a few large values are pulling it up.

Which number should you report?

QuestionUse
What does a typical value look like?Median
What’s the total, or the value per order for budgeting?Average (and sum)
How spread out are the values?Standard deviation or IQR
What’s the worst case for most customers?P90 or P95
What’s the most common choice?Mode
Are there strange values?IQR rule or z-score

When in doubt, show both the average and the median. If they’re far apart, that’s a story worth telling.

Practice exercises

Use the orders table above.

1. Calculate the average number of items per order correctly in SQL Server (with decimals).

2. Find the median amount for orders with 2 or more items.

3. Show each order with the difference between its amount and the overall median.

Answers

1.

SELECT AVG(items * 1.0) AS avg_items
FROM orders;

Result: 4.83. Without * 1.0, SQL Server returns 4.

2.

SELECT DISTINCT
       PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY amount) OVER () AS median
FROM orders
WHERE items >= 2;

The amounts are 25, 25, 30, 35, 40, 45 and 950, so the median is 35.

3.

SELECT order_id, amount,
       amount - PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY amount) OVER () AS diff_from_median
FROM orders
WHERE amount IS NOT NULL
ORDER BY diff_from_median DESC;

Order 11 is 925 above the median; order 6 is 10 below it. Because PERCENTILE_CONT is a window function, you can use it next to normal columns without GROUP BY.

Summary

  • COUNT, SUM, AVG, MIN and MAX give the basic picture. AVG ignores NULLs.
  • In SQL Server, AVG on integers drops the decimals. Multiply by 1.0 first.
  • The median (PERCENTILE_CONT(0.5)) is the better “typical value” when a few large values exist. MySQL needs the ROW_NUMBER method.
  • Quartiles and P90 show where most of your data sits.
  • Standard deviation measures spread. Know whether your function is the sample or population version.
  • Find outliers with the IQR rule or z-scores, and report them instead of deleting them.

This is part 2 of our SQL for Data Analysis series. Part 1 covered data cleaning in SQL, which you should always do before calculating statistics. Next up: funnel analysis, to find where users drop off.

Related articles

Scroll to Top