Skip to content
System nominalUTC --:--:--Articles 041
MODULE 02 Lesson 04 / 07 30 MIN

Querying

SELECT, WHERE, and the Order of Evaluation

SQL is written in one order and evaluated in another. That single fact explains why aliases fail in WHERE and why LIMIT without ORDER BY is a coin toss.

You write a query that reads well top to bottom: select the amount, filter on the amount, sort by the amount. The engine answers with Unknown column 'amount' in 'where clause' and you rename things until it stops complaining. The clause that fails can only see what earlier clauses have computed, and the SELECT list is not one of them.

The same fact explains a bug that produces no error at all. You run a report with LIMIT 20, nobody mentions ORDER BY, and a week later the numbers have changed although no row was updated. The rows were always arbitrary; you got a different arbitrary set.

The examples below run on MySQL 9.6.0. The dataset starts with five customers, seven orders and a call log, and it stays the same for the rest of the module. SQLite 3.51.0 was run alongside every query that both engines accept; differences are called out where they exist.

FROM runs first and LIMIT runs last

The evaluation order is fixed and it is not the order you type: FROM, WHERE, GROUP BY, HAVING, SELECT, ORDER BY, LIMIT. The engine has to know which tables it is talking about before it can resolve a column name anywhere else.

CREATE TABLE customer (
  customer_id INT NOT NULL AUTO_INCREMENT PRIMARY KEY,
  email       VARCHAR(160) NOT NULL,
  city        VARCHAR(64)  NULL,
  UNIQUE KEY uq_customer_email (email)
) ENGINE=InnoDB;

CREATE TABLE orders (
  order_id    INT NOT NULL PRIMARY KEY,
  customer_id INT NOT NULL,
  status      VARCHAR(16) NOT NULL,
  placed_at   DATETIME NOT NULL,
  total_cents INT NOT NULL,
  KEY idx_orders_customer (customer_id),
  CONSTRAINT fk_orders_customer FOREIGN KEY (customer_id)
    REFERENCES customer (customer_id)
) ENGINE=InnoDB;

CREATE TABLE call_log (
  call_id     INT NOT NULL AUTO_INCREMENT PRIMARY KEY,
  customer_id INT NULL,
  note        VARCHAR(64) NOT NULL
) ENGINE=InnoDB;

INSERT INTO customer (customer_id, email, city) VALUES
 (1, 'ana@example.com',  'Lisbon'),
 (2, 'bo@example.com',   'Porto'),
 (3, 'cai@example.com',  NULL),
 (4, 'dee@example.com',  'Braga'),
 (5, 'eve@example.com',  'Lisbon');

INSERT INTO orders (order_id, customer_id, status, placed_at, total_cents) VALUES
 (1001, 1, 'paid',     '2026-03-02 09:14:00', 12000),
 (1002, 1, 'paid',     '2026-03-11 16:40:00',  4500),
 (1003, 2, 'pending',  '2026-03-12 11:02:00',  9900),
 (1004, 4, 'refunded', '2026-04-01 08:20:00',  3000),
 (1005, 2, 'paid',     '2026-04-04 19:55:00', 22500),
 (1006, 4, 'pending',  '2026-04-09 10:10:00',  1800),
 (1007, 1, 'paid',     '2026-04-15 13:05:00',  4500);

FROM assembles the rows. WHERE throws rows away. GROUP BY collapses them into groups. HAVING throws groups away. SELECT computes the result columns. ORDER BY sorts. LIMIT truncates. Only one of those knows about the word amount in your SELECT list, and it is not WHERE.

Aliases and aggregates are both findings of later clauses, which is why the two questions everyone has about SQL are one question. WHERE runs before the projection exists, so it cannot filter on it. LIMIT runs after the sort, so with no sort there is nothing to cut from except an unordered bag of rows.

A SELECT alias does not exist yet when WHERE runs

The failing query is the natural one to write:

SELECT order_id, total_cents / 100 AS amount
FROM orders
WHERE amount > 100;
ERROR 1054 (42S22): Unknown column 'amount' in 'where clause'

The message is accurate. amount is introduced by the SELECT list, and WHERE is evaluated two steps earlier, over rows that have no projection. Repeat the expression, or filter on the source columns:

SELECT order_id, total_cents / 100 AS amount
FROM orders
WHERE total_cents > 10000
ORDER BY amount DESC;
+----------+----------+
| order_id | amount   |
+----------+----------+
|     1005 | 225.0000 |
|     1001 | 120.0000 |
+----------+----------+

That works, and the reason is worth noticing. ORDER BY runs after SELECT, so it can see amount even though WHERE cannot. MySQL and SQLite both accept the alias there. HAVING runs after SELECT as well, which is why the next section works without repeating the expression.

HAVING exists because WHERE cannot see an aggregate

An aggregate has no value until the rows have been grouped, and grouping happens after WHERE, so WHERE COUNT(*) > 2 is not inconvenient but impossible. HAVING runs after the grouping; that is its entire job.

SELECT c.customer_id, c.email, COUNT(*) AS paid_orders,
       SUM(o.total_cents) / 100.0 AS paid_euros
FROM customer c
JOIN orders o ON o.customer_id = c.customer_id
WHERE o.status <> 'pending'
GROUP BY c.customer_id, c.email
HAVING COUNT(*) >= 2 AND SUM(o.total_cents) > 15000
ORDER BY paid_euros DESC;
+-------------+-----------------+-------------+------------+
| customer_id | email           | paid_orders | paid_euros |
+-------------+-----------------+-------------+------------+
|           1 | ana@example.com |           3 |   210.0000 |
+-------------+-----------------+-------------+------------+

Customer 1 has three paid orders and 210 euros, and passes both conditions. Customer 2 has one paid order, because 1003 is pending and WHERE removed it before the grouping, so the count condition is what cuts it. Both conditions live in HAVING for the same reason: WHERE cannot see an aggregate, and the grouping has not happened yet when it runs.

HAVING without GROUP BY is legal

With no GROUP BY, the whole result is one group, and HAVING filters that single row. SELECT COUNT(*) FROM orders HAVING COUNT(*) > 5 returns one row; with > 100 it returns none. The clause is not an error, it is an odd way to write a WHERE against an aggregate.

LIMIT without ORDER BY is not reproducible

Nothing about a table guarantees an order. Rows come back in whatever order the access path produces, and the optimizer changes its mind when an index appears. In SQLite, adding one index changes the same query's output. The DDL below is SQLite's; in MySQL the date column would be DATETIME, and the rest is the same:

CREATE TABLE ticket (
  id        INTEGER PRIMARY KEY,
  opened_at TEXT NOT NULL,
  subject   TEXT NOT NULL
);

INSERT INTO ticket VALUES
 (1, '2026-01-05', 'locked out'),
 (2, '2026-01-02', 'invoice wrong'),
 (3, '2026-01-09', 'slow admin'),
 (4, '2026-01-03', 'refund'),
 (5, '2026-01-07', 'cannot log in');

SELECT id FROM ticket LIMIT 3;
┌────┐
│ id │
├────┤
│ 1  │
│ 2  │
│ 3  │
└────┘

Now create an index on opened_at, run ANALYZE, and run the identical statement again:

CREATE INDEX idx_ticket_opened ON ticket (opened_at);
ANALYZE;

SELECT id FROM ticket LIMIT 3;
┌────┐
│ id │
├────┤
│ 2  │
│ 4  │
│ 1  │
└────┘

The query text did not change and the data did not change. The plan changed from SCAN ticket to SCAN ticket USING COVERING INDEX idx_ticket_opened, and the first three rows became a different three rows. In MySQL I could not produce the flip on a five-row table, because InnoDB chose a primary key scan both times; the instability is there, it needs a larger table or a different index. If a query is supposed to return the twenty most recent tickets, the ORDER BY is part of the question.

LIMIT also cannot be pushed before ORDER BY, or the sort would be sorting an arbitrary subset. That is why ORDER BY is not optional whenever LIMIT is present. The ANSI spelling is FETCH FIRST 20 ROWS ONLY; MySQL uses LIMIT, and SQLite accepts both.

NULL is unknown, not false

SQL comparisons are three-valued: true, false, unknown. A comparison with NULL returns unknown, and WHERE keeps a row only when the predicate is true. Unknown is discarded along with false, which is why this query is empty rather than an error:

SELECT customer_id, email, city FROM customer WHERE city = NULL;
Empty set (0.00 sec)

Use IS NULL, or a null-safe comparison when either side may be NULL. MySQL spells that <=>; SQLite and PostgreSQL spell it IS DISTINCT FROM. MySQL 9.6 has no IS DISTINCT FROM:

SELECT customer_id, city FROM customer WHERE city IS NULL;

SELECT customer_id, city FROM customer WHERE city <=> NULL;
+-------------+------+
| customer_id | city |
+-------------+------+
|           3 | NULL |
+-------------+------+

The same three-valued logic explains a trap that eats more production queries than any other. NOT IN with a NULL anywhere in the list returns no rows, because x NOT IN (1, 2, NULL) expands to x <> 1 AND x <> 2 AND x <> NULL, and the last term is unknown for every value of x. Unknown propagates through AND.

INSERT INTO call_log (call_id, customer_id, note) VALUES
 (1, 1,    'asked for invoice'),
 (2, NULL, 'wrong number'),
 (3, 3,    'asked for invoice'),
 (4, 99,   'orphan import');

SELECT COUNT(*) AS customers_without_calls
FROM customer
WHERE customer_id NOT IN (SELECT customer_id FROM call_log);
+--------------------------+
| customers_without_calls  |
+--------------------------+
|                        0 |
+--------------------------+

The call log contains a NULL, the subquery includes it, and every customer is eliminated. NOT EXISTS asks a different question, one with only two possible answers, and returns the rows you wanted:

SELECT COUNT(*) AS customers_without_calls
FROM customer c
WHERE NOT EXISTS (
  SELECT 1 FROM call_log l WHERE l.customer_id = c.customer_id
);
+--------------------------+
| customers_without_calls  |
+--------------------------+
|                        3 |
+--------------------------+

Three customers: bo, dee and eve. The orphan call with customer_id = 99 is invisible to this query, which is correct for the question being asked. The NULL in the list is what makes the two statements disagree, not the data being wrong.

LIKE matches a pattern, and a leading wildcard costs a scan

LIKE compares against a pattern where % stands for any sequence of characters and _ stands for exactly one. Backslash is the default escape character, so a literal underscore needs escaping:

SELECT 'axb' LIKE 'a_b'    AS one_char,
       'axxb' LIKE 'a_b'   AS two_chars,
       'a_b' LIKE 'a\_b'   AS literal_underscore;
+----------+-----------+--------------------+
| one_char | two_chars | literal_underscore |
+----------+-----------+--------------------+
|        1 |         0 |                  1 |
+----------+-----------+--------------------+

Case sensitivity belongs to the engine and the collation, not to LIKE. MySQL's connection collation here is utf8mb4_0900_ai_ci, where ai is accent-insensitive and ci case-insensitive, so email LIKE 'ana%' matches ana@example.com unaided. PostgreSQL's LIKE is case-sensitive and ILIKE the variant. SQLite's LIKE is case-insensitive for ASCII, GLOB is case-sensitive, and SQLite 3.51 has no ILIKE, so a PostgreSQL query will not parse there.

The part that matters for performance is where the wildcard sits. Only a pattern with a literal prefix can be turned into an index range. On a separate 50,000-row subscription table with an index on email, MySQL reports the difference in the estimated rows directly:

CREATE TABLE subscription (
  id    INT NOT NULL AUTO_INCREMENT PRIMARY KEY,
  email VARCHAR(160) NOT NULL,
  KEY idx_sub_email (email)
) ENGINE=InnoDB;

EXPLAIN SELECT id FROM subscription WHERE email LIKE 'user000042%';
EXPLAIN SELECT id FROM subscription WHERE email LIKE '%user000042%';
prefix:           type=range  key=idx_sub_email  rows=1
leading wildcard: type=index  key=idx_sub_email  rows=48000

MySQL's default EXPLAIN output is wide enough to wrap in a terminal, so those are the two columns that matter, extracted from it. range with rows=1 means the index narrowed the search to a single entry. index with a row estimate in the tens of thousands means the engine read the whole index and applied the pattern to every entry: a scan wearing an index as a costume. The estimate moves between runs and after ANALYZE, which is expected for a number the optimizer guesses at. Counting the matches shows the same thing at this size, at about 13 milliseconds for the prefix and about 24 for the pattern that starts with %, against 18 for a plain COUNT(*) of the table, measured through the MySQL client on one machine.

SQLite is stricter and clearer about this. Its query planner will not use an index for LIKE unless the column has a case-insensitive collation, because plain LIKE is case-insensitive and an ordinary index is not:

CREATE INDEX idx_sub_email ON subscription (email);
EXPLAIN QUERY PLAN SELECT id FROM subscription WHERE email LIKE 'user000042%';

CREATE INDEX idx_sub_email_nc ON subscription (email COLLATE NOCASE);
EXPLAIN QUERY PLAN SELECT id FROM subscription WHERE email LIKE 'user000042%' COLLATE NOCASE;
`--SCAN subscription
`--SEARCH subscription USING COVERING INDEX idx_sub_email_nc (email>? AND email<?)

Two plans for the same query shape, and two fixes: keep the wildcard suffix-only, or add a case-insensitive or functional index. Past a prefix, the tools are a full-text index or a search service, and LIKE '%...%' on a large table is where that conversation starts.

Write the clause order into the query

The three clauses people get wrong are all explained by the same ordering. Filter rows in WHERE, filter groups in HAVING, and never assume a projection exists before SELECT has run. When a filter needs an alias, wrap the projection in a derived table and filter outside it, which is what lesson three does with window functions.

Two habits are worth keeping. Write ORDER BY in the same edit as any LIMIT. And when a comparison has to handle missing data, decide explicitly whether NULL is a value you want, then write IS NULL or IS NOT NULL rather than leaving the answer to three-valued logic.

Lab 30 minutes

Using the customer, orders and call_log tables from this lesson, write four statements.

  1. The twenty most recent orders, with the amount in euros, newest first. Say why your ORDER BY has two columns.
  2. The customers whose city is unknown, using the operator that works.
  3. The customers who have no row in call_log. Then explain what the NOT IN version returns and why.
  4. Every customer whose email contains example.com after the first character, and the reason to avoid that shape on a large table.
Show the solution and the reasoning

Nothing here needs an alias in WHERE, which is the point of the first item. The projection and the tie-breaker belong in clauses that run after SELECT:

SELECT order_id, placed_at, total_cents / 100 AS amount
FROM orders
ORDER BY placed_at DESC, order_id DESC
LIMIT 20;

The second sort key matters because placed_at is not unique here and will not be unique in yours. Without it, two orders placed in the same second have no defined relative position, and LIMIT 20 can cut a different pair depending on the plan. Adding order_id makes the order total: no two rows compare equal, so the cut is reproducible.

The unknown city uses IS NULL, not = NULL:

SELECT customer_id, email FROM customer WHERE city IS NULL;

The explicit form, city <=> NULL in MySQL or city IS NOT DISTINCT FROM NULL in SQLite and PostgreSQL, gives the same answer. = NULL returns nothing, because the comparison is unknown and WHERE keeps only true.

The anti-join is where the reasoning gets interesting:

SELECT c.customer_id, c.email
FROM customer c
WHERE NOT EXISTS (
  SELECT 1 FROM call_log l WHERE l.customer_id = c.customer_id
);
+-------------+-----------------+
| customer_id | email           |
+-------------+-----------------+
|           2 | bo@example.com  |
|           4 | dee@example.com |
|           5 | eve@example.com |
+-------------+-----------------+

The NOT IN rewrite returns zero rows. call_log contains a NULL customer_id, so the list inside the IN contains a NULL, and NOT IN is a chain of ANDed inequality tests. For every customer the term customer_id <> NULL is unknown, unknown AND anything is unknown, and the row is dropped. The bug is silent: the syntax is valid and the answer is a plausible zero. NOT EXISTS has no NULL problem because an EXISTS predicate asks whether a row is present, which is true or false with no third option.

The email pattern is where the obvious answer is the wrong shape:

SELECT customer_id, email FROM customer WHERE email LIKE '%example.com%';

The pattern starts with %, so no index range can be derived from it. On a large table the engine reads every row and applies the match. If the domain is genuinely a suffix, the query you want is the one with a fixed prefix: email LIKE 'ana%'. If you need a substring search, that is a full-text index, and on MySQL the FULLTEXT index and MATCH ... AGAINST are the built-in answer. Note also that "anywhere after the first character" literally reads email LIKE '_%example.com%', which is even less indexable.

Progress

Mark this lesson done and it stays marked in this browser.