Що база погодиться зберегти
Тип колонки працює як найдешевша перевірка в системі: він відкидає неправильне значення ще до того, як воно стане частиною даних. Невдалий тип відкидає не те. FLOAT спокійно приймає гроші й губить копійки, VARCHAR(5) у SQLite приймає рядок на сто символів, а TIMESTAMP у MySQL працює рівно до 19 січня 2038 року.
зберігають двійковий дріб. 0.1 у двійковому записі нескінченний, як 1/3 у десятковому, тож у колонці лежить найближче наближення, і похибки накопичуються в сумах. зберігає десяткові цифри точно, і PHP отримує його рядком, а не float.
MySQL і PostgreSQL перевіряють тип строго: MySQL у , який увімкнений за замовчуванням, PostgreSQL завжди. SQLite має : оголошений тип лише радить, як зберігати, і в колонку INTEGER ляже текст 'yes'. Тому тести на SQLite пропускають значення, на яких продакшн на PostgreSQL впаде.
Колонка і тип під неї
Майже кожна погана схема зводиться до кількох рішень, ухвалених на автопілоті. Нижче ці рішення для типових колонок сайту вакансій і що з кожним стається на справжніх даних.
amount FLOAT
наближений
Десять платежів по 0.1 у FLOAT MySQL дають 1.0000000149011612, а WHERE amount = 0.1 не знаходить жодного з них.
amount DECIMAL(12, 2)
точний
Сума десяти по 0.10 рівно 1.00. У PHP приходить рядок, рахувати його далі варто через bcmath або brick/math, а не через float.
amount_cents BIGINT
ціле
Копійки цілим числом: додавання завжди точне, а PHP отримує int. Валюта окремою колонкою, і ділити на 100 треба лише при показі.
status ENUM('draft', 'active', 'closed')
жорсткий
Компактно і з перевіркою, але новий статус це ALTER TABLE, ORDER BY іде за позицією в списку, а не за абеткою, і прибрати значення з ENUM у PostgreSQL неможливо.
status VARCHAR(20) + PHP enum
гнучкий
Допустимі значення живуть у backed enum PHP і cast у моделі, за потреби плюс CHECK у базі. Новий статус не вимагає міграції схеми.
is_test BOOLEAN (MySQL)
це TINYINT(1)
MySQL перетворює BOOLEAN на TINYINT(1) і приймає 5. Для такого рядка is_test = TRUE хибне, а is_test IS TRUE істинне.
published_at TIMESTAMP (MySQL)
до 2038
Зберігається в UTC і показується в поясі сесії, але '2038-01-19 03:14:08' UTC уже не вставити. timestampTz() у міграції Laravel на MySQL створює саме цей тип.
published_at timestamptz (PostgreSQL)
момент часу
Однозначний момент у часі, показ у будь-якому поясі. Межа далеко за 2038 роком.
payload JSON (PostgreSQL)
лише текст
Зберігає текст як є, з дублями ключів. Немає оператора =, GIN-індексу і @>. Для пошуку всередині документа беріть JSONB.
Кожен рядок перевірено на MySQL 8.0.46 і PostgreSQL 16; виводи нижче.
Десять платежів по 0.1
Таблиця payments, десять рядків, у кожному 0.1 у трьох колонках: FLOAT, DOUBLE PRECISION і DECIMAL(12, 2). Сума мала б бути 1.
У MySQL FLOAT це 4 байти і близько 7 значущих цифр, тому похибку видно одразу, а пошук на рівність промахується. У PostgreSQL FLOAT без точності означає DOUBLE PRECISION. PHP поводиться так само: 0.1 + 0.2 == 0.3 дає false.
+--------------------+--------------------+---------------------+ | SUM(amount_float) | SUM(amount_double) | SUM(amount_decimal) | +--------------------+--------------------+---------------------+ | 1.0000000149011612 | 0.9999999999999999 | 1.00 | +--------------------+--------------------+---------------------+ COUNT(*): 0
Літерал 0.1 у WHERE MySQL порівнює з FLOAT як число подвійної точності, а в колонці лежить одинарне наближення 0.1, тож рівності немає. DOUBLE знайде всі десять рядків, але сума все одно не 1.
sum | sum | sum
--------------------+--------------------+------
0.9999999999999999 | 0.9999999999999999 | 1.00
float | numeric
---------------------+---------
0.30000000000000004 | 0.3 Тут FLOAT і DOUBLE PRECISION однакові, обидва float8. Точний лише NUMERIC.
┌──────┬───────┐ │ type │ equal │ ├──────┼───────┤ │ real │ 0 │ └──────┴───────┘
Точного десяткового типу в SQLite немає: DECIMAL отримує NUMERIC affinity, і 0.10 лягає як REAL. Якщо тести ганяються на SQLite, а гроші в DECIMAL, перевірки сум у тестах і на продакшні можуть розійтися. Копійки цілим числом поводяться однаково всюди.
Довжина, індекс і хто її перевіряє
У PostgreSQL VARCHAR(n) і TEXT зберігаються однаково і працюють однаково швидко; n лише додає перевірку довжини. Тож вибір між ними це питання правила для даних, а не продуктивності. CHAR(n) доповнює значення пробілами до n і майже завжди гірший за обидва.
У MySQL різниця є. TEXT не можна проіндексувати повністю, лише префікс заданої довжини. Ключ індексу InnoDB обмежений 3072 байтами, а utf8mb4 рахує по 4 байти на символ, тож повністю індексується VARCHAR(768) і коротший. Звідси Schema::defaultStringLength(191) у старих проєктах: так лізли в старий ліміт 767 байтів.
ERROR 1406 (22001): Data too long for column 'name' at row 1 ERROR 1170 (42000): BLOB/TEXT column 'bio' used in key specification without a key length ERROR 1071 (42000): Specified key was too long; max key length is 3072 bytes -- url(191): індекс лише за першими 191 символами, Sub_part = 191
Префіксний індекс не може бути покриваючим і не допомагає ORDER BY url. Без strict mode перша вставка обрізала б рядок до 'Kvitk' з попередженням.
ERROR: value too long for type character varying(5) -- CREATE INDEX на TEXT пройшов ERROR: index row size 3216 exceeds btree version 4 maximum 2704 for index "c_bio_index" HINT: Values larger than 1/3 of a buffer page cannot be indexed.
Межа B-tree в PostgreSQL це розмір значення, а не оголошений тип: близько 2,7 KB на запис індексу. Довгі тексти індексують хешем (індекс за виразом md5(bio)) або повнотекстовим пошуком.
Kvitka Soft|11
Довжину у VARCHAR(5) SQLite не перевіряє взагалі: тип дає лише TEXT affinity. Валідація довжини має бути в застосунку, інакше тест на SQLite пропустить те, що впаде на PostgreSQL.
Межі, переповнення і TINYINT(1)
INT вміщує до 2 147 483 647, UNSIGNED удвічі більше, BIGINT до 9,2 квінтильйона. Лічильник переглядів чи автоінкремент на INT може дійти до межі, і тоді вставка падає. Тому id() у Laravel це BIGINT.
Окремого булевого типу в MySQL немає: BOOLEAN це синонім TINYINT(1), і туди ляже будь-яке число до 127. У PostgreSQL boolean справжній і число 5 не прийме. У SQLite булевих немає, TRUE і FALSE це 1 і 0.
ERROR 1264 (22003): Out of range value for column 'delta' at row 1 +---------+----------------+-----------------+ | is_test | is_test = TRUE | is_test IS TRUE | +---------+----------------+-----------------+ | 5 | 0 | 1 | +---------+----------------+-----------------+ ERROR 1690 (22003): BIGINT UNSIGNED value is out of range in '(`lab`.`n`.`views` - 1)'
Віднімання з UNSIGNED, яке йде нижче нуля, падає навіть посеред виразу. Автоінкремент на INT, що дійшов до 2147483647, наступною вставкою дає Duplicate entry '2147483647' for key PRIMARY.
ERROR: integer out of range ERROR: column "is_test" is of type boolean but expression is of type integer is_test --------- t
Беззнакових цілих у PostgreSQL немає: unsignedInteger() у Laravel створює звичайний integer, і межа в нього та сама 2 147 483 647. Рядки 'yes', 'on', '1' boolean приймає.
┌─────────────────────┬───────────────┬─────────┬─────────────────┐ │ delta │ typeof(delta) │ is_test │ typeof(is_test) │ ├─────────────────────┼───────────────┼─────────┼─────────────────┤ │ 2147483648 │ integer │ 5 │ integer │ │ 9223372036854775807 │ integer │ yes │ text │ └─────────────────────┴───────────────┴─────────┴─────────────────┘
Ціле в SQLite завжди до 8 байтів, хоч би як колонка була оголошена, а в «булеву» колонку ліг текст. На продакшні обидва рядки впали б.
DATETIME, TIMESTAMP і timestamptz
У MySQL DATETIME зберігає «стінний» час без поясу: що записали, те й прочитаєте. TIMESTAMP переводить значення з поясу сесії в UTC при записі і назад при читанні, тому той самий рядок у різних сесіях показується по-різному. Ціна TIMESTAMP: 4 байти секунд від 1970 року, і останній момент це 2038-01-19 03:14:07 UTC.
У PostgreSQL timestamp це стінний час, timestamptz це момент у часі, збережений в UTC. Для подій (створено, опубліковано, переглянуто) майже завжди потрібен саме момент. Laravel пише timestampTz() як timestamptz у PostgreSQL і як звичайний TIMESTAMP у MySQL.
+---------------------+---------------------+ | as_datetime | as_timestamp | +---------------------+---------------------+ | 2026-10-08 12:00:00 | 2026-10-08 15:00:00 | +---------------------+---------------------+ ERROR 1292 (22007): Incorrect datetime value: '2038-01-19 03:14:08' for column 'as_timestamp' at row 1
DATETIME у ту саму мить показав 12:00, TIMESTAMP 15:00. Якщо застосунок і сервер бази живуть у різних поясах, різниця тихо зʼявляється в даних. DATETIME іде до 9999 року.
plain | with_tz ---------------------+------------------------ 2026-10-08 12:00:00 | 2026-10-08 09:00:00+00
with_tz зрозумів, що 12:00 це київський час, і зберіг момент. plain лишився 12:00 без жодного поясу, і чий це полудень, база вже не знає.
┌───────────────────────────┬────────────────────┬─────────────────────┐ │ created_at │ typeof(created_at) │ utc │ ├───────────────────────────┼────────────────────┼─────────────────────┤ │ 2026-10-08 12:00:00+03:00 │ text │ 2026-10-08 09:00:00 │ │ 1791460800 │ integer │ │ └───────────────────────────┴────────────────────┴─────────────────────┘
Типу дати в SQLite немає: це текст, число чи REAL, а функції дат розбирають текст ISO 8601. Число як Unix-час читає лише datetime(x, 'unixepoch'). Laravel пише дати текстом 'Y-m-d H:i:s', і порівняння рядків тоді збігається з порівнянням дат.
Текст чи розібраний документ
У PostgreSQL json зберігає текст як є: пробіли, порядок ключів і навіть дублі. jsonb розбирає документ у двійковий вигляд: лишає останнє значення дубля, впорядковує ключі, і на ньому працюють GIN-індекс, оператор @> і порівняння. Для майже всього, що потрібно запитувати, беріть jsonb.
У MySQL JSON один, і він уже двійковий, ближчий до jsonb. Індексують його через згенеровану колонку або багатозначний індекс на масив (MySQL 8.0.17+). SQLite зберігає JSON текстом і з версії 3.45 має функції jsonb для двійкового формату.
{"title": "Senior PHP", "tags": [1, 2], "title": "Lead"}
{"tags": [1, 2], "title": "Lead"}
ERROR: data type json has no default operator class for access method "gin"
t json навіть не має оператора =: SELECT a = a падає з operator does not exist: json = json. Тому DISTINCT і GROUP BY по json-колонці теж не працюють.
{"tags": [1, 2], "title": "Lead"} Як jsonb: дубль ключа відкинуто, лишилось останнє значення, ключі впорядковані. Метод jsonb() у міграції Laravel на MySQL створює той самий json.
{"title":"Senior PHP","tags":[1,2],"title":"Lead"} json() лише мінімізує текст і дублі не чіпає. json() і jsonb() у міграції Laravel на SQLite створюють колонку text.
Список значень, який важко змінити
ENUM у MySQL зберігає номер значення в списку, тож займає 1-2 байти і не прийме незнайоме значення. Пастки в іншому: сортує за номером, а не за абеткою; додати значення в кінець можна швидко, а вставити в середину означає перебудувати таблицю.
У PostgreSQL ENUM це окремий тип. Значення додаються через ALTER TYPE … ADD VALUE, а прибрати значення не можна взагалі, лише перестворити тип. Laravel цього не використовує: enum() у міграції на PostgreSQL і SQLite створює varchar з CHECK.
+----+--------+----------+ | id | status | position | +----+--------+----------+ | 3 | draft | 1 | | 2 | active | 2 | | 1 | closed | 3 | +----+--------+----------+ ERROR 1265 (01000): Data truncated for column 'status' at row 1 ERROR 1846 (0A000): ALGORITHM=INSTANT is not supported. Reason: Need to rebuild the table to change column type.
Додати 'archived' в кінець списку з ALGORITHM=INSTANT вдалося миттєво. Вставка 'moderation' в середину змінює номери, і MySQL переписує всю таблицю.
ERROR: invalid input value for enum vacancy_status: "archived" -- ADD VALUE пройшов ERROR: syntax error at or near "VALUE"
DROP VALUE не існує. Прибрати значення означає створити новий тип, перевести колонку на нього і видалити старий.
Що створює кожен метод на кожному драйвері
Схема в міграції одна, а SQL граматика драйвера пише свій. Коментарі нижче взято з граматик laravel/framework 13 для MySQL, PostgreSQL і SQLite і перевірено через migrate --pretend.
Типи таблиці вакансій
Міграція vacancies показує кілька свідомих рішень. Зарплата лежить цілими числами в одиницях валюти вакансії плюс тризначний код валюти, а поруч ті самі межі в доларах, теж цілими: за ними сайт фільтрує й рахує медіани, і жодного FLOAT. Статус, рівень і формат це string(20), а не enum(): допустимі значення живуть у PHP enum домену (Framework, Level тощо), і новий рівень не вимагає змінювати схему. Усі моменти часу записані timestampTz(), бо продакшн на PostgreSQL і там це timestamptz. Масиви міст і тегів це jsonb з GIN-індексами, які міграція додає лише на PostgreSQL.
database/migrations/2026_09_04_000008_create_market_snapshots.phpЩоденні знімки ринку тримають медіану й квартилі зарплат у integer median_usd, p25_usd, p75_usd: долари без центів, цілими числами.database/migrations/2026_09_04_000001_add_employer_cabinet.phpcompanies.hiring це json з default('{}'), а applications.created_at і viewed_at це timestampTz.database/migrations/2026_09_10_000009_add_test_accounts.phpПрапорець is_test через boolean()->default(false): на PostgreSQL boolean, на SQLite у тестах tinyint(1).Де тип підводить
Гроші у FLOAT чи double
Суми розходяться на копійку, рівність не знаходить рядків, звіт за місяць не сходиться з банком. У PHP те саме: 0.1 + 0.2 дає 0.30000000000000004.
Натомість: DECIMAL з достатньою кількістю цифр або цілі копійки в BIGINT. У PHP рахувати рядками через bcmath чи brick/math або в цілих копійках.
Тести на SQLite, продакшн на PostgreSQL
SQLite не перевіряє довжину VARCHAR, ціле в boolean, текст у integer і зберігає DECIMAL як REAL. Зелений тест не означає, що значення влізе в колонку на продакшні.
Натомість: Валідація довжини й формату в застосунку, а тести, що залежать від типів, на тому ж рушії, що й продакшн, хоча б у CI.
timestamp у MySQL для дат у майбутньому
Дата закінчення підписки чи гарантії на 15 років уперед не вставиться в TIMESTAMP: межа 2038-01-19. timestampTz() у Laravel на MySQL це той самий TIMESTAMP.
Натомість: dateTime() для дат, що можуть вийти за 2038 рік, і домовленість зберігати UTC.
Час без поясу
DATETIME чи timestamp без tz пишуть те, що дав застосунок. Змінили часовий пояс сервера чи config('app.timezone'), і старі та нові рядки означають різне.
Натомість: timestamptz у PostgreSQL; у MySQL UTC в app.timezone і в з’єднанні, а переведення в київський час лише при показі.
ENUM для того, що росте
Статуси й категорії додаються з кожною фічею. Кожне нове значення в ENUM це міграція схеми, а прибрати значення в PostgreSQL неможливо.
Натомість: string() плюс backed enum PHP у cast моделі, за потреби CHECK. ENUM лишити для справді незмінних наборів.
- FLOAT і DOUBLE
- Числа з рухомою комою за IEEE 754: 4 і 8 байтів, близько 7 і 15-17 значущих десяткових цифр. Швидкі й придатні для вимірювань, координат, оцінок. Непридатні для грошей і всього, що порівнюють на рівність.
- DECIMAL
- DECIMAL(12, 2) чи NUMERIC: точне десяткове число, тут 12 цифр, з них 2 після коми. В PostgreSQL DECIMAL і NUMERIC це один тип. PDO віддає його в PHP рядком '0.10', щоб не втратити точність.
- Strict mode
- sql_mode зі STRICT_TRANS_TABLES: MySQL відкидає значення, яке не влазить у колонку, з помилкою. Без нього обрізає рядок чи число до межі й лише пише попередження. Увімкнений за замовчуванням з MySQL 5.7, Laravel теж вмикає його в config/database.php ('strict' => true).
- Type affinity
- У SQLite тип належить значенню, а не колонці. Оголошений тип колонки (INTEGER, TEXT, NUMERIC, REAL) лише підказує, у що спробувати перетворити значення. Не вийшло перетворити, значення збережеться як є. STRICT-таблиці з SQLite 3.37 це вимикають.
- timestamptz
- timestamp with time zone у PostgreSQL. Попри назву, часового поясу не зберігає: на вході переводить момент в UTC, на виході показує в поясі сесії (TimeZone). timestamp без tz зберігає «стінний» час без поясу.
Питання зі співбесід
FLOAT і DOUBLE зберігають двійкове наближення, і 0.1 у них не дорівнює рівно 0.1. Похибки накопичуються в сумах і ламають порівняння на рівність: десять по 0.1 у FLOAT MySQL дають 1.0000000149011612. Для грошей беруть DECIMAL або цілі копійки.
У PostgreSQL продуктивність однакова, VARCHAR(n) лише додає перевірку довжини, тож питання в тому, чи є в даних справжня межа. У MySQL TEXT не індексується повністю, лише префіксом, і має інші обмеження, тож для того, що індексують і сортують, беруть VARCHAR з реальною довжиною, а довгий опис кладуть у TEXT.
DATETIME зберігає значення як є, без поясу, до 9999 року. TIMESTAMP переводить з поясу сесії в UTC і назад, займає 4 байти і закінчується 2038-01-19 03:14:07 UTC. timestampTz() на MySQL створює звичайний TIMESTAMP, тож обмеження 2038 року стосується і його. На PostgreSQL той самий метод дає timestamptz.
Перевести колонку на jsonb: ALTER TABLE … ALTER COLUMN tags TYPE jsonb USING tags::jsonb, а на великій таблиці через нову колонку і поступове копіювання, бо зміна типу переписує таблицю під блокуванням. Потім GIN-індекс і запит через оператор @>, який цей індекс використовує. На json ні GIN, ні @> не працюють.