A reference of the core SQL constructs with examples run on PostgreSQL 17 against a training shop database: SELECT, WHERE, JOIN, GROUP BY, window functions, CTEs, INSERT … ON CONFLICT, UPDATE and DELETE with joins, DDL, indexes, transactions and functions. A query is not executed in the order it is written: FROM → WHERE → GROUP BY → HAVING → SELECT → ORDER BY → LIMIT, which is why an alias from SELECT is not visible in WHERE. Where MySQL 8.4 behaves differently, a verified equivalent is given.
After this you will be able to
- Find the syntax and a verified example for
SELECT,JOIN,GROUP BY, window functions, CTEs,ON CONFLICT, DDL and transactions - Explain from the query execution order why a
SELECTalias doesn’t work inWHEREand why a window function can’t be filtered inWHERE - Translate a query between PostgreSQL and MySQL 8.4 using the differences table
Know beforehand
Verified with
PostgreSQL 17 (17.11, образ postgres:17-alpine)psql 17MySQL 8.4 (8.4.11, образ mysql:8.4) — для пометок об отличияхDocker Engine 29
Checked against docs: October 12, 2026
Contents
- SELECT: query template and execution order
- WHERE: comparison operators and NULL
- JOIN, GROUP BY and HAVING
- Window functions: ROW_NUMBER, RANK, SUM OVER
- Subqueries and CTEs (WITH)
- INSERT, UPSERT (ON CONFLICT), UPDATE and DELETE with JOIN
- DDL, constraints, indexes and transactions
- Functions: dates, strings, NULL and CASE
SELECT: query template and execution order
Every example was run on PostgreSQL 17.11 (the postgres:17-alpine image) against the training shop database from the “SQL from scratch” lesson: tables clients, products, orders and order_items; the shop.sql script is there too. The database was reloaded before each example, so changes from one query don’t affect another. Under the code is the real psql output. MySQL differences were checked on MySQL 8.4.11.
SELECT category,
count(*) AS items,
min(price) AS from_price
FROM products
WHERE price < 15000
GROUP BY category
HAVING count(*) > 1
ORDER BY from_price
LIMIT 3;tea
- items
- 2
- from_price
- 3200.00
coffee
- items
- 3
- from_price
- 4900.00
equipment group is missing: after WHERE only one product was left in it, and HAVING filtered it out.1
- Clause
WITH- What happens
- CTEs — temporary named results — are computed
2
- Clause
FROM,JOIN- What happens
- Rows are gathered from tables and joins
3
- Clause
WHERE- What happens
- Rows for which the condition is not true are discarded
4
- Clause
GROUP BY,HAVING- What happens
- Rows are grouped, aggregates computed,
HAVINGfilters groups
5
- Clause
SELECT- What happens
- Expressions,
ASaliases and window functions are computed
6
- Clause
DISTINCT- What happens
- Duplicate rows are removed
7
- Clause
UNION/INTERSECT/EXCEPT- What happens
- Results of several queries are combined
8
- Clause
ORDER BY- What happens
- Sorting; aliases from
SELECTare available here
9
- Clause
LIMIT/OFFSET- What happens
- The requested slice of rows is returned
SELECT name, price * 0.9 AS sale_price
FROM products
WHERE sale_price < 4000;
-- ERROR: column "sale_price" does not exist
-- LINE 3: WHERE sale_price < 4000;
-- ^WHERE and HAVING you repeat the expression; in ORDER BY and GROUP BY the output column name is allowed.SELECT id, name, price
FROM products
ORDER BY price DESC, id
LIMIT 3 OFFSET 3;7
- name
- French Press 600 ml
- price
- 8900.00
3
- name
- Ethiopia Yirgacheffe 250 g
- price
- 5600.00
2
- name
- Colombia Supremo 250 g
- price
- 4900.00
id makes the order unambiguous.SELECT id FROM products ORDER BY id LIMIT 3, 3;
-- ERROR: LIMIT #,# syntax is not supported
-- LINE 1: SELECT id FROM products ORDER BY id LIMIT 3, 3;
-- ^
-- HINT: Use separate LIMIT and OFFSET clauses.WHERE: comparison operators and NULL
price = 4900
- Rows
- 1
- What matters
- Equality. Strings and dates go in single quotes
category <> 'coffee'
- Rows
- 6
- What matters
- “Not equal”;
!=is the same
price BETWEEN 3000 AND 6000
- Rows
- 4
- What matters
- Both bounds are included
category IN ('tea', 'gifts')
- Rows
- 3
- What matters
- Shorthand for several
ORs on one column
name LIKE '%250 g'
- Rows
- 2
- What matters
%is any substring,_exactly one character; case-sensitive
name ILIKE '%MUG%'
- Rows
- 1
- What matters
- Case-insensitive. PostgreSQL only
weight_g IS NULL
- Rows
- 4
- What matters
- The only way to find
NULL
weight_g <> 250
- Rows
- 3
- What matters
- Only 3: rows with
NULLdropped out, comparing withNULLyieldsNULL
weight_g IS DISTINCT FROM 250
- Rows
- 7
- What matters
- 7 = 3 + 4 rows with
NULL: hereNULLis an ordinary value
id = ANY (ARRAY[1, 2, 3])
- Rows
- 3
- What matters
- Handy for passing an array from the app as a single parameter
NOT (category = 'coffee' OR price > 10000)
- Rows
- 4
- What matters
NOTand parentheses:ANDbinds tighter thanOR
SELECT count(*) FROM products WHERE <condition> on the training database (9 products, 4 with a NULL weight).- MySQL 8.4: there is no
ILIKE— the query fails with syntax error 1064. WhetherLIKEis case-sensitive depends on the column’s collation. - MySQL 8.4:
IS DISTINCT FROMalso fails with error 1064. The equivalent is the NULL-safe equality operator<=>:NOT (city <=> 'Almaty'). - Values coming from users are passed as parameters (
$1in PostgreSQL,?in MySQL drivers), never concatenated into the query string — otherwise you get SQL injection.
JOIN, GROUP BY and HAVING
JOIN (INNER JOIN)
- What it returns
- Only pairs that matched the
ONcondition - Example
clients JOIN orders- Rows
- 10
LEFT JOIN
- What it returns
- All rows of the left table;
NULLin the right table’s columns when there is no match - Example
clients LEFT JOIN orders- Rows
- 12 (two clients without orders)
RIGHT JOIN
- What it returns
- Mirror of
LEFT JOIN: all rows of the right table - Example
orders RIGHT JOIN clients- Rows
- 12
FULL JOIN
- What it returns
- All rows of both tables, the missing side is
NULL - Example
order_items FULL JOIN products- Rows
- 16 (15 lines and an unsold product)
CROSS JOIN
- What it returns
- Every row with every row, no condition
- Example
clients CROSS JOIN products- Rows
- 72 = 8 × 9
SELECT c.name
FROM clients AS c
LEFT JOIN orders AS o ON o.client_id = c.id
WHERE o.id IS NULL
ORDER BY c.id;Zarina Kaliyeva
Sergey Pak
LEFT JOIN plus an IS NULL check on the right table’s key.SELECT count(*) AS all_rows,
count(weight_g) AS with_weight,
count(DISTINCT category) AS categories,
sum(weight_g) AS total_g,
round(avg(weight_g)) AS avg_g
FROM products;9
- with_weight
- 5
- categories
- 5
- total_g
- 1800
- avg_g
- 360
count(*) counts rows, count(column) only non-NULL values; sum and avg skip NULL too.SELECT p.category,
sum(oi.quantity * oi.price) AS revenue,
count(DISTINCT o.id) FILTER (WHERE o.promo_code IS NOT NULL) AS promo_orders
FROM order_items AS oi
JOIN orders AS o ON o.id = oi.order_id
JOIN products AS p ON p.id = oi.product_id
WHERE o.status IN ('paid', 'shipped')
GROUP BY p.category
HAVING sum(oi.quantity * oi.price) > 10000
ORDER BY revenue DESC;coffee
- revenue
- 77500.00
- promo_orders
- 3
equipment
- revenue
- 27400.00
- promo_orders
- 1
tea
- revenue
- 10800.00
- promo_orders
- 1
HAVING removed categories under 10,000 ₸.FILTER (WHERE …) restricts the input of a single aggregate without touching the others. MySQL 8.4 has no FILTER — the same is counted with CASE: count(CASE WHEN status = 'paid' THEN 1 END). Joins, duplicates after JOIN and grouping are covered in detail in the lesson “SQL: JOIN, GROUP BY and aggregates”.
Window functions: ROW_NUMBER, RANK, SUM OVER
A window function computes a value over a set of rows related to the current one but, unlike GROUP BY, does not collapse the rows. Syntax: function() OVER (PARTITION BY … ORDER BY …). PARTITION BY splits rows into groups, ORDER BY sets the order inside a group.
row_number()
- What it returns
- Row number within the partition: 1, 2, 3… no repeats
rank()
- What it returns
- Rank with gaps after ties: 1, 2, 2, 4
dense_rank()
- What it returns
- Rank without gaps: 1, 2, 2, 3
lag(x) / lead(x)
- What it returns
- Value of
xfrom the previous / next row, orNULL
first_value(x) / last_value(x)
- What it returns
- First / last value in the window frame
ntile(n)
- What it returns
- Which of
nroughly equal buckets the row falls into
sum(x) OVER (…), avg, count
- What it returns
- Aggregate over the window: with
ORDER BYa running total, without it a total for the whole partition
SELECT client_id, id AS order_id, ordered_on, status
FROM (
SELECT o.*,
row_number() OVER (PARTITION BY client_id ORDER BY ordered_on DESC) AS rn
FROM orders AS o
) AS t
WHERE rn = 1
ORDER BY client_id;1
- order_id
- 9
- ordered_on
- 2026-10-08
- status
- paid
2
- order_id
- 10
- ordered_on
- 2026-10-09
- status
- new
3
- order_id
- 7
- ordered_on
- 2026-10-03
- status
- shipped
4
- order_id
- 5
- ordered_on
- 2026-09-28
- status
- shipped
5
- order_id
- 6
- ordered_on
- 2026-10-01
- status
- paid
6
- order_id
- 8
- ordered_on
- 2026-10-05
- status
- new
rn = 1 filter in the outer query.WITH totals AS (
SELECT o.id, o.ordered_on, sum(oi.quantity * oi.price) AS total
FROM orders AS o
JOIN order_items AS oi ON oi.order_id = o.id
WHERE o.status IN ('paid', 'shipped')
GROUP BY o.id
)
SELECT id, ordered_on, total,
sum(total) OVER (ORDER BY ordered_on) AS running_total,
total - lag(total) OVER (ORDER BY ordered_on) AS diff_prev,
round(100 * total / sum(total) OVER (), 1) AS pct
FROM totals
ORDER BY ordered_on;1
- ordered_on
- 2026-09-02
- total
- 17900.00
- running_total
- 17900.00
- diff_prev
- NULL
- pct
- 14.5
2
- ordered_on
- 2026-09-05
- total
- 13000.00
- running_total
- 30900.00
- diff_prev
- -4900.00
- pct
- 10.6
3
- ordered_on
- 2026-09-14
- total
- 8900.00
- running_total
- 39800.00
- diff_prev
- -4100.00
- pct
- 7.2
5
- ordered_on
- 2026-09-28
- total
- 15700.00
- running_total
- 55500.00
- diff_prev
- 6800.00
- pct
- 12.7
6
- ordered_on
- 2026-10-01
- total
- 25800.00
- running_total
- 81300.00
- diff_prev
- 10100.00
- pct
- 20.9
7
- ordered_on
- 2026-10-03
- total
- 10500.00
- running_total
- 91800.00
- diff_prev
- -15300.00
- pct
- 8.5
9
- ordered_on
- 2026-10-08
- total
- 31400.00
- running_total
- 123200.00
- diff_prev
- 20900.00
- pct
- 25.5
SELECT p.name, oi.price,
rank() OVER (ORDER BY oi.price DESC) AS rnk,
dense_rank() OVER (ORDER BY oi.price DESC) AS dense,
row_number() OVER (ORDER BY oi.price DESC, p.name) AS rn
FROM order_items AS oi
JOIN products AS p ON p.id = oi.product_id
WHERE oi.order_id IN (1, 5, 9)
ORDER BY oi.price DESC, p.name;Hand Grinder
- price
- 18500.00
- rnk
- 1
- dense
- 1
- rn
- 1
Espresso Blend 1 kg
- price
- 12900.00
- rnk
- 2
- dense
- 2
- rn
- 2
Espresso Blend 1 kg
- price
- 12900.00
- rnk
- 2
- dense
- 2
- rn
- 3
Ethiopia Yirgacheffe 250 g
- price
- 5600.00
- rnk
- 4
- dense
- 3
- rn
- 4
Masala Chai 200 g
- price
- 3800.00
- rnk
- 5
- dense
- 4
- rn
- 5
Ceramic Mug 350 ml
- price
- 2500.00
- rnk
- 6
- dense
- 5
- rn
- 6
Ceramic Mug 350 ml
- price
- 2500.00
- rnk
- 6
- dense
- 5
- rn
- 7
rank skips places, dense_rank doesn’t, row_number is always unique.SELECT name, row_number() OVER (ORDER BY price) AS rn
FROM products
WHERE row_number() OVER (ORDER BY price) <= 3;
-- ERROR: window functions are not allowed in WHERE
-- LINE 3: WHERE row_number() OVER (ORDER BY price) <= 3;
-- ^WHERE, GROUP BY and HAVING, so you filter on them in an outer query or a CTE, as in the latest-order example.Subqueries and CTEs (WITH)
Scalar subquery
- Example
price - (SELECT avg(price) FROM products)- When
- Compare with a single computed value
IN (subquery)
- Example
id IN (SELECT client_id FROM orders …)- When
- The value is in a list from another table
EXISTS / NOT EXISTS
- Example
NOT EXISTS (SELECT 1 FROM order_items oi WHERE oi.product_id = p.id)- When
- Is there a match;
NOT EXISTSis safer thanNOT INwhen the list may containNULL
Subquery in FROM
- Example
FROM (SELECT …, row_number() OVER (…) AS rn …) AS t- When
- Filter on a window function or aggregate result
WITH (CTE)
- Example
WITH totals AS (…) SELECT … FROM totals- When
- Split a report into named steps
WITH RECURSIVE
- Example
- A date calendar, a category tree
- When
- Repeat a step while it returns rows
LATERAL
- Example
CROSS JOIN LATERAL (… LIMIT 1)- When
- A subquery per row on the left: “latest N per group”
SELECT name, price, round(price - (SELECT avg(price) FROM products), 2) AS vs_avg
FROM products WHERE category = 'coffee' ORDER BY id;
SELECT name FROM clients
WHERE id IN (SELECT client_id FROM orders WHERE promo_code IS NOT NULL) ORDER BY id;
SELECT p.name FROM products AS p
WHERE NOT EXISTS (SELECT 1 FROM order_items AS oi WHERE oi.product_id = p.id);
-- name | price | vs_avg
-- ----------------------------+----------+----------
-- Espresso Blend 1 kg | 12900.00 | 4533.33
-- Colombia Supremo 250 g | 4900.00 | -3466.67
-- Ethiopia Yirgacheffe 250 g | 5600.00 | -2766.67
-- (3 rows)
--
-- name
-- ------------------
-- Aigerim Seitova
-- Daniyar Akhmetov
-- Aliya Bekova
-- (3 rows)
--
-- name
-- ----------
-- Gift Box
-- (1 row)WITH RECURSIVE days AS (
SELECT date '2026-10-01' AS day
UNION ALL
SELECT day + 1 FROM days WHERE day < date '2026-10-07'
)
SELECT d.day, count(o.id) AS orders
FROM days AS d
LEFT JOIN orders AS o ON o.ordered_on = d.day
GROUP BY d.day
ORDER BY d.day;2026-10-01
- orders
- 1
2026-10-02
- orders
- 0
2026-10-03
- orders
- 1
2026-10-04
- orders
- 0
2026-10-05
- orders
- 1
2026-10-06
- orders
- 0
2026-10-07
- orders
- 0
LEFT JOIN shows days without orders too.SELECT c.name, last.id AS order_id, last.ordered_on
FROM clients AS c
CROSS JOIN LATERAL (
SELECT o.id, o.ordered_on
FROM orders AS o
WHERE o.client_id = c.id
ORDER BY o.ordered_on DESC
LIMIT 1
) AS last
ORDER BY c.id
LIMIT 3;Aigerim Seitova
- order_id
- 9
- ordered_on
- 2026-10-08
Daniyar Akhmetov
- order_id
- 10
- ordered_on
- 2026-10-09
Madina Omarova
- order_id
- 7
- ordered_on
- 2026-10-03
LATERAL: for each clients row the subquery runs with its own c.id.PostgreSQL inlines a CTE into the main query when it is non-recursive, side-effect-free and referenced once; the MATERIALIZED and NOT MATERIALIZED hints change this. The recursive part must eventually return zero rows, or the query loops forever. The WITH, WITH RECURSIVE and LATERAL from these examples also work in MySQL 8.4.
INSERT, UPSERT (ON CONFLICT), UPDATE and DELETE with JOIN
INSERT INTO clients (name, city, email)
VALUES ('Asel Nurlanova', 'Almaty', 'asel@example.com'),
('Bauyrzhan Ospanov', 'Astana', NULL)
RETURNING id, name;
-- id | name
-- ----+-------------------
-- 9 | Asel Nurlanova
-- 10 | Bauyrzhan Ospanov
-- (2 rows)
--
-- INSERT 0 2RETURNING returns the created rows, including the generated id, without a second query.INSERT INTO clients (name, city, email)
VALUES ('Madina Omarova', 'Astana', 'madina@example.com')
ON CONFLICT (email) DO UPDATE
SET city = EXCLUDED.city
RETURNING id, name, city;
INSERT INTO clients (name, city, email)
VALUES ('Madina O.', 'Shymkent', 'madina@example.com')
ON CONFLICT (email) DO NOTHING
RETURNING id;
INSERT INTO clients (name, city, email)
VALUES ('Nurlan Abenov', 'Almaty', 'nurlan@example.com')
RETURNING id;
-- id | name | city
-- ----+----------------+--------
-- 3 | Madina Omarova | Astana
-- (1 row)
--
-- INSERT 0 1
-- id
-- ----
-- (0 rows)
--
-- INSERT 0 0
-- id
-- ----
-- 11
-- (1 row)
--
-- INSERT 0 1email: update, skip and a plain insert.EXCLUDED is the row you tried to insert; the table name or its alias is the row that already exists. DO NOTHING silently skips the conflict and returns nothing from RETURNING. Note the last id: 11, not 9 — identity values are consumed even by inserts that ended in a conflict. Ids are not guaranteed to be gapless, and no logic should rely on that.
INSERT INTO products (name, category, price)
VALUES ('Gift Box', 'gifts', 16000)
ON CONFLICT (name) DO UPDATE SET price = EXCLUDED.price;
-- ERROR: there is no unique or exclusion constraint matching the ON CONFLICT specificationON CONFLICT (columns) only works when there is a unique index or constraint on exactly those columns.UPDATE orders AS o
SET status = 'shipped'
FROM clients AS c
WHERE c.id = o.client_id
AND c.city = 'Almaty'
AND o.status = 'paid'
RETURNING o.id, c.name, o.status;
-- id | name | status
-- ----+-----------------+---------
-- 3 | Aigerim Seitova | shipped
-- 9 | Aigerim Seitova | shipped
-- (2 rows)
--
-- UPDATE 2UPDATE … FROM: join another table right inside the update.DELETE FROM order_items AS oi
USING orders AS o
WHERE o.id = oi.order_id
AND o.status = 'cancelled'
RETURNING oi.order_id, oi.product_id, oi.price;
-- order_id | product_id | price
-- ----------+------------+----------
-- 4 | 8 | 18500.00
-- (1 row)
--
-- DELETE 1DELETE … USING deletes by a condition on another table. To empty a whole table, TRUNCATE is faster.INSERT INTO clients (name, city, email)
VALUES ('Madina Omarova', 'Astana', 'madina@example.com') AS new
ON DUPLICATE KEY UPDATE city = new.city;
UPDATE orders AS o
JOIN clients AS c ON c.id = o.client_id
SET o.status = 'shipped'
WHERE c.city = 'Almaty' AND o.status = 'paid';
DELETE o FROM orders AS o
JOIN clients AS c ON c.id = o.client_id
WHERE o.status = 'cancelled';VALUES() in ON DUPLICATE KEY UPDATE is deprecated — use the row alias AS new. MySQL has no RETURNING: a query with it fails with syntax error 1064.DDL, constraints, indexes and transactions
CREATE TABLE reviews (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
product_id bigint NOT NULL REFERENCES products (id) ON DELETE CASCADE,
client_id bigint NOT NULL REFERENCES clients (id),
rating smallint NOT NULL CHECK (rating BETWEEN 1 AND 5),
body text,
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (product_id, client_id)
);
ALTER TABLE reviews ADD COLUMN is_published boolean NOT NULL DEFAULT false;
ALTER TABLE reviews ALTER COLUMN body TYPE varchar(2000);
ALTER TABLE reviews RENAME COLUMN body TO comment;
ALTER TABLE reviews ADD CONSTRAINT comment_not_blank CHECK (comment <> '');
ALTER TABLE reviews DROP COLUMN is_published;ALTER TABLE commands. psql answered CREATE TABLE and ALTER TABLE to each one, with no errors.UNIQUE
- SQLSTATE
- 23505
- PostgreSQL message
duplicate key value violates unique constraint "reviews_product_id_client_id_key"
CHECK
- SQLSTATE
- 23514
- PostgreSQL message
new row for relation "reviews" violates check constraint "reviews_rating_check"
FOREIGN KEY
- SQLSTATE
- 23503
- PostgreSQL message
insert or update on table "reviews" violates foreign key constraint "reviews_product_id_fkey"
NOT NULL
- SQLSTATE
- 23502
- PostgreSQL message
null value in column "rating" of relation "reviews" violates not-null constraint
reviews (output with \set VERBOSITY verbose). In application code, check the SQLSTATE code rather than the text.CHECKlets a row through when the expression is true orNULL— addNOT NULLfor a required field.UNIQUEallows severalNULLs by default;NULLS NOT DISTINCTforbids repeatedNULLs.PRIMARY KEY=UNIQUE+NOT NULLand creates an index automatically. Foreign key columns get no index — you usually create one yourself.- Foreign key actions:
ON DELETE NO ACTION(default),RESTRICT,CASCADE,SET NULL,SET DEFAULT.
CREATE INDEX orders_client_id_idx ON orders (client_id);
CREATE INDEX orders_status_date_idx ON orders (status, ordered_on DESC);
CREATE INDEX orders_new_idx ON orders (ordered_on) WHERE status = 'new';
CREATE UNIQUE INDEX clients_email_lower_key ON clients (lower(email));
CREATE INDEX CONCURRENTLY products_category_idx ON products (category);
DROP INDEX IF EXISTS products_category_idx;
SELECT indexname, indexdef FROM pg_indexes WHERE tablename = 'orders' ORDER BY indexname;orders_client_id_idx
- indexdef
- CREATE INDEX orders_client_id_idx ON public.orders USING btree (client_id)
orders_new_idx
- indexdef
- CREATE INDEX orders_new_idx ON public.orders USING btree (ordered_on) WHERE (status = 'new'::text)
orders_pkey
- indexdef
- CREATE UNIQUE INDEX orders_pkey ON public.orders USING btree (id)
orders_status_date_idx
- indexdef
- CREATE INDEX orders_status_date_idx ON public.orders USING btree (status, ordered_on DESC)
orders after the commands: plain, composite, partial and the primary key index.CREATE INDEX CONCURRENTLY builds an index without blocking writes, but it cannot run inside BEGIN … COMMIT: PostgreSQL answers CREATE INDEX CONCURRENTLY cannot run inside a transaction block. In MySQL 8.4 an expression index is written in double parentheses — ((lower(email))) — and there are no partial indexes with WHERE (error 1064). How to choose indexes and check them with EXPLAIN is covered in the “Indexes” article.
BEGIN;
UPDATE products SET price = price * 1.1 WHERE category = 'tea';
SAVEPOINT before_mugs;
UPDATE products SET price = 0 WHERE id = 6;
ROLLBACK TO SAVEPOINT before_mugs;
COMMIT;
SELECT id, name, price FROM products WHERE id IN (4, 5, 6) ORDER BY id;
-- BEGIN
-- UPDATE 2
-- SAVEPOINT
-- ERROR: new row for relation "products" violates check constraint "products_price_check"
-- DETAIL: Failing row contains (6, Ceramic Mug 350 ml, tableware, 0.00, null).
-- ROLLBACK
-- COMMIT
-- id | name | price
-- ----+--------------------+---------
-- 4 | Green Sencha 100 g | 3520.00
-- 5 | Masala Chai 200 g | 4180.00
-- 6 | Ceramic Mug 350 ml | 2500.00
-- (3 rows)SAVEPOINT: rolling back to the savepoint keeps the other changes — tea went up 10%, the mug didn’t change.BEGIN;
INSERT INTO clients (name, email) VALUES ('Test', 'aigerim@example.com');
SELECT count(*) FROM clients;
ROLLBACK;
-- BEGIN
-- ERROR: duplicate key value violates unique constraint "clients_email_key"
-- DETAIL: Key (email)=(aigerim@example.com) already exists.
-- ERROR: current transaction is aborted, commands ignored until end of transaction block
-- ROLLBACKROLLBACK.BEGIN;
CREATE TABLE tmp_promo (code text PRIMARY KEY);
ROLLBACK;
SELECT to_regclass('tmp_promo') AS table_after_rollback;
-- BEGIN
-- CREATE TABLE
-- ROLLBACK
-- table_after_rollback
-- ----------------------
-- NULL
-- (1 row)ROLLBACK. In MySQL 8.4 CREATE TABLE causes an implicit COMMIT — after the same ROLLBACK the table was still there.BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT id, status FROM orders WHERE id = 8 FOR UPDATE;
UPDATE orders SET status = 'paid' WHERE id = 8;
COMMIT;
-- BEGIN
-- id | status
-- ----+--------
-- 8 | new
-- (1 row)
--
-- UPDATE 1
-- COMMITBEGIN; SELECT … FOR UPDATE locks the row until the transaction ends. When you need this is covered in “Transactions and isolation”.Functions: dates, strings, NULL and CASE
SELECT id,
ordered_on,
ordered_on + 7 AS plus_7_days,
ordered_on + interval '1 month' AS plus_month,
date_trunc('month', ordered_on)::date AS month_start,
extract(isodow FROM ordered_on) AS iso_weekday,
to_char(ordered_on, 'DD.MM.YYYY') AS ru_format,
date '2026-10-12' - ordered_on AS days_ago
FROM orders
WHERE id IN (1, 10)
ORDER BY id;1
- ordered_on
- 2026-09-02
- plus_7_days
- 2026-09-09
- plus_month
- 2026-10-02 00:00:00
- month_start
- 2026-09-01
- iso_weekday
- 3
- ru_format
- 02.09.2026
- days_ago
- 40
10
- ordered_on
- 2026-10-09
- plus_7_days
- 2026-10-16
- plus_month
- 2026-11-09 00:00:00
- month_start
- 2026-10-01
- iso_weekday
- 5
- ru_format
- 09.10.2026
- days_ago
- 3
date + integer is a date, date + interval is a timestamp; isodow is the weekday from 1 (Mon) to 7 (Sun).SELECT name,
upper(split_part(name, ' ', 1)) AS first_word,
length(name) AS len,
left(name, 8) || '...' AS short,
replace(name, ' g', ' гр') AS ru_unit,
coalesce(weight_g::text, 'n/a') AS weight,
nullif(category, 'gifts') AS cat_or_null
FROM products
WHERE id IN (2, 4, 9)
ORDER BY id;Colombia Supremo 250 g
- first_word
- COLOMBIA
- len
- 22
- short
- Colombia...
- ru_unit
- Colombia Supremo 250 гр
- weight
- 250
- cat_or_null
- coffee
Green Sencha 100 g
- first_word
- GREEN
- len
- 18
- short
- Green Se...
- ru_unit
- Green Sencha 100 гр
- weight
- 100
- cat_or_null
- tea
Gift Box
- first_word
- GIFT
- len
- 8
- short
- Gift Box...
- ru_unit
- Gift Box
- weight
- n/a
- cat_or_null
- NULL
coalesce returns the first non-NULL argument, nullif(a, b) returns NULL when a = b.SELECT category, string_agg(name, ', ' ORDER BY price) AS items
FROM products
GROUP BY category
ORDER BY category;coffee
- items
- Colombia Supremo 250 g, Ethiopia Yirgacheffe 250 g, Espresso Blend 1 kg
equipment
- items
- French Press 600 ml, Hand Grinder
gifts
- items
- Gift Box
tableware
- items
- Ceramic Mug 350 ml
tea
- items
- Green Sencha 100 g, Masala Chai 200 g
string_agg concatenates a group’s values; the order is set inside the call.SELECT name, price,
CASE
WHEN price >= 15000 THEN 'premium'
WHEN price >= 5000 THEN 'mid'
ELSE 'budget'
END AS segment,
greatest(price - 3000, 0) AS after_coupon,
price::int AS as_int
FROM products
WHERE id IN (1, 4, 8)
ORDER BY price;Green Sencha 100 g
- price
- 3200.00
- segment
- budget
- after_coupon
- 200.00
- as_int
- 3200
Espresso Blend 1 kg
- price
- 12900.00
- segment
- mid
- after_coupon
- 9900.00
- as_int
- 12900
Hand Grinder
- price
- 18500.00
- segment
- premium
- after_coupon
- 15500.00
- as_int
- 18500
CASE checks conditions top to bottom; ::int and CAST(x AS type) convert types.Concatenate strings
- PostgreSQL 17
'a' || 'b',concat('a', 'b')- MySQL 8.4
CONCAT('a', 'b');'a' || 'b'returned0with a warning:||is a deprecated synonym forORhere
Fallback for NULL
- PostgreSQL 17
coalesce(x, 'n/a')- MySQL 8.4
COALESCEorIFNULL(x, 'n/a')
Group values as a list
- PostgreSQL 17
string_agg(name, ', ' ORDER BY name)- MySQL 8.4
GROUP_CONCAT(name ORDER BY name SEPARATOR ', ')
Date as DD.MM.YYYY
- PostgreSQL 17
to_char(d, 'DD.MM.YYYY')- MySQL 8.4
DATE_FORMAT(d, '%d.%m.%Y')
Auto-increment
- PostgreSQL 17
GENERATED ALWAYS AS IDENTITY- MySQL 8.4
AUTO_INCREMENT
UPSERT
- PostgreSQL 17
ON CONFLICT (email) DO UPDATE SET city = EXCLUDED.city- MySQL 8.4
… AS new ON DUPLICATE KEY UPDATE city = new.city
Return changed rows
- PostgreSQL 17
RETURNING- MySQL 8.4
- Not available
Conditional aggregate
- PostgreSQL 17
count(*) FILTER (WHERE …)- MySQL 8.4
count(CASE WHEN … THEN 1 END)
DDL in a transaction
- PostgreSQL 17
- Rolled back with the transaction
- MySQL 8.4
- Implicit
COMMIT
How this was verified2 · passedchecked 12 October 2026
“Run” means the code was executed in a scratch project or container and the output in the article comes from that run. “Partial” means it was checked against stubs instead of external services. “Not run” means it needs a device, an account or a key; the steps were checked against the official documentation. The verification project files and run logs are kept in the InterLAB archive.