<? phpukraine PHP-ПЛАТФОРМА
⌕Пошук по платформі
БАЗИ ДАНИХ · ПРОЄКТУВАННЯ

НормалізаціяNormalization

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

17 хв читання Junior → Senior
01 · ОДИН ФАКТ, ОДНЕ МІСЦЕ

Що ламається в таблиці з дублями

Уявіть таблицю відгуків, де в кожному рядку лежить і назва вакансії, і назва компанії, і сайт компанії. Читати її зручно: один SELECT без JOIN. Але компанія змінила назву, і тепер її треба оновити в сотні рядків. Пропустили один, і в базі дві правди. Це оновлення. Є ще дві: не можна додати компанію, поки на неї ніхто не відгукнувся (аномалія вставки), і видалення останнього відгуку стирає все, що ми знали про компанію (аномалія видалення).

СЛОВНИКАномалія. Ситуація, коли звичайна операція псує дані через дублювання. Оновлення: змінили копію в одному рядку, а в інших лишилась стара. Вставка: факт не можна записати без іншого, непотрібного факту. Видалення: разом з рядком зникає факт, який був потрібен.

Усі три мають одну причину: в одній таблиці зібрано факти про різні речі. Нормалізація шукає ці речі через . Якщо за vacancy_id завжди однозначно визначається назва вакансії, то назва залежить від vacancy_id і має лежати там, де vacancy_id є ключем, тобто в таблиці vacancies.

СЛОВНИКФункціональна залежність. A → B означає: однакові значення A завжди дають однакове B. vacancy_id → title, бо в однієї вакансії одна назва. Залежність це правило предметної області, а не збіг у поточних даних: те, що зараз в усіх Олен однакова адреса, ще не залежність.

це сходинки, кожна прибирає свій вид дублювання. 1НФ: у клітинці одне значення, а не список. 2НФ: кожна неключова колонка залежить від усього складеного ключа, а не від його частини. 3НФ: неключова колонка залежить від ключа напряму, а не через іншу неключову колонку. Далі йде , а 4НФ і 5НФ трапляються переважно в підручниках. Для робочої схеми зазвичай досить 3НФ.

СЛОВНИКНормальна форма. Набір вимог до таблиці, кожна наступна суворіша. Таблиця в 3НФ автоматично в 2НФ і 1НФ. Форма описує одну таблицю, а «схема в 3НФ» означає, що в 3НФ кожна її таблиця.
СЛОВНИКBCNF. Нормальна форма Бойса й Кодда: ліва частина кожної нетривіальної залежності має бути потенційним ключем. Відрізняється від 3НФ лише тоді, коли в таблиці кілька складених потенційних ключів, що перекриваються.

Нормалізація не про швидкість. Вона про те, щоб база не могла суперечити сама собі. Ціна в JOIN при читанні, і іноді її свідомо не платять: копіюють поле, рахують лічильник заздалегідь, зберігають знімок. Це денормалізація, і в неї є правило: дубль має бути рішенням з причиною, а не випадковістю.

02 · НОРМАЛІЗАЦІЯ ПО КРОКАХ

Одна брудна таблиця розпадається на чисті

Таблиця замовлень магазину, яку зібрали «щоб усе було під рукою». На кожному кроці підсвічено те, що ще може зіпсуватися. Перемикайте кроки: таблиця ділиться за залежностями, і разом з нею зникає аномалія.

orders
id client address items
1 Олена Київ, Хрещатик 1 Мишка, Клавіатура
2 Олена Київ, Хрещатик 1 Монітор
3 Тарас Львів, Ринок 5 Мишка
АНОМАЛІЯ · СПИСОК У КЛІТИНЦІУ клітинці items два товари через кому. Знайти замовлення з мишкою можна лише через LIKE '%Мишка%', який знайде і «Мишка бездротова», а індекс тут не допоможе. Порахувати продажі товару чи повісти зовнішній ключ на товар неможливо.
order_lines
order_id product client address
1 Мишка Олена Київ, Хрещатик 1
1 Клавіатура Олена Київ, Хрещатик 1
2 Монітор Олена Київ, Хрещатик 1
3 Мишка Тарас Львів, Ринок 5
ключ (order_id, product)
1НФ · ОДНЕ ЗНАЧЕННЯ В КЛІТИНЦІКожен товар у своєму рядку, ключ тепер пара (order_id, product). Але клієнт і адреса повторюються в кожному рядку замовлення: адреса Олени тричі. Змінили її в одному рядку, а в двох інших лишилась стара. Це аномалія оновлення.
order_lines
order_id product
1 Мишка
1 Клавіатура
2 Монітор
3 Мишка
order_id → orders.id
orders
id client address
1 Олена Київ, Хрещатик 1
2 Олена Київ, Хрещатик 1
3 Тарас Львів, Ринок 5
2НФ · ЗАЛЕЖНІСТЬ ВІД УСЬОГО КЛЮЧАКлієнт і адреса залежать лише від order_id, тобто від частини ключа, тож переїхали в orders. Дублі всередині замовлення зникли, але адреса досі лежить у кожному замовленні Олени. І аномалія видалення: видалимо єдине замовлення Тараса, і його адреса зникне з бази.
order_lines
order_id product
1 Мишка
1 Клавіатура
2 Монітор
3 Мишка
order_id → orders.id
orders
id client_id
1 1
2 1
3 2
client_id → clients.id
clients
id name address
1 Олена Київ, Хрещатик 1
2 Тарас Львів, Ринок 5
3НФ · БЕЗ ТРАНЗИТИВНИХ ЗАЛЕЖНОСТЕЙАдреса залежить від клієнта, а клієнт від замовлення: id → client → address. Така залежність через посередника називається транзитивною, і 3НФ її забороняє. Клієнти отримали свою таблицю: адресу змінюють в одному рядку, клієнта можна додати без замовлення і не втратити разом з ним.
orders
id client_id ship_address
1 1 Київ, Хрещатик 1
2 1 Київ, Хрещатик 1
3 2 Львів, Ринок 5
знімок на день відправки
clients
id name address
1 Олена Одеса, Дерибасівська 3
2 Тарас Львів, Ринок 5
поточна адреса
КОЛИ ДУБЛЬ ВИПРАВДАНИЙОлена переїхала до Одеси, але замовлення 2 вже поїхало до Києва. Адресу доставки копіюють у замовлення на день відправки: це історичний факт, а не дубль, і змінюватися разом з клієнтом він не повинен. Так само відгук на цьому сайті зберігає знімок вакансії. Інші причини дублювати дані розібрано в темі «Коли денормалізувати».
Крок 1 з 5

Імена, адреси й товари це приклад. Ключ таблиці позначено, стрілка під таблицею показує зовнішній ключ.

03 · ЗНАЙДІТЬ ПОРУШЕННЯ

Сім схем з домену цього сайту

Щоб знайти порушення, випишіть залежності: що визначає кожна неключова колонка. Якщо щось визначає частина складеного ключа, це 2НФ. Якщо неключова колонка, це 3НФ. Нижче таблиці, які легко зустріти в будь-якому сайті вакансій.

ТАБЛИЦЯ І ЇЇ КОЛОНКИ
developers (id, name, skills = 'PHP, Laravel, Redis') не 1НФ Список у рядку. Розробника з Redis шукають через LIKE, перейменувати навичку можна лише текстовою заміною в кожному рядку.
developer_skills (developer_id, skill), ключ з обох 3НФ Звʼязок багато-до-багатьох окремою таблицею. Пошук розробників з Redis це WHERE skill = 'redis' з індексом.
applications (vacancy_id, email, vacancy_title, message), ключ (vacancy_id, email) не 2НФ vacancy_title залежить лише від vacancy_id, тобто від частини ключа. Назва вакансії повториться в кожному відгуку на неї.
vacancies (id, title, company_id, company_name) не 3НФ id → company_id → company_name: транзитивна залежність. Компанія перейменувалась, і треба оновити всі її вакансії.
vacancies (id, title, company_id) + companies (id, name) 3НФ Назва компанії лежить в одному рядку, вакансія тримає лише ключ. Так зроблено в цьому проєкті.
vacancies (id, title, cities = ['kyiv', 'lviv']) компроміс Формально список у клітинці. Але JSON-масив база вміє індексувати й шукати всередині, і коли список читають і пишуть лише цілком, це свідомий компроміс. Так зберігаються міста вакансій на цьому сайті.
applications (id, vacancy_id, job_snapshot) знімок Копія вакансії на день відгуку не залежить від поточної вакансії: це окремий факт про минуле. Дублем вона була б, якби її оновлювали разом з вакансією.

Залежності тут взято з предметної області: в однієї вакансії одна назва, в одного розробника багато навичок.

04 · СПИСОК У КЛІТИНЦІ

Коли список таки лежить у колонці

Сучасні бази вміють JSON, і список міст чи тегів у JSON-колонці вже не те саме, що рядок через кому: база знає, де закінчується одне значення, і може шукати всередині. Платять за це синтаксисом, який у кожної бази свій, і тим, що звичайний B-tree тут не допомагає.

Ось два запити, які сайт вакансій робить постійно: знайти вакансії у Львові й порахувати вакансії за містами. Для другого масив розгортають у рядки, тобто на льоту повертаються до 1НФ. Дані однакові в усіх трьох вкладках: три вакансії з містами ["kyiv", "lviv"], ["kyiv"] і ["odesa", "kyiv"].

MySQL 8.0 PostgreSQL 16 SQLite 3.45
1SELECT id, title FROM vacancies
2WHERE JSON_CONTAINS(cities, '"lviv"');
3
4SELECT jt.city, COUNT(*) AS n
5FROM vacancies
6CROSS JOIN JSON_TABLE(vacancies.cities, '$[*]'
7 COLUMNS (city VARCHAR(20) PATH '$')) AS jt
8GROUP BY jt.city
9ORDER BY n DESC, jt.city;
+----+-------------------+
| id | title             |
+----+-------------------+
|  1 | Laravel Developer |
+----+-------------------+
+-------+---+
| city  | n |
+-------+---+
| kyiv  | 3 |
| lviv  | 1 |
| odesa | 1 |
+-------+---+

Другий аргумент JSON_CONTAINS теж JSON, тому рядок береться в подвійні лапки всередині одинарних. JSON_TABLE зʼявилась у MySQL 8.0.4 і розгортає масив у справжню таблицю з колонками.

1SELECT id, title FROM vacancies
2WHERE cities @> '["lviv"]';
3
4SELECT city, count(*) AS n
5FROM vacancies
6CROSS JOIN jsonb_array_elements_text(vacancies.cities) AS city
7GROUP BY city
8ORDER BY n DESC, city;
 id |       title
----+-------------------
  1 | Laravel Developer
(1 row)

 city  | n
-------+---
 kyiv  | 3
 lviv  | 1
 odesa | 1
(3 rows)

@> означає «містить»: масив зліва містить усі елементи масиву справа. Саме цей оператор підхоплює GIN-індекс. jsonb_array_elements_text повертає кожен елемент окремим рядком, і далі це звичайний GROUP BY.

1SELECT id, title FROM vacancies
2WHERE EXISTS (SELECT 1 FROM json_each(vacancies.cities)
3 WHERE json_each.value = 'lviv');
4
5SELECT city_el.value AS city, count(*) AS n
6FROM vacancies
7CROSS JOIN json_each(vacancies.cities) AS city_el
8GROUP BY city_el.value
9ORDER BY n DESC, city;
+----+-------------------+
| id |       title       |
+----+-------------------+
| 1  | Laravel Developer |
+----+-------------------+
+-------+---+
| city  | n |
+-------+---+
| kyiv  | 3 |
| lviv  | 1 |
| odesa | 1 |
+-------+---+

Окремого типу JSON у SQLite немає, масив лежить у TEXT. json_each робить з нього таблицю з колонками key і value, а індексу всередину масиву SQLite не має: пошук завжди перебирає рядки.

05 · ЯК ЦЕ ІНДЕКСУВАТИ

Звичайний індекс у масив не зазирне

B-tree на колонці cities індексував би масив цілком, і пошук «є lviv серед міст» його не використав би. Потрібен індекс, у якому кожен елемент масиву має свій запис: GIN у PostgreSQL, multi-valued index у MySQL з версії 8.0.17. Нижче пошук вакансій у Вінниці на таблиці зі 100 000 вакансій, з яких 1 000 мають це місто.

без індексуSELECT id FROM vacancies WHERE JSON_CONTAINS(cities, '"vinnytsia"');
typekeyrowsExtra
ALLNULL99699Using where
multi-valuedSELECT id FROM vacancies WHERE JSON_CONTAINS(cities, '"vinnytsia"');
typekeyrowsExtra
rangevacancies_cities_index1000Using where
MEMBER OFSELECT id FROM vacancies WHERE 'vinnytsia' MEMBER OF (cities);
typekeyrowsExtra
refvacancies_cities_index1000Using where

MySQL 8.0.46, InnoDB, 100 000 рядків. Вивід знято зі справжньої бази, частину колонок прибрано.

без індексуSELECT id FROM vacancies WHERE cities @> '["vinnytsia"]';
Seq Scan on vacancies  (cost=0.00..2151.00 rows=1007 width=4) (actual time=0.029..31.081 rows=1000 loops=1)
  Filter: (cities @> '["vinnytsia"]'::jsonb)
  Rows Removed by Filter: 99000
Execution Time: 31.577 ms
GINSELECT id FROM vacancies WHERE cities @> '["vinnytsia"]';
Bitmap Heap Scan on vacancies  (cost=38.55..994.14 rows=1007 width=4) (actual time=0.223..1.434 rows=1000 loops=1)
  Recheck Cond: (cities @> '["vinnytsia"]'::jsonb)
  Heap Blocks: exact=901
  ->  Bitmap Index Scan on vacancies_cities_gin  (cost=0.00..38.30 rows=1007 width=0) (actual time=0.128..0.128 rows=1000 loops=1)
        Index Cond: (cities @> '["vinnytsia"]'::jsonb)
Execution Time: 1.493 ms

PostgreSQL 16.15, 100 000 рядків, індекс CREATE INDEX vacancies_cities_gin ON vacancies USING gin (cities), як у міграції цього сайту. Рядки Planning Time прибрано.

type
ALL це перебір усієї таблиці. З індексом range або ref: база йде по записах індексу для значення vinnytsia.
key
Індекс створено виразом: ALTER TABLE vacancies ADD INDEX vacancies_cities_index ((CAST(cities AS CHAR(20) ARRAY))). Він працює для MEMBER OF, JSON_CONTAINS і JSON_OVERLAPS.
rows
Оцінка прочитаних рядків: майже вся таблиця проти тисячі потрібних.
Seq Scan
Без індексу база розбирає JSON кожного рядка і відкидає 99 000 з них.
Bitmap Index Scan
GIN знаходить усі рядки, де в масиві є vinnytsia, і збирає їхні адреси. Далі Bitmap Heap Scan читає ці сторінки таблиці.
Recheck Cond
Умова, яку база перевіряє ще раз на самих рядках, коли бітова карта стала надто великою і памʼятає лише сторінки. Тут Heap Blocks: exact, тобто карта точна і перевіряти не довелося.
06 · У LARAVEL

Звʼязки замість копій

Нормалізована схема в Eloquent це звʼязки: модель тримає ключ, а дані іншої таблиці підтягуються окремим запитом або JOIN. JSON-колонка і знімок теж мають своє місце, і в Laravel для них є готові інструменти. Усе нижче звірено з vendor/laravel/framework 13.30.

php Developer.php Один-до-багатьох
1// vacancies.company_id → companies.id
2Schema::table('vacancies', function (Blueprint $table) {
3 $table->foreignId('company_id')->nullable()
4 ->constrained()->nullOnDelete();
5});
6
7class Vacancy extends Model
8{
9 public function company(): BelongsTo
10 {
11 return $this->belongsTo(Company::class);
12 }
13}
ЩО ПЕРЕВІРЯЄ
Вакансія зберігає лише company_id, назву бере з companies. constrained() без аргументів виводить таблицю з назви колонки: company_id означає companies.
КОЛИ БРАТИ
Майже завжди, коли в рядку зʼявляється поле, яке описує іншу сутність.
php Developer.php Багато-до-багатьох
1Schema::create('developer_skill', function (Blueprint $table) {
2 $table->foreignId('developer_id')->constrained()->cascadeOnDelete();
3 $table->foreignId('skill_id')->constrained()->cascadeOnDelete();
4 $table->primary(['developer_id', 'skill_id']);
5});
6
7public function skills(): BelongsToMany
8{
9 return $this->belongsToMany(Skill::class);
10}
ЩО ПЕРЕВІРЯЄ
Проміжна таблиця за замовчуванням називається іменами обох моделей в однині, за абеткою і через підкреслення: developer_skill. Складений первинний ключ не пустить ту саму навичку двічі.
КОЛИ БРАТИ
Коли елементи списку самі по собі сутності: їх перейменовують, рахують, шукають за ними.
php Developer.php JSON-колонка
1protected function casts(): array
2{
3 return ['cities' => 'array'];
4}
5
6Vacancy::query()->whereJsonContains('cities', 'lviv')->get();
7// MySQL: json_contains(`cities`, ?)
8// PostgreSQL: ("cities")::jsonb @> ?
9// SQLite: exists (select 1 from json_each("cities") where "json_each"."value" is ?)
ЩО ПЕРЕВІРЯЄ
Каст array перетворює JSON на масив PHP і назад. whereJsonContains() збирає для кожної бази свій запит, а значення передає параметром.
КОЛИ БРАТИ
Короткий список значень, які читають і пишуть цілком і не перейменовують: міста, теги, налаштування.
php Developer.php Знімок
1Application::query()->create([
2 'vacancy_id' => $vacancy->id,
3 'job_snapshot' => [
4 'title' => $vacancy->title,
5 'company' => $vacancy->company?->name,
6 ],
7]);
ЩО ПЕРЕВІРЯЄ
Знімок записують один раз і більше не оновлюють. Поруч лежить і ключ vacancy_id, тож поточну вакансію теж можна дістати.
КОЛИ БРАТИ
Коли важливий стан на певний день: ціна в замовленні, адреса доставки, вакансія, на яку відгукнулися.
07 · ЯК У PHP UKRAINE

Компанії, вакансії й відгуки

Схема сайту здебільшого в 3НФ: вакансія тримає company_id, а назва й сайт компанії лежать у companies в одному рядку. Відгук посилається на вакансію через vacancy_id. Відступів від чистої схеми три, і всі свідомі. applications.company_id дублює vacancies.company_id: компанію відгуку можна знайти через вакансію, але кабінет роботодавця фільтрує відгуки за company_id напряму, через індекс (company_id, status), без JOIN. Синхронізації між ними немає: ApplyToJob копіює company_id з вакансії один раз, у момент подачі, і лише для власних вакансій сайту з відомою компанією. Компанію в коді змінюють тільки вакансіям з інших дошок, а на них відгуки через сайт не приймаються, тож розійтися значенням нема звідки. job_snapshot це знімок вакансії на день відгуку. Міста й теги вакансії лежать JSON-масивами з GIN-індексами.

php 2026_09_04_000001_add_employer_cabinet.php фрагмент
1Schema::create('applications', function (Blueprint $table) {
2 $table->id();
3 $table->foreignId('vacancy_id')->constrained('vacancies')->cascadeOnDelete();← ключ вакансії
4 $table->foreignId('company_id')->constrained('companies')->cascadeOnDelete();← дубль, видно й через вакансію
5 $table->string('name', 120);
6 $table->string('email', 200);
7 $table->string('link', 500)->nullable();
8 $table->text('message');
9 $table->string('status', 20)->default('new');
10 $table->timestampTz('viewed_at')->nullable();
11 $table->timestampsTz();
12
13 $table->index(['company_id', 'status']);← кабінет роботодавця
14 $table->index(['vacancy_id', 'email']);← пошук повторного відгуку
15});
src/Hiring/Application/Employer/ApplyToJob.phphandle() бере company_id з вакансії і збирає job_snapshot: назву, компанію, зарплату, формат і рівень на момент відгуку. Зарплату в знімок не кладуть, якщо роботодавець її приховав.
database/migrations/2026_09_10_000002_add_history_to_applications.phpКолонка job_snapshot (JSON) і таблиця application_events: історія відгуку окремими рядками з ключем application_id, а не списком у колонці.
database/migrations/2026_09_02_000002_create_vacancies_table.phpcities і tags це jsonb з GIN-індексами vacancies_cities_gin і vacancies_tags_gin. Індекси й колонку search tsvector міграція створює лише на PostgreSQL.
src/Hiring/Infrastructure/Catalog/EloquentJobCatalog.phpФільтр за містом це whereJsonContains('cities', …). Лічильники міст розгортають масив: jsonb_array_elements_text на PostgreSQL і json_each на SQLite, де бігають тести.
database/migrations/2026_09_04_000008_create_market_snapshots.phpmarket_snapshots зберігає пораховані за день медіани й кількість вакансій. Їх можна було б порахувати з vacancies, але за минулі дні вакансій уже немає в тому стані: це теж знімок.
08 · ТИПОВІ ПОМИЛКИ

Де схема починає собі суперечити

✕ 1

Список через кому в текстовій колонці

skills = 'PHP, Laravel'. Пошук через LIKE знаходить зайве, перейменування навички стає текстовою заміною, а порахувати розробників з кожною навичкою можна лише в PHP.

Натомість: Окрема таблиця звʼязку (developer_skill). Якщо список справді лише цілком читають і пишуть, JSON-масив з індексом GIN чи multi-valued.

✕ 2

Копія назви «щоб не робити JOIN»

vacancies.company_name поруч з company_id. Через пів року частина вакансій показує стару назву компанії, бо одне місце оновлення забули.

Натомість: Зберігайте ключ і робіть JOIN або eager loading (with('company')). Якщо копія справді потрібна, назвіть її знімком і вирішіть, хто і коли її оновлює.

✕ 3

Плутати знімок з дублем

Відгук бере назву вакансії через JOIN. Роботодавець переписав вакансію, і кандидат бачить у своїй історії відгук на те, на що він не відгукувався.

Натомість: Те, що має лишитися як було, копіюйте в момент події: адресу доставки, ціну, вакансію на день відгуку.

✕ 4

Денормалізувати без механізму синхронізації

Лічильник відгуків у vacancies збільшують у контролері, а видалення відгуку його не зменшує. Через місяць цифра на сторінці не збігається з COUNT(*).

Натомість: Оновлюйте копію в тій самій транзакції, що й оригінал, або перераховуйте її задачею. І тримайте запит, який знаходить розбіжності.

✕ 5

Нормалізувати те, що для вас атомарне

Адресу розбили на таблиці країн, міст, вулиць і будинків, хоча сайт лише показує її одним рядком. Кожна сторінка тепер робить пʼять JOIN.

Натомість: Атомарність визначає ваша предметна область. Якщо за частиною значення не шукають і не групують, воно може лишитися одним рядком.

09 · СЛОВНИК
Аномалія
Ситуація, коли звичайна операція псує дані через дублювання. Оновлення: змінили копію в одному рядку, а в інших лишилась стара. Вставка: факт не можна записати без іншого, непотрібного факту. Видалення: разом з рядком зникає факт, який був потрібен.
Функціональна залежність
A → B означає: однакові значення A завжди дають однакове B. vacancy_id → title, бо в однієї вакансії одна назва. Залежність це правило предметної області, а не збіг у поточних даних: те, що зараз в усіх Олен однакова адреса, ще не залежність.
Нормальна форма
Набір вимог до таблиці, кожна наступна суворіша. Таблиця в 3НФ автоматично в 2НФ і 1НФ. Форма описує одну таблицю, а «схема в 3НФ» означає, що в 3НФ кожна її таблиця.
BCNF
Нормальна форма Бойса й Кодда: ліва частина кожної нетривіальної залежності має бути потенційним ключем. Відрізняється від 3НФ лише тоді, коли в таблиці кілька складених потенційних ключів, що перекриваються.
10 · ПЕРЕВІР СЕБЕ

Питання зі співбесід

Усі питання з SQL →

Той самий факт записано в кількох рядках, і UPDATE змінив лише частину копій. Наприклад, назва компанії лежить у кожній її вакансії, а перейменування зачепило не всі. Береться вона з того, що таблиця зберігає факти про дві різні сутності. Нормалізація виносить факт туди, де він залежить лише від свого ключа.

2НФ: vacancy_title залежить лише від vacancy_id, тобто від частини складеного ключа. Назва повторюється в кожному відгуку на вакансію. Колонку прибирають, а назву беруть з vacancies через vacancy_id. Якщо ж потрібна назва на день відгуку, це вже знімок, і його так і називають.

Спершу подивитися EXPLAIN: найчастіше бракує індексу на колонках зʼєднання, особливо на зовнішніх ключах у PostgreSQL, де їх не створюють самі. Денормалізувати варто, коли читань набагато більше, ніж записів, план уже хороший, а виміряна ціна JOIN завелика. І тоді треба одразу вирішити, хто оновлює копію: та сама транзакція, тригер чи фонова задача.

3НФ дозволяє залежність, у якій права частина є частиною потенційного ключа. BCNF вимагає, щоб ліва частина кожної залежності була потенційним ключем. Приклад: mentoring (developer, skill, mentor), де кожен ментор веде одну навичку (mentor → skill), а розробник з кожної навички має одного ментора. Ключі (developer, skill) і (developer, mentor). skill входить у ключ, тож 3НФ дотримано, але mentor не ключ, і факт «ментор веде навичку» повторюється для кожного його учня. Розклад на (mentor, skill) і (developer, mentor) дає BCNF, але правило «один ментор на навичку» тоді вже не перевірити унікальним ключем однієї таблиці.