Part · Chapter

The SQL map

SQL has a few statement families, and every statement takes the same road through the server.

The short version

  • You say what, the engine says how. Two queries for the same rows can run at very different speeds.
  • Five families, plus admin. DDL, DML, DQL, DCL and TCL say if a ticket is a change window, a privilege call or a plan review.
  • A SELECT runs out of order. FROM, WHERE, GROUP BY, HAVING, SELECT, DISTINCT, ORDER BY, LIMIT. WHERE never heard of your alias.
  • MySQL DDL commits. CREATE, ALTER, DROP and TRUNCATE end your transaction; PostgreSQL can roll them back.
  • One road, one stop per problem. Syntax errors at the parser, slow plans at the optimizer, lock waits in storage.
  • Dialects differ in small things. Quoting, concatenation, generated ids, case sensitivity.

The five families of statements

Every statement belongs to a family, and the family tells you what it touches: structure, rows, permissions or the transaction.

FamilyStands forForStatements
DDLData Definition LanguageStructure: databases, tables, indexes, viewsCREATE, ALTER, DROP, TRUNCATE, RENAME
DMLData Manipulation LanguageAdd, change, remove rowsINSERT, UPDATE, DELETE, upserts
DQLData Query LanguageRead rowsSELECT, UNION, INTERSECT, EXCEPT
DCLData Control LanguageWho may do whatGRANT, REVOKE, CREATE USER, CREATE ROLE
TCLTransaction Control LanguageAll-or-nothing units of workSTART TRANSACTION / BEGIN, COMMIT, ROLLBACK, SAVEPOINT
AdminAdministrative and utilitySteer the server itselfSHOW, EXPLAIN, SET, FLUSH, KILL (MySQL); VACUUM, ANALYZE, REINDEX (PostgreSQL)

DQL is one statement, SELECT (MySQL added INTERSECT and EXCEPT in 8.0.31). DCL is yours to decide (chapter 14), TCL marks the unit of work (chapter 9), and the admin statements are much of your day (chapter 16). Client commands like psql's \dt are not SQL (chapter 6).

DDL: shaping the schema

CREATE, ALTER and DROP are the core; TRUNCATE empties a table and keeps its definition. Migrations and index changes are your hands-on time (chapter 3).

Careful: MySQL DDL commits

MySQL Every CREATE, ALTER, DROP and TRUNCATE implicitly commits the current transaction and cannot be rolled back. PostgreSQL DDL is transactional, so the same failed migration rolls back cleanly there.

DML: the upsert

INSERT, UPDATE and DELETE change rows. The one people forget is the upsert: insert a row, or update the existing one if its key is taken.

Illustration · MySQL
-- Reprice the mouse if it exists, add it if it does not.
INSERT INTO products (id, name, price)
VALUES (101, 'Mouse', 22.00) AS new
ON DUPLICATE KEY UPDATE price = new.price;
Illustration · PostgreSQL
-- The same upsert. EXCLUDED is the row that failed to insert.
INSERT INTO products (id, name, price)
VALUES (101, 'Mouse', 22.00)
ON CONFLICT (id) DO UPDATE SET price = EXCLUDED.price
RETURNING id, price;

For you as a DBA

Developers live in DML and DQL; you live in DDL, DCL, TCL and the admin statements. Ask which family a ticket is in.

Anatomy of a SELECT

A SELECT is written in one order and evaluated in another, and most "why does this not work" questions come from that gap.

StepClauseIts jobNote
1FROM and JOINName the tables and how they connectWritten second, evaluated first (chapter 7).
2WHEREKeep the rows that pass a test (a predicate), one row at a timeRuns before grouping, so no COUNT or SUM.
3GROUP BYFold rows into groupsChapter 8.
4HAVINGKeep the groups that pass a testMay use aggregates: groups now exist.
5SELECTCompute the output columns, name them with ASWritten first, evaluated fifth. Aliases are born here.
6DISTINCTRemove duplicate output rowsWorks on the finished rows.
7ORDER BYSort the outputCan use aliases in both engines. Window functions run just before it.
8LIMIT / OFFSETReturn only a slicePostgreSQL also accepts FETCH FIRST.

WHERE tests one row at a time, before grouping, which is why HAVING may say COUNT(*) > 1 and WHERE may not.

Illustration · Both
SELECT c.city, COUNT(*) AS orders, SUM(o.total) AS revenue
FROM orders AS o
JOIN customers AS c ON c.id = o.customer_id
WHERE o.status <> 'cancelled'
GROUP BY c.city
HAVING SUM(o.total) > 30
ORDER BY revenue DESC
LIMIT 5;
Result: ORDER BY may say revenue because SELECT just created it
cityordersrevenue
Dubai2245.00
Cairo140.00

The alias rule

An alias from SELECT cannot be used in WHERE, in either engine. ORDER BY and GROUP BY can. To filter on a computed value, repeat the expression or use a subquery (chapter 13).

Illustration · Both
-- Fails in both engines: WHERE runs before SELECT names anything.
SELECT id, ROUND(total * 1.05, 2) AS with_tax
FROM orders
WHERE with_tax > 100;

-- Works: repeat the expression (or move the query into a subquery).
SELECT id, ROUND(total * 1.05, 2) AS with_tax
FROM orders
WHERE ROUND(total * 1.05, 2) > 100
ORDER BY with_tax;
Result of the second query only
idwith_tax
11189.00

Remember

The logical order is FROM, WHERE, GROUP BY, HAVING, SELECT, DISTINCT, ORDER BY, LIMIT. An alias is born in SELECT, so WHERE cannot see it and ORDER BY can.

How a query travels through the server

Every statement takes the same road, and each kind of problem belongs to one stop.

SQL text travels left to right Client Workbench, psql, your application Connection login, session, settings Parser syntax check, name resolution Optimizer picks indexes, join order Executor runs the plan step by step Storage engine InnoDB (MySQL), heap files (PG) rows, row counts, warnings and errors travel back
Syntax errors stop at the parser, slow plans come from the optimizer, lock waits from storage.

The parser rejects bad syntax and unknown columns before a row is read. The optimizer costs its plan from statistics, so stale statistics mean bad plans; EXPLAIN prints the plan it chose, and EXPLAIN ANALYZE adds real timings (chapter 10). Writes are logged before the server answers, which is what makes backups and replicas possible (chapter 15).

MySQL serves each connection with a thread on port 3306; PostgreSQL starts a process per connection on 5432, which is why poolers come up more often there.

Sessions and the settings that travel with them

A session is your connection's memory: who you are, which database, and settings that change how statements behave.

Autocommit
On in both engines: each statement commits the moment it succeeds. START TRANSACTION or BEGIN suspends that, so you can check the row count before a DELETE becomes permanent.
MySQL sql_mode
Flags that decide how strictly MySQL treats bad data. The default is strict, and ONLY_FULL_GROUP_BY is why old tutorials fail on a new server. SET PERSIST survives a restart.
PostgreSQL search_path
The schemas an unqualified name is looked up in, "$user", public by default. Two teams can each have their own orders.
Illustration · MySQL
-- Where am I, and what mood is the session in?
USE shop;
SELECT DATABASE(), @@autocommit, @@sql_mode;
SET SESSION sql_mode = 'STRICT_TRANS_TABLES,NO_ENGINE_SUBSTITUTION';
Illustration · PostgreSQL
-- psql: \c is a client command, so no semicolon
\c shop
SELECT current_database(), current_user;
SHOW search_path;
SET search_path = reporting, public;   -- this session only

Dialect differences that trip a returning person

Both engines take the standard core. The differences hide in quoting names, gluing strings and numbering rows (chapter 17).

ThingMySQL 8.4 / 9.7 / 26.xPostgreSQL 17 / 18
Quoting a nameBackticks: `order`. Double quotes make a string unless ANSI_QUOTES is set.Double quotes: "order". Unquoted names fold to lowercase; a quoted name keeps its case.
String literalsSingle quotes (double quotes also work).Single quotes only. "paid" is a column name.
ConcatenationCONCAT(a, b). || means OR unless PIPES_AS_CONCAT is set.a || b, and CONCAT() too (it skips NULLs; || does not).
Limiting rowsLIMIT 10 OFFSET 20, or older LIMIT 20, 10.LIMIT 10 OFFSET 20, or OFFSET 20 ROWS FETCH FIRST 10 ROWS ONLY.
Auto-numbered idsAUTO_INCREMENT, read back with LAST_INSERT_ID().GENERATED ALWAYS AS IDENTITY (older scripts: SERIAL), read back with RETURNING id.
DatesNOW(), CURDATE(), DATE_ADD(d, INTERVAL 7 DAY), DATE_FORMAT(d, '%Y-%m').now(), CURRENT_DATE, d + INTERVAL '7 days', to_char(d, 'YYYY-MM'). Both do EXTRACT.
Table name caseFollows lower_case_table_names: 1 on Windows, 0 on Linux. Fixed at initialization.Unquoted names are lowercase on every OS, so Orders and orders are one table.
Comparing textCase-insensitive by default (utf8mb4_0900_ai_ci).Case-sensitive: use ILIKE or lower().
Boolean columnsBOOLEAN is TINYINT(1); TRUE and FALSE are 1 and 0.A real boolean, shown as t and f; WHERE active works alone.
UpsertINSERT … ON DUPLICATE KEY UPDATE, REPLACE.INSERT … ON CONFLICT, MERGE (15+).
Illustration · Both
-- MySQL spelling
SELECT CONCAT(name, ' (', city, ')') AS label FROM customers;

-- PostgreSQL spelling
SELECT name || ' (' || city || ')' AS label FROM customers;
Result in both: NULL swallows Chloé's label
label
Amira (Dubai)
Ben (Cairo)
NULL
Dev (Riyadh)

Your Windows server treats Orders and orders as one table, so a mixed-case script works for you and fails on a Linux replica. Keep names lowercase.

CapabilityMySQL 8.4 / 9.7 / 26.xPostgreSQL 17 / 18
ROLLBACK undoes CREATE, ALTER, DROP, TRUNCATENo (implicit commit)Yes
UpsertYes ON DUPLICATE KEY UPDATE, REPLACEYes ON CONFLICT, MERGE (15+)
RETURNING on INSERT, UPDATE, DELETENo (LAST_INSERT_ID() for ids)Yes
FETCH FIRST n ROWS ONLYNo (LIMIT only)Yes (and LIMIT)
INTERSECT and EXCEPTYes since 8.0.31Yes
Native boolean typePartial TINYINT(1) in disguiseYes
Schemas inside a databaseNo (schema = database)Yes (search_path picks)

Asking the AI for the exact syntax

Let an assistant write the syntax. A prompt that works carries four things.

Engine and version
"MySQL 8.4" or "PostgreSQL 17". A feature may not exist in yours (MERGE before PostgreSQL 15).
The table definitions
Paste SHOW CREATE TABLE orders or \d orders.
The rows you expect
"One row per customer, including customers with no orders" tells it you need an outer join.
A request to explain
You will be the one asked about it later.

Then verify: run it in a transaction you roll back, compare the rows, and EXPLAIN it before production (chapter 10, chapter 18).

Ask the AI

"MySQL 8.4. Tables: customers(id, name, city) and orders(id, customer_id, status, total); customer_id is NULL for guest checkouts. List every customer with the count and total of their paid orders in 2026, including customers with no orders. Explain each clause and its logical step."

Quick check

1. A developer's ticket says "please run GRANT SELECT ON shop.* TO 'reporting'@'%';". Which family is that statement in, and what does that tell you?

GRANT and REVOKE are Data Control Language: they decide who may do what, and deciding is your job. Reread "The five families of statements."

2. SELECT id, ROUND(total * 1.05, 2) AS with_tax FROM orders WHERE with_tax > 100; fails with "unknown column with_tax". Why?

The logical order is FROM, WHERE, GROUP BY, HAVING, SELECT, then ORDER BY. An alias is born in SELECT, so WHERE cannot see it in either engine, while ORDER BY can. Repeat the expression or use a subquery. Reread "Anatomy of a SELECT."

3. On MySQL 8.4 you run, in one session: START TRANSACTION; DELETE FROM orders WHERE id = 14; CREATE INDEX ix_status ON orders (status); ROLLBACK;. What is the state of order 14 afterwards?

MySQL DDL causes an implicit commit, so the DELETE was committed the moment CREATE INDEX started, and ROLLBACK had nothing left to undo. On PostgreSQL the whole sequence would roll back. Reread "DDL: shaping the schema" and the "Careful: MySQL DDL commits" callout.

4. A report query suddenly takes minutes instead of seconds, with no change to its text. Which stop in the pipeline most likely changed its mind?

The optimizer picks indexes and join order from statistics, so the same text can get a different, worse plan when the data or its statistics change. EXPLAIN shows what it picked. Reread "How a query travels through the server."

5. A script from a Linux server contains SELECT * FROM Orders; (capital O). The table is called orders. What happens on your Windows MySQL 8.4 server?

On Windows MySQL stores table names in lowercase and compares them case-insensitively (lower_case_table_names = 1); on Linux the default is 0 and Orders and orders are different tables. The setting is fixed when the server is initialized. Reread "Dialect differences that trip a returning person."