← Valtive QA Quiz quiz.valtive.io/notes/sql valtive.io
📖 Про цей гайд📖 About this guide
Для когоWho it's for
Для QA-інженерів, яких на співбесіді питають про SQL: від мануальних тестувальників, що перевіряють записи після дії в UI, до автоматизаторів і senior-кандидатів, які звіряють дані після міграції чи шукають, чому звіт не сходиться. Усі 90 питань квізу «SQL та бази даних» покриті картками, а кожна картка має запит, який можна виконати.
For QA engineers who get SQL questions in interviews: from manual testers who check records after a UI action to automation engineers and senior candidates who reconcile data after a migration or chase a report that does not add up. All 90 questions of the SQL and databases quiz are covered by cards, and every card has a query you can run.
Як користуватисьHow to use it
Підніми PostgreSQL, виконай блок зі схемою нижче й запускай запити з карток: результати під ними отримані саме на цих даних. Змінюй умови й дивись, як міняється вивід, — так пастки з NULL і JOIN запам'ятовуються краще, ніж з тексту. Потім закрий картку й розкажи відповідь уголос за блоком 🗣 Як читати вголос: на співбесіді оцінюють, як ти пояснюєш, а не чи впізнаєш правильний варіант.
Start PostgreSQL, run the schema block below and then run the queries from the cards: the results under them were produced on exactly this data. Change the conditions and watch the output move — NULL and JOIN traps stick much better that way than from reading. Then close the card and give the answer out loud using the 🗣 How to say it out loud block: an interview checks how you explain, not whether you recognise the right option.
Три якорі — з них випливає більшість відповідейThree anchors — most answers follow from them
ЯКІР 1 · JOIN і агрегаціяANCHOR 1 · JOIN and aggregation
JOIN множить рядки, GROUP BY їх згортає. Перш ніж рахувати SUM чи COUNT після з'єднання, спитай себе, скільки рядків дає кожен ключ, і пам'ятай, що NULL не дорівнює нічому, навіть іншому NULL.
A JOIN multiplies rows and GROUP BY folds them. Before a SUM or COUNT over a join, ask how many rows each key produces, and remember that NULL equals nothing, not even another NULL.
ЯКІР 2 · Перевірка даних після тестуANCHOR 2 · Checking data after a test
UI може показати «успіх», а в базі лишиться сирота, дубль чи сума, що не збігається. QA пише запити, які повертають порожній результат, коли все гаразд, і конкретні рядки, коли ні.
The UI can say «success» while the database holds an orphan, a duplicate or a total that does not match. QA writes queries that return nothing when all is well and the exact bad rows when it is not.
ЯКІР 3 · Транзакції та ізоляціяANCHOR 3 · Transactions and isolation
Будь-яку зміну в спільній базі роби в BEGIN … ROLLBACK, доки не переконаєшся в результаті. Паралельні сесії бачать дані по-різному залежно від рівня ізоляції, і саме там живуть «плаваючі» баги.
Make any change in a shared database inside BEGIN … ROLLBACK until you trust the result. Concurrent sessions see data differently depending on the isolation level, and that is where the «flaky» bugs live.
57
питаньquestions
3
рівніlevels
—
voice-блокиvoice blocks
90/90
питань квізу покритоquiz questions covered
Наскрізна схема: інтернет-магазинThe shared schema: an online shop
Усі картки працюють з п'ятьма таблицями: users (покупці, referred_by посилається на того, хто запросив), products, orders, order_items і payments. Таблиця payments навмисно без зовнішнього ключа: вона імітує дані, які завантажуються від платіжного провайдера, тому в ній можуть бути сироти. У даних є кілька «багів», які картки знаходять: email, що відрізняється лише регістром, замовлення без позицій, сума замовлення, що не збігається з позиціями, подвійна оплата і платіж за неіснуюче замовлення. Запити перевірені на PostgreSQL 18. Щоб повторити: docker run --name sqlqa -e POSTGRES_PASSWORD=qa -p 5432:5432 -d postgres:18, далі docker exec -it sqlqa psql -U postgres, у psql виконай \pset null NULL (так NULL видно у виводі, як у картках) і встав блок нижче. Кожен приклад розрахований на свіжі дані: якщо запит щось змінює, він загорнутий у транзакцію з ROLLBACK або його легко відкотити, перезапустивши схему.
Every card works with five tables: users (customers, with referred_by pointing to whoever invited them), products, orders, order_items and payments. The payments table deliberately has no foreign key: it mimics data loaded from a payment provider, so orphans can live there. The data hides a few «bugs» that the cards find: an email that differs only by letter case, an order without items, an order total that does not match its items, a double charge and a payment for an order that does not exist. The queries were checked on PostgreSQL 18. To reproduce: docker run --name sqlqa -e POSTGRES_PASSWORD=qa -p 5432:5432 -d postgres:18, then docker exec -it sqlqa psql -U postgres, run \pset null NULL in psql (so NULL shows up in the output the way it does in the cards) and paste the block below. Each example assumes fresh data: a query that changes something is wrapped in a transaction with ROLLBACK, or you can reset by re-running the schema.
DROP TABLE IF EXISTS payments, order_items, orders, products, users;

SET TIME ZONE 'UTC';

CREATE TABLE users (
  id          integer PRIMARY KEY,
  email       text NOT NULL UNIQUE,
  name        text NOT NULL,
  city        text,
  referred_by integer REFERENCES users(id),
  created_at  timestamptz NOT NULL DEFAULT now()
);

CREATE TABLE products (
  id       integer PRIMARY KEY,
  sku      text NOT NULL UNIQUE,
  name     text NOT NULL,
  price    numeric(10,2) NOT NULL CHECK (price > 0),
  category text
);

CREATE TABLE orders (
  id         integer PRIMARY KEY,
  user_id    integer NOT NULL REFERENCES users(id),
  status     text NOT NULL CHECK (status IN ('new', 'paid', 'shipped', 'cancelled')),
  total      numeric(10,2) NOT NULL,
  created_at timestamptz NOT NULL
);

CREATE TABLE order_items (
  order_id   integer REFERENCES orders(id) ON DELETE CASCADE,
  product_id integer REFERENCES products(id),
  qty        integer NOT NULL CHECK (qty > 0),
  price      numeric(10,2) NOT NULL,
  PRIMARY KEY (order_id, product_id)
);

CREATE TABLE payments (
  id       integer PRIMARY KEY,
  order_id integer NOT NULL,
  amount   numeric(10,2) NOT NULL,
  status   text NOT NULL,
  paid_at  timestamptz NOT NULL
);

INSERT INTO users (id, email, name, city, referred_by, created_at) VALUES
  (1, 'anna@mail.com',   'Anna',    'Kyiv',  NULL, '2026-01-05 10:00+00'),
  (2, 'bohdan@mail.com', 'Bohdan',  'Lviv',  1,    '2026-01-10 12:30+00'),
  (3, 'olena@mail.com',  'Olena',   NULL,    1,    '2026-02-01 08:00+00'),
  (4, 'Anna@Mail.com',   'Anna K.', 'Kyiv',  NULL, '2026-02-14 19:45+00'),
  (5, 'taras@mail.com',  'Taras',   'Odesa', 2,    '2026-03-01 07:10+00');

INSERT INTO products (id, sku, name, price, category) VALUES
  (1, 'SKU-001', 'Keyboard', 50.00,  'accessories'),
  (2, 'SKU-002', 'Mouse',    20.00,  'accessories'),
  (3, 'SKU-003', 'Monitor',  200.00, 'displays'),
  (4, 'SKU-004', 'Webcam',   80.00,  NULL);

INSERT INTO orders (id, user_id, status, total, created_at) VALUES
  (101, 1, 'paid',      90.00,  '2026-03-01 09:15+00'),
  (102, 1, 'shipped',   200.00, '2026-03-05 22:30+00'),
  (103, 2, 'new',       40.00,  '2026-03-10 14:00+00'),
  (104, 2, 'paid',      260.00, '2026-03-31 23:30+00'),
  (105, 3, 'cancelled', 50.00,  '2026-04-02 11:20+00'),
  (106, 5, 'paid',      20.00,  '2026-04-03 11:55+00');

INSERT INTO order_items (order_id, product_id, qty, price) VALUES
  (101, 1, 1, 50.00), (101, 2, 2, 20.00),
  (102, 3, 1, 200.00),
  (103, 2, 2, 20.00),
  (104, 3, 1, 200.00), (104, 1, 1, 50.00),
  (105, 1, 1, 50.00);

INSERT INTO payments (id, order_id, amount, status, paid_at) VALUES
  (1, 101, 90.00,  'failed',  '2026-03-01 09:16+00'),
  (2, 101, 90.00,  'success', '2026-03-01 09:18+00'),
  (3, 102, 200.00, 'success', '2026-03-05 22:31+00'),
  (4, 104, 250.00, 'success', '2026-03-31 23:40+00'),
  (5, 106, 20.00,  'success', '2026-04-03 12:00+00'),
  (6, 106, 20.00,  'success', '2026-04-03 12:01+00'),
  (7, 999, 15.00,  'success', '2026-04-05 10:00+00');
📚 8 РОЗДІЛІВ📚 8 SECTIONS
—
Junior
—
Middle
—
Senior
▶ 1 7
1 Як побудувати базовий SELECT з сортуванням, LIMIT/OFFSET і аліасами? How do you build a basic SELECT with sorting, LIMIT/OFFSET and aliases?
Основи SELECTSELECT basics Junior ▶

Що це. Базовий запит читається як речення: SELECT — які колонки, FROM — з якої таблиці, WHERE — які рядки, ORDER BY — у якому порядку, LIMIT — скільки. Аліас через AS дає колонці чи таблиці тимчасове ім'я лише в межах цього запиту: total AS amount перейменує колонку у виводі, а FROM orders o дозволяє писати o.status.

What it is. A basic query reads like a sentence: SELECT — which columns, FROM — which table, WHERE — which rows, ORDER BY — in what order, LIMIT — how many. An alias with AS gives a column or table a temporary name inside this query only: total AS amount renames the output column, and FROM orders o lets you write o.status.

Сортування і сторінки. ORDER BY total DESC сортує від більшого до меншого, ASC — за замовчуванням. LIMIT 10 OFFSET 20 пропускає 20 рядків і повертає наступні 10, тобто рядки 21–30. ORDER BY 2 означає «за другою колонкою зі списку SELECT» — коротко, але крихко: хтось додасть колонку на початок, і сортування тихо зміниться.

Sorting and paging. ORDER BY total DESC sorts from largest to smallest; ASC is the default. LIMIT 10 OFFSET 20 skips 20 rows and returns the next 10, i.e. rows 21–30. ORDER BY 2 means «by the second column of the SELECT list» — short but brittle: someone adds a column at the front and the sort silently changes.

Підводні камені. Без ORDER BY порядок рядків не гарантований, навіть якщо сьогодні він «завжди однаковий». Тест, який бере «перший рядок» без сортування, стає нестабільним після VACUUM, оновлення PostgreSQL чи паралельного плану. Пагінація з OFFSET по неунікальному ключу (наприклад, лише за status) може показати один рядок на двох сторінках і загубити інший — додавай унікальний ключ останнім у ORDER BY.

Pitfalls. Without ORDER BY the row order is not guaranteed, even if today it is «always the same». A test that takes «the first row» without sorting turns flaky after a VACUUM, a PostgreSQL upgrade or a parallel plan. OFFSET paging on a non-unique key (say, only status) can show one row on two pages and lose another — put a unique key last in ORDER BY.

Друга «сторінка» з двох найдорожчих замовлень
The second «page» of two most expensive orders
SELECT o.id, o.status, o.total AS amount
FROM orders AS o
WHERE o.status <> 'cancelled'
ORDER BY amount DESC, o.id
LIMIT 2 OFFSET 2;
Результат у psql
Result in psql
 id  | status | amount
-----+--------+--------
 101 | paid   |  90.00
 103 | new    |  40.00
(2 rows)
🔧 Розбір запиту: Аліас amount можна використати в ORDER BY, бо сортування виконується після SELECT. o.id другим ключем робить порядок детермінованим. OFFSET 2 пропускає 260.00 і 200.00, тому бачимо третій і четвертий рядки.
🔧 Query breakdown: The amount alias is allowed in ORDER BY because sorting runs after SELECT. o.id as the second key makes the order deterministic. OFFSET 2 skips 260.00 and 200.00, so we see the third and fourth rows.
💡 Простими словами: LIMIT без ORDER BY — це «будь-які N рядків», а не «перші N».
💡 In plain words: LIMIT without ORDER BY means «any N rows», not «the first N».
🗣 Як читати вголос Я читаю запит зверху вниз: які колонки, звідки, які рядки і в якому порядку. Сортування за спаданням — це DESC, а LIMIT 10 OFFSET 20 дає рядки з двадцять першого по тридцятий. Коли пишу перевірку в тесті, завжди додаю ORDER BY з унікальним ключем наприкінці, бо без нього база має право віддати рядки в будь-якому порядку, і тест почне «плавати». ORDER BY 2 знаю, але в коді віддаю перевагу імені колонки.
🗣 How to say it out loud I read a query top to bottom: which columns, from where, which rows and in what order. Descending sort is DESC, and LIMIT 10 OFFSET 20 gives rows twenty-one to thirty. When I write a check inside a test I always end ORDER BY with a unique key, because without it the database may hand rows back in any order and the test starts to flake. I know ORDER BY 2, but in code I prefer the column name.
2 У якому логічному порядку виконуються частини SQL-запиту і чому це важливо? In what logical order are the parts of an SQL query evaluated, and why does it matter?
Основи SELECTSELECT basics Middle ▶

Порядок. Пишемо ми SELECT … FROM … WHERE … GROUP BY … HAVING … ORDER BY … LIMIT, а логічно база обробляє так: FROM і JOIN → WHERE → GROUP BY → HAVING → SELECT (разом з віконними функціями і DISTINCT) → ORDER BY → LIMIT/OFFSET. Оптимізатор може фізично робити інакше, але результат мусить бути таким, ніби кроки йшли саме так.

The order. We write SELECT … FROM … WHERE … GROUP BY … HAVING … ORDER BY … LIMIT, but logically the database processes: FROM and JOINs → WHERE → GROUP BY → HAVING → SELECT (with window functions and DISTINCT) → ORDER BY → LIMIT/OFFSET. The optimizer may physically do something else, but the result has to look as if the steps ran in this order.

Навіщо це знати. З порядку випливає половина «дивних» помилок. Аліас із SELECT не видно у WHERE, бо WHERE виконується раніше: WHERE amount > 100 дасть column "amount" does not exist. Агрегат не можна поставити у WHERE, бо груп ще немає, — для цього існує HAVING. Віконну функцію не можна фільтрувати в тому ж запиті — її загортають у підзапит або CTE. А в ORDER BY аліас уже доступний.

Why you need it. Half of the «weird» errors follow from this order. A SELECT alias is invisible in WHERE because WHERE runs earlier: WHERE amount > 100 gives column "amount" does not exist. An aggregate cannot go into WHERE because the groups do not exist yet — that is what HAVING is for. A window function cannot be filtered in the same query — wrap it in a subquery or CTE. In ORDER BY, though, the alias is already visible.

Нюанс діалектів. PostgreSQL дозволяє аліаси з SELECT у GROUP BY (як розширення), MySQL — навіть у HAVING. Стандарт цього не вимагає, тому переносний запит повторює вираз. У тестових перевірках я обираю переносний варіант, бо той самий SQL часто запускають на різних СУБД.

Dialect nuance. PostgreSQL accepts SELECT aliases in GROUP BY (as an extension), and MySQL even in HAVING. The standard does not require it, so a portable query repeats the expression. In test checks I pick the portable form, because the same SQL often runs against different databases.

Аліас у WHERE падає, у ORDER BY — працює
An alias fails in WHERE and works in ORDER BY
SELECT id, total * 1.2 AS amount
FROM orders
WHERE amount > 100;

SELECT id, total * 1.2 AS amount
FROM orders
WHERE total * 1.2 > 100
ORDER BY amount DESC;
Результат у psql
Result in psql
ERROR:  column "amount" does not exist

 id  | amount
-----+---------
 104 | 312.000
 102 | 240.000
 101 | 108.000
(3 rows)
🔧 Розбір запиту: Перший запит падає на етапі WHERE, бо колонка amount з'являється лише на етапі SELECT. У другому вираз повторено у WHERE, а аліас спокійно використано в ORDER BY, який іде після SELECT.
🔧 Query breakdown: The first query fails at the WHERE step because the amount column only appears at the SELECT step. The second repeats the expression in WHERE and uses the alias in ORDER BY, which comes after SELECT.
💡 Простими словами: Пиши запит у порядку синтаксису, а думай у порядку виконання: спершу звідки й які рядки, потім групи, потім що показати.
💡 In plain words: Write the query in syntax order, but think in execution order: first where from and which rows, then groups, then what to show.
🗣 Як читати вголос Логічний порядок такий: FROM з JOIN, потім WHERE, GROUP BY, HAVING, далі SELECT, ORDER BY і наприкінці LIMIT. Мені це пояснює більшість помилок: чому аліас не працює у WHERE, чому агрегат можна фільтрувати тільки в HAVING і чому ROW_NUMBER доводиться загортати в підзапит. Коли запит поводиться незрозуміло, я подумки проганяю його саме в цьому порядку.
🗣 How to say it out loud The logical order is FROM with the joins, then WHERE, GROUP BY, HAVING, then SELECT, ORDER BY and finally LIMIT. That explains most errors for me: why an alias does not work in WHERE, why an aggregate can only be filtered in HAVING, and why ROW_NUMBER has to be wrapped in a subquery. When a query behaves strangely, I walk through it in exactly this order in my head.
3 Як фільтрувати рядки через LIKE, BETWEEN та IN і де в них пастки? How do you filter rows with LIKE, BETWEEN and IN, and where are the traps?
Основи SELECTSELECT basics Junior ▶

LIKE. Шаблон 'An%' знаходить рядки, що починаються з «An»: % — будь-яка кількість символів, _ — рівно один. У PostgreSQL LIKE чутливий до регістру, тому 'an%' не знайде «Anna»; для цього є ILIKE (розширення PostgreSQL) або LOWER(name) LIKE 'an%'. У MySQL звичайні колації регістронезалежні, і той самий запит поводиться інакше.

LIKE. The pattern 'An%' matches rows starting with «An»: % is any number of characters, _ is exactly one. In PostgreSQL LIKE is case-sensitive, so 'an%' will not find «Anna»; use ILIKE (a PostgreSQL extension) or LOWER(name) LIKE 'an%'. In MySQL the usual collations are case-insensitive, and the same query behaves differently.

BETWEEN. price BETWEEN 10 AND 20 — це price >= 10 AND price <= 20, обидві межі включно. З датами це підступно: created_at BETWEEN '2026-03-01' AND '2026-03-31' для timestamp зупиниться на 31 березня 00:00 і загубить увесь останній день. Для часу надійніше напіввідкритий інтервал >= '2026-03-01' AND < '2026-04-01'.

BETWEEN. price BETWEEN 10 AND 20 is price >= 10 AND price <= 20, both bounds included. With dates this is sneaky: created_at BETWEEN '2026-03-01' AND '2026-03-31' on a timestamp stops at March 31 00:00 and loses the whole last day. For time a half-open range >= '2026-03-01' AND < '2026-04-01' is safer.

IN. status IN ('paid', 'shipped') — коротший запис кількох OR, і він приймає також підзапит. Для тестів IN зручний, щоб перевірити одразу кілька очікуваних станів: WHERE id IN (101, 102) AND status NOT IN ('paid', 'shipped') має повернути порожній результат. Пастка з NULL живе в NOT IN — про неї окрема картка в розділі підзапитів.

IN. status IN ('paid', 'shipped') is a shorter form of several ORs and also accepts a subquery. In tests IN is handy for checking several expected states at once: WHERE id IN (101, 102) AND status NOT IN ('paid', 'shipped') should return an empty result. The NULL trap lives in NOT IN — it gets its own card in the subqueries section.

Три фільтри і пастка BETWEEN з датами
Three filters and the BETWEEN trap with dates
SELECT id, name FROM users WHERE name LIKE 'An%' ORDER BY id;

SELECT id, name, price FROM products WHERE price BETWEEN 20 AND 80 ORDER BY id;

SELECT count(*) AS march_between
FROM orders WHERE created_at BETWEEN '2026-03-01' AND '2026-03-31';

SELECT count(*) AS march_half_open
FROM orders WHERE created_at >= '2026-03-01' AND created_at < '2026-04-01';
Результат у psql
Result in psql
 id |  name
----+---------
  1 | Anna
  4 | Anna K.
(2 rows)

 id |   name   | price
----+----------+-------
  1 | Keyboard | 50.00
  2 | Mouse    | 20.00
  4 | Webcam   | 80.00
(3 rows)

 march_between
---------------
             3
(1 row)

 march_half_open
-----------------
               4
(1 row)
🔧 Розбір запиту: BETWEEN 20 AND 80 включає обидві межі, тому Mouse (20) і Webcam (80) потрапили у вивід. Замовлення 104 створене 31 березня о 23:30 UTC, і BETWEEN до '2026-03-31' його відрізає: 3 проти 4.
🔧 Query breakdown: BETWEEN 20 AND 80 includes both bounds, so Mouse (20) and Webcam (80) are in the output. Order 104 was created on March 31 at 23:30 UTC, and BETWEEN up to '2026-03-31' cuts it off: 3 versus 4.
💡 Простими словами: Для дат і часу пиши «від включно — до не включно», а не BETWEEN.
💡 In plain words: For dates and times write «from inclusive — to exclusive», not BETWEEN.
🗣 Як читати вголос LIKE з відсотком шукає за шаблоном: 'An%' — все, що починається на An, і в PostgreSQL це чутливо до регістру, тому для пошуку без регістру є ILIKE. BETWEEN включає обидві межі — це легко перевірити граничними значеннями. З датами я BETWEEN не використовую: якщо колонка timestamp, останній день періоду випадає, тож пишу більше-або-дорівнює початку і строго менше наступного дня.
🗣 How to say it out loud LIKE with a percent sign matches a pattern: 'An%' is anything starting with An, and in PostgreSQL that is case-sensitive, so ILIKE exists for case-insensitive search. BETWEEN includes both bounds, which is easy to verify with boundary values. I avoid BETWEEN with dates: on a timestamp column the last day of the period falls out, so I write greater-or-equal to the start and strictly less than the next day.
4 Що робить SELECT DISTINCT і коли він маскує проблему в запиті? What does SELECT DISTINCT do, and when does it hide a problem in the query?
Основи SELECTSELECT basics Junior ▶

Що це. DISTINCT прибирає з результату повністю однакові рядки — порівнюються всі колонки SELECT разом, а не лише перша. SELECT DISTINCT city FROM users дасть кожне місто один раз, причому NULL для DISTINCT вважаються однаковими й залишаться одним рядком. COUNT(DISTINCT city) рахує унікальні значення без NULL.

What it is. DISTINCT removes rows that are identical across all SELECT columns together, not just the first one. SELECT DISTINCT city FROM users returns each city once, and for DISTINCT NULLs count as equal, so they stay as a single row. COUNT(DISTINCT city) counts unique values excluding NULL.

Коли доречно. Для довідників («які статуси взагалі є в таблиці»), для перевірки, що колонка містить лише дозволені значення, і для підрахунку унікальних покупців. У PostgreSQL є ще DISTINCT ON (col) — «перший рядок на кожне значення col» за ORDER BY, це розширення, якого немає в MySQL.

When it fits. For lookups («which statuses exist in the table at all»), for checking that a column holds only allowed values, and for counting unique buyers. PostgreSQL also has DISTINCT ON (col) — «the first row per value of col» by ORDER BY; it is an extension MySQL does not have.

Підводний камінь. Якщо після JOIN з'явилися дублікати і хтось «вилікував» їх DISTINCT, це майже завжди маскування: JOIN множить рядки, бо ключ з'єднання неунікальний, а DISTINCT ховає симптом і дорого сортує. Якщо в такому запиті є SUM, він усе одно порахований по розмножених рядках. На рев'ю тестових запитів DISTINCT — привід спитати «звідки дублікати?».

Pitfall. If a JOIN produced duplicates and someone «fixed» them with DISTINCT, it is almost always masking: the JOIN multiplies rows because the join key is not unique, and DISTINCT hides the symptom while paying for an expensive sort. If the query has a SUM, it is still computed over the multiplied rows. When reviewing test queries, a DISTINCT is a cue to ask «where do the duplicates come from?».

Унікальні міста, лічильники і DISTINCT по двох колонках
Unique cities, counters and DISTINCT over two columns
SELECT DISTINCT city FROM users ORDER BY city;

SELECT count(*) AS all_rows, count(city) AS non_null, count(DISTINCT city) AS unique_cities
FROM users;

SELECT DISTINCT user_id, status FROM orders ORDER BY user_id, status;
Результат у psql
Result in psql
 city
-------
 Kyiv
 Lviv
 Odesa
 NULL
(4 rows)

 all_rows | non_null | unique_cities
----------+----------+---------------
        5 |        4 |             3
(1 row)

 user_id |  status
---------+-----------
       1 | paid
       1 | shipped
       2 | new
       2 | paid
       3 | cancelled
       5 | paid
(6 rows)
🔧 Розбір запиту: У першому виводі NULL — окремий рядок. count(DISTINCT city) дає 3 (Kyiv, Lviv, Odesa), бо NULL не рахується. У третьому запиті унікальною є пара (user_id, status), тому user 2 з'являється двічі.
🔧 Query breakdown: In the first output NULL is a row of its own. count(DISTINCT city) is 3 (Kyiv, Lviv, Odesa) because NULL is not counted. In the third query the pair (user_id, status) is what must be unique, so user 2 appears twice.
💡 Простими словами: DISTINCT відповідає на «які значення є», а не лікує дублікати після JOIN.
💡 In plain words: DISTINCT answers «which values exist»; it does not cure duplicates after a JOIN.
🗣 Як читати вголос DISTINCT прибирає однакові рядки з результату, причому порівнюються всі вибрані колонки разом. Я використовую його, щоб подивитися, які значення взагалі є в колонці, або порахувати унікальних покупців через COUNT DISTINCT, який, до речі, NULL не рахує. А от якщо бачу DISTINCT після JOIN, питаю, звідки дублікати: зазвичай ключ з'єднання неунікальний, і суми в такому запиті вже неправильні.
🗣 How to say it out loud DISTINCT removes identical rows from the result, comparing all selected columns together. I use it to see which values a column actually holds, or to count unique buyers with COUNT DISTINCT, which by the way ignores NULL. But when I see DISTINCT after a JOIN, I ask where the duplicates come from: usually the join key is not unique, and any sums in that query are already wrong.
5 Чому WHERE city = NULL не повертає нічого і як правильно перевіряти NULL? Why does WHERE city = NULL return nothing, and how do you check for NULL correctly?
Основи SELECTSELECT basics Junior ▶

Що таке NULL. NULL — це не нуль і не порожній рядок, а «значення невідоме». Будь-яке порівняння з NULL через =, <>, > дає не TRUE і не FALSE, а третій стан — UNKNOWN. Навіть NULL = NULL — це UNKNOWN. WHERE пропускає лише рядки з TRUE, тому WHERE city = NULL завжди повертає порожній результат без жодної помилки.

What NULL is. NULL is not zero and not an empty string but «value unknown». Any comparison with NULL through =, <>, > yields neither TRUE nor FALSE but a third state, UNKNOWN. Even NULL = NULL is UNKNOWN. WHERE lets through only rows where the condition is TRUE, so WHERE city = NULL always returns an empty result with no error.

Як правильно. Для перевірки є окремі оператори IS NULL та IS NOT NULL. Коли треба порівняти дві колонки, які обидві можуть бути NULL, у PostgreSQL є IS DISTINCT FROM / IS NOT DISTINCT FROM: вони трактують два NULL як рівні. У MySQL аналог — оператор <=>.

The right way. Checking needs the dedicated operators IS NULL and IS NOT NULL. To compare two columns that may both be NULL, PostgreSQL has IS DISTINCT FROM / IS NOT DISTINCT FROM, which treat two NULLs as equal. MySQL's counterpart is the <=> operator.

Пастка для QA. Умова city <> 'Kyiv' не поверне рядки, де city = NULL. Тест «усі користувачі не з Києва отримали розсилку» тоді мовчки пропустить людей без міста. Коли пишеш заперечну умову по колонці, яка допускає NULL, одразу вирішуй, куди мають потрапити NULL-рядки. Те саме з JOIN: рядки з NULL у ключі з'єднання не з'єднаються ні з чим, навіть з іншим NULL.

A QA trap. The condition city <> 'Kyiv' will not return rows where city is NULL. A test like «every user outside Kyiv got the newsletter» then silently skips people with no city. Whenever you write a negative condition on a nullable column, decide right away where the NULL rows should go. The same applies to JOINs: rows with NULL in the join key match nothing, not even another NULL.

Три стани логіки на реальних даних
Three-valued logic on real data
SELECT NULL = NULL AS eq, NULL IS NULL AS is_null,
       NULL IS NOT DISTINCT FROM NULL AS same;

SELECT count(*) AS eq_null FROM users WHERE city = NULL;
SELECT id, name FROM users WHERE city IS NULL;

SELECT id, name, city FROM users WHERE city <> 'Kyiv' ORDER BY id;
SELECT id, name, city FROM users WHERE city IS DISTINCT FROM 'Kyiv' ORDER BY id;
Результат у psql
Result in psql
  eq  | is_null | same
------+---------+------
 NULL | t       | t
(1 row)

 eq_null
---------
       0
(1 row)

 id | name
----+-------
  3 | Olena
(1 row)

 id |  name  | city
----+--------+-------
  2 | Bohdan | Lviv
  5 | Taras  | Odesa
(2 rows)

 id |  name  | city
----+--------+-------
  2 | Bohdan | Lviv
  3 | Olena  | NULL
  5 | Taras  | Odesa
(3 rows)
🔧 Розбір запиту: Перший рядок показує NULL (UNKNOWN), t і t. city <> 'Kyiv' повертає лише Bohdan і Taras — Olena без міста зникла. IS DISTINCT FROM повертає її, бо NULL відрізняється від 'Kyiv'.
🔧 Query breakdown: The first row shows NULL (UNKNOWN), t and t. city <> 'Kyiv' returns only Bohdan and Taras — Olena with no city vanished. IS DISTINCT FROM returns her because NULL differs from 'Kyiv'.
💡 Простими словами: NULL — це «не знаю». «Не знаю» дорівнює «не знаю»? Теж «не знаю».
💡 In plain words: NULL means «don't know». Does «don't know» equal «don't know»? Also «don't know».
🗣 Як читати вголос NULL означає невідоме значення, тому порівняння з ним дає не true і не false, а unknown, і WHERE такі рядки відкидає. Тому city = NULL нічого не знайде, а правильно писати IS NULL. Найчастіше це кусає в заперечних умовах: «місто не Київ» не поверне людей без міста. Якщо мені потрібно, щоб NULL поводився як звичайне значення, я беру IS DISTINCT FROM у PostgreSQL.
🗣 How to say it out loud NULL stands for an unknown value, so comparing to it gives neither true nor false but unknown, and WHERE drops those rows. That is why city = NULL finds nothing and the right form is IS NULL. It bites most in negative conditions: «city is not Kyiv» will not return people with no city. When I need NULL to behave like an ordinary value, I use IS DISTINCT FROM in PostgreSQL.
6 Як працюють COALESCE і CASE WHEN і як ними замінити NULL у звіті? How do COALESCE and CASE WHEN work, and how do you use them to replace NULL in a report?
Основи SELECTSELECT basics Junior ▶

COALESCE. COALESCE(a, b, c) повертає перший аргумент, який не NULL. Типове застосування — показати 0 замість NULL: COALESCE(SUM(amount), 0) для покупця без оплат або COALESCE(city, 'unknown') у звіті. Це стандартна функція; IFNULL (MySQL) та NVL (Oracle) роблять те саме, але лише з двома аргументами й не переносяться між базами. Усі аргументи мають бути сумісного типу: COALESCE(total, 'n/a') для числової колонки впаде.

COALESCE. COALESCE(a, b, c) returns the first argument that is not NULL. The typical use is showing 0 instead of NULL: COALESCE(SUM(amount), 0) for a buyer with no payments, or COALESCE(city, 'unknown') in a report. It is a standard function; IFNULL (MySQL) and NVL (Oracle) do the same with only two arguments and do not port between databases. All arguments must have compatible types: COALESCE(total, 'n/a') on a numeric column fails.

CASE WHEN. Це умовна логіка всередині запиту: CASE WHEN умова THEN значення … ELSE інше END. Гілки перевіряються зверху вниз, спрацьовує перша з TRUE; без ELSE результат — NULL. Зручно для категорій («дороге/дешеве»), для людських назв статусів і для умовних підрахунків SUM(CASE WHEN … THEN 1 ELSE 0 END).

CASE WHEN. This is conditional logic inside a query: CASE WHEN condition THEN value … ELSE other END. Branches are checked top to bottom and the first TRUE wins; without ELSE the result is NULL. It is handy for buckets («expensive/cheap»), for human-readable status names and for conditional counts SUM(CASE WHEN … THEN 1 ELSE 0 END).

Пастка. COALESCE у звіті змінює зміст: 0 означає «оплат нуль», а NULL — «даних немає». Для QA-перевірки інколи чесніше залишити NULL і окремо рахувати, скільки таких рядків. І CASE WHEN city = NULL не спрацює ніколи — у CASE теж пишуть IS NULL.

Trap. COALESCE in a report changes the meaning: 0 says «zero payments», NULL says «no data». For a QA check it is sometimes more honest to keep NULL and count such rows separately. And CASE WHEN city = NULL never fires — CASE needs IS NULL too.

Сума оплат на покупця і категорія суми
Paid amount per buyer and an amount bucket
SELECT u.id,
       COALESCE(u.city, 'unknown') AS city,
       COALESCE(SUM(p.amount), 0)  AS paid,
       CASE
         WHEN SUM(p.amount) IS NULL THEN 'no payments'
         WHEN SUM(p.amount) >= 200  THEN 'big'
         ELSE 'small'
       END AS bucket
FROM users u
LEFT JOIN orders o   ON o.user_id = u.id
LEFT JOIN payments p ON p.order_id = o.id AND p.status = 'success'
GROUP BY u.id, u.city
ORDER BY u.id;
Результат у psql
Result in psql
 id |  city   |  paid  |   bucket
----+---------+--------+-------------
  1 | Kyiv    | 290.00 | big
  2 | Lviv    | 250.00 | big
  3 | unknown |      0 | no payments
  4 | Kyiv    |      0 | no payments
  5 | Odesa   |  40.00 | small
(5 rows)
🔧 Розбір запиту: LEFT JOIN залишає покупців без замовлень і оплат, тому SUM для них NULL, а COALESCE робить із нього 0. CASE перевіряє NULL першою гілкою, інакше покупці без оплат потрапили б у «small». Taras має 40.00 через подвійну оплату замовлення 106 — цей баг ще знайдемо.
🔧 Query breakdown: LEFT JOIN keeps buyers without orders or payments, so SUM is NULL for them and COALESCE turns it into 0. CASE checks for NULL in the first branch, otherwise buyers without payments would land in «small». Taras shows 40.00 because of the double payment for order 106 — a bug we will catch later.
💡 Простими словами: COALESCE — «візьми перше, що відомо»; CASE — «якщо так, то це, інакше те».
💡 In plain words: COALESCE is «take the first thing you know»; CASE is «if this, then that, otherwise the other».
🗣 Як читати вголос COALESCE повертає перший аргумент, що не NULL, тому COALESCE від суми і нуля — стандартний спосіб показати нуль замість порожнечі. IFNULL і NVL роблять те саме, але вони діалектні. CASE WHEN — це if-else у запиті: перша істинна гілка перемагає, а без ELSE буде NULL. Але я пам'ятаю, що нуль і NULL означають різне, тож у перевірках даних не завжди поспішаю ховати NULL.
🗣 How to say it out loud COALESCE returns the first non-NULL argument, so COALESCE of a sum and zero is the standard way to show zero instead of nothing. IFNULL and NVL do the same but are dialect-specific. CASE WHEN is an if-else inside the query: the first true branch wins, and without ELSE you get NULL. Still, zero and NULL mean different things, so in data checks I am not always quick to hide NULL.
7 Як склеювати рядки, перетворювати типи і чим CHAR відрізняється від VARCHAR? How do you concatenate strings, convert types, and how does CHAR differ from VARCHAR?
Основи SELECTSELECT basics Junior ▶

Склеювання. Стандартний оператор — ||: name || ' <' || email || '>'. Якщо хоч один операнд NULL, весь результат NULL. Функція CONCAT(a, b) пропускає NULL як порожній рядок. У MySQL || за замовчуванням означає логічне OR, тому там пишуть CONCAT — класична причина, чому запит «працював у Postgres і зламався в MySQL».

Concatenation. The standard operator is ||: name || ' <' || email || '>'. If any operand is NULL, the whole result is NULL. The CONCAT(a, b) function treats NULL as an empty string. In MySQL || means logical OR by default, so people write CONCAT there — the classic reason a query «worked in Postgres and broke in MySQL».

Приведення типів. CAST(total AS integer) — стандартний запис, total::integer — скорочення PostgreSQL. Числа при переведенні в integer округлюються (у PostgreSQL 90.50 → 91), тобто не просто обрізаються. Рядок, який не є числом, дає помилку invalid input syntax — це корисно в перевірках імпорту: знайти рядки, що не парсяться.

Type conversion. CAST(total AS integer) is the standard form, total::integer is PostgreSQL shorthand. Casting numbers to integer rounds them (in PostgreSQL 90.50 → 91) rather than simply truncating. A string that is not a number raises invalid input syntax — useful in import checks for finding rows that do not parse.

CHAR проти VARCHAR. CHAR(n) має фіксовану довжину і доповнює значення пробілами, VARCHAR(n) зберігає рівно введене в межах ліміту. У PostgreSQL text і varchar працюють однаково швидко, а CHAR майже ніколи не потрібен. Для тестів важливо: у CHAR хвостові пробіли ігноруються при порівнянні, а довжина ліміту — це межа для тест-кейсу на граничні значення (n символів проходить, n+1 дає помилку).

CHAR versus VARCHAR. CHAR(n) has a fixed length and pads values with spaces; VARCHAR(n) stores exactly what was entered, within the limit. In PostgreSQL text and varchar are equally fast, and CHAR is almost never needed. For tests this matters: CHAR ignores trailing spaces in comparisons, and the length limit is a boundary for a test case (n characters pass, n+1 fails).

Склеювання з NULL, CAST і межа VARCHAR
Concatenation with NULL, CAST and a VARCHAR limit
SELECT name || ' (' || city || ')'        AS with_pipes,
       CONCAT(name, ' (', city, ')')         AS with_concat
FROM users WHERE id IN (1, 3) ORDER BY id;

SELECT CAST('42' AS integer) + 1 AS n, 90.5::integer AS rounded,
       'ab'::char(4) = 'ab'::char(4) AS char_eq,
       length('ab'::char(4)) AS char_len, octet_length('ab'::char(4)) AS stored;

SELECT 'abcdef'::varchar(5);
SELECT CAST('12a' AS integer);
Результат у psql
Result in psql
 with_pipes  | with_concat
-------------+-------------
 Anna (Kyiv) | Anna (Kyiv)
 NULL        | Olena ()
(2 rows)

 n  | rounded | char_eq | char_len | stored
----+---------+---------+----------+--------
 43 |      91 | t       |        2 |      4
(1 row)

 varchar
---------
 abcde
(1 row)

ERROR:  invalid input syntax for type integer: "12a"
🔧 Розбір запиту: Для Olena (city = NULL) || дає NULL, а CONCAT — «Olena ()». length для CHAR не рахує хвостові пробіли, а octet_length показує, що зберігається 4 байти. Явне приведення до varchar(5) обрізає рядок мовчки, а вставка такого значення в колонку varchar(5) дала б помилку.
🔧 Query breakdown: For Olena (city = NULL) || gives NULL and CONCAT gives «Olena ()». length on CHAR ignores trailing spaces, while octet_length shows 4 bytes are stored. An explicit cast to varchar(5) truncates silently, whereas inserting that value into a varchar(5) column would raise an error.
💡 Простими словами: Один NULL «отруює» весь ланцюжок через ||, CONCAT — ні.
💡 In plain words: One NULL «poisons» a whole || chain; CONCAT does not.
🗣 Як читати вголос Для склеювання в стандарті є дві вертикальні риски, але якщо серед операндів NULL, вийде NULL, а CONCAT трактує NULL як порожній рядок. У MySQL ці риски взагалі означають OR, тому там CONCAT. Типи змінюю через CAST або подвійну двокрапку в Postgres. CHAR доповнює пробілами до фіксованої довжини, VARCHAR зберігає як є, і ліміт довжини я завжди перевіряю граничними значеннями.
🗣 How to say it out loud The standard concatenation operator is the double pipe, but if any operand is NULL you get NULL, while CONCAT treats NULL as an empty string. In MySQL the double pipe even means OR, so CONCAT is used there. I change types with CAST or the double colon in Postgres. CHAR pads to a fixed length with spaces, VARCHAR stores the value as is, and I always test the length limit with boundary values.
▶ 2 7
8 Чим INNER JOIN відрізняється від LEFT JOIN на прикладі покупців і замовлень? How does INNER JOIN differ from LEFT JOIN, using buyers and orders as the example?
JOINJOIN Junior ▶

INNER JOIN. Повертає лише пари рядків, для яких умова в ON істинна. Покупець без замовлень не має пари — його у виводі немає. Замовлення без покупця теж зникло б, але в нашій схемі це заборонено зовнішнім ключем. Слово INNER можна не писати: просто JOIN означає те саме.

INNER JOIN. Returns only the row pairs for which the ON condition is true. A buyer with no orders has no pair and is absent from the output. An order without a buyer would vanish too, but our schema forbids that with a foreign key. The word INNER is optional: plain JOIN means the same.

LEFT JOIN. Повертає всі рядки лівої таблиці; де пари справа немає, колонки правої таблиці заповнюються NULL. Тому users LEFT JOIN orders покаже і Anna K., яка нічого не купувала, — з NULL у колонках замовлення. Порядок таблиць важливий: orders LEFT JOIN users — вже зовсім інший запит.

LEFT JOIN. Returns every row of the left table; where there is no match on the right, the right-hand columns are filled with NULL. So users LEFT JOIN orders also shows Anna K., who bought nothing, with NULL in the order columns. Table order matters: orders LEFT JOIN users is a completely different query.

Як обирати. Питання звучить так: «чи потрібні мені рядки, у яких немає пари?». Звіт «скільки замовлень у кожного покупця» вимагає LEFT, інакше покупці з нулем просто зникнуть, і тест, що звіряє кількість рядків звіту з кількістю користувачів, впаде. Перевірка «кожне замовлення має покупця» навпаки шукає рядки без пари. Для QA важливо ще одне: JOIN не зменшує і не збільшує кількість рядків «сам по собі» — він дає по рядку на кожну знайдену пару.

How to choose. The question is «do I need the rows that have no match?». A report of «how many orders each buyer has» needs LEFT, otherwise buyers with zero simply disappear, and a test comparing the report row count with the user count fails. A check that «every order has a buyer» looks for unmatched rows instead. One more thing for QA: a JOIN does not shrink or grow the row count «by itself» — it yields one row per matching pair.

Одна пара таблиць — два різні результати
One pair of tables, two different results
SELECT u.id, u.name, o.id AS order_id, o.status
FROM users u
JOIN orders o ON o.user_id = u.id
ORDER BY u.id, o.id;

SELECT u.id, u.name, o.id AS order_id, o.status
FROM users u
LEFT JOIN orders o ON o.user_id = u.id
ORDER BY u.id, o.id;
Результат у psql
Result in psql
 id |  name  | order_id |  status
----+--------+----------+-----------
  1 | Anna   |      101 | paid
  1 | Anna   |      102 | shipped
  2 | Bohdan |      103 | new
  2 | Bohdan |      104 | paid
  3 | Olena  |      105 | cancelled
  5 | Taras  |      106 | paid
(6 rows)

 id |  name   | order_id |  status
----+---------+----------+-----------
  1 | Anna    |      101 | paid
  1 | Anna    |      102 | shipped
  2 | Bohdan  |      103 | new
  2 | Bohdan  |      104 | paid
  3 | Olena   |      105 | cancelled
  4 | Anna K. |     NULL | NULL
  5 | Taras   |      106 | paid
(7 rows)
🔧 Розбір запиту: INNER JOIN дає 6 рядків — по одному на замовлення. LEFT JOIN дає 7: додається Anna K. (id 4) з NULL в order_id і status. Anna (id 1) і Bohdan мають по два замовлення, тому з'являються двічі в обох варіантах.
🔧 Query breakdown: INNER JOIN yields 6 rows, one per order. LEFT JOIN yields 7: Anna K. (id 4) is added with NULL in order_id and status. Anna (id 1) and Bohdan have two orders each, so they appear twice in both versions.
💡 Простими словами: INNER — «тільки пари», LEFT — «вся ліва таблиця, а пари, якщо є».
💡 In plain words: INNER is «pairs only», LEFT is «the whole left table, plus pairs where they exist».
🗣 Як читати вголос INNER JOIN повертає тільки ті рядки, для яких знайшлася пара за умовою ON, а LEFT JOIN — усі рядки лівої таблиці, і там, де пари немає, праві колонки будуть NULL. На наших даних покупець без замовлень зникає з INNER і з'являється з порожніми полями в LEFT. Я обираю LEFT, коли мені потрібні і рядки без пари, наприклад у звіті, де кожен покупець має бути навіть з нулем.
🗣 How to say it out loud INNER JOIN returns only the rows that found a partner through the ON condition, while LEFT JOIN returns every row of the left table and fills the right columns with NULL where there is no partner. On our data a buyer with no orders disappears from INNER and shows up with empty fields in LEFT. I go with LEFT when I need unmatched rows too, like a report where every buyer must appear even with zero.
9 Що повертають RIGHT JOIN і FULL OUTER JOIN і де вони корисні для звірки даних? What do RIGHT JOIN and FULL OUTER JOIN return, and where do they help reconcile data?
JOINJOIN Middle ▶

RIGHT JOIN. Дзеркало LEFT JOIN: зберігає всі рядки правої таблиці. a RIGHT JOIN b дає те саме, що b LEFT JOIN a, тому в коді його пишуть рідко — більшості людей легше читати запит, де «головна» таблиця стоїть першою. На співбесіді достатньо сказати, що це еквівалент із переставленими таблицями.

RIGHT JOIN. The mirror of LEFT JOIN: it keeps every row of the right table. a RIGHT JOIN b gives the same as b LEFT JOIN a, so it is rare in code — most people find a query easier to read when the «main» table comes first. In an interview it is enough to say it is the equivalent with the tables swapped.

FULL OUTER JOIN. Повертає всі рядки обох таблиць: пари з'єднуються, а рядки без пари з будь-якого боку додаються з NULL на іншому боці. Це природний інструмент звірки двох джерел: замовлення проти платежів, стара таблиця проти нової після міграції, наш облік проти вивантаження провайдера. В одному запиті видно і «є в нас, немає в них», і «є в них, немає в нас».

FULL OUTER JOIN. Returns every row of both tables: matching rows are paired, and unmatched rows from either side are added with NULL on the other side. It is the natural tool for reconciling two sources: orders against payments, the old table against the new one after a migration, our ledger against a provider export. One query shows both «we have it, they don't» and «they have it, we don't».

Нюанси. MySQL не підтримує FULL OUTER JOIN — його емулюють через LEFT JOIN … UNION … RIGHT JOIN. Умову фільтра на таблицю з «опціонального» боку треба ставити в ON або перевіряти разом з IS NULL, інакше FULL JOIN мовчки перетвориться на звичайний. Щоб результат читався як звіт, додають колонку-діагноз через CASE: missing_payment, orphan_payment, ok.

Nuances. MySQL does not support FULL OUTER JOIN — it is emulated with LEFT JOIN … UNION … RIGHT JOIN. A filter on the «optional» side belongs in ON or has to allow IS NULL, otherwise the FULL JOIN silently becomes an ordinary one. To make the output read like a report, add a diagnosis column via CASE: missing_payment, orphan_payment, ok.

Звірка оплачених замовлень з успішними платежами
Reconciling paid orders with successful payments
SELECT o.id AS order_id, o.status, p.id AS payment_id, p.order_id AS pay_order,
       CASE
         WHEN p.id IS NULL THEN 'missing_payment'
         WHEN o.id IS NULL THEN 'orphan_payment'
         ELSE 'ok'
       END AS diagnosis
FROM (SELECT * FROM orders WHERE status IN ('paid', 'shipped')) o
FULL JOIN (SELECT * FROM payments WHERE status = 'success') p
       ON p.order_id = o.id
ORDER BY COALESCE(o.id, p.order_id), p.id;
Результат у psql
Result in psql
 order_id | status  | payment_id | pay_order |   diagnosis
----------+---------+------------+-----------+----------------
      101 | paid    |          2 |       101 | ok
      102 | shipped |          3 |       102 | ok
      104 | paid    |          4 |       104 | ok
      106 | paid    |          5 |       106 | ok
      106 | paid    |          6 |       106 | ok
     NULL | NULL    |          7 |       999 | orphan_payment
(6 rows)
🔧 Розбір запиту: Фільтри застосовано до кожної сторони окремо в підзапитах, тому FULL JOIN лишається повним. Замовлення 106 має два успішні платежі (два рядки ok — це подвійне списання), а платіж 7 посилається на замовлення 999, якого немає.
🔧 Query breakdown: Filters are applied to each side separately in subqueries, so the FULL JOIN stays full. Order 106 has two successful payments (two ok rows — a double charge), and payment 7 points to order 999, which does not exist.
💡 Простими словами: FULL JOIN — це «покажи все з обох боків і де вони не зійшлися».
💡 In plain words: FULL JOIN means «show everything from both sides and where they fail to meet».
🗣 Як читати вголос RIGHT JOIN — це той самий LEFT JOIN, тільки з переставленими таблицями, тому я його майже не пишу. FULL OUTER JOIN повертає все з обох боків, і для QA це зручний інструмент звірки: беру оплачені замовлення, успішні платежі, з'єдную і одразу бачу, де немає оплати, а де платіж без замовлення. Фільтри ставлю в підзапити або в ON, щоб не зламати FULL. У MySQL його немає, там роблю через UNION.
🗣 How to say it out loud RIGHT JOIN is the same LEFT JOIN with the tables swapped, so I hardly ever write it. FULL OUTER JOIN returns everything from both sides, and for QA it is a handy reconciliation tool: I take paid orders and successful payments, join them and immediately see where a payment is missing and where a payment has no order. I put filters into subqueries or into ON so the FULL does not break. MySQL lacks it, so there I go through UNION.
10 Що таке CROSS JOIN і скільки рядків дасть JOIN, якщо ключ повторюється з обох боків? What is a CROSS JOIN, and how many rows does a JOIN give when the key repeats on both sides?
JOINJOIN Middle ▶

CROSS JOIN. Декартів добуток: кожен рядок першої таблиці з кожним рядком другої, без умови. 5 покупців × 4 товари = 20 рядків. Випадковий CROSS JOIN — класичний баг: забута умова в старому синтаксисі FROM a, b дає мільйони рядків і «вбиває» базу. Навмисно його використовують, щоб згенерувати всі комбінації: матрицю «кожен статус × кожен день» для звіту без пропусків чи набір тестових даних «кожен тип користувача × кожен тариф».

CROSS JOIN. The Cartesian product: every row of the first table with every row of the second, with no condition. 5 buyers × 4 products = 20 rows. An accidental CROSS JOIN is a classic bug: a missing condition in the old FROM a, b syntax produces millions of rows and «kills» the database. It is used on purpose to generate every combination: a «each status × each day» matrix for a report with no gaps, or test data like «each user type × each plan».

Множення рядків. Будь-який JOIN — це відфільтрований CROSS JOIN. Якщо зліва 3 рядки з key = 1, а справа 2 рядки з key = 1, INNER JOIN за key дасть 3 × 2 = 6 рядків: кожен лівий зустрінеться з кожним правим. Питання з квізу саме про це, і правильна відповідь — 6, а не 2 чи 3.

Row multiplication. Every JOIN is a filtered CROSS JOIN. If the left side has 3 rows with key = 1 and the right side has 2 rows with key = 1, an INNER JOIN on key yields 3 × 2 = 6 rows: each left row meets each right row. The quiz question is exactly about this, and the right answer is 6, not 2 or 3.

Чому це важливо для QA. Коли в звіті сума раптом удвічі більша, першою гіпотезою має бути неунікальний ключ з'єднання. Перевірка проста: до і після JOIN порахувати count(*) і перевірити унікальність ключа з кожного боку через GROUP BY key HAVING count(*) > 1.

Why QA cares. When a report total is suddenly doubled, the first hypothesis should be a non-unique join key. The check is simple: count count(*) before and after the JOIN and verify the key is unique on each side with GROUP BY key HAVING count(*) > 1.

3 × 2 = 6 і декартів добуток для матриці
3 × 2 = 6 and a Cartesian product for a matrix
WITH l(key, tag) AS (VALUES (1, 'L1'), (1, 'L2'), (1, 'L3')),
     r(key, tag) AS (VALUES (1, 'R1'), (1, 'R2'))
SELECT count(*) AS joined_rows
FROM l JOIN r ON r.key = l.key;

SELECT s.status, c.category
FROM (VALUES ('paid'), ('cancelled')) AS s(status)
CROSS JOIN (SELECT DISTINCT category FROM products WHERE category IS NOT NULL) AS c
ORDER BY 1, 2;
Результат у psql
Result in psql
 joined_rows
-------------
           6
(1 row)

  status   |  category
-----------+-------------
 cancelled | accessories
 cancelled | displays
 paid      | accessories
 paid      | displays
(4 rows)
🔧 Розбір запиту: VALUES створює маленькі таблиці прямо в запиті, щоб перевірити поведінку без зміни схеми. CROSS JOIN двох статусів із двома категоріями дає 4 комбінації — основу для звіту, у якому потрібні й нульові клітинки.
🔧 Query breakdown: VALUES builds tiny tables inside the query so you can check behaviour without touching the schema. A CROSS JOIN of two statuses and two categories gives 4 combinations — the skeleton of a report that needs the zero cells too.
💡 Простими словами: JOIN не «знаходить відповідник», він «знаходить усі відповідники» — і кожен дає рядок.
💡 In plain words: A JOIN does not «find the match», it «finds every match» — and each one becomes a row.
🗣 Як читати вголос CROSS JOIN — це декартів добуток, кожен з кожним, і випадково його отримують, коли забувають умову з'єднання. Навмисно я ним генерую комбінації, наприклад усі статуси на всі дні. А на питання про три і два рядки з однаковим ключем відповідь шість: кожен лівий зустрічається з кожним правим. Тому коли сума в звіті подвоюється, я першим ділом перевіряю унікальність ключа з'єднання.
🗣 How to say it out loud CROSS JOIN is the Cartesian product, everything with everything, and people get it by accident when they forget the join condition. I use it on purpose to generate combinations, say every status for every day. For the three-and-two rows question the answer is six: each left row meets each right row. So when a report total doubles, the first thing I check is whether the join key is unique.
11 Що таке self join і як ним знайти, хто кого запросив? What is a self join, and how do you use it to find who referred whom?
JOINJOIN Middle ▶

Що це. Self join — з'єднання таблиці самої з собою. Окремого ключового слова немає: це звичайний JOIN, де та сама таблиця з'являється двічі під різними аліасами. Аліаси тут обов'язкові, інакше база не зрозуміє, яка з двох копій має на увазі id.

What it is. A self join joins a table to itself. There is no special keyword: it is an ordinary JOIN where the same table appears twice under different aliases. The aliases are mandatory here, otherwise the database cannot tell which of the two copies id refers to.

Коли потрібен. Для ієрархій і зв'язків усередині однієї таблиці: співробітник і його менеджер, категорія і батьківська категорія, у нашій схемі — покупець і той, хто його запросив (referred_by). А ще для порівняння рядків між собою: знайти двох користувачів з однаковим email без урахування регістру, замовлення одного покупця, створені з різницею менше хвилини (підозра на подвійний клік), чи пари товарів з однаковою ціною.

When you need it. For hierarchies and links inside one table: an employee and their manager, a category and its parent, and in our schema a buyer and whoever referred them (referred_by). Also for comparing rows with each other: finding two users with the same email ignoring case, orders by one buyer created less than a minute apart (a double-click suspect), or pairs of products with the same price.

Підводні камені. Для запрошень потрібен LEFT JOIN, інакше покупці без реферера зникнуть. При пошуку пар умова a.id < b.id прибирає і пару «рядок сам із собою», і дзеркальні дублікати (1–4 та 4–1). Для глибокої ієрархії (запрошений запрошеним запрошеного) self join на кожен рівень не масштабується — там потрібен рекурсивний CTE.

Pitfalls. Referrals need a LEFT JOIN, otherwise buyers without a referrer disappear. When looking for pairs, the condition a.id < b.id removes both the «row with itself» pair and the mirrored duplicates (1–4 and 4–1). For a deep hierarchy (the referral of a referral of a referral) one self join per level does not scale — that needs a recursive CTE.

Реферер кожного покупця і пари з однаковим email
Each buyer's referrer and pairs sharing an email
SELECT u.name AS buyer, r.name AS referred_by
FROM users u
LEFT JOIN users r ON r.id = u.referred_by
ORDER BY u.id;

SELECT a.id AS id_a, b.id AS id_b, a.email, b.email
FROM users a
JOIN users b ON lower(a.email) = lower(b.email) AND a.id < b.id;
Результат у psql
Result in psql
  buyer  | referred_by
---------+-------------
 Anna    | NULL
 Bohdan  | Anna
 Olena   | Anna
 Anna K. | NULL
 Taras   | Bohdan
(5 rows)

 id_a | id_b |     email     |     email
------+------+---------------+---------------
    1 |    4 | anna@mail.com | Anna@Mail.com
(1 row)
🔧 Розбір запиту: u — покупець, r — та сама таблиця в ролі реферера. У другому запиті a.id < b.id залишає одну пару (1, 4) замість трьох: без неї були б ще (4, 1) і кожен рядок сам із собою.
🔧 Query breakdown: u is the buyer, r is the same table in the referrer role. In the second query a.id < b.id keeps one pair (1, 4) instead of several: without it you would also get (4, 1) and every row paired with itself.
💡 Простими словами: Self join — це дві копії однієї таблиці з різними іменами.
💡 In plain words: A self join is two copies of one table under different names.
🗣 Як читати вголос Self join — це звичайний JOIN таблиці із самою собою під двома аліасами. Класичний приклад — працівник і менеджер, а в нашій схемі — хто кого запросив через колонку referred_by, і тут я беру LEFT JOIN, щоб не загубити тих, кого ніхто не запрошував. Ще я використовую self join для пошуку підозрілих пар, наприклад email, що відрізняються лише регістром, і додаю умову a.id менше b.id, щоб не бачити дзеркальних дублів.
🗣 How to say it out loud A self join is an ordinary JOIN of a table with itself under two aliases. The classic example is employee and manager, and in our schema it is who referred whom via the referred_by column, where I use LEFT JOIN so I do not lose people nobody referred. I also use a self join to find suspicious pairs, like emails that differ only by case, and add a.id less than b.id so I do not see mirrored duplicates.
12 Як знайти рядки без пари в іншій таблиці: LEFT JOIN IS NULL, NOT EXISTS чи EXCEPT? How do you find rows with no match in another table: LEFT JOIN IS NULL, NOT EXISTS or EXCEPT?
JOINJOIN Middle ▶

Що це. Anti-join — «рядки з A, для яких немає пари в B». Для QA це основний запит перевірки цілісності: товари, яких ніхто не купував, замовлення без позицій, платежі на неіснуючі замовлення, користувачі без профілю після реєстрації.

What it is. An anti-join is «rows of A with no match in B». For QA it is the core integrity-check query: products nobody bought, orders without items, payments for orders that do not exist, users without a profile after sign-up.

Три способи. (1) LEFT JOIN b … WHERE b.key IS NULL — перевіряти треба колонку, яка в B не може бути NULL (краще первинний ключ), інакше справжні пари з NULL у цій колонці теж потраплять у вивід. (2) WHERE NOT EXISTS (SELECT 1 FROM b WHERE b.key = a.key) — найчитабельніше і безпечне щодо NULL; PostgreSQL виконує його як той самий anti-join. (3) SELECT key FROM a EXCEPT SELECT key FROM b — порівнює набори значень і прибирає дублікати, але повертає тільки ключі, без інших колонок. У MySQL EXCEPT з'явився лише у 8.0.31.

Three ways. (1) LEFT JOIN b … WHERE b.key IS NULL — you must test a column that cannot be NULL in B (the primary key is best), otherwise genuine matches with NULL in that column show up too. (2) WHERE NOT EXISTS (SELECT 1 FROM b WHERE b.key = a.key) — the most readable and NULL-safe; PostgreSQL runs it as the same anti-join. (3) SELECT key FROM a EXCEPT SELECT key FROM b — compares sets of values and removes duplicates, but returns only the keys, no other columns. MySQL got EXCEPT only in 8.0.31.

Чого уникати. NOT IN (SELECT key FROM b) ламається, якщо в підзапиті є хоч один NULL, — тоді результат порожній. Детально про це окрема картка. Практична порада: називай такі запити за тим, що вони шукають (orphan_payments, orders_without_items), і збирай їх в один набір перевірок.

What to avoid. NOT IN (SELECT key FROM b) breaks if the subquery has even one NULL — the result is then empty. That gets a card of its own. A practical tip: name such queries after what they look for (orphan_payments, orders_without_items) and collect them into one set of checks.

Сироти трьома способами
Orphans three ways
SELECT p.id, p.name
FROM products p
LEFT JOIN order_items i ON i.product_id = p.id
WHERE i.order_id IS NULL;

SELECT o.id, o.status
FROM orders o
WHERE NOT EXISTS (SELECT 1 FROM order_items i WHERE i.order_id = o.id);

SELECT order_id FROM payments
EXCEPT
SELECT id FROM orders;
Результат у psql
Result in psql
 id |  name
----+--------
  4 | Webcam
(1 row)

 id  | status
-----+--------
 106 | paid
(1 row)

 order_id
----------
      999
(1 row)
🔧 Розбір запиту: Webcam ніхто не купував. Замовлення 106 оплачене, але не має жодної позиції — це баг даних. EXCEPT повертає 999: платіж посилається на замовлення, якого немає, і лише відсутність зовнішнього ключа в payments дозволила йому з'явитися.
🔧 Query breakdown: Nobody bought the Webcam. Order 106 is paid but has no items at all — a data bug. EXCEPT returns 999: a payment points to an order that does not exist, and only the missing foreign key on payments let it in.
💡 Простими словами: Правильна перевірка сиріт повертає порожній результат, коли все гаразд.
💡 In plain words: A good orphan check returns an empty result when everything is fine.
🗣 Як читати вголос Для пошуку рядків без пари я знаю три способи. LEFT JOIN і перевірка, що ключ правої таблиці IS NULL, — тільки треба брати колонку, яка не може бути NULL. NOT EXISTS — мій улюблений, бо читається як речення і не боїться NULL. EXCEPT порівнює набори ключів, коли інші колонки не потрібні. NOT IN для цього не беру через пастку з NULL. Такі запити я тримаю як автоматичні перевірки цілісності.
🗣 How to say it out loud I know three ways to find rows with no match. LEFT JOIN and a check that the right table's key IS NULL — as long as the column cannot be NULL. NOT EXISTS is my favourite because it reads like a sentence and does not fear NULL. EXCEPT compares sets of keys when I need no other columns. I do not use NOT IN for this because of the NULL trap. I keep such queries as automated integrity checks.
13 Чим умова в ON відрізняється від умови в WHERE для LEFT JOIN? How does a condition in ON differ from one in WHERE for a LEFT JOIN?
JOINJOIN Senior ▶

Суть. Для INNER JOIN різниці немає — результат той самий. Для LEFT JOIN вона принципова. Умова в ON вирішує, які рядки справа вважаються парою; лівий рядок залишається в будь-якому разі, просто з NULL, якщо пари не знайшлося. Умова в WHERE виконується після з'єднання і фільтрує вже готові рядки — а в рядках без пари праві колонки NULL, тож WHERE o.status = 'paid' їх відкидає. LEFT JOIN мовчки стає INNER.

The point. For an INNER JOIN there is no difference — the result is the same. For a LEFT JOIN it is fundamental. A condition in ON decides which right-hand rows count as a match; the left row stays regardless, just with NULLs if nothing matched. A condition in WHERE runs after the join and filters finished rows — and unmatched rows have NULL in the right-hand columns, so WHERE o.status = 'paid' throws them away. The LEFT JOIN silently becomes an INNER one.

Приклад з проєкту. Звіт «кількість оплачених замовлень на кожного покупця» має показати всіх п'ятьох, навіть з нулем. Якщо фільтр статусу поставити у WHERE, зникнуть Olena (лише скасоване замовлення) та Anna K. (без замовлень), і тест, що перевіряє «рядків у звіті стільки ж, скільки користувачів», впаде — або, гірше, хтось «підправить» очікування.

A project example. A report of «number of paid orders per buyer» must show all five buyers, even with zero. Put the status filter in WHERE and Olena (only a cancelled order) and Anna K. (no orders) disappear, and a test checking «the report has as many rows as there are users» fails — or worse, someone «adjusts» the expectation.

Правило. Умови на праву (опціональну) таблицю — в ON; умови на ліву таблицю — у WHERE. Виняток — навмисний WHERE right.key IS NULL для anti-join. На рев'ю запитів це одна з найчастіших знахідок: LEFT JOIN плюс WHERE по правій таблиці. Поставити фільтр лівої таблиці в ON — теж помилка навпаки: лівий рядок усе одно лишиться у виводі, лише втратить пару, тож фільтр нічого не відфільтрує.

The rule. Conditions on the right (optional) table go into ON; conditions on the left table go into WHERE. The exception is a deliberate WHERE right.key IS NULL for an anti-join. In query reviews this is one of the most frequent findings: a LEFT JOIN plus WHERE on the right table. Putting a left-table filter into ON is the opposite mistake: the left row still stays in the output, it just loses its match, so the filter filters nothing.

Той самий фільтр у ON і у WHERE
The same filter in ON and in WHERE
SELECT u.name, count(o.id) AS paid_orders
FROM users u
LEFT JOIN orders o ON o.user_id = u.id AND o.status = 'paid'
GROUP BY u.id, u.name
ORDER BY u.id;

SELECT u.name, count(o.id) AS paid_orders
FROM users u
LEFT JOIN orders o ON o.user_id = u.id
WHERE o.status = 'paid'
GROUP BY u.id, u.name
ORDER BY u.id;
Результат у psql
Result in psql
  name   | paid_orders
---------+-------------
 Anna    |           1
 Bohdan  |           1
 Olena   |           0
 Anna K. |           0
 Taras   |           1
(5 rows)

  name  | paid_orders
--------+-------------
 Anna   |           1
 Bohdan |           1
 Taras  |           1
(3 rows)
🔧 Розбір запиту: Перший варіант дає 5 рядків, у тому числі нулі для Olena та Anna K.: count(o.id) не рахує NULL. Другий дає 3 рядки — WHERE відкинув усі рядки, де o.status NULL або не 'paid'.
🔧 Query breakdown: The first version gives 5 rows, including zeros for Olena and Anna K.: count(o.id) does not count NULL. The second gives 3 rows — WHERE dropped every row where o.status is NULL or not 'paid'.
💡 Простими словами: ON вирішує, з ким з'єднати; WHERE — кого залишити після з'єднання.
💡 In plain words: ON decides whom to join with; WHERE decides whom to keep after the join.
🗣 Як читати вголос Для INNER JOIN різниці немає, а для LEFT вона ключова. Умова в ON лише визначає, що вважати парою, і лівий рядок усе одно лишається. Умова у WHERE працює після з'єднання, а в рядках без пари праві колонки порожні, тому фільтр по правій таблиці у WHERE фактично перетворює LEFT на INNER. Я на рев'ю завжди шукаю саме цей патерн, бо він тихо губить рядки у звітах з нулями.
🗣 How to say it out loud For INNER JOIN there is no difference; for LEFT it is the key point. A condition in ON only defines what counts as a match, and the left row stays anyway. A condition in WHERE runs after the join, and in unmatched rows the right columns are empty, so a filter on the right table in WHERE effectively turns LEFT into INNER. In reviews I always look for exactly this pattern, because it quietly drops rows from reports that should show zeros.
14 Що роблять JOIN … USING і NATURAL JOIN і чому NATURAL JOIN уникають? What do JOIN … USING and NATURAL JOIN do, and why is NATURAL JOIN avoided?
JOINJOIN Middle ▶

USING. JOIN payments USING (order_id) — скорочення для ON a.order_id = b.order_id, коли колонки з обох боків називаються однаково. Бонус: у результаті буде одна колонка order_id, а не дві, і її можна писати без префікса таблиці. Працює в PostgreSQL, MySQL, Oracle; зручно у схемах, де ключі послідовно названі.

USING. JOIN payments USING (order_id) is shorthand for ON a.order_id = b.order_id when the columns are named the same on both sides. Bonus: the result has a single order_id column instead of two, and you can refer to it without a table prefix. It works in PostgreSQL, MySQL and Oracle and is handy in schemas with consistently named keys.

NATURAL JOIN. З'єднує за всіма колонками з однаковими іменами, які знайде. Умова неявна: її не видно в запиті, і вона змінюється, коли хтось додає колонку. У нашій схемі orders NATURAL JOIN payments з'єднає за id і status — бо обидві таблиці мають такі колонки, хоча orders.id і payments.id означають зовсім різне. Результат — тихо неправильний.

NATURAL JOIN. Joins on all columns with matching names it can find. The condition is implicit: you cannot see it in the query, and it changes when someone adds a column. In our schema orders NATURAL JOIN payments joins on id and status, because both tables have those columns, even though orders.id and payments.id mean completely different things. The result is silently wrong.

Чому це важливо. Типова історія: додали в обидві таблиці колонку updated_at, і NATURAL JOIN почав повертати майже нуль рядків — без жодної помилки. Тому в продакшн-коді й у тестових перевірках пишуть явний ON (або USING для однойменних ключів), а NATURAL лишають для швидких експериментів у консолі.

Why it matters. A typical story: someone adds an updated_at column to both tables, and the NATURAL JOIN starts returning almost no rows — without any error. That is why production code and test checks spell out ON (or USING for same-named keys), and NATURAL stays for quick experiments in the console.

USING проти NATURAL на реальній схемі
USING versus NATURAL on the real schema
SELECT order_id, i.product_id, p.amount
FROM order_items i
JOIN payments p USING (order_id)
WHERE p.status = 'success'
ORDER BY order_id, i.product_id;

SELECT count(*) AS natural_rows
FROM orders NATURAL JOIN payments;
Результат у psql
Result in psql
 order_id | product_id | amount
----------+------------+--------
      101 |          1 |  90.00
      101 |          2 |  90.00
      102 |          3 | 200.00
      104 |          1 | 250.00
      104 |          3 | 250.00
(5 rows)

 natural_rows
--------------
            0
(1 row)
🔧 Розбір запиту: USING дає одну колонку order_id без префікса. NATURAL JOIN повертає 0 рядків: він шукав пари, де orders.id = payments.id і однаковий status, а таких немає. Помилки немає — лише неправильна відповідь.
🔧 Query breakdown: USING gives a single order_id column with no prefix. NATURAL JOIN returns 0 rows: it looked for pairs with orders.id = payments.id and the same status, and there are none. No error — just a wrong answer.
💡 Простими словами: USING — явне скорочення; NATURAL — неявна здогадка бази.
💡 In plain words: USING is an explicit shorthand; NATURAL is the database guessing.
🗣 Як читати вголос USING — це скорочення для ON, коли колонка ключа названа однаково в обох таблицях, і в результаті вона одна. NATURAL JOIN іде далі й сам з'єднує за всіма однойменними колонками, а це крихко: на нашій схемі він підхопив би id і status і повернув нуль рядків без жодної помилки. Тому я пишу явний ON, а NATURAL вважаю ризиком, який вибухне після першої ж міграції схеми.
🗣 How to say it out loud USING is shorthand for ON when the key column has the same name in both tables, and the result has just one such column. NATURAL JOIN goes further and joins on every same-named column by itself, which is fragile: on our schema it would pick up id and status and return zero rows without any error. So I write an explicit ON and treat NATURAL as a risk that blows up after the first schema migration.
▶ 3 8
15 Які є агрегатні функції і як GROUP BY рахує замовлення кожного покупця? What aggregate functions exist, and how does GROUP BY count each buyer's orders?
АгрегаціяAggregation Junior ▶

Агрегати. Стандартні агрегатні функції — COUNT, SUM, AVG, MIN, MAX. Вони згортають багато рядків в одне значення. Без GROUP BY агрегат рахується по всій таблиці (після WHERE) і повертає рівно один рядок, навіть якщо таблиця порожня: count(*) тоді дасть 0, а sum — NULL.

Aggregates. The standard aggregate functions are COUNT, SUM, AVG, MIN, MAX. They fold many rows into one value. Without GROUP BY an aggregate runs over the whole table (after WHERE) and returns exactly one row, even when the table is empty: count(*) then gives 0 and sum gives NULL.

GROUP BY. Розбиває рядки на групи з однаковими значеннями вказаних колонок і рахує агрегат для кожної групи окремо. SELECT user_id, count(*) FROM orders GROUP BY user_id — класична відповідь на питання «скільки замовлень у кожного покупця». Групувати можна за кількома колонками (user_id, status) і за виразом, наприклад за місяцем date_trunc('month', created_at).

GROUP BY. Splits rows into groups with equal values in the listed columns and computes the aggregate for each group separately. SELECT user_id, count(*) FROM orders GROUP BY user_id is the classic answer to «how many orders does each buyer have». You can group by several columns (user_id, status) and by an expression such as the month, date_trunc('month', created_at).

Нюанси для перевірок. Групування по orders не покаже покупців без замовлень — для них потрібен LEFT JOIN від users. NULL у колонці групування утворює окрему групу. А порожня таблиця з GROUP BY поверне нуль рядків, а не рядок з нулем, — тест, що чекає «0» у першому рядку, отримає порожній результат і впаде з незрозумілою помилкою.

Nuances for checks. Grouping over orders will not show buyers with no orders — that needs a LEFT JOIN from users. NULL in a grouping column forms a group of its own. And an empty table with GROUP BY returns zero rows, not a row with a zero — a test expecting «0» in the first row gets an empty result and fails with a confusing error.

Лічильник і суми на покупця та загальні агрегати
A counter and totals per buyer, plus overall aggregates
SELECT user_id, count(*) AS orders, sum(total) AS spent, max(created_at)::date AS last_order
FROM orders
GROUP BY user_id
ORDER BY user_id;

SELECT count(*) AS n, sum(total) AS s, min(total) AS lo, max(total) AS hi, round(avg(total), 2) AS mean
FROM orders WHERE status = 'refunded';
Результат у psql
Result in psql
 user_id | orders | spent  | last_order
---------+--------+--------+------------
       1 |      2 | 290.00 | 2026-03-05
       2 |      2 | 300.00 | 2026-03-31
       3 |      1 |  50.00 | 2026-04-02
       5 |      1 |  20.00 | 2026-04-03
(4 rows)

 n |  s   |  lo  |  hi  | mean
---+------+------+------+------
 0 | NULL | NULL | NULL | NULL
(1 row)
🔧 Розбір запиту: Перший запит дає рядок на кожного покупця з замовленнями — user 4 відсутній. Другий фільтрує неіснуючий статус: без GROUP BY агрегат все одно повертає один рядок, де count = 0, а решта — NULL.
🔧 Query breakdown: The first query returns one row per buyer who has orders — user 4 is absent. The second filters on a status that does not exist: without GROUP BY the aggregate still returns one row, with count = 0 and NULL for the rest.
💡 Простими словами: GROUP BY — «розклади по купках», агрегат — «порахуй кожну купку».
💡 In plain words: GROUP BY is «sort into piles», the aggregate is «count each pile».
🗣 Як читати вголос Основні агрегатні функції — COUNT, SUM, AVG, MIN і MAX, вони згортають багато рядків в одне значення. GROUP BY розкладає рядки на групи за значенням колонки, тож кількість замовлень на покупця — це count зірочка з групуванням по user_id. Важливі дрібниці: покупців без замовлень так не побачиш, NULL стає окремою групою, а SUM порожнього набору — NULL, а не нуль.
🗣 How to say it out loud The main aggregate functions are COUNT, SUM, AVG, MIN and MAX, and they fold many rows into one value. GROUP BY splits rows into groups by a column value, so orders per buyer is count star grouped by user_id. The small print matters: you will not see buyers with no orders that way, NULL becomes a group of its own, and the SUM of an empty set is NULL, not zero.
16 Чим COUNT(*) відрізняється від COUNT(col) і COUNT(DISTINCT col) після LEFT JOIN? How does COUNT(*) differ from COUNT(col) and COUNT(DISTINCT col) after a LEFT JOIN?
АгрегаціяAggregation Middle ▶

Різниця. COUNT(*) рахує рядки, незалежно від вмісту. COUNT(col) рахує рядки, де col не NULL. COUNT(DISTINCT col) рахує унікальні значення без NULL. На звичайній таблиці різниця видна лише для колонок з NULL, а після LEFT JOIN вона стає критичною.

The difference. COUNT(*) counts rows regardless of content. COUNT(col) counts rows where col is not NULL. COUNT(DISTINCT col) counts unique values without NULL. On a plain table the difference only shows for nullable columns; after a LEFT JOIN it becomes critical.

Пастка з LEFT JOIN. Покупець без замовлень після users LEFT JOIN orders все одно дає один рядок — з NULL в колонках замовлення. COUNT(*) порахує його як 1, і звіт покаже, що в Anna K. одне замовлення. Правильно — COUNT(o.id): NULL не рахується, і виходить 0. Це одна з найпоширеніших помилок у звітах, яку QA ловить, порівнюючи звіт з даними одного конкретного покупця.

The LEFT JOIN trap. A buyer with no orders still yields one row after users LEFT JOIN orders — with NULL in the order columns. COUNT(*) counts it as 1, and the report says Anna K. has one order. The right form is COUNT(o.id): NULL is not counted, and you get 0. It is one of the most common report bugs, and QA catches it by comparing the report with the data of one specific buyer.

DISTINCT. Якщо в запиті ще й приєднано позиції замовлень, рядків стає більше, ніж замовлень, і лише COUNT(DISTINCT o.id) дасть кількість замовлень. Але DISTINCT тут — ознака, що запит варто переписати: рахувати в підзапиті до JOIN, бо з SUM такий трюк не спрацює. Щодо швидкодії: COUNT(1) не швидший за COUNT(*) — результат однаковий, а в PostgreSQL COUNT(*) навіть трохи дешевший, бо не перевіряє аргумент на NULL.

DISTINCT. If order items are joined in as well, there are more rows than orders, and only COUNT(DISTINCT o.id) gives the number of orders. But a DISTINCT here signals the query should be rewritten to count in a subquery before the JOIN, because this trick does not work for SUM. On performance: COUNT(1) is not faster than COUNT(*) — the result is the same, and in PostgreSQL COUNT(*) is even slightly cheaper because it does not check an argument for NULL.

Три лічильники на одному LEFT JOIN
Three counters on one LEFT JOIN
SELECT u.name,
       count(*)             AS count_star,
       count(o.id)          AS count_orders,
       count(i.product_id)  AS count_items,
       count(DISTINCT o.id) AS distinct_orders
FROM users u
LEFT JOIN orders o      ON o.user_id = u.id
LEFT JOIN order_items i ON i.order_id = o.id
GROUP BY u.id, u.name
ORDER BY u.id;
Результат у psql
Result in psql
  name   | count_star | count_orders | count_items | distinct_orders
---------+------------+--------------+-------------+-----------------
 Anna    |          3 |            3 |           3 |               2
 Bohdan  |          3 |            3 |           3 |               2
 Olena   |          1 |            1 |           1 |               1
 Anna K. |          1 |            0 |           0 |               0
 Taras   |          1 |            1 |           0 |               1
(5 rows)
🔧 Розбір запиту: Anna має 2 замовлення, але 3 позиції, тому count(*) і count(o.id) дають 3 — рахуються рядки після JOIN з позиціями. Лише count(DISTINCT o.id) дає 2. Для Anna K. count(*) = 1, решта — 0. Taras: замовлення є, позицій немає.
🔧 Query breakdown: Anna has 2 orders but 3 items, so count(*) and count(o.id) both give 3 — they count rows after joining the items. Only count(DISTINCT o.id) gives 2. For Anna K. count(*) = 1 and the rest are 0. Taras has an order but no items.
💡 Простими словами: COUNT(*) рахує рядки, COUNT(col) — заповнені значення.
💡 In plain words: COUNT(*) counts rows; COUNT(col) counts filled-in values.
🗣 Як читати вголос COUNT зірочка рахує рядки, COUNT колонки — тільки ті, де значення не NULL, а COUNT DISTINCT — унікальні значення. Різниця найболючіша після LEFT JOIN: покупець без замовлень дає рядок з NULL, і COUNT зірочка покаже в нього одне замовлення. Тому в таких звітах я рахую COUNT по первинному ключу правої таблиці, а якщо бачу COUNT DISTINCT, перевіряю, чи не розмножив рядки ще один JOIN.
🗣 How to say it out loud COUNT star counts rows, COUNT of a column counts only non-NULL values, and COUNT DISTINCT counts unique values. The difference hurts most after a LEFT JOIN: a buyer without orders yields a row full of NULLs, and COUNT star reports one order for them. So in such reports I count the primary key of the right table, and when I see COUNT DISTINCT I check whether another JOIN multiplied the rows.
17 Чим WHERE відрізняється від HAVING і як залишити покупців з більш ніж N замовленнями? How does WHERE differ from HAVING, and how do you keep buyers with more than N orders?
АгрегаціяAggregation Middle ▶

Різниця. WHERE фільтрує окремі рядки до групування, HAVING — готові групи після GROUP BY. Тому агрегат у WHERE заборонений (груп ще немає): WHERE count(*) > 1 дає помилку aggregate functions are not allowed in WHERE. А в HAVING можна і агрегати, і колонки з GROUP BY.

The difference. WHERE filters individual rows before grouping, HAVING filters finished groups after GROUP BY. That is why an aggregate is forbidden in WHERE (there are no groups yet): WHERE count(*) > 1 raises aggregate functions are not allowed in WHERE. HAVING accepts both aggregates and GROUP BY columns.

Як писати. «Покупці з більш ніж N замовленнями»: GROUP BY user_id HAVING count(*) > N. Якщо треба рахувати лише неcкасовані замовлення, умова на статус іде у WHERE — вона відкидає рядки до підрахунку. Якщо поставити її в HAVING, запит або не скомпілюється, або рахуватиме не те. Умови, які можна перевірити на окремому рядку, завжди краще тримати у WHERE: менше рядків потрапляє в групування, і планувальник може використати індекс.

How to write it. «Buyers with more than N orders»: GROUP BY user_id HAVING count(*) > N. If only non-cancelled orders should count, the status condition goes into WHERE — it drops rows before counting. Put it in HAVING and the query either fails to compile or counts the wrong thing. Conditions you can check on a single row are always better in WHERE: fewer rows go into grouping, and the planner can use an index.

Типова помилка. Сплутати «замовлень більше N» з «сума більше N»: перше — count(*), друге — sum(total). На співбесіді часто просять одразу обидва фільтри — показуй, що WHERE і HAVING можуть бути в одному запиті й роблять різну роботу.

A common mistake. Confusing «more than N orders» with «total above N»: the first is count(*), the second is sum(total). Interviews often ask for both filters at once — show that WHERE and HAVING can sit in one query and do different jobs.

WHERE до групування, HAVING після
WHERE before grouping, HAVING after
SELECT user_id, count(*) AS orders
FROM orders
WHERE count(*) > 1
GROUP BY user_id;

SELECT user_id, count(*) AS orders, sum(total) AS spent
FROM orders
WHERE status <> 'cancelled'
GROUP BY user_id
HAVING count(*) > 1 AND sum(total) > 250
ORDER BY user_id;
Результат у psql
Result in psql
ERROR:  aggregate functions are not allowed in WHERE

 user_id | orders | spent
---------+--------+--------
       1 |      2 | 290.00
       2 |      2 | 300.00
(2 rows)
🔧 Розбір запиту: Перший запит падає, бо агрегат у WHERE. У другому WHERE спочатку прибирає скасоване замовлення, GROUP BY рахує, а HAVING залишає групи з більш ніж одним замовленням і сумою понад 250: Anna має 290.00, Bohdan — 300.00.
🔧 Query breakdown: The first query fails because of the aggregate in WHERE. In the second, WHERE first removes the cancelled order, GROUP BY counts, and HAVING keeps groups with more than one order and a total above 250: Anna has 290.00, Bohdan 300.00.
💡 Простими словами: WHERE — фейс-контроль для рядків, HAVING — для груп.
💡 In plain words: WHERE is the bouncer for rows; HAVING is the bouncer for groups.
🗣 Як читати вголос WHERE фільтрує рядки до групування, HAVING — групи після, тому агрегат можна ставити тільки в HAVING, а у WHERE база відповість помилкою. Покупців з більш ніж п'ятьма замовленнями я знайду через GROUP BY по user_id і HAVING count більше п'яти. Якщо є умова на окремий рядок, наприклад виключити скасовані, вона йде у WHERE — так і логіка правильна, і база обробляє менше даних.
🗣 How to say it out loud WHERE filters rows before grouping and HAVING filters groups afterwards, so an aggregate can only go into HAVING. To find buyers with more than five orders I group by user_id with HAVING count greater than five. If there is a per-row condition, like excluding cancelled orders, it goes into WHERE — that keeps the logic right and the database processes less data.
18 Чи можна в SELECT вибрати колонку, якої немає ні в GROUP BY, ні в агрегаті? Can SELECT include a column that is neither in GROUP BY nor inside an aggregate?
АгрегаціяAggregation Middle ▶

Правило стандарту. Ні: кожна колонка в SELECT має бути або в GROUP BY, або всередині агрегатної функції. Інакше незрозуміло, яке значення показати для групи з кількох рядків. PostgreSQL відповідає помилкою column "o.status" must appear in the GROUP BY clause or be used in an aggregate function.

The standard rule. No: every SELECT column must be either in GROUP BY or inside an aggregate function. Otherwise it is unclear which value to show for a group of several rows. PostgreSQL answers with column "o.status" must appear in the GROUP BY clause or be used in an aggregate function.

Виняток — функціональна залежність. Якщо групуєш за первинним ключем таблиці, PostgreSQL дозволяє вибирати будь-які інші колонки цієї таблиці: при відомому u.id ім'я та місто однозначні. Тому GROUP BY u.id з u.name у SELECT працює, а GROUP BY u.name з u.city — ні.

The exception is functional dependency. If you group by a table's primary key, PostgreSQL lets you select any other column of that table: once u.id is known, the name and city are unambiguous. So GROUP BY u.id with u.name in SELECT works, while GROUP BY u.name with u.city does not.

Різниця діалектів. Старі версії MySQL (до 5.7) або MySQL з вимкненим режимом ONLY_FULL_GROUP_BY приймають такий запит і повертають довільне значення з групи. Це джерело «плаваючих» звітів: сьогодні показує один статус, завтра інший. Якщо запит переноситься з MySQL у PostgreSQL і падає з цією помилкою, це не баг PostgreSQL, а прихований баг у запиті. Виправлення — явно обрати значення: max(status), string_agg або «останній рядок групи» через віконну функцію.

Dialect difference. Old MySQL versions (before 5.7), or MySQL with the ONLY_FULL_GROUP_BY mode switched off, accept such a query and return an arbitrary value from the group. That is a source of «flaky» reports: one status today, another tomorrow. If a query moved from MySQL to PostgreSQL fails with this error, it is not a PostgreSQL bug but a hidden bug in the query. The fix is to choose the value explicitly: max(status), string_agg, or «the latest row of the group» with a window function.

Помилка, функціональна залежність і явний вибір значення
The error, functional dependency and an explicit choice
SELECT o.user_id, o.status, count(*)
FROM orders o
GROUP BY o.user_id;

SELECT u.id, u.name, u.city, count(o.id) AS orders
FROM users u LEFT JOIN orders o ON o.user_id = u.id
GROUP BY u.id
ORDER BY u.id;

SELECT user_id, string_agg(status, ',' ORDER BY id) AS statuses
FROM orders GROUP BY user_id ORDER BY user_id;
Результат у psql
Result in psql
ERROR:  column "o.status" must appear in the GROUP BY clause or be used in an aggregate function

 id |  name   | city  | orders
----+---------+-------+--------
  1 | Anna    | Kyiv  |      2
  2 | Bohdan  | Lviv  |      2
  3 | Olena   | NULL  |      1
  4 | Anna K. | Kyiv  |      0
  5 | Taras   | Odesa |      1
(5 rows)

 user_id |   statuses
---------+--------------
       1 | paid,shipped
       2 | new,paid
       3 | cancelled
       5 | paid
(4 rows)
🔧 Розбір запиту: Перший запит падає. Другий працює, бо u.id — первинний ключ, і name, city від нього залежать. Третій явно показує всі статуси групи замість «якогось одного».
🔧 Query breakdown: The first query fails. The second works because u.id is the primary key and name and city depend on it. The third explicitly shows every status in the group instead of «some single one».
💡 Простими словами: Якщо в групі п'ять рядків, база не вгадуватиме, котрий із них показати — треба сказати їй явно.
💡 In plain words: With five rows in a group, the database will not guess which one to show — you have to tell it.
🗣 Як читати вголос За стандартом кожна колонка в SELECT має бути або в GROUP BY, або в агрегаті, бо інакше незрозуміло, яке значення з групи показати. PostgreSQL робить виняток, якщо групуєш за первинним ключем, — тоді інші колонки цієї таблиці однозначні. MySQL без ONLY_FULL_GROUP_BY мовчки повертає випадкове значення, і для мене це червоний прапорець: такий звіт може змінюватися між запусками.
🗣 How to say it out loud By the standard every SELECT column must be in GROUP BY or inside an aggregate, otherwise it is unclear which value of the group to show. PostgreSQL makes an exception when you group by the primary key, since the other columns of that table are then unambiguous. MySQL without ONLY_FULL_GROUP_BY quietly returns an arbitrary value, and to me that is a red flag: such a report can change between runs.
19 Як AVG обробляє NULL і чому середнє в звіті може бути завищеним? How does AVG treat NULL, and why can the average in a report come out too high?
АгрегаціяAggregation Middle ▶

Правило. Усі агрегати, крім COUNT(*), ігнорують NULL. AVG(col) — це SUM(col) / COUNT(col): сума ненульових значень ділиться на їхню кількість, а не на кількість рядків. Якщо всі значення NULL, AVG повертає NULL.

The rule. Every aggregate except COUNT(*) ignores NULL. AVG(col) is SUM(col) / COUNT(col): the sum of non-NULL values is divided by how many of them there are, not by the number of rows. If every value is NULL, AVG returns NULL.

Чому це пастка. Питання, на яке відповідає AVG, залежить від того, що означає NULL. Якщо NULL — «покупець нічого не оплатив», то середня оплата на покупця має враховувати його як 0, і правильно писати AVG(COALESCE(paid, 0)). Якщо NULL — «даних ще немає» (наприклад, оцінка, яку не поставили), ігнорувати його чесно. Бізнес-вимога мусить це визначати, і QA варто спитати про це до того, як писати очікуване значення в тест.

Why it is a trap. The question AVG answers depends on what NULL means. If NULL means «the buyer paid nothing», then the average payment per buyer must count them as 0, and the right form is AVG(COALESCE(paid, 0)). If NULL means «no data yet» (say, a rating nobody has given), ignoring it is honest. The business requirement has to decide, and QA should ask before writing the expected value into a test.

Ще нюанси. AVG від цілих чисел у PostgreSQL повертає numeric, тому дробова частина не губиться, а от ділення двох integer через / — ціле: 7 / 2 дає 3. У MySQL AVG теж повертає десяткове значення, але округлення у виводі може відрізнятися — порівнюй з допуском або через round().

More nuances. AVG of integers in PostgreSQL returns numeric, so the fraction is kept, whereas dividing two integers with / is integer division: 7 / 2 gives 3. MySQL AVG also returns a decimal, but rounding in the output may differ — compare with a tolerance or through round().

Середня оплата на покупця двома способами
Average payment per buyer, two ways
WITH per_user AS (
  SELECT u.id, sum(p.amount) AS paid
  FROM users u
  LEFT JOIN orders o   ON o.user_id = u.id
  LEFT JOIN payments p ON p.order_id = o.id AND p.status = 'success'
  GROUP BY u.id
)
SELECT count(*)                          AS buyers,
       count(paid)                       AS with_payments,
       round(avg(paid), 2)               AS avg_ignoring_null,
       round(avg(COALESCE(paid, 0)), 2)  AS avg_as_zero,
       7 / 2 AS int_div, 7 / 2.0 AS num_div
FROM per_user;
Результат у psql
Result in psql
 buyers | with_payments | avg_ignoring_null | avg_as_zero | int_div |      num_div
--------+---------------+-------------------+-------------+---------+--------------------
      5 |             3 |            193.33 |      116.00 |       3 | 3.5000000000000000
(1 row)
🔧 Розбір запиту: Сума оплат 580.00. AVG без COALESCE ділить на 3 покупців з оплатами і дає 193.33; з COALESCE ділить на всіх 5 і дає 116.00. Різниця — не баг бази, а різне питання.
🔧 Query breakdown: Total payments are 580.00. AVG without COALESCE divides by the 3 buyers who paid and gives 193.33; with COALESCE it divides by all 5 and gives 116.00. The difference is not a database bug but a different question.
💡 Простими словами: AVG ділить на кількість відомих значень, а не на кількість рядків.
💡 In plain words: AVG divides by the number of known values, not by the number of rows.
🗣 Як читати вголос AVG ігнорує NULL: він ділить суму заповнених значень на їхню кількість, а не на всі рядки. Тому середня оплата на покупця без COALESCE рахується тільки по тих, хто платив, і виходить завищеною. Я в таких випадках уточнюю, що бізнес вважає NULL: нулем чи відсутністю даних, і вже тоді пишу очікуване значення. І ще пам'ятаю, що ділення двох цілих у Postgres дає ціле.
🗣 How to say it out loud AVG ignores NULL: it divides the sum of filled-in values by how many there are. So an average payment per buyer without COALESCE only covers those who paid and comes out too high. In such cases I clarify whether the business treats NULL as zero or as missing data, and only then write the expected value. I also keep in mind that dividing two integers in Postgres gives an integer.
20 Що роблять STRING_AGG / GROUP_CONCAT і FILTER для умовних підрахунків? What do STRING_AGG / GROUP_CONCAT and FILTER do for conditional counts?
АгрегаціяAggregation Middle ▶

STRING_AGG. Склеює значення групи в один рядок з роздільником: string_agg(sku, ', ' ORDER BY sku). Це функція PostgreSQL (у SQL Server теж є; у стандарті SQL:2016 і Oracle вона називається LISTAGG); у MySQL аналог — GROUP_CONCAT(sku ORDER BY sku SEPARATOR ', '). Без ORDER BY усередині порядок елементів не гарантований, і тест, що порівнює рядок «SKU-001, SKU-002», стане нестабільним. У MySQL GROUP_CONCAT ще й мовчки обрізає результат до group_concat_max_len (1024 байти за замовчуванням).

STRING_AGG. Glues a group's values into one string with a separator: string_agg(sku, ', ' ORDER BY sku). It is a PostgreSQL function (SQL Server has it too; the SQL:2016 standard and Oracle call it LISTAGG); MySQL's counterpart is GROUP_CONCAT(sku ORDER BY sku SEPARATOR ', '). Without ORDER BY inside, element order is not guaranteed, and a test comparing the string «SKU-001, SKU-002» becomes flaky. MySQL's GROUP_CONCAT also silently truncates the result to group_concat_max_len (1024 bytes by default).

Умовна агрегація. Щоб в одному рядку порахувати кілька різних речей — скільки замовлень оплачено, скільки скасовано, на яку суму, — не потрібні окремі запити. Стандартний запис count(*) FILTER (WHERE status = 'paid') підтримує PostgreSQL; переносний варіант — sum(CASE WHEN status = 'paid' THEN 1 ELSE 0 END), він працює і в MySQL.

Conditional aggregation. To count several different things in one row — how many orders are paid, how many cancelled, for what amount — you do not need separate queries. The standard form count(*) FILTER (WHERE status = 'paid') is supported by PostgreSQL; the portable version is sum(CASE WHEN status = 'paid' THEN 1 ELSE 0 END), which works in MySQL too.

Навіщо QA. Такий «зведений» запит — зручний знімок стану до й після тесту: один рядок з лічильниками за статусами, який легко порівняти з очікуванням або з дашбордом. А STRING_AGG робить вивід перевірки людським: замість десяти рядків — один з переліком проблемних id.

Why QA cares. Such a «summary» query is a handy snapshot of state before and after a test: one row of per-status counters that is easy to compare with an expectation or a dashboard. And STRING_AGG makes a check's output human-friendly: one row listing the problem ids instead of ten rows.

Товари в замовленні одним рядком і зведення за статусами
Items of an order in one string and a per-status summary
SELECT i.order_id, string_agg(p.sku, ', ' ORDER BY p.sku) AS skus
FROM order_items i JOIN products p ON p.id = i.product_id
GROUP BY i.order_id
ORDER BY i.order_id;

SELECT count(*) FILTER (WHERE status = 'paid')                    AS paid,
       count(*) FILTER (WHERE status = 'cancelled')               AS cancelled,
       sum(CASE WHEN status IN ('paid', 'shipped') THEN total END) AS revenue
FROM orders;
Результат у psql
Result in psql
 order_id |       skus
----------+------------------
      101 | SKU-001, SKU-002
      102 | SKU-003
      103 | SKU-002
      104 | SKU-001, SKU-003
      105 | SKU-001
(5 rows)

 paid | cancelled | revenue
------+-----------+---------
    3 |         1 |  570.00
(1 row)
🔧 Розбір запиту: Порядок у string_agg задано явно, тому рядок стабільний. FILTER і CASE дають той самий тип відповіді; у CASE без ELSE рядки інших статусів стають NULL, і SUM їх пропускає.
🔧 Query breakdown: The order inside string_agg is explicit, so the string is stable. FILTER and CASE give the same kind of answer; in CASE without ELSE the rows of other statuses become NULL and SUM skips them.
💡 Простими словами: Один рядок з лічильниками замість п'яти запитів — ідеальний знімок стану для тесту.
💡 In plain words: One row of counters instead of five queries is the ideal state snapshot for a test.
🗣 Як читати вголос STRING_AGG у Postgres і GROUP_CONCAT у MySQL склеюють значення групи в один рядок з роздільником. Я завжди додаю ORDER BY всередину, інакше порядок плаває, а в MySQL ще пам'ятаю про ліміт довжини, який мовчки обрізає результат. Для умовних підрахунків у Postgres є FILTER, а переносний варіант — SUM по CASE. Такий зведений запит я використовую як знімок стану до й після тесту.
🗣 How to say it out loud STRING_AGG in Postgres and GROUP_CONCAT in MySQL glue a group's values into one string with a separator. I always add ORDER BY inside, otherwise the order drifts, and in MySQL I also remember the length limit that silently truncates the result. For conditional counts Postgres has FILTER, and the portable version is SUM over CASE. I use such a summary query as a snapshot of state before and after a test.
21 Чим UNION відрізняється від UNION ALL і коли потрібні INTERSECT та EXCEPT? How does UNION differ from UNION ALL, and when do you need INTERSECT and EXCEPT?
АгрегаціяAggregation Middle ▶

UNION проти UNION ALL. Обидва склеюють результати двох запитів «один під одним». UNION прибирає повні дублікати рядків (як DISTINCT, тобто з сортуванням або хешуванням), UNION ALL залишає все й працює швидше. Кількість і типи колонок в обох частинах мають збігатися, а імена колонок береться з першого запиту. ORDER BY застосовується до всього результату і пишеться один раз наприкінці.

UNION versus UNION ALL. Both stack the results of two queries «one under the other». UNION removes exact duplicate rows (like DISTINCT, so with a sort or a hash), UNION ALL keeps everything and is faster. The number and types of columns must match in both parts, and the column names come from the first query. ORDER BY applies to the whole result and is written once at the end.

Коли що. Для підрахунків і звірок майже завжди потрібен UNION ALL: якщо два платежі мають однакову суму й статус, UNION «злипне» їх в один, і сума в перевірці зменшиться. UNION доречний, коли потрібен саме унікальний перелік, наприклад усі email з двох джерел.

Which one when. Counts and reconciliations almost always need UNION ALL: if two payments have the same amount and status, UNION «glues» them into one and the total in your check shrinks. UNION fits when you really want a unique list, say every email from two sources.

INTERSECT і EXCEPT. INTERSECT повертає рядки, що є в обох результатах, EXCEPT — рядки першого, яких немає в другому. Для QA це готові запити звірки: «id, які є в старій таблиці, але зникли в новій». Варіанти з ALL (EXCEPT ALL) враховують кількість повторів. У MySQL INTERSECT і EXCEPT з'явилися тільки у 8.0.31, в Oracle EXCEPT називається MINUS.

INTERSECT and EXCEPT. INTERSECT returns rows present in both results, EXCEPT returns rows of the first that are not in the second. For QA these are ready-made reconciliation queries: «ids that exist in the old table but vanished from the new one». The ALL variants (EXCEPT ALL) respect how many times a row repeats. MySQL got INTERSECT and EXCEPT only in 8.0.31, and Oracle calls EXCEPT MINUS.

UNION «з'їдає» подвійну оплату, UNION ALL — ні
UNION «eats» the double payment, UNION ALL does not
SELECT sum(amount) AS union_sum FROM (
  SELECT order_id, amount FROM payments WHERE order_id = 106 AND id = 5
  UNION
  SELECT order_id, amount FROM payments WHERE order_id = 106 AND id = 6
) t;

SELECT sum(amount) AS union_all_sum FROM (
  SELECT order_id, amount FROM payments WHERE order_id = 106 AND id = 5
  UNION ALL
  SELECT order_id, amount FROM payments WHERE order_id = 106 AND id = 6
) t;

SELECT id FROM orders WHERE status IN ('paid', 'shipped')
INTERSECT
SELECT order_id FROM payments WHERE status = 'success'
ORDER BY 1;
Результат у psql
Result in psql
 union_sum
-----------
     20.00
(1 row)

 union_all_sum
---------------
         40.00
(1 row)

 id
-----
 101
 102
 104
 106
(4 rows)
🔧 Розбір запиту: Два платежі по 20.00 за замовлення 106 мають однакові (order_id, amount), тому UNION лишає один рядок і сума 20.00 замість реальних 40.00. INTERSECT показує оплачені замовлення, для яких є успішний платіж.
🔧 Query breakdown: The two 20.00 payments for order 106 share (order_id, amount), so UNION keeps one row and the total is 20.00 instead of the real 40.00. INTERSECT shows paid orders that have a successful payment.
💡 Простими словами: Рахуєш гроші — пиши UNION ALL.
💡 In plain words: Counting money? Write UNION ALL.
🗣 Як читати вголос UNION і UNION ALL склеюють результати двох запитів, але UNION ще прибирає дублікати, а UNION ALL залишає все й працює швидше. Для сум і звірок я беру UNION ALL, бо два однакові платежі UNION злив би в один і сума зменшилася б. INTERSECT дає спільні рядки, EXCEPT — ті, що є тільки в першому наборі, і EXCEPT я часто використовую, щоб знайти записи, які загубилися після міграції.
🗣 How to say it out loud UNION and UNION ALL stack the results of two queries, but UNION also removes duplicates while UNION ALL keeps everything and is faster. For totals and reconciliations I use UNION ALL, because UNION would merge two identical payments into one and the total would shrink. INTERSECT gives the common rows, EXCEPT those only in the first set, and I often use EXCEPT to find records lost during a migration.
22 Чому сума після JOIN кількох таблиць виходить завищеною і як це виправити? Why does a total come out inflated after joining several tables, and how do you fix it?
АгрегаціяAggregation Senior ▶

Проблема. Коли до замовлень одночасно приєднують дві «дочірні» таблиці — позиції і платежі, — кожна позиція з'єднується з кожним платежем того самого замовлення. Замовлення з двома позиціями і двома платежами дає 4 рядки, і sum(p.amount) подвоюється, а sum(i.qty * i.price) — теж. Помилки немає, числа виглядають правдоподібно, тому баг доживає до продакшн-дашборду. Це називають fan-out або «вибухом» JOIN.

The problem. When two «child» tables — items and payments — are joined to orders at once, each item is paired with each payment of the same order. An order with two items and two payments yields 4 rows, and sum(p.amount) doubles, as does sum(i.qty * i.price). There is no error and the numbers look plausible, so the bug survives all the way to a production dashboard. This is called fan-out or a JOIN «explosion».

Як розпізнати. Тест на звіт має звіряти суму з незалежно порахованим значенням: суму платежів з таблиці payments напряму. Якщо звіт більший у ціле число разів — майже напевно fan-out. Діагностика: порахувати рядки на ключ після JOIN (count(*) на order_id) і порівняти з кількістю позицій та платежів.

How to spot it. A report test should reconcile the total with an independently computed value: the sum of payments straight from the payments table. If the report is larger by a whole multiple, it is almost certainly fan-out. To diagnose, count rows per key after the JOIN (count(*) per order_id) and compare with the number of items and payments.

Виправлення. Агрегувати кожну дочірню таблицю окремо до рівня ключа (у CTE чи підзапиті), а вже потім з'єднувати агрегати 1:1. Альтернатива — корельовані підзапити в SELECT. DISTINCT усередині SUM (sum(DISTINCT amount)) — не виправлення: він склеїть два різні платежі з однаковою сумою, як на замовленні 106. На рев'ю звітних запитів я перевіряю, що кожен JOIN іде по ключу, унікальному з одного боку.

The fix. Aggregate each child table separately to the key level (in a CTE or subquery), then join the aggregates 1:1. The alternative is correlated subqueries in SELECT. DISTINCT inside SUM (sum(DISTINCT amount)) is not a fix: it merges two different payments with the same amount, like on order 106. When reviewing report queries I check that every JOIN goes over a key that is unique on one side.

Наївний звіт проти агрегації до JOIN
A naive report versus aggregating before the JOIN
SELECT o.id, sum(i.qty * i.price) AS items_sum, sum(p.amount) AS paid
FROM orders o
LEFT JOIN order_items i ON i.order_id = o.id
LEFT JOIN payments p    ON p.order_id = o.id AND p.status = 'success'
WHERE o.id IN (101, 104)
GROUP BY o.id ORDER BY o.id;

WITH items AS (
  SELECT order_id, sum(qty * price) AS items_sum FROM order_items GROUP BY order_id
), pays AS (
  SELECT order_id, sum(amount) AS paid FROM payments WHERE status = 'success' GROUP BY order_id
)
SELECT o.id, i.items_sum, p.paid
FROM orders o
LEFT JOIN items i ON i.order_id = o.id
LEFT JOIN pays  p ON p.order_id = o.id
WHERE o.id IN (101, 104)
ORDER BY o.id;
Результат у psql
Result in psql
 id  | items_sum |  paid
-----+-----------+--------
 101 |     90.00 | 180.00
 104 |    250.00 | 500.00
(2 rows)

 id  | items_sum |  paid
-----+-----------+--------
 101 |     90.00 |  90.00
 104 |    250.00 | 250.00
(2 rows)
🔧 Розбір запиту: У замовлення 101 дві позиції й один успішний платіж 90.00: наївний запит показує оплату 180.00. У 104 дві позиції — 500.00 замість 250.00. Після агрегації в CTE кожна сторона дає рівно один рядок на замовлення, і суми правильні.
🔧 Query breakdown: Order 101 has two items and one successful 90.00 payment: the naive query shows 180.00 paid. Order 104 has two items — 500.00 instead of 250.00. After aggregating in CTEs each side gives exactly one row per order, and the totals are right.
💡 Простими словами: Спочатку згорни кожну дочірню таблицю до одного рядка на ключ, потім з'єднуй.
💡 In plain words: First fold each child table to one row per key, then join.
🗣 Як читати вголос Якщо до замовлень одночасно приєднати позиції й платежі, кожна позиція з'єднується з кожним платежем, і суми множаться — помилки немає, просто неправильні числа. Я ловлю це, звіряючи звіт з сумою, порахованою напряму з однієї таблиці: якщо різниця кратна, це fan-out. Виправляю агрегацією кожної дочірньої таблиці окремо в CTE і з'єднанням уже агрегатів. SUM DISTINCT не рятує, бо зливає різні платежі з однаковою сумою.
🗣 How to say it out loud Join items and payments to orders at the same time and every item pairs with every payment, so the totals multiply — no error, just wrong numbers. I catch it by reconciling the report with a total computed directly from one table: a whole-multiple gap means fan-out. I fix it by aggregating each child table separately in a CTE and joining the aggregates. SUM DISTINCT does not help, because it merges different payments that happen to have the same amount.
▶ 4 8
23 Що таке підзапит і чим скалярний підзапит відрізняється від derived table? What is a subquery, and how does a scalar subquery differ from a derived table?
Підзапити і вікнаSubqueries and windows Junior ▶

Підзапит. Це запит усередині іншого запиту, у дужках. Залежно від місця і того, що він повертає, розрізняють кілька видів. Скалярний повертає рівно одне значення (1 рядок, 1 колонка) і може стояти там, де очікується значення: у SELECT чи в порівнянні total > (SELECT avg(total) FROM orders). Табличний повертає набір рядків і використовується з IN, EXISTS, ANY.

A subquery. It is a query inside another query, in parentheses. Depending on where it sits and what it returns, there are several kinds. A scalar one returns exactly one value (1 row, 1 column) and can stand wherever a value is expected: in SELECT or in a comparison like total > (SELECT avg(total) FROM orders). A table subquery returns a set of rows and is used with IN, EXISTS, ANY.

Derived table. Підзапит у FROM з обов'язковим аліасом (у PostgreSQL до версії 16 аліас був обов'язковим, у 16+ його можна пропустити, але з ним читабельніше). Він поводиться як тимчасова таблиця, з якою можна робити JOIN, фільтр і групування. Типовий приклад — спочатку порахувати суму на покупця, а потім відфільтрувати або з'єднати результат.

Derived table. A subquery in FROM with an alias (PostgreSQL required the alias before version 16; from 16 on it may be omitted, but it reads better with one). It behaves like a temporary table you can JOIN, filter and group. The typical example is computing the total per buyer first, then filtering or joining the result.

Пастки. Скалярний підзапит, який несподівано повернув два рядки, дає помилку more than one row returned by a subquery used as an expression — це реальний баг у тестових хелперах, коли дані «забруднилися» дублікатами. Якщо підзапит не повернув жодного рядка, результат — NULL, і порівняння з ним тихо відкине всі рядки.

Pitfalls. A scalar subquery that unexpectedly returns two rows raises more than one row returned by a subquery used as an expression — a real bug in test helpers when the data got «polluted» with duplicates. If the subquery returns no rows, the result is NULL, and a comparison with it silently drops every row.

Скалярний підзапит, derived table і помилка «більше одного рядка»
A scalar subquery, a derived table and the «more than one row» error
SELECT id, total,
       (SELECT round(avg(total), 2) FROM orders) AS avg_total
FROM orders
WHERE total > (SELECT avg(total) FROM orders)
ORDER BY id;

SELECT t.user_id, t.spent
FROM (SELECT user_id, sum(total) AS spent FROM orders GROUP BY user_id) AS t
WHERE t.spent > 100
ORDER BY t.user_id;

SELECT (SELECT id FROM users WHERE lower(email) = 'anna@mail.com') AS user_id;
Результат у psql
Result in psql
 id  | total  | avg_total
-----+--------+-----------
 102 | 200.00 |    110.00
 104 | 260.00 |    110.00
(2 rows)

 user_id | spent
---------+--------
       1 | 290.00
       2 | 300.00
(2 rows)

ERROR:  more than one row returned by a subquery used as an expression
🔧 Розбір запиту: Середня сума замовлення — 110.00, тому вище неї лише 102 і 104. Derived table t рахує суму на покупця, а зовнішній запит фільтрує результат. Останній запит падає: без урахування регістру такий email мають два користувачі.
🔧 Query breakdown: The average order total is 110.00, so only 102 and 104 are above it. Derived table t computes the total per buyer, and the outer query filters that result. The last query fails: ignoring case, two users share that email.
💡 Простими словами: Скалярний підзапит — «одне число», derived table — «тимчасова таблиця на один запит».
💡 In plain words: A scalar subquery is «one number»; a derived table is «a temporary table for one query».
🗣 Як читати вголос Підзапит — це запит усередині іншого. Скалярний повертає одне значення, і його можна поставити в SELECT або в порівняння, наприклад замовлення дорожчі за середнє. Якщо він раптом поверне два рядки, база впаде з помилкою, і я бачив, як так ламалися тестові хелпери через дублікати в даних. Derived table — підзапит у FROM з аліасом, з яким далі працюєш як з таблицею.
🗣 How to say it out loud A subquery is a query inside another one. A scalar one returns a single value and can go into SELECT or a comparison, such as orders above the average. If it suddenly returns two rows, the database throws an error, and I have seen test helpers break that way because of duplicate data. A derived table is a subquery in FROM with an alias that you then treat like a table.
24 Що таке корельований підзапит і коли він стає повільним? What is a correlated subquery, and when does it become slow?
Підзапити і вікнаSubqueries and windows Middle ▶

Що це. Корельований підзапит посилається на колонки зовнішнього запиту, тому логічно виконується для кожного рядка зовнішнього запиту окремо. (SELECT sum(amount) FROM payments p WHERE p.order_id = o.id) у SELECT по orders o рахує оплату саме для поточного замовлення. Некорельований підзапит, навпаки, не залежить від зовнішнього і виконується один раз.

What it is. A correlated subquery references columns of the outer query, so logically it runs separately for every outer row. (SELECT sum(amount) FROM payments p WHERE p.order_id = o.id) in the SELECT over orders o computes the payment for exactly the current order. A non-correlated subquery, by contrast, does not depend on the outer query and runs once.

Навіщо. Він природно виражає «для кожного рядка знайди пов'язане значення»: останній платіж замовлення, кількість позицій, чи є хоч одна успішна оплата (EXISTS). І, на відміну від JOIN двох дочірніх таблиць, не розмножує рядки — кожен підзапит повертає одне значення на замовлення.

Why use it. It naturally expresses «for each row find the related value»: an order's latest payment, its item count, whether there is at least one successful payment (EXISTS). And unlike joining two child tables, it does not multiply rows — each subquery returns one value per order.

Швидкодія. «Для кожного рядка» на мільйоні рядків означає мільйон виконань, якщо планувальник не перепише запит. PostgreSQL вміє перетворювати EXISTS/IN на semi-join, але скалярний корельований підзапит у SELECT зазвичай виконується як SubPlan на кожен рядок. З індексом по payments.order_id це прийнятно, без нього — повний перегляд таблиці на кожен рядок. У EXPLAIN це видно як SubPlan з великим loops. Альтернатива для великих обсягів — агрегат у CTE і JOIN.

Performance. «For each row» over a million rows means a million executions unless the planner rewrites the query. PostgreSQL can turn EXISTS/IN into a semi-join, but a scalar correlated subquery in SELECT usually runs as a SubPlan per row. With an index on payments.order_id that is acceptable; without one it is a full table scan per row. In EXPLAIN it shows up as a SubPlan with a large loops. The alternative for big volumes is an aggregate in a CTE plus a JOIN.

Оплата і кількість позицій для кожного замовлення
Payment and item count for each order
SELECT o.id, o.total,
       (SELECT count(*) FROM order_items i WHERE i.order_id = o.id)              AS items,
       (SELECT sum(amount) FROM payments p
         WHERE p.order_id = o.id AND p.status = 'success')                       AS paid
FROM orders o
WHERE o.status <> 'cancelled'
ORDER BY o.id;
Результат у psql
Result in psql
 id  | total  | items |  paid
-----+--------+-------+--------
 101 |  90.00 |     2 |  90.00
 102 | 200.00 |     1 | 200.00
 103 |  40.00 |     1 |   NULL
 104 | 260.00 |     2 | 250.00
 106 |  20.00 |     0 |  40.00
(5 rows)
🔧 Розбір запиту: Обидва підзапити посилаються на o.id — це і є кореляція. Вивід уже показує проблеми: у 104 оплачено 250.00 при сумі 260.00, у 106 немає позицій і оплачено вдвічі більше, ніж треба.
🔧 Query breakdown: Both subqueries reference o.id — that is the correlation. The output already shows problems: 104 has 250.00 paid against a 260.00 total, and 106 has no items and was paid twice as much as needed.
💡 Простими словами: Корельований підзапит — це маленький запит «для цього рядка», повторений для кожного рядка.
💡 In plain words: A correlated subquery is a small «for this row» query repeated for every row.
🗣 Як читати вголос Корельований підзапит посилається на колонку зовнішнього запиту, тому логічно виконується для кожного його рядка — наприклад, сума оплат саме цього замовлення. Мені він подобається тим, що не розмножує рядки, як JOIN з кількома дочірніми таблицями. Але на великих таблицях це може бути мільйон виконань, тому я дивлюся в EXPLAIN на SubPlan і loops і перевіряю, чи є індекс на ключі зв'язку.
🗣 How to say it out loud A correlated subquery references a column of the outer query, so logically it runs for every outer row — for instance, the payment total of this particular order. I like that it does not multiply rows the way a JOIN with several child tables does. On big tables, though, it can mean a million executions, so I look at SubPlan and loops in EXPLAIN and check there is an index on the linking key.
25 Чому NOT IN з підзапитом повертає порожній результат, якщо там є NULL, і чим EXISTS кращий? Why does NOT IN with a subquery return nothing when it contains NULL, and why is EXISTS better?
Підзапити і вікнаSubqueries and windows Senior ▶

Механіка. x NOT IN (1, 2, NULL) розгортається в x <> 1 AND x <> 2 AND x <> NULL. Останнє порівняння — UNKNOWN, а TRUE AND UNKNOWN дає UNKNOWN. Умова ніколи не стає TRUE, і запит повертає порожній результат без жодної помилки. Для IN проблеми немає: x IN (1, NULL) для x = 1 дає TRUE.

The mechanics. x NOT IN (1, 2, NULL) expands to x <> 1 AND x <> 2 AND x <> NULL. The last comparison is UNKNOWN, and TRUE AND UNKNOWN is UNKNOWN. The condition never becomes TRUE, and the query returns an empty result without any error. IN has no such problem: x IN (1, NULL) is TRUE for x = 1.

Чому це небезпечно. Запит «користувачі, які нікого не запросили» через id NOT IN (SELECT referred_by FROM users) повертає нуль рядків, бо в referred_by є NULL. Тест перевірки даних, який очікує «порожній результат = все добре», тут пройде, хоча логіка зламана. Сьогодні NULL у колонці немає — запит працює; завтра з'явиться один — і перевірка мовчки перестає щось знаходити.

Why it is dangerous. The query «users who have not referred anyone» written as id NOT IN (SELECT referred_by FROM users) returns zero rows because referred_by contains NULL. A data check that expects «empty result = all good» passes here even though the logic is broken. Today the column has no NULLs and the query works; tomorrow one appears and the check silently stops finding anything.

EXISTS. NOT EXISTS (SELECT 1 FROM … WHERE r.referred_by = u.id) перевіряє лише наявність рядка, і NULL у referred_by просто не дає збігу. До того ж EXISTS зупиняється на першому знайденому рядку. PostgreSQL виконує NOT EXISTS як ефективний anti-join, а NOT IN з підзапитом — ні, бо зобов'язаний врахувати NULL-семантику. Тому правило: для заперечення з підзапитом — NOT EXISTS. Якщо NOT IN таки потрібен, фільтруй WHERE col IS NOT NULL у підзапиті.

EXISTS. NOT EXISTS (SELECT 1 FROM … WHERE r.referred_by = u.id) only checks whether a row exists, and a NULL in referred_by simply does not match. EXISTS also stops at the first row it finds. PostgreSQL runs NOT EXISTS as an efficient anti-join, but not NOT IN with a subquery, because it has to honour NULL semantics. Hence the rule: for a negation with a subquery, use NOT EXISTS. If you really need NOT IN, filter WHERE col IS NOT NULL in the subquery.

NOT IN мовчить, NOT EXISTS знаходить
NOT IN stays silent, NOT EXISTS finds
SELECT id, name FROM users
WHERE id NOT IN (SELECT referred_by FROM users);

SELECT u.id, u.name FROM users u
WHERE NOT EXISTS (SELECT 1 FROM users r WHERE r.referred_by = u.id)
ORDER BY u.id;

SELECT id, name FROM users
WHERE id NOT IN (SELECT referred_by FROM users WHERE referred_by IS NOT NULL)
ORDER BY id;
Результат у psql
Result in psql
 id | name
----+------
(0 rows)

 id |  name
----+---------
  3 | Olena
  4 | Anna K.
  5 | Taras
(3 rows)

 id |  name
----+---------
  3 | Olena
  4 | Anna K.
  5 | Taras
(3 rows)
🔧 Розбір запиту: Перший запит — 0 рядків через NULL у referred_by. NOT EXISTS повертає трьох користувачів, які нікого не запросили. Третій варіант з IS NOT NULL у підзапиті дає той самий правильний результат.
🔧 Query breakdown: The first query returns 0 rows because of NULL in referred_by. NOT EXISTS returns the three users who referred nobody. The third version, with IS NOT NULL in the subquery, gives the same correct result.
💡 Простими словами: Один NULL у списку NOT IN вимикає весь фільтр.
💡 In plain words: One NULL in a NOT IN list switches the whole filter off.
🗣 Як читати вголос NOT IN розгортається в ланцюжок «не дорівнює» через AND, і якщо в підзапиті хоч один NULL, одне з порівнянь дає unknown, тож умова ніколи не стає true і результат порожній. Найгірше, що помилки немає: перевірка просто перестає щось знаходити. NOT EXISTS перевіряє лише наявність рядка, NULL йому не заважає, і Postgres виконує його як anti-join. Тому для заперечень я пишу NOT EXISTS.
🗣 How to say it out loud NOT IN expands into a chain of not-equals joined by AND, and if the subquery has even one NULL, one comparison is unknown, so the condition is never true and the result is empty. The worst part is that there is no error: the check just stops finding anything. NOT EXISTS only checks whether a row exists, NULL does not bother it, and Postgres runs it as an anti-join. So for negations I write NOT EXISTS.
26 Що таке CTE (WITH) і чому з ним складний запит легше читати й перевіряти? What is a CTE (WITH), and why does it make a complex query easier to read and verify?
Підзапити і вікнаSubqueries and windows Junior ▶

Що це. CTE (Common Table Expression) — іменований підзапит, оголошений перед основним запитом: WITH paid AS (SELECT …) SELECT … FROM paid. Кілька CTE пишуться через кому, і кожен наступний може посилатися на попередні. Це той самий підзапит, але з ім'ям, тож запит читається зверху вниз як послідовність кроків, а не зсередини назовні.

What it is. A CTE (Common Table Expression) is a named subquery declared before the main query: WITH paid AS (SELECT …) SELECT … FROM paid. Several CTEs are separated by commas, and each can refer to the ones before it. It is the same subquery, but named, so the query reads top to bottom as a sequence of steps rather than inside out.

Навіщо QA. Складні перевірки даних зручно будувати кроками: expected — що має бути, actual — що є, фінальний SELECT — де вони розходяться. Кожен крок можна виконати окремо (замінивши фінальний SELECT на SELECT * FROM expected) і перевірити проміжний результат — так налагоджують запит, а не вгадують.

Why QA cares. Complex data checks are easy to build in steps: expected — what should be there, actual — what is there, and a final SELECT — where they differ. Each step can be run on its own (replace the final SELECT with SELECT * FROM expected) and its intermediate result checked — that is how you debug a query rather than guess.

Нюанси. У PostgreSQL до версії 12 кожен CTE завжди матеріалізувався і був «бар'єром оптимізації». З 12-ї версії CTE, на який посилаються один раз, вбудовується в запит, а поведінку можна задати явно: AS MATERIALIZED / AS NOT MATERIALIZED. CTE існує лише в межах одного запиту — це не тимчасова таблиця. У MySQL CTE з'явилися тільки у 8.0.

Nuances. Before PostgreSQL 12 every CTE was always materialized and acted as an «optimization fence». Since 12, a CTE referenced once is inlined into the query, and you can set the behaviour explicitly with AS MATERIALIZED / AS NOT MATERIALIZED. A CTE lives only within one query — it is not a temporary table. MySQL got CTEs only in 8.0.

Перевірка «сума замовлення = сума позицій» кроками
A step-by-step check that «order total = items total»
WITH expected AS (
  SELECT order_id, sum(qty * price) AS items_total
  FROM order_items
  GROUP BY order_id
),
actual AS (
  SELECT id AS order_id, total FROM orders
)
SELECT a.order_id, a.total, e.items_total
FROM actual a
LEFT JOIN expected e USING (order_id)
WHERE e.items_total IS DISTINCT FROM a.total
ORDER BY a.order_id;
Результат у psql
Result in psql
 order_id | total  | items_total
----------+--------+-------------
      104 | 260.00 |      250.00
      106 |  20.00 |        NULL
(2 rows)
🔧 Розбір запиту: expected рахує суму позицій, actual бере збережений total, фінальний SELECT показує розбіжності. IS DISTINCT FROM ловить і різні числа (104), і відсутність позицій (106, де items_total NULL).
🔧 Query breakdown: expected sums the items, actual takes the stored total, and the final SELECT shows the mismatches. IS DISTINCT FROM catches both different numbers (104) and missing items (106, where items_total is NULL).
💡 Простими словами: CTE — це чернетка з підписаними кроками: кожен крок можна перевірити окремо.
💡 In plain words: A CTE is a draft with labelled steps: you can check each step on its own.
🗣 Як читати вголос CTE — це іменований підзапит, який оголошують через WITH перед основним запитом. Мені він потрібен насамперед для читабельності: складну перевірку я розбиваю на кроки на кшталт «очікуване», «фактичне» і «розбіжності», і кожен крок можу запустити окремо. У Postgres з дванадцятої версії CTE зазвичай вбудовується в запит, тож на швидкодію він більше не впливає так, як раніше.
🗣 How to say it out loud A CTE is a named subquery declared with WITH before the main query. I mostly need it for readability: I split a complex check into steps like «expected», «actual» and «mismatches», and I can run each step on its own to see the intermediate result. Since Postgres 12 a CTE is usually inlined into the query, so it no longer affects performance the way it used to.
27 Що таке рекурсивний CTE і як ним пройти ланцюжок запрошень? What is a recursive CTE, and how do you walk a referral chain with it?
Підзапити і вікнаSubqueries and windows Senior ▶

Що це. WITH RECURSIVE складається з двох частин, об'єднаних UNION ALL: якірна частина дає стартові рядки, рекурсивна посилається на сам CTE і додає наступний рівень. База повторює рекурсивну частину, доки вона повертає нові рядки. Так обходять ієрархії довільної глибини: дерево категорій, ланцюжок менеджерів, граф запрошень, залежності задач.

What it is. WITH RECURSIVE has two parts joined by UNION ALL: the anchor part produces the starting rows, and the recursive part refers to the CTE itself and adds the next level. The database repeats the recursive part while it keeps returning new rows. That is how you traverse hierarchies of any depth: a category tree, a management chain, a referral graph, task dependencies.

Приклад. Від Anna (id 1) вниз: вона запросила Bohdan і Olena, Bohdan — Taras. Якір — Anna з рівнем 0, рекурсивний крок — «користувачі, чий referred_by є серед уже знайдених», з рівнем +1. Корисно накопичувати шлях (path || name), щоб бачити, як ми дійшли до рядка.

Example. Downward from Anna (id 1): she referred Bohdan and Olena, and Bohdan referred Taras. The anchor is Anna at level 0; the recursive step is «users whose referred_by is among those already found», at level +1. It helps to accumulate the path (path || name) to see how we reached each row.

Ризики і тестування. Якщо дані містять цикл (A запросив B, B — A), рекурсія стає нескінченною. Захист — лічильник глибини з WHERE depth < 20 або масив відвіданих id і умова NOT id = ANY(path); у PostgreSQL 14+ є ще конструкція CYCLE. Для QA рекурсивний CTE — і інструмент (побудувати дерево для перевірки), і об'єкт тестування: цикли, самопосилання, дуже глибокі гілки й «осиротілі» вузли, чий батько видалений.

Risks and testing. If the data contains a cycle (A referred B, B referred A), the recursion never ends. Protect against it with a depth counter and WHERE depth < 20, or an array of visited ids with NOT id = ANY(path); PostgreSQL 14+ also has the CYCLE clause. For QA a recursive CTE is both a tool (building a tree to verify) and a test target: cycles, self-references, very deep branches and «orphaned» nodes whose parent was deleted.

Дерево запрошень від Anna з рівнем і шляхом
The referral tree from Anna with level and path
WITH RECURSIVE tree AS (
  SELECT id, name, 0 AS depth, name AS path
  FROM users
  WHERE id = 1
  UNION ALL
  SELECT u.id, u.name, t.depth + 1, t.path || ' > ' || u.name
  FROM users u
  JOIN tree t ON u.referred_by = t.id
  WHERE t.depth < 10
)
SELECT id, depth, path FROM tree ORDER BY path;
Результат у psql
Result in psql
 id | depth |         path
----+-------+-----------------------
  1 |     0 | Anna
  2 |     1 | Anna > Bohdan
  5 |     2 | Anna > Bohdan > Taras
  3 |     1 | Anna > Olena
(4 rows)
🔧 Розбір запиту: Якір вибирає Anna, рекурсивна частина на кожному кроці приєднує тих, кого запросили знайдені на попередньому. depth < 10 — запобіжник від циклів. Taras на рівні 2, бо його запросив Bohdan.
🔧 Query breakdown: The anchor picks Anna; at each step the recursive part joins in whoever was referred by the users found on the previous step. depth < 10 is a guard against cycles. Taras is at level 2 because Bohdan referred him.
💡 Простими словами: Рекурсивний CTE — це «знайди дітей, потім дітей дітей» доти, доки є кого знаходити.
💡 In plain words: A recursive CTE is «find the children, then the children's children» for as long as there is anyone left to find.
🗣 Як читати вголос Рекурсивний CTE має якірну частину, яка дає стартові рядки, і рекурсивну, яка через UNION ALL приєднує наступний рівень до вже знайденого. База повторює її, доки з'являються нові рядки. Так я обходжу дерево категорій чи ланцюжок запрошень будь-якої глибини. Обов'язково ставлю захист від циклів — обмеження глибини або перевірку відвіданих вузлів, бо один кривий запис у даних зробить рекурсію нескінченною.
🗣 How to say it out loud A recursive CTE has an anchor part that produces the starting rows and a recursive part that, through UNION ALL, joins the next level onto what has been found. The database repeats it while new rows keep appearing. That is how I walk a category tree or a referral chain of any depth. I always add a cycle guard — a depth limit or a visited-nodes check — because one bad record in the data would make the recursion endless.
28 Чим ROW_NUMBER, RANK і DENSE_RANK відрізняються і як знайти останнє замовлення кожного покупця? How do ROW_NUMBER, RANK and DENSE_RANK differ, and how do you find each buyer's latest order?
Підзапити і вікнаSubqueries and windows Middle ▶

Віконні функції. На відміну від GROUP BY, вони не згортають рядки: кожен рядок лишається, а поруч з'являється значення, пораховане по «вікну» пов'язаних рядків. OVER (PARTITION BY user_id ORDER BY created_at DESC) означає «окремо для кожного покупця, упорядковано від нових до старих».

Window functions. Unlike GROUP BY, they do not fold rows: every row stays, and next to it appears a value computed over a «window» of related rows. OVER (PARTITION BY user_id ORDER BY created_at DESC) means «separately for each buyer, ordered newest to oldest».

Три нумерації. ROW_NUMBER() дає унікальні 1, 2, 3 навіть для рівних значень (між рівними порядок довільний, якщо не додати ключ). RANK() дає рівним однаковий ранг і пропускає наступні: 1, 1, 3. DENSE_RANK() — без пропусків: 1, 1, 2. Вираз ROW_NUMBER() OVER (PARTITION BY dept ORDER BY salary DESC) нумерує співробітників усередині відділу за зарплатою — це питання з квізу.

Three numberings. ROW_NUMBER() gives unique 1, 2, 3 even for equal values (the order among equals is arbitrary unless you add a key). RANK() gives equals the same rank and skips the next ones: 1, 1, 3. DENSE_RANK() has no gaps: 1, 1, 2. The expression ROW_NUMBER() OVER (PARTITION BY dept ORDER BY salary DESC) numbers employees inside a department by salary — that is the quiz question.

Top-N на групу. «Останнє замовлення кожного покупця» — це ROW_NUMBER() … = 1. Фільтрувати віконну функцію у WHERE того ж запиту не можна (вона рахується на етапі SELECT), тому її загортають у CTE або підзапит. Для QA це основа перевірок на кшталт «поточний статус — це останній запис історії» і пошуку дублікатів, де треба залишити один рядок і видалити решту.

Top-N per group. «Each buyer's latest order» is ROW_NUMBER() … = 1. You cannot filter a window function in the WHERE of the same query (it is computed at the SELECT step), so it gets wrapped in a CTE or subquery. For QA this underpins checks like «the current status equals the last history record» and duplicate hunting, where one row is kept and the rest are deleted.

Три ранги на рівних значеннях і останнє замовлення
Three ranks on tied values and the latest order
SELECT user_id, count(*) AS orders,
       ROW_NUMBER() OVER (ORDER BY count(*) DESC, user_id) AS rn,
       RANK()       OVER (ORDER BY count(*) DESC)          AS rnk,
       DENSE_RANK() OVER (ORDER BY count(*) DESC)          AS dense
FROM orders GROUP BY user_id
ORDER BY rn;

WITH ranked AS (
  SELECT o.*, ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY created_at DESC, id DESC) AS rn
  FROM orders o
)
SELECT user_id, id AS last_order, status, created_at::date
FROM ranked WHERE rn = 1 ORDER BY user_id;
Результат у psql
Result in psql
 user_id | orders | rn | rnk | dense
---------+--------+----+-----+-------
       1 |      2 |  1 |   1 |     1
       2 |      2 |  2 |   1 |     1
       3 |      1 |  3 |   3 |     2
       5 |      1 |  4 |   3 |     2
(4 rows)

 user_id | last_order |  status   | created_at
---------+------------+-----------+------------
       1 |        102 | shipped   | 2026-03-05
       2 |        104 | paid      | 2026-03-31
       3 |        105 | cancelled | 2026-04-02
       5 |        106 | paid      | 2026-04-03
(4 rows)
🔧 Розбір запиту: Anna і Bohdan мають по 2 замовлення: RANK дає обом 1, а наступному — 3; DENSE_RANK — 2. У другому запиті PARTITION BY починає нумерацію заново для кожного покупця, а фільтр rn = 1 стоїть поза CTE.
🔧 Query breakdown: Anna and Bohdan have 2 orders each: RANK gives both 1 and the next one 3; DENSE_RANK gives 2. In the second query PARTITION BY restarts the numbering for each buyer, and the rn = 1 filter sits outside the CTE.
💡 Простими словами: GROUP BY згортає рядки, вікно — ні: воно лише дописує поруч корисне число.
💡 In plain words: GROUP BY folds rows; a window does not — it just writes a useful number next to each row.
🗣 Як читати вголос Віконні функції рахують значення по групі рядків, але, на відміну від GROUP BY, не згортають їх. ROW_NUMBER дає унікальні номери, RANK — однакові для рівних з пропуском наступного, DENSE_RANK — без пропусків. Останнє замовлення кожного покупця я шукаю через ROW_NUMBER з PARTITION BY user_id і сортуванням за датою, а фільтр рівний одиниці ставлю в зовнішньому запиті, бо у WHERE віконну функцію використати не можна.
🗣 How to say it out loud Window functions compute a value over a group of rows but, unlike GROUP BY, they do not fold them. ROW_NUMBER gives unique numbers, RANK gives ties the same number and skips the next, DENSE_RANK does not skip. I find each buyer's latest order with ROW_NUMBER partitioned by user_id and sorted by date, and I put the equals-one filter in an outer query, because a window function cannot be used in WHERE.
29 Як LAG і наростаючий підсумок допомагають знаходити аномалії в даних? How do LAG and running totals help find anomalies in data?
Підзапити і вікнаSubqueries and windows Middle ▶

LAG і LEAD. LAG(col) повертає значення з попереднього рядка вікна, LEAD(col) — з наступного. Так порівнюють сусідні записи без self join: інтервал між платежами, зміну статусу, стрибок ціни. Для першого рядка LAG дає NULL (або значення за замовчуванням третім аргументом).

LAG and LEAD. LAG(col) returns the value from the previous row of the window, LEAD(col) from the next one. That lets you compare neighbouring records without a self join: the gap between payments, a status change, a price jump. For the first row LAG yields NULL (or a default given as the third argument).

Наростаючий підсумок. sum(total) OVER (ORDER BY created_at) додає кожен рядок до суми попередніх. Важливий нюанс: за замовчуванням рамка вікна з ORDER BY — RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW, і рядки з однаковим ключем сортування потрапляють у підсумок разом. Якщо потрібен суворо «рядок за рядком», пишуть ROWS BETWEEN … і додають унікальний ключ.

Running total. sum(total) OVER (ORDER BY created_at) adds each row to the sum of the previous ones. An important nuance: with ORDER BY the default frame is RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW, so rows with the same sort key enter the total together. If you need strictly «row by row», write ROWS BETWEEN … and add a unique key.

QA-перевірки. Подвійне списання: два успішні платежі одного замовлення з інтервалом у секунди — paid_at - LAG(paid_at) менше п'яти хвилин. Діри в нумерації документів: id - LAG(id) > 1. Баланс рахунку, який ніде не має стати від'ємним: наростаюча сума руху коштів і пошук першого рядка, де вона менша за нуль. Такі запити знаходять баги, яких не видно в UI.

QA checks. A double charge: two successful payments for one order seconds apart — paid_at - LAG(paid_at) under five minutes. Gaps in document numbering: id - LAG(id) > 1. An account balance that must never go negative: a running sum of movements and a search for the first row where it drops below zero. Such queries find bugs you cannot see in the UI.

Подвійне списання через LAG і наростаюча виручка
A double charge via LAG and running revenue
SELECT *
FROM (
  SELECT order_id, id, paid_at,
         paid_at - LAG(paid_at) OVER (PARTITION BY order_id ORDER BY paid_at) AS since_prev
  FROM payments
  WHERE status = 'success'
) t
WHERE since_prev < interval '5 minutes';

SELECT id, created_at::date AS day, total,
       sum(total) OVER (ORDER BY created_at ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS running
FROM orders
WHERE status <> 'cancelled'
ORDER BY created_at;
Результат у psql
Result in psql
 order_id | id |        paid_at         | since_prev
----------+----+------------------------+------------
      106 |  6 | 2026-04-03 12:01:00+00 | 00:01:00
(1 row)

 id  |    day     | total  | running
-----+------------+--------+---------
 101 | 2026-03-01 |  90.00 |   90.00
 102 | 2026-03-05 | 200.00 |  290.00
 103 | 2026-03-10 |  40.00 |  330.00
 104 | 2026-03-31 | 260.00 |  590.00
 106 | 2026-04-03 |  20.00 |  610.00
(5 rows)
🔧 Розбір запиту: LAG дивиться на попередній успішний платіж того самого замовлення: для 106 друга оплата прийшла через хвилину — це кандидат на подвійне списання. Наростаюча сума в останньому рядку збігається з виручкою без скасованих замовлень.
🔧 Query breakdown: LAG looks at the previous successful payment of the same order: for 106 the second payment came one minute later — a double-charge candidate. The running total in the last row matches revenue without cancelled orders.
💡 Простими словами: LAG — «що було рядком раніше», наростаючий підсумок — «скільки набралося до цього моменту».
💡 In plain words: LAG is «what the previous row said»; a running total is «how much has piled up so far».
🗣 Як читати вголос LAG повертає значення з попереднього рядка вікна, LEAD — з наступного, і це дає змогу порівнювати сусідні записи без self join. Я так шукаю подвійні списання: два успішні платежі одного замовлення з інтервалом у секунди. Наростаючий підсумок — це SUM з OVER і ORDER BY, і тут я пам'ятаю про рамку за замовчуванням: рядки з однаковою датою складаються разом, тож для точності пишу ROWS.
🗣 How to say it out loud LAG returns the value from the previous row of the window and LEAD from the next, which lets me compare neighbouring records without a self join. That is how I hunt double charges: two successful payments for one order seconds apart. A running total is SUM with OVER and ORDER BY, and I keep the default frame in mind: rows with the same date are added together, so for precision I write ROWS.
30 Що таке VIEW і materialized view і як тестувати звіт, побудований на них? What are a VIEW and a materialized view, and how do you test a report built on them?
Підзапити і вікнаSubqueries and windows Middle ▶

VIEW. Збережений іменований запит — віртуальна таблиця без власних даних. Кожне звернення до view заново виконує його запит, тож дані завжди актуальні. View спрощує складні запити, приховує колонки (наприклад, без email для аналітиків) і дає стабільний інтерфейс, коли таблиці під ним змінюються.

VIEW. A stored named query — a virtual table with no data of its own. Each access re-runs the view's query, so the data is always current. A view simplifies complex queries, hides columns (for example, no email for analysts) and gives a stable interface when the tables underneath change.

Materialized view. Результат запиту фізично зберігається, тому читання швидке, але дані застигають на момент останнього REFRESH MATERIALIZED VIEW. Оновлення запускають за розкладом або після завантаження даних; варіант CONCURRENTLY не блокує читачів, але потребує унікального індексу. У MySQL materialized view немає — їх емулюють таблицею і подією.

Materialized view. The query result is physically stored, so reads are fast, but the data freezes at the last REFRESH MATERIALIZED VIEW. Refreshes run on a schedule or after a data load; the CONCURRENTLY variant does not block readers but needs a unique index. MySQL has no materialized views — they are emulated with a table and an event.

Як тестувати. Для звичайного view — порівняти його вивід з незалежно написаним запитом на базових таблицях. Для materialized view головний ризик — застарілість: тест створює замовлення, звіт його не бачить, і це не баг, якщо так задумано, але баг, якщо REFRESH мав спрацювати. Тому треба знати, хто і коли запускає оновлення, і перевіряти звіт після нього. Ще варто перевірити, що view не «ламається» після міграції схеми, яка перейменувала колонку.

How to test. For a plain view, compare its output with an independently written query on the base tables. For a materialized view the main risk is staleness: a test creates an order, the report does not show it, and that is not a bug if intended but a bug if the REFRESH should have fired. So you need to know who triggers the refresh and when, and check the report after it. It is also worth checking that the view does not «break» after a schema migration renames a column.

View бачить нове замовлення одразу, materialized view — після REFRESH
A view sees a new order at once, a materialized view only after REFRESH
CREATE VIEW v_revenue AS
  SELECT sum(total) AS revenue FROM orders WHERE status IN ('paid', 'shipped');
CREATE MATERIALIZED VIEW mv_revenue AS
  SELECT sum(total) AS revenue FROM orders WHERE status IN ('paid', 'shipped');

INSERT INTO orders (id, user_id, status, total, created_at)
VALUES (107, 4, 'paid', 80.00, now());

SELECT (SELECT revenue FROM v_revenue) AS view_now, (SELECT revenue FROM mv_revenue) AS mv_now;
REFRESH MATERIALIZED VIEW mv_revenue;
SELECT revenue AS mv_after_refresh FROM mv_revenue;
Результат у psql
Result in psql
CREATE VIEW

CREATE MATERIALIZED VIEW

INSERT 0 1

 view_now | mv_now
----------+--------
   650.00 | 570.00
(1 row)

REFRESH MATERIALIZED VIEW

 mv_after_refresh
------------------
           650.00
(1 row)
🔧 Розбір запиту: Після INSERT звичайний view уже показує 650.00, а materialized view — старі 570.00, доки не виконано REFRESH. Саме таку різницю тест має очікувати або ловити, залежно від вимог.
🔧 Query breakdown: After the INSERT the plain view already shows 650.00, while the materialized view still shows the old 570.00 until REFRESH runs. That is the gap a test must either expect or catch, depending on the requirements.
💡 Простими словами: VIEW — збережений запит, materialized view — збережений результат.
💡 In plain words: A VIEW is a stored query; a materialized view is a stored result.
🗣 Як читати вголос VIEW — це збережений запит, віртуальна таблиця без своїх даних, тому вона завжди показує актуальний стан. Materialized view зберігає результат фізично: читання швидке, але дані застарівають до наступного REFRESH. Тестуючи звіт на такому view, я спершу з'ясовую, коли він оновлюється, і перевіряю вже після оновлення. Звичайний view звіряю з незалежним запитом на базових таблицях.
🗣 How to say it out loud A VIEW is a stored query, a virtual table with no data of its own, so it always shows the current state. A materialized view physically stores the result: reads are fast, but the data goes stale until the next REFRESH. When testing a report on such a view, I first find out when it refreshes and check after that. A plain view I reconcile with an independent query on the base tables.
▶ 5 9
31 Як правильно писати INSERT, як скопіювати дані одним запитом і що дає RETURNING? How do you write an INSERT correctly, copy data in one query, and what does RETURNING give you?
Зміна данихChanging data Junior ▶

Правильна форма. INSERT INTO t (col1, col2) VALUES (v1, v2) — завжди зі списком колонок. Форма без нього (INSERT INTO t VALUES (…)) залежить від порядку колонок у таблиці і ламається, щойно хтось додасть колонку. В одному INSERT можна вставити кілька рядків через кому — це швидше, ніж окремі запити, і зручно для тестових даних.

The right form. INSERT INTO t (col1, col2) VALUES (v1, v2) — always with a column list. The form without one (INSERT INTO t VALUES (…)) depends on the table's column order and breaks as soon as someone adds a column. One INSERT can add several rows separated by commas — faster than separate statements and handy for test data.

INSERT … SELECT. Копіює дані одним запитом: INSERT INTO t2 (cols) SELECT cols FROM t1 WHERE …. Так роблять резервну копію перед ризикованою зміною, переносять дані між таблицями, розмножують тестові дані. Для «копії таблиці разом зі структурою» в PostgreSQL є CREATE TABLE t2 AS SELECT …, але вона не переносить обмеження та індекси.

INSERT … SELECT. Copies data in one query: INSERT INTO t2 (cols) SELECT cols FROM t1 WHERE …. It is how you back up rows before a risky change, move data between tables or multiply test data. For «a copy of a table with its structure» PostgreSQL has CREATE TABLE t2 AS SELECT …, but it does not carry over constraints and indexes.

RETURNING. PostgreSQL повертає значення вставлених, змінених чи видалених рядків прямо з INSERT/UPDATE/DELETE: RETURNING id, created_at. У тестах це прибирає зайвий SELECT і гонки на кшталт «взяти max(id) після вставки», які ламаються при паралельному запуску. У MySQL RETURNING немає (є лише в MariaDB), там використовують LAST_INSERT_ID().

RETURNING. PostgreSQL returns the values of inserted, updated or deleted rows straight from INSERT/UPDATE/DELETE: RETURNING id, created_at. In tests this removes an extra SELECT and races like «take max(id) after the insert», which break under parallel runs. MySQL has no RETURNING (only MariaDB does); there you use LAST_INSERT_ID().

Кілька рядків, RETURNING і копія через INSERT … SELECT
Several rows, RETURNING and a copy via INSERT … SELECT
INSERT INTO users (id, email, name, city)
VALUES (100, 'qa+100@mail.com', 'QA One', 'Kyiv'),
       (101, 'qa+101@mail.com', 'QA Two', NULL)
RETURNING id, email;

CREATE TABLE users_backup (LIKE users INCLUDING ALL);
INSERT INTO users_backup SELECT * FROM users WHERE city = 'Kyiv';
SELECT id, email FROM users_backup ORDER BY id;
Результат у psql
Result in psql
 id  |      email
-----+-----------------
 100 | qa+100@mail.com
 101 | qa+101@mail.com
(2 rows)

CREATE TABLE

INSERT 0 3

 id  |      email
-----+-----------------
   1 | anna@mail.com
   4 | Anna@Mail.com
 100 | qa+100@mail.com
(3 rows)
🔧 Розбір запиту: RETURNING одразу віддає вставлені id — не треба шукати їх окремим запитом. LIKE users INCLUDING ALL копіює структуру разом з обмеженнями, а INSERT … SELECT переносить лише рядки з Києва.
🔧 Query breakdown: RETURNING hands back the inserted ids at once — no separate lookup. LIKE users INCLUDING ALL copies the structure together with constraints, and INSERT … SELECT moves only the Kyiv rows.
💡 Простими словами: Список колонок в INSERT — це страховка від чужих міграцій.
💡 In plain words: A column list in an INSERT is insurance against other people's migrations.
🗣 Як читати вголос Я завжди пишу INSERT зі списком колонок, бо без нього запит залежить від порядку колонок у таблиці. Кілька рядків вставляю одним запитом, а дані з іншої таблиці копіюю через INSERT … SELECT, зокрема для бекапу перед ризикованою зміною. У Postgres дуже люблю RETURNING: він одразу повертає id створених рядків, і тест не мусить вгадувати їх через max id, що ламається при паралельному запуску.
🗣 How to say it out loud I always write INSERT with a column list, because without it the query depends on the table's column order. I insert several rows in one statement and copy data from another table with INSERT … SELECT, including backups before a risky change. In Postgres I really like RETURNING: it hands back the ids of the new rows at once, so the test does not have to guess them through max id, which breaks under parallel runs.
32 Що станеться після UPDATE або DELETE без WHERE і як безпечно змінювати дані в спільній тестовій базі? What happens after an UPDATE or DELETE without WHERE, and how do you change data safely in a shared test database?
Зміна данихChanging data Middle ▶

Без WHERE. UPDATE users SET status = 0 змінить усі рядки таблиці, DELETE FROM t видалить усі рядки, залишивши порожню таблицю зі структурою. Жодного попередження база не дасть — у режимі autocommit зміна одразу зафіксована. У спільному тестовому середовищі це зламає тести й дані колег, а відновлення з бекапу забере години.

Without WHERE. UPDATE users SET status = 0 changes every row of the table, and DELETE FROM t deletes every row, leaving an empty table with its structure. The database gives no warning — in autocommit mode the change is committed immediately. In a shared test environment that breaks your colleagues' tests and data, and restoring from a backup takes hours.

Безпечна процедура. (1) Спочатку напиши SELECT з тією самою умовою і подивись, які й скільки рядків він повертає. (2) Відкрий транзакцію BEGIN. (3) Виконай UPDATE/DELETE і звір кількість змінених рядків (psql пише UPDATE 1) з очікуваною. (4) Перевір результат SELECT-ом. (5) Лише тоді COMMIT, інакше ROLLBACK. RETURNING * дає побачити змінені рядки прямо в тій же команді.

A safe procedure. (1) First write a SELECT with the same condition and look at which rows and how many it returns. (2) Open a transaction with BEGIN. (3) Run the UPDATE/DELETE and compare the changed-row count (psql prints UPDATE 1) with the expected one. (4) Check the result with a SELECT. (5) Only then COMMIT, otherwise ROLLBACK. RETURNING * shows the changed rows in the same command.

Додаткові запобіжники. У MySQL-клієнті є режим --safe-updates, який забороняє UPDATE/DELETE без ключа в WHERE. У DBeaver та інших IDE варто вимкнути autocommit для спільних баз. І найпростіше — обмеж умову ключем тестових даних (префікс email, id тест-рану), а не «статусом», який можуть мати й чужі рядки.

Extra safety nets. The MySQL client has a --safe-updates mode that forbids UPDATE/DELETE without a key in WHERE. In DBeaver and other IDEs, turn autocommit off for shared databases. And simplest of all, scope the condition by a test-data key (an email prefix, a test-run id) rather than by a «status» that other people's rows may share.

SELECT першим, потім зміна в транзакції з перевіркою
SELECT first, then the change inside a transaction with a check
SELECT id, status FROM orders WHERE user_id = 2 AND status = 'new';

BEGIN;
UPDATE orders SET status = 'cancelled'
WHERE user_id = 2 AND status = 'new'
RETURNING id, status;
SELECT status, count(*) FROM orders GROUP BY status ORDER BY status;
ROLLBACK;

SELECT id, status FROM orders WHERE id = 103;
Результат у psql
Result in psql
 id  | status
-----+--------
 103 | new
(1 row)

BEGIN

 id  |  status
-----+-----------
 103 | cancelled
(1 row)

  status   | count
-----------+-------
 cancelled |     2
 paid      |     3
 shipped   |     1
(3 rows)

ROLLBACK

 id  | status
-----+--------
 103 | new
(1 row)
🔧 Розбір запиту: SELECT показав один рядок — UPDATE теж змінив рівно один (видно з RETURNING). Після ROLLBACK замовлення 103 знову має статус new: зміну перевірили, але не зафіксували.
🔧 Query breakdown: The SELECT showed one row, and the UPDATE changed exactly one too (visible through RETURNING). After ROLLBACK order 103 is new again: the change was checked but not committed.
💡 Простими словами: Спершу SELECT з тією ж умовою, потім зміна в BEGIN, і лише потім COMMIT.
💡 In plain words: First a SELECT with the same condition, then the change inside BEGIN, and only then COMMIT.
🗣 Як читати вголос UPDATE чи DELETE без WHERE зачепить усі рядки таблиці, і в autocommit це станеться миттєво й без попереджень. Тому в спільній базі я роблю так: спочатку SELECT з тією ж умовою, щоб побачити, що і скільки зміниться, потім BEGIN, сама зміна, перевірка кількості рядків і результату, і тільки після цього COMMIT, а якщо щось не так — ROLLBACK. Умову обмежую ключем своїх тестових даних.
🗣 How to say it out loud An UPDATE or DELETE without WHERE touches every row of the table, and in autocommit it happens instantly and without warning. So in a shared database I do this: first a SELECT with the same condition to see what and how much will change, then BEGIN, the change itself, a check of the row count and the result, and only then COMMIT — or ROLLBACK if anything looks off. I scope the condition by the key of my own test data.
33 Чим DELETE відрізняється від TRUNCATE і DROP і що обрати для очищення тестових даних? How do DELETE, TRUNCATE and DROP differ, and which one fits cleaning up test data?
Зміна данихChanging data Middle ▶

DELETE. Видаляє рядки за умовою (або всі без WHERE), по рядку: спрацьовують тригери, перевіряються зовнішні ключі, кожен рядок пишеться в журнал. Повільно на великих таблицях, але точково — можна видалити лише свої тестові дані. Лічильники (sequence/identity) не скидаються.

DELETE. Removes rows by condition (or all of them without WHERE), row by row: triggers fire, foreign keys are checked, each row is logged. Slow on big tables but precise — you can delete only your own test data. Counters (sequence/identity) are not reset.

TRUNCATE. Миттєво спорожнює всю таблицю, без WHERE і без рядкових тригерів. RESTART IDENTITY скидає лічильники, CASCADE очищує і таблиці, що посилаються на неї. Без CASCADE TRUNCATE таблиці, на яку є FK, дає помилку. У PostgreSQL TRUNCATE транзакційний і відкочується через ROLLBACK; у MySQL він неявно фіксує транзакцію і відкотити його не можна.

TRUNCATE. Empties the whole table instantly, with no WHERE and no row-level triggers. RESTART IDENTITY resets the counters, and CASCADE also clears the tables that reference it. Without CASCADE, truncating a table referenced by an FK raises an error. In PostgreSQL TRUNCATE is transactional and rolls back with ROLLBACK; in MySQL it implicitly commits and cannot be rolled back.

DROP. Видаляє саму таблицю — структуру, індекси, обмеження, права. Після DROP таблиці немає, а TRUNCATE залишає порожню таблицю. Для очищення тестових даних між тестами у виділеній базі зручний TRUNCATE усіх таблиць однією командою; у спільній базі — лише DELETE з умовою на свої дані; DROP — для тимчасових таблиць, які тест сам створив.

DROP. Removes the table itself — structure, indexes, constraints, grants. After DROP the table is gone, whereas TRUNCATE leaves an empty table. To clean test data between tests in a dedicated database, TRUNCATE of all tables in one command is handy; in a shared database only DELETE scoped to your own data; DROP is for temporary tables the test created itself.

Три способи прибрати дані і їхні наслідки
Three ways to remove data and what they leave behind
BEGIN;
DELETE FROM payments WHERE order_id = 999;
TRUNCATE orders;
ROLLBACK;

BEGIN;
TRUNCATE orders CASCADE;
SELECT (SELECT count(*) FROM orders) AS orders, (SELECT count(*) FROM order_items) AS items,
       (SELECT count(*) FROM users) AS users;
ROLLBACK;

BEGIN;
DROP TABLE payments;
SELECT count(*) FROM payments;
ROLLBACK;
Результат у psql
Result in psql
BEGIN

DELETE 1

ERROR:  cannot truncate a table referenced in a foreign key constraint
DETAIL:  Table "order_items" references "orders".
HINT:  Truncate table "order_items" at the same time, or use TRUNCATE ... CASCADE.

ROLLBACK

BEGIN

TRUNCATE TABLE

 orders | items | users
--------+-------+-------
      0 |     0 |     5
(1 row)

ROLLBACK

BEGIN

DROP TABLE

ERROR:  relation "payments" does not exist

ROLLBACK
🔧 Розбір запиту: DELETE прибрав один рядок-сироту. TRUNCATE без CASCADE впав, бо order_items посилається на orders. З CASCADE очищено і позиції, а users не зачеплено. Після DROP таблиці вже немає — навіть запит до неї падає. Усе загорнуто в ROLLBACK, тож дані залишилися.
🔧 Query breakdown: DELETE removed one orphan row. TRUNCATE without CASCADE failed because order_items references orders. With CASCADE the items were cleared too, while users was untouched. After DROP the table no longer exists — even a query against it fails. Everything is wrapped in ROLLBACK, so the data survived.
💡 Простими словами: DELETE — виносиш речі по одній, TRUNCATE — вивозиш усе одразу, DROP — зносиш кімнату.
💡 In plain words: DELETE carries things out one by one, TRUNCATE empties the room at once, DROP demolishes the room.
🗣 Як читати вголос DELETE видаляє рядки за умовою по одному, з тригерами й перевіркою ключів, і лічильники не скидає. TRUNCATE миттєво очищує всю таблицю, вміє скинути identity і каскадно очистити залежні таблиці. DROP видаляє таблицю разом зі структурою. У Postgres TRUNCATE відкочується в транзакції, а в MySQL — ні. Для тестів у виділеній базі я чищу TRUNCATE, а в спільній — лише DELETE по своїх даних.
🗣 How to say it out loud DELETE removes rows by condition one at a time, with triggers and key checks, and does not reset counters. TRUNCATE instantly empties the whole table, can reset identity and cascade to dependent tables. DROP removes the table along with its structure. In Postgres TRUNCATE rolls back inside a transaction, in MySQL it does not. For tests in a dedicated database I clean with TRUNCATE, in a shared one only with DELETE on my own data.
34 Що таке UPSERT і чим ON CONFLICT у PostgreSQL відрізняється від ON DUPLICATE KEY у MySQL? What is an UPSERT, and how does ON CONFLICT in PostgreSQL differ from ON DUPLICATE KEY in MySQL?
Зміна данихChanging data Middle ▶

Що це. UPSERT — «вставити, а якщо такий ключ уже є, оновити». Це атомарна операція, і вона розв'язує гонку «SELECT, чи є рядок → INSERT або UPDATE», коли два паралельні запити обидва бачать «немає» і другий падає на унікальному ключі.

What it is. An UPSERT means «insert, and if that key already exists, update». It is atomic and solves the «SELECT whether the row exists → INSERT or UPDATE» race, where two parallel requests both see «not there» and the second fails on the unique key.

PostgreSQL. INSERT … ON CONFLICT (email) DO UPDATE SET name = EXCLUDED.name. EXCLUDED — рядок, який намагалися вставити. Треба вказати унікальний індекс чи обмеження, за яким визначається конфлікт. DO NOTHING просто пропускає дублікат — зручно для ідемпотентних сид-скриптів тестових даних. З версії 15 є ще стандартний MERGE.

PostgreSQL. INSERT … ON CONFLICT (email) DO UPDATE SET name = EXCLUDED.name. EXCLUDED is the row you tried to insert. You must name the unique index or constraint that defines the conflict. DO NOTHING just skips the duplicate — handy for idempotent test-data seed scripts. Since version 15 there is also the standard MERGE.

MySQL. INSERT … ON DUPLICATE KEY UPDATE name = VALUES(name) (у 8.0.20+ замість VALUES() рекомендують аліас рядка). Спрацьовує на будь-якому унікальному ключі, не можна обрати, на якому саме, — якщо в таблиці їх кілька, результат може здивувати. REPLACE INTO — не UPSERT: він видаляє старий рядок і вставляє новий, тобто спрацюють DELETE-каскади й зміниться автоінкремент.

MySQL. INSERT … ON DUPLICATE KEY UPDATE name = VALUES(name) (from 8.0.20 a row alias is recommended instead of VALUES()). It fires on any unique key and you cannot pick which one — with several of them in a table the result can surprise you. REPLACE INTO is not an UPSERT: it deletes the old row and inserts a new one, so DELETE cascades fire and the auto-increment changes.

Що тестувати. Повторний виклик API з тими самими даними не створює дубль; оновлюються лише потрібні поля (created_at не перезаписується); регістр і пробіли в ключі (email) нормалізовані однаково, інакше UPSERT створить «майже дубль».

What to test. Calling the API again with the same data does not create a duplicate; only the intended fields are updated (created_at is not overwritten); case and spaces in the key (email) are normalized the same way, otherwise the UPSERT creates a «near duplicate».

UPSERT за email і DO NOTHING для ідемпотентного сиду
An UPSERT by email and DO NOTHING for an idempotent seed
INSERT INTO users (id, email, name, city)
VALUES (200, 'bohdan@mail.com', 'Bohdan Petrenko', 'Kharkiv')
ON CONFLICT (email) DO UPDATE
  SET name = EXCLUDED.name, city = EXCLUDED.city
RETURNING id, name, city;

INSERT INTO products (id, sku, name, price)
VALUES (1, 'SKU-001', 'Keyboard', 50.00)
ON CONFLICT (id) DO NOTHING;

SELECT count(*) AS products FROM products;
Результат у psql
Result in psql
 id |      name       |  city
----+-----------------+---------
  2 | Bohdan Petrenko | Kharkiv
(1 row)

INSERT 0 0

 products
----------
        4
(1 row)
🔧 Розбір запиту: Email bohdan@mail.com уже є, тому замість нового рядка з id 200 оновлено існуючий id 2 — RETURNING це показує. DO NOTHING повертає INSERT 0 0: сид можна запускати скільки завгодно разів.
🔧 Query breakdown: The email bohdan@mail.com already exists, so instead of a new row with id 200 the existing id 2 is updated — RETURNING shows it. DO NOTHING reports INSERT 0 0: the seed can be run any number of times.
💡 Простими словами: UPSERT — «створи або онови» однією атомарною командою.
💡 In plain words: An UPSERT is «create or update» in one atomic command.
🗣 Як читати вголос UPSERT — це вставка, яка при конфлікті унікального ключа оновлює існуючий рядок. У Postgres це ON CONFLICT із зазначенням ключа і EXCLUDED для нових значень, а DO NOTHING я використовую в сид-скриптах, щоб їх можна було запускати повторно. У MySQL є ON DUPLICATE KEY UPDATE, який реагує на будь-який унікальний ключ. Тестую, що повторний запит не створює дубль і не перезаписує поля на кшталт дати створення.
🗣 How to say it out loud An UPSERT is an insert that updates the existing row on a unique-key conflict. In Postgres that is ON CONFLICT with the key named and EXCLUDED for the new values, and I use DO NOTHING in seed scripts so they can be rerun. MySQL has ON DUPLICATE KEY UPDATE, which reacts to any unique key. I test that a repeated request does not create a duplicate and does not overwrite fields like the creation date.
35 Що таке PRIMARY KEY, як генерується сурогатний ключ і чим UNIQUE відрізняється від PK? What is a PRIMARY KEY, how is a surrogate key generated, and how does UNIQUE differ from a PK?
Зміна данихChanging data Junior ▶

PRIMARY KEY. Унікальний ідентифікатор рядка, який не може бути NULL. У таблиці він один (але може складатися з кількох колонок, як (order_id, product_id) в order_items). База автоматично створює для нього унікальний індекс.

PRIMARY KEY. A unique row identifier that cannot be NULL. A table has one (though it may span several columns, like (order_id, product_id) in order_items). The database creates a unique index for it automatically.

Сурогатний ключ. Штучне число без бізнес-сенсу, яке генерує база: GENERATED ALWAYS AS IDENTITY (стандарт, PostgreSQL 10+), старіший serial, AUTO_INCREMENT у MySQL, або UUID. Природний ключ (email, номер паспорта) змінюється в реальному житті, тому його роблять UNIQUE, а PK — сурогатним.

Surrogate key. An artificial number with no business meaning, generated by the database: GENERATED ALWAYS AS IDENTITY (standard, PostgreSQL 10+), the older serial, AUTO_INCREMENT in MySQL, or a UUID. A natural key (email, passport number) changes in real life, so it is made UNIQUE while the PK stays surrogate.

UNIQUE проти PK. UNIQUE-обмежень може бути кілька, і вони зазвичай допускають NULL — причому в PostgreSQL кілька NULL не вважаються дублікатами (з 15-ї версії це можна змінити через NULLS NOT DISTINCT). PK — один і завжди NOT NULL.

UNIQUE versus PK. There can be several UNIQUE constraints, and they usually allow NULL — and in PostgreSQL several NULLs are not treated as duplicates (since version 15 you can change that with NULLS NOT DISTINCT). The PK is single and always NOT NULL.

Для тестів. Не хардкодь id у перевірках — бери їх з RETURNING. Послідовності не відкочуються з транзакцією, тому після ROLLBACK у нумерації з'являються «дірки», і це не баг. Класична проблема сид-скриптів: вставили рядки з явними id, а sequence лишився на 1 — наступна вставка без id падає з duplicate key. Лікується setval.

For tests. Do not hard-code ids in checks — take them from RETURNING. Sequences do not roll back with a transaction, so after a ROLLBACK the numbering has «holes», and that is not a bug. A classic seed-script problem: rows inserted with explicit ids while the sequence stayed at 1 — the next insert without an id fails with a duplicate key. The cure is setval.

Identity, дірка після ROLLBACK і кілька NULL в UNIQUE
Identity, a hole after ROLLBACK and several NULLs under UNIQUE
CREATE TABLE tickets (
  id    integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  code  text UNIQUE,
  title text NOT NULL
);
INSERT INTO tickets (code, title) VALUES ('T-1', 'first') RETURNING id;
BEGIN;
INSERT INTO tickets (code, title) VALUES ('T-2', 'rolled back') RETURNING id;
ROLLBACK;
INSERT INTO tickets (code, title) VALUES (NULL, 'no code'), (NULL, 'no code either');
INSERT INTO tickets (code, title) VALUES ('T-1', 'duplicate');
SELECT id, code, title FROM tickets ORDER BY id;
Результат у psql
Result in psql
CREATE TABLE

 id
----
  1
(1 row)

BEGIN

 id
----
  2
(1 row)

ROLLBACK

INSERT 0 2

ERROR:  duplicate key value violates unique constraint "tickets_code_key"
DETAIL:  Key (code)=(T-1) already exists.

 id | code |     title
----+------+----------------
  1 | T-1  | first
  3 | NULL | no code
  4 | NULL | no code either
(3 rows)
🔧 Розбір запиту: Після ROLLBACK значення 2 «згоріло», тому наступні рядки отримали 3 і 4. Два NULL у code не порушують UNIQUE, а повторний 'T-1' — порушує, і помилка називає і обмеження, і значення.
🔧 Query breakdown: After the ROLLBACK the value 2 is «burnt», so the next rows got 3 and 4. Two NULLs in code do not violate UNIQUE, but a second 'T-1' does, and the error names both the constraint and the value.
💡 Простими словами: PK — «паспорт» рядка: один, обов'язковий, унікальний.
💡 In plain words: The PK is the row's «passport»: one, mandatory, unique.
🗣 Як читати вголос Первинний ключ — унікальний і не NULL ідентифікатор рядка, і він у таблиці один, хоча може бути складеним. Зазвичай це сурогатне число, яке генерує база через IDENTITY чи AUTO_INCREMENT, а природні ключі на кшталт email я роблю UNIQUE. UNIQUE-обмежень може бути кілька, і NULL вони допускають. У тестах не хардкоджу id і не дивуюся діркам у нумерації: послідовності не відкочуються разом з транзакцією.
🗣 How to say it out loud A primary key is a unique, non-NULL row identifier, one per table, though it can be composite. Usually it is a surrogate number generated by the database through IDENTITY or AUTO_INCREMENT, while natural keys like email get a UNIQUE constraint. There can be several UNIQUE constraints and they allow NULL. In tests I never hard-code ids and I am not surprised by holes in the numbering: sequences do not roll back with a transaction.
36 Що таке FOREIGN KEY, що робить ON DELETE CASCADE і як виглядає порушення зв'язку? What is a FOREIGN KEY, what does ON DELETE CASCADE do, and what does a broken link look like?
Зміна данихChanging data Middle ▶

Що це. Зовнішній ключ — посилання на ключ іншої таблиці, яке база гарантує: не можна вставити orders.user_id, якого немає в users, і не можна видалити користувача, на якого посилаються замовлення. Це і є referential integrity — цілісність зв'язків.

What it is. A foreign key is a reference to another table's key that the database enforces: you cannot insert an orders.user_id that does not exist in users, and you cannot delete a user that orders point to. That is referential integrity.

Дії при видаленні. За замовчуванням (NO ACTION/RESTRICT) видалення батька, на якого є посилання, дає помилку. ON DELETE CASCADE автоматично видаляє дочірні рядки — у нашій схемі видалення замовлення прибирає його позиції. SET NULL обнуляє посилання, SET DEFAULT ставить значення за замовчуванням.

Actions on delete. By default (NO ACTION/RESTRICT), deleting a referenced parent raises an error. ON DELETE CASCADE deletes the child rows automatically — in our schema, deleting an order removes its items. SET NULL clears the reference, SET DEFAULT sets the default value.

Що тестувати. Каскад — потужна і небезпечна річ: видалення одного користувача може тихо прибрати тисячі рядків через ланцюжок каскадів. Тест-кейси: видалення батька з дітьми (помилка чи каскад — що передбачено вимогою?), вставка з неіснуючим посиланням, API-відповідь на порушення FK (має бути 409/422, а не 500 з текстом SQL-помилки). Якщо FK немає (як у нашій payments), цілісність доводиться перевіряти запитами на сироти — база сама нічого не гарантує. У MySQL FK працюють лише в InnoDB; MyISAM їх мовчки ігнорує.

What to test. A cascade is powerful and dangerous: deleting one user can silently remove thousands of rows through a chain of cascades. Test cases: deleting a parent that has children (error or cascade — which does the requirement specify?), inserting with a non-existent reference, the API response to an FK violation (should be 409/422, not a 500 carrying the SQL error text). Without an FK (as in our payments), integrity has to be verified with orphan queries — the database guarantees nothing. In MySQL, FKs work only in InnoDB; MyISAM silently ignores them.

Порушення FK, RESTRICT і каскад
An FK violation, RESTRICT and a cascade
INSERT INTO orders (id, user_id, status, total, created_at)
VALUES (300, 42, 'new', 10.00, now());

DELETE FROM users WHERE id = 3;

BEGIN;
DELETE FROM orders WHERE id = 105;
SELECT count(*) AS items_of_105 FROM order_items WHERE order_id = 105;
ROLLBACK;
Результат у psql
Result in psql
ERROR:  insert or update on table "orders" violates foreign key constraint "orders_user_id_fkey"
DETAIL:  Key (user_id)=(42) is not present in table "users".

ERROR:  update or delete on table "users" violates foreign key constraint "orders_user_id_fkey" on table "orders"
DETAIL:  Key (id)=(3) is still referenced from table "orders".

BEGIN

DELETE 1

 items_of_105
--------------
            0
(1 row)

ROLLBACK
🔧 Розбір запиту: Вставка з user_id = 42 падає: такого користувача немає. Видалити Olena не можна, бо на неї посилається замовлення 105. А видалення замовлення 105 проходить і каскадом прибирає його позицію — лічильник показує 0.
🔧 Query breakdown: The insert with user_id = 42 fails: there is no such user. Olena cannot be deleted because order 105 points to her. Deleting order 105 succeeds and cascades to its item — the counter shows 0.
💡 Простими словами: FK — це обіцянка бази, що посилання ведуть на реальні рядки.
💡 In plain words: An FK is the database's promise that references lead to real rows.
🗣 Як читати вголос Зовнішній ключ — це посилання на ключ іншої таблиці, яке база перевіряє: не можна вставити замовлення з неіснуючим користувачем і не можна видалити користувача, на якого є замовлення. ON DELETE CASCADE замість помилки видаляє дочірні рядки. Я тестую обидва сценарії, дивлюся, що API повертає зрозумілу помилку замість п'ятисотої, а там, де FK немає, пишу запити на пошук сиріт.
🗣 How to say it out loud A foreign key is a reference to another table's key that the database checks: you cannot insert an order for a non-existent user or delete a user who has orders. ON DELETE CASCADE deletes the child rows instead of raising an error. I test both scenarios, check that the API returns a clear error rather than a five hundred, and where there is no FK I write orphan-hunting queries.
37 Що перевіряють CHECK і NOT NULL, як додати колонку через ALTER TABLE і як виглядають порушення? What do CHECK and NOT NULL enforce, how do you add a column with ALTER TABLE, and what do violations look like?
Зміна данихChanging data Junior ▶

CHECK і NOT NULL. CHECK (price > 0) перевіряє умову на кожному рядку при вставці та оновленні; NOT NULL забороняє порожнє значення. Важливий нюанс: CHECK пропускає рядок, якщо умова дає UNKNOWN, тому CHECK (price > 0) без NOT NULL дозволяє NULL. У MySQL CHECK реально перевіряється лише з версії 8.0.16 — раніше його синтаксис приймався і мовчки ігнорувався.

CHECK and NOT NULL. CHECK (price > 0) validates a condition on every row on insert and update; NOT NULL forbids an empty value. An important nuance: CHECK lets a row through when the condition is UNKNOWN, so CHECK (price > 0) without NOT NULL allows NULL. In MySQL, CHECK is actually enforced only since 8.0.16 — before that its syntax was accepted and silently ignored.

ALTER TABLE. ALTER TABLE t ADD COLUMN col type додає колонку; для наявних рядків вона буде NULL або значенням DEFAULT. Додати колонку NOT NULL без DEFAULT у заповнену таблицю не вийде. Обмеження додають так само: ALTER TABLE … ADD CONSTRAINT … CHECK (…), і база одразу перевірить наявні рядки.

ALTER TABLE. ALTER TABLE t ADD COLUMN col type adds a column; existing rows get NULL or the DEFAULT value. You cannot add a NOT NULL column without a DEFAULT to a populated table. Constraints are added the same way: ALTER TABLE … ADD CONSTRAINT … CHECK (…), and the database checks the existing rows right away.

Навіщо QA. Обмеження — остання лінія захисту, коли валідація в API пропустила погане значення. Тест-кейси на межі (0, −1, NULL, порожній рядок) варто проганяти і через API, і прямим INSERT: якщо API пропускає 0, а база відхиляє, користувач побачить 500 замість зрозумілої помилки. Текст помилки PostgreSQL містить ім'я обмеження — за ним легко знайти правило в схемі.

Why QA cares. Constraints are the last line of defence when API validation let a bad value through. Boundary cases (0, −1, NULL, empty string) are worth running both through the API and with a direct INSERT: if the API accepts 0 and the database rejects it, the user sees a 500 instead of a clear error. The PostgreSQL error text contains the constraint name, which makes the rule easy to find in the schema.

Порушення CHECK і NOT NULL та нова колонка
CHECK and NOT NULL violations and a new column
INSERT INTO products (id, sku, name, price) VALUES (10, 'SKU-010', 'Free thing', 0);
INSERT INTO products (id, sku, name, price) VALUES (11, 'SKU-011', NULL, 5);

ALTER TABLE products ADD COLUMN stock integer NOT NULL;
ALTER TABLE products ADD COLUMN stock integer NOT NULL DEFAULT 0 CHECK (stock >= 0);
UPDATE products SET stock = -1 WHERE id = 1;
SELECT id, sku, stock FROM products ORDER BY id;
Результат у psql
Result in psql
ERROR:  new row for relation "products" violates check constraint "products_price_check"
DETAIL:  Failing row contains (10, SKU-010, Free thing, 0.00, null).

ERROR:  null value in column "name" of relation "products" violates not-null constraint
DETAIL:  Failing row contains (11, SKU-011, null, 5.00, null).

ERROR:  column "stock" of relation "products" contains null values

ALTER TABLE

ERROR:  new row for relation "products" violates check constraint "products_stock_check"
DETAIL:  Failing row contains (1, SKU-001, Keyboard, 50.00, accessories, -1).

 id |   sku   | stock
----+---------+-------
  1 | SKU-001 |     0
  2 | SKU-002 |     0
  3 | SKU-003 |     0
  4 | SKU-004 |     0
(4 rows)
🔧 Розбір запиту: Ціна 0 порушує products_price_check, NULL у name — NOT NULL. Колонку NOT NULL без DEFAULT додати не можна, бо в таблиці вже є рядки; з DEFAULT 0 вона з'являється і відразу охороняється CHECK.
🔧 Query breakdown: A price of 0 violates products_price_check, and NULL in name violates NOT NULL. A NOT NULL column without a DEFAULT cannot be added because the table already has rows; with DEFAULT 0 it appears and is guarded by a CHECK straight away.
💡 Простими словами: Обмеження — це правила, які база не дасть порушити навіть коду з багом.
💡 In plain words: Constraints are rules the database will not let even buggy code break.
🗣 Як читати вголос CHECK перевіряє умову на кожному рядку, наприклад що ціна більша за нуль, а NOT NULL забороняє порожні значення. Цікавий нюанс: CHECK пропускає NULL, тож без NOT NULL порожня ціна пройде. Колонку додають через ALTER TABLE ADD COLUMN, і якщо вона NOT NULL, потрібен DEFAULT для наявних рядків. Граничні значення я перевіряю і через API, і напряму в базі, щоб API не віддавав п'ятисоту на порушення обмеження.
🗣 How to say it out loud CHECK validates a condition on every row, like a price above zero, and NOT NULL forbids empty values. A curious nuance: CHECK lets NULL through, so without NOT NULL an empty price passes. You add a column with ALTER TABLE ADD COLUMN, and a NOT NULL one needs a DEFAULT for the existing rows. I test boundary values both through the API and directly in the database, so the API does not answer a constraint violation with a five hundred.
38 Що таке нормалізація, 1NF і 3NF і коли денормалізація виправдана? What are normalization, 1NF and 3NF, and when is denormalization justified?
Зміна данихChanging data Middle ▶

Мета нормалізації. Кожен факт зберігається в одному місці. Це прибирає надлишковість і аномалії: оновили адресу в одному рядку, а в іншому лишилась стара (аномалія оновлення); не можна додати товар, поки його ніхто не замовив (аномалія вставки); видалили останнє замовлення — загубили дані про товар (аномалія видалення).

The goal of normalization. Each fact is stored in one place. That removes redundancy and anomalies: you update an address in one row while another keeps the old one (update anomaly); you cannot add a product until someone orders it (insert anomaly); you delete the last order and lose the product data (delete anomaly).

Нормальні форми. 1NF — атомарні значення: жодних списків у клітинці ('SKU-001,SKU-002' у колонці замовлення порушує 1NF — для цього існує order_items). 2NF — неключові колонки залежать від усього складеного ключа, а не від його частини. 3NF — неключові колонки не залежать від інших неключових (немає транзитивних залежностей): місто покупця не повинно зберігатися в orders, бо воно залежить від user_id, а не від замовлення.

Normal forms. 1NF — atomic values: no lists in a cell ('SKU-001,SKU-002' in an order column breaks 1NF — that is what order_items is for). 2NF — non-key columns depend on the whole composite key, not on part of it. 3NF — non-key columns do not depend on other non-key columns (no transitive dependencies): the buyer's city should not live in orders, because it depends on user_id, not on the order.

Денормалізація. Навмисне дублювання заради швидкого читання: orders.total замість підрахунку з позицій щоразу, ціна в order_items на момент покупки (це навіть не денормалізація, а історичний факт). Ціна цього — ризик розсинхронізації, і саме тут QA пише перевірки узгодженості: orders.total проти суми позицій. У нашій схемі така перевірка знаходить замовлення 104.

Denormalization. Deliberate duplication for fast reads: orders.total instead of summing the items every time, the price in order_items at purchase time (strictly a historical fact rather than denormalization). The cost is the risk of drift, and that is exactly where QA writes consistency checks: orders.total against the sum of items. In our schema that check finds order 104.

Порушення 1NF і як його «розпакувати» у рядки
A 1NF violation and how to «unpack» it into rows
WITH legacy_orders(order_id, skus) AS (
  VALUES (101, 'SKU-001,SKU-002'), (104, 'SKU-003,SKU-001')
)
SELECT order_id, trim(sku) AS sku
FROM legacy_orders, unnest(string_to_array(skus, ',')) AS sku
ORDER BY order_id, sku;
Результат у psql
Result in psql
 order_id |   sku
----------+---------
      101 | SKU-001
      101 | SKU-002
      104 | SKU-001
      104 | SKU-003
(4 rows)
🔧 Розбір запиту: legacy_orders імітує ненормалізовану таблицю зі списком у клітинці. string_to_array + unnest розкладає список на рядки — саме так дані переносять у нормалізовану order_items під час міграції.
🔧 Query breakdown: legacy_orders mimics a denormalized table with a list in a cell. string_to_array + unnest splits the list into rows — exactly how such data is moved into a normalized order_items during a migration.
💡 Простими словами: Нормалізація — «кожен факт в одному місці», денормалізація — «копія заради швидкості, яку треба стерегти».
💡 In plain words: Normalization is «each fact in one place»; denormalization is «a copy for speed that has to be guarded».
🗣 Як читати вголос Нормалізація потрібна, щоб кожен факт жив в одному місці й не було аномалій оновлення, вставки й видалення. Перша нормальна форма вимагає атомарних значень, без списків у клітинці, третя — щоб неключові колонки не залежали одна від одної. Денормалізацію роблять свідомо заради швидкості, наприклад зберігають суму замовлення, і тоді я як QA додаю перевірку, що ця сума збігається з позиціями.
🗣 How to say it out loud Normalization makes every fact live in one place, with no update, insert or delete anomalies. First normal form requires atomic values, no lists in a cell, and third normal form says non-key columns must not depend on each other. Denormalization is a conscious choice for speed, like storing an order total, and then as QA I add a check that this total matches the items.
39 Що таке збережена процедура і тригер і як тестувати логіку, що живе в базі? What are a stored procedure and a trigger, and how do you test logic that lives in the database?
Зміна данихChanging data Middle ▶

Процедура і функція. Збережена процедура — програма в базі, яку викликають за іменем: CALL close_order(101). У PostgreSQL є функції (CREATE FUNCTION, викликаються в SELECT і повертають значення) і, з версії 11, процедури (CREATE PROCEDURE, викликаються через CALL і можуть керувати транзакціями). Логіка поруч з даними працює швидко, але її важче версіонувати, рев'юїти й покривати тестами.

Procedure and function. A stored procedure is a program in the database called by name: CALL close_order(101). PostgreSQL has functions (CREATE FUNCTION, called in SELECT and returning a value) and, since version 11, procedures (CREATE PROCEDURE, invoked with CALL and able to control transactions). Logic next to the data runs fast, but it is harder to version, review and cover with tests.

Тригер. Код, який база запускає автоматично на INSERT/UPDATE/DELETE: заповнити updated_at, записати історію змін в audit-таблицю, перерахувати агрегат. У PostgreSQL тригер — це окрема функція RETURNS trigger плюс CREATE TRIGGER, що прив'язує її до таблиці. Тригер BEFORE може змінити рядок, AFTER — побачити фінальний стан.

Trigger. Code the database runs automatically on INSERT/UPDATE/DELETE: filling updated_at, writing change history to an audit table, recomputing an aggregate. In PostgreSQL a trigger is a separate function RETURNS trigger plus a CREATE TRIGGER that attaches it to a table. A BEFORE trigger can change the row, an AFTER trigger sees the final state.

Як тестувати. Тригер — «невидима» логіка: розробник у коді застосунку не бачить, що UPDATE статусу ще й пише в audit. Тест-кейси: тригер спрацьовує на кожну операцію, на яку розрахований (і не спрацьовує на інші), коректно обробляє масові оновлення, не ламає TRUNCATE (рядкові тригери на нього не реагують) і не створює каскад тригерів. Перевірка — через SELECT з таблиці, куди тригер пише.

How to test. A trigger is «invisible» logic: a developer reading application code does not see that a status UPDATE also writes to an audit table. Test cases: the trigger fires on every operation it is meant for (and not on others), handles bulk updates correctly, is not bypassed by TRUNCATE (row triggers ignore it) and does not start a cascade of triggers. Verify through a SELECT from the table the trigger writes to.

Тригер аудиту статусу замовлення
An audit trigger for order status
CREATE TABLE order_audit (order_id integer, old_status text, new_status text);

CREATE FUNCTION log_status() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
  IF NEW.status IS DISTINCT FROM OLD.status THEN
    INSERT INTO order_audit VALUES (OLD.id, OLD.status, NEW.status);
  END IF;
  RETURN NEW;
END $$;

CREATE TRIGGER trg_status AFTER UPDATE ON orders
FOR EACH ROW EXECUTE FUNCTION log_status();

UPDATE orders SET status = 'shipped' WHERE id = 101;
UPDATE orders SET total = total WHERE id = 104;
SELECT * FROM order_audit;
Результат у psql
Result in psql
CREATE TABLE

CREATE FUNCTION

CREATE TRIGGER

UPDATE 1

UPDATE 1

 order_id | old_status | new_status
----------+------------+------------
      101 | paid       | shipped
(1 row)
🔧 Розбір запиту: Функція порівнює OLD і NEW і пише в аудит лише реальну зміну статусу. Другий UPDATE змінив рядок, але не статус, тому в аудиті один запис — саме таку поведінку і варто перевіряти.
🔧 Query breakdown: The function compares OLD and NEW and writes to the audit only a real status change. The second UPDATE touched the row but not the status, so the audit has one record — exactly the behaviour worth checking.
💡 Простими словами: Процедуру викликають явно, тригер спрацьовує сам — тому його легко забути протестувати.
💡 In plain words: A procedure is called explicitly; a trigger fires on its own — which is why it is easy to forget to test.
🗣 Як читати вголос Збережена процедура — це програма в базі, яку викликають за іменем, а тригер — код, що база запускає сама на вставку, оновлення чи видалення. Тригери я вважаю прихованою логікою: з коду застосунку їх не видно. Тестую так: виконую операцію і перевіряю SELECT-ом таблицю, куди тригер пише, окремо — масове оновлення, оновлення без реальної зміни і TRUNCATE, на який рядкові тригери не реагують.
🗣 How to say it out loud A stored procedure is a program in the database called by name, and a trigger is code the database runs by itself on insert, update or delete. I treat triggers as hidden logic: you cannot see them from application code. I test by performing the operation and checking with a SELECT the table the trigger writes to, and separately a bulk update, an update with no real change and a TRUNCATE, which row triggers ignore.
▶ 6 7
40 Що таке транзакція, які властивості ACID і якими командами її керують? What is a transaction, what are the ACID properties, and which commands control it?
ТранзакціїTransactions Junior ▶

Що це. Транзакція — група операцій, яка виконується атомарно: або всі зміни фіксуються, або жодна. Класичний приклад — переказ: списати з одного рахунку і зарахувати на інший. Якщо друга операція впала, перша не має залишитися. У нашій схемі це «створити замовлення + позиції + платіж»: замовлення без позицій (як 106) — саме те, що буває, коли кроки йдуть без транзакції.

What it is. A transaction is a group of operations executed atomically: either every change is committed or none is. The classic example is a transfer: debit one account and credit another. If the second operation fails, the first must not remain. In our schema it is «create an order + items + payment»: an order with no items (like 106) is exactly what happens when the steps run without a transaction.

ACID. Atomicity — все або нічого. Consistency — після транзакції дані задовольняють усі обмеження. Isolation — паралельні транзакції не бачать проміжних станів одна одної (наскільки суворо — визначає рівень ізоляції). Durability — зафіксоване переживе падіння сервера.

ACID. Atomicity — all or nothing. Consistency — after the transaction the data satisfies every constraint. Isolation — concurrent transactions do not see each other's intermediate states (how strictly is set by the isolation level). Durability — committed data survives a server crash.

Команди. BEGIN (або стандартне START TRANSACTION) відкриває транзакцію, COMMIT фіксує, ROLLBACK скасовує. SAVEPOINT name і ROLLBACK TO SAVEPOINT name дозволяють відкотити частину. Без BEGIN кожна команда — окрема транзакція (autocommit). У PostgreSQL помилка всередині транзакції «отруює» її: наступні команди падають з current transaction is aborted, доки не буде ROLLBACK. У MySQL DDL (CREATE, ALTER, TRUNCATE) неявно фіксує транзакцію, тому відкотити міграцію там не вийде.

Commands. BEGIN (or the standard START TRANSACTION) opens a transaction, COMMIT commits, ROLLBACK cancels. SAVEPOINT name and ROLLBACK TO SAVEPOINT name let you undo part of it. Without BEGIN every statement is its own transaction (autocommit). In PostgreSQL an error inside a transaction «poisons» it: later statements fail with current transaction is aborted until ROLLBACK. In MySQL, DDL (CREATE, ALTER, TRUNCATE) implicitly commits, so you cannot roll a migration back there.

Атомарність, «отруєна» транзакція і SAVEPOINT
Atomicity, a «poisoned» transaction and SAVEPOINT
BEGIN;
INSERT INTO orders (id, user_id, status, total, created_at) VALUES (110, 4, 'new', 80.00, now());
INSERT INTO order_items (order_id, product_id, qty, price) VALUES (110, 4, 0, 80.00);
SELECT count(*) FROM orders;
ROLLBACK;
SELECT count(*) AS orders_after_rollback FROM orders WHERE id = 110;

BEGIN;
UPDATE products SET price = 45.00 WHERE id = 1;
SAVEPOINT before_mouse;
UPDATE products SET price = 0 WHERE id = 2;
ROLLBACK TO SAVEPOINT before_mouse;
COMMIT;
SELECT id, price FROM products WHERE id IN (1, 2) ORDER BY id;
Результат у psql
Result in psql
BEGIN

INSERT 0 1

ERROR:  new row for relation "order_items" violates check constraint "order_items_qty_check"
DETAIL:  Failing row contains (110, 4, 0, 80.00).

ERROR:  current transaction is aborted, commands ignored until end of transaction block

ROLLBACK

 orders_after_rollback
-----------------------
                     0
(1 row)

BEGIN

UPDATE 1

SAVEPOINT

ERROR:  new row for relation "products" violates check constraint "products_price_check"
DETAIL:  Failing row contains (2, SKU-002, Mouse, 0.00, accessories).

ROLLBACK

COMMIT

 id | price
----+-------
  1 | 45.00
  2 | 20.00
(2 rows)
🔧 Розбір запиту: Позиція з qty = 0 порушує CHECK, і після цього навіть SELECT у тій самій транзакції падає. ROLLBACK прибирає і замовлення 110 — ні замовлення, ні позиції. У другому блоці відкочено лише частину після SAVEPOINT: нова ціна клавіатури зафіксована, мишки — ні.
🔧 Query breakdown: The item with qty = 0 violates a CHECK, and after that even a SELECT in the same transaction fails. ROLLBACK removes order 110 as well — neither the order nor the item. In the second block only the part after the SAVEPOINT is undone: the keyboard's new price is committed, the mouse's is not.
💡 Простими словами: Транзакція — це «або все, або нічого» для групи змін.
💡 In plain words: A transaction is «all or nothing» for a group of changes.
🗣 Як читати вголос Транзакція — це група операцій, які виконуються атомарно: або всі зміни фіксуються, або жодна. ACID розшифровую як атомарність, узгодженість, ізоляцію і довговічність. Керую нею через BEGIN, COMMIT і ROLLBACK, а для часткового відкату є SAVEPOINT. У Postgres після помилки транзакція вже зламана, і треба робити ROLLBACK. Замовлення без позицій у даних для мене — привід перевірити, чи кроки створення загорнуті в одну транзакцію.
🗣 How to say it out loud A transaction is a group of operations executed atomically: either every change is committed or none is. I spell ACID as atomicity, consistency, isolation and durability. I control it with BEGIN, COMMIT and ROLLBACK, and SAVEPOINT gives a partial rollback. In Postgres, after an error the transaction is broken and needs a ROLLBACK. An order without items in the data tells me to check whether the creation steps are wrapped in one transaction.
41 Які є рівні ізоляції і що таке dirty read, якщо показати його на двох сесіях? What isolation levels exist, and what is a dirty read when shown with two sessions?
ТранзакціїTransactions Middle ▶

Рівні за стандартом (від слабшого до суворішого): READ UNCOMMITTED → READ COMMITTED → REPEATABLE READ → SERIALIZABLE. Кожен наступний забороняє більше аномалій: dirty read, non-repeatable read, phantom read, а SERIALIZABLE гарантує, що результат такий, ніби транзакції йшли по черзі.

The standard levels (weakest to strictest): READ UNCOMMITTED → READ COMMITTED → REPEATABLE READ → SERIALIZABLE. Each level forbids more anomalies: dirty read, non-repeatable read, phantom read, and SERIALIZABLE guarantees the outcome is as if the transactions ran one after another.

Dirty read. Читання незафіксованих змін іншої транзакції. Сесія A змінила ціну, але ще не зробила COMMIT; сесія B бачить нову ціну; A робить ROLLBACK — і B прийняла рішення на даних, яких ніколи не існувало. Стандарт дозволяє це лише на READ UNCOMMITTED.

Dirty read. Reading another transaction's uncommitted changes. Session A changes a price but has not committed; session B sees the new price; A rolls back — and B made a decision on data that never existed. The standard allows this only at READ UNCOMMITTED.

Реальні бази. PostgreSQL взагалі не має dirty read: READ UNCOMMITTED поводиться як READ COMMITTED, і це рівень за замовчуванням. У MySQL InnoDB за замовчуванням REPEATABLE READ, а READ UNCOMMITTED справді дозволяє брудне читання. Тому відповідь «на якому рівні працює наша база» — перше, що варто з'ясувати, коли тест на паралельні операції поводиться по-різному на різних середовищах. Поточний рівень показує SHOW transaction_isolation, змінюють його для транзакції через BEGIN ISOLATION LEVEL ….

Real databases. PostgreSQL has no dirty reads at all: READ UNCOMMITTED behaves as READ COMMITTED, which is the default level. MySQL InnoDB defaults to REPEATABLE READ, and its READ UNCOMMITTED really does allow dirty reads. So «which level does our database run at» is the first thing to find out when a concurrency test behaves differently across environments. SHOW transaction_isolation shows the current level, and BEGIN ISOLATION LEVEL … changes it for one transaction.

Рівень за замовчуванням і явний рівень для транзакції
The default level and an explicit level for one transaction
SHOW transaction_isolation;

BEGIN ISOLATION LEVEL REPEATABLE READ;
SHOW transaction_isolation;
COMMIT;
Результат у psql
Result in psql
 transaction_isolation
-----------------------
 read committed
(1 row)

BEGIN

 transaction_isolation
-----------------------
 repeatable read
(1 row)

COMMIT
Дві сесії в PostgreSQL: dirty read не стається
Two sessions in PostgreSQL: no dirty read happens
крок | сесія A                                   | сесія B
-----+-------------------------------------------+------------------------------------------
  1  | BEGIN;                                    |
  2  | UPDATE products SET price = 10            |
     |   WHERE id = 1;          -- ще без COMMIT |
  3  |                                           | BEGIN ISOLATION LEVEL READ UNCOMMITTED;
  4  |                                           | SELECT price FROM products WHERE id = 1;
     |                                           |   -> 50.00 (стара, зафіксована ціна)
  5  | ROLLBACK;                                 |
  6  |                                           | COMMIT;
step | session A                                 | session B
-----+-------------------------------------------+------------------------------------------
  1  | BEGIN;                                    |
  2  | UPDATE products SET price = 10            |
     |   WHERE id = 1;          -- not committed |
  3  |                                           | BEGIN ISOLATION LEVEL READ UNCOMMITTED;
  4  |                                           | SELECT price FROM products WHERE id = 1;
     |                                           |   -> 50.00 (the old committed price)
  5  | ROLLBACK;                                 |
  6  |                                           | COMMIT;
🔧 Розбір запиту: Щоб відтворити, відкрий два вікна psql. Навіть попросивши READ UNCOMMITTED, сесія B у PostgreSQL бачить лише зафіксовані дані. У MySQL на тому ж рівні крок 4 повернув би 10.00 — ціну, якої після ROLLBACK вже не існує.
🔧 Query breakdown: To reproduce, open two psql windows. Even when asking for READ UNCOMMITTED, session B in PostgreSQL sees only committed data. In MySQL at the same level step 4 would return 10.00 — a price that no longer exists after the ROLLBACK.
💡 Простими словами: Dirty read — повірити чернетці, яку потім викинули.
💡 In plain words: A dirty read is trusting a draft that was later thrown away.
🗣 Як читати вголос Рівні ізоляції за стандартом: READ UNCOMMITTED, READ COMMITTED, REPEATABLE READ і SERIALIZABLE, і кожен наступний забороняє більше аномалій. Dirty read — коли одна транзакція бачить незафіксовані зміни іншої, а та потім робить відкат. У Postgres такого не буває взагалі, навіть на READ UNCOMMITTED, і за замовчуванням там READ COMMITTED, а в MySQL за замовчуванням REPEATABLE READ. Коли тест на паралельність поводиться по-різному на середовищах, я першим ділом звіряю рівень ізоляції.
🗣 How to say it out loud The standard isolation levels are READ UNCOMMITTED, READ COMMITTED, REPEATABLE READ and SERIALIZABLE, and each one forbids more anomalies. A dirty read is when one transaction sees another's uncommitted changes and the other then rolls back. Postgres never allows it, even at READ UNCOMMITTED, and its default is READ COMMITTED, while MySQL defaults to REPEATABLE READ. When a concurrency test behaves differently across environments, the isolation level is the first thing I compare.
42 Чим non-repeatable read відрізняється від phantom read і як їх відтворити на двох сесіях? How does a non-repeatable read differ from a phantom read, and how do you reproduce both with two sessions?
ТранзакціїTransactions Senior ▶

Non-repeatable read. Транзакція двічі читає той самий рядок і отримує різні значення, бо між читаннями інша транзакція змінила його і зафіксувала. Приклад: звіт читає ціну товару на початку й наприкінці розрахунку — і суми не сходяться.

Non-repeatable read. A transaction reads the same row twice and gets different values, because between the reads another transaction changed it and committed. Example: a report reads a product price at the start and at the end of a calculation — and the totals do not match.

Phantom read. Транзакція двічі виконує той самий запит з умовою і бачить нові рядки, що з'явилися між читаннями: count(*) оплачених замовлень був 3, став 4. Різниця: non-repeatable — змінилися наявні рядки, phantom — змінився сам набір рядків.

Phantom read. A transaction runs the same filtered query twice and sees new rows that appeared in between: the count(*) of paid orders was 3 and becomes 4. The difference: non-repeatable means existing rows changed; phantom means the set of rows itself changed.

Хто що забороняє. За стандартом READ COMMITTED допускає обидві аномалії, REPEATABLE READ забороняє non-repeatable, але допускає фантоми, SERIALIZABLE — жодної. PostgreSQL суворіший: його REPEATABLE READ працює на знімку (snapshot isolation) і не показує фантомів. MySQL InnoDB на REPEATABLE READ теж читає зі знімка, але SELECT … FOR UPDATE там читає актуальні дані — ще одна причина, чому поведінка між базами відрізняється.

What forbids what. By the standard, READ COMMITTED allows both anomalies, REPEATABLE READ forbids non-repeatable reads but allows phantoms, and SERIALIZABLE allows neither. PostgreSQL is stricter: its REPEATABLE READ works on a snapshot (snapshot isolation) and shows no phantoms. MySQL InnoDB also reads from a snapshot at REPEATABLE READ, but SELECT … FOR UPDATE there reads current data — one more reason behaviour differs between databases.

Навіщо QA. Звіти й фонові задачі, що читають дані кілька разів, мають працювати на REPEATABLE READ, інакше дають неузгоджений результат під навантаженням. Такий баг не відтворюється в одиночному тесті — його ловлять навмисно паралельними сесіями, як у таблиці нижче.

Why QA cares. Reports and background jobs that read data several times should run at REPEATABLE READ, otherwise they produce inconsistent results under load. Such a bug does not reproduce in a single-user test — you catch it deliberately with concurrent sessions, as in the table below.

Дві сесії на READ COMMITTED (за замовчуванням у PostgreSQL)
Two sessions at READ COMMITTED (the PostgreSQL default)
крок | сесія A (звіт)                               | сесія B (застосунок)
-----+----------------------------------------------+------------------------------------------
  1  | BEGIN;                                       |
  2  | SELECT price FROM products WHERE id = 3;     |
     |   -> 200.00                                  |
  3  | SELECT count(*) FROM orders                  |
     |   WHERE status = 'paid';     -> 3            |
  4  |                                              | UPDATE products SET price = 180 WHERE id = 3;
  5  |                                              | INSERT INTO orders VALUES
     |                                              |   (120, 4, 'paid', 80, now());
  6  | SELECT price ... ;   -> 180.00  (non-repeatable read)
  7  | SELECT count(*) ...; -> 4       (phantom read)
  8  | COMMIT;                                      |

Той самий сценарій з BEGIN ISOLATION LEVEL REPEATABLE READ на кроці 1:
кроки 6 і 7 повертають 200.00 і 3 — транзакція A бачить знімок на момент першого запиту.
step | session A (report)                           | session B (application)
-----+----------------------------------------------+------------------------------------------
  1  | BEGIN;                                       |
  2  | SELECT price FROM products WHERE id = 3;     |
     |   -> 200.00                                  |
  3  | SELECT count(*) FROM orders                  |
     |   WHERE status = 'paid';     -> 3            |
  4  |                                              | UPDATE products SET price = 180 WHERE id = 3;
  5  |                                              | INSERT INTO orders VALUES
     |                                              |   (120, 4, 'paid', 80, now());
  6  | SELECT price ... ;   -> 180.00  (non-repeatable read)
  7  | SELECT count(*) ...; -> 4       (phantom read)
  8  | COMMIT;                                      |

The same scenario with BEGIN ISOLATION LEVEL REPEATABLE READ at step 1:
steps 6 and 7 return 200.00 and 3 — transaction A sees a snapshot taken at its first query.
🔧 Розбір запиту: Сесія B працює в autocommit, тому кожна її команда одразу зафіксована. На READ COMMITTED кожен SELECT сесії A бере свіжий знімок, тож бачить і змінену ціну, і нове замовлення. На REPEATABLE READ знімок один на всю транзакцію.
🔧 Query breakdown: Session B runs in autocommit, so each of its statements is committed at once. At READ COMMITTED every SELECT of session A takes a fresh snapshot, so it sees both the changed price and the new order. At REPEATABLE READ there is one snapshot for the whole transaction.
💡 Простими словами: Non-repeatable — «той самий рядок став іншим», phantom — «з'явилися нові рядки».
💡 In plain words: Non-repeatable is «the same row changed»; phantom is «new rows appeared».
🗣 Як читати вголос Non-repeatable read — це коли я в одній транзакції двічі читаю той самий рядок і бачу різні значення, бо хтось між читаннями змінив і зафіксував його. Phantom read — коли повторний запит з умовою повертає нові рядки. За стандартом REPEATABLE READ прибирає першу аномалію, а SERIALIZABLE обидві, але в Postgres REPEATABLE READ працює на знімку і фантомів теж не показує. Відтворюю це двома вікнами psql за чіткою послідовністю кроків.
🗣 How to say it out loud A non-repeatable read is when I read the same row twice in one transaction and get different values, because someone changed and committed it in between. A phantom read is when repeating a filtered query returns new rows. By the standard REPEATABLE READ removes the first anomaly and SERIALIZABLE both, but in Postgres REPEATABLE READ runs on a snapshot and shows no phantoms either. I reproduce this with two psql windows and a precise sequence of steps.
43 Що таке lost update і як його запобігти: атомарний UPDATE, SELECT FOR UPDATE чи версія рядка? What is a lost update, and how do you prevent it: an atomic UPDATE, SELECT FOR UPDATE or a row version?
ТранзакціїTransactions Senior ▶

Що це. Дві транзакції читають те саме значення, кожна рахує нове у коді застосунку і записує його. Друга запис перетирає першу, і одна зміна зникає без жодної помилки. Приклад: склад 10 шт., два замовлення одночасно читають 10, кожне пише 10 − 1 = 9, хоча має бути 8. READ COMMITTED цього не запобігає — кожен UPDATE сам по собі коректний.

What it is. Two transactions read the same value, each computes a new one in application code and writes it back. The second write overwrites the first, and one change vanishes without any error. Example: stock is 10, two orders read 10 at the same time, each writes 10 − 1 = 9, though it should be 8. READ COMMITTED does not prevent this — each UPDATE is correct on its own.

Способи захисту. (1) Атомарний UPDATE: SET stock = stock - 1 — база рахує від актуального значення під блокуванням рядка. Найпростіше і найкраще, коли логіку можна виразити в SQL. (2) Песимістичне блокування: SELECT … FOR UPDATE блокує рядок до кінця транзакції, друга сесія чекає. (3) Оптимістичне блокування: колонка version, і UPDATE … WHERE id = ? AND version = ?; якщо змінено 0 рядків — хтось встиг раніше, треба перечитати або повернути 409. (4) REPEATABLE READ/SERIALIZABLE у PostgreSQL: друга транзакція отримує could not serialize access due to concurrent update, і застосунок має повторити спробу.

Ways to protect. (1) An atomic UPDATE: SET stock = stock - 1 — the database computes from the current value under a row lock. The simplest and best option when the logic can be expressed in SQL. (2) Pessimistic locking: SELECT … FOR UPDATE locks the row until the end of the transaction, and the second session waits. (3) Optimistic locking: a version column and UPDATE … WHERE id = ? AND version = ?; if 0 rows changed, someone got there first — reread or return 409. (4) REPEATABLE READ/SERIALIZABLE in PostgreSQL: the second transaction gets could not serialize access due to concurrent update, and the application must retry.

Як тестувати. Паралельні запити на одну сутність (два PATCH, дві покупки останнього товару) і перевірка фінального стану в базі: сума змін має збігатися з кількістю успішних відповідей API. Такий тест ловить баг, якого не видно в послідовному прогоні.

How to test. Concurrent requests against one entity (two PATCHes, two purchases of the last item) and a check of the final state in the database: the sum of changes must match the number of successful API responses. Such a test catches a bug that never shows in a sequential run.

Дві сесії: втрачене оновлення і захист через FOR UPDATE
Two sessions: a lost update and the FOR UPDATE fix
Втрачене оновлення (застосунок рахує нову ціну сам):
крок | сесія A                                  | сесія B
-----+------------------------------------------+-------------------------------------------
  1  | BEGIN; SELECT price FROM products        | BEGIN; SELECT price FROM products
     |   WHERE id = 2;           -> 20.00       |   WHERE id = 2;            -> 20.00
  2  | UPDATE products SET price = 25           |
     |   WHERE id = 2;  COMMIT;   -- 20 + 5     |
  3  |                                          | UPDATE products SET price = 30
     |                                          |   WHERE id = 2;  COMMIT;   -- 20 + 10
  Результат: 30.00, а мало бути 35.00 — зміна A втрачена.

З блокуванням:
  1  | BEGIN; SELECT price ... FOR UPDATE;      |
     |   -> 20.00                               |
  2  |                                          | BEGIN; SELECT price ... FOR UPDATE;
     |                                          |   -- чекає, поки A завершиться
  3  | UPDATE ... SET price = 25; COMMIT;       |
  4  |                                          |   -> 25.00; UPDATE ... SET price = 35; COMMIT;
Lost update (the application computes the new price itself):
step | session A                                | session B
-----+------------------------------------------+-------------------------------------------
  1  | BEGIN; SELECT price FROM products        | BEGIN; SELECT price FROM products
     |   WHERE id = 2;           -> 20.00       |   WHERE id = 2;            -> 20.00
  2  | UPDATE products SET price = 25           |
     |   WHERE id = 2;  COMMIT;   -- 20 + 5     |
  3  |                                          | UPDATE products SET price = 30
     |                                          |   WHERE id = 2;  COMMIT;   -- 20 + 10
  Result: 30.00, but it should be 35.00 — A's change is lost.

With a lock:
  1  | BEGIN; SELECT price ... FOR UPDATE;      |
     |   -> 20.00                               |
  2  |                                          | BEGIN; SELECT price ... FOR UPDATE;
     |                                          |   -- waits until A finishes
  3  | UPDATE ... SET price = 25; COMMIT;       |
  4  |                                          |   -> 25.00; UPDATE ... SET price = 35; COMMIT;
Атомарний UPDATE і оптимістичне блокування в одній сесії
An atomic UPDATE and optimistic locking in one session
UPDATE products SET price = price + 5 WHERE id = 2 RETURNING price;

ALTER TABLE products ADD COLUMN version integer NOT NULL DEFAULT 1;
UPDATE products SET price = 30, version = version + 1 WHERE id = 2 AND version = 1;
UPDATE products SET price = 99, version = version + 1 WHERE id = 2 AND version = 1;
SELECT id, price, version FROM products WHERE id = 2;
Результат у psql
Result in psql
 price
-------
 25.00
(1 row)

ALTER TABLE

UPDATE 1

UPDATE 0

 id | price | version
----+-------+---------
  2 | 30.00 |       2
(1 row)
🔧 Розбір запиту: Атомарний price + 5 рахує від актуального значення. В оптимістичному варіанті перший UPDATE змінює 1 рядок і підвищує версію, другий з застарілою версією змінює 0 рядків — застосунок має побачити UPDATE 0 і повідомити про конфлікт.
🔧 Query breakdown: The atomic price + 5 computes from the current value. In the optimistic version the first UPDATE changes 1 row and bumps the version, while the second, with a stale version, changes 0 rows — the application must notice UPDATE 0 and report a conflict.
💡 Простими словами: Lost update — два редактори зберегли свої копії документа, і перемогла остання.
💡 In plain words: A lost update is two editors saving their copies of a document, and the last one wins.
🗣 Як читати вголос Lost update — коли дві транзакції прочитали одне значення, кожна порахувала своє в коді й записала, і друга мовчки перетерла першу. Захищаються атомарним UPDATE на кшталт stock мінус один, песимістичним SELECT FOR UPDATE, оптимістичною колонкою version або суворішим рівнем ізоляції з повтором транзакції. Я тестую це паралельними запитами на одну сутність і звіряю фінальний стан у базі з кількістю успішних відповідей.
🗣 How to say it out loud A lost update is when two transactions read the same value, each computes its own result in code and writes it, and the second silently overwrites the first. Protection comes from an atomic UPDATE like stock minus one, a pessimistic SELECT FOR UPDATE, an optimistic version column, or a stricter isolation level with a retry. I test it with concurrent requests on one entity and reconcile the final database state with the number of successful responses.
44 Що таке deadlock у базі даних і як його відтворити й уникнути? What is a deadlock in a database, and how do you reproduce and avoid it?
ТранзакціїTransactions Middle ▶

Що це. Дві транзакції тримають блокування і чекають одна на одну: A заблокувала рядок 101 і хоче 102, B заблокувала 102 і хоче 101. Жодна не може продовжити. База виявляє цикл очікування (у PostgreSQL перевірка запускається через deadlock_timeout, за замовчуванням 1 секунда) і перериває одну з транзакцій з помилкою deadlock detected; друга продовжує роботу.

What it is. Two transactions hold locks and wait for each other: A locked row 101 and wants 102, B locked 102 and wants 101. Neither can proceed. The database detects the wait cycle (in PostgreSQL the check runs after deadlock_timeout, 1 second by default) and aborts one transaction with deadlock detected; the other carries on.

Звідки береться. Найчастіше — різний порядок оновлення тих самих рядків у різних сценаріях: один ендпоінт оновлює спочатку замовлення, потім платіж, інший — навпаки. Також — масові UPDATE без ORDER BY, які блокують рядки в непередбачуваному порядку, і зовнішні ключі, що беруть блокування на батьківський рядок.

Where it comes from. Most often from updating the same rows in different orders in different flows: one endpoint updates the order first and then the payment, another does the reverse. Also from bulk UPDATEs without ORDER BY, which lock rows in an unpredictable order, and from foreign keys that take a lock on the parent row.

Як уникати і тестувати. Блокувати рядки в однаковому порядку (наприклад, за зростанням id), робити транзакції короткими, не тримати транзакцію відкритою під час HTTP-виклику. Застосунок має бути готовий повторити транзакцію, яку база обрала «жертвою». Для QA ознаки дедлоку — рідкісні 500 під навантаженням і deadlock detected у логах PostgreSQL; відтворюють двома сесіями або паралельним навантажувальним тестом на ті самі сутності.

Avoiding and testing it. Lock rows in a consistent order (say, ascending id), keep transactions short, and never hold a transaction open across an HTTP call. The application must be ready to retry a transaction the database picked as the «victim». For QA the signs of a deadlock are rare 500s under load and deadlock detected in the PostgreSQL log; you reproduce it with two sessions or a parallel load test on the same entities.

Дві сесії, два рядки, зворотний порядок
Two sessions, two rows, opposite order
крок | сесія A                                     | сесія B
-----+---------------------------------------------+---------------------------------------------
  1  | BEGIN;                                      | BEGIN;
  2  | UPDATE orders SET total = total WHERE id = 101;
  3  |                                             | UPDATE orders SET total = total WHERE id = 102;
  4  | UPDATE orders SET total = total WHERE id = 102;
     |   -- чекає на B                             |
  5  |                                             | UPDATE orders SET total = total WHERE id = 101;
     |                                             | ERROR:  deadlock detected
     |                                             | DETAIL:  Process ... waits for ShareLock on
     |                                             |   transaction ...; blocked by process ...
  6  |   -- розблоковано, UPDATE 1                 | ROLLBACK;
  7  | COMMIT;                                     |
step | session A                                   | session B
-----+---------------------------------------------+---------------------------------------------
  1  | BEGIN;                                      | BEGIN;
  2  | UPDATE orders SET total = total WHERE id = 101;
  3  |                                             | UPDATE orders SET total = total WHERE id = 102;
  4  | UPDATE orders SET total = total WHERE id = 102;
     |   -- waits for B                            |
  5  |                                             | UPDATE orders SET total = total WHERE id = 101;
     |                                             | ERROR:  deadlock detected
     |                                             | DETAIL:  Process ... waits for ShareLock on
     |                                             |   transaction ...; blocked by process ...
  6  |   -- unblocked, UPDATE 1                    | ROLLBACK;
  7  | COMMIT;                                     |
Хто кого блокує прямо зараз (виконай у третьому вікні)
Who is blocking whom right now (run in a third window)
SELECT pid, pg_blocking_pids(pid) AS blocked_by, state, left(query, 40) AS query
FROM pg_stat_activity
WHERE cardinality(pg_blocking_pids(pid)) > 0;
Результат у psql
Result in psql
 pid | blocked_by | state | query
-----+------------+-------+-------
(0 rows)
🔧 Розбір запиту: Між кроками 4 і 5 запит до pg_stat_activity з третьої сесії покаже сесію A, заблоковану сесією B. У нашому однокористувацькому прогоні заблокованих немає, тому результат порожній. Жертву обирає сама база — це може бути будь-яка з двох.
🔧 Query breakdown: Between steps 4 and 5 a query against pg_stat_activity from a third session shows session A blocked by session B. In our single-user run nothing is blocked, so the result is empty. The database picks the victim itself — it can be either one.
💡 Простими словами: Deadlock — двоє в коридорі, кожен чекає, поки інший поступиться.
💡 In plain words: A deadlock is two people in a corridor, each waiting for the other to step aside.
🗣 Як читати вголос Deadlock — це коли дві транзакції тримають блокування і чекають одна на одну, наприклад A оновила замовлення 101 і хоче 102, а B навпаки. База знаходить такий цикл і відкочує одну з них з помилкою deadlock detected. Зазвичай причина в різному порядку оновлення рядків у різних сценаріях, тому лікують однаковим порядком, короткими транзакціями і повтором у застосунку. Відтворюю двома вікнами psql.
🗣 How to say it out loud A deadlock is when two transactions hold locks and wait on each other, say A updated order 101 and wants 102 while B did the reverse. The database spots the cycle and rolls one of them back with deadlock detected. The usual cause is updating rows in a different order in different flows, so the fix is a consistent order, short transactions and a retry in the application. I reproduce it with two psql windows.
45 Який головний компроміс індексу і чи спрацює складений індекс (col1, col2) для WHERE col2 = 5? What is the main trade-off of an index, and will a composite index (col1, col2) serve WHERE col2 = 5?
ТранзакціїTransactions Senior ▶

Компроміс. Індекс (у PostgreSQL за замовчуванням B-tree) пришвидшує пошук, сортування і JOIN, але займає місце і сповільнює кожен INSERT, UPDATE індексованих колонок і DELETE — усі індекси таблиці треба оновити. Таблиця з десятком індексів швидко читається і повільно пишеться. Тому індекс створюють під конкретні запити, а невикористані знаходять через pg_stat_user_indexes (idx_scan = 0) і видаляють.

The trade-off. An index (B-tree by default in PostgreSQL) speeds up lookups, sorting and JOINs, but it takes space and slows down every INSERT, every UPDATE of indexed columns and every DELETE — all the table's indexes must be maintained. A table with a dozen indexes reads fast and writes slowly. So indexes are built for specific queries, and unused ones are found through pg_stat_user_indexes (idx_scan = 0) and dropped.

Складений індекс. Він упорядкований спочатку за col1, а всередині — за col2, як телефонний довідник за прізвищем, потім ім'ям. Пошук за прізвищем чи за прізвищем та ім'ям — швидкий; лише за ім'ям — ні, бо однакові імена розкидані по всьому довіднику. Це правило лівого префікса: індекс (user_id, total) допомагає для WHERE user_id = 42 і WHERE user_id = 42 AND total = 42, але зазвичай не для WHERE total = 5.

A composite index. It is ordered by col1 first and by col2 within it, like a phone book sorted by surname, then first name. Looking up a surname, or surname plus first name, is fast; only a first name is not, because equal first names are scattered across the whole book. That is the leftmost-prefix rule: an index on (user_id, total) helps WHERE user_id = 42 and WHERE user_id = 42 AND total = 42, but usually not WHERE total = 5.

Нюанс версій. PostgreSQL 18 додав skip scan для B-tree: якщо в першій колонці мало різних значень, планувальник може «перестрибувати» по них і все ж використати індекс. Тому точна відповідь — «зазвичай ні, перевір в EXPLAIN». Порядок колонок обирають так: спершу ті, що стоять у рівностях найчастіших запитів, потім діапазони. І не забувай: індекс не допоможе, якщо колонку загорнуто у функцію (lower(email)) — для цього є індекс за виразом.

A version nuance. PostgreSQL 18 added B-tree skip scan: if the first column has few distinct values, the planner may «hop» over them and still use the index. So the precise answer is «usually not — check EXPLAIN». Choose the column order like this: equality columns of the most frequent queries first, then ranges. And remember an index will not help if the column is wrapped in a function (lower(email)) — that needs an expression index.

Лівий префікс на таблиці з 200 000 рядків
The leftmost prefix on a 200,000-row table
SET max_parallel_workers_per_gather = 0;
CREATE TABLE orders_big AS
SELECT g AS id, g % 5000 AS user_id,
       (ARRAY['new', 'paid', 'shipped', 'cancelled'])[1 + (g / 7) % 4] AS status,
       (g % 1000)::numeric(10,2) AS total
FROM generate_series(1, 200000) AS g;
CREATE INDEX ob_user_total ON orders_big (user_id, total);
ANALYZE orders_big;

EXPLAIN (COSTS OFF) SELECT * FROM orders_big WHERE user_id = 42 AND total = 42;
EXPLAIN (COSTS OFF) SELECT * FROM orders_big WHERE user_id = 42;
EXPLAIN (COSTS OFF) SELECT * FROM orders_big WHERE total = 5;
Результат у psql
Result in psql
                         QUERY PLAN
------------------------------------------------------------
 Index Scan using ob_user_total on orders_big
   Index Cond: ((user_id = 42) AND (total = '42'::numeric))
(2 rows)

                QUERY PLAN
------------------------------------------
 Bitmap Heap Scan on orders_big
   Recheck Cond: (user_id = 42)
   ->  Bitmap Index Scan on ob_user_total
         Index Cond: (user_id = 42)
(4 rows)

            QUERY PLAN
----------------------------------
 Seq Scan on orders_big
   Filter: (total = '5'::numeric)
(2 rows)
🔧 Розбір запиту: generate_series створює 200 000 рядків, щоб планувальнику було що оптимізувати, а вимкнені паралельні воркери роблять план однаковим на будь-якій машині. Обидві умови або лише user_id використовують індекс; умова тільки на другу колонку total дає Seq Scan, хоча збігів лише 200.
🔧 Query breakdown: generate_series builds 200,000 rows so the planner has something to optimize, and disabling parallel workers keeps the plan identical on any machine. Both conditions, or user_id alone, use the index; a condition only on the second column, total, gives a Seq Scan even though there are only 200 matches.
💡 Простими словами: Складений індекс — довідник «прізвище, потім ім'я»: за самим ім'ям у ньому не пошукаєш.
💡 In plain words: A composite index is a «surname, then first name» phone book: you cannot search it by first name alone.
🗣 Як читати вголос Індекс пришвидшує читання, але займає місце і сповільнює кожну вставку, оновлення і видалення, бо його теж треба оновити. Складений індекс працює за правилом лівого префікса: індекс по user_id і total допоможе для умови на user_id або на обидві колонки, але зазвичай не для умови лише на total. У Postgres 18 є skip scan, тож я не вгадую, а дивлюся в EXPLAIN, що реально вибрав планувальник.
🗣 How to say it out loud An index speeds up reads but takes space and slows down every insert, update and delete, because it has to be maintained too. A composite index follows the leftmost-prefix rule: an index on user_id and total helps a condition on user_id or on both columns, but usually not one on total alone. Postgres 18 has skip scan, so I do not guess — I look in EXPLAIN at what the planner really chose.
46 Що показує EXPLAIN, що таке full table scan і що дає covering index? What does EXPLAIN show, what is a full table scan, and what does a covering index give you?
ТранзакціїTransactions Middle ▶

EXPLAIN. Показує план виконання: як база читатиме таблиці (Seq Scan, Index Scan, Bitmap Heap Scan, Index Only Scan), у якому порядку й яким алгоритмом з'єднуватиме (Nested Loop, Hash Join, Merge Join), і оцінку кількості рядків. EXPLAIN ANALYZE ще й реально виконує запит і показує фактичні рядки та час — тому на UPDATE/DELETE його запускають у транзакції з ROLLBACK. Головне, на що дивитися: де розходяться rows оцінені й фактичні і який вузол забирає найбільше часу.

EXPLAIN. Shows the execution plan: how the database will read tables (Seq Scan, Index Scan, Bitmap Heap Scan, Index Only Scan), in what order and with which algorithm it joins them (Nested Loop, Hash Join, Merge Join), and the estimated row counts. EXPLAIN ANALYZE actually runs the query and shows real rows and timings — so for UPDATE/DELETE it is run inside a transaction with ROLLBACK. What to look at first: where estimated and actual rows diverge, and which node takes the most time.

Full table scan. У PostgreSQL це Seq Scan — читання всієї таблиці рядок за рядком. Це не завжди погано: для маленької таблиці чи запиту, що повертає більшу частину рядків, послідовне читання швидше за індекс. Погано, коли Seq Scan по мільйонній таблиці заради кількох рядків.

Full table scan. In PostgreSQL that is a Seq Scan — reading the whole table row by row. It is not always bad: for a small table or a query returning most rows, sequential reading beats an index. It is bad when a Seq Scan crawls a million-row table for a handful of rows.

Covering index. Індекс, що містить усі колонки, потрібні запиту. Тоді база відповідає лише з індексу, не звертаючись до таблиці, — у плані це Index Only Scan. У PostgreSQL 11+ додаткові колонки додають через INCLUDE. Нюанс: Index Only Scan ефективний лише коли visibility map свіжа (після VACUUM), інакше база однаково перевіряє рядки в таблиці. І саме тому SELECT * руйнує covering-індекси: зайві колонки змушують читати таблицю.

Covering index. An index that holds every column the query needs. The database then answers from the index alone, without touching the table — Index Only Scan in the plan. In PostgreSQL 11+ extra columns are added with INCLUDE. The nuance: an Index Only Scan is efficient only when the visibility map is fresh (after VACUUM), otherwise the database still checks rows in the table. And that is exactly why SELECT * defeats covering indexes: the extra columns force a table read.

Seq Scan, потім Index Only Scan завдяки INCLUDE
A Seq Scan, then an Index Only Scan thanks to INCLUDE
SET max_parallel_workers_per_gather = 0;
CREATE TABLE orders_big AS
SELECT g AS id, g % 5000 AS user_id,
       (ARRAY['new', 'paid', 'shipped', 'cancelled'])[1 + (g / 7) % 4] AS status,
       (g % 1000)::numeric(10,2) AS total
FROM generate_series(1, 200000) AS g;
VACUUM ANALYZE orders_big;
EXPLAIN (COSTS OFF) SELECT user_id, sum(total) FROM orders_big WHERE user_id = 42 GROUP BY user_id;

CREATE INDEX ob_user_cover ON orders_big (user_id) INCLUDE (total);
VACUUM ANALYZE orders_big;
EXPLAIN (COSTS OFF) SELECT user_id, sum(total) FROM orders_big WHERE user_id = 42 GROUP BY user_id;
EXPLAIN (COSTS OFF) SELECT * FROM orders_big WHERE user_id = 42;
Результат у psql
Result in psql
           QUERY PLAN
--------------------------------
 GroupAggregate
   ->  Seq Scan on orders_big
         Filter: (user_id = 42)
(3 rows)

                       QUERY PLAN
---------------------------------------------------------
 GroupAggregate
   ->  Index Only Scan using ob_user_cover on orders_big
         Index Cond: (user_id = 42)
(3 rows)

                QUERY PLAN
------------------------------------------
 Bitmap Heap Scan on orders_big
   Recheck Cond: (user_id = 42)
   ->  Bitmap Index Scan on ob_user_cover
         Index Cond: (user_id = 42)
(4 rows)
🔧 Розбір запиту: Без індексу — Seq Scan з фільтром. З INCLUDE (total) запит, якому потрібні лише user_id і total, читає тільки індекс. SELECT * з тією ж умовою змушений іти в таблицю (Bitmap Heap Scan), бо status в індексі немає.
🔧 Query breakdown: Without the index — a Seq Scan with a filter. With INCLUDE (total), a query that needs only user_id and total reads just the index. SELECT * with the same condition has to visit the table (Bitmap Heap Scan), because status is not in the index.
💡 Простими словами: EXPLAIN — маршрут, яким база піде по дані; ANALYZE — той самий маршрут, пройдений насправді.
💡 In plain words: EXPLAIN is the route the database will take to the data; ANALYZE is that route actually travelled.
🗣 Як читати вголос EXPLAIN показує план: як база читатиме таблиці, чи візьме індекс і як з'єднуватиме, а EXPLAIN ANALYZE ще й виконує запит і дає реальні рядки й час. Seq Scan — це повний перегляд таблиці, і на маленьких таблицях він нормальний. Covering index містить усі потрібні запиту колонки, тоді в плані з'являється Index Only Scan. Тому я й не люблю SELECT зірочка в коді: він змушує читати таблицю навіть там, де вистачило б індексу.
🗣 How to say it out loud EXPLAIN shows the plan: how the database will read tables, whether it takes an index and how it joins, and EXPLAIN ANALYZE also runs the query and gives real rows and timings. A Seq Scan is a full table read, and on small tables that is fine. A covering index holds every column the query needs, and then an Index Only Scan appears in the plan. That is also why I dislike SELECT star in code: it forces a table read even where the index would do.
▶ 7 8
47 Як QA перевіряє, що дія в UI створила правильний запис у базі? How does QA verify that a UI action created the correct database record?
SQL у тестахSQL in tests Middle ▶

Підхід. Після дії (оформили замовлення в UI) знайти запис за унікальними критеріями, які тест сам задав або отримав: id з відповіді API чи URL, унікальний email тестового користувача, номер замовлення. Шукати «останній запис у таблиці» не можна — паралельні тести й інші люди теж створюють рядки. Далі порівняти поле за полем з очікуванням: статус, суми, зв'язки, службові поля.

The approach. After the action (an order placed in the UI), find the record by unique criteria the test itself set or received: an id from the API response or URL, the test user's unique email, the order number. Never search for «the latest row in the table» — parallel tests and other people create rows too. Then compare field by field with the expectation: status, amounts, links, technical fields.

Що перевіряти, крім очевидного. (1) Кількість: створено рівно одне замовлення, а не два через подвійний клік. (2) Зв'язки: позиції належать цьому замовленню, user_id — тому користувачу, що увійшов. (3) Узгодженість: total дорівнює сумі позицій. (4) Службові поля: created_at у UTC і в межах часу тесту, updated_by, статус за замовчуванням. (5) Що не змінилося: сусідні записи, залишки інших товарів.

What to check beyond the obvious. (1) Count: exactly one order was created, not two because of a double click. (2) Links: the items belong to this order and user_id is the logged-in user. (3) Consistency: total equals the sum of the items. (4) Technical fields: created_at in UTC and within the test's time window, updated_by, the default status. (5) What did not change: neighbouring records, stock of other products.

Нюанси. Якщо запис створюється асинхронно (черга, фоновий обробник), потрібне очікування з тайм-аутом — повторювати SELECT, доки рядок не з'явиться, а не sleep(5). Тестова база має бути доступна тесту з обліковим записом лише на читання для перевірок — так перевірка не зіпсує дані випадково.

Nuances. If the record is created asynchronously (a queue, a background worker), you need a wait with a timeout — repeat the SELECT until the row appears, not sleep(5). The test database should be reachable from the test with a read-only account for checks, so a check cannot damage data by accident.

Одна перевірка замовлення після оформлення в UI
One check of an order after checkout in the UI
SELECT o.id, o.status, o.total,
       u.email,
       count(i.product_id)          AS items,
       sum(i.qty * i.price)         AS items_total,
       o.total = sum(i.qty * i.price) AS total_matches
FROM orders o
JOIN users u            ON u.id = o.user_id
LEFT JOIN order_items i ON i.order_id = o.id
WHERE u.email = 'bohdan@mail.com' AND o.id = 104
GROUP BY o.id, u.email;
Результат у psql
Result in psql
 id  | status | total  |      email      | items | items_total | total_matches
-----+--------+--------+-----------------+-------+-------------+---------------
 104 | paid   | 260.00 | bohdan@mail.com |     2 |      250.00 | f
(1 row)
🔧 Розбір запиту: Запис шукаємо за email тестового користувача і id з відповіді, а не за «останнім». Один рядок збирає все, що треба порівняти, і колонка total_matches одразу показує баг: збережена сума 260.00 не збігається з позиціями.
🔧 Query breakdown: The record is found by the test user's email and the id from the response, not by «latest». One row gathers everything to compare, and the total_matches column exposes the bug right away: the stored 260.00 does not match the items.
💡 Простими словами: Шукай запис за тим, що знає тест, а не за тим, що «напевно останнє».
💡 In plain words: Find the record by what the test knows, not by what is «probably the latest».
🗣 Як читати вголос Після дії в UI я шукаю запис за унікальними даними, які знає тест: id з відповіді, email тестового користувача чи номер замовлення, і ніколи за «останнім рядком». Потім порівнюю поля з очікуванням і дивлюся ширше: що запис рівно один, зв'язки правильні, сума збігається з позиціями, дати в UTC і що сусідні дані не змінилися. Якщо запис створюється асинхронно, чекаю з тайм-аутом, а не сплю фіксований час.
🗣 How to say it out loud After a UI action I look the record up by unique data the test knows: the id from the response, the test user's email or the order number, never «the latest row». Then I compare fields with the expectation and look wider: there is exactly one record, the links are right, the total matches the items, dates are in UTC, and neighbouring data did not change. If the record is created asynchronously, I wait with a timeout rather than sleeping a fixed time.
48 Як знайти дублікати, наприклад однакові email, і як безпечно залишити один рядок? How do you find duplicates, such as identical emails, and safely keep only one row?
SQL у тестахSQL in tests Middle ▶

Пошук. Класика — SELECT email, count(*) FROM users GROUP BY email HAVING count(*) > 1. Але в реальних даних дублікати рідко ідентичні: Anna@Mail.com і anna@mail.com — для людини одна адреса, а для UNIQUE-обмеження — дві різні. Тому групують за нормалізованим значенням: lower(trim(email)). Так само з телефонами (прибрати пробіли і дужки) та іменами.

Finding them. The classic is SELECT email, count(*) FROM users GROUP BY email HAVING count(*) > 1. But duplicates in real data are rarely identical: Anna@Mail.com and anna@mail.com are one address to a human and two different values to a UNIQUE constraint. So group by the normalized value: lower(trim(email)). The same goes for phone numbers (strip spaces and brackets) and names.

Подробиці. Щоб побачити, які саме рядки дублюються, додай string_agg(id::text, ',') або використай віконну функцію count(*) OVER (PARTITION BY lower(email)) — вона залишає всі рядки і позначає кожен дубль. Для складених дублікатів групують за кількома колонками: два успішні платежі з тим самим order_id — теж дублікат.

Details. To see exactly which rows are duplicated, add string_agg(id::text, ',') or use the window function count(*) OVER (PARTITION BY lower(email)), which keeps every row and marks each duplicate. For composite duplicates, group by several columns: two successful payments with the same order_id are a duplicate too.

Очищення. Залишити один рядок з групи (найстаріший, за ROW_NUMBER) і видалити решту — тільки в транзакції і після SELECT тих самих рядків. Але спершу з'ясуй, чи на дублікати не посилаються інші таблиці: видалення користувача з замовленнями або впаде на FK, або каскадом знищить історію. Найкраще виправлення — не видалення, а обмеження, яке не дасть дублікатам з'явитися знову: CREATE UNIQUE INDEX … ON users (lower(email)). Баг-репорт має містити і запит, і кількість знайдених дублікатів.

Cleaning up. Keep one row per group (the oldest, via ROW_NUMBER) and delete the rest — only inside a transaction and after a SELECT of the same rows. But first check whether other tables reference the duplicates: deleting a user who has orders either fails on the FK or wipes history through a cascade. The best fix is not deletion but a constraint that keeps duplicates from coming back: CREATE UNIQUE INDEX … ON users (lower(email)). The bug report should include both the query and the number of duplicates found.

Точні й нормалізовані дублікати та захист від нових
Exact and normalized duplicates, and a guard against new ones
SELECT email, count(*) FROM users GROUP BY email HAVING count(*) > 1;

SELECT lower(trim(email)) AS email_norm, count(*) AS n, string_agg(id::text, ',' ORDER BY id) AS ids
FROM users
GROUP BY lower(trim(email))
HAVING count(*) > 1;

SELECT order_id, count(*) AS success_payments
FROM payments WHERE status = 'success'
GROUP BY order_id HAVING count(*) > 1;

CREATE UNIQUE INDEX users_email_lower_uq ON users (lower(email));
Результат у psql
Result in psql
 email | count
-------+-------
(0 rows)

  email_norm   | n | ids
---------------+---+-----
 anna@mail.com | 2 | 1,4
(1 row)

 order_id | success_payments
----------+------------------
      106 |                2
(1 row)

ERROR:  could not create unique index "users_email_lower_uq"
DETAIL:  Key (lower(email))=(anna@mail.com) is duplicated.
🔧 Розбір запиту: Точний пошук нічого не знаходить, нормалізований — пару id 1 і 4. Третій запит знаходить подвійну оплату замовлення 106. Унікальний індекс за виразом створити не вдається, поки дублікат є, — і помилка точно вказує конфліктне значення.
🔧 Query breakdown: The exact search finds nothing; the normalized one finds the pair of ids 1 and 4. The third query finds the double payment for order 106. The unique expression index cannot be created while the duplicate exists — and the error names the conflicting value exactly.
💡 Простими словами: Дублікат — це те, що людина вважає однаковим, а не лише те, що побайтово однакове.
💡 In plain words: A duplicate is whatever a human considers the same, not only what is byte-for-byte equal.
🗣 Як читати вголос Базовий запит — GROUP BY по email з HAVING count більше одного. Але я одразу нормалізую значення через lower і trim, бо в реальних даних дублікати відрізняються регістром чи пробілами, і звичайний UNIQUE їх пропускає. Щоб побачити конкретні id, додаю string_agg. Видаляю зайві лише в транзакції й після перевірки посилань, а як виправлення пропоную унікальний індекс по lower email.
🗣 How to say it out loud The basic query is GROUP BY email with HAVING count greater than one. But I normalize the value straight away with lower and trim, because real duplicates differ in case or spaces and a plain UNIQUE lets them through. To see the specific ids I add string_agg. I delete extras only inside a transaction and after checking references, and as a fix I propose a unique index on lower email.
49 Які запити перевірки цілісності даних QA тримає під рукою і як звірити суми між таблицями? Which data integrity queries does QA keep at hand, and how do you reconcile totals between tables?
SQL у тестахSQL in tests Middle ▶

Набір перевірок. Кожна перевірка — запит, що повертає порожній результат, коли все гаразд, і конкретні проблемні рядки, коли ні. Типовий набір: сироти (платежі без замовлень, позиції без товарів), обов'язкові зв'язки (оплачене замовлення має успішний платіж, замовлення має хоч одну позицію), узгодженість сум (orders.total = сума позицій = сума успішних платежів), допустимі значення статусів і відсутність дублікатів.

A set of checks. Each check is a query that returns nothing when all is well and the specific bad rows when it is not. The typical set: orphans (payments without orders, items without products), mandatory links (a paid order has a successful payment, an order has at least one item), total consistency (orders.total = sum of items = sum of successful payments), allowed status values and no duplicates.

Звірка сум. Порахуй кожну величину окремо на рівні ключа — позиції в одному CTE, платежі в іншому — і лише потім з'єднай, інакше JOIN розмножить рядки й суми. Порівнюй через IS DISTINCT FROM, щоб NULL (немає позицій чи платежів) теж потрапив у звіт. Для грошей — точний тип numeric, без допуску; для float — з допуском.

Reconciling totals. Compute each quantity separately at the key level — items in one CTE, payments in another — and only then join, otherwise the JOIN multiplies rows and totals. Compare with IS DISTINCT FROM so that NULL (no items or no payments) makes it into the report too. For money use the exact numeric type with no tolerance; for floats, use a tolerance.

Як використовувати. Такі запити запускають після регресу, після міграції, у нічній задачі на staging — і будь-який непорожній результат стає баг-репортом з готовим запитом для відтворення. Корисно об'єднати перевірки через UNION ALL в одну «панель» з колонками check_name і entity_id, щоб тест падав з переліком усіх порушень одразу.

How to use them. Run such queries after regression, after a migration, in a nightly job on staging — and any non-empty result becomes a bug report with a ready query to reproduce it. It helps to merge the checks with UNION ALL into one «panel» with check_name and entity_id columns, so the test fails listing every violation at once.

Панель перевірок цілісності одним запитом
An integrity check panel in one query
WITH items AS (
  SELECT order_id, sum(qty * price) AS s FROM order_items GROUP BY order_id
), pays AS (
  SELECT order_id, sum(amount) AS s FROM payments WHERE status = 'success' GROUP BY order_id
)
SELECT 'orphan_payment' AS check_name, p.id AS entity_id
FROM payments p WHERE NOT EXISTS (SELECT 1 FROM orders o WHERE o.id = p.order_id)
UNION ALL
SELECT 'order_without_items', o.id
FROM orders o WHERE NOT EXISTS (SELECT 1 FROM order_items i WHERE i.order_id = o.id)
UNION ALL
SELECT 'total_vs_items', o.id
FROM orders o LEFT JOIN items i ON i.order_id = o.id
WHERE i.s IS DISTINCT FROM o.total
UNION ALL
SELECT 'paid_vs_total', o.id
FROM orders o LEFT JOIN pays p ON p.order_id = o.id
WHERE o.status IN ('paid', 'shipped') AND p.s IS DISTINCT FROM o.total
ORDER BY 1, 2;
Результат у psql
Result in psql
     check_name      | entity_id
---------------------+-----------
 order_without_items |       106
 orphan_payment      |         7
 paid_vs_total       |       104
 paid_vs_total       |       106
 total_vs_items      |       104
 total_vs_items      |       106
(6 rows)
🔧 Розбір запиту: Кожна гілка UNION ALL — окрема перевірка з назвою. Результат одразу показує всі «баги» наскрізної схеми: платіж-сироту 7, замовлення 106 без позицій, розбіжність суми 104 з позиціями, недоплату 104 і переплату 106.
🔧 Query breakdown: Each UNION ALL branch is a separate named check. The result shows every «bug» of the shared schema at once: orphan payment 7, order 106 without items, order 104 not matching its items, the underpayment on 104 and the overpayment on 106.
💡 Простими словами: Гарна перевірка даних мовчить, коли все добре, і називає винних, коли ні.
💡 In plain words: A good data check is silent when all is well and names the culprits when it is not.
🗣 Як читати вголос Я тримаю набір запитів, які повертають порожній результат, коли дані в порядку: сироти, обов'язкові зв'язки на кшталт «оплачене замовлення має платіж», збіг сум між замовленням, позиціями і платежами, допустимі статуси й дублікати. Суми рахую окремо в CTE і лише потім з'єдную, щоб не розмножити рядки, а порівнюю через IS DISTINCT FROM, щоб не загубити NULL. Усе об'єдную через UNION ALL в одну панель з назвою перевірки.
🗣 How to say it out loud I keep a set of queries that return nothing when the data is fine: orphans, mandatory links like «a paid order has a payment», totals matching across the order, its items and its payments, allowed statuses and duplicates. I compute totals separately in CTEs and join afterwards so rows do not multiply, and I compare with IS DISTINCT FROM so NULLs are not lost. I merge everything with UNION ALL into one panel keyed by check name.
50 Як готувати й прибирати тестові дані через SQL і чому тести ніколи не запускають на продакшн-базі? How do you set up and clean up test data with SQL, and why must tests never run against the production database?
SQL у тестахSQL in tests Middle ▶

Seed і фікстури. Seed-скрипт приводить базу до відомого стану перед тестами: довідники, базові користувачі, товари. Фікстура — набір даних під конкретний тест. Гарний seed ідемпотентний (ON CONFLICT DO NOTHING або TRUNCATE перед вставкою), версіонується разом з кодом і не залежить від порядку запуску тестів. Кожен тест створює свої дані з унікальним маркером (префікс email qa+run42_…, окремий tenant), щоб не конфліктувати з паралельними тестами.

Seeds and fixtures. A seed script brings the database to a known state before tests: lookups, base users, products. A fixture is a data set for a specific test. A good seed is idempotent (ON CONFLICT DO NOTHING or a TRUNCATE before inserting), versioned with the code and independent of test order. Each test creates its own data with a unique marker (an email prefix qa+run42_…, a separate tenant), so it does not clash with parallel tests.

Прибирання. Варіанти від найшвидшого: (1) тест усередині транзакції з ROLLBACK наприкінці — нічого не лишається, але не працює, якщо застосунок ходить у базу окремим з'єднанням; (2) DELETE за маркером тест-рану у зворотному порядку залежностей (платежі → позиції → замовлення → користувачі); (3) TRUNCATE усіх таблиць з RESTART IDENTITY CASCADE у виділеній базі; (4) свіжа база з контейнера (Testcontainers) на кожен набір тестів.

Cleaning up. Options from fastest: (1) the test runs inside a transaction with a ROLLBACK at the end — nothing is left behind, but it does not work when the application uses its own connection; (2) DELETE by the test-run marker in reverse dependency order (payments → items → orders → users); (3) TRUNCATE of all tables with RESTART IDENTITY CASCADE in a dedicated database; (4) a fresh database from a container (Testcontainers) per test suite.

Чому не продакшн. Ризик змінити чи видалити дані реальних користувачів, списати реальні гроші чи відправити листи, створити навантаження, що вповільнить сервіс, і порушити вимоги до персональних даних. Тести працюють на окремій базі; якщо потрібні «реалістичні» дані — беруть знеособлену копію, а не прод.

Why not production. The risk of changing or deleting real users' data, charging real money or sending emails, creating load that slows the service, and breaching personal-data rules. Tests run on a separate database; when «realistic» data is needed, take an anonymized copy, not production itself.

Дані тест-рану з маркером і прибирання у зворотному порядку
Test-run data with a marker and cleanup in reverse order
INSERT INTO users (id, email, name) VALUES (900, 'qa+run42_buyer@mail.com', 'QA run 42');
INSERT INTO orders (id, user_id, status, total, created_at) VALUES (900, 900, 'new', 20.00, now());
INSERT INTO order_items (order_id, product_id, qty, price) VALUES (900, 2, 1, 20.00);

DELETE FROM users WHERE email LIKE 'qa+run42\_%';

DELETE FROM order_items WHERE order_id IN
  (SELECT o.id FROM orders o JOIN users u ON u.id = o.user_id WHERE u.email LIKE 'qa+run42\_%');
DELETE FROM orders WHERE user_id IN (SELECT id FROM users WHERE email LIKE 'qa+run42\_%');
DELETE FROM users WHERE email LIKE 'qa+run42\_%';
Результат у psql
Result in psql
INSERT 0 1

INSERT 0 1

INSERT 0 1

ERROR:  update or delete on table "users" violates foreign key constraint "orders_user_id_fkey" on table "orders"
DETAIL:  Key (id)=(900) is still referenced from table "orders".

DELETE 1

DELETE 1

DELETE 1
🔧 Розбір запиту: Видалити користувача першим не вийде — FK з orders захищає від сиріт. Прибирання йде від дочірніх таблиць до батьківських, і всі умови прив'язані до маркера тест-рану. \_ екранує підкреслення, яке в LIKE означає «будь-який символ».
🔧 Query breakdown: Deleting the user first fails — the FK from orders guards against orphans. Cleanup runs from child tables to parents, and every condition is tied to the test-run marker. \_ escapes the underscore, which in LIKE means «any single character».
💡 Простими словами: Кожен тест прибирає рівно те, що сам створив, — за своїм маркером.
💡 In plain words: Each test cleans up exactly what it created — by its own marker.
🗣 Як читати вголос Перед тестами я приводжу базу до відомого стану ідемпотентним seed-скриптом, а кожен тест створює свої дані з унікальним маркером, наприклад префіксом email з номером прогону. Прибираю у зворотному порядку залежностей, від платежів до користувачів, або відкатом транзакції, або TRUNCATE у виділеній базі, або свіжим контейнером. На продакшн тести не пускаю: там реальні люди, гроші й персональні дані.
🗣 How to say it out loud Before tests I bring the database to a known state with an idempotent seed script, and each test creates its own data with a unique marker, like an email prefix carrying the run number. I clean up in reverse dependency order, from payments to users, or by rolling back a transaction, or with TRUNCATE in a dedicated database, or with a fresh container. I never point tests at production: real people, money and personal data live there.
51 Як виконувати SQL з автотестів на Java чи Python і як параметризовані запити захищають від SQL-ін'єкцій? How do you run SQL from Java or Python automated tests, and how do parameterized queries protect against SQL injection?
SQL у тестахSQL in tests Middle ▶

SQL-ін'єкція. Якщо запит склеюють з рядком від користувача, ввід стає частиною SQL. Email ' OR '1'='1, підставлений у "… WHERE email = '" + email + "'", перетворює умову на завжди істинну — і запит повертає всіх користувачів. Так крадуть дані, обходять логін і видаляють таблиці. Це OWASP Top 10 (категорія Injection).

SQL injection. When a query is glued together with user input, the input becomes part of the SQL. The email ' OR '1'='1, inserted into "… WHERE email = '" + email + "'", turns the condition into an always-true one — and the query returns every user. That is how data is stolen, logins are bypassed and tables dropped. It is in the OWASP Top 10 (the Injection category).

Захист. Параметризовані запити (prepared statements): текст запиту і значення передаються в базу окремо, і значення ніколи не розбирається як SQL. У JDBC це PreparedStatement з ? і setString, у Python psycopg — %s плюс кортеж значень окремим аргументом (не f-рядок!). Параметром можна передати лише значення, не ім'я таблиці чи колонки — їх беруть з білого списку.

Protection. Parameterized queries (prepared statements): the query text and the values travel to the database separately, and a value is never parsed as SQL. In JDBC that is a PreparedStatement with ? and setString; in Python psycopg it is %s plus a tuple of values as a separate argument (not an f-string!). Only values can be parameters, not table or column names — those come from an allow-list.

Що це означає для тестів. Власні тестові хелпери теж пиши з параметрами — інакше тест з email o'brien@mail.com впаде на синтаксичній помилці. А для перевірки застосунку на ін'єкції подавай у поля типові рядки (', ' OR '1'='1, '; DROP TABLE users; --) і дивись: помилка SQL у відповіді чи 500 — вже знахідка, навіть якщо дані не витекли.

What it means for tests. Write your own test helpers with parameters too — otherwise a test with the email o'brien@mail.com fails with a syntax error. And to probe the application for injection, feed typical strings into fields (', ' OR '1'='1, '; DROP TABLE users; --) and watch: an SQL error in the response or a 500 is already a finding, even if no data leaked.

Склеєний рядок проти параметра на рівні SQL
A glued string versus a parameter at the SQL level
SELECT count(*) AS leaked FROM users WHERE email = '' OR '1'='1';

PREPARE find_user(text) AS SELECT id, email FROM users WHERE email = $1;
EXECUTE find_user(''' OR ''1''=''1');
EXECUTE find_user('olena@mail.com');
Результат у psql
Result in psql
 leaked
--------
      5
(1 row)

 id | email
----+-------
(0 rows)

 id |     email
----+----------------
  3 | olena@mail.com
(1 row)
Python (psycopg 3): перевірка замовлення з параметрами
Python (psycopg 3): an order check with parameters
import psycopg

def order_row(dsn: str, order_id: int, email: str):
    # Значення йдуть окремим аргументом, а не в текст запиту
    sql = """
        SELECT o.status, o.total
        FROM orders o JOIN users u ON u.id = o.user_id
        WHERE o.id = %s AND u.email = %s
    """
    with psycopg.connect(dsn) as conn:
        return conn.execute(sql, (order_id, email)).fetchone()

def test_order_is_paid(dsn):
    status, total = order_row(dsn, 101, "anna@mail.com")
    assert status == "paid"
    assert str(total) == "90.00"
import psycopg

def order_row(dsn: str, order_id: int, email: str):
    # Values go as a separate argument, not into the query text
    sql = """
        SELECT o.status, o.total
        FROM orders o JOIN users u ON u.id = o.user_id
        WHERE o.id = %s AND u.email = %s
    """
    with psycopg.connect(dsn) as conn:
        return conn.execute(sql, (order_id, email)).fetchone()

def test_order_is_paid(dsn):
    status, total = order_row(dsn, 101, "anna@mail.com")
    assert status == "paid"
    assert str(total) == "90.00"
Java (JDBC): той самий запит через PreparedStatement
Java (JDBC): the same query through a PreparedStatement
String sql = "SELECT o.status FROM orders o JOIN users u ON u.id = o.user_id "
           + "WHERE o.id = ? AND u.email = ?";
try (Connection c = DriverManager.getConnection(url, user, pass);
     PreparedStatement ps = c.prepareStatement(sql)) {
    ps.setInt(1, 101);
    ps.setString(2, "anna@mail.com");
    try (ResultSet rs = ps.executeQuery()) {
        assertTrue(rs.next());
        assertEquals("paid", rs.getString("status"));
        assertFalse(rs.next()); // рівно один рядок
    }
}
String sql = "SELECT o.status FROM orders o JOIN users u ON u.id = o.user_id "
           + "WHERE o.id = ? AND u.email = ?";
try (Connection c = DriverManager.getConnection(url, user, pass);
     PreparedStatement ps = c.prepareStatement(sql)) {
    ps.setInt(1, 101);
    ps.setString(2, "anna@mail.com");
    try (ResultSet rs = ps.executeQuery()) {
        assertTrue(rs.next());
        assertEquals("paid", rs.getString("status"));
        assertFalse(rs.next()); // exactly one row
    }
}
🔧 Розбір запиту: Перший запит показує, що дає склеювання: умова OR '1'='1' повертає всіх 5 користувачів. З PREPARE той самий рядок стає звичайним значенням параметра $1, тому збігів нуль. У Python і Java параметри — %s і ?, а значення передаються окремо.
🔧 Query breakdown: The first query shows what gluing gives: the OR '1'='1' condition returns all 5 users. With PREPARE the same string becomes an ordinary value of parameter $1, so there are zero matches. In Python and Java the parameters are %s and ?, and values are passed separately.
💡 Простими словами: Текст запиту — окремо, дані — окремо; тоді дані ніколи не стануть командою.
💡 In plain words: Query text separately, data separately — then data never becomes a command.
🗣 Як читати вголос SQL-ін'єкція виникає, коли ввід користувача склеюють з текстом запиту, і рядок на кшталт апостроф OR один дорівнює один робить умову завжди істинною. Захист — параметризовані запити: у JDBC це PreparedStatement зі знаками питання, у psycopg — відсоток s і окремий кортеж значень. Свої тестові хелпери я пишу так само, а застосунок перевіряю типовими рядками з апострофами і дивлюся, чи не протікає SQL-помилка у відповідь.
🗣 How to say it out loud SQL injection happens when user input is glued into the query text, and a string like quote OR one equals one makes the condition always true. The protection is parameterized queries: in JDBC a PreparedStatement with question marks, in psycopg percent-s with a separate tuple of values. I write my own test helpers the same way, and I probe the application with typical strings containing quotes to see whether an SQL error leaks into the response.
52 Які пастки з датами і часовими поясами трапляються в SQL-перевірках? What date and time zone traps show up in SQL checks?
SQL у тестахSQL in tests Middle ▶

Два типи. У PostgreSQL timestamptz зберігає момент часу (усередині — UTC) і показує його в часовому поясі сесії; timestamp без зони — просто «дата і час на годиннику», без прив'язки до поясу. Той самий рядок timestamptz у сесії з TimeZone = UTC виглядає як 23:30 31 березня, а в Europe/Kyiv — як 02:30 1 квітня. Для подій (створення, оплата) правильний тип — timestamptz.

Two types. In PostgreSQL timestamptz stores a moment in time (UTC inside) and displays it in the session time zone; timestamp without a zone is just «the date and time on a clock», tied to no zone. The same timestamptz value looks like 23:30 on March 31 in a session with TimeZone = UTC and like 02:30 on April 1 in Europe/Kyiv. For events (creation, payment) the right type is timestamptz.

Типові баги. (1) Місячний звіт групує за date_trunc('month', created_at) у UTC, а бізнес рахує в Києві — замовлення з ночі 31-го «переїжджає» в інший місяць, і звіт не сходиться з бухгалтерією. (2) Перехід на літній час: доба має 23 або 25 годин, а «+ 24 години» — не те саме, що «+ 1 день». (3) BETWEEN з датою обрізає останній день. (4) now() повертає час початку транзакції, тож два виклики в одній транзакції дають однакове значення.

Typical bugs. (1) A monthly report groups by date_trunc('month', created_at) in UTC while the business counts in Kyiv time — an order from the night of the 31st «moves» to another month, and the report disagrees with accounting. (2) Daylight saving: a day has 23 or 25 hours, and «+ 24 hours» is not the same as «+ 1 day». (3) BETWEEN with a date cuts off the last day. (4) now() returns the transaction start time, so two calls in one transaction give the same value.

Як тестувати. Явно задавати пояс у перевірці (AT TIME ZONE 'Europe/Kyiv'), а не покладатися на налаштування сервера CI. Мати тестові дані на межах: 23:30 UTC останнього дня місяця, ніч переходу на літній час, 29 лютого. У MySQL TIMESTAMP конвертується за поясом сесії, а DATETIME — ні, і обмежений 2038 роком для TIMESTAMP.

How to test. Set the zone explicitly in the check (AT TIME ZONE 'Europe/Kyiv') instead of relying on the CI server's settings. Keep boundary test data: 23:30 UTC on the last day of a month, the night of the DST switch, February 29. In MySQL TIMESTAMP is converted using the session zone while DATETIME is not, and TIMESTAMP is limited to the year 2038.

Місячний звіт в UTC і в Києві, перехід на літній час
A monthly report in UTC and in Kyiv, and a DST switch
SELECT to_char(date_trunc('month', created_at), 'YYYY-MM') AS month_utc, count(*)
FROM orders GROUP BY 1 ORDER BY 1;

SELECT to_char(date_trunc('month', created_at AT TIME ZONE 'Europe/Kyiv'), 'YYYY-MM') AS month_kyiv, count(*)
FROM orders GROUP BY 1 ORDER BY 1;

SET TIME ZONE 'Europe/Kyiv';
SELECT id, created_at FROM orders WHERE id = 104;
SELECT timestamptz '2026-03-28 12:00' + interval '1 day'   AS plus_day,
       timestamptz '2026-03-28 12:00' + interval '24 hours' AS plus_24h;
Результат у psql
Result in psql
 month_utc | count
-----------+-------
 2026-03   |     4
 2026-04   |     2
(2 rows)

 month_kyiv | count
------------+-------
 2026-03    |     3
 2026-04    |     3
(2 rows)

 id  |       created_at
-----+------------------------
 104 | 2026-04-01 02:30:00+03
(1 row)

        plus_day        |        plus_24h
------------------------+------------------------
 2026-03-29 12:00:00+03 | 2026-03-29 13:00:00+03
(1 row)
🔧 Розбір запиту: В UTC у березні 4 замовлення, у Києві — 3: замовлення 104 створене 31 березня о 23:30 UTC, а в Києві це вже 02:30 1 квітня. Уночі на 29 березня 2026 Україна переходить на літній час, тому «+1 день» і «+24 години» дають різний час на годиннику.
🔧 Query breakdown: In UTC March has 4 orders, in Kyiv 3: order 104 was created on March 31 at 23:30 UTC, which is already 02:30 on April 1 in Kyiv. On the night of March 29, 2026 Ukraine switches to summer time, so «+1 day» and «+24 hours» give different clock times.
💡 Простими словами: Зберігай момент в UTC, а межі доби й місяця рахуй у поясі бізнесу — явно.
💡 In plain words: Store the moment in UTC, and compute day and month boundaries in the business time zone — explicitly.
🗣 Як читати вголос Для подій я очікую тип timestamptz: він зберігає момент в UTC і показує його в поясі сесії. Найчастіший баг — звіт групує за місяцем в UTC, а бізнес живе за Києвом, і замовлення з ночі останнього дня переїжджає в інший місяць. Ще пастки — перехід на літній час, коли доба не дорівнює двадцяти чотирьом годинам, і BETWEEN з датами. Тому в перевірках задаю часовий пояс явно і тримаю тестові дані на межах.
🗣 How to say it out loud For events I expect the timestamptz type: it stores the moment in UTC and shows it in the session zone. The most common bug is a report grouping by month in UTC while the business lives on Kyiv time, so an order from the night of the last day moves to another month. Other traps are the daylight-saving switch, when a day is not twenty-four hours, and BETWEEN on dates. So in checks I set the time zone explicitly and keep test data on the boundaries.
53 Де різниця між PostgreSQL і MySQL найчастіше ламає запити й тести? Where do differences between PostgreSQL and MySQL most often break queries and tests?
SQL у тестахSQL in tests Middle ▶

Порівняння рядків. У PostgreSQL 'Anna' = 'anna' — false, у MySQL зі стандартною колацією utf8mb4_0900_ai_ci — true (і ще й без урахування наголосів). Тест, що шукає користувача за email, може знайти один рядок у MySQL і нуль у PostgreSQL. Унікальність email теж працює по-різному.

String comparison. In PostgreSQL 'Anna' = 'anna' is false; in MySQL with the default utf8mb4_0900_ai_ci collation it is true (and accent-insensitive as well). A test looking up a user by email may find one row in MySQL and zero in PostgreSQL. Email uniqueness behaves differently too.

Синтаксис і семантика. Ідентифікатори: у PostgreSQL подвійні лапки, у MySQL — зворотні лапки. Склеювання: || проти CONCAT. Ділення цілих: 7 / 2 у PostgreSQL дає 3, у MySQL — 3.5000. UPSERT: ON CONFLICT проти ON DUPLICATE KEY UPDATE; RETURNING є лише в PostgreSQL. FULL OUTER JOIN немає в MySQL. GROUP BY без ONLY_FULL_GROUP_BY у MySQL мовчки повертає довільне значення.

Syntax and semantics. Identifiers: double quotes in PostgreSQL, backquotes in MySQL. Concatenation: || versus CONCAT. Integer division: 7 / 2 gives 3 in PostgreSQL and 3.5000 in MySQL. UPSERT: ON CONFLICT versus ON DUPLICATE KEY UPDATE; RETURNING exists only in PostgreSQL. MySQL has no FULL OUTER JOIN. GROUP BY without ONLY_FULL_GROUP_BY in MySQL silently returns an arbitrary value.

Транзакції і типи. Рівень ізоляції за замовчуванням: READ COMMITTED проти REPEATABLE READ. DDL у PostgreSQL транзакційний, у MySQL — неявний COMMIT. Тип boolean у MySQL — це tinyint(1), тож тест отримає 1 замість true. Лічильники: identity/sequence проти AUTO_INCREMENT. Тому інтеграційні тести мають бігти на тій самій СУБД і версії, що й продакшн; H2 чи SQLite «замість» справжньої бази ховають саме ці розбіжності.

Transactions and types. The default isolation level: READ COMMITTED versus REPEATABLE READ. DDL is transactional in PostgreSQL and an implicit COMMIT in MySQL. MySQL's boolean is really tinyint(1), so a test gets 1 instead of true. Counters: identity/sequence versus AUTO_INCREMENT. That is why integration tests should run on the same DBMS and version as production; H2 or SQLite «standing in» for the real database hide exactly these differences.

Ті самі вирази в PostgreSQL (у MySQL результати інші)
The same expressions in PostgreSQL (MySQL gives different results)
SELECT 'Anna' = 'anna'          AS case_equal,
       7 / 2                     AS int_div,
       'a' || 'b'                AS concat_op,
       pg_typeof(true)           AS bool_type,
       count(*) FILTER (WHERE email ILIKE 'anna@%') AS anna_rows
FROM users;
Результат у psql
Result in psql
 case_equal | int_div | concat_op | bool_type | anna_rows
------------+---------+-----------+-----------+-----------
 f          |       3 | ab        | boolean   |         2
(1 row)
Що покаже MySQL 8.0 на тих самих даних
What MySQL 8.0 shows on the same data
вираз                          PostgreSQL        MySQL 8.0 (utf8mb4_0900_ai_ci)
'Anna' = 'anna'                false             1 (true)
7 / 2                          3                 3.5000
'a' || 'b'                     ab                0 (логічне OR; з PIPES_AS_CONCAT — ab)
тип true                       boolean           tinyint(1), значення 1
вставка 'Anna@Mail.com'        проходить         Duplicate entry: UNIQUE ігнорує регістр
expression                     PostgreSQL        MySQL 8.0 (utf8mb4_0900_ai_ci)
'Anna' = 'anna'                false             1 (true)
7 / 2                          3                 3.5000
'a' || 'b'                     ab                0 (logical OR; with PIPES_AS_CONCAT — ab)
type of true                   boolean           tinyint(1), value 1
insert 'Anna@Mail.com'         succeeds          Duplicate entry: UNIQUE ignores case
🔧 Розбір запиту: Перший блок виконано в PostgreSQL на наскрізній схемі. Другий — довідкова таблиця поведінки MySQL 8.0 з колацією за замовчуванням для тих самих виразів: саме такі розбіжності ламають тести при переході між СУБД.
🔧 Query breakdown: The first block ran in PostgreSQL on the shared schema. The second is a reference table of MySQL 8.0 behaviour with its default collation for the same expressions: exactly these gaps break tests when moving between DBMSs.
💡 Простими словами: «Це ж просто SQL» — доки тест не запустили на іншій СУБД.
💡 In plain words: «It is just SQL» — until the test runs on another DBMS.
🗣 Як читати вголос Найчастіше кусає порівняння рядків: у Postgres воно чутливе до регістру, а в MySQL з колацією за замовчуванням — ні, тож пошук за email дає різну кількість рядків. Далі дрібниці, які ламають тести: дві вертикальні риски в MySQL — це OR, ділення цілих дає різні результати, boolean у MySQL — це tinyint, UPSERT і RETURNING різні, а DDL у MySQL не відкочується. Тому інтеграційні тести я ганяю на тій самій СУБД і версії, що в проді.
🗣 How to say it out loud String comparison bites most often: in Postgres it is case-sensitive, in MySQL with the default collation it is not, so an email lookup returns a different number of rows. Then there are small things that break tests: the double pipe is OR in MySQL, integer division gives different results, MySQL's boolean is a tinyint, UPSERT and RETURNING differ, and DDL does not roll back in MySQL. So I run integration tests on the same DBMS and version as production.
54 Чим NoSQL-бази відрізняються від реляційних і що це змінює в перевірці даних? How do NoSQL databases differ from relational ones, and what does that change about checking data?
SQL у тестахSQL in tests Middle ▶

Види. Документні (MongoDB) зберігають JSON-подібні документи з вкладеними об'єктами; key-value (Redis) — значення за ключем, часто з TTL; wide-column (Cassandra) — рядки з гнучким набором колонок, розраховані на величезні обсяги запису; графові (Neo4j) — вузли і зв'язки. Загальне: немає жорсткої схеми, JOIN або немає, або вони обмежені, дані часто денормалізовані «під запит».

Kinds. Document stores (MongoDB) keep JSON-like documents with nested objects; key-value stores (Redis) keep a value per key, often with a TTL; wide-column stores (Cassandra) keep rows with a flexible set of columns, built for huge write volumes; graph databases (Neo4j) keep nodes and relationships. What they share: no rigid schema, JOINs either missing or limited, and data often denormalized «for the query».

Що змінюється для QA. (1) Немає FK і схеми — база не захистить від сиріт і від документа без обов'язкового поля; перевірки форми даних лягають на тести (JSON Schema, валідатори). (2) «Поле відсутнє» і «поле null» — різні стани, і фільтр {field: null} у MongoDB знаходить обидва. (3) Багато NoSQL-систем за замовчуванням eventually consistent: запис з'явиться на репліці не одразу, тож перевірка після дії має вміти чекати. (4) Дубльовані дані (ім'я покупця всередині замовлення) розходяться після оновлення джерела.

What changes for QA. (1) No FKs and no schema — the database will not protect you from orphans or from a document missing a required field; data-shape checks move into tests (JSON Schema, validators). (2) «Field missing» and «field is null» are different states, and the filter {field: null} in MongoDB matches both. (3) Many NoSQL systems are eventually consistent by default: a write shows up on a replica with a delay, so a check after an action must be able to wait. (4) Duplicated data (the buyer's name inside an order) drifts after the source is updated.

Спільне. Питання ті самі: чи є дублікати, чи узгоджені суми, чи не лишилося сиріт. PostgreSQL сам уміє зберігати документи в jsonb з індексами, тож перевірку «документного» підходу можна потренувати й у SQL.

What stays the same. The questions are the same: are there duplicates, do totals agree, are there orphans. PostgreSQL itself can store documents in jsonb with indexes, so you can practise checking a «document» approach in SQL as well.

«Документ» у jsonb: поле відсутнє проти поля null
A «document» in jsonb: a missing field versus a null field
WITH docs(id, doc) AS (VALUES
  (1, '{"status": "paid", "coupon": "SPRING"}'::jsonb),
  (2, '{"status": "paid", "coupon": null}'::jsonb),
  (3, '{"status": "new"}'::jsonb)
)
SELECT id,
       doc ? 'coupon'                  AS has_key,
       doc -> 'coupon'                 AS json_value,
       (doc ->> 'coupon') IS NULL      AS sql_null
FROM docs ORDER BY id;
Результат у psql
Result in psql
 id | has_key | json_value | sql_null
----+---------+------------+----------
  1 | t       | "SPRING"   | f
  2 | t       | null       | t
  3 | f       | NULL       | t
(3 rows)
Той самий пошук у MongoDB
The same lookup in MongoDB
db.orders.find({ coupon: null })              // документи 2 і 3
db.orders.find({ coupon: { $exists: false } }) // лише документ 3
db.orders.find({ coupon: { $type: "null" } })  // лише документ 2
db.orders.find({ coupon: null })              // documents 2 and 3
db.orders.find({ coupon: { $exists: false } }) // only document 3
db.orders.find({ coupon: { $type: "null" } })  // only document 2
🔧 Розбір запиту: Оператор ? перевіряє наявність ключа, -> повертає JSON-значення (для документа 2 це JSON null, для 3 — SQL NULL), а ->> перетворює на текст, де обидва стають NULL. MongoDB має ту саму різницю між $exists і значенням null.
🔧 Query breakdown: The ? operator checks whether a key exists, -> returns the JSON value (for document 2 that is JSON null, for 3 an SQL NULL), and ->> converts to text, where both become NULL. MongoDB has the same split between $exists and a null value.
💡 Простими словами: У NoSQL база не стереже цілісність — її стережуть тести.
💡 In plain words: In NoSQL the database does not guard integrity — the tests do.
🗣 Як читати вголос NoSQL — це кілька різних моделей: документні як MongoDB, key-value як Redis, wide-column як Cassandra і графові. Спільне в них — немає жорсткої схеми й зовнішніх ключів, JOIN обмежені, дані часто продубльовані. Для мене як QA це означає, що цілісність і форму даних перевіряють тести, що відсутнє поле і null — різні випадки, а через eventual consistency перевірка після дії має вміти почекати.
🗣 How to say it out loud NoSQL is several different models: document stores like MongoDB, key-value like Redis, wide-column like Cassandra, and graph databases. What they share is no rigid schema and no foreign keys, limited JOINs and often duplicated data. For me as QA that means tests guard integrity and data shape, a missing field and null are different cases, and because of eventual consistency a check after an action has to be able to wait.
▶ 8 3
55 Як перевірити дані після міграції між базами або схемами? How do you verify data after a migration between databases or schemas?
Senior-сценаріїSenior scenarios Senior ▶

Рівні перевірки. (1) Кількість: count(*) по кожній таблиці в джерелі й цілі, плюс кількість у розрізах (за статусом, за місяцем) — загальний лічильник може збігтися випадково. (2) Набори ключів: EXCEPT в обидва боки показує, які id загубилися і які з'явилися зайві. (3) Вміст: контрольні суми по колонках — sum для грошей, md5(string_agg(… ORDER BY id)) для рядка чи всієї таблиці; якщо хеші різні, звужуєш пошук до діапазону id. (4) Вибіркова звірка кількох записів поле за полем, включно з крайніми випадками.

Levels of checking. (1) Counts: count(*) per table in the source and the target, plus counts by slice (by status, by month) — an overall counter can match by coincidence. (2) Key sets: EXCEPT in both directions shows which ids were lost and which extra ones appeared. (3) Content: checksums per column — sum for money, md5(string_agg(… ORDER BY id)) for a row or a whole table; if the hashes differ, narrow down by id range. (4) A sample of records compared field by field, edge cases included.

Що ламається найчастіше. NULL перетворився на порожній рядок чи 0; обрізалися довгі рядки (varchar меншої довжини); загубилась точність numeric чи мілісекунди; часовий пояс зсунув timestamp; кодування зіпсувало кирилицю; дублікати в джерелі впали на новому UNIQUE і «тихо» відфільтрувались; послідовності не перевстановлено, і перша ж вставка після міграції падає на duplicate key.

What breaks most often. NULL turned into an empty string or 0; long strings got truncated (a shorter varchar); numeric precision or milliseconds were lost; a time zone shifted a timestamp; an encoding mangled Cyrillic text; duplicates in the source failed a new UNIQUE and were «quietly» filtered out; sequences were not reset, so the first insert after the migration fails on a duplicate key.

Процес. Запиши очікування до міграції (знімок лічильників і хешів), автоматизуй звірку скриптом, який повертає порожній результат при успіху, і прогони її на копії продакшн-даних, а не лише на тестовому seed. Після міграції перевір і застосунок: основні сценарії читання й запису на нових даних. Ще варто перевірити час міграції на реальному обсязі і що вона ідемпотентна — повторний запуск не дублює дані.

The process. Record expectations before the migration (a snapshot of counters and hashes), automate the reconciliation with a script that returns nothing on success, and run it on a copy of production data, not just the test seed. After the migration check the application too: the main read and write flows on the new data. It is also worth checking the migration time on a real volume, and that it is idempotent — a second run does not duplicate data.

Міграція з двома навмисними багами і три рівні звірки
A migration with two deliberate bugs and three levels of reconciliation
CREATE TABLE users_v2 AS
SELECT id, email, name, COALESCE(city, '') AS city, referred_by, created_at
FROM users WHERE email = lower(email);

SELECT (SELECT count(*) FROM users) AS src, (SELECT count(*) FROM users_v2) AS dst;

SELECT 'missing_in_target' AS problem, id FROM (SELECT id FROM users EXCEPT SELECT id FROM users_v2) m
UNION ALL
SELECT 'extra_in_target', id FROM (SELECT id FROM users_v2 EXCEPT SELECT id FROM users) e;

SELECT s.id, s.city AS src_city, t.city AS dst_city
FROM users s JOIN users_v2 t USING (id)
WHERE md5(row(s.email, s.name, s.city, s.referred_by, s.created_at)::text)
   <> md5(row(t.email, t.name, t.city, t.referred_by, t.created_at)::text);
Результат у psql
Result in psql
CREATE TABLE

 src | dst
-----+-----
   5 |   4
(1 row)

      problem      | id
-------------------+----
 missing_in_target |  4
(1 row)

 id | src_city | dst_city
----+----------+----------
  3 | NULL     |
(1 row)
🔧 Розбір запиту: Міграція «тихо» відкинула рядок з email у верхньому регістрі й замінила NULL у city на порожній рядок. Лічильник показує 5 проти 4, EXCEPT називає загублений id 4, а порівняння хешів рядків знаходить Olena, чиє місто змінилося з NULL на ''.
🔧 Query breakdown: The migration «quietly» dropped the row with an upper-case email and replaced NULL in city with an empty string. The counter shows 5 versus 4, EXCEPT names the lost id 4, and the row-hash comparison finds Olena, whose city changed from NULL to ''.
💡 Простими словами: Лічильник каже «скільки», EXCEPT — «які», хеш — «чи ті самі всередині».
💡 In plain words: A count says «how many», EXCEPT says «which ones», a hash says «are they the same inside».
🗣 Як читати вголос Міграцію я перевіряю шарами. Спочатку кількість рядків у кожній таблиці і в розрізах, бо загальне число може зійтися випадково. Потім EXCEPT в обидва боки, щоб побачити загублені й зайві ключі. Далі контрольні суми: сума грошей і хеш по рядках, щоб знайти змінений вміст, наприклад NULL, що став порожнім рядком. І насамкінець вибіркова звірка крайніх випадків і перевірка, що послідовності виставлені і застосунок працює на нових даних.
🗣 How to say it out loud I verify a migration in layers. First the row counts per table and per slice, because the overall number can match by accident. Then EXCEPT in both directions to see lost and extra keys. Then checksums: the money total and a per-row hash to find changed content, such as a NULL that became an empty string. Finally a sample check of edge cases, plus confirming the sequences are set and the application works on the new data.
56 Дашборд і бухгалтерія показують різну виручку за березень: як розслідувати розбіжність? The dashboard and accounting show different March revenue: how do you investigate the mismatch?
Senior-сценаріїSenior scenarios Senior ▶

Спершу визначення. Розбіжність звітів — найчастіше не баг обчислень, а різні визначення. Треба з'ясувати для кожного звіту: з якої таблиці береться «виручка» (сума замовлень чи сума платежів), які статуси враховано (paid, shipped, чи й new), за якою датою (створення замовлення чи оплати), у якому часовому поясі і чи відраховано повернення.

Definitions first. A report mismatch is most often not a calculation bug but different definitions. For each report, find out: which table «revenue» comes from (order totals or payment amounts), which statuses count (paid, shipped, or new too), which date is used (order creation or payment), in which time zone, and whether refunds are deducted.

Потім — міст між числами. Не порівнюй дві суми «в лоб». Порахуй обидва варіанти на рівні окремих записів і з'єднай їх FULL JOIN за id, щоб кожна розбіжність мала причину: «замовлення 104 у березні за UTC, але у квітні за Києвом», «платіж не збігається з сумою замовлення», «замовлення оплачене двічі». Сума стовпця «різниця» має точно дорівнювати розриву між звітами — тоді розслідування закінчене.

Then a bridge between the numbers. Do not compare two totals «head on». Compute both versions at the level of individual records and FULL JOIN them by id, so every difference has a reason: «order 104 is in March by UTC but in April by Kyiv time», «the payment does not match the order total», «the order was paid twice». The sum of the «difference» column must exactly equal the gap between the reports — then the investigation is complete.

Типові винуватці. Часовий пояс на межі місяця; fan-out від JOIN з позиціями чи платежами; дублікати платежів; платежі-сироти, які враховує одна сторона; різні правила статусів; кешовані чи materialized дані, оновлені в різний час. Результат розслідування — не лише баг-репорт, а й узгоджене визначення метрики, записане як запит, який стає автотестом для обох звітів.

The usual suspects. The time zone at a month boundary; fan-out from a JOIN with items or payments; duplicate payments; orphan payments that one side counts; different status rules; cached or materialized data refreshed at different times. The outcome is not just a bug report but an agreed metric definition written as a query, which becomes an automated test for both reports.

Міст між «виручкою за замовленнями в UTC» і «платежами за Києвом»
A bridge between «order revenue in UTC» and «payments on Kyiv time»
WITH dash AS (
  SELECT id AS order_id, total AS amount
  FROM orders
  WHERE status IN ('paid', 'shipped')
    AND created_at >= '2026-03-01 00:00+00' AND created_at < '2026-04-01 00:00+00'
), acct AS (
  SELECT order_id, sum(amount) AS amount
  FROM payments
  WHERE status = 'success'
    AND paid_at >= '2026-03-01 00:00 Europe/Kyiv' AND paid_at < '2026-04-01 00:00 Europe/Kyiv'
  GROUP BY order_id
)
SELECT COALESCE(d.order_id, a.order_id) AS order_id,
       d.amount AS dashboard, a.amount AS accounting,
       COALESCE(d.amount, 0) - COALESCE(a.amount, 0) AS diff
FROM dash d FULL JOIN acct a ON a.order_id = d.order_id
ORDER BY 1;
Результат у psql
Result in psql
 order_id | dashboard | accounting |  diff
----------+-----------+------------+--------
      101 |     90.00 |      90.00 |   0.00
      102 |    200.00 |     200.00 |   0.00
      104 |    260.00 |       NULL | 260.00
(3 rows)
🔧 Розбір запиту: Дашборд рахує 550.00 (101, 102, 104 за UTC), бухгалтерія — 290.00: платіж за 104 надійшов 31 березня о 23:40 UTC, а за Києвом це вже 1 квітня. Стовпець diff пояснює весь розрив 260.00 одним рядком. Але розслідування на цьому не закінчене: у квітні бухгалтерія отримає за 104 лише 250.00, а не 260.00.
🔧 Query breakdown: The dashboard counts 550.00 (101, 102, 104 in UTC), accounting 290.00: the payment for 104 arrived on March 31 at 23:40 UTC, which is already April 1 in Kyiv. The diff column explains the whole 260.00 gap in a single row. But the investigation is not over: in April accounting will receive only 250.00 for 104, not 260.00.
💡 Простими словами: Не сперечайся, яка цифра правильна, — побудуй міст, де кожен рядок пояснює свою частину різниці.
💡 In plain words: Do not argue about which number is right — build a bridge where each row explains its share of the difference.
🗣 Як читати вголос Спершу я з'ясовую визначення: звідки кожен звіт бере виручку, які статуси, яка дата і в якому поясі. Потім будую міст — рахую обидві версії на рівні окремих замовлень і з'єдную FULL JOIN, щоб у кожної розбіжності була конкретна причина, а сума різниць точно дорівнювала розриву. Найчастіше винні часовий пояс на межі місяця, розмножені JOIN-ом суми, дублікати платежів і різні правила статусів. На виході — узгоджене визначення метрики як автотест.
🗣 How to say it out loud First I pin down definitions: where each report takes revenue from, which statuses, which date and in which zone. Then I build a bridge — computing both versions per order and joining them with a FULL JOIN, so every difference has a concrete cause and the sum of the differences equals the gap exactly. The usual culprits are the time zone at a month boundary, totals multiplied by a JOIN, duplicate payments and different status rules. The outcome is an agreed metric definition turned into an automated test.
57 Запит, який учора працював за 50 мс, сьогодні виконується 8 секунд: як знайти причину? A query that ran in 50 ms yesterday takes 8 seconds today: how do you find the cause?
Senior-сценаріїSenior scenarios Senior ▶

Відділи запит від середовища. Спочатку перевір, чи повільний саме запит, чи він чекає: pg_stat_activity покаже wait_event і блокування (pg_blocking_pids) — довга транзакція чи міграція, що тримає lock, робить повільним будь-який запит. Якщо очікувань немає, дивись план: EXPLAIN (ANALYZE, BUFFERS) на тих самих параметрах, що й у продакшн-виклику.

Separate the query from its environment. First check whether the query itself is slow or whether it is waiting: pg_stat_activity shows the wait_event and locks (pg_blocking_pids) — a long transaction or a migration holding a lock makes any query slow. If there is no waiting, look at the plan: EXPLAIN (ANALYZE, BUFFERS) with the same parameters as the production call.

Що шукати в плані. (1) Seq Scan по великій таблиці там, де очікувався індекс: індекс видалили, або колонку загорнули у функцію (lower(email), created_at::date), або типи не збігаються і відбувається неявне приведення, або шаблон LIKE '%…' з початковим відсотком. (2) Велика розбіжність між оціненими і фактичними rows — застаріла статистика після масового завантаження, лікується ANALYZE. (3) Nested Loop з мільйонами loops, сортування чи хеш, що вилились на диск. (4) SELECT *, що тягне широкі колонки і вбиває Index Only Scan.

What to look for in the plan. (1) A Seq Scan on a big table where an index was expected: the index was dropped, or the column got wrapped in a function (lower(email), created_at::date), or the types differ and an implicit cast happens, or a LIKE '%…' pattern starts with a percent sign. (2) A big gap between estimated and actual rows — stale statistics after a bulk load, cured by ANALYZE. (3) A Nested Loop with millions of loops, a sort or hash spilling to disk. (4) A SELECT * that drags in wide columns and kills an Index Only Scan.

Поза планом. «Сьогодні» часто означає: виросли дані, з'явився новий фільтр в ORM, змінився параметр, для якого план гірший, або код почав викликати запит у циклі (N+1). pg_stat_statements покаже, який запит і скільки разів з'їв час. Після виправлення — повтори EXPLAIN ANALYZE і зафіксуй бюджет часу в навантажувальному тесті, щоб регресія не повернулась непоміченою.

Beyond the plan. «Today» often means the data grew, the ORM added a new filter, a parameter changed to one with a worse plan, or code started calling the query in a loop (N+1). pg_stat_statements shows which query ate the time and how many times it ran. After the fix, rerun EXPLAIN ANALYZE and pin a time budget in a load test so the regression cannot come back unnoticed.

Функція над колонкою вимикає індекс, індекс за виразом повертає його
A function on the column disables the index; an expression index brings it back
SET max_parallel_workers_per_gather = 0;
CREATE TABLE users_big AS
SELECT g AS id, 'user' || g || '@mail.com' AS email
FROM generate_series(1, 100000) AS g;
CREATE INDEX users_big_email ON users_big (email);
ANALYZE users_big;

EXPLAIN (COSTS OFF) SELECT id FROM users_big WHERE email = 'user42@mail.com';
EXPLAIN (COSTS OFF) SELECT id FROM users_big WHERE lower(email) = 'user42@mail.com';
EXPLAIN (COSTS OFF) SELECT id FROM users_big WHERE email LIKE '%42@mail.com';

CREATE INDEX users_big_email_lower ON users_big (lower(email));
ANALYZE users_big;
EXPLAIN (COSTS OFF) SELECT id FROM users_big WHERE lower(email) = 'user42@mail.com';
Результат у psql
Result in psql
                   QUERY PLAN
-------------------------------------------------
 Index Scan using users_big_email on users_big
   Index Cond: (email = 'user42@mail.com'::text)
(2 rows)

                     QUERY PLAN
----------------------------------------------------
 Seq Scan on users_big
   Filter: (lower(email) = 'user42@mail.com'::text)
(2 rows)

                QUERY PLAN
-------------------------------------------
 Seq Scan on users_big
   Filter: (email ~~ '%42@mail.com'::text)
(2 rows)

                       QUERY PLAN
--------------------------------------------------------
 Index Scan using users_big_email_lower on users_big
   Index Cond: (lower(email) = 'user42@mail.com'::text)
(2 rows)
🔧 Розбір запиту: Рівність по email використовує індекс. Той самий пошук через lower(email) і LIKE з початковим відсотком дають Seq Scan по всій таблиці. Індекс за виразом lower(email) повертає швидкий пошук — але лише для запитів, що пишуть той самий вираз.
🔧 Query breakdown: Equality on email uses the index. The same lookup through lower(email) and a LIKE with a leading percent sign both give a Seq Scan over the whole table. An expression index on lower(email) brings fast lookup back — but only for queries that write that same expression.
💡 Простими словами: Спершу «чи чекає запит?», потім «що каже план?», і лише потім «який індекс додати?».
💡 In plain words: First «is the query waiting?», then «what does the plan say?», and only then «which index should I add?».
🗣 Як читати вголос Спершу я відділяю сам запит від середовища: дивлюся в pg_stat_activity, чи він не чекає на блокування від довгої транзакції чи міграції. Якщо ні — знімаю EXPLAIN ANALYZE з тими самими параметрами і шукаю Seq Scan там, де мав бути індекс, функцію над колонкою, LIKE з відсотком на початку і розбіжність оцінених і фактичних рядків через застарілу статистику. Ще перевіряю pg_stat_statements на N+1. Після виправлення закріплюю бюджет часу в навантажувальному тесті.
🗣 How to say it out loud First I separate the query from its environment: I check pg_stat_activity to see whether it is waiting on a lock from a long transaction or a migration. If not, I take EXPLAIN ANALYZE with the same parameters and look for a Seq Scan where an index should be, a function on the column, a LIKE with a leading percent sign, and a gap between estimated and actual rows from stale statistics. I also check pg_stat_statements for N+1. After the fix I pin a time budget in a load test.