{"id":6882,"date":"2025-09-03T08:19:36","date_gmt":"2025-09-03T13:19:36","guid":{"rendered":"https:\/\/frontendmasters.com\/blog\/?p=6882"},"modified":"2025-09-03T08:28:19","modified_gmt":"2025-09-03T13:28:19","slug":"advanced-postgresql-indexing","status":"publish","type":"post","link":"https:\/\/frontendmasters.com\/blog\/advanced-postgresql-indexing\/","title":{"rendered":"Advanced PostgreSQL Indexing: Multi-Key Queries and Performance Optimization"},"content":{"rendered":"\n<p>Welcome to part two of our exploration of Postgres indexes. Be sure to check out part one if you haven&#8217;t already. We&#8217;ll be picking up exactly where we left off.<\/p>\n\n\n<div class=\"box article-series\">\n  <header>\n    <h3 class=\"article-series-header\">Article Series<\/h3>\n  <\/header>\n  <div class=\"box-content\">\n            <ol>\n                      <li>\n              <a href=\"https:\/\/frontendmasters.com\/blog\/intro-to-postgres-indexes\/\">Introduction to Postgres Indexes<\/a>\n            <\/li>\n                      <li>\n              <a href=\"https:\/\/frontendmasters.com\/blog\/advanced-postgresql-indexing\/\">Advanced PostgreSQL Indexing: Multi-Key Queries and Performance Optimization<\/a>\n            <\/li>\n                  <\/ol>\n        <\/div>\n<\/div>\n\n\n\n<p>We have the same books table as before, containing approximately 90 million records.<\/p>\n\n\n\n<p class=\"learn-more\"><strong>Editors&#8217; note: <\/strong>Need to bone up on PostgreSQL all around? Our course <a href=\"https:\/\/frontendmasters.com\/courses\/sql\/?utm_source=boost&amp;utm_medium=blog&amp;utm_campaign=boost\">Complete Intro to SQL &amp; PostgreSQL<\/a> from Brian Holt will be perfect for you.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Filtering and sorting<\/h2>\n\n\n\n<p>Let&#8217;s dive right in. Imagine you work for a book distribution company. You&#8217;re responsible for publishers and need to query info on them. There are approximately 250,000 different publishers, with a wide variance in the number of books published by each, which we&#8217;ll explore.<\/p>\n\n\n\n<p>Let&#8217;s start easy. You want to see the top 10 books, sorted alphabetically, for a single publisher.<\/p>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-1\" data-shcb-language-name=\"PostgreSQL SQL dialect and PL\/pgSQL\" data-shcb-language-slug=\"pgsql\"><span><code class=\"hljs language-pgsql\"><span class=\"hljs-keyword\">explain<\/span> <span class=\"hljs-keyword\">analyze<\/span>\n<span class=\"hljs-keyword\">select<\/span> *\n<span class=\"hljs-keyword\">from<\/span> books\n<span class=\"hljs-keyword\">where<\/span> publisher = <span class=\"hljs-number\">157595<\/span>\n<span class=\"hljs-keyword\">order<\/span> <span class=\"hljs-keyword\">by<\/span> title\n<span class=\"hljs-keyword\">limit<\/span> <span class=\"hljs-number\">10<\/span>;\n<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-1\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">PostgreSQL SQL dialect and PL\/pgSQL<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">pgsql<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<p>This publisher is relatively small, with only 65 books in its catalog. Nonetheless, the query is slow to run, taking almost <strong>four seconds<\/strong>.<\/p>\n\n\n\n<p class=\"learn-more\">If you followed the same steps from part 1 to create this same database, note that your ids will be different.<\/p>\n\n\n\n<figure class=\"wp-block-image size-large\"><img data-recalc-dims=\"1\" loading=\"lazy\" decoding=\"async\" width=\"1024\" height=\"462\" src=\"https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2025\/08\/img1-basic-publisher-query.png?resize=1024%2C462&#038;ssl=1\" alt=\"A detailed execution plan showing the performance analysis of a query in Postgres, including sorting and filtering operations.\" class=\"wp-image-6885\" srcset=\"https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2025\/08\/img1-basic-publisher-query.png?resize=1024%2C462&amp;ssl=1 1024w, https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2025\/08\/img1-basic-publisher-query.png?resize=300%2C135&amp;ssl=1 300w, https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2025\/08\/img1-basic-publisher-query.png?resize=768%2C347&amp;ssl=1 768w, https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2025\/08\/img1-basic-publisher-query.png?resize=1536%2C693&amp;ssl=1 1536w, https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2025\/08\/img1-basic-publisher-query.png?resize=2048%2C924&amp;ssl=1 2048w\" sizes=\"auto, (max-width: 1000px) 100vw, 1000px\" \/><\/figure>\n\n\n\n<p>This is hardly surprising; there are a lot of rows in our table, and finding the rows for that publisher takes a while, since Postgres has to scan the entire heap.<\/p>\n\n\n\n<p>So we add an index on, for now, just publisher.<\/p>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-2\" data-shcb-language-name=\"PostgreSQL SQL dialect and PL\/pgSQL\" data-shcb-language-slug=\"pgsql\"><span><code class=\"hljs language-pgsql\"><span class=\"hljs-keyword\">CREATE<\/span> <span class=\"hljs-keyword\">INDEX<\/span> idx_publisher <span class=\"hljs-keyword\">ON<\/span> books(publisher);\n<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-2\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">PostgreSQL SQL dialect and PL\/pgSQL<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">pgsql<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<p>We can think of our index in this way. It just helps us identify all the book entries by publisher. To get the rest of the info on the book, we go to the heap.<\/p>\n\n\n\n<figure class=\"wp-block-image size-large\"><img data-recalc-dims=\"1\" loading=\"lazy\" decoding=\"async\" width=\"1024\" height=\"294\" src=\"https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2025\/08\/img2-publisher-btree.png?resize=1024%2C294&#038;ssl=1\" alt=\"A visual representation of a tree structure showing nodes and leaves, illustrating the hierarchical arrangement of data, likely related to a database index.\" class=\"wp-image-6886\" srcset=\"https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2025\/08\/img2-publisher-btree-scaled.png?resize=1024%2C294&amp;ssl=1 1024w, https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2025\/08\/img2-publisher-btree-scaled.png?resize=300%2C86&amp;ssl=1 300w, https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2025\/08\/img2-publisher-btree-scaled.png?resize=768%2C220&amp;ssl=1 768w, https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2025\/08\/img2-publisher-btree-scaled.png?resize=1536%2C441&amp;ssl=1 1536w, https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2025\/08\/img2-publisher-btree-scaled.png?resize=2048%2C588&amp;ssl=1 2048w\" sizes=\"auto, (max-width: 1000px) 100vw, 1000px\" \/><\/figure>\n\n\n\n<p>And now our same query is incredibly fast.<\/p>\n\n\n\n<figure class=\"wp-block-image size-large\"><img data-recalc-dims=\"1\" loading=\"lazy\" decoding=\"async\" width=\"1024\" height=\"225\" src=\"https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2025\/08\/img3-small-publisher-query-plan.png?resize=1024%2C225&#038;ssl=1\" alt=\"Execution plan displayed for a SQL query, includes cost, index conditions, number of rows, and execution time details.\" class=\"wp-image-6887\" srcset=\"https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2025\/08\/img3-small-publisher-query-plan.png?resize=1024%2C225&amp;ssl=1 1024w, https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2025\/08\/img3-small-publisher-query-plan.png?resize=300%2C66&amp;ssl=1 300w, https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2025\/08\/img3-small-publisher-query-plan.png?resize=768%2C169&amp;ssl=1 768w, https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2025\/08\/img3-small-publisher-query-plan.png?resize=1536%2C338&amp;ssl=1 1536w, https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2025\/08\/img3-small-publisher-query-plan.png?resize=2048%2C450&amp;ssl=1 2048w\" sizes=\"auto, (max-width: 1000px) 100vw, 1000px\" \/><\/figure>\n\n\n\n<p>Nothing surprising or interesting.<\/p>\n\n\n\n<p>But now you need to run the same query, but on a different publisher, number 210537. This is the biggest publisher in the entire database, with over 2 million books. Let&#8217;s see how our index fares.<\/p>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-3\" data-shcb-language-name=\"PostgreSQL SQL dialect and PL\/pgSQL\" data-shcb-language-slug=\"pgsql\"><span><code class=\"hljs language-pgsql\"><span class=\"hljs-keyword\">explain<\/span> <span class=\"hljs-keyword\">analyze<\/span>\n<span class=\"hljs-keyword\">select<\/span> *\n<span class=\"hljs-keyword\">from<\/span> books\n<span class=\"hljs-keyword\">where<\/span> publisher = <span class=\"hljs-number\">210537<\/span>\n<span class=\"hljs-keyword\">order<\/span> <span class=\"hljs-keyword\">by<\/span> title\n<span class=\"hljs-keyword\">limit<\/span> <span class=\"hljs-number\">10<\/span>;\n<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-3\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">PostgreSQL SQL dialect and PL\/pgSQL<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">pgsql<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<figure class=\"wp-block-image size-large\"><img data-recalc-dims=\"1\" loading=\"lazy\" decoding=\"async\" width=\"1024\" height=\"449\" src=\"https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2025\/08\/img4-large-publisher-plan.png?resize=1024%2C449&#038;ssl=1\" alt=\"A detailed query plan output for a PostgreSQL database showing execution details, including cost, rows returned, and time taken for sorting and scanning operations.\" class=\"wp-image-6888\" srcset=\"https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2025\/08\/img4-large-publisher-plan.png?resize=1024%2C449&amp;ssl=1 1024w, https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2025\/08\/img4-large-publisher-plan.png?resize=300%2C132&amp;ssl=1 300w, https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2025\/08\/img4-large-publisher-plan.png?resize=768%2C337&amp;ssl=1 768w, https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2025\/08\/img4-large-publisher-plan.png?resize=1536%2C674&amp;ssl=1 1536w, https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2025\/08\/img4-large-publisher-plan.png?resize=2048%2C898&amp;ssl=1 2048w\" sizes=\"auto, (max-width: 1000px) 100vw, 1000px\" \/><\/figure>\n\n\n\n<p>Actually, our index wasn&#8217;t used at all. Postgres just scanned the whole table, grabbing our publisher along the way, and then sorted the results to get the top 10. We&#8217;ll discuss why a little later, as we did in the prior post, but the short of it is that the <strong>random<\/strong> heap accesses from reading so many entries off of an index would be expensive; Postgres decided the scan would be cheaper. These decisions are all about tradeoffs and are governed by statistics and cost estimates.<\/p>\n\n\n\n<p>Previously, we threw the &#8220;other&#8221; field into the <code>INCLUDE()<\/code> list, so the engine wouldn&#8217;t have to leave the index to get the other field it needed. In this case, we&#8217;re selecting <em>everything<\/em>. I said previously to be diligent in avoiding unnecessary columns in the <code>SELECT<\/code> clause for just this reason, but here, we assume we actually do need all these columns.<\/p>\n\n\n\n<p>We probably don&#8217;t want to dump every single column into the <code>INCLUDE<\/code> list of the index: we&#8217;d basically just be redefining our table into an index.<\/p>\n\n\n\n<p>But why do we need to read so many rows in the first place? We have a limit of 10 on our query. The problem, of course, is that we&#8217;re ordering on title. And Postgres needs to see all rows for a publisher (2 million rows in this case) in order to sort them, and grab the first 10.<\/p>\n\n\n\n<p>What if we built an index on <code>publisher<\/code>, <em>and then<\/em> <code>title<\/code>?<\/p>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-4\" data-shcb-language-name=\"PostgreSQL SQL dialect and PL\/pgSQL\" data-shcb-language-slug=\"pgsql\"><span><code class=\"hljs language-pgsql\"><span class=\"hljs-keyword\">CREATE<\/span> <span class=\"hljs-keyword\">INDEX<\/span> idx_publisher_title <span class=\"hljs-keyword\">ON<\/span> books(publisher, title);\n<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-4\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">PostgreSQL SQL dialect and PL\/pgSQL<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">pgsql<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<p>That would look like this:<\/p>\n\n\n\n<figure class=\"wp-block-image size-large\"><img data-recalc-dims=\"1\" loading=\"lazy\" decoding=\"async\" width=\"1024\" height=\"347\" src=\"https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2025\/08\/img5-publisher-title-index.png?resize=1024%2C347&#038;ssl=1\" alt=\"A diagram illustrating a B-tree structure for organizing books, showing nodes and leaves with book titles like 'Jane Eyre' and 'War and Peace' arranged hierarchically.\" class=\"wp-image-6889\" srcset=\"https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2025\/08\/img5-publisher-title-index.png?resize=1024%2C347&amp;ssl=1 1024w, https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2025\/08\/img5-publisher-title-index.png?resize=300%2C102&amp;ssl=1 300w, https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2025\/08\/img5-publisher-title-index.png?resize=768%2C261&amp;ssl=1 768w, https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2025\/08\/img5-publisher-title-index.png?resize=1536%2C521&amp;ssl=1 1536w, https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2025\/08\/img5-publisher-title-index.png?resize=2048%2C695&amp;ssl=1 2048w\" sizes=\"auto, (max-width: 1000px) 100vw, 1000px\" \/><\/figure>\n\n\n\n<p>If Postgres were to search for a specific publisher, it could just seek down to the start of that publisher&#8217;s books, and then read however many needed, right off the leaf nodes, couldn&#8217;t it? There could be 2 million book entries in the leaf nodes, but Postgres could just read the first 10, and be guaranteed that they&#8217;re the first 10, since that&#8217;s how the index is ordered.<\/p>\n\n\n\n<p>Let&#8217;s try it.<\/p>\n\n\n\n<figure class=\"wp-block-image size-large\"><img data-recalc-dims=\"1\" loading=\"lazy\" decoding=\"async\" width=\"1024\" height=\"150\" src=\"https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2025\/08\/img6-fast-search-large-pub.png?resize=1024%2C150&#038;ssl=1\" alt=\"Execution plan showing the limit, index scan using idx_publisher_title on books, and the planning and execution times.\" class=\"wp-image-6891\" srcset=\"https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2025\/08\/img6-fast-search-large-pub.png?resize=1024%2C150&amp;ssl=1 1024w, https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2025\/08\/img6-fast-search-large-pub.png?resize=300%2C44&amp;ssl=1 300w, https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2025\/08\/img6-fast-search-large-pub.png?resize=768%2C112&amp;ssl=1 768w, https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2025\/08\/img6-fast-search-large-pub.png?resize=1536%2C225&amp;ssl=1 1536w, https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2025\/08\/img6-fast-search-large-pub.png?resize=2048%2C299&amp;ssl=1 2048w\" sizes=\"auto, (max-width: 1000px) 100vw, 1000px\" \/><\/figure>\n\n\n\n<p>We got the top 10 books, sorted, from a list of over two million in less than a fourth of a millisecond. Amazing!<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">More publishers!<\/h2>\n\n\n\n<p>Now your boss comes and tells you to query the top 10 books, sorted alphabetically, as before, but over <strong>either<\/strong> publisher, combined. To be clear, the requirement is to take all books both publishers have published, combine them, then get the first ten, alphabetically.<\/p>\n\n\n\n<p>Easy, you say assuredly, fresh off the high of seeing Postgres grab you that same data for your enormous publisher in under a millisecond.<\/p>\n\n\n\n<p>You can put both publisher ids into an <code>IN<\/code> clause. Then, Postgres can search for each, one at a time, save the starting points of both, and then start reading forward on both, and sort of merge them together, taking the smaller title from either, until you have 10 books total.<\/p>\n\n\n\n<p>Let&#8217;s try it!<\/p>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-5\" data-shcb-language-name=\"PostgreSQL SQL dialect and PL\/pgSQL\" data-shcb-language-slug=\"pgsql\"><span><code class=\"hljs language-pgsql\"><span class=\"hljs-keyword\">explain<\/span> <span class=\"hljs-keyword\">analyze<\/span>\n<span class=\"hljs-keyword\">select<\/span> *\n<span class=\"hljs-keyword\">from<\/span> books\n<span class=\"hljs-keyword\">where<\/span> publisher <span class=\"hljs-keyword\">in<\/span> (<span class=\"hljs-number\">157595<\/span>, <span class=\"hljs-number\">210537<\/span>)\n<span class=\"hljs-keyword\">order<\/span> <span class=\"hljs-keyword\">by<\/span> title\n<span class=\"hljs-keyword\">limit<\/span> <span class=\"hljs-number\">10<\/span>;\n<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-5\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">PostgreSQL SQL dialect and PL\/pgSQL<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">pgsql<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<p>Which produces this<\/p>\n\n\n\n<figure class=\"wp-block-image size-large\"><img data-recalc-dims=\"1\" loading=\"lazy\" decoding=\"async\" width=\"1024\" height=\"447\" src=\"https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2025\/08\/img7-multiple-publishers.png?resize=1024%2C447&#038;ssl=1\" alt=\"Query plan output from a PostgreSQL execution showing the execution strategy and performance metrics.\" class=\"wp-image-6892\" srcset=\"https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2025\/08\/img7-multiple-publishers.png?resize=1024%2C447&amp;ssl=1 1024w, https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2025\/08\/img7-multiple-publishers.png?resize=300%2C131&amp;ssl=1 300w, https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2025\/08\/img7-multiple-publishers.png?resize=768%2C335&amp;ssl=1 768w, https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2025\/08\/img7-multiple-publishers.png?resize=1536%2C671&amp;ssl=1 1536w, https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2025\/08\/img7-multiple-publishers.png?resize=2048%2C894&amp;ssl=1 2048w\" sizes=\"auto, (max-width: 1000px) 100vw, 1000px\" \/><\/figure>\n\n\n\n<p><em>[Sad Trombone]<\/em><\/p>\n\n\n\n<p>Let&#8217;s re-read my completely made-up, assumed chain of events Postgres would take, from above.<\/p>\n\n\n\n<blockquote class=\"wp-block-quote is-layout-flow wp-block-quote-is-layout-flow\">\n<p>Postgres can search for each, one at a time, save the starting points of both, and then start reading forward on both, and sort of merge them together, taking the smaller title from either, until you have 10 books total.<\/p>\n<\/blockquote>\n\n\n\n<p>It reads like the Charlie meme from Always Sunny in Philadelphia.<\/p>\n\n\n<div class=\"wp-block-image\">\n<figure class=\"aligncenter size-full is-resized\"><img data-recalc-dims=\"1\" loading=\"lazy\" decoding=\"async\" width=\"640\" height=\"404\" src=\"https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2025\/08\/always-sunny-meme.gif?resize=640%2C404&#038;ssl=1\" alt=\"A scene from a television show featuring a man pointing at a chaotic board covered in papers, with red strings connecting different documents, emphasizing a frenzied investigation.\" class=\"wp-image-6893\" style=\"width:425px;height:auto\"\/><\/figure>\n<\/div>\n\n\n<p>If your description of what the database will do sounds like something that would fit with this meme, you&#8217;re probably overthinking things.<\/p>\n\n\n\n<p>Postgres operates on very simple operations that it chains together. Index Scan, Gather Merge, Sort, Sequential Scan, etc.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Searching multiple publishers<\/h2>\n\n\n\n<p>To be crystal clear, Postgres absolutely can search multiple keys from an index. Here&#8217;s the execution plan for the identical query from a moment ago, but with two small publishers for the publisher ids, which each have just a few hundred books<\/p>\n\n\n\n<figure class=\"wp-block-image size-large\"><img data-recalc-dims=\"1\" loading=\"lazy\" decoding=\"async\" width=\"1024\" height=\"215\" src=\"https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2025\/08\/img8-multiple-small-publishers.png?resize=1024%2C215&#038;ssl=1\" alt=\"Query plan visualization showing execution details for a SQL command, including limit, sort key, and index scan operations with timing statistics.\" class=\"wp-image-6894\" srcset=\"https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2025\/08\/img8-multiple-small-publishers.png?resize=1024%2C215&amp;ssl=1 1024w, https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2025\/08\/img8-multiple-small-publishers.png?resize=300%2C63&amp;ssl=1 300w, https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2025\/08\/img8-multiple-small-publishers.png?resize=768%2C162&amp;ssl=1 768w, https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2025\/08\/img8-multiple-small-publishers.png?resize=1536%2C323&amp;ssl=1 1536w, https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2025\/08\/img8-multiple-small-publishers.png?resize=2048%2C431&amp;ssl=1 2048w\" sizes=\"auto, (max-width: 1000px) 100vw, 1000px\" \/><\/figure>\n\n\n\n<p>It did indeed do an index scan, on that same index. It just matched two values at once.<\/p>\n\n\n\n<p>Rather than taking one path down the B Tree, it takes multiple paths down the B Tree, based on the multiple key value matches.<\/p>\n\n\n\n<pre class=\"wp-block-preformatted\">Index Cond: (publisher = ANY ('{157595,141129}'::integer[]))\n<\/pre>\n\n\n\n<p>That gives us <strong>all<\/strong> rows for <em>either<\/em> publisher. Then it needs to sort them, which it does next, followed by the limit.<\/p>\n\n\n\n<p>Why does it need to sort them? When we have a <em>single<\/em> publisher, we <em>know<\/em> all values under that publisher are ordered.<\/p>\n\n\n\n<p><em>Look<\/em> at the index.<\/p>\n\n\n\n<figure class=\"wp-block-image size-large\"><img data-recalc-dims=\"1\" loading=\"lazy\" decoding=\"async\" width=\"1024\" height=\"352\" src=\"https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2025\/08\/img9-single-publisher-read.png?resize=1024%2C352&#038;ssl=1\" alt=\"A flowchart representing a tree structure of book titles, with nodes for major titles like 'Jane Eyre' and 'War and Peace,' and leaves for individual entries, including 'The Great Gatsby' and 'Moby Dick.'\" class=\"wp-image-6895\" srcset=\"https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2025\/08\/img9-single-publisher-read.png?resize=1024%2C352&amp;ssl=1 1024w, https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2025\/08\/img9-single-publisher-read.png?resize=300%2C103&amp;ssl=1 300w, https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2025\/08\/img9-single-publisher-read.png?resize=768%2C264&amp;ssl=1 768w, https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2025\/08\/img9-single-publisher-read.png?resize=1536%2C528&amp;ssl=1 1536w, https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2025\/08\/img9-single-publisher-read.png?resize=2048%2C704&amp;ssl=1 2048w\" sizes=\"auto, (max-width: 1000px) 100vw, 1000px\" \/><\/figure>\n\n\n\n<p>Imagine we searched for publisher 8. Postgres can go directly to the beginning of that publisher, and <em>just read<\/em>:<\/p>\n\n\n\n<pre class=\"wp-block-preformatted\">\"Animal Farm\"<br>\"Of Mice and Men\"<br><\/pre>\n\n\n\n<p>Look what happens when we search for <em>two<\/em> publishers, 8 and also, now, 21.<\/p>\n\n\n\n<figure class=\"wp-block-image size-large\"><img data-recalc-dims=\"1\" loading=\"lazy\" decoding=\"async\" width=\"1024\" height=\"348\" src=\"https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2025\/08\/img10-double-publisher-read.png?resize=1024%2C348&#038;ssl=1\" alt=\"A tree structure diagram representing a set of books, showing nodes with titles such as 'Jane Eyre' and 'War and Peace', along with leaf nodes containing book entries like 'The Great Gatsby', 'Animal Farm', and 'To Kill a Mockingbird'.\" class=\"wp-image-6896\" srcset=\"https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2025\/08\/img10-double-publisher-read.png?resize=1024%2C348&amp;ssl=1 1024w, https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2025\/08\/img10-double-publisher-read.png?resize=300%2C102&amp;ssl=1 300w, https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2025\/08\/img10-double-publisher-read.png?resize=768%2C261&amp;ssl=1 768w, https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2025\/08\/img10-double-publisher-read.png?resize=1536%2C522&amp;ssl=1 1536w, https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2025\/08\/img10-double-publisher-read.png?resize=2048%2C695&amp;ssl=1 2048w\" sizes=\"auto, (max-width: 1000px) 100vw, 1000px\" \/><\/figure>\n\n\n\n<p>We can&#8217;t just start reading for those matched records. That would give us<\/p>\n\n\n\n<pre class=\"wp-block-preformatted\">\"Animal Farm\"\n\"Of Mice and Men\"\n\"Lord of The Flies\"\n\"The Catcher in The Rye\"\n<\/pre>\n\n\n\n<p>The books under each publisher are ordered, but the overall list of matches is not. And again, Postgres operates on <em>simple<\/em> operations. Elaborate meta descriptions like &#8220;well it&#8217;ll just merge the matches from each publisher taking the less of the next entry from either until the limit is satisfied&#8221; won&#8217;t show up in your execution plan, at least not directly.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Why did the publisher id change the plan?<\/h3>\n\n\n\n<p>Before we make this query fast, let&#8217;s briefly consider why our query&#8217;s plan changed so radically between searching for two small publishers compared to an enormous publisher and a small one.<\/p>\n\n\n\n<p>As we discussed in part 1, Postgres tracks and uses statistics about your data in order to craft the best execution plan it can. Here, when you searched for the large publisher, it realized that query would yield an enormous number of rows. That led it to decide that simply scanning through the heap directly would be faster than the large number of random i\/o that would be incurred from following so many matches in the index&#8217;s leaf nodes, over to the corresponding locations on the heap. Random i\/o is bad, and Postgres will usually try to avoid it.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Crafting a better query<\/h2>\n\n\n\n<p>You can absolutely have Postgres find the top 10 books in both publishers, and then put them together, sorted, and take the first 10 from there. You just have to be explicit about it.<\/p>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-6\" data-shcb-language-name=\"PostgreSQL SQL dialect and PL\/pgSQL\" data-shcb-language-slug=\"pgsql\"><span><code class=\"hljs language-pgsql\"><span class=\"hljs-keyword\">explain<\/span> <span class=\"hljs-keyword\">analyze<\/span>\n<span class=\"hljs-keyword\">with<\/span> pub1 <span class=\"hljs-keyword\">as<\/span> (\n    <span class=\"hljs-keyword\">select<\/span> * <span class=\"hljs-keyword\">from<\/span> books\n    <span class=\"hljs-keyword\">where<\/span> publisher = <span class=\"hljs-number\">157595<\/span>\n    <span class=\"hljs-keyword\">order<\/span> <span class=\"hljs-keyword\">by<\/span> title <span class=\"hljs-keyword\">limit<\/span> <span class=\"hljs-number\">10<\/span>\n), pub2 <span class=\"hljs-keyword\">as<\/span> (\n    <span class=\"hljs-keyword\">select<\/span> * <span class=\"hljs-keyword\">from<\/span> books\n    <span class=\"hljs-keyword\">where<\/span> publisher = <span class=\"hljs-number\">210537<\/span>\n    <span class=\"hljs-keyword\">order<\/span> <span class=\"hljs-keyword\">by<\/span> title <span class=\"hljs-keyword\">limit<\/span> <span class=\"hljs-number\">10<\/span>\n)\n<span class=\"hljs-keyword\">select<\/span> * <span class=\"hljs-keyword\">from<\/span> pub1\n<span class=\"hljs-keyword\">union<\/span> <span class=\"hljs-keyword\">all<\/span>\n<span class=\"hljs-keyword\">select<\/span> * <span class=\"hljs-keyword\">from<\/span> pub2\n<span class=\"hljs-keyword\">order<\/span> <span class=\"hljs-keyword\">by<\/span> title\n<span class=\"hljs-keyword\">limit<\/span> <span class=\"hljs-number\">10<\/span>;\n<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-6\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">PostgreSQL SQL dialect and PL\/pgSQL<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">pgsql<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<p>The syntax below is called a common table expression, or a CTE. It&#8217;s basically a query that we define, and then query against later.<\/p>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-7\" data-shcb-language-name=\"PostgreSQL SQL dialect and PL\/pgSQL\" data-shcb-language-slug=\"pgsql\"><span><code class=\"hljs language-pgsql\"><span class=\"hljs-keyword\">with<\/span> pub1 <span class=\"hljs-keyword\">as<\/span> (\n    <span class=\"hljs-keyword\">select<\/span> * <span class=\"hljs-keyword\">from<\/span> books\n    <span class=\"hljs-keyword\">where<\/span> publisher = <span class=\"hljs-number\">157595<\/span>\n    <span class=\"hljs-keyword\">order<\/span> <span class=\"hljs-keyword\">by<\/span> title <span class=\"hljs-keyword\">limit<\/span> <span class=\"hljs-number\">10<\/span>\n)\n<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-7\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">PostgreSQL SQL dialect and PL\/pgSQL<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">pgsql<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<p>Let&#8217;s run it!<\/p>\n\n\n\n<p>The execution plan is beautiful<\/p>\n\n\n\n<figure class=\"wp-block-image size-large\"><img data-recalc-dims=\"1\" loading=\"lazy\" decoding=\"async\" width=\"1024\" height=\"257\" src=\"https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2025\/08\/img11-cte-append.png?resize=1024%2C257&#038;ssl=1\" alt=\"A screenshot displaying a query execution plan from a database, showing the steps involved in retrieving data for two publishers from a books table using indexed scans and sorting.\" class=\"wp-image-6897\" srcset=\"https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2025\/08\/img11-cte-append-scaled.png?resize=1024%2C257&amp;ssl=1 1024w, https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2025\/08\/img11-cte-append-scaled.png?resize=300%2C75&amp;ssl=1 300w, https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2025\/08\/img11-cte-append-scaled.png?resize=768%2C193&amp;ssl=1 768w, https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2025\/08\/img11-cte-append-scaled.png?resize=1536%2C386&amp;ssl=1 1536w, https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2025\/08\/img11-cte-append-scaled.png?resize=2048%2C515&amp;ssl=1 2048w\" sizes=\"auto, (max-width: 1000px) 100vw, 1000px\" \/><\/figure>\n\n\n\n<p>It&#8217;s fast! As you can see, it runs in less than a fifth of a millisecond (0.186ms \u2014 but who&#8217;s counting)?<\/p>\n\n\n\n<p>Always read these from the bottom:<\/p>\n\n\n\n<figure class=\"wp-block-image size-large\"><img data-recalc-dims=\"1\" loading=\"lazy\" decoding=\"async\" width=\"1024\" height=\"130\" src=\"https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2025\/08\/img11a-cte-append.png?resize=1024%2C130&#038;ssl=1\" alt=\"Execution plan showing the performance of an index scan in a PostgreSQL database, with details on limits, rows processed, and execution time.\" class=\"wp-image-6898\" srcset=\"https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2025\/08\/img11a-cte-append-scaled.png?resize=1024%2C130&amp;ssl=1 1024w, https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2025\/08\/img11a-cte-append-scaled.png?resize=300%2C38&amp;ssl=1 300w, https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2025\/08\/img11a-cte-append-scaled.png?resize=768%2C98&amp;ssl=1 768w, https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2025\/08\/img11a-cte-append-scaled.png?resize=1536%2C195&amp;ssl=1 1536w, https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2025\/08\/img11a-cte-append-scaled.png?resize=2048%2C260&amp;ssl=1 2048w\" sizes=\"auto, (max-width: 1000px) 100vw, 1000px\" \/><\/figure>\n\n\n\n<p>It&#8217;s the same exact index scan from before, but on a single publisher, with a limit of 10, run twice. Postgres can seek to the right publisher, and just read 10 for the first publisher, and then repeat for the second publisher. Then it puts those lists together.<\/p>\n\n\n\n<p>Remember the silly, contrived Postgres operation I made up before?<\/p>\n\n\n\n<blockquote class=\"wp-block-quote is-layout-flow wp-block-quote-is-layout-flow\">\n<p>&#8230; and then start reading forward on both, and sort of merge them together, taking the smaller title from either, until you have 10 books total.<\/p>\n<\/blockquote>\n\n\n\n<p>You&#8217;re not going to believe this, but that&#8217;s exactly what the Merge Append on line 2 does<\/p>\n\n\n\n<pre class=\"wp-block-preformatted\">-&gt;  Merge Append  (cost=1.40..74.28 rows=20 width=111) (actual time=0.086..0.115 rows=10 loops=1)\n<\/pre>\n\n\n\n<p>You can achieve amazing things with modern databases if you know how to structure your queries <em>just<\/em> right.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">How does this scale?<\/h2>\n\n\n\n<p>You won&#8217;t want to write queries like this manually. Presumably, you&#8217;d have application code taking a list of publisher ids, and constructing something like this. How will it perform as you add more and more publishers?<\/p>\n\n\n\n<p>I&#8217;ve explored this very idea on larger production sets of data (much larger than what we&#8217;re using here). I found that, somewhere around a <em>thousand<\/em> ids, the performance does break down. But not because there&#8217;s too much data to work with. The <em>execution<\/em> of those queries, with even a thousand ids, took only a few hundred <code>ms<\/code>. But the <em>Planning Time<\/em> started to take many, many seconds. It turns out having Postgres parse through a thousand CTEs, and put a plan together takes time.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Version 2<\/h2>\n\n\n\n<p>We&#8217;re onto something, for sure. But can we take a list of ids, and force them into individual queries that match on that specific id, with a limit, and then select from the overall bucket of results? Exactly like before, but without having to manually cobble together a CTE for each id?<\/p>\n\n\n\n<p>When there&#8217;s a will, there&#8217;s a way.<\/p>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-8\" data-shcb-language-name=\"PostgreSQL SQL dialect and PL\/pgSQL\" data-shcb-language-slug=\"pgsql\"><span><code class=\"hljs language-pgsql\"><span class=\"hljs-keyword\">explain<\/span> <span class=\"hljs-keyword\">analyze<\/span>\n<span class=\"hljs-keyword\">with<\/span> ids <span class=\"hljs-keyword\">as<\/span> (\n    <span class=\"hljs-keyword\">select<\/span> * <span class=\"hljs-keyword\">from<\/span> (\n      <span class=\"hljs-keyword\">values<\/span> (<span class=\"hljs-number\">157595<\/span>), (<span class=\"hljs-number\">210537<\/span>)\n    ) t(id)\n), results <span class=\"hljs-keyword\">as<\/span> (\n    <span class=\"hljs-keyword\">select<\/span> bookInfo.*\n    <span class=\"hljs-keyword\">from<\/span> ids\n    <span class=\"hljs-keyword\">cross<\/span> <span class=\"hljs-keyword\">join<\/span> <span class=\"hljs-keyword\">lateral<\/span> (\n      <span class=\"hljs-keyword\">select<\/span> *\n      <span class=\"hljs-keyword\">from<\/span> books\n      <span class=\"hljs-keyword\">where<\/span> publisher = ids.id\n      <span class=\"hljs-keyword\">order<\/span> <span class=\"hljs-keyword\">by<\/span> title\n      <span class=\"hljs-keyword\">limit<\/span> <span class=\"hljs-number\">10<\/span>\n    ) bookInfo\n)\n<span class=\"hljs-keyword\">select<\/span> *\n<span class=\"hljs-keyword\">from<\/span> results\n<span class=\"hljs-keyword\">order<\/span> <span class=\"hljs-keyword\">by<\/span> title\n<span class=\"hljs-keyword\">limit<\/span> <span class=\"hljs-number\">10<\/span>;\n<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-8\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">PostgreSQL SQL dialect and PL\/pgSQL<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">pgsql<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<p>Let&#8217;s walk through this.<\/p>\n\n\n\n<p>Our <code>ids<\/code> CTE:<\/p>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-9\" data-shcb-language-name=\"PostgreSQL SQL dialect and PL\/pgSQL\" data-shcb-language-slug=\"pgsql\"><span><code class=\"hljs language-pgsql\"><span class=\"hljs-keyword\">with<\/span> ids <span class=\"hljs-keyword\">as<\/span> (\n    <span class=\"hljs-keyword\">select<\/span> * <span class=\"hljs-keyword\">from<\/span> (\n      <span class=\"hljs-keyword\">values<\/span> (<span class=\"hljs-number\">157595<\/span>), (<span class=\"hljs-number\">210537<\/span>)\n    ) t(id)\n)\n<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-9\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">PostgreSQL SQL dialect and PL\/pgSQL<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">pgsql<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<p>This defines a pseudo-table that has one column, with two rows. The rows have values of our publisher ids for the sole column: 157595 and 210537.<\/p>\n\n\n\n<pre class=\"wp-block-preformatted\">values (157595), (210537)\n<\/pre>\n\n\n\n<p>But if it&#8217;s a table, how do we query against the column? It needs to have a name. That&#8217;s what this syntax is.<\/p>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-10\" data-shcb-language-name=\"PostgreSQL SQL dialect and PL\/pgSQL\" data-shcb-language-slug=\"pgsql\"><span><code class=\"hljs language-pgsql\">t(id)\n<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-10\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">PostgreSQL SQL dialect and PL\/pgSQL<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">pgsql<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<p>We gave that column a name of <code>id<\/code>.<\/p>\n\n\n\n<p>The <code>results<\/code> CTE is where the real work happens.<\/p>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-11\" data-shcb-language-name=\"PostgreSQL SQL dialect and PL\/pgSQL\" data-shcb-language-slug=\"pgsql\"><span><code class=\"hljs language-pgsql\">results <span class=\"hljs-keyword\">as<\/span> (\n    <span class=\"hljs-keyword\">select<\/span> bookInfo.*\n    <span class=\"hljs-keyword\">from<\/span> ids\n    <span class=\"hljs-keyword\">cross<\/span> <span class=\"hljs-keyword\">join<\/span> <span class=\"hljs-keyword\">lateral<\/span> (\n      <span class=\"hljs-keyword\">select<\/span> *\n      <span class=\"hljs-keyword\">from<\/span> books\n      <span class=\"hljs-keyword\">where<\/span> publisher = ids.id\n      <span class=\"hljs-keyword\">order<\/span> <span class=\"hljs-keyword\">by<\/span> title\n      <span class=\"hljs-keyword\">limit<\/span> <span class=\"hljs-number\">10<\/span>\n    ) bookInfo\n)\n<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-11\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">PostgreSQL SQL dialect and PL\/pgSQL<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">pgsql<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<p>We query against our <code>ids<\/code> table, and then use the ugly <code>cross join lateral<\/code> expression as a neat trick to run our normal books query, but with access to the publisher value in the ids CTE. The value in the ids CTE is the publisher id. So we&#8217;ve achieved what we want: we&#8217;re conceptually looping through those ids, and then running our fast query on each.<\/p>\n\n\n\n<p>The term <code>lateral<\/code> is the key. Think of (American) football, where a lateral is a sideways pass. Here, the lateral keyword allows us to &#8220;laterally&#8221; reference the <code>ids.id<\/code> value from the expression right beside it; the <code>ids<\/code> CTE <em>laterals<\/em> each id over to the results CTE.<\/p>\n\n\n\n<p>That coaxes Postgres to run its normal index scan, followed by a read of the next 10 rows. That happens once for each id. That whole meta-list will then contain (up to) 10 rows for each publisher, and then this&#8230;<\/p>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-12\" data-shcb-language-name=\"PostgreSQL SQL dialect and PL\/pgSQL\" data-shcb-language-slug=\"pgsql\"><span><code class=\"hljs language-pgsql\"><span class=\"hljs-keyword\">select<\/span> *\n<span class=\"hljs-keyword\">from<\/span> results\n<span class=\"hljs-keyword\">order<\/span> <span class=\"hljs-keyword\">by<\/span> title\n<span class=\"hljs-keyword\">limit<\/span> <span class=\"hljs-number\">10<\/span>;\n<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-12\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">PostgreSQL SQL dialect and PL\/pgSQL<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">pgsql<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<p>&#8230; re-sorts, and takes the first 10.<\/p>\n\n\n\n<p>In my own experience, this scales fabulously. Even with a few thousand ids I couldn&#8217;t get this basic setup to take longer than half a second, even on a much larger table than we&#8217;ve been looking at here.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Let&#8217;s run it!<\/h3>\n\n\n\n<p>Let&#8217;s see what this version of our query looks like<\/p>\n\n\n\n<figure class=\"wp-block-image size-large\"><img data-recalc-dims=\"1\" loading=\"lazy\" decoding=\"async\" width=\"1024\" height=\"268\" src=\"https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2025\/08\/img12-cross-join.png?resize=1024%2C268&#038;ssl=1\" alt=\"A query plan execution analysis demonstrating performance improvements after creating an index on the 'books' table in Postgres.\" class=\"wp-image-6899\" srcset=\"https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2025\/08\/img12-cross-join.png?resize=1024%2C268&amp;ssl=1 1024w, https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2025\/08\/img12-cross-join.png?resize=300%2C78&amp;ssl=1 300w, https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2025\/08\/img12-cross-join.png?resize=768%2C201&amp;ssl=1 768w, https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2025\/08\/img12-cross-join.png?resize=1536%2C402&amp;ssl=1 1536w, https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2025\/08\/img12-cross-join.png?resize=2048%2C536&amp;ssl=1 2048w\" sizes=\"auto, (max-width: 1000px) 100vw, 1000px\" \/><\/figure>\n\n\n\n<p>Still a small fraction of a millisecond, but ever so slightly slower; this now runs in 0.207ms. And the execution plan is a bit longer and more complex.<\/p>\n\n\n\n<pre class=\"wp-block-preformatted\">-&gt;  Nested Loop  (cost=0.69..81.19 rows=20 width=111) (actual time=0.042..0.087 rows=20 loops=1)\n<\/pre>\n\n\n\n<p>A nested loop join is a pretty simple (and <em>usually<\/em> pretty slow) join algorithm. It just takes each value in the one list, and then applies it to each value in the second list. In this case, though, it&#8217;s taking values from a static list and applying them against an incredibly fast query.<\/p>\n\n\n\n<p>The left side of the join is each id from that static table we built<\/p>\n\n\n\n<pre class=\"wp-block-preformatted\">-&gt;  Values Scan on \"*VALUES*\"  (cost=0.00..0.03 rows=2 width=4) (actual time=0.001..0.002 rows=2 loops=1)\n<\/pre>\n\n\n\n<p>The right side is our normal (<em>fast<\/em>) query that we&#8217;ve seen a few times now.<\/p>\n\n\n\n<pre class=\"wp-block-preformatted\">-&gt;  Limit  (cost=0.69..40.48 rows=10 width=111) (actual time=0.024..0.037 rows=10 loops=2)\n      -&gt;  Index Scan using idx_publisher_title on books  (cost=0.69..2288.59 rows=575 width=111) (actual time=0.023..0.034 rows=10 loops=2)\n         Index Cond: (publisher = \"*VALUES*\".column1)\n<\/pre>\n\n\n\n<p>However, our nice Merge Append is gone, replaced with a normal sort. The reason is that we replaced discrete CTEs, each of which produced separate, identically sorted outputs, which the planner could identify, and apply a Merge Append to. Merge Append works on multiple, independently sorted streams of data. Instead, this is just a regular join, which produces one stream of data, and therefore needs to be sorted.<\/p>\n\n\n\n<p>But this is no tragedy. The query runs in a tiny fraction of a <strong>milli<\/strong>second, and will not suffer planning time degradation like the previous CTE version would, as we add more and more publisher ids. Plus, the sort is over just N*10 records, where N is the number of publishers. It would take a catastrophically large N to wind up with enough rows where Postgres would struggle to sort them quickly, especially since the limit of 10 would allow it to do an efficient top-N heapsort, like we saw in part 1.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Stepping back<\/h2>\n\n\n\n<p>The hardest part of writing this post is knowing when to stop. I could easily write as much content again: we haven&#8217;t even gotten into joins, and how indexes can help there, or even materialized views. This is an endless topic, and one that I enjoy, but we&#8217;ll stop here for now.<\/p>\n\n\n\n<p>The one theme throughout can be summed up as: understand <em>how<\/em> your data is stored, and <em>craft<\/em> your queries to make the best use possible of this knowledge. If you&#8217;re not sure exactly how to craft your queries to do this, then use your knowledge of how indexes work, and what you want your queries to accomplish to ask an <em>extremely<\/em> specific question to your favorite AI model. It&#8217;s very likely to <em>at least<\/em> get you closer to your answer. Oftentimes knowing <em>what<\/em> to ask is half the battle.<\/p>\n\n\n\n<p>And of course, if your data is not stored as you need, then change how your data is stored. Indexes are the most common way, which we&#8217;ve discussed here. Materialized views would be the next power tool to consider when needed. But that&#8217;s a topic for another day.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Parting thoughts<\/h2>\n\n\n\n<p>Hopefully, these posts have taught you a few things about querying, query tuning, and crafting the right index for the right situation. These are skills that can have a huge payoff in achieving palpable performance gains that your users will notice.<\/p>\n\n\n<div class=\"box article-series\">\n  <header>\n    <h3 class=\"article-series-header\">Article Series<\/h3>\n  <\/header>\n  <div class=\"box-content\">\n            <ol>\n                      <li>\n              <a href=\"https:\/\/frontendmasters.com\/blog\/intro-to-postgres-indexes\/\">Introduction to Postgres Indexes<\/a>\n            <\/li>\n                      <li>\n              <a href=\"https:\/\/frontendmasters.com\/blog\/advanced-postgresql-indexing\/\">Advanced PostgreSQL Indexing: Multi-Key Queries and Performance Optimization<\/a>\n            <\/li>\n                  <\/ol>\n        <\/div>\n<\/div>\n\n\n\n<p class=\"learn-more\">Editor&#8217;s note: our <a href=\"https:\/\/frontendmasters.com\/courses\/complete-go\/?utm_source=boost&amp;utm_medium=blog&amp;utm_campaign=boost\">The Complete Course for Building Backend Web Apps with Go<\/a> includes setting up a PostgreSQL database and running it in Docker, all from scratch.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>Postgres creates an execution plan for how to retrieve the data you&#8217;re asking for in a query. The execution plan is based in part on statistics from your data and indexes it has available. Just the right index and a bit of query tuning can have a huge payoff in performance gains that your users will notice.<\/p>\n","protected":false},"author":21,"featured_media":7031,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"_acf_changed":false,"sig_custom_text":"","sig_image_type":"featured-image","sig_custom_image":0,"sig_is_disabled":false,"inline_featured_image":false,"_jetpack_memberships_contains_paid_content":false,"footnotes":""},"categories":[1],"tags":[386,387,385,384],"class_list":["post-6882","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-blog-post","tag-database","tag-database-indexes","tag-mysql","tag-postgres"],"acf":[],"jetpack_featured_media_url":"https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2025\/08\/postgres-fast.jpg?fit=2100%2C1194&ssl=1","jetpack_sharing_enabled":true,"_links":{"self":[{"href":"https:\/\/frontendmasters.com\/blog\/wp-json\/wp\/v2\/posts\/6882","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/frontendmasters.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/frontendmasters.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/frontendmasters.com\/blog\/wp-json\/wp\/v2\/users\/21"}],"replies":[{"embeddable":true,"href":"https:\/\/frontendmasters.com\/blog\/wp-json\/wp\/v2\/comments?post=6882"}],"version-history":[{"count":16,"href":"https:\/\/frontendmasters.com\/blog\/wp-json\/wp\/v2\/posts\/6882\/revisions"}],"predecessor-version":[{"id":7078,"href":"https:\/\/frontendmasters.com\/blog\/wp-json\/wp\/v2\/posts\/6882\/revisions\/7078"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/frontendmasters.com\/blog\/wp-json\/wp\/v2\/media\/7031"}],"wp:attachment":[{"href":"https:\/\/frontendmasters.com\/blog\/wp-json\/wp\/v2\/media?parent=6882"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/frontendmasters.com\/blog\/wp-json\/wp\/v2\/categories?post=6882"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/frontendmasters.com\/blog\/wp-json\/wp\/v2\/tags?post=6882"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}