<? phpukraine СПІВБЕСІДИ
⌕Пошук по платформі
SQL · MIDDLE ЧАСТО ПИТАЮТЬ

Чим PostgreSQL відрізняється від MySQL на практиці для PHP-розробника?

PostgreSQL дає транзакційний DDL, багату систему типів (масиви, діапазони, справжні ENUM-типи), суворі правила GROUP BY і порівняння типів, RETURNING та екосистему розширень; MySQL фіксує кожен DDL негайно, мовчки приводить типи й порівнює рядки без урахування регістру.

Переїжджаємо з MySQL на PostgreSQL. Що зламається в коді й міграціях?
Чому міграція, що впала посередині, на MySQL залишила пів-схеми, а на PostgreSQL відкотилась повністю?
Запит із GROUP BY працював на MySQL і падає на PostgreSQL. Чому?
Що візьмете для нового проєкту і за яких умов це взагалі має значення?
PostgreSQL MySQL типи даних міграції RETURNING

Різниця, яку відчуваєш щодня, починається з міграцій. У PostgreSQL CREATE TABLE, ALTER TABLE, DROP INDEX і навіть CREATE TYPE виконуються всередині транзакції, тому міграція з п'яти кроків або застосовується повністю, або не застосовується зовсім. Laravel це враховує: Migrator обгортає міграцію в транзакцію лише тоді, коли граматика схеми повертає supportsSchemaTransactions() === true, а це pgsql і sqlite, але не mysql. На MySQL кожен DDL-оператор робить implicit commit відкритої транзакції, тож якщо впав третій ALTER TABLE, перші два залишились у схемі, а запис у таблиці migrations так і не з'явився. Версія 8.0 принесла atomic DDL, і окремий оператор більше не залишає після краху сервера напіврозібрану таблицю, проте відкотити виконаний DDL командою ROLLBACK не вийде й там. Звідси звичка тримати міграції на MySQL короткими та ідемпотентними: чистити доведеться руками.

Система типів у PostgreSQL дозволяє описати в схемі те, що в MySQL осідає в JSON або в додаткових таблицях. Масив text[] з GIN-індексом і оператором @> закриває теги й стек технологій. int4range і daterange зберігають інтервал одним значенням, мають оператори перетину && та вкладення, а разом з EXCLUDE USING gist (плюс розширення btree_gist) дають обмеження «два бронювання однієї переговорки не можуть перетинатися в часі», яке неможливо обійти жодним застосунком. ENUM тут окремий тип через CREATE TYPE, і його перевикористовують кілька колонок. У MySQL ENUM належить колонці, зберігається як індекс у списку значень, і переставити порядок означає переписати дані. Пастка для тих, хто мігрує: $table->enum() у Laravel на pgsql не створює тип, а генерує varchar(255) з check-обмеженням, тому реальні типи заводять через DB::statement.

PostgreSQL суворіший там, де MySQL прощає. Кожна неагрегована колонка в SELECT має бути або в GROUP BY, або функційно залежати від згрупованого первинного ключа (це вміння з'явилось у 9.1), тому групування по v.id автоматично легалізує v.title та v.status. MySQL до 5.7.5 просто повертав довільний рядок групи; ONLY_FULL_GROUP_BY тепер у sql_mode за умовчанням, але його регулярно вимикають, і тоді запит «працює», видаючи неузгоджені дані. З типами те саме: MySQL порівнює varchar-колонку з числом, зводячи обидва боки до double, через що індекс не використовується, а '12abc' = 12 повертає true; PostgreSQL відповідає operator does not exist: character varying = integer. Додайте до цього колацію: типова utf8mb4_0900_ai_ci робить порівняння нечутливим до регістру й діакритики, тому WHERE email = 'Ivan@Example.com' на MySQL знайде рядок, а на PostgreSQL ні, поки ви не візьмете citext або виразний індекс по lower(email).

У коді на PHP найчастіше бракує саме RETURNING. Він працює для INSERT, UPDATE і DELETE, тому UPDATE accounts SET balance = balance - 100 WHERE id = 1 RETURNING balance віддає нове значення без другого запиту й без гонки, а UPDATE ... WHERE id IN (SELECT ... FOR UPDATE SKIP LOCKED) RETURNING * атомарно забирає порцію задач із черги. У MySQL 8 такої конструкції немає (в MariaDB INSERT ... RETURNING є з 10.5), а LAST_INSERT_ID() після багаторядкової вставки повертає id лише першого рядка. Поруч стоїть екосистема розширень: pg_trgm робить LIKE '%текст%' індексованим, unaccent знімає діакритику в пошуку, pg_stat_statements показує найдорожчі запити, pgvector додає тип для embeddings. І типи індексів, яких у MySQL немає: GIN, GiST, BRIN, часткові та виразні індекси.

Зворотний бік теж є. PostgreSQL виділяє окремий процес на кожне з'єднання, тому PHP-FPM із сотнями воркерів чи Octane вимагають PgBouncer, а transaction-режим пулера ламає серверні prepared statements, advisory locks і LISTEN/NOTIFY (PgBouncer відстежує prepared statements лише з 1.21). MVCC-роздування потребує уваги до autovacuum, послідовності дають дірки в id, бо nextval не відкочується. MySQL натомість простіший у реплікації, стоїть на будь-якому хостингу й залишається єдиним варіантом для WordPress та купи легасі. На питання «що брати» відповідайте так: PostgreSQL для нового продукту з непростою предметною областю, бо транзакційний DDL, типи й суворість ловлять помилки раніше, і одразу уточніть, що працюючий проєкт на MySQL 8 переносять, коли впираються в конкретну відсутню можливість, а не з принципу.

-- PostgreSQL: DDL у транзакції, при помилці не залишиться ні типу, ні таблиці
BEGIN;

CREATE TYPE vacancy_status AS ENUM ('draft', 'published', 'archived');

CREATE TABLE vacancies (
    id           bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
    status       vacancy_status NOT NULL DEFAULT 'draft',
    stack        text[] NOT NULL DEFAULT '{}',  -- масив замість таблиці-довідника
    salary       int4range,                     -- «від-до» одним полем, з операторами
    published_at timestamptz
);

-- GIN по масиву: пошук «усі вакансії зі Laravel у стеку» без join-ів
CREATE INDEX vacancies_stack ON vacancies USING gin (stack);

-- Частковий індекс: у дерево потрапляють лише опубліковані рядки
CREATE INDEX vacancies_published ON vacancies (published_at DESC)
    WHERE status = 'published';

COMMIT;  -- ROLLBACK тут скасував би все; у MySQL CREATE TABLE уже був би зафіксований

-- RETURNING: id і серверний дефолт одним запитом, без LAST_INSERT_ID()
INSERT INTO vacancies (status, stack, salary, published_at)
VALUES ('published', '{Laravel,PostgreSQL}', int4range(2500, 4000), now())
RETURNING id, published_at;

-- @> - масив містить елемент, && - діапазони перетинаються
SELECT id
FROM vacancies
WHERE stack @> ARRAY['Laravel'] AND salary && int4range(3000, 3500);

-- Суворий GROUP BY: id у GROUP BY робить title функційно залежним і дозволеним
SELECT v.id, v.status, count(a.id) AS applications
FROM vacancies v LEFT JOIN applications a ON a.vacancy_id = v.id
GROUP BY v.id;
Що в PostgreSQL DDL живе в транзакції: невдала міграція відкочується цілком, тому Laravel обгортає міграції в транзакцію лише там, де `supportsSchemaTransactions` повертає true (pgsql, sqlite), а для MySQL не обгортає.
Що MySQL 8.0 має atomic DDL (окремий оператор не залишає схему напівзміненою після збою), але DDL усе одно робить implicit commit і відкотити його не можна.
Конкретні типи, яких у MySQL немає: `text[]`, `int4range`/`daterange`, `CREATE TYPE ... AS ENUM`, `citext`, `inet`, і що MySQL ENUM - це властивість колонки зі зберіганням значення як індексу в списку.
Що `ONLY_FULL_GROUP_BY` увімкнений у MySQL за умовчанням з 5.7.5, а до того запит повертав довільний рядок групи; PostgreSQL вимагає або агрегат, або функційну залежність від згрупованого первинного ключа.
Що MySQL приводить типи неявно (`WHERE phone = 380441234567` по varchar-колонці вбиває індекс, `'12abc' = 12` дає true), а PostgreSQL кидає `operator does not exist`.
Що `RETURNING` економить round-trip і працює для INSERT, UPDATE, DELETE, тоді як у MySQL 8 його немає взагалі, а `LAST_INSERT_ID()` для багаторядкової вставки віддає лише перший id.
Вважати, що міграції на MySQL теж транзакційні, і писати в одній міграції п'ять `ALTER TABLE` без плану, що робити, коли впаде третій.
Переносити схему з MySQL у PostgreSQL через `$table->enum()` і думати, що отримали тип: у pgsql-грамматиці Laravel це `varchar(255)` з `check`-обмеженням, а не `CREATE TYPE`.
Писати `SELECT user_id, name, COUNT(*) ... GROUP BY user_id` і дивуватись помилці на PostgreSQL замість того, щоб згрупувати по первинному ключу або взяти агрегат.
Покладатись на нечутливе до регістру порівняння: `WHERE email = 'Ivan@Example.com'` знайде рядок у MySQL з `utf8mb4_0900_ai_ci` і не знайде в PostgreSQL.
Казати «PostgreSQL просто кращий» без жодного технічного аргументу або, навпаки, тягнути масиви й діапазони в схему там, де нормальна таблиця зв'язків простіша.
Забути, що в PostgreSQL кожне з'єднання - це окремий процес, і поставити 300 PHP-FPM воркерів проти `max_connections = 100` без PgBouncer.
ПОРАДА

Назвіть одну річ, яка змінює щоденну роботу: транзакційний DDL. Далі один тип, який ви реально використовували (`daterange` з `EXCLUDE`, `text[]` з GIN), і одну пастку міграції (неявне приведення типів у MySQL, якого PostgreSQL не прощає). Три конкретні факти переконують більше, ніж список із двадцяти пунктів.

оновлено 3 жовтня 2026 · ліцензія CC-BY-SA-4.0 Знайшли неточність? Напишіть →
ПЕРЕВІРТЕ СЕБЕ

MySQL 8.0 додав atomic DDL, тобто окремий оператор не залишає схему напівзміненою після збою, але кожен DDL усе одно фіксує відкриту транзакцію. У PostgreSQL DDL бере участь у транзакції нарівні з DML, тому Laravel обгортає pgsql-міграції в транзакцію, а mysql-міграції ні.