A histogram is just a GROUP BY on a bucketed version of the value. The portable form works in every dialect — divide by the bucket width, FLOOR it:
-- portable everywhere: fixed-width buckets of 10, starting at 0
SELECT
FLOOR(amount / 10) * 10 AS bucket_start,
COUNT(*) AS n
FROM orders
GROUP BY FLOOR(amount / 10)
ORDER BY bucket_start;
PostgreSQL, DuckDB and Snowflake have a dedicated WIDTH_BUCKET function that also handles the bucket count directly:
-- WIDTH_BUCKET(value, min, max, bucket_count) — 10 equal-width buckets between 0 and 100
SELECT
WIDTH_BUCKET(amount, 0, 100, 10) AS bucket,
COUNT(*) AS n
FROM orders
GROUP BY bucket
ORDER BY bucket;
Dialect notes:
- MySQL and SQLite have no
WIDTH_BUCKET. Use theFLOOR()form above. - BigQuery has no
WIDTH_BUCKETeither. SameFLOOR()form, or aCASEexpression for custom-width buckets. WIDTH_BUCKETdoesn’t error on out-of-range values — it puts them in an overflow bucket (0, orbucket_count + 1), so a value belowminor abovemaxdoesn’t get dropped, it just lands outside your labeled range. Check for those overflow bucket numbers if yourmin/maxmight not cover the real data.
For uneven bucket widths, a portable CASE beats both of the above:
SELECT
CASE
WHEN amount < 25 THEN '0-24'
WHEN amount < 50 THEN '25-49'
WHEN amount < 100 THEN '50-99'
ELSE '100+'
END AS bucket,
COUNT(*) AS n
FROM orders
GROUP BY 1
ORDER BY MIN(amount);
Sort text buckets by
MIN(amount), not the label.'100+'sorts before'25-49'alphabetically — ordering by the bucket’s actual minimum value keeps the histogram in numeric order regardless of how the labels read.GROUP BY 1(the ordinal position) works in all six dialects, including BigQuery.