PostgreSQL vs MySQL: Schema, Transactions, Queries &
이 글의 핵심
PostgreSQL vs MySQL compared for schema flexibility, ACID, SQL features, replication, and ops—plus Node.js–friendly criteria to pick the right RDBMS for your workload.
Why Comparing These Two Matters
PostgreSQL and MySQL (including MariaDB) dominate self-hosted and cloud RDBMS choices. Both are mature, both support ACID transactions, both have wide cloud support. The decision is not about correctness — either will work — but about fit for your specific workload, schema complexity, and team expertise.
“Which is faster” is rarely the right question. Correct indexing and schema design affect performance far more than the choice between the two. The right questions are: which features do you need, and which can your team operate confidently?
Schema and Data Types
PostgreSQL Strengths
PostgreSQL has a richer type system that can enforce more invariants at the database level:
-- Arrays — native support
CREATE TABLE product (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
tags TEXT[], -- array of strings
prices NUMERIC[] -- array of prices
);
-- Range types — e.g., date ranges for bookings
CREATE TABLE booking (
room_id INT,
occupied DATERANGE, -- native date range
EXCLUDE USING GIST (occupied WITH &&) -- no overlapping bookings, enforced by DB
);
-- UUID as primary key (built-in type)
CREATE TABLE user_account (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email TEXT UNIQUE NOT NULL
);
-- JSONB — binary JSON with GIN index support
CREATE TABLE event_log (
id SERIAL,
data JSONB
);
CREATE INDEX ON event_log USING GIN (data); -- query into nested JSON keys
MySQL/InnoDB Comparison
-- MySQL: JSON support (5.7+) — no native array type
CREATE TABLE product (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NOT NULL,
tags JSON -- stored as JSON string, less flexible indexing
);
-- MySQL: no native range type — you model it manually
CREATE TABLE booking (
room_id INT,
check_in DATE,
check_out DATE,
INDEX (room_id, check_in, check_out)
-- overlap prevention requires application logic or triggers
);
Practical decision: if your schema uses complex constraints (overlapping ranges, array columns, composite types), PostgreSQL is a natural fit. For simple key-value CRUD tables, both work equally well.
Transactions and Concurrency
Both databases use MVCC (Multi-Version Concurrency Control) for isolation, but their behaviors differ in important ways.
Transactional DDL (PostgreSQL Only)
PostgreSQL lets you run CREATE TABLE, ALTER TABLE, and DROP TABLE inside transactions — you can roll back schema changes:
-- PostgreSQL: DDL inside a transaction
BEGIN;
ALTER TABLE users ADD COLUMN last_login TIMESTAMP;
-- test the migration...
ROLLBACK; -- undo the schema change — column is gone
-- MySQL: DDL causes an implicit COMMIT
BEGIN;
ALTER TABLE users ADD COLUMN last_login TIMESTAMP; -- implicit COMMIT here!
ROLLBACK; -- has no effect — the column was already committed
This matters for migrations. PostgreSQL migration tools like Flyway and Liquibase can wrap schema changes in transactions. MySQL migrations must be written defensively.
Isolation Levels
-- Both support these isolation levels:
SET TRANSACTION ISOLATION LEVEL READ COMMITTED; -- default in many configs
SET TRANSACTION ISOLATION LEVEL REPEATABLE READ; -- MySQL default
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
-- PostgreSQL: SERIALIZABLE is true SSI (Serializable Snapshot Isolation)
-- MySQL: SERIALIZABLE uses locking — more blocking, less concurrency
SELECT FOR UPDATE
-- Both support row-level locking for pessimistic concurrency
BEGIN;
SELECT * FROM orders WHERE id = 1 FOR UPDATE; -- lock this row
UPDATE orders SET status = 'processing' WHERE id = 1;
COMMIT;
Queries and Indexing
PostgreSQL: Richer SQL
PostgreSQL has a longer history of standard SQL compliance and advanced query features:
-- CTEs with RECURSIVE (both support this, but PostgreSQL is more expressive)
WITH RECURSIVE org_tree AS (
SELECT id, name, parent_id FROM departments WHERE parent_id IS NULL
UNION ALL
SELECT d.id, d.name, d.parent_id
FROM departments d
JOIN org_tree o ON d.parent_id = o.id
)
SELECT * FROM org_tree;
-- Window functions with FILTER (PostgreSQL 9.4+)
SELECT
department,
COUNT(*) FILTER (WHERE status = 'active') AS active_count,
AVG(salary) OVER (PARTITION BY department ORDER BY hire_date) AS running_avg
FROM employees;
-- JSONB queries
SELECT *
FROM event_log
WHERE data @> '{"event_type": "login"}' -- containment operator
AND data ->> 'user_id' = '1234'; -- extract field
EXPLAIN and Query Plans
Both databases support EXPLAIN but with different syntax and information:
-- PostgreSQL: EXPLAIN ANALYZE BUFFERS shows actual cost and buffer usage
EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON)
SELECT * FROM orders
JOIN users ON orders.user_id = users.id
WHERE orders.created_at > NOW() - INTERVAL '7 days';
-- MySQL: EXPLAIN or EXPLAIN ANALYZE (8.0+)
EXPLAIN FORMAT=JSON
SELECT * FROM orders
JOIN users ON orders.user_id = users.id
WHERE orders.created_at > DATE_SUB(NOW(), INTERVAL 7 DAY);
Index Types
-- PostgreSQL: multiple index types
CREATE INDEX ON products USING BTREE (price); -- default, range queries
CREATE INDEX ON products USING HASH (sku); -- equality only
CREATE INDEX ON event_log USING GIN (data); -- JSONB, arrays, full-text
CREATE INDEX ON locations USING GIST (coordinates); -- geometric/range types
-- Partial index (both support)
CREATE INDEX ON orders (created_at)
WHERE status = 'pending'; -- only index pending orders
-- MySQL: BTREE and HASH (storage engine dependent), no GIN/GIST
CREATE INDEX idx_price ON products (price); -- BTREE by default
CREATE FULLTEXT INDEX idx_content ON articles (body); -- full-text only
Operations and Ecosystem
Cloud and Hosting
Both have equivalent managed offerings:
- PostgreSQL: Amazon RDS, Cloud SQL (GCP), Azure Database, Supabase, Neon
- MySQL: Amazon RDS, Cloud SQL, Azure Database, PlanetScale, Vitess
Monitoring
-- PostgreSQL: pg_stat_* views for real-time monitoring
SELECT query, calls, total_exec_time, mean_exec_time, rows
FROM pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT 10;
-- Check locks
SELECT pid, query, state, wait_event_type, wait_event
FROM pg_stat_activity
WHERE state != 'idle';
-- MySQL: Performance Schema
SELECT * FROM performance_schema.events_statements_summary_by_digest
ORDER BY sum_timer_wait DESC LIMIT 10;
-- MySQL slow query log
SET GLOBAL slow_query_log = 1;
SET GLOBAL long_query_time = 1; -- log queries over 1 second
Replication
PostgreSQL:
Primary → Streaming replication → Standbys (physical)
Primary → Logical replication → Subscribers (selective tables)
Patroni/pgBouncer for HA
MySQL:
Primary → Binary log replication → Replicas
Group Replication (multi-primary)
ProxySQL for HA
PlanetScale (Vitess-based sharding)
Making the Decision
| Factor | Choose PostgreSQL | Choose MySQL |
|---|---|---|
| Complex schema (arrays, ranges, custom types) | ✓ | |
| Strong SQL compliance and analytical queries | ✓ | |
| JSONB with indexing | ✓ | |
| Transactional DDL for safe migrations | ✓ | |
| Existing CMS / WordPress / PHP stack | ✓ | |
| Team has deep MySQL ops experience | ✓ | |
| Need Vitess sharding (PlanetScale) | ✓ | |
| PostGIS geographic data | ✓ | |
| Simple CRUD with well-known hosting | Both work | Both work |
For new Node.js services in 2026, PostgreSQL is the common default — Supabase, Neon, Railway, and Render all use PostgreSQL and have mature Node.js clients. The pg driver is stable, Prisma and Drizzle both support PostgreSQL first-class.
Node.js Connection Examples
// PostgreSQL with pg + connection pooling
import { Pool } from 'pg';
const pool = new Pool({
host: process.env.DB_HOST,
database: process.env.DB_NAME,
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
max: 20, // connection pool size
idleTimeoutMillis: 30000,
});
const result = await pool.query(
'SELECT * FROM users WHERE email = $1', // parameterized — no SQL injection
[email]
);
// MySQL with mysql2 + connection pooling
import mysql from 'mysql2/promise';
const pool = mysql.createPool({
host: process.env.DB_HOST,
database: process.env.DB_NAME,
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
waitForConnections: true,
connectionLimit: 20,
});
const [rows] = await pool.execute(
'SELECT * FROM users WHERE email = ?', // parameterized
[email]
);
Key Takeaways
- PostgreSQL: richer types (arrays, ranges, JSONB with GIN indexes), transactional DDL, true serializable isolation — generally the better default for new projects
- MySQL/MariaDB: fits existing stacks, familiar to operations teams with web hosting backgrounds, strong ecosystem for CMS workloads
- Schema design and indexes matter far more than which database you choose — correct indexing eliminates most performance differences
- EXPLAIN / EXPLAIN ANALYZE is your primary performance tool in both — different syntax, same goal
- Cloud: both have equivalent managed offerings; pick based on your existing cloud and team
- For Node.js,
pg(PostgreSQL) andmysql2both support connection pooling and parameterized queries — use parameterized queries always to prevent SQL injection
자주 묻는 질문 (FAQ)
Q. 이 내용을 실무에서 언제 쓰나요?
A. PostgreSQL vs MySQL compared for schema flexibility, ACID, SQL features, replication, and ops—plus Node.js–friendly crit… 실무에서는 위 본문의 예제와 선택 가이드를 참고해 적용하면 됩니다.
Q. 선행으로 읽으면 좋은 글은?
A. 각 글 하단의 이전 글 또는 관련 글 링크를 따라가면 순서대로 배울 수 있습니다. C++ 시리즈 목차에서 전체 흐름을 확인할 수 있습니다.
Q. 더 깊이 공부하려면?
A. cppreference와 해당 라이브러리 공식 문서를 참고하세요. 글 말미의 참고 자료 링크도 활용하면 좋습니다.
같이 보면 좋은 글 (내부 링크)
이 주제와 연결되는 다른 글입니다.
- Node.js 데이터베이스 연동 | MongoDB, PostgreSQL, MySQL
- Docker Compose로 Node API·PostgreSQL·Redis 한 번에 띄우기
- Redis 캐싱 전략 패턴 5가지 | Cache-Aside부터 Refresh-ahead까지
이 글에서 다루는 키워드 (관련 검색어)
PostgreSQL, MySQL, Database, SQL, Query Optimization, Comparison, Operations 등으로 검색하시면 이 글이 도움이 됩니다.