SQL • Aggregation and Grouping

Basic Aggregate Functions — Use COUNT, SUM, AVG, MIN, MAX correctly

Transform entire result sets into single summary values using SQL's five foundational aggregate functions.

SQL illustration with a database, structured table, and query code symbols
Inside this lesson 10 sections 5 questions 10 figures

SECTION 1

Historical Context & Motivation

Before relational databases existed, computing summary statistics over large data collections required writing custom procedural code — iterating through files, accumulating totals in variables, and manually tracking extrema. This approach was error-prone, tightly coupled to data layout, and demanded that every analyst reinvent the same logic. The emergence of the relational model and its declarative query language fundamentally changed this paradigm by embedding aggregation directly into the data retrieval layer. Rather than telling the system how to loop through records, analysts could simply declare what summary they needed, and the database engine would determine the most efficient execution strategy.

1970 • Codd's Relational Model

Edgar F. Codd published "A Relational Model of Data for Large Shared Data Banks," introducing relational algebra with operators that implicitly supported set-level operations, laying the mathematical groundwork for aggregate computation.

1974 • SEQUEL at IBM

Donald Chamberlin and Raymond Boyce developed SEQUEL (later SQL) at IBM's San Jose Research Laboratory. Early prototypes already included SUM and COUNT as built-in operators, reflecting the immediate demand for aggregation in business reporting.

1986 • SQL-86 Standard (ANSI)

The first ANSI SQL standard formally codified COUNT, SUM, AVG, MIN, and MAX as the five standard aggregate functions, along with GROUP BY and HAVING clauses, ensuring portability across emerging RDBMS products.

1999 • SQL:1999 — OLAP Extensions

SQL:1999 introduced ROLLUP, CUBE, and GROUPING SETS, extending the basic aggregate functions with multidimensional summarization capabilities essential for data warehousing and online analytical processing (OLAP).

2003 • SQL:2003 — Window Functions

SQL:2003 introduced window (analytic) functions, enabling the same aggregate functions to operate over sliding partitions without collapsing rows — a capability that built directly upon understanding COUNT, SUM, AVG, MIN, and MAX.

The central question these aggregate functions address is deceptively simple: how can a declarative language collapse an arbitrary number of rows into a single scalar result while correctly handling NULL values, duplicate entries, and empty sets? Mastering these five functions is the prerequisite for every subsequent SQL aggregation topic, from GROUP BY partitioning to window functions and OLAP extensions.

SECTION 2

Core Principles & Definitions

An aggregate function is a set function that accepts a multiset (bag) of values drawn from a column and returns a single scalar value. Unlike scalar functions that operate on one row at a time, aggregates consume the entire group of rows that survives the WHERE filter (or a partition defined by GROUP BY). The SQL standard defines five fundamental aggregates, each satisfying distinct statistical or counting needs. Understanding their behavior requires internalizing several cross-cutting principles that govern how all five functions interact with NULL values, the DISTINCT keyword, and empty input sets.

1

NULL Exclusion Rule

All aggregate functions except COUNT(*) silently ignore NULL values. SUM, AVG, MIN, and MAX operate only on the non-NULL subset. COUNT(column) also excludes NULLs, while COUNT(*) counts all rows regardless.
2

ALL vs. DISTINCT

Every aggregate implicitly uses ALL by default, meaning duplicates are included. Specifying DISTINCT inside the function (e.g., COUNT(DISTINCT department)) eliminates duplicates before the computation.
3

Empty-Set Behavior

When the input set is empty, COUNT returns 0, while SUM, AVG, MIN, and MAX all return NULL. This asymmetry is a common source of bugs and should be handled with COALESCE when a zero or default is preferred.
4

Single-Value Output

Without a GROUP BY clause, an aggregate function reduces the entire result set to one row. With GROUP BY, it produces one scalar result per group. Mixing aggregated and non-aggregated columns without grouping is a semantic error in standard SQL.
5

Data Type Sensitivity

SUM and AVG require numeric input types; applying them to strings causes a type error. MIN and MAX use the column's collation order for strings and chronological order for dates. COUNT is type-agnostic since it only tallies the existence of non-NULL values.
✦

KEY TAKEAWAY

Think of an aggregate function like a funnel in a chemistry lab: you pour in an entire beaker of individual data points (rows), and what drips out the bottom is a single distilled measurement — a count, a total, an average, or an extreme value. The funnel (the aggregate) has a built-in filter that catches NULLs on a mesh screen before they reach the computation chamber. Understanding this mental model — multiset in, scalar out, NULLs filtered — prevents the majority of aggregate-related bugs in production SQL.

SECTION 3

Visual Explanation — How Aggregates Transform Data

Aggregate Functions: Row-Level → Scalar Transformationorders Tableidamountstatus1150.00paid2200.00paid3NULLpending4350.00paid5150.00paid6500.00paid7NULLrefund8200.00paidCOUNT(*)All rows incl. NULLsSUM(amount)NULLs excludedAVG(amount)SUM ÷ COUNT(non-NULL)MIN(amount)Smallest non-NULLMAX(amount)Largest non-NULLResultsCOUNT8SUM1550.00AVG258.33MIN150.00MAX500.00⚠ Rows 3 & 7 have NULL amountExcluded by SUM, AVG, MIN, MAXCOUNT(*) = 8 rows total • COUNT(amount) = 6 non-NULL values • AVG = 1550 ÷ 6 = 258.33SELECT COUNT(*), SUM(amount), AVG(amount), MIN(amount), MAX(amount) FROM orders;
The diagram traces how eight rows in the orders table flow through each of the five aggregate functions. Notice that rows 3 and 7 contain NULL amounts and are excluded by SUM, AVG, MIN, and MAX, but are still counted by COUNT(*). The AVG is computed as SUM ÷ COUNT(non-NULL), not SUM ÷ COUNT(*).

The diagram above illustrates the fundamental transformation that every aggregate function performs: a multiset of row-level values enters on the left, and a single scalar exits on the right. The critical detail to observe is the asymmetry between COUNT(*) and COUNT(amount). The former counts all eight rows because it tests for row existence, not column value. The latter counts only the six rows where amount is not NULL. This distinction propagates directly into AVG, which divides SUM by the count of non-NULL values (6, not 8), yielding 258.33 rather than 193.75 — a difference of roughly 33% that can silently corrupt analytics if misunderstood.

SECTION 4

How Each Aggregate Function Works

Although SQL is declarative and hides implementation details, understanding the mathematical semantics of each aggregate function clarifies edge cases and prevents misuse. Let S denote the multiset of non-NULL values drawn from the target column within a given group, and let N = |S| represent its cardinality.

COUNT

COUNT(∗)=∣allrows∣COUNT(col)=∣x∈col:x≠NULL∣COUNT(*) = |all rows| COUNT(col) = |{x ∈ col : x ≠ NULL}|

COUNT(*) tallies every row, including those where every column is NULL. COUNT(col) tallies only rows where col is non-NULL. COUNT(DISTINCT col) counts unique non-NULL values. Return type is always an integer ≥ 0.

SUM

SUM(S)=Σi=1Nsi(returnsNULLifN=0)SUM(S) = Σᵢ₌₁ᴺ sᵢ (returns NULL if N = 0)

Computes the arithmetic sum of all non-NULL values in S. The return type matches the column's numeric type (or a wider promotion — e.g., INTEGER column may produce a BIGINT sum). If every value is NULL, the result is NULL, not 0.

AVG

AVG(S)=(Σi=1Nsi)/N(returnsNULLifN=0)AVG(S) = (Σᵢ₌₁ᴺ sᵢ) / N (returns NULL if N = 0)

Computes the arithmetic mean over non-NULL values. Crucially, N is the count of non-NULL values, not the total row count. Integer division semantics vary by RDBMS: PostgreSQL performs true division, while MySQL truncates unless a DECIMAL cast is applied.

MIN / MAX

MIN(S)=ssuchthat∀si∈S,s≤siMAX(S)=ssuchthat∀si∈S,s≥siMIN(S) = s such that ∀sᵢ ∈ S, s ≤ sᵢ MAX(S) = s such that ∀sᵢ ∈ S, s ≥ sᵢ

Return the minimum or maximum non-NULL value respectively. For numeric columns, the comparison is numeric; for strings, it follows the column's collation; for dates/timestamps, it follows chronological order. Both return NULL on empty input sets.

⚠

Integer Division Trap

In some database systems (e.g., older MySQL configurations, SQL Server with integer columns), AVG(integer_column) performs integer division, silently truncating the decimal portion. To guarantee fractional results, cast the column: AVG(CAST(salary AS DECIMAL(10,2))). PostgreSQL handles this correctly by default, returning a numeric type.

SECTION 5

Detailed Breakdown — NULL Handling & DISTINCT Interactions

The interplay between NULL values and the DISTINCT qualifier creates subtle behavioral differences across the five aggregate functions. The table below provides a comprehensive reference that maps every combination of function, qualifier, and edge condition to its expected result. Study it carefully, as exam questions and real-world debugging scenarios frequently exploit these corner cases.

ExpressionInput MultisetNULLs?Result
COUNT(*){10, 20, NULL, 20}Included4
COUNT(col){10, 20, NULL, 20}Excluded3
COUNT(DISTINCT col){10, 20, NULL, 20}Excluded2
SUM(col){10, 20, NULL, 20}Excluded50
SUM(DISTINCT col){10, 20, NULL, 20}Excluded30
AVG(col){10, 20, NULL, 20}Excluded16.67 (50 ÷ 3)
AVG(DISTINCT col){10, 20, NULL, 20}Excluded15.00 (30 ÷ 2)
MIN(col){10, 20, NULL, 20}Excluded10
MAX(col){10, 20, NULL, 20}Excluded20
SUM(col){NULL, NULL}All NULLNULL (not 0)
COUNT(col){NULL, NULL}All NULL0

Aggregate function results for the multiset {10, 20, NULL, 20} and the all-NULL edge case

NULL Filtering Pipeline in Aggregate FunctionsTracing {10, 20, NULL, 20, NULL, 30} through COUNT(*), COUNT(col), and AVG(col)Raw Input Row Values1020NULL20NULL306 rows total, 2 NULLsCOUNT(*)No NULL filteringCounts all 6 rows= 6COUNT(col)Filters 2 NULLsCounts 4 non-NULL rows= 4AVG(col)Filters 2 NULLsSUM(80) ÷ COUNT(4)= 20.00Key Insight: AVG DenominatorAVG uses COUNT(col) = 4 as its denominator, NOT COUNT(*) = 6Correct: 80 ÷ 4 = 20.00Wrong assumption: 80 ÷ 6 = 13.33 ✗
This pipeline diagram traces the multiset {10, 20, NULL, 20, NULL, 30} through three different aggregate paths. The critical insight is that AVG divides by the count of non-NULL values (4), not the total row count (6), producing 20.00 rather than 13.33.

The second diagram reinforces a point that cannot be overstated: AVG never divides by the total row count. It always divides SUM by the number of non-NULL values. If you want the average to treat NULLs as zeros (perhaps because a missing sales figure means no sale occurred), you must explicitly convert them: AVG(COALESCE(col, 0)). This forces the denominator to equal the total row count by substituting zeros for NULLs before the aggregate computes.

SECTION 6

Worked Example — E-Commerce Order Analytics

Consider an e-commerce database with a table order_items containing the following data. We want to generate a summary report showing the total number of line items, total revenue, average unit price, cheapest item price, and most expensive item price — grouped by product category. We will also need to count how many distinct products appear in each category.

idcategoryproductunit_pricequantity
1ElectronicsKeyboard49.993
2ElectronicsMonitor299.001
3ElectronicsKeyboard49.992
4BooksSQL Guide39.955
5BooksAlgorithms59.991
6BooksSQL Guide39.952

order_items table

Category-Level Aggregate Report

  1. 1

    Step 1 — Identify the Aggregation Groups

    The report requires per-category statistics, so we GROUP BY category. This partitions the six rows into two groups: Electronics (rows 1, 2, 3) and Books (rows 4, 5, 6).
  2. 2

    Step 2 — Write the SELECT with Aggregate Functions

    We construct the query using all five aggregate functions plus a DISTINCT variant: SELECT category, COUNT(*) AS line_items, COUNT(DISTINCT product) AS unique_products, SUM(unit_price * quantity) AS total_revenue, AVG(unit_price) AS avg_unit_price, MIN(unit_price) AS cheapest, MAX(unit_price) AS most_expensive FROM order_items GROUP BY category;
  3. 3

    Step 3 — Compute the Electronics Group

    For the Electronics group (rows 1, 2, 3): COUNT(*) = 3 line items. COUNT(DISTINCT product) = 2 (Keyboard and Monitor). SUM(unit_price × quantity) = (49.99 × 3) + (299.00 × 1) + (49.99 × 2) = 149.97 + 299.00 + 99.98 = 548.95. AVG(unit_price) = (49.99 + 299.00 + 49.99) ÷ 3 = 398.98 ÷ 3 = 132.99. MIN(unit_price) = 49.99. MAX(unit_price) = 299.00.
    Electronics: 3 items, 2 products, $548.95 revenue, $132.99 avg price, $49.99–$299.00 range
  4. 4

    Step 4 — Compute the Books Group

    For the Books group (rows 4, 5, 6): COUNT(*) = 3 line items. COUNT(DISTINCT product) = 2 (SQL Guide and Algorithms). SUM(unit_price × quantity) = (39.95 × 5) + (59.99 × 1) + (39.95 × 2) = 199.75 + 59.99 + 79.90 = 339.64. AVG(unit_price) = (39.95 + 59.99 + 39.95) ÷ 3 = 139.89 ÷ 3 = 46.63. MIN(unit_price) = 39.95. MAX(unit_price) = 59.99.
    Books: 3 items, 2 products, $339.64 revenue, $46.63 avg price, $39.95–$59.99 range
  5. 5

    Step 5 — Interpret and Validate

    The final result set contains two rows — one per category. Notice that AVG(unit_price) computes the average of the unit_price column values, not the weighted average by quantity. If a revenue-weighted average were needed, we would compute SUM(unit_price × quantity) ÷ SUM(quantity). Always verify which average is semantically appropriate for the business question.
    Unweighted avg ≠ weighted avg — always clarify the business requirement

SECTION 7

Common Pitfalls & Function Comparisons

Even experienced developers encounter subtle bugs when using aggregate functions. The following table catalogs the most common pitfalls, contrasting the incorrect assumption with the actual SQL behavior and providing the corrective pattern.

PitfallIncorrect AssumptionActual BehaviorFix
NULL in AVG denominatorAVG divides by total rowsAVG divides by non-NULL countAVG(COALESCE(col, 0))
SUM on empty setSUM returns 0SUM returns NULLCOALESCE(SUM(col), 0)
COUNT(*) vs COUNT(col)They are interchangeableCOUNT(col) excludes NULLsChoose deliberately based on intent
Non-aggregated columnsCan SELECT any column with aggregatesNon-aggregated cols must appear in GROUP BY (standard SQL)Add to GROUP BY or wrap in an aggregate
DISTINCT in multiple aggregatesDISTINCT applies globallyDISTINCT is per-functionCOUNT(DISTINCT a), SUM(DISTINCT b)
WHERE vs HAVINGWHERE can filter on aggregatesAggregates in WHERE cause a syntax errorUse HAVING for post-aggregation filters

Common aggregate function pitfalls with corrections

✦

KEY TAKEAWAY

Think of aggregate functions as instruments in an orchestra: each one plays a distinct role (COUNT tallies, SUM totals, AVG averages, MIN/MAX find extremes), but they all obey the same conductor — the NULL exclusion rule and the GROUP BY clause. When the orchestra plays without a GROUP BY, the entire result set is one ensemble producing one chord (one output row). When GROUP BY is present, the orchestra splits into sections, each producing its own chord. Mastering when to use HAVING (filtering after the music is composed) versus WHERE (filtering before musicians even sit down) is the difference between a coherent query and a cacophony of errors.

SECTION 8

Connection to Advanced Aggregation Concepts

The five basic aggregate functions serve as the foundation upon which SQL's more advanced analytical capabilities are built. Understanding COUNT, SUM, AVG, MIN, and MAX thoroughly is a prerequisite for window functions, OLAP extensions, and user-defined aggregates. The table below maps each basic aggregate concept to its advanced counterpart, illustrating how the same mathematical operations are reused in progressively more powerful contexts.

Basic ConceptAdvanced ExtensionKey Difference
SUM(col) ... GROUP BYSUM(col) OVER (PARTITION BY ...)Window function: computes SUM per partition without collapsing rows; every input row is preserved with the aggregate appended
COUNT(*) ... GROUP BYCOUNT(*) OVER (ORDER BY ... ROWS ...)Running count: window frame defines a sliding range; enables cumulative or moving counts
GROUP BY single levelGROUP BY ROLLUP(a, b)Produces subtotals and grand totals automatically; generates additional grouping sets with NULL placeholders
Five built-in aggregatesUser-Defined Aggregate Functions (UDAFs)Custom accumulation logic (e.g., geometric mean, median) using RDBMS extension APIs (CREATE AGGREGATE in PostgreSQL)
HAVING COUNT(*) > nQUALIFY ROW_NUMBER() OVER (...) = 1QUALIFY (available in some dialects) filters on window function results, analogous to HAVING for window aggregates

Mapping basic aggregation concepts to advanced SQL features

The conceptual leap from basic aggregates to window functions is arguably the most important transition in intermediate SQL. Window functions reuse the exact same five aggregate operations but alter the scope of computation: instead of collapsing an entire group into one row, they compute the aggregate over a defined window frame and attach the result to every row in the partition. Once you have internalized how SUM, AVG, and COUNT behave with GROUP BY — particularly their NULL handling and DISTINCT semantics — extending that understanding to OVER clauses and frame specifications becomes a natural progression rather than a conceptual hurdle.

SECTION 9

Practice Problems

Question 1: CONCEPTUAL

A table reviews has 100 rows. The rating column contains 15 NULL values. Without running a query, determine the results of COUNT(*), COUNT(rating), and explain why they differ.
Show answer
COUNT() returns 100 because it counts all rows regardless of column values — it tests for row existence, not column non-nullity. COUNT(rating) returns 85 because it excludes the 15 rows where rating is NULL, counting only those rows where the specified column holds a non-NULL value. The difference arises from the SQL standard's NULL exclusion rule: all aggregate functions except COUNT() silently discard NULLs before computation. This distinction is critical in production code because choosing the wrong COUNT variant can lead to incorrect denominators in percentage calculations or misleading dashboard metrics.

Question 2: BASIC CALCULATION

Given a table employees with salaries {50000, 60000, NULL, 75000, 60000, NULL, 90000}, write a single SELECT statement that returns the total salary expenditure, the average salary, the number of employees, and the number of employees with recorded salaries. What value does AVG return?
Show answer
The query is: SELECT COUNT() AS total_emps, COUNT(salary) AS emps_with_salary, SUM(salary) AS total_salary, AVG(salary) AS avg_salary FROM employees; COUNT() = 7 (all rows). COUNT(salary) = 5 (excludes 2 NULLs). SUM(salary) = 50000 + 60000 + 75000 + 60000 + 90000 = 335000. AVG(salary) = 335000 ÷ 5 = 67000.00. Note that AVG divides by 5 (the non-NULL count), not by 7 (the total row count). If the business intent is to treat missing salaries as zero, use AVG(COALESCE(salary, 0)) which yields 335000 ÷ 7 ≈ 47857.14.

Question 3: INTERMEDIATE

A transactions table has columns store_id, customer_id, and amount. Write a query to find all stores that have more than 50 unique customers and whose average transaction amount exceeds $100. Which clause (WHERE or HAVING) is appropriate for filtering on aggregate results, and why?
Show answer
The correct query is: SELECT store_id, COUNT(DISTINCT customer_id) AS unique_customers, AVG(amount) AS avg_amount FROM transactions GROUP BY store_id HAVING COUNT(DISTINCT customer_id) > 50 AND AVG(amount) > 100; The HAVING clause must be used because WHERE is evaluated before aggregation — it filters individual rows before groups are formed, so it cannot reference aggregate expressions. HAVING is evaluated after GROUP BY has partitioned the rows and aggregate functions have computed their results. Attempting to write WHERE COUNT(DISTINCT customer_id) > 50 would produce a syntax error. In the logical query processing order (FROM → WHERE → GROUP BY → HAVING → SELECT → ORDER BY), HAVING occupies the post-aggregation filter position.

Question 4: APPLIED

A data engineering team discovers that their daily revenue dashboard shows an average order value of $85.20, but the finance team manually calculates it as $71.00. Upon investigation, the orders table has a total column with 20% of rows containing NULL (cancelled orders). The dashboard uses AVG(total). Explain the discrepancy and propose two alternative queries — one matching each team's interpretation.
Show answer
The discrepancy arises because AVG(total) excludes the 20% NULL rows from its denominator, dividing the sum only by the 80% of orders that have a non-NULL total. The finance team presumably includes cancelled orders (counted as $0 revenue) in their denominator, dividing total revenue by all orders. Dashboard query (current): SELECT AVG(total) FROM orders; — this yields $85.20 because NULLs are excluded. Finance-aligned query: SELECT AVG(COALESCE(total, 0)) FROM orders; — this replaces NULLs with 0 before averaging, producing $71.00 (since 85.20 × 0.80 ≈ 71.00 when divided by all orders). Alternatively: SELECT SUM(total) / COUNT() FROM orders; achieves the same result because SUM excludes NULLs while COUNT() includes all rows. Neither interpretation is inherently wrong — the choice depends on whether cancelled orders should reduce the average. This scenario underscores why documenting NULL semantics in data dictionaries is essential.

Question 5: CRITICAL THINKING

Prove or disprove: for any table T with a numeric column c, the following identity always holds: AVG(c) = SUM(c) / COUNT(c). Then consider whether AVG(c) = SUM(c) / COUNT(*) holds. Finally, discuss under what conditions both expressions yield identical results and what this implies about schema design.
Show answer
The identity AVG(c) = SUM(c) / COUNT(c) holds in all cases by definition: the SQL standard defines AVG as the sum of non-NULL values divided by the count of non-NULL values, and both SUM(c) and COUNT(c) independently exclude NULLs, so the quotient is mathematically equivalent. However, AVG(c) = SUM(c) / COUNT() does NOT hold in general. COUNT() includes rows where c is NULL, inflating the denominator. The two expressions are equal if and only if the column c contains zero NULL values — i.e., c has a NOT NULL constraint. This has a direct schema design implication: applying NOT NULL constraints to numeric columns that participate in AVG calculations eliminates an entire class of semantic ambiguity. When NULLs are semantically meaningful (e.g., 'measurement not taken' vs. 'measurement was zero'), the column should remain nullable, but all aggregate queries must explicitly document which denominator is intended. In practice, wrapping the column in COALESCE or using FILTER (WHERE c IS NOT NULL) syntax in PostgreSQL makes the intent explicit and self-documenting.

SUMMARY

Lesson Summary

Summary

SQL provides five foundational aggregate functions — COUNT, SUM, AVG, MIN, and MAX — that transform multisets of row-level values into single scalar results. All five functions except COUNT(*) silently exclude NULL values before computation. AVG computes SUM divided by the non-NULL count, not the total row count — a distinction that is the single most common source of aggregate-related bugs in production systems.

Use DISTINCT inside any aggregate to de-duplicate before computing, and remember that on empty sets, COUNT returns 0 while all other aggregates return NULL. The GROUP BY clause partitions rows into groups and produces one aggregate result per group, while HAVING filters groups after aggregation — in contrast to WHERE, which filters individual rows before groups are formed. These five functions are the gateway to window functions, ROLLUP, CUBE, and user-defined aggregates — every advanced SQL analytics feature builds directly upon the semantics mastered in this lesson.

Varsity Tutors • SQL • Basic Aggregate Functions — Use COUNT, SUM, AVG, MIN, MAX correctly