Value Histograms: Counting Rows per Range Bucket in T-SQL

Value histograms turn a long column of numbers into a short list of ranges and how many rows fall in each. One GROUP BY gets you started. Three small traps decide whether the answer is true.

Pistachios fill six cavities of a wooden mold while two middle cavities remain empty.

Twenty orders and one expression

Say a manager asks, “How are our order amounts spread out?” A list of twenty amounts does not answer that. Buckets of 100 do. The setup below creates twenty orders. Run it first.

DROP TABLE IF EXISTS #Sales;
CREATE TABLE #Sales (SaleId int PRIMARY KEY, Amount decimal(10,2) NOT NULL);
INSERT #Sales VALUES
    (1, 12.50), (2, 45.00), (3, 99.99), (4, 100.00), (5, 135.25),
    (6, 180.00), (7, 199.99), (8, 200.00), (9, 215.40), (10, 240.00),
    (11, 275.10), (12, 505.75), (13, 520.00), (14, 560.20), (15, 610.00),
    (16, 95.00), (17, 150.00), (18, 130.00), (19, 750.00), (20, 20.00);

The bucket start is Amount divided by the bucket width, rounded down, then multiplied back. That one expression is the heart of every histogram you will write.

SELECT CAST(FLOOR(Amount / 100) AS int) * 100 AS BucketStart,
       COUNT(*) AS Orders
FROM #Sales
GROUP BY CAST(FLOOR(Amount / 100) AS int) * 100
ORDER BY BucketStart;

Six rows come back: 0, 100, 200, 500, 600 and 700. Look closely. There is no 300 and no 400. That is trap number one.

Empty buckets vanish unless you ask for them

GROUP BY can only report groups that have rows. A bucket with zero orders has no rows, so it silently disappears. On a chart, the gap between 200 and 500 would look like the bars simply touch each other. Readers draw the wrong conclusion.

The fix is to list every bucket yourself and LEFT JOIN the data to it. Here I also show the mistake that goes with it. COUNT(*) counts the join row itself, so an empty bucket reports 1 instead of 0.

SELECT b.BucketStart,
       COUNT(*) AS WrongCount,
       COUNT(s.SaleId) AS Orders
FROM (VALUES (0), (100), (200), (300), (400), (500), (600), (700)) AS b(BucketStart)
LEFT JOIN #Sales AS s
    ON s.Amount >= b.BucketStart
   AND s.Amount < b.BucketStart + 100
GROUP BY b.BucketStart
ORDER BY b.BucketStart;
Eight histogram buckets show WrongCount 1 and Orders 0 for buckets 300 and 400.
Notice that the WrongCount column still counts 1 in the 300 and 400 buckets while the real Orders count there is 0.

Now eight rows appear, with 300 and 400 showing 0 under Orders. WrongCount shows 1 for both. Counting a column from the right-hand table, one that is never NULL when a match exists, is the habit that saves you.

Check these before you trust the counts

When the buckets are not the same width

Real reports rarely use equal widths. Small, medium, large and huge orders are more useful to a manager than eight even slices. For that, put the ranges in a small table. Each range has a lower bound that is included and an upper bound that is not.

DROP TABLE IF EXISTS #Ranges;
CREATE TABLE #Ranges (RangeNo int PRIMARY KEY, Label varchar(20) NOT NULL,
                      LowerBound decimal(10,2) NOT NULL, UpperBound decimal(10,2) NOT NULL);
INSERT #Ranges VALUES
    (1, 'Under 50', 0, 50), (2, '50 to 199', 50, 200),
    (3, '200 to 499', 200, 500), (4, '500 and up', 500, 1000000);

SELECT r.Label, COUNT(s.SaleId) AS Orders
FROM #Ranges AS r
LEFT JOIN #Sales AS s
    ON s.Amount >= r.LowerBound
   AND s.Amount < r.UpperBound
GROUP BY r.RangeNo, r.Label
ORDER BY r.RangeNo;

The counts are 3, 8, 4 and 5, adding up to the twenty orders. A range table also lets a business user change the cut points without touching the query.

The BETWEEN trap

Here is trap number three, and I see it in code reviews every month. BETWEEN includes both ends. Two neighboring buckets written with BETWEEN both claim the value that sits on the border. Order 4 is exactly 100.00, and order 8 is exactly 200.00.

SELECT
    SUM(CASE WHEN Amount BETWEEN 0 AND 100 THEN 1 ELSE 0 END)
  + SUM(CASE WHEN Amount BETWEEN 100 AND 200 THEN 1 ELSE 0 END) AS BetweenTotal,
    SUM(CASE WHEN Amount >= 0 AND Amount < 100 THEN 1 ELSE 0 END)
  + SUM(CASE WHEN Amount >= 100 AND Amount < 200 THEN 1 ELSE 0 END) AS HalfOpenTotal
FROM #Sales;

The real number of orders below 200 is 11. The half-open version says 11. The BETWEEN version says 13. The 100.00 order was counted twice, and the 200.00 order was pulled into a bucket where it does not belong. Write every bucket as “greater than or equal to the start, less than the next start” and the borders behave.

Draw it right in the result grid

You do not need a charting tool to see the shape. REPLICATE can draw a bar from the count. It works well for the equal-width buckets only. With uneven ranges, a wide bucket holds more rows just by being wide, so compare those by count per unit of width.

SELECT b.BucketStart,
       COUNT(s.SaleId) AS Orders,
       REPLICATE('#', COUNT(s.SaleId)) AS Bar
FROM (VALUES (0), (100), (200), (300), (400), (500), (600), (700)) AS b(BucketStart)
LEFT JOIN #Sales AS s
    ON s.Amount >= b.BucketStart
   AND s.Amount < b.BucketStart + 100
GROUP BY b.BucketStart
ORDER BY b.BucketStart;

DROP TABLE IF EXISTS #Ranges;
DROP TABLE IF EXISTS #Sales;
Histogram bars use hash characters; buckets 300 and 400 have blank bars.
Notice that each bar is just a row of # characters and the 300 and 400 buckets show an empty bar for their zero orders.

The bars make the two empty buckets obvious, and the lonely order at 750 stands out. Next time someone asks how the numbers are spread, you can show the shape before they finish the sentence.

A histogram is not a count, it is a shape.

Published by Pinal Dave on SQLAuthority. More of my work at pinaldave.com.


Discover more from SQL Authority with Pinal Dave

Subscribe to get the latest posts sent to your email.

SQL Group By, SQL Scripts, Temp Table
Previous Post
Checking IDENTITY Columns Before They Run Out of Numbers
Next Post
When SSMS and Your Application Return Different Results

Related Posts

Leave a Reply

Your email address will not be published. Required fields are marked *

Fill out this field
Fill out this field
Please enter a valid email address.