How to buildOctober 12, 202611 min read
Developer ReferenceStep 6 of 8

SQL reference: queries, data changes, DDL and transactions

In short

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.

ReferenceLevel: BeginnerSQL Reference

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 SELECT alias doesn’t work in WHERE and why a window function can’t be filtered in WHERE
  • Translate a query between PostgreSQL and MySQL 8.4 using the differences table

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
  1. 01SELECT: query template and execution order
  2. 02WHERE: comparison operators and NULL
  3. 03JOIN, GROUP BY and HAVING
  4. 04Window functions: ROW_NUMBER, RANK, SUM OVER
  5. 05Subqueries and CTEs (WITH)
  6. 06INSERT, UPSERT (ON CONFLICT), UPDATE and DELETE with JOIN
  7. 07DDL, constraints, indexes and transactions
  8. 08Functions: 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.

SQL
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
The 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, HAVING filters groups

5

Clause
SELECT
What happens
Expressions, AS aliases 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 SELECT are available here

9

Clause
LIMIT / OFFSET
What happens
The requested slice of rows is returned
Logical processing order according to the SELECT command documentation.
SQL
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;
--               ^
The alias appears only at step 5. In WHERE and HAVING you repeat the expression; in ORDER BY and GROUP BY the output column name is allowed.
SQL
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
The second “page” of 3 products, from most to least expensive. The second key id makes the order unambiguous.
SQL
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 NULL dropped out, comparing with NULL yields NULL

weight_g IS DISTINCT FROM 250

Rows
7
What matters
7 = 3 + 4 rows with NULL: here NULL is 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
NOT and parentheses: AND binds tighter than OR
Each condition was run as 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. Whether LIKE is case-sensitive depends on the column’s collation.
  • MySQL 8.4: IS DISTINCT FROM also fails with error 1064. The equivalent is the NULL-safe equality operator <=>: NOT (city <=> 'Almaty').
  • Values coming from users are passed as parameters ($1 in 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 ON condition
Example
clients JOIN orders
Rows
10

LEFT JOIN

What it returns
All rows of the left table; NULL in 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
Row counts from the training database: 8 clients, 10 orders, 9 products, 15 order lines.
SQL
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

Clients without orders: LEFT JOIN plus an IS NULL check on the right table’s key.
SQL
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.
SQL
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
Revenue by category for paid and shipped orders; 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 x from the previous / next row, or NULL

first_value(x) / last_value(x)

What it returns
First / last value in the window frame

ntile(n)

What it returns
Which of n roughly equal buckets the row falls into

sum(x) OVER (…), avg, count

What it returns
Aggregate over the window: with ORDER BY a running total, without it a total for the whole partition
SQL
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
Each client’s latest order: numbering within a client from newest to oldest and an rn = 1 filter in the outer query.
SQL
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
Running total, difference from the previous order and each order’s share of total revenue.
SQL
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
The three functions on tied prices: rank skips places, dense_rank doesn’t, row_number is always unique.
SQL
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;
--               ^
Window functions are evaluated after 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 EXISTS is safer than NOT IN when the list may contain NULL

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”
SQL
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)
Three subqueries in a row, real output in the comments.
SQL
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
A recursive CTE builds a calendar; LEFT JOIN shows days without orders too.
SQL
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
A client’s latest order via 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

SQL
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 2
RETURNING returns the created rows, including the generated id, without a second query.
SQL
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 1
UPSERT on the unique email: 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.

SQL
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 specification
ON CONFLICT (columns) only works when there is a unique index or constraint on exactly those columns.
SQL
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 2
UPDATE … FROM: join another table right inside the update.
SQL
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 1
DELETE … USING deletes by a condition on another table. To empty a whole table, TRUNCATE is faster.
SQL
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';
MySQL 8.4: the same three operations. 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

SQL
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;
Creating a table with constraints and typical 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
Real errors when inserting into reviews (output with \set VERBOSITY verbose). In application code, check the SQLSTATE code rather than the text.
  • CHECK lets a row through when the expression is true or NULL — add NOT NULL for a required field.
  • UNIQUE allows several NULLs by default; NULLS NOT DISTINCT forbids repeated NULLs.
  • PRIMARY KEY = UNIQUE + NOT NULL and 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.
SQL
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)
Indexes on 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.

SQL
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)
An error inside a transaction after a SAVEPOINT: rolling back to the savepoint keeps the other changes — tea went up 10%, the mug didn’t change.
SQL
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
-- ROLLBACK
Without a savepoint, after the first error the transaction rejects every command until ROLLBACK.
SQL
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)
In PostgreSQL DDL is transactional: the table is gone after ROLLBACK. In MySQL 8.4 CREATE TABLE causes an implicit COMMIT — after the same ROLLBACK the table was still there.
SQL
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
-- COMMIT
The isolation level is set in BEGIN; 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

SQL
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).
SQL
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.
SQL
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.
SQL
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' returned 0 with a warning: || is a deprecated synonym for OR here

Fallback for NULL

PostgreSQL 17
coalesce(x, 'n/a')
MySQL 8.4
COALESCE or IFNULL(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
Every MySQL entry was run on MySQL 8.4.11.
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.

Tell us about your task — we'll review your request and get back to you

Got a project?
We'll scope and estimate it — for free.

Tell us about the product or the problem. After reviewing the task we'll come back with an architecture, a timeline and a price for the first version.

  • Scope and risks estimated before we start
  • NDA — yours or ours
  • Work starts within a week

Please do not send passwords, access keys or source code through this form — we will agree a safe way to share access separately.

By clicking the button you agree to the processing of your personal data.