skip to content

Should COALESCE go inside AVG(rating) or around it, and what changes?

level: seniorimportance: should knowfreq 38%

answer

  1. two NULLs live in one query
  2. one is a row value, one is a result
  3. inside changes the metric's meaning
  4. outside only fires on empty input
  5. publish the rated-row count beside the average

basics

~20 s

They answer different questions. AVG(COALESCE(rating, 0)) counts unrated rows as zero and lowers the average; COALESCE(AVG(rating), 0) leaves the average of rated rows untouched and only substitutes a value when there is no data at all.

solid answer

~50 s

Placement decides *which* NULL you are handling. **Inside** — `AVG(COALESCE(rating, 0))` — rewrites each row's value before NULL elimination, so unrated rows enter both the numerator (as 0) and the denominator. That is correct only if a missing rating genuinely means "scored zero". **Outside** — `COALESCE(AVG(rating), 0)` — leaves the average of the rated rows exactly as it was and fires only when the input was empty, guarding the caller against a NULL scalar. With ratings 5, 4, 3 and two NULLs, the inner form gives 2.4 and the outer gives 4.0. For an average, the inner form is almost always wrong — treating unknown as zero fabricates data and makes the metric drift with reporting completeness rather than with quality. The defensible pattern is to average the rated rows, publish the sample size beside it, and let the presentation layer render "no data" rather than a manufactured zero.

code

sql · 7 lines
sql
-- reviews: 5 rows; ratings 5, 4, 3 and two NULLs
SELECT AVG(rating)              AS avg_of_rated,    -- 4.0  (12 / 3)
       AVG(COALESCE(rating, 0)) AS missing_as_zero, -- 2.4  (12 / 5)
       COALESCE(AVG(rating), 0) AS zero_if_no_data, -- 4.0  (guard never fires)
       COUNT(*)                 AS rows_total,      -- 5
       COUNT(rating)            AS rows_rated       -- 3
FROM reviews;

go deeper

for a junior

Know that COALESCE inside the aggregate changes each row's value while COALESCE outside only replaces a NULL final result, and be able to compute both on a small example.

for a middle

Explain the ordering that makes them different — substitution happens before NULL elimination inside, after aggregation outside — and state which NULL each one is actually guarding.

for a senior

Show the judgment: zeroing unknown ratings makes the metric track reporting completeness rather than quality. Describe reconciling two disagreeing dashboards with COUNT(*), COUNT(col) and both averages side by side.

for a principal

Own the definition layer: whether a column is allowed to be nullable when NULL and 0 mean the same thing, how metric semantics are documented, and what the reporting contract says about presenting sparse or absent data.

## Two NULLs, two guards There are two distinct NULLs in an aggregate query, and `COALESCE` addresses whichever one it is nearest to. - **A row-value NULL**: the column is NULL on a row that exists. Aggregates eliminate these before computing, so the row contributes nothing and does not appear in `AVG`'s denominator. `COALESCE` placed *inside* the aggregate intercepts this NULL and substitutes a value, which puts the row back into the computation. - **An aggregate-result NULL**: the aggregate had no non-NULL input at all — no rows matched, or every value was NULL — so it returned NULL. `COALESCE` placed *outside* intercepts this one and gives the caller a scalar default. They are not two spellings of one idea. Each leaves the other case untouched. ## The numbers ```sql -- reviews: 5 rows; ratings 5, 4, 3 and two NULLs SELECT AVG(rating) AS avg_of_rated, -- 4.0 (12 / 3) AVG(COALESCE(rating, 0)) AS missing_as_zero, -- 2.4 (12 / 5) COALESCE(AVG(rating), 0) AS zero_if_no_data, -- 4.0 (guard never fires) COUNT(*) AS rows_total, -- 5 COUNT(rating) AS rows_rated -- 3 FROM reviews; ``` The inner form moved the answer by 1.6 stars without any data changing — it changed the *definition* of the metric. The outer form changed nothing here, because there was data; it would only have mattered had the table been empty. ## Choosing between them Ask what NULL means in this column. **If NULL means "unknown" or "not yet supplied"** — the usual case for a rating — the inner form is a data-fabrication bug. Its worst property is that the metric becomes a function of reporting completeness: a product whose reviewers mostly leave the score blank looks terrible, and the number improves whenever collection improves, even if satisfaction is flat. It also makes the measure non-comparable across periods and across segments with different response rates. Average the rated rows and disclose `COUNT(rating)` alongside so the reader can judge how much weight the figure deserves. **If NULL means "zero by convention"** — a `late_fee` column left NULL when there was no fee, a `discount` that is NULL when none applied — then the missing rows genuinely belong in the denominator and the inner form is correct. In that situation, prefer fixing the model: `DEFAULT 0` plus `NOT NULL` removes the ambiguity permanently and takes an entire class of bug off the table. Leaving a column nullable when NULL and 0 mean the same thing guarantees that some future query will disagree with this one. **The outer guard is a separate decision** about the caller's contract. Use it when a NULL scalar would break the consumer — arithmetic that would propagate the NULL, a driver mapping to a non-nullable numeric type, a JSON field that must be a number. Do not use it when zero would be a false statement to the reader; for averages it usually is. Returning `COUNT(*)` next to the measure lets the consumer distinguish "no data" from a real zero without you having to encode that distinction in the value itself. ## The combined form and why it is rarely right `COALESCE(AVG(COALESCE(rating, 0)), 0)` is legal and occasionally seen. It says "missing ratings score zero, and if there are no rows at all report zero" — a strong claim, and in this shape the inner guard already makes the outer one nearly unreachable: with at least one row, the average of coalesced values is never NULL. If you find yourself writing it, that is a sign the metric definition has not been decided. ## Diagnosing the disagreement in the wild When two dashboards report different averages for "the same" number, the usual culprits are exactly these two placements, plus a third: a `WHERE rating IS NOT NULL` filter, which matches `AVG(rating)` for that column but silently changes every other measure in the same query — `COUNT(*)`, sums over other columns, ratios. A quick reconciliation query showing `COUNT(*)`, `COUNT(rating)`, `AVG(rating)` and `AVG(COALESCE(rating, 0))` side by side pins down which definition each dashboard implemented, and the gap between the two counts tells you how much the choice is worth. ## The senior answer in one breath "Inside `COALESCE` changes what the metric means — unrated rows become zeros in both numerator and denominator. Outside `COALESCE` only protects the caller from a NULL when there was no data. For a rating, I average the rated rows and publish the count with it; I would only push the zero inside if NULL genuinely meant zero, and in that case I would make the column `NOT NULL DEFAULT 0` so nobody has to decide again."

  • When is AVG(COALESCE(x, 0)) the right choice rather than a bug?
    When NULL in that column genuinely encodes zero — a discount that is NULL because none applied, a fee that is NULL because none was charged. The rows are real and their true value is zero, so they belong in the denominator. In that case go further and fix the schema with `NOT NULL DEFAULT 0`, so no future query has to make the same judgment call.
  • Is WHERE rating IS NOT NULL a valid substitute for either placement?
    Not really. It leaves `AVG(rating)` byte-identical, because those rows were never in the average, while silently changing every other measure in the same statement — `COUNT(*)`, sums over other columns, any ratio. Filter when you mean "restrict the population"; use COALESCE placement when you mean "decide what a missing value contributes".
  • How would you present an average that has very few rated rows behind it?
    Return the sample size with it — `COUNT(rating)` beside `AVG(rating)` — and let the presentation layer suppress or annotate averages below a threshold. Substituting zeros to "fill in" the missing raters is worse than showing a thin sample honestly, because it makes the metric track response rate instead of quality.

saying these in an interview costs you the question

  • Treats the two COALESCE placements as equivalent
  • Coalesces ratings to zero without asking what NULL means
  • Says COALESCE(AVG(x), 0) protects against NULL row values
  • Uses WHERE x IS NOT NULL expecting the average to change
  • Reports an average with no sample size behind it

context