<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Adil Khan</title>
    <description>The latest articles on DEV Community by Adil Khan (@adil_khan_35649a2ba914bfb).</description>
    <link>https://dev.to/adil_khan_35649a2ba914bfb</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3980582%2Fb5ea229a-e6a3-4ac9-bc81-6bc3aaf9e466.png</url>
      <title>DEV Community: Adil Khan</title>
      <link>https://dev.to/adil_khan_35649a2ba914bfb</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/adil_khan_35649a2ba914bfb"/>
    <language>en</language>
    <item>
      <title>UUIDv4 vs UUIDv7 in PostgreSQL: A B-Tree Index Performance Deep Dive</title>
      <dc:creator>Adil Khan</dc:creator>
      <pubDate>Fri, 12 Jun 2026 07:50:07 +0000</pubDate>
      <link>https://dev.to/adil_khan_35649a2ba914bfb/uuidv4-vs-uuidv7-in-postgresql-a-b-tree-index-performance-deep-dive-3f0g</link>
      <guid>https://dev.to/adil_khan_35649a2ba914bfb/uuidv4-vs-uuidv7-in-postgresql-a-b-tree-index-performance-deep-dive-3f0g</guid>
      <description>&lt;h2&gt;
  
  
  Migrating MedicoSync: UUIDv4 → UUIDv7
&lt;/h2&gt;

&lt;h3&gt;
  
  
  (Why I'm doing it, and why it's cheap to defer)
&lt;/h3&gt;

&lt;p&gt;When building MedicoSync (an open-source FastAPI + PostgreSQL medical records platform), hiding internal database IDs was a key requirement on Day 1 [source: 0.1.1].&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The Problem with Integers&lt;/strong&gt;: Simple sequential numbers like &lt;code&gt;/api/v1/patients/42&lt;/code&gt; leak data. A malicious user can guess the next numbers and scan your endpoints like &lt;code&gt;/43&lt;/code&gt;, &lt;code&gt;/44&lt;/code&gt;, or &lt;code&gt;/45&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The Vulnerability&lt;/strong&gt;: This mistake is called an &lt;strong&gt;IDOR (Insecure Direct Object Reference)&lt;/strong&gt; vulnerability.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;To fix this security risk, I used standard UUIDv4 keys [source: 0.1.1]. It stopped the endpoint scanning problem perfectly. However, random text strings create a hidden slowdown inside relational database storage engines over time.&lt;/p&gt;




&lt;h2&gt;
  
  
  THE PROBLEM: B-Tree Page Splits with UUIDv4
&lt;/h2&gt;

&lt;p&gt;PostgreSQL keeps database rows organized on disk using a B-Tree index map. Think of your drive storage space as numbered storage boxes that are already completely full:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;┌───────────┐ ┌───────────┐ ┌───────────┐ ┌───────────┐
│   Box 1   │ │   Box 2   │ │   Box 3   │ │   Box 4   │
│  [ FULL ] │ │  [ FULL ] │ │  [ FULL ] │ │  [ FULL ] │
└───────────┘ └───────────┘ └───────────┘ └───────────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Because UUIDv4 is fully random, a brand-new row ID might belong right in the middle of Box 2. PostgreSQL cannot just append it to the end. It must force Box 2 open, execute a &lt;strong&gt;B-Tree page split&lt;/strong&gt;, and reorganize the disk layout:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;┌───────────┐ ┌───────────┐┌───────────┐ ┌───────────┐ ┌───────────┐
│   Box 1   │ │  Box 2A   ││  Box 2B   │ │   Box 3   │ │   Box 4   │
│  [ FULL ] │ │  [ HALF ] ││  [ HALF ] │ │  [ FULL ] │ │  [ FULL ] │
└───────────┘ └───────────┘└─────▲─────┘ └───────────┘ └───────────┘
                                 │
                     [ New random row forced here. ]
                     [ Everything else shifts down. ]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When tables grow to 1 million+ rows, this constant random disk fragmentation slows down write speeds.&lt;/p&gt;




&lt;h2&gt;
  
  
  THE FIX: UUIDv7's Time-Ordered Prefix
&lt;/h2&gt;

&lt;p&gt;UUIDv7 solves this problem by arranging its internal layout chronologically:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;Layout = [ 48-bit UNIX Timestamp ][ 80 Random Bits ]&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Because the prefix is time-prefixed, every newly generated UUIDv7 is greater than the last one. PostgreSQL can now cleanly append new rows directly to the very end of the index tree:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;┌───────────┐ ┌───────────┐ ┌───────────┐ ┌───────────┐ ┌───────────┐
│   Box 1   │ │   Box 2   │ │   Box 3   │ │   Box 4   │ │   Box 5   │
│  [ FULL ] │ │  [ FULL ] │ │  [ FULL ] │ │  [ FULL ] │ │ [ NEW ROW]│
└───────────┘ └───────────┘ └───────────┘ └───────────┘ └─────▲─────┘
                                                             │
                                                  [ Appends cleanly ]
                                                  [ with zero splits]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No page splits, no disk index reorganizing, and smooth data writing performance at scale.&lt;/p&gt;




&lt;h2&gt;
  
  
  THE STORAGE TRADE-OFF
&lt;/h2&gt;

&lt;p&gt;You must balance system storage memory against data security:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;UUID (v4 or v7)&lt;/strong&gt; ➔ Consumes &lt;strong&gt;16 bytes&lt;/strong&gt; of disk and RAM index space.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;BIGINT&lt;/strong&gt;          ➔ Consumes &lt;strong&gt;8 bytes&lt;/strong&gt; of disk and RAM index space.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;UUIDs take up double the index storage space compared to standard integers, but they keep sensitive resources hidden.&lt;/p&gt;




&lt;h2&gt;
  
  
  RESOLVING QUERY OVERHEAD: THE DUAL-ID PATTERN
&lt;/h2&gt;

&lt;p&gt;Fixing the &lt;em&gt;write&lt;/em&gt; path with UUIDv7 doesn't fully close the topic. There's a related pattern worth knowing about for systems like this: the &lt;strong&gt;internal ID / external ID split&lt;/strong&gt;, where a lightweight &lt;code&gt;BIGINT&lt;/code&gt; is used for internal joins and lookups, while a UUID is exposed externally as the public-facing identifier.&lt;/p&gt;

&lt;p&gt;A 16-byte UUID is more expensive to push through CPU cache lines and join operations than an 8-byte integer. At a high enough query volume, decoupling "what the database uses internally" from "what the API exposes externally" can shave meaningful overhead off hot-path queries.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;┌────────────────────────────────────────────────────────┐
│                   INCOMING API REQUEST                 │
│              GET /api/v1/patients/[ UUID ]             │
└───────────────────────────┬────────────────────────────┘
                            │ Public Route Boundary
                            ▼
┌────────────────────────────────────────────────────────┐
│                   BACKEND SERVICE LAYER                │
│    1. Match external public 16-byte UUID               │
│    2. Extract internal private 8-byte BIGINT (Fast)    │
└───────────────────────────┬────────────────────────────┘
                            │ Internal Network Only
                            ▼
┌────────────────────────────────────────────────────────┐
│                   POSTGRESQL DATABASE ENGINE           │
│     Executes blazing fast JOINs via internal BIGINT    │
└────────────────────────────────────────────────────────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;1. The Internal ID (BIGINT / 8 Bytes)&lt;/strong&gt;: Acts as the primary database key for table lookups and internal resource joins. This key never leaves your private backend network.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;2. The External ID (UUID / 16 Bytes)&lt;/strong&gt;: A standalone unique column used solely for frontend payloads and public routing links to hide database context from automated scanners.&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;📝 &lt;em&gt;Note: I haven't implemented this in MedicoSync — at MVP scale, the query-cost difference is negligible compared to the UUIDv7 write-path fix. But I saw this pattern often, and it's the natural next thing to evaluate if MedicoSync's traffic ever grows to where join performance on the patients/records tables becomes a bottleneck. Worth flagging as the "next escalation point" if you're designing a similar system and expect serious scale early. I will publish a full step-by-step implementation guide on this dual-ID abstraction layer next week!&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  ARCHITECTURAL SUMMARY: WHICH ONE SHOULD YOU CHOOSE?
&lt;/h2&gt;

&lt;p&gt;Choosing between these options depends on the current lifecycle state of your database project:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Scenario 1: Starting a Brand New Project&lt;/strong&gt; ➔ Use native &lt;strong&gt;UUIDv7&lt;/strong&gt;. It provides security for public endpoints out of the box while naturally preserving index write performance on disk.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Scenario 2: Inheriting a Legacy System (Stuck on UUIDv4)&lt;/strong&gt; ➔ Do not rush into a high-risk schema rewrite. You can protect endpoints against scanning attacks by embedding tracking metadata directly inside secure, encrypted &lt;strong&gt;JWT (JSON Web Token) Payloads&lt;/strong&gt;. (I will be breaking down this pattern in an upcoming article!).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Scenario 3: High-Throughput Systems&lt;/strong&gt; ➔ If your system writes millions of entries a day, decouple lookup stress entirely by adding a distributed caching store like &lt;strong&gt;Redis&lt;/strong&gt; to protect your primary database tables.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  THE ARCHITECTURAL VERDICT
&lt;/h2&gt;

&lt;p&gt;At MedicoSync's current MVP scale, the UUIDv4 → UUIDv7 gap isn't an urgent fire [source: 0.1.1].&lt;/p&gt;

&lt;p&gt;The reason it's cheap to defer is a foundational choice made on Day 1: &lt;strong&gt;Alembic migrations&lt;/strong&gt; [source: 0.1.1]. Because the schema is wrapped in Alembic from the start, updating the SQLAlchemy model's UUID generation strategy and auto-generating the migration is a scaffolded, low-risk change — not a rewrite [source: 0.1.1].&lt;/p&gt;

&lt;p&gt;Engineering isn't about shipping a flawless schema on day one [source: 0.1.1]. It's about building something clean enough to evolve when you learn there's a better way [source: 0.1.1].&lt;/p&gt;




&lt;h2&gt;
  
  
  LET'S CONNECT
&lt;/h2&gt;

&lt;p&gt;🛠️ Building a secure, high-performance Python/FastAPI backend, data pipeline, or custom workflow automation engine? Let’s design your platform together:&lt;br&gt;
👉 Work with me on Fiverr: &lt;a href="https://www.fiverr.com/s/gDe5pAo" rel="noopener noreferrer"&gt;Fiverr Profile&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;💾 Explore the full tested architecture codebase:&lt;br&gt;
👉 GitHub Profile: &lt;a href="https://github.com/FanoyG/medicosync" rel="noopener noreferrer"&gt;FanoyG GitHub&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;🛠️ Working through similar backend/schema decisions? Happy to chat — find me here on dev.to or connect on &lt;a href="//linkedin.com/in/adil-khan5/"&gt;LinkedIn&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>python</category>
      <category>fastapi</category>
      <category>systemdesign</category>
    </item>
  </channel>
</rss>
