Chapter 6: Filtering, Sorting, and Aggregation
Now that you know the basic CRUD operations, let's master the tools for asking precise questions of your data.
WHERE — Filtering Rows
Comparison Operators
SELECT * FROM users WHERE age = 30; -- equals
SELECT * FROM users WHERE age != 30; -- not equals (also: <>)
SELECT * FROM users WHERE age > 25; -- greater than
SELECT * FROM users WHERE age >= 25; -- greater or equal
SELECT * FROM users WHERE age BETWEEN 20 AND 30; -- inclusive range
Logical Operators
-- AND: both conditions must be true
SELECT * FROM users WHERE age > 25 AND name LIKE 'A%';
-- OR: either condition
SELECT * FROM users WHERE age < 20 OR age > 60;
-- NOT: negate
SELECT * FROM users WHERE NOT (age BETWEEN 20 AND 30);
-- IN: match any value in a list
SELECT * FROM users WHERE country IN ('Sweden', 'Norway', 'Finland');
-- IS NULL / IS NOT NULL
SELECT * FROM users WHERE phone IS NOT NULL;
Pattern Matching
-- LIKE: simple patterns (% = any chars, _ = one char)
SELECT * FROM users WHERE name LIKE 'A%'; -- starts with A
SELECT * FROM users WHERE email LIKE '%@gmail.com'; -- ends with
SELECT * FROM users WHERE name LIKE '_ob'; -- 3 chars ending in "ob"
-- ILIKE: case-insensitive (PostgreSQL)
SELECT * FROM users WHERE name ILIKE 'alice'; -- matches Alice, ALICE, etc.
ORDER BY — Sorting Results
-- Sort ascending (default)
SELECT * FROM users ORDER BY name;
-- Sort descending
SELECT * FROM users ORDER BY age DESC;
-- Multiple sort keys
SELECT * FROM users ORDER BY country ASC, age DESC;
-- NULLs: control where they appear
SELECT * FROM users ORDER BY age NULLS LAST;
LIMIT and OFFSET — Pagination
-- First 10 results
SELECT * FROM users ORDER BY id LIMIT 10;
-- Page 2 (skip first 10, get next 10)
SELECT * FROM users ORDER BY id LIMIT 10 OFFSET 10;
-- Page 3
SELECT * FROM users ORDER BY id LIMIT 10 OFFSET 20;
⚠️ OFFSET Performance
OFFSET 1000000 means the database must scan and discard 1 million rows before returning results. For large offsets, use cursor-based pagination instead: WHERE id > last_seen_id ORDER BY id LIMIT 10.
Aggregate Functions
Aggregate functions compute a single value from multiple rows:
SELECT COUNT(*) FROM users; -- total rows
SELECT COUNT(phone) FROM users; -- non-NULL phones
SELECT SUM(salary) FROM employees; -- total salary
SELECT AVG(age) FROM users; -- average age
SELECT MIN(created_at) FROM users; -- earliest signup
SELECT MAX(salary) FROM employees; -- highest salary
SELECT COUNT(DISTINCT country) FROM users; -- unique countries
GROUP BY — Aggregating by Category
GROUP BY splits rows into groups and applies aggregate functions per group:
-- Count users per country
SELECT country, COUNT(*) AS user_count
FROM users
GROUP BY country;
-- Result:
-- country | user_count
-- Sweden | 42
-- Norway | 15
-- Finland | 23
-- Average salary per department
SELECT dept_id, AVG(salary) AS avg_salary, COUNT(*) AS headcount
FROM employees
GROUP BY dept_id;
-- Multiple grouping columns
SELECT country, city, COUNT(*) AS user_count
FROM users
GROUP BY country, city
ORDER BY user_count DESC;
💡 Rule
Every column in SELECT must either be in GROUP BY or inside an aggregate function. You can't SELECT name, COUNT(*) FROM users GROUP BY country — which "name" would it show for each country?
HAVING — Filtering Groups
WHERE filters rows before grouping. HAVING filters groups after aggregation:
-- Countries with more than 20 users
SELECT country, COUNT(*) AS user_count
FROM users
GROUP BY country
HAVING COUNT(*) > 20;
-- Departments where average salary exceeds 80K
SELECT dept_id, AVG(salary) AS avg_sal
FROM employees
WHERE active = true -- WHERE: filter rows first
GROUP BY dept_id
HAVING AVG(salary) > 80000 -- HAVING: filter groups after
ORDER BY avg_sal DESC;
DISTINCT — Removing Duplicates
-- Unique countries
SELECT DISTINCT country FROM users;
-- Unique combinations
SELECT DISTINCT country, city FROM users;
CASE — Conditional Logic
SELECT name, salary,
CASE
WHEN salary > 100000 THEN 'Senior'
WHEN salary > 70000 THEN 'Mid'
ELSE 'Junior'
END AS level
FROM employees;
Putting It All Together
-- "Show me the top 5 countries by active user count,
-- but only countries with at least 10 users,
-- excluding test accounts"
SELECT
country,
COUNT(*) AS active_users,
AVG(age) AS avg_age
FROM users
WHERE active = true
AND email NOT LIKE '%@test.com'
GROUP BY country
HAVING COUNT(*) >= 10
ORDER BY active_users DESC
LIMIT 5;
Key Takeaways
- WHERE filters rows, HAVING filters groups
- Aggregates: COUNT, SUM, AVG, MIN, MAX
- GROUP BY splits data into groups for per-group aggregation
- ORDER BY + LIMIT for pagination (but avoid large OFFSET)
- CASE provides if/else logic inside queries
- Every SELECT column must be in GROUP BY or an aggregate