<?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: Rahul Dhawan</title>
    <description>The latest articles on DEV Community by Rahul Dhawan (@r4hul).</description>
    <link>https://dev.to/r4hul</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.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4044180%2F75e0ea0c-63e0-4296-8acd-c1343fbd4153.png</url>
      <title>DEV Community: Rahul Dhawan</title>
      <link>https://dev.to/r4hul</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/r4hul"/>
    <language>en</language>
    <item>
      <title>What Really Happens When PostgreSQL Runs Your Query?</title>
      <dc:creator>Rahul Dhawan</dc:creator>
      <pubDate>Thu, 23 Jul 2026 19:12:35 +0000</pubDate>
      <link>https://dev.to/r4hul/what-really-happens-when-postgresql-runs-your-query-3829</link>
      <guid>https://dev.to/r4hul/what-really-happens-when-postgresql-runs-your-query-3829</guid>
      <description>&lt;p&gt;I recently had one of those exciting engineering moments where something I had used for months finally &lt;strong&gt;clicked&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;While investigating query performance on a project, I started looking beyond the SQL and into what PostgreSQL was doing behind the scenes. Using execution plans helped me understand why schema design, indexes, and even the columns in a &lt;code&gt;SELECT&lt;/code&gt; statement can significantly affect performance.&lt;/p&gt;

&lt;p&gt;This article shares the practical mental model I learned—without going too deeply into PostgreSQL internals.&lt;/p&gt;




&lt;h2&gt;
  
  
  What happens when PostgreSQL receives a query?
&lt;/h2&gt;

&lt;p&gt;When PostgreSQL receives SQL, it does not immediately start reading rows.&lt;/p&gt;

&lt;p&gt;It first:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Parses&lt;/strong&gt; the SQL and validates its syntax.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rewrites&lt;/strong&gt; the query when views or rules are involved.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Plans&lt;/strong&gt; possible execution strategies.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Estimates the cost&lt;/strong&gt; of each strategy.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Executes&lt;/strong&gt; the plan it believes will be cheapest.&lt;/li&gt;
&lt;li&gt;Returns the result.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;A simplified view looks like this:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fe4b4wcty51rym515k35v.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fe4b4wcty51rym515k35v.png" alt="Flowchart showing a SQL query moving from an application through PostgreSQL’s parser, rewriter, planner, and executor before accessing tables and indexes" width="800" height="580"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;PostgreSQL uses table statistics, estimated row counts, available indexes, and expected I/O costs to choose a plan.&lt;/p&gt;

&lt;p&gt;An index existing does not guarantee that PostgreSQL will use it. The planner may decide that another approach is cheaper.&lt;/p&gt;




&lt;h2&gt;
  
  
  Tables are stored as pages
&lt;/h2&gt;

&lt;p&gt;At the SQL level, we think of a table as a collection of rows. Internally, PostgreSQL stores rows—also called tuples—inside fixed-size blocks called &lt;strong&gt;pages&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;A page is typically 8 KB.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fz80fofe5pc4woxb1qy60.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fz80fofe5pc4woxb1qy60.png" alt="PostgreSQL stores tuples inside fixed-size pages rather than independently reading logical SQL rows" width="799" height="317"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;When PostgreSQL retrieves data, it works with these pages. A page may already be available in memory, or it may need to be read from storage.&lt;/p&gt;

&lt;p&gt;This is one reason row width matters.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffl0wfroehre3sl9pd4at.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffl0wfroehre3sl9pd4at.png" alt="Wider rows can reduce the number of tuples that fit on each page, potentially increasing the number of pages PostgreSQL must process" width="800" height="155"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Narrow rows allow more records to fit on a page. Wider rows may require PostgreSQL to process more pages for the same number of records.&lt;/p&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzoawiujp7x0w04z8epl4.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzoawiujp7x0w04z8epl4.png" alt="Agamemnon aura farming meme" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  So how does PostgreSQL find a record?
&lt;/h2&gt;

&lt;p&gt;Consider the following table:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;users&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="n"&gt;BIGSERIAL&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;email&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="n"&gt;TIMESTAMPTZ&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;NOW&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now consider this query:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;email&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;users&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'active'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;PostgreSQL has several ways to find matching records. Three important ones are:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Sequential scan&lt;/li&gt;
&lt;li&gt;Index scan&lt;/li&gt;
&lt;li&gt;Bitmap scan&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Sequential scan
&lt;/h2&gt;

&lt;p&gt;A sequential scan, shown as &lt;code&gt;Seq Scan&lt;/code&gt; in an execution plan, reads the table page by page and checks visible rows against the filter.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkv9nh8eda2271nlhehlc.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkv9nh8eda2271nlhehlc.png" alt="During a sequential scan, PostgreSQL visits table pages and evaluates each visible row against the filter" width="800" height="1100"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Conceptually, PostgreSQL:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Loads a table page.&lt;/li&gt;
&lt;li&gt;Inspects its tuples.&lt;/li&gt;
&lt;li&gt;Applies transaction visibility rules.&lt;/li&gt;
&lt;li&gt;Evaluates the &lt;code&gt;WHERE&lt;/code&gt; condition.&lt;/li&gt;
&lt;li&gt;Returns matching rows.&lt;/li&gt;
&lt;li&gt;Continues through the table.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;A simplified plan may look like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Seq Scan on users
  Filter: (status = 'active')
  Rows Removed by Filter: 800000
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A sequential scan is not automatically bad. It can be efficient when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The table is small.&lt;/li&gt;
&lt;li&gt;The query returns a large percentage of the table.&lt;/li&gt;
&lt;li&gt;The filter is not selective.&lt;/li&gt;
&lt;li&gt;No useful index exists.&lt;/li&gt;
&lt;li&gt;Reading the table sequentially is cheaper than performing many separate lookups.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If almost every user is active, scanning the table may be cheaper than using an index to fetch nearly every row.&lt;/p&gt;




&lt;h2&gt;
  
  
  Index scan
&lt;/h2&gt;

&lt;p&gt;An index is a separate data structure that helps PostgreSQL locate records without scanning the entire table.&lt;/p&gt;

&lt;p&gt;PostgreSQL uses B-tree indexes by default:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;idx_users_email&lt;/span&gt;
&lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;users&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A simplified B-tree index looks like this:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fej8rkl0m8cxtzbm349wg.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fej8rkl0m8cxtzbm349wg.png" alt="A simplified B-tree. PostgreSQL follows the ordered tree to locate matching keys without scanning every table row" width="799" height="324"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;For this query:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;email&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;users&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;email&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'alice@example.com'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;PostgreSQL can:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Search the index for the email.&lt;/li&gt;
&lt;li&gt;Find the corresponding tuple location.&lt;/li&gt;
&lt;li&gt;Fetch the relevant table page.&lt;/li&gt;
&lt;li&gt;Check whether the row is visible.&lt;/li&gt;
&lt;li&gt;Return the requested columns.
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;                      B-tree index
                           |
             search for alice@example.com
                           |
                           v
                  tuple location: (42, 7)
                           |
                           v
                    Table page 42
                           |
                           v
                       Row 7
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An index scan is most useful when the query returns a small, selective portion of the table.&lt;/p&gt;

&lt;h3&gt;
  
  
  Index-only scans
&lt;/h3&gt;

&lt;p&gt;If an index contains every value required by a query, PostgreSQL may use an &lt;code&gt;Index Only Scan&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;idx_users_email_name&lt;/span&gt;
&lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;users&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This index could support:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;users&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;email&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'alice@example.com'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;PostgreSQL may still need to check visibility information from the table, so an index-only scan does not always mean that the table is completely ignored.&lt;/p&gt;




&lt;h2&gt;
  
  
  Bitmap scan
&lt;/h2&gt;

&lt;p&gt;A bitmap scan is useful when PostgreSQL expects more than a few matches, but not enough to justify scanning the entire table.&lt;/p&gt;

&lt;p&gt;It normally appears as two operations:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Bitmap Heap Scan on users
  -&amp;gt; Bitmap Index Scan on idx_users_status
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;PostgreSQL first uses the index to collect matching tuple locations. It then groups those locations by table page before fetching the records.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgzso694rupn021s8zdrt.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgzso694rupn021s8zdrt.png" alt="A bitmap scan groups matching tuple locations by page before fetching table data, reducing scattered page access" width="800" height="868"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This can reduce scattered access by allowing PostgreSQL to fetch each required page and retrieve multiple matching rows from it.&lt;/p&gt;



&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Scan type&lt;/th&gt;
&lt;th&gt;Usually useful when&lt;/th&gt;
&lt;th&gt;Main trade-off&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Sequential scan&lt;/td&gt;
&lt;td&gt;A large part of the table is needed&lt;/td&gt;
&lt;td&gt;Inspects many rows&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Index scan&lt;/td&gt;
&lt;td&gt;A small number of rows is needed&lt;/td&gt;
&lt;td&gt;May perform random page access&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bitmap scan&lt;/td&gt;
&lt;td&gt;A moderate number of rows is needed&lt;/td&gt;
&lt;td&gt;Must build and process a bitmap&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Index-only scan&lt;/td&gt;
&lt;td&gt;Required values are covered by an index&lt;/td&gt;
&lt;td&gt;May still require visibility checks&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fotmlakpeq3bcr98krsv8.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fotmlakpeq3bcr98krsv8.png" alt="A simplified scan-selection model. PostgreSQL chooses the plan with the lowest estimated cost—not the plan with the most indexes" width="800" height="245"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;These are guidelines, not fixed rules. PostgreSQL chooses the plan with the lowest estimated cost.&lt;/p&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fixpuqdstart2k3wdrbxn.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fixpuqdstart2k3wdrbxn.png" alt="Low cortisol meme" width="800" height="552"&gt;&lt;/a&gt;&lt;/p&gt;






&lt;h2&gt;
  
  
  Why schema design matters
&lt;/h2&gt;

&lt;p&gt;Schema design affects more than data organization. It can influence:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Row width&lt;/li&gt;
&lt;li&gt;The number of pages required&lt;/li&gt;
&lt;li&gt;Cache efficiency&lt;/li&gt;
&lt;li&gt;Query memory usage&lt;/li&gt;
&lt;li&gt;Network traffic&lt;/li&gt;
&lt;li&gt;Serialization and deserialization work&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Consider a table containing several large fields:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="n"&gt;BIGSERIAL&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;customer_id&lt;/span&gt; &lt;span class="nb"&gt;BIGINT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;total&lt;/span&gt; &lt;span class="nb"&gt;NUMERIC&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="n"&gt;TIMESTAMPTZ&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;customer_notes&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;internal_notes&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;provider_response&lt;/span&gt; &lt;span class="n"&gt;JSONB&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;metadata&lt;/span&gt; &lt;span class="n"&gt;JSONB&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An order-list endpoint probably does not need every field.&lt;/p&gt;

&lt;p&gt;Instead of:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;customer_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;42&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Request only the columns the application needs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;total&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;customer_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;42&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Selecting fewer columns can reduce:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Query output width&lt;/li&gt;
&lt;li&gt;Network transfer&lt;/li&gt;
&lt;li&gt;Application memory usage&lt;/li&gt;
&lt;li&gt;Serialization work&lt;/li&gt;
&lt;li&gt;Retrieval of unnecessary large values&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It may also make an index-only scan possible when the required columns are covered by an index.&lt;/p&gt;

&lt;p&gt;PostgreSQL has optimizations for null and large values, including variable-length storage and TOAST. Adding a column therefore does not always increase every row by its full declared size.&lt;/p&gt;

&lt;p&gt;Still, columns—especially large text and JSON fields—should be added deliberately.&lt;/p&gt;




&lt;h2&gt;
  
  
  Indexes are powerful, but not free
&lt;/h2&gt;

&lt;p&gt;Columns frequently used for filtering, joining, or sorting are often good index candidates.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;idx_orders_customer_id&lt;/span&gt;
&lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;customer_id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Composite indexes can support more specific access patterns:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;idx_orders_customer_created&lt;/span&gt;
&lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;customer_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This may improve:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;total&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;customer_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;42&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;
&lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Benefits of indexes
&lt;/h3&gt;

&lt;p&gt;Indexes can:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Avoid unnecessary table scans.&lt;/li&gt;
&lt;li&gt;Improve selective lookups.&lt;/li&gt;
&lt;li&gt;Speed up joins.&lt;/li&gt;
&lt;li&gt;Support sorting.&lt;/li&gt;
&lt;li&gt;Enforce uniqueness.&lt;/li&gt;
&lt;li&gt;Enable index-only scans.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Costs of indexes
&lt;/h3&gt;

&lt;p&gt;Indexes also:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Consume storage.&lt;/li&gt;
&lt;li&gt;Make inserts more expensive.&lt;/li&gt;
&lt;li&gt;Add work to updates and deletes.&lt;/li&gt;
&lt;li&gt;Require vacuuming and maintenance.&lt;/li&gt;
&lt;li&gt;Can become bloated or redundant.&lt;/li&gt;
&lt;/ul&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxl5mqbtmjo4cc5pbwhu0.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxl5mqbtmjo4cc5pbwhu0.png" alt="Every maintained index may add work to inserts, updates, deletes, vacuuming, and storage" width="800" height="368"&gt;&lt;/a&gt;&lt;/p&gt;



&lt;p&gt;Indexes trade storage and write performance for faster reads. The goal is not to index every column, but to create a small set of useful indexes based on real query patterns.&lt;/p&gt;

&lt;p&gt;A frequently queried column is not necessarily a good index candidate if most rows contain the same value.&lt;/p&gt;




&lt;h2&gt;
  
  
  Understanding queries with &lt;code&gt;EXPLAIN&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;The most useful tool during my investigation was:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;EXPLAIN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;ANALYZE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;BUFFERS&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;VERBOSE&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;email&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;users&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;email&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'alice@example.com'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each option provides different information:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;EXPLAIN&lt;/code&gt; displays the selected execution plan.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;ANALYZE&lt;/code&gt; executes the query and reports actual measurements.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;BUFFERS&lt;/code&gt; reports how PostgreSQL accessed data pages.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;VERBOSE&lt;/code&gt; includes additional details about operations and output.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Be careful: &lt;code&gt;ANALYZE&lt;/code&gt; actually executes the statement.&lt;/p&gt;

&lt;p&gt;For a modifying query, investigate it inside a transaction and roll it back:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;BEGIN&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;EXPLAIN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;ANALYZE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;BUFFERS&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;VERBOSE&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;DELETE&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;users&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'inactive'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;ROLLBACK&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Reading an execution plan
&lt;/h2&gt;

&lt;p&gt;A simplified plan might look like this:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fq15tadt856afw11rkq2e.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fq15tadt856afw11rkq2e.png" alt="The main fields to inspect in a PostgreSQL execution plan" width="799" height="230"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Buffers
&lt;/h3&gt;

&lt;p&gt;A plan may include:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Buffers: shared hit=12 read=4
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;At a high level:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;shared hit&lt;/code&gt; means the block was already in PostgreSQL’s shared buffers.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;shared read&lt;/code&gt; means the block had to be loaded into shared buffers.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;dirtied&lt;/code&gt; means the block was modified in memory.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;written&lt;/code&gt; means a block was written out.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Buffer information adds useful context because execution times can change depending on caching.&lt;/p&gt;




&lt;h2&gt;
  
  
  Sequential scan versus index scan
&lt;/h2&gt;

&lt;p&gt;Before adding an index, a selective email lookup might produce:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Seq Scan on users
  Filter: (email = 'target@example.com')
  Rows Removed by Filter: 999999
  Buffers: shared hit=2100 read=6200
Execution Time: 82.400 ms
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After creating an index:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;idx_users_email&lt;/span&gt;
&lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;users&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;ANALYZE&lt;/span&gt; &lt;span class="n"&gt;users&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The plan might change to:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Index Scan using idx_users_email on users
  Index Cond: (email = 'target@example.com')
  Buffers: shared hit=4
Execution Time: 0.080 ms
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The exact numbers depend on the data, caching, hardware, and configuration. The important difference is the amount of work performed.&lt;/p&gt;

&lt;p&gt;The index is not magically making PostgreSQL faster. It is allowing PostgreSQL to &lt;strong&gt;do less work&lt;/strong&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  A practical query-investigation workflow
&lt;/h2&gt;

&lt;p&gt;When investigating a slow query:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Run &lt;code&gt;EXPLAIN (ANALYZE, BUFFERS, VERBOSE)&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Check the scan type.&lt;/li&gt;
&lt;li&gt;Compare estimated and actual rows.&lt;/li&gt;
&lt;li&gt;Inspect buffer usage.&lt;/li&gt;
&lt;li&gt;Look for rows removed by filters.&lt;/li&gt;
&lt;li&gt;Check how many times each node loops.&lt;/li&gt;
&lt;li&gt;Remove unnecessary selected columns.&lt;/li&gt;
&lt;li&gt;Review existing indexes.&lt;/li&gt;
&lt;li&gt;Test using realistic data.&lt;/li&gt;
&lt;li&gt;Make one change and measure again.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Performance optimization should be evidence-driven:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Measure → Change → Measure again
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Not:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Guess → Add indexes → Deploy → Hope
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fytls4vwa9my50xgvxeaa.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fytls4vwa9my50xgvxeaa.png" alt="PostgreSQL performance is primarily about reducing unnecessary work while balancing read, write, memory, and storage costs" width="800" height="444"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Helpful PostgreSQL metadata queries
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Describe a table in &lt;code&gt;psql&lt;/code&gt;
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;\d public.users
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For additional details:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;\d+ public.users
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;These are &lt;code&gt;psql&lt;/code&gt; commands and may not work in every database client.&lt;/p&gt;

&lt;h3&gt;
  
  
  List columns and their types
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;
    &lt;span class="n"&gt;ordinal_position&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;column_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;data_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;is_nullable&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;column_default&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;information_schema&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;columns&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;table_schema&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'public'&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="k"&gt;table_name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'users'&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;ordinal_position&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  List table indexes
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;
    &lt;span class="n"&gt;indexname&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;indexdef&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_indexes&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;schemaname&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'public'&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;tablename&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'users'&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;indexname&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Check table and index sizes
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;
    &lt;span class="n"&gt;pg_size_pretty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;pg_table_size&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'public.users'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;table_size&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;pg_size_pretty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;pg_indexes_size&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'public.users'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;index_size&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;pg_size_pretty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;pg_total_relation_size&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'public.users'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;total_size&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Check scan statistics
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;
    &lt;span class="n"&gt;relname&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;seq_scan&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;seq_tup_read&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;idx_scan&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;idx_tup_fetch&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;n_live_tup&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;n_dead_tup&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_stat_user_tables&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;schemaname&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'public'&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;relname&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'users'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;These are cumulative statistics. A high sequential-scan count does not automatically indicate a problem.&lt;/p&gt;




&lt;h2&gt;
  
  
  Final thoughts
&lt;/h2&gt;

&lt;p&gt;Understanding how PostgreSQL executes a query changed the way I approach database performance.&lt;/p&gt;

&lt;p&gt;The main lesson is simple: &lt;strong&gt;performance is often about reducing unnecessary work.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That means:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Returning only the required columns&lt;/li&gt;
&lt;li&gt;Examining fewer rows&lt;/li&gt;
&lt;li&gt;Reading fewer pages&lt;/li&gt;
&lt;li&gt;Creating indexes for real access patterns&lt;/li&gt;
&lt;li&gt;Keeping planner statistics current&lt;/li&gt;
&lt;li&gt;Balancing read improvements against write costs&lt;/li&gt;
&lt;li&gt;Measuring changes instead of guessing&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Most importantly, use:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;EXPLAIN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;ANALYZE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;BUFFERS&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;VERBOSE&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It turns query optimization from guesswork into investigation.&lt;/p&gt;

&lt;p&gt;Once you can read an execution plan, PostgreSQL stops feeling like a black box. You can see the decisions it makes—and begin designing systems that work with the database rather than against it.&lt;/p&gt;



&lt;p&gt;&lt;strong&gt;&lt;em&gt;Hopefully reading through my first post was not an odyssey and if you made it this far then your answer to the below question might be yes ;)&lt;/em&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwdfrg0vkg52i4viankex.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwdfrg0vkg52i4viankex.png" alt="will smith i robot meme" width="800" height="1224"&gt;&lt;/a&gt;&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>database</category>
      <category>performance</category>
      <category>backend</category>
    </item>
  </channel>
</rss>
