PostgreSQL и MySQL — зрелые реляционные СУБД с транзакциями и репликацией, и для типичного веб-приложения подойдёт любая. Различия проявляются в деталях: по умолчанию PostgreSQL работает в Read Committed, а InnoDB в MySQL — в Repeatable Read; MySQL сравнивает строки без учёта регистра и не откатывает CREATE TABLE; у PostgreSQL богаче типы, jsonb с индексами GIN и механизм расширений. Выбирайте PostgreSQL для сложной модели данных, аналитики и расширений вроде PostGIS или pgvector, MySQL — когда его требует стек (например, WordPress) или он уже есть у команды и хостинга.
После материала вы сможете
- назвать практические различия PostgreSQL и MySQL: сравнение строк, типы, DDL в транзакции,
RETURNING, JSON и индексы, расширения - объяснить, чем Read Committed в PostgreSQL отличается от Repeatable Read в MySQL, и показать разницу на двух сессиях
- предсказать, какой SQL поведёт себя в двух СУБД по-разному, и переписать его переносимо
- выбрать СУБД под проект по таблице «когда что» с учётом хостинга и экосистемы
Что знать заранее
Проверено на
PostgreSQL 17.11MySQL 8.4.11 LTS
Сверено с документацией: 11 октября 2026 г.
Содержание
Общая база и версии
Обе СУБД реляционные: таблицы, SQL, внешние ключи, транзакции, репликация, управляемые сервисы у крупных облаков. Поэтому вопрос «какая лучше» без контекста не имеет ответа. Полезнее знать, где они ведут себя по-разному: именно эти места ломают перенос приложения и удивляют разработчика, привыкшего к другой базе.
Версии на дату проверки. У PostgreSQL каждая мажорная версия поддерживается пять лет: актуальная — 18, а 17, на которой проверены примеры, поддерживается до ноября 2029 года. У MySQL две линии выпусков: LTS (8.4) получает только исправления, без удаления функций, и следует политике Oracle Lifetime Support — пять лет основной и три года расширенной поддержки; Innovation-выпуски приносят новые возможности и поддерживаются до следующего выпуска. Документация MySQL уже описывает следующую LTS-линию 9.7 и переход между соседними LTS без пропуска.
Изоляция по умолчанию
- PostgreSQL 17
- Read Committed
- MySQL 8.4 (InnoDB)
- Repeatable Read
Сравнение строк по умолчанию
- PostgreSQL 17
- С учётом регистра
- MySQL 8.4 (InnoDB)
- Без учёта регистра и диакритики (
utf8mb4_0900_ai_ci)
CREATE TABLE внутри транзакции
- PostgreSQL 17
- Откатывается вместе с транзакцией
- MySQL 8.4 (InnoDB)
- Неявный
COMMIT, откатить нельзя
Вернуть вставленную строку
- PostgreSQL 17
INSERT … RETURNING- MySQL 8.4 (InnoDB)
- Нет
RETURNING; для автоинкремента —LAST_INSERT_ID()
7 / 2
- PostgreSQL 17
3(целочисленное деление)- MySQL 8.4 (InnoDB)
3.5000
||
- PostgreSQL 17
- Склейка строк
- MySQL 8.4 (InnoDB)
- Логическое
OR(устаревший синоним)
boolean
- PostgreSQL 17
- Отдельный тип:
t/f - MySQL 8.4 (InnoDB)
- Синоним
TINYINT(1):1/0
JSON
- PostgreSQL 17
jsonb+ индекс GIN на весь документ- MySQL 8.4 (InnoDB)
JSON+ индекс на сгенерированный столбец или многозначный индекс
Расширения
- PostgreSQL 17
CREATE EXTENSION: модули contrib, PostGIS, pgvector- MySQL 8.4 (InnoDB)
- Возможности встроены в сервер и зависят от версии
Один скрипт — два результата
Запустим один и тот же файл в обеих СУБД. Оба сервера — временные контейнеры с флагом --rm: после остановки от них ничего не остаётся.
docker run -d --rm --name kb-pg-pgvsmy -e POSTGRES_PASSWORD=test postgres:17-alpine
docker run -d --rm --name kb-mysql-pgvsmy -e MYSQL_ROOT_PASSWORD=test -e MYSQL_DATABASE=kb mysql:8.4
docker cp compare.sql kb-pg-pgvsmy:/tmp/-- compare.sql — one script for PostgreSQL 17 and MySQL 8.4
CREATE TABLE customers (
id integer PRIMARY KEY,
email varchar(50) NOT NULL UNIQUE,
name varchar(10) NOT NULL,
active boolean NOT NULL DEFAULT true
);
INSERT INTO customers (id, email, name) VALUES (1, 'aigerim@example.com', 'Aigerim');
-- 1. The same email in a different case
INSERT INTO customers (id, email, name) VALUES (2, 'Aigerim@Example.com', 'Aigerim');
SELECT 'Almaty' = 'ALMATY' AS same_text;
-- 2. A value longer than the column
INSERT INTO customers (id, email, name) VALUES (3, 'kon@example.com', 'Konstantin Ivanov');
-- 3. Integer division and the || operator
SELECT 7 / 2 AS division;
SELECT 'Inter' || 'LAB' AS joined;
-- 4. What a boolean looks like
SELECT id, active FROM customers ORDER BY id;
-- 5. Can you roll back CREATE TABLE?
BEGIN;
CREATE TABLE draft (id integer);
ROLLBACK;
SELECT count(*) AS draft_exists FROM information_schema.tables WHERE table_name = 'draft';
-- 6. Get the new row back in the same statement
INSERT INTO customers (id, email, name) VALUES (4, 'timur@example.com', 'Timur') RETURNING id, active;||, boolean, DDL в транзакции и RETURNING$ docker exec kb-pg-pgvsmy psql -U postgres -f /tmp/compare.sql
CREATE TABLE
INSERT 0 1
INSERT 0 1
same_text
-----------
f
(1 row)
psql:/tmp/compare.sql:16: ERROR: value too long for type character varying(10)
division
----------
3
(1 row)
joined
----------
InterLAB
(1 row)
id | active
----+--------
1 | t
2 | t
(2 rows)
BEGIN
CREATE TABLE
ROLLBACK
draft_exists
--------------
0
(1 row)
id | active
----+--------
4 | t
(1 row)
INSERT 0 1$ docker exec -i -e MYSQL_PWD=test kb-mysql-pgvsmy mysql -uroot --force --unbuffered --show-warnings -t kb < compare.sql
ERROR 1062 (23000) at line 12: Duplicate entry 'Aigerim@Example.com' for key 'customers.email'
+-----------+
| same_text |
+-----------+
| 1 |
+-----------+
ERROR 1406 (22001) at line 16: Data too long for column 'name' at row 1
+----------+
| division |
+----------+
| 3.5000 |
+----------+
+--------+
| joined |
+--------+
| 0 |
+--------+
Warning (Code 1287): '|| as a synonym for OR' is deprecated and will be removed in a future release. Please use OR instead
Warning (Code 1292): Truncated incorrect DOUBLE value: 'Inter'
Warning (Code 1292): Truncated incorrect DOUBLE value: 'LAB'
+----+--------+
| id | active |
+----+--------+
| 1 | 1 |
+----+--------+
+--------------+
| draft_exists |
+--------------+
| 1 |
+--------------+
ERROR 1064 (42000) at line 32: You have an error in your SQL syntax; check the manual that corresponds to your MySQL server version for the right syntax to use near 'RETURNING id, active' at line 1--force продолжает после ошибок, --show-warnings печатает предупрежденияEmail в другом регистре
- PostgreSQL
- Вставка прошла: две «разные» записи
- MySQL
Duplicate entry: для колляции это один email- Что делать
- В PostgreSQL — уникальный индекс по
lower(email); в MySQL помнить, чтоé=e
Строка длиннее колонки
- PostgreSQL
- Ошибка
- MySQL
- Ошибка — благодаря строгому режиму
STRICT_TRANS_TABLESвsql_modeпо умолчанию - Что делать
- Не отключать строгий режим: без него MySQL подрежет значение и выдаст лишь предупреждение
7 / 2
- PostgreSQL
3: деление целых отбрасывает дробную часть- MySQL
3.5000: для деления целых в MySQL естьDIV- Что делать
- Приводить тип явно:
CAST(7 AS decimal(10, 2)) / 2
'Inter' || 'LAB'
- PostgreSQL
InterLAB- MySQL
0и три предупреждения- Что делать
CONCAT()работает в обеих
boolean
- PostgreSQL
t- MySQL
1- Что делать
- Драйверы приводят оба варианта к булеву типу языка, но в сырых выгрузках разница видна
CREATE TABLE + ROLLBACK
- PostgreSQL
- Таблицы нет
- MySQL
- Таблица осталась
- Что делать
- В MySQL неудачная миграция может примениться частично — планируйте откат отдельно
RETURNING
- PostgreSQL
- Вернул
idиactive - MySQL
- Синтаксическая ошибка
- Что делать
- В MySQL —
LAST_INSERT_ID()после вставки
Транзакции: Read Committed против Repeatable Read
Обе СУБД принимают все четыре уровня изоляции из стандарта SQL (в PostgreSQL Read Uncommitted работает как Read Committed), но по умолчанию выбирают разные.
$ docker exec kb-pg-pgvsmy psql -U postgres -c 'SHOW transaction_isolation'
transaction_isolation
-----------------------
read committed
(1 row)
$ docker exec -e MYSQL_PWD=test kb-mysql-pgvsmy mysql -uroot -t -e 'SELECT @@transaction_isolation'
+-------------------------+
| @@transaction_isolation |
+-------------------------+
| REPEATABLE-READ |
+-------------------------+В Read Committed каждый запрос видит данные, зафиксированные до его начала, поэтому два одинаковых SELECT в одной транзакции могут вернуть разное. В Repeatable Read InnoDB все обычные чтения транзакции видят снимок, созданный первым чтением. Проверим на двух сессиях: A читает, B вставляет строку и фиксирует, A читает снова.
#!/usr/bin/env bash
# isolation.sh — session A reads twice inside one transaction,
# session B inserts and commits a row between those reads.
# Usage: ./isolation.sh pg | ./isolation.sh mysql
set -eu
if [ "$1" = "pg" ]; then
client() { docker exec -i kb-pg-pgvsmy psql -U postgres -qtA; }
else
client() { docker exec -i -e MYSQL_PWD=test kb-mysql-pgvsmy mysql -uroot -N --unbuffered kb; }
fi
{
echo "BEGIN;"
echo "SELECT 'A, first read:', count(*) FROM customers WHERE id >= 5;"
sleep 2 # session B commits during this pause
echo "SELECT 'A, second read:', count(*) FROM customers WHERE id >= 5;"
echo "COMMIT;"
} | client &
sleep 1
echo "INSERT INTO customers (id, email, name) VALUES (5, 'dana@example.com', 'Dana');" | client
echo "B: inserted id 5 and committed"
wait$ bash isolation.sh pg
A, first read:|0
B: inserted id 5 and committed
A, second read:|1
$ bash isolation.sh mysql
A, first read: 0
B: inserted id 5 and committed
A, second read: 0- PostgreSQL в Read Committed: отчёт из нескольких запросов в одной транзакции может собрать цифры из разных моментов времени. Если нужен согласованный снимок —
BEGIN ISOLATION LEVEL REPEATABLE READ. - В Repeatable Read PostgreSQL при попытке изменить строку, которую уже изменила и зафиксировала другая транзакция, вернёт ошибку
could not serialize access due to concurrent update; документация требует повторить транзакцию целиком. Код с уровнем выше Read Committed должен уметь повторять транзакции. - MySQL в Repeatable Read: снимок действует для обычных
SELECT, но не дляUPDATEиDELETE— они могут изменить строки, которые другая транзакция только что зафиксировала, хотяSELECTтой же транзакции их не видит. Блокирующие чтения (SELECT … FOR UPDATE) и изменения ставят блокировки, в том числе на промежутки (gap locks). Документация не рекомендует смешивать в одной такой транзакции блокирующие и обычные чтения.
JSON, типы и расширения
Обе СУБД хранят JSON в двоичном формате и проверяют его при записи. Различие — в поиске и индексах. В PostgreSQL индекс GIN строится по всему документу jsonb и ускоряет оператор вхождения @>.
-- json_pg.sql — PostgreSQL: jsonb, the containment operator and a GIN index
CREATE TABLE products (
id integer PRIMARY KEY,
attrs jsonb NOT NULL
);
INSERT INTO products VALUES
(1, '{"name": "Latte", "tags": ["coffee", "milk"], "volume_ml": 350}'),
(2, '{"name": "Espresso", "tags": ["coffee"], "volume_ml": 40}'),
(3, '{"name": "Matcha", "tags": ["tea", "milk"]}');
CREATE INDEX products_attrs_gin ON products USING GIN (attrs jsonb_path_ops);
SELECT id, attrs ->> 'name' AS name
FROM products
WHERE attrs @> '{"tags": ["milk"]}'
ORDER BY id;
SET enable_seqscan = off; -- three rows: force the planner to show the index
EXPLAIN (COSTS OFF) SELECT id FROM products WHERE attrs @> '{"tags": ["milk"]}';$ docker exec kb-pg-pgvsmy psql -U postgres -f /tmp/json_pg.sql
CREATE TABLE
INSERT 0 3
CREATE INDEX
id | name
----+--------
1 | Latte
3 | Matcha
(2 rows)
SET
QUERY PLAN
------------------------------------------------------------
Bitmap Heap Scan on products
Recheck Cond: (attrs @> '{"tags": ["milk"]}'::jsonb)
-> Bitmap Index Scan on products_attrs_gin
Index Cond: (attrs @> '{"tags": ["milk"]}'::jsonb)
(4 rows)В MySQL столбец JSON напрямую не индексируется: индекс строят на сгенерированном столбце, который извлекает значение по пути, или многозначный индекс по массиву через CAST(… AS … ARRAY). Оптимизатор использует такой индекс для MEMBER OF(), JSON_CONTAINS() и JSON_OVERLAPS().
-- json_mysql.sql — MySQL: JSON, MEMBER OF and a multi-valued index
CREATE TABLE products (
id integer PRIMARY KEY,
attrs json NOT NULL
);
INSERT INTO products VALUES
(1, '{"name": "Latte", "tags": ["coffee", "milk"], "volume_ml": 350}'),
(2, '{"name": "Espresso", "tags": ["coffee"], "volume_ml": 40}'),
(3, '{"name": "Matcha", "tags": ["tea", "milk"]}');
-- A JSON column cannot be indexed directly...
CREATE INDEX products_attrs_idx ON products (attrs);
-- ...so index the array values instead (a multi-valued index)
CREATE INDEX products_tags_idx ON products ((CAST(attrs -> '$.tags' AS CHAR(20) ARRAY)));
SELECT id, attrs ->> '$.name' AS name
FROM products
WHERE 'milk' MEMBER OF (attrs -> '$.tags')
ORDER BY id;
EXPLAIN FORMAT=TREE SELECT id FROM products WHERE 'milk' MEMBER OF (attrs -> '$.tags');$ docker exec -i -e MYSQL_PWD=test kb-mysql-pgvsmy mysql -uroot --force --unbuffered -r kb < json_mysql.sql
ERROR 3152 (42000) at line 13: JSON column 'attrs' supports indexing only via generated columns on a specified JSON path.
id name
1 Latte
3 Matcha
EXPLAIN
-> Filter: json'"milk"' member of (cast(json_extract(attrs,_latin1'$.tags') as char(20) array)) (cost=0.7 rows=2)
-> Index lookup on products using products_tags_idx (cast(json_extract(attrs,_latin1'$.tags') as char(20) array)=json'"milk"') (cost=0.7 rows=2)Встроенных типов у PostgreSQL больше: uuid, сетевые inet и cidr, интервалы, массивы, диапазоны, tsvector для полнотекстового поиска, а собственные типы добавляются через CREATE TYPE. В MySQL BOOLEAN — синоним TINYINT(1), зато в сервер встроены пространственные типы по модели OpenGIS с индексами SPATIAL в InnoDB и полнотекстовые индексы.
Главное отличие PostgreSQL — расширения. Модули из поставки (contrib) подключаются командой CREATE EXTENSION в нужной базе; сторонние проекты, например PostGIS для геоданных и pgvector для векторного поиска, устанавливаются на сервер отдельно.
-- ext_pg.sql — extensions in the official postgres:17-alpine image
SELECT count(*) AS available FROM pg_available_extensions;
CREATE EXTENSION pg_trgm; -- trigram similarity, ships with PostgreSQL
SELECT similarity('Almaty', 'Almata') AS score;
CREATE EXTENSION vector; -- pgvector is a separate project$ docker exec kb-pg-pgvsmy psql -U postgres -f /tmp/ext_pg.sql
available
-----------
59
(1 row)
CREATE EXTENSION
score
-----------
0.5555556
(1 row)
psql:/tmp/ext_pg.sql:7: ERROR: extension "vector" is not available
DETAIL: Could not open extension control file "/usr/local/share/postgresql/extension/vector.control": No such file or directory.
HINT: The extension must first be installed on the system where PostgreSQL is running.Отсюда практическое правило: если проект зависит от расширения, проверьте, что оно есть у выбранного хостинга, — в управляемых сервисах доступен только список, который поддерживает провайдер, а в Docker нужен образ с установленным расширением.
Репликация, хостинг и экосистема
Физическая репликация
- PostgreSQL
- Потоковая передача WAL на резервные серверы; hot standby принимает запросы только на чтение
- MySQL
- Репликация из двоичного журнала (binlog) с источника на реплики, по умолчанию асинхронная
Логическая и дополнительные режимы
- PostgreSQL
- Логическая репликация отдельных таблиц по идентичности строк (обычно первичному ключу); синхронная репликация как опция
- MySQL
- GTID вместо позиций в журнале, полусинхронная репликация, Group Replication и InnoDB Cluster
Управляемые сервисы
- PostgreSQL
- Amazon RDS, Google Cloud SQL и другие
- MySQL
- Amazon RDS, Google Cloud SQL и другие
Экосистема
- PostgreSQL
- Расширения (PostGIS, pgvector), сложные запросы и аналитика внутри базы
- MySQL
- Традиционный стек PHP; WordPress требует MySQL 8.0+ или MariaDB 10.11+
По хостингу различий почти нет: Amazon RDS и Google Cloud SQL предлагают обе СУБД. Разница начинается в деталях — какие версии и расширения доступны, как настраиваются реплики, — поэтому их проверяют в документации конкретного провайдера, а не по общему названию СУБД.
Когда что: таблица выбора
Новый продукт без жёстких ограничений по стеку
- Выбор
- PostgreSQL
- Почему
- Строгие типы, транзакционные миграции,
jsonbи расширения дают запас на рост
Геоданные, полнотекстовый или векторный поиск в основной базе
- Выбор
- PostgreSQL
- Почему
- PostGIS,
tsvector, pgvector — без отдельного сервиса
Сложные отчёты и аналитика внутри базы
- Выбор
- PostgreSQL
- Почему
- Богатые типы и функции, Read Committed с явным переходом на Repeatable Read для отчётов
WordPress и другие системы, рассчитанные на MySQL
- Выбор
- MySQL (или MariaDB)
- Почему
- Это требование самой системы; другая СУБД — путь без официальной поддержки
У команды и хостинга уже есть MySQL, опыт и мониторинг
- Выбор
- MySQL
- Почему
- Знакомая эксплуатация важнее различий, которые проекту не нужны
Перенос существующего приложения
- Выбор
- Та же СУБД, что сейчас
- Почему
- Смена базы затрагивает коллации, изоляцию, DDL и синтаксис — это отдельный проект
- Какую СУБД требует платформа или CMS проекта?
- Нужны ли расширения (PostGIS, pgvector) и есть ли они у выбранного хостинга?
- Какой уровень изоляции ожидает код и умеет ли он повторять транзакции?
- Должны ли уникальные текстовые поля учитывать регистр?
- Как откатывается неудачная миграция?
Как проверено6 · пройденосверено 11 октября 2026 г.
- запущено, работает
compare.sql в PostgreSQL и MySQL, вывод обеих СУБД
docker run --rm postgres:17-alpine (17.11) и mysql:8.4 (8.4.11, sql_mode по умолчанию, collation_server utf8mb4_0900_ai_ci); psql -f и mysql --force --unbuffered --show-warnings -t со стандартного ввода — ровно те команды, что в статье; вывод вставлен скриптом build.mjs из out/
- запущено, работает
SHOW transaction_isolation / SELECT @@transaction_isolation
read committed / REPEATABLE-READ
- запущено, работает
isolation.sh (две сессии) и isolation_swap.sh (упражнение)
Git Bash на Windows 11 + docker exec: PG RC 0→1, MySQL RR 0→0; после смены уровней PG RR 0→0, MySQL RC 0→1; запускались несколько раз с одинаковым результатом. На Linux/macOS не запускались, но используют только bash и docker
- запущено, работает
json_pg.sql и json_mysql.sql
обе возвращают Latte и Matcha; PG — Bitmap Index Scan on products_attrs_gin (enable_seqscan=off); MySQL — ERROR 3152 на прямой индекс и Index lookup using products_tags_idx в EXPLAIN FORMAT=TREE
- запущено, работает
ext_pg.sql
59 доступных расширений в postgres:17-alpine, CREATE EXTENSION pg_trgm, similarity = 0.5555556, CREATE EXTENSION vector → extension "vector" is not available
- запущено, работает
решения упражнений: exercise_pg.sql, portable.sql
уникальный индекс по lower(email) отклоняет вторую вставку; portable.sql в обеих СУБД: InterLAB, 3.5 (3.5000000000000000 / 3.500000), true (t / 1)
«Запущено» — код выполнен во временном проекте или контейнере, вывод в статье взят из прогона. «Частично» — проверено с заглушками вместо внешних сервисов. «Не запускалось» — нужны устройство, аккаунт или ключ; шаги сверены с официальной документацией. Файлы проверочного проекта и логи запусков сохранены в архиве InterLAB.