SQL Intermediate: Joins, Aggregates & Beyond
You know SELECT and WHERE. Now it's time to combine tables with JOINs, summarize data with aggregate functions, write subqueries, use set operators, control flow with CASE, and supercharge performance with indexes.
SQL Joins — Combining Tables
SQL Joins combine rows from two or more tables based on a related column. Joins are the primary way relational databases connect data spread across normalized tables.
The sample data used throughout this section:
INNER JOIN — Only Matching Rows
Returns rows that have a matching value in both tables.
LEFT JOIN — All Left Rows, Matched Right Rows
Returns all rows from the left table and matching rows from the right. Unmatched right rows appear as NULL.
RIGHT JOIN — All Right Rows, Matched Left Rows
Returns all rows from the right table and matching rows from the left.
FULL OUTER JOIN — All Rows from Both Tables
Returns all rows when there is a match in either table. Unmatched rows on either side get NULL.
CROSS JOIN — Cartesian Product
Returns every possible combination of rows from both tables. Use with care on large tables — result size = rows_left × rows_right.
SELF JOIN — Table Joined to Itself
A table joined to itself — the classic use case is an employee-manager hierarchy where both employees and managers are in the same table.
Multi-Table Join
Always use table aliases with JOINs
Table aliases (e, d, c) make multi-table queries dramatically more readable. Use short, meaningful aliases. Qualify every column with its alias when more than one table is in the FROM clause to avoid ambiguity and improve query clarity.
Aggregate Functions — COUNT, SUM, AVG, MIN, MAX
Aggregate functions compute a single result from a set of rows. They are the foundation of reporting and analytics queries.
| Column | Type | Description |
|---|---|---|
COUNT(*) | Number of rows | Counts all rows including NULLs |
COUNT(col) | Non-NULL rows | Excludes rows where col is NULL |
COUNT(DISTINCT col) | Unique non-NULL values | Excludes NULLs and duplicates |
SUM(col) | Total of values | Ignores NULLs — NULLs are treated as 0 contribution |
AVG(col) | Average of values | Ignores NULLs — denominator is count of non-NULL rows |
MIN(col) | Smallest value | Ignores NULLs |
MAX(col) | Largest value | Ignores NULLs |
AVG ignores NULLs — this changes the denominator
If 10 employees have salary data and 2 have NULL, AVG(salary) divides by 10 (not 12). If NULLs mean "zero salary" in your context, replace them first: AVG(NVL(salary, 0)).
GROUP BY — Grouping & Summarizing
GROUP BY groups rows with the same values in specified columns, then aggregate functions compute a result per group.
Every non-aggregated SELECT column must appear in GROUP BY
This is the most common GROUP BY mistake. If your SELECT contains first_name, department, COUNT(*), then first_name and department must both be in GROUP BY (or wrapped in an aggregate). Most databases enforce this strictly.
HAVING — Filtering Groups
HAVING filters groups after aggregation, just as WHERE filters rows before aggregation.
| Column | Type | Description |
|---|---|---|
WHERE | Before GROUP BY | Filter individual rows — can reference any column |
HAVING | After GROUP BY | Filter groups — must reference aggregate expressions or GROUP BY columns |
Subqueries — Queries Inside Queries
A subquery is a SELECT statement nested inside another SQL statement. Subqueries can appear in WHERE, FROM, SELECT, or HAVING clauses.
Single-Row Subquery
Returns exactly one value — used with =, >, .
Multi-Row Subquery
Returns multiple rows — used with IN, ANY, ALL.
Correlated Subquery
Executes once for each row in the outer query — the subquery references a column from the outer query.
Inline View (Subquery in FROM)
A subquery in the FROM clause acts like a temporary table.
Correlated subqueries can be slow on large tables
A correlated subquery runs once per outer row. On a table with 100,000 rows, that is 100,000 subquery executions. For performance, consider rewriting as a JOIN or using window functions (covered in the Advanced guide).
UNION, INTERSECT & EXCEPT
Set operators combine the result sets of two SELECT queries. Both queries must return the same number of columns in the same order with compatible data types.
UNION vs UNION ALL
INTERSECT — Common Rows
EXCEPT / MINUS — Rows in First But Not Second
| Column | Type | Description |
|---|---|---|
UNION | Set operator | All rows from both queries. Yes. |
UNION ALL | Set operator | All rows from both queries. No — faster. |
INTERSECT | Set operator | Only rows present in both queries. Yes. |
EXCEPT / MINUS | Set operator | Rows in first query not in second. Yes. |
CASE — Conditional Logic in SQL
CASE is SQL's if-then-else. It evaluates conditions and returns different values based on which condition is true.
Simple CASE
Searched CASE
CASE in Aggregate Functions
COALESCE, NULLIF & NVL
These functions provide clean ways to handle NULL values without cluttering your queries with CASE expressions.
COALESCE — First Non-NULL Value
NVL — Oracle's Two-Argument NULL Replace
NULLIF — Return NULL if Two Values Are Equal
String Functions — CONCAT, LENGTH, SUBSTRING, TRIM
Date Functions
Mathematical Functions
SQL Views
A view is a stored SQL query given a name. It behaves like a virtual table — you query a view exactly like a real table, but the database runs the underlying SELECT at query time.
Why use views:
- Hide complexity from end users and report writers
- Enforce row-level or column-level security (expose only permitted data)
- Create a stable interface when the underlying tables may change
- Avoid duplicating complex JOIN logic across many queries
CREATE VIEW
CREATE OR REPLACE VIEW
DROP VIEW
Views don't store data — they store the query
Every time you SELECT from a view, the database executes the underlying query fresh. This means views always reflect the current state of the data, but they don't provide a performance benefit from caching. Use Materialized Views (Oracle) or indexed views (SQL Server) when you need pre-computed, cached results.
SQL Indexes
An index is a data structure that allows the database engine to find rows much faster — similar to an index at the back of a book. Without an index, the database must scan every row in a table (a full table scan) for each query.
CREATE INDEX
DROP INDEX
Index Types
| Column | Type | Description |
|---|---|---|
B-Tree (default) | General purpose | Equality and range queries on almost any column. The default for CREATE INDEX. |
Unique Index | Uniqueness + speed | Columns that must be unique (email, SSN). Automatically created for PRIMARY KEY and UNIQUE constraints. |
Composite Index | Multi-column filters | Queries that filter on multiple columns together. Column order matters — leftmost columns are used first. |
Function-Based (Oracle) | Indexed expressions | Queries that filter on UPPER(col), LOWER(col), or other expressions. |
Bitmap (Oracle) | Low-cardinality columns | Columns with few distinct values (status, gender) in data warehouse / analytics contexts. |
Indexes speed up reads but slow down writes
Every INSERT, UPDATE, or DELETE on an indexed table must also update the index. More indexes = faster SELECT, slower DML. Index columns that appear in WHERE, JOIN ON, and ORDER BY clauses of your most critical queries. Don't index every column.
Common Gotchas
Important Gotchas
- !
JOIN without an ON condition creates a CROSS JOIN. In some SQL dialects, writing
FROM employees, departmentsor forgetting the ON clause silently produces a Cartesian product. Always explicitly write your JOIN type and ON condition. - !
Aggregate functions in WHERE cause an error. You cannot use
COUNT(*),SUM(), or other aggregate functions in a WHERE clause. Use HAVING to filter after grouping. The error message is often cryptic: "ORA-00934: group function is not allowed here." - !
UNION removes duplicates silently — use UNION ALL if you want all rows. If you're combining logs, event streams, or any data where duplicates are meaningful, use UNION ALL. UNION deduplication has a performance cost and can silently drop valid rows.
- !
Correlated subqueries are not always the right tool. Correlated subqueries execute once per outer row and can be extremely slow on large datasets. Most correlated subqueries can be rewritten as a JOIN or a window function, which the query optimizer can plan more efficiently.
- !
Function on an indexed column defeats the index.
WHERE UPPER(last_name) = 'SHARMA'cannot use a regular index onlast_name. Either create a function-based index onUPPER(last_name), or store data consistently (e.g., always in uppercase) to avoid the function call. - !
Too many indexes hurt write performance. Indexes are not free. Each index must be maintained on every INSERT/UPDATE/DELETE. A table with 15 indexes can be dramatically slower for bulk loads than one with 3 well-chosen indexes.
Key Takeaways
Key Takeaways
- INNER JOIN returns only matched rows. LEFT JOIN keeps all left rows. FULL OUTER JOIN keeps all rows from both sides. Choose based on whether you need to preserve unmatched rows.
- Aggregate functions (COUNT, SUM, AVG, MIN, MAX) ignore NULLs except COUNT(*). WHERE filters before aggregation; HAVING filters after aggregation.
- Subqueries can go in WHERE (filter), FROM (inline view), SELECT (scalar), or HAVING. Correlated subqueries run once per outer row — use JOINs or window functions for better performance at scale.
- UNION removes duplicates; UNION ALL keeps them all (and is faster). Use INTERSECT for common rows, and EXCEPT/MINUS for rows in one set but not another.
- CASE is SQL's conditional logic. Use it in SELECT columns, in WHERE with complex conditions, or inside aggregate functions for pivot-style summaries.
- COALESCE returns the first non-NULL value in a list. NVL is Oracle's two-argument version. NULLIF returns NULL when two values are equal — perfect for avoiding divide-by-zero errors.
- Views are stored queries, not stored data. They simplify complex queries, enforce security, and create stable interfaces. Use Materialized Views when you need cached, precomputed results.
- Indexes speed up reads but add overhead to writes. Index columns in WHERE, JOIN ON, and ORDER BY clauses of your critical queries. Avoid over-indexing.


