Basic statistics are the first analysis step for quickly understanding the overall scale, center, and range of a dataset. Combining SQL aggregate functions lets you retrieve them in a single query.
COUNT(*) -- All rows, including NULLs (the table's record count) COUNT(col) -- Rows excluding NULLs (note the difference from *) SUM(col) -- Total (NULLs are skipped automatically) AVG(col) -- Average (divided by the number of non-NULL rows) MIN(col) / MAX(col) -- Minimum / maximum
ROUND(AVG(amount), 0) is a practical standard for rounding to an integer. ROUND rounds to the nearest value, while TRUNC truncates, so their behavior differs. Always specify ROUND for reports to make the intended number of digits explicit.From the orders table, calculate the basic statistics for all orders in one row. Return total_orders, total_revenue, avg_amount, min_amount, max_amount (round avg_amount to an integer).
| order_id | user_id | amount | order_date |
|---|---|---|---|
| 1 | 1 | 1200 | 2024-01-03 |
| 2 | 2 | 3500 | 2024-01-05 |
| 3 | 1 | 800 | 2024-01-08 |
| 4 | 3 | 5200 | 2024-01-10 |
| 5 | 2 | 2400 | 2024-01-12 |
| 6 | 4 | 1800 | 2024-01-15 |
Expected output (one row):
| total_orders | total_revenue | avg_amount | min_amount | max_amount |
|---|---|---|---|---|
| 6 | 14900 | 2483 | 800 | 5200 |
SUM: 1200+3500+800+5200+2400+1800=14900 / AVG: 14900÷6=2483.33→ROUND→2483 / MIN: 800 / MAX: 5200
SELECT COUNT(*) AS total_orders, -- Count every row regardless of NULLs SUM(amount) AS total_revenue, -- Skip NULLs automatically and calculate the total ROUND(AVG(amount), 0) AS avg_amount, -- Round the average to an integer MIN(amount) AS min_amount, -- Minimum order amount MAX(amount) AS max_amount -- Maximum order amount FROM orders; /* Execution order (logical SQL evaluation order): 1. FROM orders → Read 6 rows 2. COUNT/SUM/AVG/MIN/MAX → Evaluate the aggregate functions 3. ROUND(AVG(amount), 0) → Round to zero decimal places 4. SELECT → Output the aggregate result in one row */
LEGEND
① FROM orders — Read all 6 order rows
FROM ordersRead all 6 rows from the orders table. In an aggregate query without GROUP BY, the entire table is treated as one group, so every row is included in the aggregate functions.| order_id | user_id | ▸ amount | order_date |
|---|---|---|---|
| 1 | 1 | 1200 | 2024-01-03 |
| 2 | 2 | 3500 | 2024-01-05 |
| 3 | 1 | 800 | 2024-01-08 |
| 4 | 3 | 5200 | 2024-01-10 |
| 5 | 2 | 2400 | 2024-01-12 |
| 6 | 4 | 1800 | 2024-01-15 |
COUNT(*) counts every row, including rows with NULLs, while COUNT(amount) excludes rows where amount is NULL. The results are the same when amount has no NULLs, but when counting a column that may contain NULLs, first clarify whether you intend to count distinct values or only non-NULL values with COUNT(DISTINCT col) or an IS NOT NULL check.ROUND(2483.5, 0) = 2484 (rounding), while TRUNC(2483.9, 0) = 2483 (truncation). ROUND is standard for reports. Control the number of decimal places with the second argument, as in ROUND(value, 2).COUNT(amount) returns the count excluding those rows. Use COUNT(*) for the number of orders. Use COUNT(amount) only when you intentionally want to exclude NULLs, such as for the number of orders with a confirmed amount.WHERE order_date BETWEEN ... AND ... as a period filter to monitor statistics for any selected time range.A percentile is a statistic expressed as a position when data is sorted in ascending order. The median (p50) is widely used in practice as a robust representative value that is not strongly affected by outliers. PostgreSQL provides two kinds: PERCENTILE_CONT (continuous interpolation) and PERCENTILE_DISC (nearest discrete value).
-- Continuous interpolation: linearly interpolate between adjacent values (may be a decimal) PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY amount) -- Discrete value: return the nearest value that actually exists (always an original data value) PERCENTILE_DISC(0.5) WITHIN GROUP (ORDER BY amount)
PERCENTILE_CONT is an ordered-set aggregate. WITHIN GROUP (ORDER BY col) specifies which column and order to use when calculating the percentile. The same syntax is available in BigQuery, Snowflake, and Redshift.From the amount column of the orders table, calculate the average, median, third quartile (p75), and top-10% threshold (p90) in one row. Return avg_amount, median_amount, p75_amount, p90_amount (round avg_amount to an integer).
| order_id | user_id | amount | order_date |
|---|---|---|---|
| 1 | 1 | 1200 | 2024-01-03 |
| 2 | 2 | 3500 | 2024-01-05 |
| 3 | 1 | 800 | 2024-01-08 |
| 4 | 3 | 5200 | 2024-01-10 |
| 5 | 2 | 2400 | 2024-01-12 |
| 6 | 4 | 1800 | 2024-01-15 |
Expected output (one row):
| avg_amount | median_amount | p75_amount | p90_amount |
|---|---|---|---|
| 2483 | 2100.0 | 3225.0 | 4350.0 |
Ascending order: [800, 1200, 1800, 2400, 3500, 5200] — Median: 1800+(2400-1800)×0.5=2100 / The gap of 383 between avg=2483 and the median shows the effect of the outlier (5200).
SELECT ROUND(AVG(amount), 0) AS avg_amount, PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY amount) AS median_amount, -- Median PERCENTILE_CONT(0.75) WITHIN GROUP (ORDER BY amount) AS p75_amount, -- Third quartile PERCENTILE_CONT(0.90) WITHIN GROUP (ORDER BY amount) AS p90_amount -- Top-10% threshold FROM orders; /* Execution order (logical SQL evaluation order): 1. FROM orders → Read 6 rows 2. AVG(amount) → Calculate the average 3. PERCENTILE_CONT(0.5) → Interpolate the median 4. PERCENTILE_CONT(0.75) → Interpolate the third quartile 5. PERCENTILE_CONT(0.90) → Interpolate the 90th percentile 6. SELECT → Output one row */
LEGEND
① FROM orders — Read 6 source rows
FROM ordersBecause PERCENTILE_CONT performs an internal sort using ORDER BY, the input order from FROM does not matter. All 6 amount values are included.| order_id | ▸ amount | order_date |
|---|---|---|
| 1 | 1200 | 2024-01-03 |
| 2 | 3500 | 2024-01-05 |
| 3 | 800 | 2024-01-08 |
| 4 | 5200 | 2024-01-10 |
| 5 | 2400 | 2024-01-12 |
| 6 | 1800 | 2024-01-15 |
PERCENTILE_CONT returns decimals through interpolation, while PERCENTILE_DISC returns a value that exists in the source data. DISC is appropriate for the median of integer rating scores from 1 to 5, while CONT is appropriate for continuous values such as amounts and durations.PERCENTILE_CONT(0.5) to a five-point rating scale (1, 2, 3, 4, 5) can return a nonexistent value such as 3.5. For integer scores, use PERCENTILE_DISC(0.5) to retrieve an actual score value.PERCENTILE_CONT(0.99) WITHIN GROUP (ORDER BY response_time_ms) to a dashboard for regular monitoring, an engineering team can detect deteriorating user experience early.Standard deviation is a statistic that measures the size of variation around the mean. Even when two means are identical, a large standard deviation indicates polarized ratings, while a small one indicates stable ratings.
STDDEV(col) -- Sample standard deviation (divide by n-1, SQL default) STDDEV_POP(col) -- Population standard deviation (divide by n) VARIANCE(col) -- Sample variance (the square of standard deviation)
From the product_ratings table, calculate the rating count, average rating, and standard deviation for each product. Return product_id, rating_count, avg_rating, stddev_rating (round each to two decimal places), ordered by product_id ascending.
| product_id | user_id | rating |
|---|---|---|
| A | 1 | 4 |
| A | 2 | 5 |
| A | 3 | 4 |
| A | 4 | 5 |
| A | 5 | 4 |
| B | 1 | 1 |
| B | 2 | 5 |
| B | 3 | 3 |
| B | 4 | 5 |
| B | 5 | 1 |
Expected output (product_id ascending):
| product_id | rating_count | avg_rating | stddev_rating |
|---|---|---|---|
| A | 5 | 4.40 | 0.55 |
| B | 5 | 3.00 | 2.00 |
A: ratings {4,5,4,5,4}→stable high ratings (stddev=0.55) / B: ratings {1,5,3,5,1}→the mean is 3.0, but extreme ratings are mixed in (stddev=2.0)
SELECT product_id, COUNT(*) AS rating_count, ROUND(AVG(rating)::numeric, 2) AS avg_rating, -- Cast to numeric before ROUND ROUND(STDDEV(rating)::numeric, 2) AS stddev_rating -- Sample standard deviation (divide by n-1) FROM product_ratings GROUP BY product_id ORDER BY product_id; /* Execution order (logical SQL evaluation order): 1. FROM product_ratings → Read 10 rows 2. GROUP BY product_id → Split into 2 groups 3. COUNT/AVG/STDDEV → Evaluate aggregates (sample standard deviation) 4. ROUND(...::numeric, 2) → Round to two decimal places 5. ORDER BY product_id → Sort ascending */
LEGEND
① FROM product_ratings — Read 10 rating rows
FROM product_ratingsRead all 10 rows from product_ratings. There are two product_id values, A and B. GROUP BY will split them into groups in the next step.| product_id | user_id | ▸ rating |
|---|---|---|
| A | 1 | 4 |
| A | 2 | 5 |
| A | 3 | 4 |
| A | 4 | 5 |
| A | 5 | 4 |
| B | 1 | 1 |
| B | 2 | 5 |
| B | 3 | 3 |
| B | 4 | 5 |
| B | 5 | 1 |
::numeric, combining them may cause a type error. AVG also returns double precision, so make it a habit to add a ::numeric cast when combining it with ROUND.CASE WHEN stddev_rating > 1.5 THEN 'Needs attention' ELSE 'Stable' END makes it easy to visualize priorities on a dashboard.A histogram visualizes the shape of a distribution by dividing continuous data into equal-width intervals (buckets) and counting the rows in each interval. PostgreSQL's WIDTH_BUCKET function dynamically creates buckets without writing many CASE WHEN branches.
WIDTH_BUCKET(value, lo, hi, count) -- value: the value to classify -- lo: lower bound (start of bucket 1) -- hi: upper bound (the out-of-range boundary) -- count: number of buckets -- return: bucket number (1 to count) / out of range is 0 or count+1 Example: WIDTH_BUCKET(1800, 0, 6000, 3) → Bucket width = 6000/3 = 2000 → [0,2000) [2000,4000) [4000,6000) → 1800 is in [0,2000), so bucket=1
((bucket-1)*width)::text || 'to' || (bucket*width-1)::text. If the bucket width changes, only one expression needs to be updated.Divide the amount column of the orders table into three buckets 2,000 yen wide (0 to 5,999 yen), then calculate the bucket number, amount range, and count. Assign bucket numbers in a CTE and aggregate in the outer query. Return bucket, bucket_range, order_count ordered by bucket ascending.
| order_id | amount |
|---|---|
| 1 | 1200 |
| 2 | 3500 |
| 3 | 800 |
| 4 | 5200 |
| 5 | 2400 |
| 6 | 1800 |
Expected output (bucket ascending):
| bucket | bucket_range | order_count |
|---|---|---|
| 1 | 0-1999 | 3 |
| 2 | 2000-3999 | 2 |
| 3 | 4000-5999 | 1 |
Bucket 1: 800,1200,1800 / Bucket 2: 2400,3500 / Bucket 3: 5200 — Orders are concentrated in the low-to-middle price range.
WITH bucketed AS ( -- Assign a bucket number to each order (6000 yen split into 3: width 2000 yen/bucket) SELECT WIDTH_BUCKET(amount, 0, 6000, 3) AS bucket, amount FROM orders ) SELECT bucket, ((bucket - 1) * 2000)::text || 'to' || (bucket * 2000 - 1)::text AS bucket_range, -- Dynamically generate the label COUNT(*) AS order_count FROM bucketed GROUP BY bucket ORDER BY bucket; /* Execution order (logical SQL evaluation order): 1. CTE bucketed 2. Outer query 3. ORDER BY bucket → Sort by bucket number ascending */
LEGEND
① CTE: FROM orders — Read 6 order rows
FROM orders (in CTE)First, read the orders table inside the CTE (common table expression). No buckets have been assigned yet at this stage.| order_id | ▸ amount |
|---|---|
| 1 | 1200 |
| 2 | 3500 |
| 3 | 800 |
| 4 | 5200 |
| 5 | 2400 |
| 6 | 1800 |
WIDTH_BUCKET(6000, 0, 6000, 3) returns bucket=4 (overflow) because hi=6000 is the upper boundary. Values below 0 similarly return bucket=0. In practice, filter first with WHERE amount BETWEEN 0 AND 5999, or clamp with GREATEST(1, LEAST(bucket, 3)).NTILE(3) OVER (ORDER BY amount) for equal-frequency buckets.SELECT * FROM bucketed to verify each row's bucket number, then write the aggregate query.CASE WHEN amount < 2000 THEN '0-1999' WHEN amount < 4000 THEN '2000-3999' ELSE '4000-5999' END means rewriting every condition whenever the bucket count or width changes. With WIDTH_BUCKET, change 3 to 5 to create five buckets, and update the label formula in one place.WIDTH_BUCKET(amount, MIN(amount), MAX(amount), 3), the MAX value is exactly the upper boundary and is classified into the overflow bucket (bucket=4). Set the upper bound to at least MAX(amount)+1, or filter out bucket=count+1 after aggregation.GROUP BY bucket, ab_variant.A moving average is a statistical technique for removing short-term noise from daily data and understanding the trend. PostgreSQL's window function AVG() OVER (... ROWS BETWEEN) calculates a moving average for every row in one query.
AVG(col) OVER ( ORDER BY date_col ROWS BETWEEN 2 PRECEDING AND CURRENT ROW -- Previous 2 rows + current row = 3-day moving average )
N PRECEDING means “up to N rows before,” and CURRENT ROW means the current row. For leading rows without enough rows in the frame, the average uses only the available rows (for example, day 1 has only one row, so its moving average is that value). Unlike RANGE BETWEEN, which is value-based, ROWS BETWEEN is row-count-based and is appropriate for moving-average calculations because it always uses an exact number of rows.From the daily_sales table, calculate daily revenue and a three-day moving average (ma3). ma3 is the average of “today + the previous two days,” rounded to an integer. Return sale_date, revenue, ma3 ordered by sale_date ascending.
| sale_date | revenue |
|---|---|
| 2024-01-01 | 1000 |
| 2024-01-02 | 1200 |
| 2024-01-03 | 900 |
| 2024-01-04 | 1500 |
| 2024-01-05 | 1300 |
| 2024-01-06 | 1100 |
| 2024-01-07 | 1600 |
Expected output (sale_date ascending):
| sale_date | revenue | ma3 |
|---|---|---|
| 2024-01-01 | 1000 | 1000 |
| 2024-01-02 | 1200 | 1100 |
| 2024-01-03 | 900 | 1033 |
| 2024-01-04 | 1500 | 1200 |
| 2024-01-05 | 1300 | 1233 |
| 2024-01-06 | 1100 | 1300 |
| 2024-01-07 | 1600 | 1333 |
Day 1: one row only→1000 / Day 2: (1000+1200)/2=1100 / Day 3 onward: average of the previous two days plus the current day / ma3 smooths revenue noise and shows the trend.
SELECT sale_date, revenue, ROUND( AVG(revenue) OVER ( ORDER BY sale_date ROWS BETWEEN 2 PRECEDING AND CURRENT ROW -- Latest 3 days (including the current day) ), 0 ) AS ma3 FROM daily_sales ORDER BY sale_date; /* Execution order (logical SQL evaluation order): 1. FROM daily_sales → Read 7 rows 2. ORDER BY sale_date → Fix row order by ascending date 3. AVG(revenue) OVER (...) → Moving average of current row plus previous 2 4. ROUND(..., 0) → Round to an integer 5. SELECT + ORDER BY → Output in ascending date order */
LEGEND
① FROM daily_sales — Read the sales data
FROM daily_salesRead all 7 rows from daily_sales. The window function has not been applied yet.| sale_date | revenue |
|---|---|
| 2024-01-01 | 1000 |
| 2024-01-02 | 1200 |
| 2024-01-03 | 900 |
| 2024-01-04 | 1500 |
| 2024-01-05 | 1300 |
| 2024-01-06 | 1100 |
| 2024-01-07 | 1600 |
ROWS BETWEEN 2 PRECEDING AND CURRENT ROW fixes the frame by physical row count. RANGE BETWEEN is value-based, so if multiple rows share a date, those rows are treated together. ROWS BETWEEN is appropriate for moving-average calculations because it guarantees exactly N rows even when several rows share the same date.ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING (all rows). Always specify ORDER BY to fix the frame for a moving average. Writing AVG OVER () without ORDER BY repeats the average of all rows for every row, rather than calculating a cumulative average.ROWS BETWEEN 6 PRECEDING AND CURRENT ROW. You can write multiple OVER clauses to show moving averages for several periods; SQL optimization can still reduce them to one scan.AVG(revenue) OVER (ROWS BETWEEN 2 PRECEDING AND CURRENT ROW), the physical row order is undefined and the moving-average result may change between executions. Always specify ORDER BY in the OVER clause.ROUND(AVG(revenue), 0) OVER (...) causes a syntax error. The correct form is ROUND(AVG(revenue) OVER (...), 0)—the OVER clause applies to the window function (AVG), and ROUND wraps it on the outside.PARTITION BY product_category to the OVER clause to calculate category-level moving averages in one query. Combining it with LAG(ma3, 7) OVER (ORDER BY sale_date) also enables a dynamic growth-rate calculation based on the difference from the moving average seven days earlier.