Anomaly Detection — Learn Rankings, cumulative sums, PARTITION BY, and aggregate alerts from the Basics
BasicAnomaly DetectionRANK / ROW_NUMBERCumulative SumPARTITION BYCOUNT FILTERPostgreSQL Compatible5 questions
QUESTION 6
Outlier Ranking — Rank the largest deviations with ROW_NUMBER / RANK / DENSE_RANK
ROW_NUMBERRANK / DENSE_RANKOutlier RankingAnomaly Triage
Background

When there are many anomalies, first rank them to determine which are most anomalous and inspect only the top entries. The three ranking window functions differ in how they handle equal values (ties).

ROW_NUMBER() OVER (ORDER BY x DESC)  -- 1,2,3,4 … always unique (even ties are numbered; order is nondeterministic)
RANK()       OVER (ORDER BY x DESC)  -- 1,1,3,4 … ties share a rank; gaps occur
DENSE_RANK() OVER (ORDER BY x DESC)  -- 1,1,2,3 … ties share a rank; no gaps
The empty OVER () window: OVER (), with neither PARTITION nor ORDER, treats the entire table as one window and attaches the same aggregate (for example, the overall average) to every row. Unlike GROUP BY, it does not collapse rows. Use that as the baseline for measuring each row's deviation.
Problem

From daily_sales, calculate the deviation from the full-period average and its absolute value (abs_dev), then assign ROW_NUMBER, RANK, and DENSE_RANK in descending abs_dev order. Return dt, amount, deviation, abs_dev, rn, rnk, dense_rnk, ordered by descending abs_dev and ascending dt for ties. Round deviation and abs_dev to one decimal place.

Table
► daily_sales (8 rows)
dtamount
2024-01-01120
2024-01-02130
2024-01-03125
2024-01-0450
2024-01-05200
2024-01-06122
2024-01-07128
2024-01-08125

The overall average is 125. The sharp drop on 01-04 (50) and surge on 01-05 (200) have deviations of the same magnitude (±75). This tie highlights the difference among the three ranking functions.

Expected Output

Expected output (descending abs_dev, ascending dt):

dtamountdeviationabs_devrnrnkdense_rnk
2024-01-0450-75.075.0111
2024-01-0520075.075.0211
2024-01-01120-5.05.0332
2024-01-021305.05.0432
2024-01-06122-3.03.0553
2024-01-071283.03.0653
2024-01-031250.00.0774
2024-01-081250.00.0874

For the two tied 75.0 rows, rn becomes 1,2; rnk becomes 1,1 (then 3); and dense_rnk becomes 1,1 (then 2).

Model Answer
WITH dev AS (
  SELECT
    dt, amount,
    amount - AVG(amount) OVER ()       AS deviation,   -- Deviation from the full-period average
    ABS(amount - AVG(amount) OVER ())  AS abs_dev     -- Absolute deviation (anomaly score)
  FROM daily_sales
)
SELECT
  dt, amount,
  ROUND(deviation, 1) AS deviation,
  ROUND(abs_dev, 1)   AS abs_dev,
  ROW_NUMBER() OVER (ORDER BY abs_dev DESC, dt) AS rn,        -- Unique sequence (break ties by dt)
  RANK()       OVER (ORDER BY abs_dev DESC)     AS rnk,       -- Equal values share a rank; gaps occur
  DENSE_RANK() OVER (ORDER BY abs_dev DESC)     AS dense_rnk  -- Equal values share a rank; no gaps
FROM dev
ORDER BY abs_dev DESC, dt;

/*
  Execution order (logical SQL evaluation order):
  1. FROM daily_sales                   → Read the rows
  2. AVG(amount) OVER ()                → Evaluate the window function (preserve row count)
  3. deviation / ABS()                  → Format the values
  4. ROW_NUMBER / RANK / DENSE_RANK     → Evaluate the window functions (preserve row count)
  5. SELECT / ORDER BY abs_dev DESC, dt → Sort and return the output
*/
Explanation (table transitions & key points)
WITH dev AS ( SELECT dt, amount, amount - AVG(amount) OVER () AS deviation, ABS(amount - AVG(amount) OVER ()) AS abs_dev FROM daily_sales ) SELECT dt, amount, ROUND(deviation, 1) AS deviation, ROUND(abs_dev, 1) AS abs_dev, ROW_NUMBER() OVER (ORDER BY abs_dev DESC, dt) AS rn, RANK() OVER (ORDER BY abs_dev DESC) AS rnk, DENSE_RANK() OVER (ORDER BY abs_dev DESC) AS dense_rnk FROM dev ORDER BY abs_dev DESC, dt;
LEGEND
Rows read / loaded
① FROM daily_sales (8 rows)
FROM daily_salesRead eight days of sales. The 50 drop on 01-04 and the 200 surge on 01-05 are anomaly candidates. Rank their deviation magnitudes to determine which is more anomalous.
1 / 5
dtamount
01-01120
01-02130
01-03125
01-0450
01-05200
01-06122
01-07128
01-08125
8 rows read
LEARNING POINTS
Function choice changes a “top N” result: An outer WHERE rn <= 3 returns exactly three rows; WHERE rnk <= 3 can return four or more rows because it includes ties (here rnk is 1,1,3,3, so it returns four). dense_rnk <= 3 returns the top three abs_dev groups: six rows. Choose for the intended outcome.
OVER () makes all rows one window: Instead of calculating the overall average in a subquery and joining it back, AVG(amount) OVER () attaches a baseline to every row at once. Unlike GROUP BY, it preserves row count, so deviations can be calculated while retaining each row.
ROW_NUMBER is nondeterministic without a unique ORDER BY: Tied values can be ordered differently on each execution. For reproducibility, always include a unique tie-break key, such as a date or ID: ORDER BY abs_dev DESC, dt.
ANTI-PATTERNS
Use ROW_NUMBER for the top entries and lose tied anomalies: If multiple events have the same anomaly score, mechanically cutting at rn <= N can keep one and miss another. Use RANK when all tied entries must be kept.
Use only ORDER BY plus LIMIT: ORDER BY abs_dev DESC LIMIT 3 handles ties nondeterministically and arbitrarily drops boundary events. A ranking function makes the intended treatment of ties explicit.
Practical note: anomaly triage and scoring
Production systems can generate many anomaly candidates every day. A common workflow normalizes and combines z-scores, change rates, and deviations into an anomaly score, ranks it with RANK() OVER (ORDER BY score DESC), and has humans review only the top entries. Adding PARTITION BY date also returns the worst anomaly per day (see PARTITION BY in Q8).
QUESTION 7
Drift Detection with Cumulative Deviations — Track accumulated variance from a target with SUM() OVER
SUM OVERUNBOUNDED PRECEDINGCumulative Sum / CUSUMDrift Detection
Background

z-scores and day-over-day changes are strong for one large point anomaly, but they miss gradual drift: a slightly high value every day. Accumulating (running total) each day's deviation from the target reveals the buildup of small biases.

SUM(amount - 100) OVER (
  ORDER BY dt
  ROWS UNBOUNDED PRECEDING   -- = ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
)
-- Running total from the first row through the current row
-- With only ORDER BY and no frame, the default is RANGE UNBOUNDED PRECEDING …
Watch the default cumulative-sum frame: When a window has ORDER BY but no frame, its default is RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW. If multiple rows have the same dt, RANGE adds tied values together. Explicitly use ROWS UNBOUNDED PRECEDING for row-by-row accumulation (the same ROWS vs. RANGE issue as Q1).
Problem

From daily_sales (with a daily target of 100), calculate each day's target deviation (daily_dev = amount − 100) and its cumulative value (cum_dev); assign drift_flag 'drift_alert' when cum_dev exceeds 20 and 'normal' otherwise. Return dt, amount, daily_dev, cum_dev, drift_flag in ascending dt order.

Table
► daily_sales (8 rows)
dtamount
2024-01-01100
2024-01-02102
2024-01-03101
2024-01-04103
2024-01-05105
2024-01-06104
2024-01-07106
2024-01-08108

Every day is within a few percent of the previous day and looks normal in isolation. But every value remains above target, so the deviations accumulate.

Expected Output

Expected output (ascending dt):

dtamountdaily_devcum_devdrift_flag
2024-01-0110000normal
2024-01-02102+22normal
2024-01-03101+13normal
2024-01-04103+36normal
2024-01-05105+511normal
2024-01-06104+415normal
2024-01-07106+621drift_alert
2024-01-08108+829drift_alert

Although no isolated value is anomalous, cumulative deviation reaches 21 on 01-07 and crosses the threshold of 20. This detects a sustained shift that point-anomaly detection misses.

Model Answer
SELECT
  dt, amount,
  amount - 100 AS daily_dev,          -- Daily deviation from the target (100)
  SUM(amount - 100) OVER (            -- Cumulative deviation (running total)
    ORDER BY dt
    ROWS UNBOUNDED PRECEDING            -- First row through current row
  ) AS cum_dev,
  CASE
    WHEN SUM(amount - 100) OVER (ORDER BY dt ROWS UNBOUNDED PRECEDING) > 20
      THEN 'drift_alert'
    ELSE 'normal'
  END AS drift_flag
FROM daily_sales
ORDER BY dt;

/*
  Execution order (logical SQL evaluation order):
  1. FROM daily_sales         → Read the rows
  2. amount - 100             → Format the values
  3. SUM(...) OVER (...)      → Evaluate the window function (preserve row count)
  4. CASE WHEN cum_dev > 20   → Format the values
  5. SELECT / ORDER BY dt     → Sort and return the output
*/
Explanation (table transitions & key points)
SELECT dt, amount, amount - 100 AS daily_dev, SUM(amount - 100) OVER ( ORDER BY dt ROWS UNBOUNDED PRECEDING ) AS cum_dev, CASE WHEN SUM(amount - 100) OVER (...) > 20 THEN 'drift_alert' ELSE 'normal' END AS drift_flag FROM daily_sales ORDER BY dt;
LEGEND
Rows read / loaded
① FROM daily_sales (8 rows)
FROM daily_salesSales are slightly above the target of 100 every day. Day-over-day changes are within a few percent, so point-anomaly detection finds nothing.
1 / 5
dtamount
01-01100
01-02102
01-03101
01-04103
01-05105
01-06104
01-07106
01-08108
8 rows read
LEARNING POINTS
Point anomalies vs. sustained drift: z-scores and change rates (Q3 and Q5) are strong for one large deviation; cumulative sums are strong for an accumulation of small deviations. They complement each other and are run together in production. This cumulative sum is the minimal form of CUSUM.
The default frame with only ORDER BY: SUM(x) OVER (ORDER BY dt), with no explicit frame, accumulates from UNBOUNDED PRECEDING through CURRENT ROW. In contrast, SUM(x) OVER (), with neither a frame nor ORDER BY, is the total across all rows. State the intended behavior explicitly.
Make the baseline dynamic: Accumulating amount - AVG(amount) OVER (ORDER BY dt ROWS BETWEEN 6 PRECEDING AND CURRENT ROW) instead of using a fixed 100 yields cumulative deviation from a moving average, adapting to series whose trend changes.
ANTI-PATTERNS
Forget ORDER BY: SUM(x) OVER () attaches the all-row total, not a cumulative value, to every row and hides drift. A cumulative sum always needs ORDER BY.
Monitor only a one-sided threshold: cum_dev > 20 misses accumulated negative drift. Use ABS(cum_dev) or two-sided thresholds when downward movement must also be monitored.
Practical note: CUSUM and SPC
Cumulative sums are used in manufacturing SPC (statistical process control) and SRE metric monitoring to detect the accumulation of small performance degradations early. A latency metric that gets only slightly worse each time may be invisible alone but apparent cumulatively. Full CUSUM variants accumulate only the amount beyond a target plus tolerance and reset at a lower bound of zero.
QUESTION 8
Segment-Specific z-scores — Detect anomalies with a separate baseline for each store using PARTITION BY
PARTITION BYWINDOW ClauseSegment-Specific z-scoreContext-Dependent Anomalies
Background

Measuring anomalies against a company-wide average fails when stores of different sizes are mixed. A high sale at a small store can appear normal against a large store's baseline, and vice versa. Measure anomalies within the same peer group. PARTITION BY splits a window by group, attaching each row's own group aggregate.

AVG(amount)    OVER (PARTITION BY store_id)  -- Average for each store
STDDEV(amount) OVER (PARTITION BY store_id)  -- Standard deviation for each store
-- Unlike GROUP BY, rows are not collapsed; group aggregates run alongside each row
Remove repetition with a WINDOW clause: Repeating PARTITION BY store_id is verbose. Define it once with WINDOW w AS (PARTITION BY store_id), then reuse it as AVG(amount) OVER w (the same mechanism as the WINDOW clause in Q2).
Problem

From store_sales, calculate each store's average and standard deviation, then calculate each row's z-score = (amount − store average) / store standard deviation; assign status 'anomaly' when |z| is at least 1.5 and 'normal' otherwise. Return store_id, dt, amount, store_avg, store_std, z_score, status, ordered by store_id and then dt. Round average, standard deviation, and z-score to two decimal places.

Table
► store_sales (10 rows)
store_iddtamount
A2024-01-01100
A2024-01-02105
A2024-01-0395
A2024-01-04100
A2024-01-05300
B2024-01-01500
B2024-01-02510
B2024-01-03490
B2024-01-04505
B2024-01-05495

Store A is normally near 100, with 300 on 01-05 standing out. Store B is consistently near 500. Using the company-wide average (=320) makes A's ordinary values look abnormally low and makes A's 300 less conspicuous. The baseline must be within each store.

Expected Output

Expected output (ascending store_id, ascending dt):

store_iddtamountstore_avgstore_stdz_scorestatus
A2024-01-01100140.0089.51-0.45normal
A2024-01-02105140.0089.51-0.39normal
A2024-01-0395140.0089.51-0.50normal
A2024-01-04100140.0089.51-0.45normal
A2024-01-05300140.0089.51+1.79anomaly
B2024-01-01500500.007.910.00normal
B2024-01-02510500.007.91+1.26normal
B2024-01-03490500.007.91-1.26normal
B2024-01-04505500.007.91+0.63normal
B2024-01-05495500.007.91-0.63normal

Under A's baseline (average 140, SD 89.5), only 01-05 (300) is anomalous at z=+1.79. B uses a different baseline (average 500, SD 7.9), so every row is normal. Each segment has its own anomaly scale.

Model Answer
SELECT
  store_id, dt, amount,
  ROUND(AVG(amount)    OVER w, 2) AS store_avg,  -- Average within the store
  ROUND(STDDEV(amount) OVER w, 2) AS store_std,  -- Standard deviation within the store
  ROUND(
    (amount - AVG(amount) OVER w)
    / NULLIF(STDDEV(amount) OVER w, 0)      -- Avoid division by zero when SD=0
  , 2) AS z_score,
  CASE
    WHEN ABS(
      (amount - AVG(amount) OVER w)
      / NULLIF(STDDEV(amount) OVER w, 0)
    ) >= 1.5 THEN 'anomaly'
    ELSE 'normal'
  END AS status
FROM store_sales
WINDOW w AS (PARTITION BY store_id)     -- Split and reuse the window for each store
ORDER BY store_id, dt;

/*
  Execution order (logical SQL evaluation order):
  1. FROM store_sales                  → Read the rows
  2. WINDOW w (PARTITION BY store_id)  → Define the windows
  3. AVG/STDDEV OVER w                 → Evaluate window functions (preserve row count)
  4. (amount-avg)/NULLIF(std,0)        → Calculate z-score
  5. CASE WHEN ABS(z) is at least 1.5  → Decide anomaly status
  6. SELECT / ORDER BY store_id, dt    → Sort and return the output
  */
Explanation (table transitions & key points)
SELECT store_id, dt, amount, ROUND(AVG(amount) OVER w, 2) AS store_avg, ROUND(STDDEV(amount) OVER w, 2) AS store_std, ROUND( (amount - AVG(amount) OVER w) / NULLIF(STDDEV(amount) OVER w, 0) , 2) AS z_score, CASE WHEN ABS( (amount - AVG(amount) OVER w) / NULLIF(STDDEV(amount) OVER w, 0) ) >= 1.5 THEN 'anomaly' ELSE 'normal' END AS status FROM store_sales WINDOW w AS (PARTITION BY store_id) ORDER BY store_id, dt;
LEGEND
Rows read / loaded
① FROM store_sales (10 rows)
FROM store_salesTwo stores of different sizes are mixed together. Store A is a small store near 100, while B is a large store near 500. A single baseline would hide anomalies behind this size difference.
1 / 6
store_iddtamount
A01-01100
A01-02105
A01-0395
A01-04100
A01-05300
B01-01500
B01-02510
B01-03490
B01-04505
B01-05495
10 rows read
LEARNING POINTS
Measure anomalies within the same context: A company-wide baseline turns differences in size, season, or weekday into apparent anomalies. Partitioning the window into the peers that should be compared reveals only deviations specific to each segment. This extends the z-score from Q3 to the store level.
The decisive difference between PARTITION BY and GROUP BY: Both group data, but GROUP BY collapses rows and returns only aggregates, whereas PARTITION BY keeps every row and attaches its group aggregate. Anomaly detection that must retain the original detail needs the latter (Q9 covers GROUP BY).
Share a definition with WINDOW: When several functions use the same window, put it in WINDOW w AS (...) and refer to it as AVG(...) OVER w. This centralizes the definition and prevents inconsistent PARTITION, ORDER, or frame changes.
ANTI-PATTERNS
Add ORDER BY carelessly and break the full-window aggregate: Without ORDER BY, the implicit frame is RANGE BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING, so average and SD cover the entire store. Adding ORDER BY dt changes the default to RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW, producing a cumulative average instead of the store-wide average. Omit ORDER BY for full-window aggregates or explicitly specify the full frame.
Judge every segment against the mean and SD of all data: A baseline that mixes differently sized groups hides anomalies in small groups and falsely flags normal values in large groups. Always partition when segments exist.
Leave division by a zero standard deviation unhandled: A store with one row or identical values has SD=0, causing a division-by-zero error (or Inf). Always divide by NULLIF(stddev, 0) so the result becomes NULL.
Practical note: PARTITION BY is an anomaly-detection context switch
The same z-score logic changes its perspective simply by changing the window: PARTITION BY store_id for stores, PARTITION BY dow for weekdays, or PARTITION BY product_category for products. In practice, run several partition axes in parallel to see which context contains the deviation. Add ORDER BY dt and a ROWS BETWEEN ... frame to PARTITION BY store_id to naturally extend it to a store-level moving average (Q1).
QUESTION 9
Duplicate Record Detection — Filter anomalous groups after aggregation with GROUP BY ... HAVING COUNT(*) > 1
GROUP BYHAVINGDuplicate / Double-Counting DetectionPost-Aggregation Filter
Background

Duplicate records—such as the same transaction being counted twice—are one of the most common data-quality anomalies. A single row cannot reveal them; you must count how often the same key occurs. The essential tools are GROUP BY (collapse rows to aggregate) and HAVING (filter the aggregated result).

WHERE  -- Filter individual rows before aggregation (COUNT cannot be used here)
GROUP BY txn_id   -- Collapse identical txn_id values into one group
HAVING COUNT(*) > 1  -- Filter each group after aggregation
WHERE and HAVING operate at different stages: WHERE filters individual rows before grouping; HAVING filters aggregates such as COUNT and SUM after grouping. A key that occurs two or more times can only be identified after aggregation, so it belongs in HAVING.
Problem

From txn_log, extract only transactions whose txn_id is recorded two or more times (duplicates), calculating their occurrence count and total amount. Return txn_id, occurrences, total_amount, ordered by descending occurrences and ascending txn_id.

Table
► txn_log (7 rows)
txn_iddtamount
T-1012024-01-011200
T-1022024-01-01800
T-1012024-01-021200
T-1032024-01-02500
T-1042024-01-03950
T-1022024-01-03800
T-1012024-01-031200

T-101 occurs three times and T-102 twice, so both are duplicates. T-103 and T-104 occur once and are normal. The same transaction ID recorded at the same amount across several days suggests double counting.

Expected Output

Expected output (descending occurrences, ascending txn_id):

txn_idoccurrencestotal_amount
T-10133600
T-10221600

T-103 and T-104, which occur only once, are removed by HAVING COUNT(*) > 1; only the two duplicates remain. total_amount is the amount counted more than once (the overstatement).

Model Answer
SELECT
  txn_id,
  COUNT(*)      AS occurrences,   -- Number of rows in the group = occurrence count
  SUM(amount)  AS total_amount  -- Amount total within the group
FROM txn_log
GROUP BY txn_id              -- Collapse each txn_id into one group
HAVING COUNT(*) > 1          -- After aggregation, keep only groups occurring twice or more
ORDER BY occurrences DESC, txn_id;

/*
  Execution order (logical SQL evaluation order):
  1. FROM txn_log           → Read the rows
  2. (no WHERE)             → Filter rows
  3. GROUP BY txn_id        → Group rows
  4. COUNT(*) / SUM(amount) → Evaluate aggregate functions
  5. HAVING COUNT(*) > 1    → Filter groups
  6. SELECT / ORDER BY      → Sort and return the output
*/
Explanation (table transitions & key points)
SELECT txn_id, COUNT(*) AS occurrences, SUM(amount) AS total_amount FROM txn_log GROUP BY txn_id HAVING COUNT(*) > 1 ORDER BY occurrences DESC, txn_id;
LEGEND
Rows read / loaded
① FROM txn_log (7 rows)
FROM txn_logThere are seven transaction-log rows. Duplicates are not visible one row at a time. Counting how often each txn_id occurs is what reveals double counting.
1 / 5
txn_iddtamount
T-10101-011200
T-10201-01800
T-10101-021200
T-10301-02500
T-10401-03950
T-10201-03800
T-10101-031200
7 rows read
LEARNING POINTS
WHERE is before aggregation; HAVING is after: This is the core of the question. WHERE filters individual rows before GROUP BY, while HAVING filters COUNT, SUM, and other aggregate values after GROUP BY. Occurrence count cannot be known before aggregation, so WHERE COUNT(*) > 1 is an error; HAVING is correct.
GROUP BY collapses rows (unlike PARTITION BY): Q8's PARTITION BY kept all rows, while GROUP BY aggregates each group into one row. Use GROUP BY when only a group property, such as the existence of duplicates, is needed; use PARTITION BY when detail must be retained for row-by-row comparison.
The GROUP BY key defines a duplicate: This question considers identical txn_id values duplicates. To define a duplicate as the same ID on the same day, use a composite key such as GROUP BY txn_id, dt. The GROUP BY clause is the definition of what counts as a duplicate.
ANTI-PATTERNS
Try to use COUNT in WHERE: WHERE COUNT(*) > 1 is evaluated before aggregation and produces a syntax error. Put aggregate filters in HAVING. Conversely, use WHERE for a date restriction when checking duplicates only within a certain period; both can be combined.
Mix a non-aggregated, non-grouped column into SELECT: SELECT txn_id, dt, COUNT(*) ... GROUP BY txn_id leaves dt indeterminate and errors in most databases (MySQL can silently return an arbitrary value, which is dangerous). SELECT only GROUP BY keys or aggregate functions.
Practical note: beyond duplicate detection (marking duplicates with window functions)
GROUP BY plus HAVING is ideal for listing duplicate keys, but not for deciding which detail record to keep and which to delete. In practice, assign sequence numbers to each duplicate with ROW_NUMBER() OVER (PARTITION BY txn_id ORDER BY dt) (combining Q6 and Q8) and delete rows other than rn = 1 as duplicates. Remember the division of responsibility: GROUP BY for detection, window functions for removal (deduplication).
QUESTION 10
Aggregate Anomaly Alerts — Surface categories requiring action with COUNT(*) FILTER + GROUP BY + HAVING
COUNT(*) FILTERGROUP BY / HAVINGConditional AggregationAlert AggregationWrap-up
Background

The final anomaly-detection step is to aggregate findings into alerts. “For each category, how many days were anomalous and what percentage of all days? Notify only categories with enough anomalous days.” COUNT(*) FILTER (WHERE ...) plus GROUP BY ... HAVING writes this combination of conditional counting and group filtering in one query (a synthesis of Q6–Q9).

COUNT(*) FILTER (WHERE amount > 200)  -- Count only rows matching the condition
COUNT(*)                              -- Count all rows (the denominator)
-- Within one group, aggregate all rows and anomalies simultaneously
FILTER is the standard conditional-aggregation syntax: COUNT(*) FILTER (WHERE condition) counts only matching rows within a group. A table-level WHERE reduces the denominator too, whereas FILTER obtains both the denominator (all rows) and numerator (anomalies) in one query.
Problem

From metrics, daily measurements by category, define amount > 200 as anomalous and aggregate each category's total days (total_days), anomalous days (anomaly_days), and anomaly rate percent (anomaly_pct). Keep only categories with at least two anomalous days as notification targets. Return category, total_days, anomaly_days, anomaly_pct, ordered by descending anomaly_days and ascending category. Round pct to one decimal place.

Table
► metrics (12 rows)
categorydtamount
web2024-01-01100
web2024-01-02250
web2024-01-03300
auth2024-01-01260
auth2024-01-02240
auth2024-01-03220
api2024-01-01120
api2024-01-02280
api2024-01-03130
cdn2024-01-0190
cdn2024-01-02100
cdn2024-01-0395

Anomalies (amount>200) are web: 2 days, auth: 3, api: 1, and cdn: 0. Filtering to at least two anomalous days leaves only web and auth as notification targets.

Expected Output

Expected output (descending anomaly_days, ascending category):

categorytotal_daysanomaly_daysanomaly_pct
auth33100.0
web3266.7

api (one anomalous day) and cdn (zero) are excluded by HAVING anomaly_days >= 2. auth is anomalous every day and takes top priority, followed by web: a prioritized list of categories that need notification.

Model Answer
SELECT
  category,
  COUNT(*)                                   AS total_days,  -- Total days in the category (denominator)
  COUNT(*) FILTER (WHERE amount > 200)     AS anomaly_days,  -- Count only anomalous days
  ROUND(
    100.0 * COUNT(*) FILTER (WHERE amount > 200)
    / COUNT(*)                                -- Anomaly rate = anomalous days / total days
  , 1) AS anomaly_pct
FROM metrics
GROUP BY category                            -- Aggregate by category
HAVING COUNT(*) FILTER (WHERE amount > 200) >= 2  -- Notify only when at least two days are anomalous
ORDER BY anomaly_days DESC, category;

/*
  Execution order (logical SQL evaluation order):
  1. FROM metrics                       → Read the rows
  2. GROUP BY category                  → Group rows
  3. COUNT(*) and COUNT(*) FILTER(...)  → Evaluate aggregate functions
  4. ROUND(100.0*anomaly/denominator,1) → Format the value
  5. HAVING COUNT(*) FILTER(...) >= 2   → Filter groups
  6. SELECT / ORDER BY anomaly_days DESC → Sort and return the output
*/
Explanation (table transitions & key points)
SELECT category, COUNT(*) AS total_days, COUNT(*) FILTER (WHERE amount > 200) AS anomaly_days, ROUND( 100.0 * COUNT(*) FILTER (WHERE amount > 200) / COUNT(*) , 1) AS anomaly_pct FROM metrics GROUP BY category HAVING COUNT(*) FILTER (WHERE amount > 200) >= 2 ORDER BY anomaly_days DESC, category;
LEGEND
Rows read / loaded
① FROM metrics (12 rows)
FROM metricsThere are three daily measurements for each of four categories. Define amount>200 as an anomaly. Raw detail does not show which category needs action, so aggregate by category.
1 / 6
categorydtamount
web01-01100
web01-02250
web01-03300
auth01-01260
auth01-02240
auth01-03220
api01-01120
api01-02280
api01-03130
cdn01-0190
cdn01-02100
cdn01-0395
12 rows read (green = anomaly amount>200)
LEARNING POINTS
FILTER gets numerator and denominator in one query: Filtering the table with WHERE amount > 200 leaves only anomalous rows even in the denominator, so no rate can be calculated. COUNT(*) FILTER (WHERE ...) sits beside total COUNT(*) and calculates the anomaly rate at the same time.
Watch integer division; multiply by 100.0: COUNT(*) FILTER(...) / COUNT(*) can truncate to 0 when both operands are integers. Multiply by floating-point 100.0 first, or cast with CAST(... AS numeric), before dividing.
Write the expression, not its alias, in HAVING: In many databases, HAVING anomaly_days >= 2 cannot reference SELECT's output alias. HAVING is logically evaluated before that alias is assigned, so repeat HAVING COUNT(*) FILTER (WHERE ...) >= 2. ORDER BY comes later, so its alias is allowed.
ANTI-PATTERNS
Filter the entire table with WHERE to calculate an anomaly rate: ... WHERE amount > 200 GROUP BY category aggregates only anomalous rows, loses the denominator, and makes every category appear 100%. Use FILTER (or CASE aggregation) when the denominator must remain.
Use FILTER in a database that does not support it: FILTER works in PostgreSQL and SQLite but not MySQL. Substitute SUM(CASE WHEN amount > 200 THEN 1 ELSE 0 END) or COUNT(CASE WHEN amount > 200 THEN 1 END) for the same behavior. Use CASE aggregation when portability is required.
Practical note: from detection to alerts — Q6–Q10 summary
To close the series: first, capture point anomalies with z-scores (Q3 and Q8) or deviation rankings (Q6); second, track sustained drift with cumulative sums (Q7); third, catch data-quality anomalies through duplicate detection (Q9). Finally, use this question's conditional aggregation (FILTER + GROUP BY + HAVING) to turn “which segment, how much, and at what priority needs attention” into an actionable alert. Detection logic can vary, but a single aggregated list is what ultimately moves people to act. Combining SQL aggregation, windows, and conditions enables practical anomaly monitoring without a dedicated tool.