Skip to content
RungsySign in

SQL Basics

SELECT, WHERE, GROUP BY — declaring what you want and letting the planner find it.

50 minDifficulty 1/5database · fundamentalsHand-writtenWritten by a person and not yet reviewed by a second one. Checked automatically: schema, the pedagogical rules the hand-written material is held to, and every diagram parsed for real.

Why this exists

SQL looks like English, which is exactly why it misleads people. You write what you want and the database decides how to get it — so two queries that read almost identically can differ by a factor of ten thousand in cost, and nothing in the syntax hints at which is which. The other trap is quieter: the clauses do not execute in the order you wrote them, and `NULL` is not a value. Most of the confusing results people get from SQL come from those two facts alone.

The mental model

Ordering at a restaurant rather than cooking. You describe the dish you want; the kitchen decides the pans, the order of operations and whether to prep something in advance. You get enormous leverage from not specifying the how — and you also lose the ability to blame the recipe when it comes out slowly. An index is you having quietly told the kitchen where the ingredients live.

How it works

You declare the result; the planner picks the strategy

SQL is declarative. A query states which rows you want, and the query planner decides how to find them — which index to use, which table to read first, whether to sort or hash. This is why the same query can be instant on one dataset and catastrophic on another: the plan changed, not the SQL. It is also why `EXPLAIN` is the single most valuable command to learn early. Reading a query and guessing its cost is unreliable; asking the database what it intends to do is not. The gap between those two habits is most of the difference between someone who writes SQL and someone who is trusted with the production database.

The clauses do not run in the order you wrote them

You write `SELECT ... FROM ... WHERE ... GROUP BY ... HAVING ... ORDER BY ... LIMIT`. It evaluates roughly `FROM`, then `WHERE`, then `GROUP BY`, then `HAVING`, then `SELECT`, then `ORDER BY`, then `LIMIT`. Two consequences follow immediately and explain a lot of confusing errors. First, an alias defined in `SELECT` cannot be used in `WHERE`, because `WHERE` ran before `SELECT` existed — but it *can* be used in `ORDER BY`, which runs after. Second, `WHERE` filters individual rows before grouping while `HAVING` filters groups after, so a condition on a raw column belongs in `WHERE` and a condition on an aggregate belongs in `HAVING`. Putting the filter in the wrong one is either an error or, worse, silently slower.

NULL is unknown, not empty — and it breaks your intuitions

`NULL` means *no known value*, so comparing with it yields `UNKNOWN` rather than true or false. `WHERE status = NULL` matches nothing, ever, including rows where status is null — you need `IS NULL`. This three-valued logic leaks everywhere. `NOT IN (SELECT ...)` returns zero rows if the subquery produces a single null, which is one of the nastiest silent bugs in SQL because the query is valid and the result is empty rather than wrong-looking. Aggregates skip nulls, so `COUNT(column)` and `COUNT(*)` differ. And `NULL = NULL` is unknown, which is why `GROUP BY` and `UNION` treat nulls as equal while `=` does not — they use a different comparison deliberately.

Aggregates collapse rows, and GROUP BY defines the collapse

`COUNT`, `SUM`, `AVG`, `MIN`, `MAX` turn many rows into one. `GROUP BY` says which rows collapse together — one output row per distinct combination of the grouped columns. The rule that follows is not arbitrary: every column in your `SELECT` must either be in the `GROUP BY` or be inside an aggregate, because for anything else the database would have to invent a value from many candidates. MySQL historically allowed this and silently picked one at random, which is precisely the kind of helpfulness that produces a report nobody can reproduce. `COUNT(*)` counts rows, `COUNT(col)` counts non-null values of that column, and `COUNT(DISTINCT col)` counts distinct non-null values — three genuinely different questions that look nearly identical.

A result set has no order unless you ask for one

Rows come back in whatever order was convenient for the plan. It will often look like insertion order on a small table, and that appearance is a coincidence that survives exactly until the table grows, an index is added, or the query is parallelised. There is no default ordering, and relying on the accidental one is a bug that appears in production and cannot be reproduced locally. This has a sharp corollary for pagination: `LIMIT 20 OFFSET 40` without an `ORDER BY` — or with an `ORDER BY` on a non-unique column — can return the same row on two pages and skip another entirely. Order by something unique, or add a tiebreaker like the primary key.

The mechanism

The evaluation order explains errors that otherwise look arbitrary: ```sql -- FAILS: total does not exist when WHERE runs SELECT price * qty AS total FROM order_items WHERE total > 100; -- WORKS: ORDER BY runs after SELECT SELECT price * qty AS total FROM order_items ORDER BY total DESC; -- WHERE filters rows, HAVING filters groups SELECT customer_id, SUM(price * qty) AS spend FROM order_items WHERE created_at >= '2026-01-01' -- per row, before grouping GROUP BY customer_id HAVING SUM(price * qty) > 500 -- per group, after ORDER BY spend DESC LIMIT 10; ``` Putting the date filter in `HAVING` would give the same answer and read every row of the year before discarding most of them. Same result, far more work — and `EXPLAIN` is where you would see that.

flowchart TD
    F[FROM<br/>pick the tables] --> W[WHERE<br/>filter individual rows]
    W --> G[GROUP BY<br/>collapse into groups]
    G --> H[HAVING<br/>filter the groups]
    H --> S[SELECT<br/>choose and alias columns]
    S --> O[ORDER BY<br/>sort the result]
    O --> L[LIMIT<br/>take a slice]
    W -.->|aliases do not exist yet| S
    S -.->|aliases usable here| O
Diagram source for SQL Basics.

What people get wrong

WHERE column = NULL finds rows where the column is null.
It matches nothing. Comparison with NULL yields UNKNOWN, and only TRUE passes a WHERE clause. NULL means unknown, so asking whether an unknown equals an unknown cannot return true. Use IS NULL and IS NOT NULL, which are separate operators for exactly this reason.
Rows come back in the order they were inserted.
There is no guaranteed order without ORDER BY. The planner returns rows in whatever order the chosen plan produced them. It often looks like insertion order on small tables, right up until an index is added or the table grows, at which point behaviour changes with no code change.
COUNT(*) and COUNT(column) are the same.
COUNT(*) counts rows; COUNT(column) counts rows where that column is not null. Aggregates skip nulls. On a table with optional emails, COUNT(*) and COUNT(email) answer two different questions, and picking the wrong one produces a report that is quietly wrong rather than obviously broken.
SELECT * is fine, I'll just use the fields I need.
It transfers every column, defeats covering indexes, and breaks silently when someone adds a column. An index containing all the columns a query needs can answer it without touching the table at all. SELECT * guarantees that never happens. It also means adding a BLOB column to a table changes the cost of every existing query against it.
NOT IN and NOT EXISTS behave the same way.
NOT IN returns zero rows if the subquery yields even one NULL. NOT EXISTS does not. NOT IN expands to a chain of not-equals comparisons, and comparing anything to NULL is UNKNOWN, so the whole condition can never be true. This is a silent, valid-looking, empty-result bug — prefer NOT EXISTS.

When not to use it

Deeply nested or schema-less documents where the shape varies per record.
A document store, or a JSON column with an appropriate index. Modelling optional nested structures as dozens of nullable columns fights the relational model.
Full-text search — relevance ranking, stemming, fuzzy matching.
A dedicated search engine, or the database's own full-text index. LIKE with a leading wildcard cannot use a normal index and degrades to a full scan.
Heavy analytical scans over billions of rows for reporting.
A columnar warehouse. Row-oriented storage is optimised for fetching whole rows, which is the wrong shape when you want one column across everything.

Terms

Declarative
You state the result you want, not the steps to produce it. The planner chooses the steps.
Query planner
The component that turns your SQL into an execution strategy, using table statistics to estimate costs.
Predicate
A condition that evaluates to true, false or unknown for a row — the contents of a WHERE clause.
Three-valued logic
SQL's TRUE / FALSE / UNKNOWN system, which exists because NULL means unknown rather than empty.
Aggregate
A function collapsing many rows into one value: COUNT, SUM, AVG, MIN, MAX.
Cardinality
The number of distinct values in a column. Low cardinality makes an index much less useful.
EXPLAIN
The command that shows the plan the database intends to use. The only reliable way to know a query's cost.

In an interview

What is the difference between WHERE and HAVING?

  • WHERE filters individual rows before grouping
  • HAVING filters groups after aggregation
  • this follows from the logical evaluation order, not from an arbitrary rule
  • aggregates cannot appear in WHERE because grouping has not happened yet
  • putting a row-level filter in HAVING gives the same answer while processing far more rows

Why might a query that runs fine in staging be slow in production?

  • the planner chooses a plan from table statistics, and production data is larger and differently distributed
  • a plan that used an index on a small table may switch to a sequential scan when the optimiser estimates it is cheaper
  • missing indexes only hurt at scale — a full scan of a thousand rows is fast
  • EXPLAIN on production-shaped data is how you find out rather than guess

Can you recall it?

SQL's clauses do not execute in the order you write them. State the actual order, and give two concrete bugs that understanding it prevents.

Sources

Keep track of this

Add SQL Foundations to your map and Rungsy will schedule reviews so you actually remember it.