Siddhant Deval
Siddhant DevalAuthor
Senior Full-Stack Engineer·Jul 5, 2026·16 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
PropertyWhat It GuaranteesWhat It Does NOT Guarantee
AtomicityAll statements in a transaction commit or none doApplication-layer logic correctness
ConsistencyConstraints (FK, UNIQUE, CHECK) always hold after commitBusiness rule correctness beyond constraints
IsolationConcurrent transactions don't see each other's in-progress writesPerformance — higher isolation = lower throughput
DurabilityCommitted 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 TypeStorage EngineWhen to Use
B-tree (default)Sorted treeEquality (=), range (<, >), ORDER BY, most WHERE clauses
GINInverted indexJSONB containment (@>), array overlap (&&), full-text search (tsvector)
GiSTGeneralized search treeGeospatial (ST_DWithin), range types (tstzrange), nearest-neighbor
BRINBlock range min/maxLarge tables with naturally ordered data (timestamps, sequential IDs) — tiny index, fast range scan
HashHash tableEquality 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…
CriterionPostgreSQLMySQL (InnoDB)
ACID complianceFull MVCC, serializable isolationFull MVCC, serializable available
JSON supportJSONB — binary, indexed, queryableJSON — text-stored, limited indexing
Extension ecosystempgvector, PostGIS, pg_search, TimescaleDBMinimal — extensions rare
ReplicationStreaming replication (WAL-based), logical replicationBinary log replication; Group Replication for multi-primary
Full-text searchtsvector + GIN — capable for moderate scaleFULLTEXT index — less flexible than Postgres
Connection modelProcess-per-connection (PgBouncer required at scale)Thread-per-connection (better raw concurrency)
LicensePostgreSQL License (permissive)GPL v2 (requires open-source compliance or commercial license)
Best fitComplex queries, extensions, multi-tenant SaaS, AI workloadsHigh-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

ScenarioWhy Relational FailsBetter Alternative
Rapidly evolving schema (early product iteration)ALTER TABLE migrations block tables; schema rigidity slows experimentationMongoDB — 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 throughputCassandra — LSM-tree append, multi-primary writes
Graph traversal with 3+ relationship hopsRecursive CTEs scale as O(n³) — impractical beyond millions of rowsNeo4j — native graph storage, Cypher in milliseconds
Sub-millisecond key lookups (session, rate limit)Network + query parse overhead adds ~1–5ms minimumRedis — in-memory O(1) hash lookups
Horizontal scale across regions (petabyte-scale, auto-sharding)Single primary model; manual sharding is complex and failure-proneCockroachDB — 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

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

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.