SQL • AGGREGATION AND GROUPING

COUNT(*) vs. COUNT(column) — Understand COUNT(*) vs COUNT(column) with NULLs

Master the subtle but critical difference between counting rows and counting non-NULL values in SQL aggregate queries.

Historical Context & Motivation

The distinction between COUNT(*) and COUNT(column) traces its roots to the very foundations of relational database theory and the design of SQL as a declarative language. When Edgar F. Codd introduced the relational model in 1970, he formalized the notion that data should be organized into relations (tables) whose cells might be missing or inapplicable — a concept later embodied by the special marker NULL. Because NULL is not a value but rather a marker indicating the absence of a value, aggregate functions needed well-defined semantics for handling it, and this is precisely where COUNT(*) and COUNT(column) diverge.

1970
Codd's Relational Model
Edgar F. Codd publishes "A Relational Model of Data for Large Shared Data Banks," introducing NULLs as markers for missing information within relations.
1974
SEQUEL / System R
IBM researchers Chamberlin and Boyce design SEQUEL (later SQL) with aggregate functions including COUNT, establishing that aggregates should ignore NULLs — except for COUNT(*).
1986
SQL-86 (ANSI Standard)
The first ANSI SQL standard formally codifies COUNT(*) as counting rows and COUNT(expression) as counting non-NULL evaluations, making the distinction part of the official specification.
1992
SQL-92 Refinement
SQL-92 extends three-valued logic (TRUE, FALSE, UNKNOWN) and clarifies NULL propagation rules across all aggregate functions, reinforcing COUNT's dual behavior.
2003–Present
Modern SQL Standards
SQL:2003 through SQL:2023 retain the original COUNT semantics unchanged, confirming that this distinction is a permanent, foundational feature of the language.

The question this lesson addresses is deceptively simple: when you write SELECT COUNT(...) FROM table, what exactly gets counted, and how does the presence of NULL values in your data affect the result? Misunderstanding this distinction is one of the most common sources of subtle bugs in SQL queries — bugs that pass cursory testing but produce silently incorrect results in production when real-world data contains missing values.

Core Principles & Definitions

To understand why COUNT(*) and COUNT(column) behave differently, you need to internalize three foundational ideas about SQL's data model: the nature of NULL, the semantics of aggregate functions, and the special status of the asterisk in COUNT.

1

NULL Is Not a Value

NULL represents the absence of a value. It is not zero, not an empty string, and not false. Any comparison with NULL — even NULL = NULL — evaluates to UNKNOWN under SQL's three-valued logic.
2

Aggregates Ignore NULLs (by Default)

The SQL standard mandates that aggregate functions — SUM, AVG, MIN, MAX, and COUNT(expression) — silently discard NULL values before performing their computation. This is not a quirk; it is by design.
3

COUNT(*) Counts Rows

The asterisk in COUNT(*) does not reference any column. It tells the engine to count the number of rows in the group (or the entire result set), regardless of NULL values in any column.
4

COUNT(column) Counts Non-NULLs

When you pass a column name (or expression) to COUNT, it evaluates that expression for each row and increments the counter only when the result is not NULL. Rows where the expression yields NULL are excluded from the count.
5

COUNT(DISTINCT column)

Adding the DISTINCT keyword causes COUNT to count only unique non-NULL values. Duplicate values are collapsed, and NULLs are still excluded.
KEY TAKEAWAY
Think of COUNT(*) as counting the chairs at a table — every seat is counted whether someone is sitting in it or not. COUNT(column) is like counting the people sitting down — empty chairs (NULLs) are ignored. This analogy maps directly: a row is a chair, and a non-NULL column value is an occupied seat.

Visual Explanation

The following diagram illustrates how COUNT(*) and COUNT(column) operate on the same table. Notice how COUNT(*) processes every row in the result set, while COUNT(email) skips rows where the email column is NULL. The visual makes the discrepancy immediately clear.

The left panel shows the raw users table with two NULL email values (rows 2 and 4). The center panel demonstrates that COUNT(*) includes all five rows, while the right panel shows COUNT(email) skipping the two NULL entries, yielding 3.

As the diagram makes clear, the difference is not about the query syntax but about what unit is being counted. COUNT(*) operates at the row level — it asks, "How many rows exist in this group?" — while COUNT(column) operates at the value level — it asks, "How many non-NULL values exist in this column within this group?" When a table has no NULLs in the specified column, both forms return the same number, which is precisely why many developers never realize they are different until a production bug surfaces.

How the Database Engine Processes COUNT

Understanding the internal processing model clarifies why COUNT(*) and COUNT(column) yield different results. Although the SQL standard specifies behavior declaratively, most query engines implement aggregation through an accumulator pattern that processes rows one at a time (or in batches in columnar engines). The following pseudocode captures the semantic difference.

Pseudocode: COUNT(*)

COUNT(*) SEMANTICS
accumulator ← 0 ; FOR EACH row IN group : accumulator ← accumulator + 1 ; RETURN accumulator
The loop body executes unconditionally for every row in the group. No column is evaluated, so NULLs are irrelevant.

Pseudocode: COUNT(column)

COUNT(column) SEMANTICS
accumulator ← 0 ; FOR EACH row IN group : IF row.column IS NOT NULL : accumulator ← accumulator + 1 ; RETURN accumulator
The conditional IS NOT NULL check filters out rows where the column contains NULL. Only non-NULL values increment the counter.

Formal Relationship

IDENTITY
COUNT(*) = COUNT(column) + COUNT_NULL(column)
Where COUNT_NULL(column) is the number of rows where column IS NULL. This identity holds for any column and any group. In SQL, you can compute COUNT_NULL as COUNT(*) − COUNT(column) — a useful technique for data quality auditing.
Performance Note
In many modern database engines (PostgreSQL, MySQL InnoDB, SQL Server), COUNT(*) can be optimized to use an index-only scan or even metadata-based counts, since it does not need to read any column values. COUNT(column) may require reading the actual column data to check for NULLs, which can be slower on large tables — especially if the column is not indexed.

Detailed Breakdown of COUNT Variants

SQL provides several forms of COUNT, and their behavior with NULLs and duplicates varies. The following table provides a comprehensive comparison, and the accompanying diagram illustrates how each variant filters a sample dataset differently.

Comparison of COUNT variants in standard SQL
FormCountsIgnores NULLs?Ignores Duplicates?
COUNT(*)All rows in the groupNoNo
COUNT(column)Non-NULL values in columnYesNo
COUNT(DISTINCT column)Unique non-NULL valuesYesYes
COUNT(expression)Non-NULL evaluationsYesNo
COUNT(1)All rows (literal never NULL)N/A — literal is never NULLNo
A filtering pipeline showing how the same six rows pass through three COUNT variants. COUNT(*) retains all six, COUNT(status) removes two NULLs to yield 4, and COUNT(DISTINCT status) further deduplicates to yield 2 unique values ('active' and 'paused').
💡 COUNT(1) vs. COUNT(*)
You will often see COUNT(1) used as a synonym for COUNT(*). Since the literal 1 is never NULL, the IS NOT NULL check always passes, so it produces the same result as COUNT(*). Modern query optimizers recognize this equivalence and generate identical execution plans, so there is no performance difference — it is purely a stylistic choice.

Worked Example: Sales Report with Missing Data

Consider a scenario where you manage an orders table tracking e-commerce transactions. Some orders have been placed but not yet assigned to a salesperson, resulting in NULL values in the salesperson_id column. Your task is to generate a report showing total orders, orders with an assigned salesperson, and the percentage of unassigned orders — grouped by region.

Sample orders table
order_idregionsalesperson_idamount
101EastS01250.00
102EastNULL180.00
103WestS03320.00
104WestNULL410.00
105WestNULL95.00
106EastS02540.00
Generating the Sales Assignment Report
1
Step 1 — Identify the QuestionWe need three metrics per region: total orders, assigned orders (non-NULL salesperson_id), and the percentage of unassigned orders. This requires using both COUNT(*) and COUNT(salesperson_id) within the same query.
2
Step 2 — Write the QueryWe group by region and use the two COUNT forms together: SELECT region, COUNT(*) AS total_orders, COUNT(salesperson_id) AS assigned_orders, COUNT(*) - COUNT(salesperson_id) AS unassigned_orders, ROUND( 100.0 * (COUNT(*) - COUNT(salesperson_id)) / COUNT(*), 1 ) AS pct_unassigned FROM orders GROUP BY region;
3
Step 3 — Trace Execution for 'East'The East region contains rows 101, 102, and 106. COUNT(*) counts all three rows → 3. COUNT(salesperson_id) skips row 102 (NULL) and counts rows 101 and 106 → 2. Therefore, unassigned = 3 − 2 = 1, and pct_unassigned = 100.0 × 1 / 3 ≈ 33.3%.
East: total = 3, assigned = 2, unassigned = 1, pct = 33.3%
4
Step 4 — Trace Execution for 'West'The West region contains rows 103, 104, and 105. COUNT(*) → 3. COUNT(salesperson_id) skips rows 104 and 105 (both NULL) → 1. Unassigned = 3 − 1 = 2, pct = 100.0 × 2 / 3 ≈ 66.7%.
West: total = 3, assigned = 1, unassigned = 2, pct = 66.7%
5
Step 5 — Interpret the Final Result SetThe query reveals that the West region has a significantly higher proportion of unassigned orders (66.7% vs. 33.3%), suggesting it may need additional salesperson resources. This insight was only possible because we leveraged the difference between COUNT(*) and COUNT(salesperson_id) — using COUNT(*) alone would have hidden the data quality issue entirely.
Key pattern: COUNT(*) − COUNT(column) = count of NULLs in that column

Common Pitfalls & Best Practices

The COUNT(*) vs. COUNT(column) distinction is a frequent source of production bugs. Understanding common pitfalls and established best practices will help you write correct, intentional SQL rather than accidentally correct SQL that breaks when data patterns change.

Common pitfalls and recommended practices
Pitfall / PracticeProblemSolution
Assuming COUNT(col) = row countIf the column gains NULLs later (schema change, new data source), the count silently drops below the true row count.Use COUNT(*) when you genuinely want the row count. Use COUNT(col) only when you intentionally want non-NULL values.
Incorrect AVG via SUM/COUNT(*)Computing SUM(col)/COUNT(*) divides by total rows, but SUM already ignores NULLs — yielding a denominator mismatch and a deflated average.Use AVG(col) directly, or use SUM(col)/COUNT(col) so both numerator and denominator exclude NULLs.
LEFT JOIN inflating COUNT(*)After a LEFT JOIN, unmatched rows produce NULLs in the right table's columns. COUNT(*) counts these 'phantom' rows; COUNT(right.col) correctly excludes them.After outer joins, use COUNT(right_table.primary_key) to count only successfully matched rows.
HAVING COUNT(col) = 0 confusionHAVING COUNT(col) = 0 matches groups where all values are NULL, not groups with zero rows (which do not appear in GROUP BY output at all).Be explicit about intent. If looking for empty groups, consider a LEFT JOIN from a dimension table.
Data quality auditingNot realizing you can use the difference between COUNT(*) and COUNT(col) to detect NULL prevalence.Use COUNT(*) − COUNT(col) to measure NULL counts, and 100.0 × (COUNT(*) − COUNT(col)) / COUNT(*) for NULL percentage per column.
🎯 BEST PRACTICE RULE
Always choose the COUNT form that matches your semantic intent. If you want to know "how many rows are in this group," write COUNT(*). If you want to know "how many rows have a non-NULL value for this attribute," write COUNT(column). Treat this like choosing between == and === in JavaScript — the stricter form prevents a class of subtle bugs, and you should always use the one that expresses your actual intent.

Connection to Advanced SQL Concepts

The NULL-handling behavior of COUNT is not an isolated feature — it reflects a broader design philosophy in SQL that extends to window functions, conditional aggregation, and analytical queries. Understanding COUNT's behavior with NULLs prepares you for these more advanced patterns, where the same principles apply but the stakes are higher.

How COUNT concepts extend to advanced SQL features
Basic ConceptAdvanced ExtensionNULL Behavior
COUNT(*) with GROUP BYCOUNT(*) OVER (PARTITION BY ...)Identical: counts all rows in each partition, ignoring NULLs in all columns.
COUNT(col)COUNT(CASE WHEN cond THEN col END)Conditional aggregation: CASE returns NULL when the condition is false, so COUNT naturally skips non-matching rows.
COUNT(DISTINCT col)APPROX_COUNT_DISTINCT(col)HyperLogLog-based approximation in systems like BigQuery, Presto, and Spark. NULLs are still excluded.
NULL detection via COUNTFILTER (WHERE ...) clause (SQL:2003)PostgreSQL/Spark's FILTER clause is a cleaner alternative to CASE-based conditional counts.

One particularly powerful advanced pattern is conditional counting using CASE expressions inside COUNT. For example, COUNT(CASE WHEN status = 'active' THEN 1 END) counts only active rows because the CASE expression returns NULL for non-active rows, and COUNT skips those NULLs. This technique, sometimes called a pivot via aggregation, is fundamental to building cross-tabulation queries and dynamic reports, and it relies entirely on the NULL-skipping behavior of COUNT(expression) that you have now mastered.

Practice Problems

The following problems test your understanding of COUNT(*), COUNT(column), and their interaction with NULLs. Work through each one before checking the answer — these are the kinds of questions that appear on database exams and in technical interviews.

PROBLEM 1CONCEPTUAL
A table students has 100 rows. The column gpa contains NULL for 15 students who have not yet received grades. What values do SELECT COUNT(*), COUNT(gpa) FROM students return, and why?
PROBLEM 2BASIC CALCULATION
Given a table products with columns id, name, discount_pct, write a query that returns the total number of products and the number of products that have a discount applied (non-NULL discount_pct).
PROBLEM 3INTERMEDIATE
A developer computes the average score using SELECT SUM(score) / COUNT(*) FROM exams instead of SELECT AVG(score) FROM exams. The table has 50 rows, 10 of which have NULL in the score column. Explain the discrepancy and compute both results if SUM of non-NULL scores is 3200.
PROBLEM 4APPLIED
You have a customers table and an orders table. You perform SELECT c.region, COUNT(*), COUNT(o.order_id) FROM customers c LEFT JOIN orders o ON c.id = o.customer_id GROUP BY c.region. Region 'South' has 5 customers, but only 3 have placed orders (total 7 orders among them). What does each COUNT return for the 'South' group, and why?
PROBLEM 5CRITICAL THINKING
Design a single SQL query that, for each column in a table survey_responses with columns q1, q2, q3, q4, q5, returns the NULL percentage for each question. Discuss how this pattern generalizes to data quality monitoring and why COUNT(*) serves as the invariant denominator.

Lesson Summary

COUNT(*) counts all rows in a group regardless of NULL values in any column — it answers the question "how many records exist?" In contrast, COUNT(column) counts only the rows where the specified column is not NULL — it answers "how many rows have a value for this attribute?" The identity COUNT(*) = COUNT(column) + number of NULLs always holds and can be exploited for data quality auditing.

Key practical implications include: avoid using SUM(col)/COUNT(*) as a manual average (use AVG or SUM/COUNT(col) instead); after a LEFT JOIN, use COUNT(right_table.pk) to count matched rows rather than COUNT(*); and leverage COUNT(DISTINCT column) when you need unique non-NULL values. Always choose the COUNT form that expresses your semantic intent explicitly — this prevents subtle bugs when data patterns evolve over time.

Varsity Tutors • SQL • COUNT(*) vs. COUNT(column)