Surfalytics
SQL course overview

Basics of selection I · Topic 10 Free

Grouping: GROUP BY, aggregates, HAVING

Data model — the tables you will query
N 1 N 1 N 1 N 1 cohorts id PK INT name TEXT start_date DATE format TEXT students id PK INT name TEXT city TEXT country TEXT cohort_id FK INT background TEXT joined_at DATE courses id PK INT title TEXT category TEXT difficulty TEXT hours INT enrollments id PK INT student_id FK INT course_id FK INT enrolled_at DATE completed_at DATE score INT job_offers id PK INT student_id FK INT company TEXT role TEXT city TEXT salary_usd INT remote BOOLEAN offer_date DATE

How the tables join

Relationship Cardinality Join condition
students.cohort_id → cohorts.id many students → one cohort JOIN cohorts ON students.cohort_id = cohorts.id
enrollments.student_id → students.id many enrollments → one student JOIN students ON enrollments.student_id = students.id
enrollments.course_id → courses.id many enrollments → one course JOIN courses ON enrollments.course_id = courses.id
job_offers.student_id → students.id many job offers → one student JOIN students ON job_offers.student_id = students.id

In short: each student belongs to one cohort. Students enroll in courses through enrollments, and their job offers land in job_offers.

So far every query returned individual rows. Analytics is mostly about summaries: totals, averages, counts per group. That is GROUP BY.

Aggregate functions

An aggregate collapses many rows into one value:

FunctionReturns
COUNT(*)number of rows
COUNT(col)number of non-NULL values
COUNT(DISTINCT col)number of unique values
SUM(col)total
AVG(col)average
MIN(col) / MAX(col)smallest / largest

Without grouping, an aggregate summarizes the whole table:

SELECT COUNT(*) AS offers, AVG(salary_usd) AS avg_salary
FROM job_offers;

Note: aggregates skip NULLs. AVG(score) averages only completed enrollments.

GROUP BY

GROUP BY splits rows into groups and runs the aggregate per group:

SELECT country, COUNT(*) AS student_count
FROM students
GROUP BY country;

The golden rule: every column in SELECT must be either inside an aggregate or listed in GROUP BY. Postgres enforces this — the error message will become an old friend.

Group by several columns to get finer groups:

SELECT category, difficulty, COUNT(*)
FROM courses
GROUP BY category, difficulty;

HAVING: filtering groups

WHERE filters rows before grouping. It cannot use aggregates. To filter the groups themselves, use HAVING:

SELECT category, COUNT(*) AS course_count
FROM courses
GROUP BY category
HAVING COUNT(*) > 2;

Both can appear in one query — and now you know the full order of clauses:

SELECT city, AVG(salary_usd) AS avg_salary
FROM job_offers
WHERE remote = false          -- 1. filter rows
GROUP BY city                 -- 2. group them
HAVING COUNT(*) >= 2          -- 3. filter groups
ORDER BY avg_salary DESC      -- 4. sort
LIMIT 5;                      -- 5. cut

This single shape — filter, group, filter groups, sort, limit — answers a huge share of real business questions. Congratulations: you have finished Basics of selection I.

Practice

A real Postgres database runs in your browser. Solve the tasks, or just experiment.