Siddhant Deval
Siddhant Deval
backend16 min read

Relational Databases: PostgreSQL & MySQL in Production

PostgreSQL is not just a SQL database — it is an extensible platform that handles semi-structured data, geospatial queries, and AI embeddings in a single query. This article covers ACID mechanics, MVCC, the extension ecosystem, data modeling discipline, Row-Level Security for multi-tenancy, and the scaling ceiling every production team will eventually hit.

Relational Databases: PostgreSQL & MySQL in Production

Selecting the right database is a foundational architectural decision; data gravity ultimately dictates the scalability and resilience of an application — choose the paradigm first, the product second. Teams that decide "PostgreSQL for everything" are often right — but not for the reasons they think. Postgres is not just a SQL database. It is an extensible platform that silently absorbs use cases that would otherwise demand three separate stores: JSONB absorbs a document database, PostGIS absorbs a geospatial tier, and pgvector absorbs a vector search layer. This article shows you how to use that extension model deliberately, how to model data correctly in a relational schema, and the exact signals that tell you Postgres can no longer grow with you.


1. ACID Mechanics — What the Guarantee Actually Covers

The broken pattern: teams interpret "ACID compliant" as "safe" and stop thinking. ACID is a contract with specific boundaries — violating any of the four properties silently undermines data integrity.

SQL
-- ❌ Broken pattern: two operations outside a transaction — partial failure leaves inconsistent state
UPDATE accounts SET balance = balance - 500 WHERE user_id = 1;
UPDATE accounts SET balance = balance + 500 WHERE user_id = 2;
-- If the second UPDATE fails (network drop, constraint violation), user 1 lost $500 permanently

-- ✅ Correct: wrap in an explicit transaction — all-or-nothing atomicity
BEGIN;
  UPDATE accounts SET balance = balance - 500 WHERE user_id = 1;
  UPDATE accounts SET balance = balance + 500 WHERE user_id = 2;
COMMIT;
-- If any statement fails, ROLLBACK is automatic — both accounts remain consistent
Property What It Guarantees What It Does NOT Guarantee
Atomicity All statements in a transaction commit or none do Application-layer logic correctness
Consistency Constraints (FK, UNIQUE, CHECK) always hold after commit Business rule correctness beyond constraints
Isolation Concurrent transactions don't see each other's in-progress writes Performance — higher isolation = lower throughput
Durability Committed writes survive crash (via WAL flush to disk) Replica lag — replicas may lag behind primary
Performance / Safety Warning

PostgreSQL's default isolation level is READ COMMITTED, not SERIALIZABLE. Under READ COMMITTED, a transaction can see rows committed by other transactions between its own statements — this causes non-repeatable reads. For financial calculations or inventory checks, explicitly set SET TRANSACTION ISOLATION LEVEL REPEATABLE READ.

1.1 MVCC — Concurrent Reads Without Locking

SQL
-- MVCC allows this to run simultaneously without blocking the UPDATE below
SELECT SUM(balance) FROM accounts; -- reader sees snapshot of committed data at query start

-- This writer proceeds without waiting for the reader above
UPDATE accounts SET balance = balance + 100 WHERE user_id = 42;
-- MVCC creates a new row version; the reader's snapshot is unaffected

MVCC (Multi-Version Concurrency Control) is why Postgres achieves high read concurrency without read locks. Each transaction works against a consistent snapshot of the database as of its start time. Writers create new row versions (tuples) rather than modifying in place. The tradeoff: old tuple versions accumulate as "dead tuples" until VACUUM reclaims them.

Crucial Requirement

Long-running transactions block VACUUM from reclaiming dead tuples. A single 4-hour analytics query can cause table bloat that degrades write performance for the entire cluster. Monitor pg_stat_activity for long-running sessions and set statement_timeout for analytics workloads.


2. The PostgreSQL Extension Ecosystem

2.1 JSONB — Semi-Structured Data Without a Document Database

SQL
-- ❌ Broken pattern: storing all variable attributes in a text column
CREATE TABLE products (
  id UUID PRIMARY KEY,
  name TEXT,
  attributes TEXT  -- JSON blob as plain text — no validation, no indexing, slow parsing
);

-- ✅ Correct: JSONB — binary-stored, indexable, queryable semi-structured data
CREATE TABLE products (
  id UUID PRIMARY KEY,
  name TEXT NOT NULL,
  category TEXT NOT NULL,       -- structured: indexed, constraint-able
  price_cents INTEGER NOT NULL,  -- structured: typed, arithmetic-able
  attributes JSONB              -- variable: product-specific fields (color, size, specs)
);

-- GIN index makes JSONB queries fast at scale
CREATE INDEX idx_products_attributes ON products USING GIN (attributes);

-- Query a nested JSONB field — uses the GIN index
SELECT name FROM products
WHERE attributes @> '{"color": "navy", "size": "M"}';
Pro Tip & Optimization

Use structured columns for fields you query with WHERE, ORDER BY, or GROUP BY. Use JSONB for variable, product-specific attributes that differ per record. Never store queryable data inside a JSONB blob — the GIN index helps but it cannot replace a B-tree index on a dedicated column.

2.2 PostGIS — Geospatial Without a Separate Tier

SQL
-- Enable the PostGIS extension (once per database)
CREATE EXTENSION IF NOT EXISTS postgis;

-- Add a geometry column to an existing table
ALTER TABLE locations ADD COLUMN geom GEOMETRY(Point, 4326);

-- Find all restaurants within 2km of a coordinate — uses spatial index
SELECT name, ST_Distance(geom, ST_MakePoint(-73.985, 40.748)::geography) AS dist_meters
FROM locations
WHERE ST_DWithin(geom::geography, ST_MakePoint(-73.985, 40.748)::geography, 2000)
ORDER BY dist_meters;

2.3 pgvector — AI Embeddings in Your Existing Postgres Stack

SQL
-- Enable pgvector (once per database)
CREATE EXTENSION IF NOT EXISTS vector;

-- Store embeddings alongside structured data — no separate vector DB needed at < 10M rows
CREATE TABLE articles (
  id UUID PRIMARY KEY,
  title TEXT,
  content TEXT,
  embedding VECTOR(1536)  -- OpenAI text-embedding-3-small dimension
);

-- HNSW index — fast approximate nearest neighbor for query-time retrieval
CREATE INDEX idx_articles_embedding ON articles
USING HNSW (embedding vector_cosine_ops) WITH (m = 16, ef_construction = 64);

-- Semantic similarity search — retrieves the 5 most relevant articles
SELECT title, 1 - (embedding <=> '[0.1, 0.2, ...]'::vector) AS similarity
FROM articles
ORDER BY embedding <=> '[0.1, 0.2, ...]'::vector
LIMIT 5;
Architectural Note

pgvector's HNSW index loads the entire graph into RAM. At 10M 1536-dimension vectors, that is approximately 60GB of RAM. If your vector dataset exceeds available memory, the HNSW index degrades to disk I/O — this is the signal to migrate to a dedicated vector store (Qdrant, Pinecone).


3. Data Modeling

3.1 Normalization vs. Strategic Denormalization

SQL
-- ❌ Broken pattern: over-normalized — every read requires expensive joins
CREATE TABLE order_items (
  id UUID PRIMARY KEY,
  order_id UUID REFERENCES orders(id),
  product_id UUID REFERENCES products(id),
  quantity INTEGER
  -- To show an order receipt: JOIN orders + order_items + products + users
  -- 4-table join on every page load
);

-- ✅ Correct: strategic denormalization — embed stable, read-time data at write time
CREATE TABLE order_items (
  id UUID PRIMARY KEY,
  order_id UUID REFERENCES orders(id),
  product_id UUID REFERENCES products(id),
  quantity INTEGER,
  -- Snapshot the product name and price AT ORDER TIME — they may change later
  product_name TEXT NOT NULL,      -- denormalized snapshot
  unit_price_cents INTEGER NOT NULL -- denormalized snapshot
  -- Receipt query: JOIN orders + order_items only — 2-table join
);

The rule: normalize for update correctness; denormalize for read performance. Snapshot data that represents a moment in time (order price, invoice total) — never join to get it at render time.

3.2 Hybrid Schema — Structured Columns + JSONB

The correct pattern for product catalogs, CMS content, and multi-tenant SaaS applications where each entity type has a shared core and a variable extension:

SQL
CREATE TABLE products (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  name TEXT NOT NULL,
  category TEXT NOT NULL,
  price_cents INTEGER NOT NULL CHECK (price_cents > 0),
  active BOOLEAN NOT NULL DEFAULT true,
  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  -- Variable attributes: color options, dimensions, technical specs, certifications
  attributes JSONB NOT NULL DEFAULT '{}'
);

-- Indexed columns for filtering UI
CREATE INDEX idx_products_category ON products (category) WHERE active = true;
CREATE INDEX idx_products_attributes ON products USING GIN (attributes);

-- Partial index: only active products in the feed query
CREATE INDEX idx_products_active_created ON products (created_at DESC) WHERE active = true;
Hybrid schema design: structured columns (indexed, typed) on the left in cyan for queried fields; JSONB attributes block on the right in amber for variable product-specific data — showing the query performance difference between a column B-tree seek and a GIN index scan.
Hybrid schema design: structured columns (indexed, typed) on the left in cyan for queried fields; JSONB attributes block on the right in amber for variable p…

3.3 Schema Migration Discipline

SQL
-- ❌ Broken: dropping a column immediately locks the table and breaks old app versions
ALTER TABLE users DROP COLUMN legacy_field;

-- ✅ Phase 1: mark as deprecated — old code still works, new code ignores it
COMMENT ON COLUMN users.legacy_field IS 'DEPRECATED: removed in v2.4 — do not read';

-- ✅ Phase 2 (next deploy): remove from all queries in application code

-- ✅ Phase 3 (after full rollout): safe to drop — no readers remain
ALTER TABLE users DROP COLUMN legacy_field;
Crucial Requirement

In PostgreSQL, ALTER TABLE ... DROP COLUMN takes an ACCESS EXCLUSIVE lock. On a table with millions of rows and active read traffic, this blocks all reads and writes until it completes. Always use the three-phase deprecation cycle in production.


4. Indexing Strategy

Index Type Storage Engine When to Use
B-tree (default) Sorted tree Equality (=), range (<, >), ORDER BY, most WHERE clauses
GIN Inverted index JSONB containment (@>), array overlap (&&), full-text search (tsvector)
GiST Generalized search tree Geospatial (ST_DWithin), range types (tstzrange), nearest-neighbor
BRIN Block range min/max Large tables with naturally ordered data (timestamps, sequential IDs) — tiny index, fast range scan
Hash Hash table Equality only — rarely preferred over B-tree
SQL
-- BRIN for append-only time-series data (events, logs) — 200x smaller than B-tree
CREATE INDEX idx_events_created_brin ON events USING BRIN (created_at);

-- Partial index — only index the subset you query — dramatically smaller and faster
CREATE INDEX idx_orders_pending ON orders (created_at DESC)
WHERE status = 'pending';  -- only ~5% of rows — index is 95% smaller

5. MySQL vs. PostgreSQL

PostgreSQL vs. MySQL comparison matrix across ACID compliance depth, extension ecosystem, JSON support, replication model, licensing, and best-fit workloads — showing where each wins.
PostgreSQL vs. MySQL comparison matrix across ACID compliance depth, extension ecosystem, JSON support, replication model, licensing, and best-fit workloads…
Criterion PostgreSQL MySQL (InnoDB)
ACID compliance Full MVCC, serializable isolation Full MVCC, serializable available
JSON support JSONB — binary, indexed, queryable JSON — text-stored, limited indexing
Extension ecosystem pgvector, PostGIS, pg_search, TimescaleDB Minimal — extensions rare
Replication Streaming replication (WAL-based), logical replication Binary log replication; Group Replication for multi-primary
Full-text search tsvector + GIN — capable for moderate scale FULLTEXT index — less flexible than Postgres
Connection model Process-per-connection (PgBouncer required at scale) Thread-per-connection (better raw concurrency)
License PostgreSQL License (permissive) GPL v2 (requires open-source compliance or commercial license)
Best fit Complex queries, extensions, multi-tenant SaaS, AI workloads High-read web apps, SaaS, CMS, WordPress, Drupal
Pro Tip & Optimization

Choose MySQL when your team already operates it well and your workload is read-heavy with simple queries. Choose PostgreSQL when you need JSONB, PostGIS, pgvector, complex window functions, or multi-tenant Row-Level Security. Do not choose based on benchmarks — choose based on the extension your workload needs.


6. Security

6.1 Row-Level Security for Multi-Tenancy

SQL
-- ❌ Broken pattern: filtering by tenant ID in the application — every query must remember it
-- SELECT * FROM invoices WHERE tenant_id = current_user_tenant -- easy to forget
-- A single missing WHERE clause exposes all tenants' data

-- ✅ Correct: enforce tenant isolation at the database layer with RLS
ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;

-- Policy: each row is only visible to the session's active tenant
CREATE POLICY tenant_isolation ON invoices
  USING (tenant_id = current_setting('app.current_tenant_id')::UUID);

-- Application sets the tenant context before any query — RLS enforces the rest
SET app.current_tenant_id = '550e8400-e29b-41d4-a716-446655440000';
SELECT * FROM invoices;  -- automatically scoped to this tenant — no WHERE needed
Crucial Requirement

RLS policies are enforced even for superusers unless FORCE ROW LEVEL SECURITY is set. Always set BYPASSRLS = false on application service roles. Application-layer WHERE tenant_id = ? filtering is a defense-in-depth layer, not a replacement for RLS.

6.2 Audit Trails with pg_audit

SQL
-- Install the pg_audit extension for compliance-grade audit logging
-- Add to postgresql.conf:
-- shared_preload_libraries = 'pgaudit'
-- pgaudit.log = 'write, ddl'  -- log all writes and schema changes

-- Query the PostgreSQL log for audit events (format depends on pgaudit config)
-- Each logged event includes: timestamp, user, database, object, command, statement

7. When NOT to Use Relational

Scenario Why Relational Fails Better Alternative
Rapidly evolving schema (early product iteration) ALTER TABLE migrations block tables; schema rigidity slows experimentation MongoDB — schemaless documents, no migration required
Write-heavy append-only at extreme volume (> 500K writes/sec) WAL becomes the bottleneck; single-primary model cannot absorb the throughput Cassandra — LSM-tree append, multi-primary writes
Graph traversal with 3+ relationship hops Recursive CTEs scale as O(n³) — impractical beyond millions of rows Neo4j — native graph storage, Cypher in milliseconds
Sub-millisecond key lookups (session, rate limit) Network + query parse overhead adds ~1–5ms minimum Redis — in-memory O(1) hash lookups
Horizontal scale across regions (petabyte-scale, auto-sharding) Single primary model; manual sharding is complex and failure-prone CockroachDB — automatic Raft-based horizontal sharding

8. The Scaling Ceiling

The correct escalation path when Postgres performance degrades:

Step 1: Connection pool exhaustion (most common first signal)
  → Deploy PgBouncer in TRANSACTION mode
  → Set pool_size = (CPU cores × 2) + disk spindles

Step 2: Read replica lag / read-heavy workload
  → Add streaming replicas; route SELECT to replicas via load balancer

Step 3: Write throughput ceiling (primary WAL saturated)
  → Vertical scale (larger instance, faster NVMe)
  → Then evaluate: CockroachDB for horizontal SQL, or DynamoDB for simple key workloads

Step 4: Dataset size > available RAM (index doesn't fit in shared_buffers)
  → Partitioning (RANGE on created_at for time-series tables)
  → Then evaluate: paradigm migration
Architectural Note

Most teams reach Step 1 and stop. PgBouncer in transaction mode (not session mode) is the single highest-leverage optimization available for Postgres at scale — it multiplies the effective connection capacity by 10–50× without changing any application code.


Summary

Concept Rule
PostgreSQL extension model PostgreSQL's extension model (JSONB, PostGIS, pgvector) absorbs use cases that would otherwise require 3 separate databases.
MVCC and concurrency MVCC enables high read concurrency without locking — understanding it prevents incorrect 'Postgres doesn't scale' conclusions.
Multi-tenant security Row-Level Security is the correct mechanism for multi-tenant isolation; application-layer filtering is a security anti-pattern.
Scaling ceiling PostgreSQL's scaling ceiling is connection-count, not query performance — deploy PgBouncer before reaching for sharding.
When to switch Relational databases are wrong for rapidly evolving schemas, 3+ hop graph queries, and write-throughput workloads exceeding what a single primary can absorb.
Architectural Note

Related: Database Foundations Series — The MVCC, constraint enforcement, and fsync behavior covered here are the physical implementation of the ACID contract. For the formal breakdown of all four ACID properties (Atomicity, Consistency, Isolation, Durability) and their failure modes, see ACID Database Properties: Atomicity, Consistency, Isolation & Durability in Practice.

What's Next

In Part 3, we cover document and NoSQL databases — MongoDB's embedding vs. referencing decision, DynamoDB's single-table design, schema versioning patterns, and the GDPR compliance challenge that document denormalization creates.

Research & Synthesis Note

This article was developed with AI-assisted deep search, specification cross-referencing, and technical research synthesis.

#PostgreSQL#MySQL#Relational Database#ACID#MVCC#Row-Level Security#pgvector
Siddhant Deval

Written by Siddhant Deval

Senior Full-Stack Engineer building high-scale architectures, browser performance engineering systems, and SaaS platforms.