SQL Fundamentals and Query Basics

Difficulty

SQL splits into four sub-languages, grouped by what kind of change a statement makes. This split matters because it affects transaction behavior, required privileges, and whether you can roll a change back.

The four categories

CategoryFull nameExample statementsWhat it affects
DDLData Definition LanguageCREATE TABLE, ALTER TABLE, DROP TABLE, TRUNCATESchema/structure (tables, indexes, constraints)
DMLData Manipulation LanguageSELECT, INSERT, UPDATE, DELETERow-level data
DCLData Control LanguageGRANT, REVOKEPermissions and access control
TCLTransaction Control LanguageCOMMIT, ROLLBACK, SAVEPOINT, SET TRANSACTIONTransaction boundaries
-- DDL: defines structure
CREATE TABLE accounts (
    id SERIAL PRIMARY KEY,
    balance NUMERIC(12,2) NOT NULL DEFAULT 0
);

-- DML: manipulates rows
INSERT INTO accounts (balance) VALUES (100.00);
UPDATE accounts SET balance = balance - 50 WHERE id = 1;

-- DCL: controls access
GRANT SELECT, INSERT ON accounts TO app_user;

-- TCL: controls the transaction
BEGIN;
UPDATE accounts SET balance = balance - 50 WHERE id = 1;
UPDATE accounts SET balance = balance + 50 WHERE id = 2;
COMMIT;

Why the distinction matters

Most databases auto-commit DDL. Some even implicitly commit any open transaction before running it. In MySQL, running ALTER TABLE mid-transaction causes an implicit commit — you can't roll back a schema change the way you can an UPDATE. PostgreSQL is an exception: it supports transactional DDL, so a CREATE TABLE inside a BEGIN...ROLLBACK block really does disappear.

DCL statements are usually not transactional either. Permission changes often take effect immediately and ROLLBACK won't undo them in many engines. Knowing which bucket a statement falls into tells you whether you can safely wrap it in a transaction for an atomic migration, or whether you need a different rollback strategy — like a paired "down" migration script.

You write a SELECT statement top to bottom, but the database evaluates it in a different logical order. Knowing that order explains several rules that would otherwise look arbitrary.

The logical order

1. FROM / JOIN     -- build the base row set
2. WHERE           -- filter individual rows
3. GROUP BY        -- bucket remaining rows into groups
4. HAVING          -- filter groups
5. SELECT          -- compute output expressions (incl. window functions)
6. DISTINCT        -- remove duplicate output rows
7. ORDER BY        -- sort the result
8. LIMIT / OFFSET  -- take a slice

This is the logical order, not necessarily the physical one. Real optimizers reorder execution — for example, pushing a WHERE predicate down before a join, or picking an index that satisfies ORDER BY for free — as long as the final result matches.

Why this explains common gotchas

You can't use a SELECT alias in WHERE:

-- Fails: "total" doesn't exist yet when WHERE is evaluated
SELECT price * quantity AS total FROM orders WHERE total > 100;

-- Works: recompute the expression, or move the filter to HAVING with a subquery/CTE
SELECT * FROM (
    SELECT price * quantity AS total FROM orders
) t WHERE total > 100;

But you can use it in ORDER BY. By the time ORDER BY runs, SELECT has already executed and the alias exists:

SELECT price * quantity AS total FROM orders ORDER BY total DESC;

WHERE can't filter on aggregates, HAVING can:

-- Fails: WHERE runs before GROUP BY, so COUNT(*) doesn't exist yet
SELECT customer_id, COUNT(*) FROM orders WHERE COUNT(*) > 5 GROUP BY customer_id;

-- Correct: HAVING runs after grouping
SELECT customer_id, COUNT(*) FROM orders GROUP BY customer_id HAVING COUNT(*) > 5;

Window functions see SELECT-time data, not raw rows. They run after WHERE/GROUP BY/HAVING but before DISTINCT/ORDER BY/LIMIT. That's why you generally can't reference a window function's result directly in the same SELECT's WHERE clause — you need to wrap it in a subquery or CTE and filter in the outer query instead.

Both clauses filter rows, but at different stages. Recall the logical execution order: FROM → WHERE → GROUP BY → HAVING → SELECT.

WHERE: filters rows before grouping

SELECT department, AVG(salary) AS avg_salary
FROM employees
WHERE hire_date >= '2020-01-01'   -- filters individual employee rows first
GROUP BY department;

HAVING: filters groups after aggregation

SELECT department, AVG(salary) AS avg_salary
FROM employees
GROUP BY department
HAVING AVG(salary) > 75000;       -- filters the resulting department groups

Combining both

SELECT department, COUNT(*) AS headcount
FROM employees
WHERE status = 'active'           -- row-level filter first
GROUP BY department
HAVING COUNT(*) >= 10;            -- group-level filter second

The rule of thumb

  • Use WHERE for conditions on raw column values that exist before grouping.
  • Use HAVING for conditions on the result of an aggregate function.
  • Putting an aggregate condition in WHERE (WHERE COUNT(*) > 5) fails, because COUNT(*) doesn't exist until GROUP BY has run.
  • Filter as early as possible with WHERE. It's almost always more efficient than HAVING, because it shrinks the row set before the often-expensive grouping work happens. Never use HAVING for a condition that WHERE could express.

Related Resources

NULL means "unknown" or "missing" — it isn't a value like zero or an empty string. That distinction drives SQL's three-valued logic.

The three truth values

Any predicate evaluates to TRUE, FALSE, or UNKNOWN. NULL compared to anything — including another NULL — produces UNKNOWN:

SELECT NULL = NULL;      -- UNKNOWN (not TRUE!)
SELECT NULL <> NULL;     -- UNKNOWN
SELECT 5 = NULL;         -- UNKNOWN
SELECT 5 <> NULL;        -- UNKNOWN

WHERE and HAVING only keep rows where the condition is TRUE. UNKNOWN is treated like FALSE for filtering, but it behaves differently under NOT — see below.

Testing for NULL correctly

-- Wrong: always UNKNOWN, matches nothing
SELECT * FROM users WHERE middle_name = NULL;

-- Correct
SELECT * FROM users WHERE middle_name IS NULL;
SELECT * FROM users WHERE middle_name IS NOT NULL;

The classic NOT IN trap

This is the single most common NULL bug in production SQL:

-- If banned_ids contains even one NULL, this returns ZERO rows,
-- because "x NOT IN (1, 2, NULL)" expands to
-- "x <> 1 AND x <> 2 AND x <> NULL", and the last comparison is UNKNOWN,
-- which poisons the whole AND chain to UNKNOWN.
SELECT * FROM users WHERE id NOT IN (SELECT banned_id FROM bans);

Fix it with NOT EXISTS, which handles NULLs correctly, or by filtering NULLs out of the subquery explicitly:

SELECT * FROM users u
WHERE NOT EXISTS (SELECT 1 FROM bans b WHERE b.banned_id = u.id);

Useful NULL-handling functions

COALESCE(a, b, c)     -- returns the first non-NULL argument
NULLIF(a, b)          -- returns NULL if a = b, otherwise returns a
-- Avoid division by zero producing an error, return NULL instead
SELECT revenue / NULLIF(units_sold, 0) AS avg_price FROM sales;

-- Provide a default for a possibly-NULL column
SELECT COALESCE(nickname, first_name) AS display_name FROM users;

One more gotcha: aggregate functions like SUM, AVG, and COUNT(column) all ignore NULLs silently. COUNT(*) counts rows, but COUNT(column) counts only non-NULL values in that column — a frequent source of off-by-some-amount bugs.

All three remove data or structure, but at very different granularities and costs.

DELETETRUNCATEDROP
RemovesMatching rows (or all rows)All rowsThe whole table object
Can filter with WHEREYesNoN/A
SpeedSlow for large tables (row-by-row logging)Fast (deallocates pages)Fast
Fires triggersYesUsually noNo
Resets identity/auto-incrementNoUsually yesN/A (table gone)
Transactional / rollback-ableYes, fullyDepends on engine (PostgreSQL: yes; MySQL/InnoDB: implicit commit)Depends on engine (same caveat)
Table structure afterwardUnchanged, empty or filteredUnchanged, emptyTable no longer exists
CategoryDMLDDL (in most engines)DDL
-- DELETE: row-level, filterable, fully logged
DELETE FROM orders WHERE status = 'cancelled';

-- TRUNCATE: removes everything, resets the table's storage
TRUNCATE TABLE orders;

-- DROP: the table itself is gone
DROP TABLE orders;

Why TRUNCATE is faster

DELETE scans and removes rows one by one, writing a log entry per row (or per page) so it can be rolled back and so triggers can fire. TRUNCATE instead deallocates the data pages that back the table all at once. It's closer to a DDL operation than a DML one, which is why many engines treat it as non-transactional or auto-committing.

  • Need to remove a subset of rows, want triggers to fire, or need full rollback mid-transaction? Use DELETE.
  • Need to empty a whole table fast (like clearing a staging table between ETL runs) and don't need row-level rollback? Use TRUNCATE.
  • Need to remove the table definition entirely, including its indexes and constraints? Use DROP.
  • Be careful with TRUNCATE on tables referenced by foreign keys. Most engines refuse to truncate a table that's the target of an active foreign key unless you cascade or disable the constraint first.

Related Resources