{"id":465784,"date":"2025-07-03T09:14:10","date_gmt":"2025-07-03T09:14:10","guid":{"rendered":"http:\/\/savepearlharbor.com\/?p=465784"},"modified":"-0001-11-30T00:00:00","modified_gmt":"-0001-11-29T21:00:00","slug":"","status":"publish","type":"post","link":"https:\/\/savepearlharbor.com\/?p=465784","title":{"rendered":"<span>On reordering expressions in Postgres<\/span>"},"content":{"rendered":"<div><!--[--><!--]--><\/div>\n<div id=\"post-content-body\">\n<div>\n<div class=\"article-formatted-body article-formatted-body article-formatted-body_version-2\">\n<div xmlns=\"http:\/\/www.w3.org\/1999\/xhtml\">\n<p>From time to time, I come across queries with complex filters like this:<\/p>\n<pre><code class=\"sql\">SELECT * FROM table WHERE  date &gt; min_date AND  date &lt; now() - interval '1 day' AND  value IN Subplan AND  id = 42;<\/code><\/pre>\n<p>And in real life, just changing the order of these conditions can noticeably improve performance. Why? Individually, each condition is cheap. But when you&#8217;re applying them to millions of rows, that &#171;cheap&#187; quickly adds up. Especially once you&#8217;ve dealt with the usual suspects \u2014 like making sure the table fits into shared buffers.<\/p>\n<p>This effect is easiest to spot in\u00a0wide\u00a0tables \u2014 the kind with dozens of variable-length columns. I sometimes see sluggish IndexScans, and when you dig in, it turns out the performance hit comes from filtering on a column that&#8217;s the 20th field in the tuple. Just figuring out the offset of that column takes CPU time.<\/p>\n<p>Looking at Postgres source code, it\u2019s clear the community thought about this long ago. Back in 2002, Tom Lane reluctantly committed\u00a0<a href=\"https:\/\/github.com\/postgres\/postgres\/commit\/3779f7f\" rel=\"noopener noreferrer nofollow\">3779f7f<\/a>, which added a basic reordering of expressions (see\u00a0<code>order_qual_clauses<\/code>) to push subplans to the end of the list. That made sense \u2014 subplans can depend on runtime parameters and are generally expensive to evaluate.<\/p>\n<p>Later, in 2007, commit\u00a0<a href=\"https:\/\/github.com\/postgres\/postgres\/commit\/5a7471c\" rel=\"noopener noreferrer nofollow\">5a7471c<\/a>\u00a0changed the logic. From that point on, expressions were ordered strictly by their estimated cost. This still holds today, with a small tweak from\u00a0<a href=\"https:\/\/github.com\/postgres\/postgres\/commit\/215b43c\" rel=\"noopener noreferrer nofollow\">215b43c<\/a>\u00a0for row-level security (RLS), where evaluation order needed more control within each plan node.<\/p>\n<p>Let\u2019s see what we get in upstream Postgres right now:<\/p>\n<pre><code class=\"sql\">CREATE TABLE test (   x integer, y numeric, w timestamp DEFAULT CURRENT_TIMESTAMP, z integer ); INSERT INTO test (x, y) SELECT gs, gs FROM generate_series(1, 1E3) AS gs; VACUUM ANALYZE test;  EXPLAIN (COSTS ON) SELECT * FROM test WHERE  z &gt; 0 AND  w &gt; now() AND  x &lt; (SELECT avg(y) FROM generate_series(1,1E2) y WHERE y % 2 = x % 3) AND  x NOT IN (SELECT avg(y) FROM generate_series(1,1E2) y OFFSET 0) AND  w IS NOT NULL AND  x = 42;<\/code><\/pre>\n<p>If we check the\u00a0<code>Filter<\/code>\u00a0line in the plan, we get:<\/p>\n<pre><code class=\"sql\">Filter: ((w IS NOT NULL) AND (z &gt; 0) AND (x = 42) AND (w &gt; now()) AND          ((x)::numeric = (InitPlan 2).col1) AND ((x)::numeric &lt; (SubPlan 1))) <\/code><\/pre>\n<p>Postgres evaluates them in this exact order, left to right.<\/p>\n<p>Here are the estimated costs:<\/p>\n<ul>\n<li>\n<p><code>z &gt; 0<\/code>: 0.0025<\/p>\n<\/li>\n<li>\n<p><code>w &gt; now()<\/code>: 0.005<\/p>\n<\/li>\n<li>\n<p><code>x &lt; SubPlan 1<\/code>: 2.0225<\/p>\n<\/li>\n<li>\n<p><code>x NOT IN SubPlan 2<\/code>: 0.005<\/p>\n<\/li>\n<li>\n<p><code>w IS NOT NULL<\/code>: 0.0<\/p>\n<\/li>\n<li>\n<p><code>x = 42<\/code>: 0.0025<\/p>\n<\/li>\n<\/ul>\n<p>This ordering looks mostly reasonable. But could we do better?<\/p>\n<p>There are at least two low-hanging fruits here:<\/p>\n<ol>\n<li>\n<p>Column position cost. If the column is far to the right in the tuple layout, accessing it costs more. We could add a tiny cost factor based on ordinal position \u2014 enough to help the planner choose\u00a0<code>x = 42<\/code>\u00a0over\u00a0<code>z = 42<\/code>, for example, all other things equal.<\/p>\n<\/li>\n<li>\n<p>Selectivity-based ordering. When two expressions have similar cost \u2014 say,\u00a0<code>x = 42<\/code>\u00a0and\u00a0<code>z &lt; 50<\/code>\u00a0\u2014 it&#8217;s better to put the more selective one first. If\u00a0<code>x = 42<\/code>\u00a0is true less often, the planner should evaluate it before the rest.<\/p>\n<\/li>\n<\/ol>\n<p>Let\u2019s test how much performance gain we could actually get from this. First, we\u2019ll build a table where some columns are far apart but have the same selectivity, and others are close together but differ in selectivity.<\/p>\n<pre><code class=\"sql\">CREATE TEMP TABLE test_2 (x1 numeric, x2 numeric, x3 numeric, x4 numeric); INSERT INTO test_2 (x1, x2, x3, x4)   SELECT x, (x::integer)%2, (x::integer)%100,x FROM      (SELECT random()*1E7 FROM generate_series(1,1E7) AS x) AS q(x); ANALYZE;<\/code><\/pre>\n<p>Let\u2019s check what happens when we search for values in this &#171;wide&#187; tuple. The columns\u00a0<code>x1<\/code>\u00a0and\u00a0<code>x4<\/code>\u00a0are identical in every way, except that the offset of\u00a0<code>x1<\/code>\u00a0within the tuple is known in advance, while for\u00a0<code>x4<\/code>, the system has to calculate it for each row:<\/p>\n<pre><code class=\"sql\">EXPLAIN (ANALYZE, TIMING OFF, COSTS OFF) SELECT * FROM test_2 WHERE x1 = 42 AND x4 = 42;  EXPLAIN (ANALYZE, TIMING OFF, COSTS OFF) SELECT * FROM test_2 WHERE x4 = 42 AND x1 = 42;  \/*  Seq Scan on test_2  (actual rows=0.00 loops=1)    Filter: ((x1 = '42'::numeric) AND (x4 = '42'::numeric))    Buffers: local read=94357   Execution Time: 2372.032 ms   Seq Scan on test_2  (actual rows=0.00 loops=1)    Filter: ((x4 = '42'::numeric) AND (x1 = '42'::numeric))    Buffers: local read=94357  Execution Time: 2413.633 ms *\/<\/code><\/pre>\n<p>So, all other things being equal, even a moderately wide tuple can cause a 2\u20133% difference in execution time. That\u2019s roughly the same impact you&#8217;d expect from enabling JIT in typical cases. <\/p>\n<p>Now let\u2019s look at how selectivity affects performance. The columns\u00a0<code>x1<\/code>\u00a0and\u00a0<code>x2<\/code>\u00a0are located close to each other in the tuple, but there&#8217;s a key difference:\u00a0<code>x1<\/code>\u00a0holds almost unique values, while\u00a0<code>x2<\/code>\u00a0is filled with near-duplicates:<\/p>\n<pre><code class=\"sql\">EXPLAIN (ANALYZE, TIMING OFF, COSTS OFF) SELECT * FROM test_2 WHERE x2 = 1 AND x1 = 42;  EXPLAIN (ANALYZE, TIMING OFF, COSTS OFF) SELECT * FROM test_2 WHERE x1 = 42 AND x2 = 1; \/*  Seq Scan on test_2  (actual rows=0.00 loops=1)    Filter: ((x2 = '1'::numeric) AND (x1 = '42'::numeric))    Buffers: local read=74596  Execution Time: 2363.903 ms   Seq Scan on test_2  (actual rows=0.00 loops=1)    Filter: ((x1 = '42'::numeric) AND (x2 = '1'::numeric))    Buffers: local read=74596  Execution Time: 2034.873 ms *\/<\/code><\/pre>\n<p>That\u2019s a 10% difference. <\/p>\n<p>If you assume that these small effects can stack across the entire plan tree \u2014 with scans, joins, groupings, etc. \u2014 then yes, reordering conditions might actually be worth doing, even if it adds a little planning overhead.<\/p>\n<p>So let\u2019s try implementing it. As an extension, this won\u2019t work \u2014 there\u2019s no hook in the planner to intercept final plan creation. We could really use a\u00a0<code>create_plan_hook()<\/code>\u00a0in\u00a0<code>create_plan()<\/code>, but the community hasn\u2019t discussed it seriously yet.<\/p>\n<p>So I went ahead and made a core patch. You need to touch two places:<\/p>\n<ul>\n<li>\n<p><code>cost_qual_eval()<\/code>\u00a0\u2014 where Postgres estimates the cost of conditions<\/p>\n<\/li>\n<li>\n<p><code>order_qual_clauses<\/code>\u00a0\u2014 the function that sorts them<\/p>\n<\/li>\n<\/ul>\n<p>You can find the final code in a <a href=\"https:\/\/github.com\/danolivo\/pgdev\/tree\/reorder-clauses-by-selectivity\" rel=\"noopener noreferrer nofollow\">branch<\/a> on GitHub.<\/p>\n<p>Running the earlier examples on that branch, expressions are now ordered better \u2014 taking both column order and selectivity into account. No extra planning overhead observed so far.<\/p>\n<\/div>\n<\/div>\n<\/div>\n<p><!----><!----><\/div>\n<p><!----><!----><br \/> \u0441\u0441\u044b\u043b\u043a\u0430 \u043d\u0430 \u043e\u0440\u0438\u0433\u0438\u043d\u0430\u043b \u0441\u0442\u0430\u0442\u044c\u0438 <a href=\"https:\/\/habr.com\/ru\/articles\/914708\/\"> https:\/\/habr.com\/ru\/articles\/914708\/<\/a><\/p>\n","protected":false},"excerpt":{"rendered":"<div><!--[--><!--]--><\/div>\n<div id=\"post-content-body\">\n<div>\n<div class=\"article-formatted-body article-formatted-body article-formatted-body_version-2\">\n<div xmlns=\"http:\/\/www.w3.org\/1999\/xhtml\">\n<p>From time to time, I come across queries with complex filters like this:<\/p>\n<pre><code class=\"sql\">SELECT * FROM table WHERE  date &gt; min_date AND  date &lt; now() - interval '1 day' AND  value IN Subplan AND  id = 42;<\/code><\/pre>\n<p>And in real life, just changing the order of these conditions can noticeably improve performance. Why? Individually, each condition is cheap. But when you&#8217;re applying them to millions of rows, that &#171;cheap&#187; quickly adds up. Especially once you&#8217;ve dealt with the usual suspects \u2014 like making sure the table fits into shared buffers.<\/p>\n<p>This effect is easiest to spot in\u00a0wide\u00a0tables \u2014 the kind with dozens of variable-length columns. I sometimes see sluggish IndexScans, and when you dig in, it turns out the performance hit comes from filtering on a column that&#8217;s the 20th field in the tuple. Just figuring out the offset of that column takes CPU time.<\/p>\n<p>Looking at Postgres source code, it\u2019s clear the community thought about this long ago. Back in 2002, Tom Lane reluctantly committed\u00a0<a href=\"https:\/\/github.com\/postgres\/postgres\/commit\/3779f7f\" rel=\"noopener noreferrer nofollow\">3779f7f<\/a>, which added a basic reordering of expressions (see\u00a0<code>order_qual_clauses<\/code>) to push subplans to the end of the list. That made sense \u2014 subplans can depend on runtime parameters and are generally expensive to evaluate.<\/p>\n<p>Later, in 2007, commit\u00a0<a href=\"https:\/\/github.com\/postgres\/postgres\/commit\/5a7471c\" rel=\"noopener noreferrer nofollow\">5a7471c<\/a>\u00a0changed the logic. From that point on, expressions were ordered strictly by their estimated cost. This still holds today, with a small tweak from\u00a0<a href=\"https:\/\/github.com\/postgres\/postgres\/commit\/215b43c\" rel=\"noopener noreferrer nofollow\">215b43c<\/a>\u00a0for row-level security (RLS), where evaluation order needed more control within each plan node.<\/p>\n<p>Let\u2019s see what we get in upstream Postgres right now:<\/p>\n<pre><code class=\"sql\">CREATE TABLE test (   x integer, y numeric, w timestamp DEFAULT CURRENT_TIMESTAMP, z integer ); INSERT INTO test (x, y) SELECT gs, gs FROM generate_series(1, 1E3) AS gs; VACUUM ANALYZE test;  EXPLAIN (COSTS ON) SELECT * FROM test WHERE  z &gt; 0 AND  w &gt; now() AND  x &lt; (SELECT avg(y) FROM generate_series(1,1E2) y WHERE y % 2 = x % 3) AND  x NOT IN (SELECT avg(y) FROM generate_series(1,1E2) y OFFSET 0) AND  w IS NOT NULL AND  x = 42;<\/code><\/pre>\n<p>If we check the\u00a0<code>Filter<\/code>\u00a0line in the plan, we get:<\/p>\n<pre><code class=\"sql\">Filter: ((w IS NOT NULL) AND (z &gt; 0) AND (x = 42) AND (w &gt; now()) AND          ((x)::numeric = (InitPlan 2).col1) AND ((x)::numeric &lt; (SubPlan 1))) <\/code><\/pre>\n<p>Postgres evaluates them in this exact order, left to right.<\/p>\n<p>Here are the estimated costs:<\/p>\n<ul>\n<li>\n<p><code>z &gt; 0<\/code>: 0.0025<\/p>\n<\/li>\n<li>\n<p><code>w &gt; now()<\/code>: 0.005<\/p>\n<\/li>\n<li>\n<p><code>x &lt; SubPlan 1<\/code>: 2.0225<\/p>\n<\/li>\n<li>\n<p><code>x NOT IN SubPlan 2<\/code>: 0.005<\/p>\n<\/li>\n<li>\n<p><code>w IS NOT NULL<\/code>: 0.0<\/p>\n<\/li>\n<li>\n<p><code>x = 42<\/code>: 0.0025<\/p>\n<\/li>\n<\/ul>\n<p>This ordering looks mostly reasonable. But could we do better?<\/p>\n<p>There are at least two low-hanging fruits here:<\/p>\n<ol>\n<li>\n<p>Column position cost. If the column is far to the right in the tuple layout, accessing it costs more. We could add a tiny cost factor based on ordinal position \u2014 enough to help the planner choose\u00a0<code>x = 42<\/code>\u00a0over\u00a0<code>z = 42<\/code>, for example, all other things equal.<\/p>\n<\/li>\n<li>\n<p>Selectivity-based ordering. When two expressions have similar cost \u2014 say,\u00a0<code>x = 42<\/code>\u00a0and\u00a0<code>z &lt; 50<\/code>\u00a0\u2014 it&#8217;s better to put the more selective one first. If\u00a0<code>x = 42<\/code>\u00a0is true less often, the planner should evaluate it before the rest.<\/p>\n<\/li>\n<\/ol>\n<p>Let\u2019s test how much performance gain we could actually get from this. First, we\u2019ll build a table where some columns are far apart but have the same selectivity, and others are close together but differ in selectivity.<\/p>\n<pre><code class=\"sql\">CREATE TEMP TABLE test_2 (x1 numeric, x2 numeric, x3 numeric, x4 numeric); INSERT INTO test_2 (x1, x2, x3, x4)   SELECT x, (x::integer)%2, (x::integer)%100,x FROM      (SELECT random()*1E7 FROM generate_series(1,1E7) AS x) AS q(x); ANALYZE;<\/code><\/pre>\n<p>Let\u2019s check what happens when we search for values in this &#171;wide&#187; tuple. The columns\u00a0<code>x1<\/code>\u00a0and\u00a0<code>x4<\/code>\u00a0are identical in every way, except that the offset of\u00a0<code>x1<\/code>\u00a0within the tuple is known in advance, while for\u00a0<code>x4<\/code>, the system has to calculate it for each row:<\/p>\n<pre><code class=\"sql\">EXPLAIN (ANALYZE, TIMING OFF, COSTS OFF) SELECT * FROM test_2 WHERE x1 = 42 AND x4 = 42;  EXPLAIN (ANALYZE, TIMING OFF, COSTS OFF) SELECT * FROM test_2 WHERE x4 = 42 AND x1 = 42;  \/*  Seq Scan on test_2  (actual rows=0.00 loops=1)    Filter: ((x1 = '42'::numeric) AND (x4 = '42'::numeric))    Buffers: local read=94357   Execution Time: 2372.032 ms   Seq Scan on test_2  (actual rows=0.00 loops=1)    Filter: ((x4 = '42'::numeric) AND (x1 = '42'::numeric))    Buffers: local read=94357  Execution Time: 2413.633 ms *\/<\/code><\/pre>\n<p>So, all other things being equal, even a moderately wide tuple can cause a 2\u20133% difference in execution time. That\u2019s roughly the same impact you&#8217;d expect from enabling JIT in typical cases. <\/p>\n<p>Now let\u2019s look at how selectivity affects performance. The columns\u00a0<code>x1<\/code>\u00a0and\u00a0<code>x2<\/code>\u00a0are located close to each other in the tuple, but there&#8217;s a key difference:\u00a0<code>x1<\/code>\u00a0holds almost unique values, while\u00a0<code>x2<\/code>\u00a0is filled with near-duplicates:<\/p>\n<pre><code class=\"sql\">EXPLAIN (ANALYZE, TIMING OFF, COSTS OFF) SELECT * FROM test_2 WHERE x2 = 1 AND x1 = 42;  EXPLAIN (ANALYZE, TIMING OFF, COSTS OFF) SELECT * FROM test_2 WHERE x1 = 42 AND x2 = 1; \/*  Seq Scan on test_2  (actual rows=0.00 loops=1)    Filter: ((x2 = '1'::numeric) AND (x1 = '42'::numeric))    Buffers: local read=74596  Execution Time: 2363.903 ms   Seq Scan on test_2  (actual rows=0.00 loops=1)    Filter: ((x1 = '42'::numeric) AND (x2 = '1'::numeric))    Buffers: local read=74596  Execution Time: 2034.873 ms *\/<\/code><\/pre>\n<p>That\u2019s a 10% difference. <\/p>\n<p>If you assume that these small effects can stack across the entire plan tree \u2014 with scans, joins, groupings, etc. \u2014 then yes, reordering conditions might actually be worth doing, even if it adds a little planning overhead.<\/p>\n<p>So let\u2019s try implementing it. As an extension, this won\u2019t work \u2014 there\u2019s no hook in the planner to intercept final plan creation. We could really use a\u00a0<code>create_plan_hook()<\/code>\u00a0in\u00a0<code>create_plan()<\/code>, but the community hasn\u2019t discussed it seriously yet.<\/p>\n<p>So I went ahead and made a core patch. You need to touch two places:<\/p>\n<ul>\n<li>\n<p><code>cost_qual_eval()<\/code>\u00a0\u2014 where Postgres estimates the cost of conditions<\/p>\n<\/li>\n<li>\n<p><code>order_qual_clauses<\/code>\u00a0\u2014 the function that sorts them<\/p>\n<\/li>\n<\/ul>\n<p>You can find the final code in a <a href=\"https:\/\/github.com\/danolivo\/pgdev\/tree\/reorder-clauses-by-selectivity\" rel=\"noopener noreferrer nofollow\">branch<\/a> on GitHub.<\/p>\n<p>Running the earlier examples on that branch, expressions are now ordered better \u2014 taking both column order and selectivity into account. No extra planning overhead observed so far.<\/p>\n<\/div>\n<\/div>\n<\/div>\n<p><!----><!----><\/div>\n<p><!----><!----><br \/> \u0441\u0441\u044b\u043b\u043a\u0430 \u043d\u0430 \u043e\u0440\u0438\u0433\u0438\u043d\u0430\u043b \u0441\u0442\u0430\u0442\u044c\u0438 <a href=\"https:\/\/habr.com\/ru\/articles\/914708\/\"> https:\/\/habr.com\/ru\/articles\/914708\/<\/a><br \/><\/br><\/br><\/p>\n","protected":false},"author":1,"featured_media":0,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[],"tags":[],"class_list":["post-465784","post","type-post","status-publish","format-standard","hentry"],"_links":{"self":[{"href":"https:\/\/savepearlharbor.com\/index.php?rest_route=\/wp\/v2\/posts\/465784","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/savepearlharbor.com\/index.php?rest_route=\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/savepearlharbor.com\/index.php?rest_route=\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/savepearlharbor.com\/index.php?rest_route=\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/savepearlharbor.com\/index.php?rest_route=%2Fwp%2Fv2%2Fcomments&post=465784"}],"version-history":[{"count":0,"href":"https:\/\/savepearlharbor.com\/index.php?rest_route=\/wp\/v2\/posts\/465784\/revisions"}],"wp:attachment":[{"href":"https:\/\/savepearlharbor.com\/index.php?rest_route=%2Fwp%2Fv2%2Fmedia&parent=465784"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/savepearlharbor.com\/index.php?rest_route=%2Fwp%2Fv2%2Fcategories&post=465784"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/savepearlharbor.com\/index.php?rest_route=%2Fwp%2Fv2%2Ftags&post=465784"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}