SQL Basics: Queries, Aggregation, JOINs, and Window Functions
Once tables are designed, the next step is to insert data and retrieve the results we need. In Database Foundations: Relational Models, Table Design, and Normalization, we separated customers, products, orders, and items. Now we use the same data to ask: how do we check stock, calculate order totals, or find customers who have never placed an order?
You can read this post in sequence or use the contents to look up syntax. Examples target MySQL 8.4 and InnoDB. General SQL ideas and MySQL-specific rules are distinguished. Run the table definitions and seed data in a new practice database first. Query examples assume this initial data, while modifications and structural changes use separate practice tables. Counterexamples are explicitly identified and do not need to be executed alongside normal examples.
Prepare One Reusable Dataset
Table Definitions: Put the Rules into the Database
This is the complete initialization SQL. Primary keys, foreign keys, and quantity constraints implement the business rules from the companion post.
1 | CREATE DATABASE database_foundations CHARACTER SET utf8mb4; |
INT stores integers. DECIMAL(10, 2) is an exact numeric type with at most ten digits, two after the decimal point, suitable for these amounts. DATETIME stores a date and time, while DATE stores only a date. VARCHAR stores variable-length strings; CHAR stores fixed-length strings and is less often used here.
NOT NULL rejects null values, UNIQUE restricts duplicates, and a primary key guarantees both uniqueness and non-nullability. MySQL 8.4 enforces CHECK conditions, but a condition evaluating to UNKNOWN also passes, so non-null requirements still need NOT NULL. DEFAULT supplies a default value and cannot replace non-null or business constraints.
Seed Data: Where Later Results Come From
1 | INSERT INTO customers (customer_id, name, city, email) VALUES |
Alice has two orders, Dana has none, and Monitor never appears in an item. Products 102 and 103 have equal current prices, and some customer emails are missing. These cases let us examine duplicates, tied ranks, nulls, and unmatched records.
Writing Conventions and Statement Categories
Keywords are uppercase here, table and column names lowercase, and statements end with semicolons. This is a readability convention.
CREATE, ALTER, and DROP are usually classified as data definition language, DDL. INSERT, UPDATE, and DELETE are data manipulation language, DML. SELECT is sometimes included in DML and sometimes called DQL separately. GRANT and REVOKE manage permissions and are commonly called DCL; commit and rollback are transaction control.
Comments can use -- followed by a space for one line or /* multiple lines */. MySQL also supports # comments, but not every database does.
Insert and Modify Data in Practice Tables
INSERT, UPDATE, and DELETE
First copy the product structure and rows. In MySQL, CREATE TABLE ... LIKE copies the structure; INSERT ... SELECT copies the existing rows.
1 | CREATE TABLE products_demo LIKE products; |
Only Cable remains, priced at 30.00. One INSERT can insert multiple rows without a separate loop for each. Explicit column names also prevent values silently being assigned to the wrong positions after schema changes.
In UPDATE and DELETE, WHERE determines which rows are affected. Omitting it processes all rows. DROP TABLE removes the table itself, while DELETE removes its rows. MySQL’s TRUNCATE TABLE is another way to empty a table; it is not a row-filterable, freely rollbackable version of DELETE.
A Default Does Not Automatically Turn NULL into Zero
A practice table with a nullable price separates the cases:
1 | CREATE TABLE defaults_demo ( |
The results are 0.00, 0.00, and NULL. Omitting price or writing DEFAULT uses the default. An explicit NULL stores a null. The core product table also declares price NOT NULL, so inserting a null fails in strict mode.
ALTER Changes Structure, Not an Individual Row
1 | ALTER TABLE products_demo ADD COLUMN note VARCHAR(100); |
Adding a column, changing its definition, and dropping it are structural changes. Changing one product’s price calls for UPDATE.
Queries: Decide Which Rows and Columns You Want
SELECT, WHERE, and Expressions
1 | SELECT product_id, name, price, stock, |
This returns products 101, 102, and 103, with inventory values of 2000.00, 2000.00, and 3000.00. SELECT specifies columns and computed expressions; AS names an expression; FROM supplies the input; WHERE keeps only rows whose condition is TRUE.
Comparisons use =, <>, <, <=, >, and >=. AND, OR, and NOT combine conditions. Use parentheses for complex expressions. For example, category = 'books' OR (category = 'accessories' AND stock >= 20) is not the same as selecting both categories and then applying the stock condition to both.
Duplicates, Ordering, and Pagination
1 | SELECT DISTINCT category FROM products ORDER BY category; |
The first query returns three categories. DISTINCT removes duplicates across the complete selected column list, rather than independently deduplicating just one column: all returned column values must match for a row to be deduplicated. The second sorts prices descending, skips the first row, and takes two, returning 101 and 102. This is closely related to pagination in backend development, allowing users to browse database results page by page. Adding ID ordering makes ties deterministic.
LIMIT ... OFFSET ... is the MySQL pagination form used here. Without ORDER BY, do not assume primary-key or insertion order. The performance of deep pagination belongs to the later query optimization discussion.
NULL and Three-Valued Logic
1 | SELECT customer_id, name |
The first query returns Bob and Dana; the second returns 4 and 2. email = NULL does not replace IS NULL, because ordinary comparisons involving null usually evaluate to UNKNOWN. WHERE discards both UNKNOWN and FALSE.
SUM, AVG, MIN, and MAX generally ignore nulls. COUNT(column) counts non-null values in that column; COUNT(*) counts rows; COUNT(1) counts a non-null constant for each row. On empty input, COUNT returns zero, while SUM and similar aggregates generally return NULL. COUNT(DISTINCT email) counts distinct non-null email values.
Nulls also affect sorting, but are not randomly placed at the beginning or end. MySQL places NULL before non-null values in ascending order and after them in descending order. Multiple nulls form one group when grouping.
LIKE, BETWEEN, and IN
1 | SELECT product_id, name FROM products WHERE name LIKE '%Book%'; |
For LIKE, % matches zero or more characters and _ matches one; case sensitivity depends on collation. BETWEEN includes both endpoints, so the second query includes prices of 100 and 200. IN tests membership among values. Its negative form needs extra care around nulls, as shown later.
Aggregation: Turn a Group of Rows into One Result
Aggregates and GROUP BY
1 | SELECT category, |
| category | product_count | total_stock | average_price |
|---|---|---|---|
| accessories | 2 | 30 | 150.00 |
| books | 1 | 30 | 100.00 |
| hardware | 1 | 5 | 1000.00 |
GROUP BY determines group membership. A nonaggregated output column must have a determinate value within the group. MySQL enables ONLY_FULL_GROUP_BY by default: nonaggregated columns must be grouped, functionally determined by grouped columns, or satisfy certain permitted single-value conditions. As a general rule, the SELECT list of a grouped aggregate query contains grouping keys and aggregate expressions; other expressions are usually invalid.
WHERE and HAVING Filter Different Stages
1 | SELECT category, COUNT(*) AS product_count |
The first filters stock before grouping and retains only accessories. WHERE filters input rows; HAVING filters grouped results. The second has no explicit GROUP BY: all input is one group, producing total stock 65. HAVING does not require an accompanying GROUP BY.
Put Conditions into Calculations with CASE
1 | SELECT |
The result is 2, 1, and 1. CASE WHEN ... THEN ... ELSE ... END is an expression usable in SELECT, aggregate arguments, ordering, and other value positions. It does not modify a table; it produces a value from conditions. Without ELSE, unmatched cases yield NULL. The simple form can be written CASE status WHEN 'paid' THEN ... END.
Logical Processing Order
For an ordinary grouped query, this logical sequence helps derive the result:
1 | FROM / JOIN → WHERE → GROUP BY → HAVING |
It explains results rather than requiring the database to materialize each intermediate table in that order. The optimizer can reorder joins or push predicates down while preserving semantics.
Multiple Tables: Understand Matching Before Joining
INNER JOIN Keeps Matching Combinations
1 | SELECT o.order_id, c.name AS customer_name, o.status |
Four orders are returned. Alice appears twice because she has two orders. A JOIN is not merely adding columns: it combines rows according to conditions, and one-to-many relationships multiply rows on one side. Counting orders directly after joining items can therefore count an order more than once.
Join orders to items, then aggregate:
1 | SELECT o.order_id, c.name AS customer_name, |
Totals are 360.00, 100.00, 370.00, and 200.00. They use sale unit prices, not current product prices. To keep only paid orders, add WHERE o.status = 'paid' before GROUP BY; their combined total is 730.00.
LEFT JOIN Also Keeps Unmatched Rows
1 | SELECT c.customer_id, c.name, COUNT(o.order_id) AS order_count |
The four customers have 2, 1, 1, and 0 orders. Dana’s right-side columns are NULL, so COUNT(o.order_id) does not count them. COUNT(*) would count her preserved row as one.
To attach only paid orders while retaining every customer, put the condition in ON:
1 | SELECT c.customer_id, c.name, o.order_id |
Moving o.status = 'paid' to WHERE removes customers with no matching order because their condition does not evaluate to TRUE. RIGHT JOIN retains unmatched rows on the right and can also be written as LEFT JOIN by exchanging the tables.
Self-Joins, Nonequality Joins, and Other Forms
1 | SELECT a.name AS cheaper_product, b.name AS more_expensive_product |
Mouse is cheaper than Keyboard and Monitor. Using two aliases of the same table makes this a self-join; comparing with < makes it a nonequality join. Self-join and inner/outer join describe different dimensions.
CROSS JOIN produces all row combinations: two four-row tables produce 16 rows. NATURAL JOIN automatically matches same-named columns; USING explicitly lists shared column names. Adding same-named fields can change a natural join’s meaning, so examples here prefer explicit ON conditions. ON is not mandatory in every join form.
MySQL 8.4 has no direct FULL OUTER JOIN syntax. To keep unmatched rows from both sides, combine a left join with the unmatched part of the reverse join. This is a syntax illustration: A and B are placeholder table names, and both id columns are assumed non-null:
1 | SELECT a.id AS a_id, b.id AS b_id |
The second part adds only unmatched B rows, avoiding duplicate addition of matches.
Subqueries, Existence Tests, and Set Operations
Scalar Subqueries, Derived Tables, and Correlated Subqueries
1 | SELECT product_id, name, price |
The average price is 350.00, so only Monitor qualifies. The parenthesized query supplies a single value and is a scalar subquery. More than one row causes an error; no rows yields NULL.
A query placed in FROM is a derived table, and MySQL requires an alias. A CTE can also name a query:
1 | WITH order_totals AS ( |
Orders 1001 and 1003 qualify. A CTE belongs only to the immediately following statement and does not create a persistent table.
A correlated subquery references an outer column:
1 | SELECT p.product_id, p.name, p.price |
EXISTS and the NOT IN Null Trap
1 | SELECT p.product_id, p.name |
The first finds Monitor, which never occurs in an order item. EXISTS asks only whether the subquery returns a row; selecting 1 or * does not change that meaning. Its spelling is EXISTS, not EXIST.
The second produces NULL, or UNKNOWN. Although 3 is different from 1 and 2, comparison with NULL is indeterminate. Thus NOT IN does not always establish definite absence. NOT IN and NOT EXISTS need not be equivalent when the list or subquery can contain NULL. A null outer value also requires explicit business handling.
UNION, INTERSECT, and EXCEPT
1 | SELECT product_id FROM products WHERE price <= 200 |
The results are {101,102,103}, {101,102}, and {103}. These operations deduplicate by default. UNION ALL retains duplicates; replacing the first UNION with UNION ALL gives five rows. MySQL 8.4 also supports INTERSECT ALL and EXCEPT ALL, whose meanings account for duplicate multiplicities rather than ordinary deduplicated sets.
Both SELECT lists must have the same number of columns, with compatible types in corresponding positions, rather than necessarily identical types. MySQL added INTERSECT and EXCEPT in 8.0.31; older versions need alternatives. INTERSECT takes precedence over UNION and EXCEPT. Use parentheses for complex combinations and place final ordering after the complete result.
Window Functions: Keep Each Row and Examine Related Rows
OVER() and Partitions
1 | SELECT product_id, name, category, price, |
Every product still has a row. Total price 1400.00 appears on each, and both accessory rows show a category average of 150.00. GROUP BY collapses rows; a window function computes a value for each query row. Empty OVER() is valid and meaningful: it uses the entire query result as a window.
MySQL permits window functions in the SELECT list and ORDER BY, not directly in WHERE or HAVING. To filter a window result, wrap it in a derived table or CTE.
Ranking and Ties
1 | SELECT product_id, price, |
| product_id | price | price_rank | dense_rank | row_num |
|---|---|---|---|---|
| 102 | 100.00 | 1 | 1 | 1 |
| 103 | 100.00 | 1 | 1 | 2 |
| 101 | 200.00 | 3 | 2 | 3 |
| 104 | 1000.00 | 4 | 3 | 4 |
RANK keeps ties and leaves gaps afterward; DENSE_RANK leaves no gaps; ROW_NUMBER assigns a sequence to individual rows. Product ID is deliberately omitted from RANK ordering, since including it would break the price tie. It is added to ROW_NUMBER for deterministic ordering of equal prices. Window ORDER BY controls calculations; final output order still requires the outer ORDER BY.
Running Totals and Moving Averages
1 | WITH order_totals AS ( |
Running totals are 360, 460, 830, and 1030. Two-row moving averages are 360, 230, 235, and 285. The first row has no preceding row, so its average uses itself alone.
Window aggregation is not always cumulative: the earlier OVER() covers the complete window. The frame specifies which rows participate in each calculation. ROWS uses row positions, while RANGE uses ordering values and peers according to its rules. For these aggregates, an ORDER BY with no explicit frame gives MySQL’s default RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW, including rows with the current row’s ordering value. This is not necessarily exactly through the current physical row. Specify ordering and a ROWS frame for row-by-row accumulation.
Exercise: Find Login Streaks Lasting at Least Three Days
This exercise uses a separate login table. Timestamps retain seconds, but the analysis deduplicates dates:
1 | CREATE TABLE login_records ( |
| user_id | start_date | end_date | consecutive_days |
|---|---|---|---|
| 1 | 2024-07-30 | 2024-08-01 | 3 |
| 3 | 2024-07-29 | 2024-08-01 | 4 |
Two logins on one day are merged before numbering. Within a streak, both the date and row number increase by one each day, so subtracting the row number from the date yields a constant group key. A missing day starts a different group. Date arithmetic handles month boundaries rather than subtracting integer day-of-month values.
The CTEs name each step. They can also be rewritten as nested FROM derived tables, retaining each alias and the same columns without changing the logic. One complete solution is kept here to avoid duplicated code with different table names suggesting it uses the same dataset.
- Title: SQL Basics: Queries, Aggregation, JOINs, and Window Functions
- Author: Hyacehila
- Created at : 2024-07-29 14:29:30
- Link: https://hyacehila.github.io//blog/2024/07/29/sql-learning-notes/
- License: This work is licensed under CC BY-NC-SA 4.0.