<?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: Alex Kargin</title>
    <description>The latest articles on DEV Community by Alex Kargin (@alex_kargin).</description>
    <link>https://dev.to/alex_kargin</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3896723%2Fa2102437-4465-4d76-8cc2-e695e722c604.jpg</url>
      <title>DEV Community: Alex Kargin</title>
      <link>https://dev.to/alex_kargin</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/alex_kargin"/>
    <language>en</language>
    <item>
      <title>Why PostgreSQL EXPLAIN ANALYZE Can Mislead You — and What to Use Instead</title>
      <dc:creator>Alex Kargin</dc:creator>
      <pubDate>Sun, 26 Apr 2026 03:47:19 +0000</pubDate>
      <link>https://dev.to/alex_kargin/why-postgresql-explain-analyze-can-mislead-you-and-what-to-use-instead-563k</link>
      <guid>https://dev.to/alex_kargin/why-postgresql-explain-analyze-can-mislead-you-and-what-to-use-instead-563k</guid>
      <description>&lt;p&gt;&lt;code&gt;EXPLAIN ANALYZE&lt;/code&gt; is the standard tool for understanding how PostgreSQL runs a query. It shows the chosen plan, estimated and actual row counts, and execution time. For most engineers, it is the first stop when a query is slow.&lt;/p&gt;

&lt;p&gt;It is also frequently misread. A query plan can look clean in &lt;code&gt;EXPLAIN ANALYZE&lt;/code&gt; and still be slow in production. A rewritten query can show a much lower cost in development and behave identically when deployed. The output is not wrong — but it answers a narrower question than most readers assume.&lt;/p&gt;

&lt;p&gt;This article walks through five common ways &lt;code&gt;EXPLAIN ANALYZE&lt;/code&gt; is misinterpreted, and the additional tools and flags that fill in the gaps.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pitfall #1: Treating "Cost" as a Time Estimate
&lt;/h2&gt;

&lt;p&gt;The first column most readers look at is &lt;code&gt;cost&lt;/code&gt;. It is presented next to row estimates and looks like a measurable quantity, so it is often read as if it were milliseconds.&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="k"&gt;ANALYZE&lt;/span&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;span class="n"&gt;QUERY&lt;/span&gt; &lt;span class="n"&gt;PLAN&lt;/span&gt;
&lt;span class="c1"&gt;------------------------------------------------------------------------&lt;/span&gt;
 &lt;span class="k"&gt;Index&lt;/span&gt; &lt;span class="n"&gt;Scan&lt;/span&gt; &lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="n"&gt;orders_customer_id_idx&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;cost&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;43&lt;/span&gt;&lt;span class="p"&gt;..&lt;/span&gt;&lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;45&lt;/span&gt; &lt;span class="k"&gt;rows&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="n"&gt;width&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;128&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
   &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;actual&lt;/span&gt; &lt;span class="nb"&gt;time&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;012&lt;/span&gt;&lt;span class="p"&gt;..&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;018&lt;/span&gt; &lt;span class="k"&gt;rows&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="n"&gt;loops&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
 &lt;span class="n"&gt;Planning&lt;/span&gt; &lt;span class="nb"&gt;Time&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;087&lt;/span&gt; &lt;span class="n"&gt;ms&lt;/span&gt;
 &lt;span class="n"&gt;Execution&lt;/span&gt; &lt;span class="nb"&gt;Time&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;034&lt;/span&gt; &lt;span class="n"&gt;ms&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;cost&lt;/code&gt; is an arbitrary unit. The PostgreSQL planner uses it for one purpose: comparing candidate plans against each other within a single planning step. A cost value of 8.45 does not correspond to milliseconds, seconds, or any wall-clock measurement. The unit is roughly "the cost of a sequential page read," but it is adjusted by configuration parameters such as &lt;code&gt;cpu_tuple_cost&lt;/code&gt;, &lt;code&gt;random_page_cost&lt;/code&gt;, and &lt;code&gt;seq_page_cost&lt;/code&gt;, so the absolute number has no fixed meaning.&lt;/p&gt;

&lt;p&gt;Two plans with identical costs can run at very different speeds, and the planner can pick a higher-cost plan over a lower-cost one when statistics are off (more on that in Pitfall #3).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What to use instead:&lt;/strong&gt; treat &lt;code&gt;cost&lt;/code&gt; as a relative ranking inside one EXPLAIN run, not as a metric to track over time. The numbers that matter for actual performance are at the bottom of the output: &lt;code&gt;actual time&lt;/code&gt;, &lt;code&gt;Planning Time&lt;/code&gt;, and &lt;code&gt;Execution Time&lt;/code&gt;. Those are real milliseconds.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pitfall #2: Skipping the BUFFERS Flag
&lt;/h2&gt;

&lt;p&gt;Plain &lt;code&gt;EXPLAIN ANALYZE&lt;/code&gt; omits one of the most important pieces of information about a query: how much data was read from disk versus served from memory. The &lt;code&gt;BUFFERS&lt;/code&gt; flag adds this.&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;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;created_at&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;interval&lt;/span&gt; &lt;span class="s1"&gt;'7 days'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

 &lt;span class="n"&gt;Seq&lt;/span&gt; &lt;span class="n"&gt;Scan&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;actual&lt;/span&gt; &lt;span class="nb"&gt;time&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;014&lt;/span&gt;&lt;span class="p"&gt;..&lt;/span&gt;&lt;span class="mi"&gt;824&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;103&lt;/span&gt; &lt;span class="k"&gt;rows&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;148291&lt;/span&gt; &lt;span class="n"&gt;loops&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
   &lt;span class="n"&gt;Filter&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'7 days'&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;interval&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
   &lt;span class="k"&gt;Rows&lt;/span&gt; &lt;span class="n"&gt;Removed&lt;/span&gt; &lt;span class="k"&gt;by&lt;/span&gt; &lt;span class="n"&gt;Filter&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;9851709&lt;/span&gt;
   &lt;span class="n"&gt;Buffers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;shared&lt;/span&gt; &lt;span class="n"&gt;hit&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;24&lt;/span&gt; &lt;span class="k"&gt;read&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;98217&lt;/span&gt;
 &lt;span class="n"&gt;Execution&lt;/span&gt; &lt;span class="nb"&gt;Time&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;824&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;512&lt;/span&gt; &lt;span class="n"&gt;ms&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;shared hit&lt;/code&gt; is the number of 8 KB pages found in PostgreSQL's shared buffer cache. &lt;code&gt;shared read&lt;/code&gt; is the number of pages that had to be fetched from disk (or the operating system's file cache) because they were not in the shared buffers. In the example above, &lt;code&gt;read=98217&lt;/code&gt; is roughly 768 MB of cold I/O.&lt;/p&gt;

&lt;p&gt;This matters because development environments and production environments rarely have the same cache state. A query tuned on a developer laptop with a small, fully cached table will report &lt;code&gt;read=0&lt;/code&gt; and look fast. The same query on a production replica with a working set that does not fit in &lt;code&gt;shared_buffers&lt;/code&gt; will show large &lt;code&gt;read&lt;/code&gt; values and take much longer — even though the plan is identical.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What to use instead:&lt;/strong&gt; make &lt;code&gt;(ANALYZE, BUFFERS)&lt;/code&gt; the default. A simple way to enforce this in psql is to set an alias in &lt;code&gt;~/.psqlrc&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="err"&gt;\&lt;/span&gt;&lt;span class="k"&gt;set&lt;/span&gt; &lt;span class="k"&gt;EXPLAIN&lt;/span&gt; &lt;span class="s1"&gt;'EXPLAIN (ANALYZE, BUFFERS, VERBOSE, FORMAT TEXT)'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After that, &lt;code&gt;:EXPLAIN SELECT ...&lt;/code&gt; always includes buffer information. The &lt;code&gt;read&lt;/code&gt; count is the closest signal &lt;code&gt;EXPLAIN&lt;/code&gt; gives to how the query will behave under cold-cache production conditions.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pitfall #3: Assuming the Planner Sees Every Row
&lt;/h2&gt;

&lt;p&gt;The planner does not scan the actual table when building a plan. It consults &lt;code&gt;pg_statistic&lt;/code&gt;, a sampled summary of each table built by the &lt;code&gt;ANALYZE&lt;/code&gt; maintenance command (which is separate from the &lt;code&gt;ANALYZE&lt;/code&gt; flag in &lt;code&gt;EXPLAIN ANALYZE&lt;/code&gt;, despite the shared name).&lt;/p&gt;

&lt;p&gt;The default sample size is roughly 300 × &lt;code&gt;default_statistics_target&lt;/code&gt; rows. With the default &lt;code&gt;default_statistics_target&lt;/code&gt; of 100, that means PostgreSQL routes queries against a billion-row table using statistics drawn from a 30,000-row sample.&lt;/p&gt;

&lt;p&gt;For uniformly distributed columns, this is usually accurate enough. For skewed columns, recently inserted or updated data, or low-cardinality columns where one value dominates, estimates can be off by one or more orders of magnitude.&lt;/p&gt;

&lt;p&gt;The current statistics for any column can be inspected directly:&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;attname&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;n_distinct&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;most_common_vals&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;most_common_freqs&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_stats&lt;/span&gt;
&lt;span class="k"&gt;WHERE&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;'orders'&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;attname&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'status'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When &lt;code&gt;most_common_freqs&lt;/code&gt; shows that one value covers, for example, 95% of rows, but the planner still assumes uniform selectivity, queries filtering on that column will pick poor plans.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What to use instead:&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Run &lt;code&gt;ANALYZE table_name&lt;/code&gt; before any performance investigation, especially after a large bulk load. Autovacuum updates statistics on its own schedule, which may lag behind recent changes.&lt;/li&gt;
&lt;li&gt;For skewed columns, raise the sample resolution: &lt;code&gt;ALTER TABLE orders ALTER COLUMN status SET STATISTICS 1000;&lt;/code&gt; and run &lt;code&gt;ANALYZE orders&lt;/code&gt; again.&lt;/li&gt;
&lt;li&gt;Compare estimated &lt;code&gt;rows=&lt;/code&gt; against &lt;code&gt;actual rows=&lt;/code&gt; in EXPLAIN ANALYZE output. A gap of 10× or more usually points to a statistics problem rather than a query problem.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Pitfall #4: Assuming the EXPLAIN Plan Is the Plan That Runs
&lt;/h2&gt;

&lt;p&gt;Running &lt;code&gt;EXPLAIN ANALYZE&lt;/code&gt; in psql produces one plan. The application running the same query in production may execute a different plan. Two mechanisms are responsible.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Prepared statements and plan caching.&lt;/strong&gt; When a query is executed as a prepared statement, PostgreSQL caches the plan after the fifth execution and may switch to a &lt;em&gt;generic plan&lt;/em&gt; — one that is not specialized to the current parameter values. If parameter values vary widely in selectivity (for example, &lt;code&gt;status = 'open'&lt;/code&gt; matches 5% of rows while &lt;code&gt;status = 'archived'&lt;/code&gt; matches 94%), a generic plan that works well for one value can be very slow for another.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Ad-hoc EXPLAIN does not reproduce this.&lt;/strong&gt; Running &lt;code&gt;EXPLAIN ANALYZE SELECT ... WHERE status = 'archived'&lt;/code&gt; in psql produces a fresh, parameter-specific plan. The cached plan running in production is something else entirely.&lt;/p&gt;

&lt;p&gt;Two PostgreSQL features close this gap.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;pg_stat_statements&lt;/code&gt; extension records every query executed against the server, normalized into templates with their call counts, total time, and mean time:&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;query&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;calls&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;mean_exec_time&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;total_exec_time&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="k"&gt;rows&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_stat_statements&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;total_exec_time&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;p&gt;This shows which queries are actually slow in aggregate, not just which ones happened to be slow during a single debugging session.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;auto_explain&lt;/code&gt; module logs the actual plan used by any query that exceeds a configured duration:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight conf"&gt;&lt;code&gt;&lt;span class="c"&gt;# postgresql.conf
&lt;/span&gt;&lt;span class="n"&gt;shared_preload_libraries&lt;/span&gt; = &lt;span class="s1"&gt;'auto_explain'&lt;/span&gt;
&lt;span class="n"&gt;auto_explain&lt;/span&gt;.&lt;span class="n"&gt;log_min_duration&lt;/span&gt; = &lt;span class="m"&gt;1000&lt;/span&gt;   &lt;span class="c"&gt;# log plans for queries &amp;gt; 1s
&lt;/span&gt;&lt;span class="n"&gt;auto_explain&lt;/span&gt;.&lt;span class="n"&gt;log_analyze&lt;/span&gt; = &lt;span class="n"&gt;true&lt;/span&gt;
&lt;span class="n"&gt;auto_explain&lt;/span&gt;.&lt;span class="n"&gt;log_buffers&lt;/span&gt; = &lt;span class="n"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With this configured, the database server logs the plan, timing, and buffer usage of any query slower than the threshold — captured at the moment the slowness occurred, on the actual server, with the actual cache state. That log entry is a more reliable source for diagnosing real production slowness than any reproduction in psql.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pitfall #5: Treating Execution Time as the User-Visible Latency
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;Execution Time&lt;/code&gt; line at the bottom of &lt;code&gt;EXPLAIN ANALYZE&lt;/code&gt; measures the time PostgreSQL spent producing the result set inside the server. It does not include several components that contribute to the time the end user actually waits:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Sending result rows across the network to the application&lt;/li&gt;
&lt;li&gt;Serialization in the client driver (for example, &lt;code&gt;pg&lt;/code&gt; for Node.js, &lt;code&gt;psycopg&lt;/code&gt; for Python, JDBC for Java)&lt;/li&gt;
&lt;li&gt;Round-trip latency between the application server and the database server&lt;/li&gt;
&lt;li&gt;Application-side parsing, ORM hydration, and object construction&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For a query returning a single row, these components are negligible. For a query returning 100,000 rows, transfer and serialization can easily exceed the database execution time. A query that runs in 80 ms inside PostgreSQL can take several seconds before the user sees a response.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What to use instead:&lt;/strong&gt; measure both layers separately. A simple example in Python:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;

&lt;span class="n"&gt;t0&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;monotonic&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;rows&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;cursor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SELECT ...&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;fetchall&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;db_time&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;monotonic&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;t0&lt;/span&gt;

&lt;span class="n"&gt;t1&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;monotonic&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;results&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nc"&gt;Order&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="n"&gt;parse_time&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;monotonic&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;t1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If &lt;code&gt;db_time&lt;/code&gt; is small but the overall request is still slow, the bottleneck is outside PostgreSQL and outside the scope of &lt;code&gt;EXPLAIN ANALYZE&lt;/code&gt;. Common causes include unnecessarily large result sets, &lt;code&gt;SELECT *&lt;/code&gt; against tables with wide TEXT or JSONB columns, network latency between the application and database, and inefficient client-side processing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Summary: Three Habits That Address Most Misreadings
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;EXPLAIN ANALYZE&lt;/code&gt; is a precise tool with a defined scope. Most confusion comes from asking it questions outside that scope — questions about cold-cache I/O, real production plans, or end-to-end latency. Three habits cover most of the gap:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Always include &lt;code&gt;BUFFERS&lt;/code&gt;.&lt;/strong&gt; The &lt;code&gt;shared read&lt;/code&gt; count is the best available indicator of how a query will behave under cold-cache production conditions. Adding it to a psql alias makes it automatic.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Enable &lt;code&gt;auto_explain&lt;/code&gt; in production&lt;/strong&gt; with a threshold such as 1000 ms. This captures the plan that actually ran when a query was actually slow, rather than the plan that can be reproduced in development.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use &lt;code&gt;pg_stat_statements&lt;/code&gt; to find slow patterns.&lt;/strong&gt; A single &lt;code&gt;EXPLAIN&lt;/code&gt; shows one execution. &lt;code&gt;pg_stat_statements&lt;/code&gt; shows aggregate behavior across millions of executions. The query that consumes the most total time is rarely the one that is slowest individually — it is often a fast query that runs very frequently.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;code&gt;EXPLAIN ANALYZE&lt;/code&gt; reports what the planner and executor know. The remaining performance picture — cache state, plan caching, network transfer, client-side processing — has to be measured with the right tool for each layer.&lt;/p&gt;

&lt;p&gt;Source: &lt;a href="https://www.kargin-utkin.com/postgresql-explain-analyze-can-mislead-you" rel="noopener noreferrer"&gt;https://www.kargin-utkin.com/postgresql-explain-analyze-can-mislead-you&lt;/a&gt;&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>database</category>
      <category>postgressql</category>
      <category>dataengineering</category>
    </item>
  </channel>
</rss>
