본문으로 건너뛰기
Previous
Next
PostgreSQL vs MySQL: Schema, Transactions, Queries &

PostgreSQL vs MySQL: Schema, Transactions, Queries &

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

FactorChoose PostgreSQLChoose 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 hostingBoth workBoth 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) and mysql2 both 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와 해당 라이브러리 공식 문서를 참고하세요. 글 말미의 참고 자료 링크도 활용하면 좋습니다.


같이 보면 좋은 글 (내부 링크)

이 주제와 연결되는 다른 글입니다.


이 글에서 다루는 키워드 (관련 검색어)

PostgreSQL, MySQL, Database, SQL, Query Optimization, Comparison, Operations 등으로 검색하시면 이 글이 도움이 됩니다.