This is episode five of The Vibecoder's Guide to Postgres.
Your users are complaining. The dashboard takes forever to load. You open your monitoring and find a query that runs against your orders table, half a million rows, filtering by customer and date range. It takes three seconds. Three seconds of your server grinding through every single row, one by one, checking each one like a librarian who lost the card catalog and has to open every book in the building to find the one you want.
You ask your AI assistant to help. It suggests one line. CREATE INDEX. [excited] You run it. The query drops from three seconds to four milliseconds. Same data, same query, same hardware. One line of SQL just made your database seven hundred and fifty times faster.
That is not a typo. That is a real number. And the fact that a single line can produce that kind of improvement should make you deeply curious about what is actually happening underneath. Because if you understand indexes, you understand the single most important performance tool in all of relational databases. And if you do not understand them, you will either never add them and wonder why everything is slow, or add them everywhere and wonder why your writes are getting slower every week.
This is the story of the data structure that makes modern databases possible. It was invented in nineteen seventy by two researchers at Boeing, it has been the backbone of every serious database for over half a century, and there is still an argument about what its name actually means.
Before we get to the invention, let us start with something you have probably seen but possibly never used. A library card catalog.
If you wanted to find a specific book in a library before computers, you walked up to a wooden cabinet with dozens of tiny drawers. Each drawer was labeled with a range of letters. You opened the drawer for the right letter, flipped through alphabetically sorted cards, found your book's card, and it told you exactly which shelf and position to find it. You walked straight there. No wandering. No guessing.
That is what a database index does. Without it, Postgres has to do what is called a sequential scan. It reads every single row in the table, one after another, checking each one against your WHERE clause. This is fine for a table with a hundred rows. It is catastrophic for a table with a hundred million rows. A sequential scan on a large table is the database equivalent of walking through every aisle of the Library of Congress, pulling every book off the shelf, checking the title, and putting it back.
An index is a separate data structure that sits alongside your table. It contains a sorted copy of the values from one or more columns, along with pointers back to the actual rows. When Postgres sees that your query matches an available index, it can look up the value in the index first, find the pointer, and jump directly to the row. Instead of reading half a million rows, it reads maybe four or five pages of index data and then fetches exactly the rows it needs.
But here is where the library analogy breaks down. A card catalog is flat. It is just alphabetically sorted cards in a drawer. That works when you have a few thousand books. It does not work when you have a billion rows, because even a sorted list requires scanning through the list to find what you want. You need something more clever. You need a tree.
In the late nineteen sixties, computer science had a problem. Data was getting bigger than memory.
The machines of that era had tiny amounts of RAM by modern standards, but they were being asked to manage increasingly large collections of data stored on magnetic disks. And disks were slow. Brutally slow. Reading a value from memory took microseconds. Reading the same value from a spinning disk meant waiting for the read head to physically move to the right track, then waiting for the platter to spin to the right sector. Milliseconds. Sometimes tens of milliseconds. A thousand times slower than memory.
The existing solution was the binary search tree. Elegant in theory. You start at the root, compare your search key to the node, go left if smaller, go right if larger, repeat until you find it. For a tree with a million entries, that is about twenty comparisons. Fantastic, if those comparisons are all in memory.
But they were not. Each comparison meant a separate disk read. Twenty disk reads for a single lookup. At ten milliseconds each, that is two hundred milliseconds just to find one value. Scale that to thousands of lookups per second and the entire system grinds to a halt. The binary search tree, designed for a world where all data lived in memory, was failing in a world where data lived on disk.
This was the problem that landed on the desks of two researchers at Boeing Scientific Research Laboratories in nineteen seventy. Rudolf Bayer was a German mathematician who had earned his doctorate at the University of Illinois and was now working at Boeing's research arm. Edward McCreight was a young American computer scientist, fresh from his PhD at Carnegie Mellon, where he had studied computational complexity under Albert Meyer. They were not building airplanes. Boeing's research labs were a place where bright people worked on fundamental problems, and the fundamental problem that Bayer and McCreight were working on was this: how do you organize an index for a very large file stored on disk, so that you can find any record with the fewest possible disk reads?
Their answer was a data structure they called the B-tree. They published it first as Boeing Scientific Research Laboratories Report Number Twenty, in July of nineteen seventy. Two years later, it appeared formally in the journal Acta Informatica. The paper's title was dry and academic: "Organization and Maintenance of Large Ordered Indexes." The idea inside it was anything but dry. It would become the single most important data structure in the history of databases.
Before we get into how it works, we need to address the single most debated question in computer science naming history. What does the B in B-tree stand for?
Boeing? It was invented at Boeing. Balanced? The tree is always perfectly balanced. Bayer? Rudolf Bayer was the senior author. Broad? The nodes are very wide compared to a binary tree. Bushy? Same idea. Between? The keys sit between the pointers.
[whisper] The answer is: nobody knows. And that is not because the information was lost. It is because Bayer and McCreight deliberately never said.
McCreight was once asked directly what the B stands for. His response began with two words.
[laugh] Everybody does!
He went on to explain that they were sitting at lunch at Boeing and needed to name their data structure. They could not call it the Boeing tree without involving the lawyers. So there was a B. It had to do with balance. There was another B. Bayer was the senior author and had many more publications. There was another B. But at the lunch table, they never resolved whether one of those meanings made more sense than the rest.
[calm] The more you think about what the B in B-tree means, the better you understand B-trees.
That might be the most elegant non-answer in the history of computer science. And he was right. The B-tree is balanced, it is broad, it was built at Boeing by Bayer. The name captures all of it and commits to none of it. Fifty-six years later, the debate continues in every computer science classroom and every Stack Overflow thread, and nobody has ever produced a definitive answer. Because one does not exist.
This next section goes deep into the internals of how B-trees work. If you just want to know when and why to use indexes, skip ahead to the chapter called "The Index Menu." But if you want to understand the data structure that underpins nearly every database on earth, stay with me. This is the deepest rabbit hole in the season, and it is worth the trip.
Imagine a filing cabinet. Not a normal one with four drawers. A magical filing cabinet where every drawer contains either folders of documents, or directions to other filing cabinets. And every filing cabinet on every floor of an infinitely tall building follows the exact same rules.
A B-tree is a tree, like a family tree drawn upside down. There is one node at the top, the root, and it branches downward into children, which branch into more children, all the way down to the leaves at the bottom. But unlike a binary tree, where each node has at most two children, a B-tree node can have hundreds of children. This is the key insight. This is what Bayer and McCreight figured out at Boeing.
Here is why it matters. In Postgres, the default page size is eight kilobytes. That is the fundamental unit of storage. Every time Postgres reads anything from disk, it reads a full eight-kilobyte page. A B-tree node is designed to fit exactly into one page. And a single eight-kilobyte page can hold roughly three hundred index entries. Each of those entries points to a child page, which also holds three hundred entries.
[slow] Do the math. The root page points to up to six hundred child pages. Each of those points to three hundred leaf pages. Each leaf page holds three hundred entries. In just three levels of tree, you can index six hundred times three hundred times three hundred rows. That is fifty-four million rows, addressable with exactly three disk reads. <break time="1s"/> Add a fourth level and you are in the tens of billions.
A binary tree with fifty-four million entries would be about twenty-six levels deep. Twenty-six disk reads. A B-tree covering the same data needs three. That is the entire invention. Not a clever algorithm. A clever shape. Make the nodes wide instead of tall, so each disk read eliminates not half the search space but hundreds of times the search space.
When you search a B-tree, you start at the root page. The keys in that page are sorted. You find where your search value falls between the keys and follow the corresponding child pointer to the next page. You load that page, find where your value falls, follow another pointer. In three or four hops, you are at a leaf page that contains the actual pointer to your table row. Postgres fetches the row. Done. Four disk reads. Four pages. Thirty-two kilobytes of data read to find one row among billions.
When you insert a new value, the tree finds the correct leaf page and slots the new key in sorted order. If the page is full, it splits. The page divides into two pages, each about half full, and the median key gets pushed up into the parent page. If the parent is also full, it splits too, and the cascade continues upward. In the rare case that the root splits, a new root is created and the tree grows one level taller. This is the only way a B-tree gets taller, a root split, and it is why all leaves are always at the same depth. [slow] The tree grows from the top, not from the bottom. It is always perfectly balanced. Every path from root to leaf is exactly the same length.
Deletion works in reverse. Remove a key from a leaf. If the leaf gets too empty, it borrows a key from a sibling or merges with one. The changes cascade upward if needed. The tree stays balanced. Always.
This is what Postgres is doing behind every indexed query you run. A quiet, elegant traversal of a tree that was designed in nineteen seventy to minimize the one thing that made computers slow: waiting for a disk to spin.
Back from the rabbit hole. Postgres gives you six types of indexes, but in practice, you will use four of them. Think of it as a restaurant menu where the specials are genuinely special and the regular items cover ninety-five percent of what anyone orders.
The default, the one you get when you type CREATE INDEX without specifying a type, is the B-tree. It handles equality checks, range queries, sorting, and pattern matching with anchored LIKE expressions. If you are filtering by a date range, sorting by a timestamp, looking up a user by ID, or searching for names that start with a specific prefix, B-tree is your index. You could use nothing but B-tree indexes for your entire career and be fine.
Hash indexes are the specialist. They store a thirty-two-bit hash of each value and can only answer one question: is this value exactly equal to that value? No ranges, no sorting, no greater-than. Just equality. They used to be unreliable in Postgres because they were not written to the write-ahead log, which meant they could corrupt after a crash. That was fixed in version ten. For pure equality lookups on large values, a hash index can be slightly faster and more compact than a B-tree, but in practice, most people just use B-tree.
GIN, which stands for Generalized Inverted Index, is where things get interesting. Think of a book index. The back of a textbook has an alphabetical list of terms, and after each term, a list of page numbers where that term appears. A GIN index works exactly like that. It takes a composite value, a JSON document, an array, a text search vector, and breaks it into individual elements. Each element gets an entry in the index, pointing to all the rows that contain it.
This is what makes full-text search work in Postgres. When you create a GIN index on a tsvector column, Postgres breaks every document into individual words, or lexemes, and builds an inverted index mapping each word to the list of rows containing it. Searching for a word means looking up one entry in the index and getting back the complete list of matching rows instantly. GIN also powers JSONB queries. When you ask "find all rows where the JSON has a key called status with a value of active," a GIN index on that JSONB column can answer that without touching the table at all.
The trade-off is writes. Because one row might contain a JSON document with fifty keys, inserting that one row means inserting fifty entries into the GIN index. GIN indexes are slow to update and expensive to maintain. They are brilliant for read-heavy workloads with complex data types. They are painful for write-heavy tables.
GiST, the Generalized Search Tree, is the framework index. It is not one data structure but an infrastructure for building different indexing schemes. The most common use is spatial data. If you are running PostGIS and storing geographic coordinates, a GiST index organizes your data into nested bounding rectangles. Want to find all restaurants within two kilometers of a point? The GiST index can skip entire regions of the map that are obviously too far away, narrowing the search to just the relevant area. GiST also handles range types, network addresses, and certain full-text search operations.
The other two, SP-GiST and BRIN, are niche tools. SP-GiST supports non-balanced tree structures like quadtrees and radix trees. BRIN, the Block Range Index, stores summaries of value ranges for physical block ranges, making it incredibly compact for naturally ordered data like timestamps on append-only tables. A BRIN index on a timestamp column might be a thousand times smaller than a B-tree index on the same column, at the cost of less precise lookups.
The four index types are the foundation. But Postgres has two features that turn basic indexes into surgical instruments. These are the features that separate someone who knows indexes from someone who just adds them.
A partial index is an index that only covers some of the rows in a table. You add a WHERE clause to the CREATE INDEX statement, and Postgres only indexes the rows that match. Consider a table of orders where ninety-eight percent are completed and two percent are pending. You almost never query completed orders by status, but you constantly query pending ones. A full index on the status column would include every row, mostly entries for "completed" that nobody ever searches for. A partial index with WHERE status equals pending only indexes the two percent you actually care about. It is smaller, faster to scan, faster to maintain, and uses a fraction of the storage.
This is where the boolean column question comes in. You might have a column called is_active. If half your rows are active and half are not, a B-tree index on that column is almost useless. The index has only two distinct values, and the planner will often decide a sequential scan is cheaper than using such a low-selectivity index. But a partial index on the table WHERE is_active equals true, indexing a different column like created_at, is extremely useful. You are not indexing the boolean. You are using the boolean to limit which rows get indexed, and then indexing the column you actually search by.
Expression indexes let you index the result of a function or computation. The classic example is case-insensitive search. Your users table has an email column with mixed case. You want to find users by email regardless of case. Without an expression index, Postgres cannot use a regular index on the email column for a query that wraps it in LOWER, because the index stores the original mixed-case values and LOWER of a value does not match the stored entries.
The fix is CREATE INDEX on LOWER of email. Postgres computes the lowercase version of every email, stores those computed values in the index, and now any query using LOWER of email in the WHERE clause can use the index directly. The function must be immutable, meaning its output depends only on its input and never on external state. LOWER qualifies. NOW does not.
Combine the two and you get genuinely powerful things. A partial expression index on LOWER of email WHERE is_active equals true gives you a tiny, focused index that answers exactly the query your application runs a thousand times per second, and ignores everything else.
Here is another rabbit hole, shorter this time. When Postgres uses an index to answer a query, it normally does two things. First, it looks up the matching entries in the index to get row pointers. Second, it follows those pointers to the actual table to fetch the full rows. That second step, the trip to the table, is called a heap fetch, and it is often the slower part.
But what if the index already contains all the columns the query needs? If you are running SELECT customer_id, order_date FROM orders WHERE customer_id equals something, and your index is on customer_id and also includes order_date, Postgres can answer the query entirely from the index. It never touches the table. This is called an index-only scan, and it can be dramatically faster, especially on large tables where the heap data is scattered across many disk pages.
PostgreSQL eleven introduced the INCLUDE clause to make this easier. You can create an index on the columns you search by and INCLUDE additional columns that you want available for index-only scans but do not need in the search tree itself. The included columns are stored in the leaf pages of the index but are not part of the tree structure, so they do not affect the index's ability to narrow the search. They are just along for the ride, ready to be read when the query needs them.
The catch is the visibility map. Postgres uses multi-version concurrency control, which means old versions of rows can linger in the table until vacuum cleans them up. An index-only scan can only skip the heap fetch for pages where the visibility map confirms all rows are visible to all transactions. If vacuum has not run recently, Postgres has to check the table anyway to verify visibility, and the performance benefit disappears. This is one of many reasons to keep vacuum running well, a topic for a future episode.
Everything so far has been about what indexes give you. Now for what they take.
Every index is a separate data structure stored on disk, alongside your table. It takes space. A table with five indexes might have indexes that collectively take up more disk space than the table itself. [surprised] One real-world case had a twenty-three gigabyte table with forty-four gigabytes of indexes. Nearly double the data, just for the indexes.
But space is cheap. The real cost is write amplification. Every time you insert a row, Postgres has to update the table and every single index on that table. Five indexes means six writes for every insert. Update a row? Postgres actually inserts a new version of the row due to its multi-version concurrency model, and the old version remains until vacuum cleans it up. That new version needs entries in every index. Delete a row? The index entries need to be marked for removal and eventually cleaned up by vacuum.
Postgres has a clever optimization called HOT updates, which stands for Heap-Only Tuple. If you update a column that is not part of any index, and there is free space on the same page as the old row, Postgres can create the new row version without touching any indexes at all. The old index entries simply follow a chain to the new row version. This is a huge performance win for tables with many indexes that get frequent updates to non-indexed columns. But the moment you add an index on a column that gets updated frequently, you lose HOT eligibility for those updates. Every update now has to create new entries in every index.
Then there is bloat. As rows are updated and deleted, the old index entries linger. Vacuum cleans them up, but it has to scan every index on the table to do so. More indexes means vacuum takes longer. If vacuum falls behind, index bloat accumulates, the index gets larger, scans get slower, and you enter a spiral where the performance problem feeds itself. This is not theoretical. It is one of the most common production Postgres problems.
The takeaway is straightforward. Every index you add makes reads faster and writes slower. The question is never "should I add an index" but "is the read improvement worth the write cost for this specific workload?"
And here is where we get to the heart of why this matters for you, the developer building things with AI assistance.
When you paste a slow query into your AI assistant and ask "how do I make this faster," the answer almost always starts with "add an index." This is not wrong, exactly. It is a correct first guess for a common problem. But it is a guess. The AI does not know your data distribution. It does not know whether the column it suggests indexing has two distinct values or two million. It does not know your write pattern. It does not know whether this table gets a hundred inserts per second or a hundred thousand. It does not know whether you already have six other indexes on this table, each one adding write amplification and vacuum overhead.
[angry] I asked ChatGPT to optimize my database and it told me to add an index on every column in my WHERE clause. I now have eleven indexes on a table with seven columns. My inserts went from two milliseconds to forty. What happened?
That is the index version of cargo cult programming. The AI saw a pattern, "slow query, add index," and applied it without understanding the cost. Here are the most common mistakes:
First, indexing low-cardinality columns without partial indexes. If your column has three distinct values, a B-tree index on it is almost useless. The planner will usually choose a sequential scan because the index is not selective enough to be worth the overhead. The right answer might be a partial index that targets the rare value, or no index at all.
Second, creating redundant indexes. If you have an index on columns A and B together, you do not also need a separate index on column A alone. The composite index already covers queries that filter on A. The AI often does not check for existing indexes before suggesting new ones.
Third, ignoring the write side. The AI optimizes for the query you showed it. It does not think about the hundred other queries that insert and update the same table. Every index suggestion is an optimization for reads at the expense of writes. The AI will never ask you, "how often does this table get written to?"
[calm] The best index is the one you do not create. If a query runs once a day and takes three seconds, that is not a problem worth adding an index for. If it runs ten thousand times a second and takes three seconds, that is a crisis. Context matters.
Fourth, missing partial and expression indexes. These are the power tools, and AI assistants rarely suggest them unprompted. When they do, it is usually the basic form. They almost never suggest combining a partial index with an expression index or using INCLUDE for covering index strategies. These are the optimizations that separate a database that works from a database that sings.
The honest answer is that index tuning requires information the AI does not have. It requires knowing your data, not just your schema. The distribution of values, the ratio of reads to writes, the patterns of access over time, the available disk space, and the tolerance for vacuum overhead. You need to understand indexes well enough to evaluate the AI's suggestions, not just accept them. The AI is a good first draft. You are the editor.
You have made it fast. Your queries hum along, hopping through B-tree pages in three or four disk reads, finding exactly the rows they need. Your partial indexes keep the hot paths lean. Your expression indexes handle the edge cases. You know what indexes cost and you have made deliberate choices about where to pay that cost.
[serious] But speed is only half of the database story. The other half is safety. What happens when you are halfway through writing ten rows and the power goes out? What happens when two users try to update the same row at the same time? What happens when a migration goes wrong and you need to undo everything?
In the next episode, we are going to talk about the migration. Changing your schema on a live database, without breaking everything. The thing that makes every developer sweat. That is episode six, The Migration Fear.