The SQL standard provides a unified language for querying data, but it dangerously obscures the violent physical differences in how database engines actually execute those queries. Engineers migrating from Oracle to PostgreSQL, or PostgreSQL to MySQL, often carry over architectural assumptions that result in catastrophic performance regressions in production.

To understand how deep these differences go, we have to look past the syntax and examine the physical bytes on the disk. The decision between MySQL and PostgreSQL is not just about JSON support or vacuuming; it is a fundamental choice about how B-Trees map to physical storage, a choice that famously forced Uber to completely re-architect their persistence layer.

Situation

As organizations modernize their tech stacks, platform teams frequently deploy a polyglot persistence layer. A single architecture might use Amazon Aurora MySQL for commerce, PostgreSQL for geo-spatial microservices, and legacy Oracle for financial ledgers.

Developers interacting with these systems often treat them as interchangeable SQL endpoints. They run EXPLAIN to verify an index is used, apply the same indexing strategies across all three, and assume the database optimizer will figure out the rest.

The Problem

A query plan is a lie agreed upon by the optimizer and the statistics engine. More importantly, an index in MySQL is a completely different physical structure than an index in PostgreSQL or Oracle.

If a developer adds three secondary indexes to a highly-updated table in PostgreSQL, the performance penalty manifests as massive write amplification and replication bloat. If they do the same thing in MySQL’s InnoDB, the penalty behaves entirely differently due to the clustered index.

Understanding why this happens requires looking at the physical storage engine layer. The core question is: how does the physical storage architecture of a table dictate index efficiency, write amplification, and replication across MySQL, PostgreSQL, and Oracle?

Storage and Execution Architecture

flowchart TD
    A[SQL Query] --> B[Database Optimizer]
    B --> C[Execution Engine]
    
    C --> D[MySQL InnoDB — Clustered Index]
    D --> E[Data stored in Primary Key B-Tree leaves]
    E --> F[Secondary Indexes store PK Strings]
    
    C --> G[PostgreSQL — Heap Storage]
    G --> H[Data stored in unordered Heap blocks]
    H --> I[Secondary Indexes store physical TIDs]
    
    C --> J[Oracle — Heap by Default]
    J --> K[Data stored in Heap blocks]
    J --> L[Optional: Index-Organized Tables IOT]

In Practice: MySQL’s Clustered Index

The documented pattern for MySQL (InnoDB) is the Clustered Index, or Index-Organized Table (IOT). In InnoDB, the table data does not exist independently of the Primary Key; the data is the leaf node of the Primary Key B+Tree.

Because of this, primary key lookups are incredibly fast. However, secondary indexes do not point directly to the physical row. Instead, a secondary index stores the string or integer value of the Primary Key. This means a secondary index lookup in MySQL is a two-step process: traverse the secondary index to find the Primary Key value, then traverse the Primary Key B-Tree to find the actual data row.

This architecture provides a massive hidden advantage during UPDATE operations. If you update a row in MySQL, and that update does not change the Primary Key or any columns used in a secondary index, the secondary indexes are completely untouched. The physical data is updated in place, and the secondary index pointers (the Primary Key values) remain perfectly valid.

In Practice: Uber’s Battle with PostgreSQL Write Amplification

PostgreSQL operates on a Heap Storage model. The table data is stored in an unordered structure (the heap). All indexes in PostgreSQL, including the primary key, are secondary indexes, and they contain direct physical pointers (Tuple IDs or TIDs) to the exact block and offset in the heap. A secondary index lookup in PostgreSQL is generally a faster, one-step process directly to the physical disk location.

However, this architecture creates a devastating problem at extreme scale: Write Amplification.

In 2016, Uber’s engineering team published a highly publicized breakdown of why they were migrating their core architecture off PostgreSQL and onto a custom sharding layer built on MySQL (“Schemaless”). Uber’s own writeup names several factors, but one of the primary documented reasons was Postgres’s implementation of Multiversion Concurrency Control (MVCC) combined with its Heap storage architecture.

When you UPDATE a row in PostgreSQL, the database does not update the physical row in place. Instead, it writes an entirely new copy of the row into the heap, and marks the old row as obsolete (to be cleaned up later by the Autovacuum process).

Because the row’s physical location on disk just changed, every single index on the table must be updated to point to the new physical TID. If Uber had a table with 10 secondary indexes, a single UPDATE to a completely unindexed column resulted in 10 physical index writes. This caused massive write amplification, saturating their SSDs and crippling their replication bandwidth, because PostgreSQL’s Write-Ahead Log (WAL) had to broadcast all of these physical changes across data centers.

By migrating to MySQL’s InnoDB, Uber leveraged the Clustered Index architecture. When a row was updated in MySQL, the data was updated in place (or the old version was pushed to the undo log). Because the Primary Key didn’t change, none of the secondary indexes had to be updated. The replication payload dropped significantly, and disk I/O was saved.

(Note: PostgreSQL has since introduced HOT (Heap-Only Tuples) updates to mitigate this, but HOT updates only work if the new row version fits on the same physical page and no indexed columns are altered. At Uber’s scale, the physical architecture of InnoDB proved superior for their specific heavy-update workload.)

In Practice: Oracle’s Enterprise Flexibility

Oracle defaults to the same Heap Storage model as PostgreSQL, meaning it separates data blocks from index blocks. However, Oracle provides the enterprise capability to explicitly declare an Index-Organized Table (IOT), allowing engineers to intentionally opt-in to the InnoDB-style clustered architecture.

Oracle requires deep DBA expertise to know when to override the default. If a financial application requires billions of fast, sequential primary-key lookups (like an IoT telemetry stream), an Oracle DBA will explicitly build an IOT. If the workload involves massive, wide tables with unpredictable secondary queries, they stick to the Heap.

When it comes to execution plans, the tooling philosophies also differ. MySQL’s EXPLAIN traditionally predicts what the optimizer will do based on heuristics. PostgreSQL’s EXPLAIN ANALYZE actually executes the query, discards the result, and reports the physical truth of memory usage, disk blocks read, and exact timing. Oracle’s DBMS_XPLAN leans heavily on historical statistics, but its Adaptive Query Optimization can actually change the execution plan mid-flight if it realizes its initial statistical assumptions were wrong.

Where It Breaks

DatabaseFeatureTradeoff
MySQL (InnoDB)Clustered IndexUpdates are highly efficient because secondary indexes aren’t physically tied to disk blocks. Primary keys must be sequential.
PostgreSQLHeap StorageSecondary indexes are fast. Heavy updates cause massive write amplification, requiring aggressive Autovacuuming.
OracleHeap Default / Optional IOTExtreme flexibility, allowing the DBA to choose the physical layout per table based on the workload.
PostgreSQLEXPLAIN ANALYZEExecutes the query to provide exact physical block reads. Do not run it on a destructive UPDATE or DELETE in production without a transaction rollback.

What to Do Next

  • Problem: Treating all relational databases identically leads to architectural failure. Updating a row in PostgreSQL triggers massive write amplification across all secondary indexes, while the same update in MySQL is localized and efficient.
  • Solution: Select PostgreSQL for read-heavy, analytical, or geo-spatial workloads where direct heap pointers are advantageous. Select MySQL for extreme high-throughput update workloads to avoid write amplification and replication bloat.
  • Proof: Uber Engineering’s public architectural migration from PostgreSQL to MySQL is a documented, named case where the I/O cost of Postgres’s Heap MVCC updates was one of the stated drivers at global scale — read their original writeup for the full set of reasons before treating this as a universal verdict on Postgres.
  • Action: Audit your PostgreSQL databases today. If you have highly updated tables with numerous secondary indexes, check your Autovacuum logs and replication lag. If write amplification is saturating your SSDs, consider refactoring the schema to utilize Heap-Only Tuples (HOT) updates, or begin a migration plan to a clustered storage engine like MySQL.