Statistical Analysis — Learn Aggregate Functions, Percentiles, Standard Deviation, and Moving Averages from the Basics
BasicStatistical AnalysisAggregate FunctionsPercentilesStandard DeviationMoving AveragePostgreSQL Compatible5 questions
QUESTION 1
Basic Statistics — Understand the Overall Sales Picture with COUNT/SUM/AVG/MIN/MAX
COUNT/SUM/AVGMIN/MAXBasic StatisticsSales Analysis
Background

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
Control output precision with ROUND(value, n): AVG returns a decimal, so 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.
Problem

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).

Table used
▸ orders (6 rows)
order_iduser_idamountorder_date
1112002024-01-03
2235002024-01-05
318002024-01-08
4352002024-01-10
5224002024-01-12
6418002024-01-15
Expected Output

Expected output (one row):

total_orderstotal_revenueavg_amountmin_amountmax_amount
61490024838005200

SUM: 1200+3500+800+5200+2400+1800=14900 / AVG: 14900÷6=2483.33→ROUND→2483 / MIN: 800 / MAX: 5200

Model Answer
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
  */
Explanation (table transitions & key points)
SELECT COUNT(*) AS total_orders, SUM(amount) AS total_revenue, ROUND(AVG(amount), 0) AS avg_amount, MIN(amount) AS min_amount, MAX(amount) AS max_amount FROM orders;
LEGEND
Rows read / loaded
① 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.
1 / 4
order_iduser_id▸ amountorder_date
1112002024-01-03
2235002024-01-05
318002024-01-08
4352002024-01-10
5224002024-01-12
6418002024-01-15
orders: 6 rows (all rows are included)
LEARNING POINTS
COUNT(*) and COUNT(col) behave differently: 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.
SUM and AVG skip NULLs: Both SUM and AVG automatically exclude NULL rows. AVG also excludes those rows from its denominator, so AVG on a column with many NULLs is the average of rows with values, not the average of the entire dataset.
When to use ROUND vs. TRUNC: 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).
ANTI-PATTERNS
Accidentally exclude NULL rows with COUNT(amount): When amount contains NULL, 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.
Judge the representative value from the average alone: When avg_amount=2483 spans min=800 to max=5200, the average has been pulled upward by the outlier order of 5200. Always check the basic statistics together; if min and max are far apart, dig into the distribution with the median (Q2) or histogram (Q4).
Practical Column: A Starting Point for EDA (Exploratory Data Analysis)
When you receive a new dataset, this basic-statistics query is a useful first run. Use total_orders to understand the data volume, the min/max difference (the range) to check for outliers, and the gap between avg and the median to diagnose skew—the three steps form the foundation of analysis. In a dashboard, add WHERE order_date BETWEEN ... AND ... as a period filter to monitor statistics for any selected time range.
QUESTION 2
Percentile Analysis — Use PERCENTILE_CONT to Understand the Median and Distribution Skew
PERCENTILE_CONTWITHIN GROUPMedianOutlier Detection
Background

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)
Role of WITHIN GROUP: 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.
Problem

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).

Table used (same as Q1)
▸ orders (6 rows)
order_iduser_idamountorder_date
1112002024-01-03
2235002024-01-05
318002024-01-08
4352002024-01-10
5224002024-01-12
6418002024-01-15
Expected Output

Expected output (one row):

avg_amountmedian_amountp75_amountp90_amount
24832100.03225.04350.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).

Model Answer
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
  */
Explanation (table transitions & key points)
SELECT ROUND(AVG(amount), 0) AS avg_amount, PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY amount) AS median_amount, PERCENTILE_CONT(0.75) WITHIN GROUP (ORDER BY amount) AS p75_amount, PERCENTILE_CONT(0.90) WITHIN GROUP (ORDER BY amount) AS p90_amount FROM orders;
LEGEND
Rows read / loaded
① 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.
1 / 4
order_id▸ amountorder_date
112002024-01-03
235002024-01-05
38002024-01-08
452002024-01-10
524002024-01-12
618002024-01-15
orders: 6 rows
LEARNING POINTS
A gap between avg and median signals an outlier: When avg(2483) is 383 above median(2100), it indicates an outlier in the right tail (high values). Conversely, median > avg indicates an outlier in the left tail. Comparing the two first is the fastest way to detect distribution skew.
Choosing between PERCENTILE_CONT and PERCENTILE_DISC: 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.
Practical uses of p75 and p90: p90 (the top-10% threshold) is often used when designing SLAs. A metric such as “API response-time p90 is at most 200ms” quantifies slow request experiences that an average cannot capture. From a user-experience perspective, checking p95 and p99 as well is standard practice.
ANTI-PATTERNS
Define a “typical user” from the average alone: If you design initiatives around avg_amount=2483 as the “standard order amount,” you may miss that many orders are around 2100 (the median) while the high-value order of 5200 pulls the average upward. Always check the median and percentiles for segmentation initiatives.
Use CONT where DISC is appropriate: Applying 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.
Practical Column: The Relationship Between SLAs and Percentiles
For Web API latency monitoring, p50 (the median), p95, and p99 are standard SLA indicators. Even if average latency is 100ms, a p99 of 3000ms means 1% of users experience serious delays. The point is that improving p99 reveals issues that are invisible when you look only at p50. By adding a query such as 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.
QUESTION 3
Group-Wise Standard Deviation — Quantify Rating Variation with STDDEV
STDDEVGROUP BYStandard DeviationProduct Rating Analysis
Background

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)
Choosing sample vs. population: Use the sample standard deviation (STDDEV) when the analysis covers a sample of all users, and the population standard deviation (STDDEV_POP) when it covers every user. STDDEV is common in practical data analysis because data is often a sample. Standard deviation has the same unit as the mean (for example, a rating score remains on a 0 to 5 scale), so it is intuitive to interpret.
Problem

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.

Table used
▸ product_ratings (10 rows)
product_iduser_idrating
A14
A25
A34
A45
A54
B11
B25
B33
B45
B51
Expected Output

Expected output (product_id ascending):

product_idrating_countavg_ratingstddev_rating
A54.400.55
B53.002.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)

Model Answer
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
  */
Explanation (table transitions & key points)
SELECT product_id, COUNT(*) AS rating_count, ROUND(AVG(rating)::numeric, 2) AS avg_rating, ROUND(STDDEV(rating)::numeric, 2) AS stddev_rating FROM product_ratings GROUP BY product_id ORDER BY product_id;
LEGEND
Rows read / loaded
① 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.
1 / 4
product_iduser_id▸ rating
A14
A25
A34
A45
A54
B11
B25
B33
B45
B51
product_ratings: 10 rows
LEARNING POINTS
Sample standard deviation (STDDEV) vs. population standard deviation (STDDEV_POP): STDDEV divides by n-1 (Bessel's correction), while STDDEV_POP divides by n. For product A, STDDEV=sqrt(0.3)≈0.55, while STDDEV_POP=sqrt(0.24)≈0.49, a small difference. Use STDDEV_POP when you have the complete dataset and STDDEV for a random sample.
Why a ::numeric cast is needed: PostgreSQL's STDDEV returns double precision, while ROUND expects a numeric first argument. Without ::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.
Applying the coefficient of variation (CV): Dividing stddev by avg gives the coefficient of variation, CV = STDDEV / AVG × 100, which lets you compare variation relatively across products with different mean levels. Product A has CV≈12.5%, while product B has CV≈66.7%; a high-CV product may benefit from quality improvements or revised product copy.
ANTI-PATTERNS
Judge product quality from the average rating alone: Looking at product B's avg_rating=3.0 and calling it “average” misses the reality: two 1-point ratings, two 5-point ratings, and one 3-point rating are polarized. Always check stddev alongside the mean; even a product with a high average and large stddev may be a niche product that strongly appeals to only some users, requiring a separate strategy.
Show VARIANCE directly to users: VARIANCE is the square of standard deviation and has a different unit from the source data (for rating scores, it is “points²”). Use STDDEV, which has the same unit as the source data, in user-facing reports. Treat VARIANCE as an intermediate calculation value.
Practical Column: Business Insights from Rating Variation
A high standard deviation is a sign that a product has room for improvement. Analyzing low-rating reviews can identify what lowers satisfaction, such as delivery, quality, or a gap between the product and its description. In contrast, a product with low stddev and high avg is a stable, high-quality product for core customers and a candidate for concentrated advertising spend. In practice, adding a flag such as CASE WHEN stddev_rating > 1.5 THEN 'Needs attention' ELSE 'Stable' END makes it easy to visualize priorities on a dashboard.
QUESTION 4
Histogram Analysis — Bucket Order Amounts with WIDTH_BUCKET
WIDTH_BUCKETCTEHistogramDistribution Analysis
Background

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
Convert bucket numbers into labels: WIDTH_BUCKET returns an integer bucket number. You can dynamically generate a human-readable label with string operations such as ((bucket-1)*width)::text || 'to' || (bucket*width-1)::text. If the bucket width changes, only one expression needs to be updated.
Problem

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.

Table used (same as Q1)
▸ orders (6 rows)
order_idamount
11200
23500
3800
45200
52400
61800
Expected Output

Expected output (bucket ascending):

bucketbucket_rangeorder_count
10-19993
22000-39992
34000-59991

Bucket 1: 800,1200,1800 / Bucket 2: 2400,3500 / Bucket 3: 5200 — Orders are concentrated in the low-to-middle price range.

Model Answer
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
  */
Explanation (table transitions & key points)
WITH bucketed AS ( 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, COUNT(*) AS order_count FROM bucketed GROUP BY bucket ORDER BY bucket;
LEGEND
Rows read / loaded
① 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.
1 / 5
order_id▸ amount
11200
23500
3800
45200
52400
61800
orders: 6 rows
LEARNING POINTS
WIDTH_BUCKET boundary behavior (out-of-range values): 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)).
Equal-width vs. equal-frequency buckets: WIDTH_BUCKET generates equal-width buckets, where every interval has the same width. When data is skewed, such as a concentration in the low-price range, counts become uneven across buckets. To make counts more even, use NTILE(3) OVER (ORDER BY amount) for equal-frequency buckets.
Why process in stages with a CTE: Separating bucket assignment in CTE bucketed from label generation and aggregation in the outer query makes debugging easier. First run SELECT * FROM bucketed to verify each row's bucket number, then write the aggregate query.
ANTI-PATTERNS
Define buckets with hard-coded CASE WHEN branches: Writing 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.
Use the maximum value as the upper boundary: If you write 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.
Practical Column: Analyze A/B Test Results by Price Segment
A practical use of histogram analysis is to break A/B-test results into price segments. Even if the overall average purchase amount is higher for initiative A, the effect may actually be positive only for high-value users and negative in the low-value segment. Splitting amounts into 3 to 5 buckets with WIDTH_BUCKET and comparing conversion rates or average purchase amounts by segment identifies which users respond to the initiative. Extend the segment analysis simply with GROUP BY bucket, ab_variant.
QUESTION 5
Moving Average — Smooth Daily Sales Trends with AVG() OVER (ROWS BETWEEN)
AVG OVERROWS BETWEENMoving AverageWindow Functions
Background

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
)
Specify the ROWS BETWEEN frame: 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.
Problem

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.

Table used
▸ daily_sales (7 rows)
sale_daterevenue
2024-01-011000
2024-01-021200
2024-01-03900
2024-01-041500
2024-01-051300
2024-01-061100
2024-01-071600
Expected Output

Expected output (sale_date ascending):

sale_daterevenuema3
2024-01-0110001000
2024-01-0212001100
2024-01-039001033
2024-01-0415001200
2024-01-0513001233
2024-01-0611001300
2024-01-0716001333

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.

Model Answer
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
  */
Explanation (table transitions & key points)
SELECT sale_date, revenue, ROUND( AVG(revenue) OVER ( ORDER BY sale_date ROWS BETWEEN 2 PRECEDING AND CURRENT ROW ), 0 ) AS ma3 FROM daily_sales ORDER BY sale_date;
LEGEND
Rows read / loaded
① FROM daily_sales — Read the sales data
FROM daily_salesRead all 7 rows from daily_sales. The window function has not been applied yet.
1 / 6
sale_daterevenue
2024-01-011000
2024-01-021200
2024-01-03900
2024-01-041500
2024-01-051300
2024-01-061100
2024-01-071600
daily_sales: 7 rows
LEARNING POINTS
ROWS BETWEEN vs. RANGE BETWEEN: 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.
Without ORDER BY, the default window frame changes: If ORDER BY is omitted from the OVER clause, the default frame is 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.
Choose the moving-average period: A three-day moving average responds quickly to short-term trends, while a seven-day moving average is useful for removing day-of-week effects. In practice, change it to seven days by using 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.
ANTI-PATTERNS
Define the window without ORDER BY: If you omit ORDER BY from 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.
Apply ROUND outside the OVER clause incorrectly: Writing 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.
Practical Column: Use Moving Averages in Dashboards
Adding a moving average to a daily-sales dashboard lets you show actual values (bars) and a trend line in one chart. Because the moving average smooths day-of-week effects such as lower Monday sales and spikes just after promotions, it is easier to distinguish true growth from noise. In practice, add 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.