Tag: Aggregate Functions

  • COUNT, SUM, AVG in SQL Server: Turning Rows Into Insight

    COUNT, SUM, AVG in SQL Server: Turning Rows Into Insight

    Everything so far has returned individual rows. Aggregate functions are the first tool that collapses many rows into one summary value — the foundation of every report, dashboard, and “how many / how much / on average” question you’ll ever answer with SQL.

    Turning Rows Into Insight(many rows → one number)COUNT(*)3 rows — always a real numberSUM(funding_usd)$45,000,000 totalAVG(employees)65 — mean team sizeCOUNT(*) vs COUNT(column) — not the same question:COUNT(*)counts every row — NULLs includedacquired_by is NULL? still +1COUNT(acquired_by)only counts non-NULL valueshere: just 1 (PayFlow)SUM / AVG / MIN / MAX return NULLon an empty group — not 0. COUNT(*)is the one exception.

    Setup — a small startup funding dataset to follow along:

    CREATE TABLE dbo.Startup (
        startup_id    INT IDENTITY(1,1) PRIMARY KEY,
        name          NVARCHAR(100) NOT NULL,
        industry      NVARCHAR(50)  NOT NULL,
        founded_year  INT           NOT NULL,
        funding_usd   DECIMAL(15,2) NOT NULL,
        employees     INT           NOT NULL
    );
    INSERT INTO dbo.Startup (name, industry, founded_year, funding_usd, employees) VALUES
    ('DataForge', 'AI', 2019, 12000000, 45),
    ('CloudNest', 'Cloud Infra', 2018, 25000000, 120),
    ('PayFlow', 'Fintech', 2020, 8000000, 30);

    The Five Core Aggregate Functions

    SELECT COUNT(*) AS total_startups FROM dbo.Startup;
    SELECT SUM(funding_usd) AS total_funding FROM dbo.Startup;
    SELECT AVG(employees) AS avg_team_size FROM dbo.Startup;
    SELECT MIN(founded_year) AS oldest, MAX(founded_year) AS newest FROM dbo.Startup;

    COUNT(*) vs COUNT(column): Not the Same Thing

    COUNT(*) Counts every row, regardless of NULLs COUNT(column) Only counts rows where that column is NOT NULL

    -- Add a nullable column to see the difference for real
    ALTER TABLE dbo.Startup ADD acquired_by NVARCHAR(100) NULL;
    UPDATE dbo.Startup SET acquired_by = 'BigTech Corp' WHERE name = 'PayFlow';
    
    SELECT COUNT(*) AS total_rows,               -- 3 (every row counts)
           COUNT(acquired_by) AS acquired_count  -- 1 (only non-NULL values count)
    FROM dbo.Startup;

    These can give genuinely different answers on the same table. Always be deliberate about which one you actually mean — “how many rows” and “how many rows have a value here” are different questions, and mixing them up silently produces a wrong number, not an error.

    COUNT(DISTINCT …): A Third Variant

    SELECT COUNT(DISTINCT industry) AS unique_industries FROM dbo.Startup;

    Counts how many distinct non-NULL values appear, not how many rows — useful for “how many different X do we have,” as opposed to “how many rows mention X.”

    Aggregates and NULL: The Result Isn’t Always 0

    -- On an empty result set (e.g. after a WHERE that matches nothing):
    SELECT SUM(funding_usd) FROM dbo.Startup WHERE industry = 'Biotech'; -- returns NULL, not 0
    SELECT COUNT(*) FROM dbo.Startup WHERE industry = 'Biotech';         -- returns 0, correctly
    Common mistake: Assuming SUM/AVG/MIN/MAX return 0 when there’s nothing to aggregate. They return NULL — which then silently propagates into any further arithmetic (NULL + 100 is still NULL). Wrap the result in ISNULL(SUM(funding_usd), 0) if a report genuinely needs to show zero rather than blank for an empty group. COUNT(*) is the one exception — it always returns a real number, since “count of nothing” is meaningfully zero.

    Combining Multiple Aggregates in One Query

    SELECT
        COUNT(*) AS total_startups,
        SUM(funding_usd) AS total_funding,
        AVG(funding_usd) AS avg_funding,
        MAX(funding_usd) AS biggest_round,
        MIN(founded_year) AS oldest_founding_year
    FROM dbo.Startup;

    Every aggregate in a single SELECT (with no GROUP BY) computes over the same full set of matching rows — this is a common, efficient way to build a one-row “summary card” for a dashboard in a single round trip.

    Key Takeaways

    • COUNT, SUM, AVG, MIN, MAX collapse many rows into one summary value
    • COUNT(*) counts rows; COUNT(column) counts non-NULL values in that column specifically; COUNT(DISTINCT column) counts unique non-NULL values
    • Aggregate functions (other than COUNT(*)) return NULL, not 0, when there’s nothing to aggregate — wrap in ISNULL/COALESCE if you need a real zero
    Practice tip: Add two more startups to the table with different industries, then write a single query returning the total count, total funding, and average team size — from memory, without re-reading the examples above.

    Enjoyed this?

    Subscribe to get every new SQL Server lesson as soon as it’s published, and share it with a developer who’d find it useful.

    📡 Subscribe via RSS  | 
    Share on X  | 
    Share on LinkedIn  | 
    Share on Facebook

    Want the full structured course with quizzes, projects, and 10+ exercises per chapter? Check out SQL Server Fundamentals, coming soon on this site.

  • GROUP BY and HAVING in SQL Server: The Rule That Trips Everyone Up

    GROUP BY and HAVING in SQL Server: The Rule That Trips Everyone Up

    Aggregate functions get far more useful once you can compute them per-group instead of across the whole table — “total funding” is fine, but “total funding per industry” is what a real report actually needs. This is also the lesson where a genuinely confusing rule shows up for the first time, so we’ll spend real time on why it exists, not just what it says.

    Rows → Bins → Filter(GROUP BY sorts, HAVING filters what’s left)6 raw rowsGROUP BY industryAI3 startupsCloud Infra2 startupsFintech1 startupHAVING COUNT(*) > 1 — only bins with more than one row survivekeptkeptdropped (only 1)HAVING filters GROUPS, not rows.WHERE can’t see COUNT(*) — it runsbefore grouping even happens.

    One Row Per Group

    SELECT industry, COUNT(*) AS startup_count, AVG(funding_usd) AS avg_funding
    FROM dbo.Startup
    GROUP BY industry
    ORDER BY avg_funding DESC;

    Instead of one row per startup, you get one row per distinct industry value, each with its own COUNT and AVG computed only from the rows in that group.

    Grouping by More Than One Column

    SELECT industry, founded_year, COUNT(*) AS count_that_year
    FROM dbo.Startup
    GROUP BY industry, founded_year;

    Each unique combination of industry and founded_year becomes its own group — grouping by more columns always produces more (or equal), never fewer, groups than grouping by one.

    HAVING: Filtering Groups, Not Rows

    SELECT industry, SUM(funding_usd) AS total_funding
    FROM dbo.Startup
    GROUP BY industry
    HAVING SUM(funding_usd) > 15000000;

    The Pipeline, Visualized

    WHERE (rows) GROUP BY (collapse) HAVING (groups) ORDER BY

    This ordering (an extension of the FROM → WHERE → SELECT → ORDER BY pipeline from Chapter 3) is the key to understanding both HAVING and the SELECT-column rule below: WHERE filters raw rows before grouping happens; HAVING filters the already-formed groups after. They operate on fundamentally different things, which is exactly why you need both, and why they can’t substitute for each other.

    The Rule That Trips Everyone Up

    Every column in SELECT must either be in the GROUP BY list, or wrapped in an aggregate function:

    -- FAILS: name is neither grouped nor aggregated
    SELECT name, industry, AVG(funding_usd) FROM dbo.Startup GROUP BY industry;
    -- Msg 8120: Column 'dbo.Startup.name' is invalid in the select list because it is
    -- not contained in either an aggregate function or the GROUP BY clause.

    Once rows collapse into groups, SQL Server has no single value to show for name — there could be several startups per industry, each with a different name. It has no way to know which one you want, so it refuses to guess. This is arguably the most useful error message to truly understand in this entire chapter, because the fix is always one of exactly two options:

    -- Option 1: add the column to GROUP BY (now every industry+name combo is its own group)
    SELECT name, industry, AVG(funding_usd) FROM dbo.Startup GROUP BY name, industry;
    
    -- Option 2: aggregate it instead (pick one representative name per group, e.g. the first alphabetically)
    SELECT industry, MIN(name) AS a_sample_name, AVG(funding_usd) FROM dbo.Startup GROUP BY industry;

    Why WHERE Can’t Filter on an Aggregate

    -- FAILS: WHERE runs before GROUP BY, so COUNT(*) doesn't exist yet at that point
    SELECT industry, COUNT(*) AS c FROM dbo.Startup WHERE COUNT(*) > 2 GROUP BY industry;
    -- Msg 147: An aggregate may not appear in the WHERE clause
    
    -- HAVING runs after grouping, so the aggregate genuinely exists by then:
    SELECT industry, COUNT(*) AS c FROM dbo.Startup GROUP BY industry HAVING COUNT(*) > 2;

    Once you internalize the logical order — rows are filtered (WHERE), then collapsed into groups (GROUP BY), and only then do aggregate values exist to filter on (HAVING) — both of these rules stop feeling arbitrary and start feeling obvious.

    Combining WHERE and HAVING in the Same Query

    -- WHERE excludes rows before grouping; HAVING excludes groups after
    SELECT industry, AVG(funding_usd) AS avg_funding
    FROM dbo.Startup
    WHERE founded_year >= 2019       -- only consider startups founded 2019+
    GROUP BY industry
    HAVING AVG(funding_usd) > 5000000; -- then only show industries averaging over $5M
    Practice tip: Whenever a GROUP BY query throws “invalid in the select list,” resist the urge to just add the offending column to GROUP BY without thinking — first ask whether that actually matches the report you’re trying to build, or whether you really meant to aggregate it instead. Adding the wrong column to GROUP BY silently changes what a “group” even means.

    Enjoyed this?

    Subscribe to get every new SQL Server lesson as soon as it’s published, and share it with a developer who’d find it useful.

    📡 Subscribe via RSS  | 
    Share on X  | 
    Share on LinkedIn  | 
    Share on Facebook

    Want the full structured course with quizzes, projects, and 10+ exercises per chapter? Check out SQL Server Fundamentals, coming soon on this site.