<?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: JoongHyuk Shin</title>
    <description>The latest articles on DEV Community by JoongHyuk Shin (@joonghyukshin).</description>
    <link>https://dev.to/joonghyukshin</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%2F3911251%2F63b302ae-0e4c-4fa7-916b-72b2a119b393.png</url>
      <title>DEV Community: JoongHyuk Shin</title>
      <link>https://dev.to/joonghyukshin</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/joonghyukshin"/>
    <language>en</language>
    <item>
      <title>1.3.3 INSERT/UPDATE/DELETE Targetlist Normalization (Default Expansion)</title>
      <dc:creator>JoongHyuk Shin</dc:creator>
      <pubDate>Fri, 22 May 2026 09:52:25 +0000</pubDate>
      <link>https://dev.to/joonghyukshin/133-insertupdatedelete-targetlist-normalization-default-expansion-31nl</link>
      <guid>https://dev.to/joonghyukshin/133-insertupdatedelete-targetlist-normalization-default-expansion-31nl</guid>
      <description>&lt;p&gt;The two rewriter tasks we have seen so far, view expansion and RLS policy injection, were unpacked under the assumption that the query is a SELECT. INSERT/UPDATE/DELETE go through one more preparatory stage on top of those. Even when the user only writes &lt;code&gt;INSERT INTO accounts (owner) VALUES ('alice')&lt;/code&gt;, what PostgreSQL ends up looking at is a query where the &lt;code&gt;id&lt;/code&gt;, &lt;code&gt;balance&lt;/code&gt;, and &lt;code&gt;created_at&lt;/code&gt; slots already contain expressions. That preparation does not exist for SELECT. The subject of this section is how that preparation works, and why it has to happen before user-defined rules get applied.&lt;/p&gt;

&lt;h2&gt;
  
  
  DML preparation has to happen before rule application
&lt;/h2&gt;

&lt;p&gt;The rewriter is divided into two phases. The first phase applies user-defined rules; the second applies view expansion and RLS to each resulting Query (&lt;code&gt;fireRIRrules&lt;/code&gt;, seen in 1.3.1 and 1.3.2). SELECT queries have essentially nothing to do in the first phase. Only INSERT/UPDATE/DELETE receive additional processing inside the first phase.&lt;/p&gt;

&lt;p&gt;The first step of that additional processing is targetlist normalization. For INSERT, the rewriter fills in default expressions for columns the user did not name (when those columns have defaults), and replaces any &lt;code&gt;DEFAULT&lt;/code&gt; keyword the user wrote with the actual expression. UPDATE goes through the same normalization function, but with less to do because there are no missing slots to fill. DELETE skips the stage entirely.&lt;/p&gt;

&lt;p&gt;Why does this normalization have to come before rule application? The answer comes from looking at how the RULE system (1.3.1) refers to columns. The &lt;code&gt;accounts&lt;/code&gt; table used throughout this section is defined as follows.&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;accounts&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;id&lt;/span&gt;          &lt;span class="nb"&gt;integer&lt;/span&gt; &lt;span class="k"&gt;GENERATED&lt;/span&gt; &lt;span class="n"&gt;ALWAYS&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="k"&gt;IDENTITY&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="k"&gt;owner&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;balance&lt;/span&gt;     &lt;span class="nb"&gt;numeric&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="mi"&gt;0&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;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="n"&gt;deleted_at&lt;/span&gt;  &lt;span class="n"&gt;timestamptz&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The last column, &lt;code&gt;deleted_at&lt;/code&gt;, has no &lt;code&gt;DEFAULT&lt;/code&gt; clause and is not an identity column. In other words, it is a column with no default. This shape is common in the soft-delete pattern, and the way this column is handled later becomes one interesting case in this section.&lt;/p&gt;

&lt;p&gt;Suppose an audit rule is attached to this 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;RULE&lt;/span&gt; &lt;span class="n"&gt;log_account_insert&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="n"&gt;accounts&lt;/span&gt;
    &lt;span class="k"&gt;DO&lt;/span&gt; &lt;span class="n"&gt;ALSO&lt;/span&gt; &lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;audit_log&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;account_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;owner_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;logged_at&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                          &lt;span class="k"&gt;VALUES&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;NEW&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;    &lt;span class="k"&gt;NEW&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;owner&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The rule says "whenever someone inserts a row into accounts, also insert a row into audit_log." Inside the rule body, &lt;code&gt;NEW.id&lt;/code&gt; and &lt;code&gt;NEW.owner&lt;/code&gt; are virtual expressions that mean "the &lt;code&gt;id&lt;/code&gt; column, the &lt;code&gt;owner&lt;/code&gt; column of the row the user is about to insert." When the rule author writes these references, they make one assumption: no matter what INSERT the user throws, that row will have an &lt;code&gt;id&lt;/code&gt; slot and an &lt;code&gt;owner&lt;/code&gt; slot, with values in them.&lt;/p&gt;

&lt;p&gt;Now suppose the normalization step did not exist, and the rule received the user's original query as is. The user runs &lt;code&gt;INSERT INTO accounts (owner) VALUES ('alice')&lt;/code&gt;. The original query's targetlist contains a single entry, &lt;code&gt;owner = 'alice'&lt;/code&gt;. The &lt;code&gt;id&lt;/code&gt; is missing. When the rule fires, it looks into the trigger query's targetlist to resolve &lt;code&gt;NEW.id&lt;/code&gt;. There is no matching slot. At this point PostgreSQL's rule mapping (the precise mechanism is covered later in this section) replaces the unmatched slot with a NULL constant. So the rule itself does not error out. But what ends up in audit_log is &lt;code&gt;(NULL, 'alice', ...)&lt;/code&gt;. Meanwhile the user's base INSERT goes through the planner separately, picks up an actual integer (say 17) from the sequence into the &lt;code&gt;id&lt;/code&gt; slot, and lands &lt;code&gt;(17, 'alice', ...)&lt;/code&gt; in accounts. The audit table is supposed to track "what row got inserted," and yet it never receives the new row's id. That is not what the rule author had in mind.&lt;/p&gt;

&lt;p&gt;The opposite case: normalization has finished, then the rule fires. The rewriter has already filled the three missing columns (&lt;code&gt;id&lt;/code&gt;, &lt;code&gt;balance&lt;/code&gt;, &lt;code&gt;created_at&lt;/code&gt;) with expressions, so the trigger query the rule sees has all four columns sitting in their proper slots. &lt;code&gt;NEW.id&lt;/code&gt; points to the sequence expression now occupying that slot, and &lt;code&gt;NEW.owner&lt;/code&gt; points to &lt;code&gt;'alice'&lt;/code&gt;. The rule mapping has nothing to fall back to NULL for. The &lt;code&gt;account_id&lt;/code&gt; slot in the audit_log INSERT receives the same sequence expression, so at execution time audit_log gets a value tied to the same new row id that accounts received. The tracking the rule author originally assumed finally works.&lt;/p&gt;

&lt;p&gt;The same INSERT may have some columns explicitly given and others omitted. The rule has no way to tell. The rewriter has to run before the rule and fill in the missing columns in attno order, so that the user's query the rule sees is in the standard shape. The position of this preparation is determined by what the next stage expects as input.&lt;/p&gt;

&lt;p&gt;This is qualitatively different from what the rewriter does for SELECT. View expansion and RLS are transformations that &lt;em&gt;add conditions that were not there&lt;/em&gt; in the user's query; DML preparation is a transformation that &lt;em&gt;fills in slots that were left empty&lt;/em&gt; in the user's query. Both touch the tree, but with different motivations.&lt;/p&gt;

&lt;h2&gt;
  
  
  INSERT auto-injects default expressions into missing columns
&lt;/h2&gt;

&lt;p&gt;Suppose the user fires this query against the &lt;code&gt;accounts&lt;/code&gt; table just defined.&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;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;accounts&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;owner&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;VALUES&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'alice'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The targetList of the Query tree produced by the analyzer contains only the single entry &lt;code&gt;owner = 'alice'&lt;/code&gt;. The id, balance, created_at, and deleted_at are missing. The rewriter fills the empty slots as follows.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;id&lt;/code&gt; is defined as &lt;code&gt;GENERATED ALWAYS AS IDENTITY&lt;/code&gt;, a SQL-standard identity column. PostgreSQL creates a dedicated sequence (a catalog object called &lt;code&gt;accounts_id_seq&lt;/code&gt;) for such columns and, on every INSERT, draws the next integer from that sequence and fills the column with it. What the rewriter places into this slot is the expression node that retrieves the next value from the sequence (&lt;code&gt;NextValueExpr&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;balance&lt;/code&gt; has &lt;code&gt;DEFAULT 0&lt;/code&gt; written in the column definition. That definition is stored as an expression in the &lt;code&gt;pg_attrdef&lt;/code&gt; catalog, and the rewriter pulls the expression out of there and places it into the slot.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;created_at&lt;/code&gt; follows the same route, and the &lt;code&gt;now()&lt;/code&gt; function call expression goes into its slot.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;deleted_at&lt;/code&gt; has no default. The rewriter does not place any expression into this slot.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;After three columns get filled, the targetList ends up shaped like &lt;code&gt;(NextValueExpr node, 'alice', 0, now(), empty)&lt;/code&gt; across five positions. The user's query had only one column (&lt;code&gt;owner&lt;/code&gt;), but the query PostgreSQL ends up looking at has four columns filled with expressions and one (&lt;code&gt;deleted_at&lt;/code&gt;) left as an empty slot. The decision of what to fill in each column (or whether to leave it empty) belongs to a single responsibility function (&lt;code&gt;build_column_default&lt;/code&gt;); when an expression is produced, it gets wrapped in a new TargetEntry and inserted into the targetList.&lt;/p&gt;

&lt;p&gt;The decision follows three priority steps. First, if the column is an identity column, the node that retrieves the next value from a sequence. Second, if the column definition contains &lt;code&gt;DEFAULT &amp;lt;expression&amp;gt;&lt;/code&gt;, that expression. Third, if neither applies but the column's data type itself has a default, that type default. In the example above, &lt;code&gt;id&lt;/code&gt; took the first path, while &lt;code&gt;balance&lt;/code&gt; and &lt;code&gt;created_at&lt;/code&gt; took the second. &lt;code&gt;deleted_at&lt;/code&gt; falls through all three.&lt;/p&gt;

&lt;h3&gt;
  
  
  Columns without a default: how the empty slot resolves to NULL
&lt;/h3&gt;

&lt;p&gt;When all three priority steps fail, &lt;code&gt;build_column_default&lt;/code&gt; returns NULL, and the rewriter does not create a TargetEntry for that column in the INSERT targetList. The attno slot itself exists, since the table has five columns by definition, but one of those slots (here, &lt;code&gt;deleted_at&lt;/code&gt;) is simply left empty rather than carrying a TargetEntry. This might look like it breaks the promise stated earlier ("the query the rule sees is in the standard shape with every column sitting in its slot"). If the slot is empty, surely the rule pointing to &lt;code&gt;NEW.deleted_at&lt;/code&gt; should get the wrong value or trigger an error.&lt;/p&gt;

&lt;p&gt;That is not how it works. PostgreSQL has been designed so that every stage downstream of the empty slot interprets it as NULL in a consistent way. A concrete scenario makes this clear. Suppose another rule is also attached to &lt;code&gt;accounts&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;RULE&lt;/span&gt; &lt;span class="n"&gt;forward_deleted_at&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="n"&gt;accounts&lt;/span&gt;
    &lt;span class="k"&gt;DO&lt;/span&gt; &lt;span class="n"&gt;ALSO&lt;/span&gt; &lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;sync_queue&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;account_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;deleted_at&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                         &lt;span class="k"&gt;VALUES&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;NEW&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;NEW&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;deleted_at&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This rule references &lt;code&gt;NEW.deleted_at&lt;/code&gt;. But the normalized targetList of the user's &lt;code&gt;INSERT INTO accounts (owner) VALUES ('alice')&lt;/code&gt; does not contain a TargetEntry for &lt;code&gt;deleted_at&lt;/code&gt; (no default, so no expression was placed there). When the rule mapping step looks into the trigger query's targetList for the &lt;code&gt;deleted_at&lt;/code&gt; slot, it does not find one.&lt;/p&gt;

&lt;p&gt;At this point PostgreSQL's rule mapping substitutes a NULL constant in place of the missing TargetEntry. So the &lt;code&gt;deleted_at&lt;/code&gt; slot of the sync_queue INSERT receives NULL. The rule's promise is upheld in a normal way. Pointing to &lt;code&gt;NEW.deleted_at&lt;/code&gt; simply produces NULL; there is no rule breakage, no neighboring column getting accidentally dragged into the slot.&lt;/p&gt;

&lt;p&gt;The base table side works the same way. When the planner produces the plan for the INSERT and sees no TargetEntry for &lt;code&gt;deleted_at&lt;/code&gt; in the normalized targetList, it fills that slot with a NULL constant to complete the row. The row that lands in accounts ends up as (1, 'alice', 0, now(), NULL).&lt;/p&gt;

&lt;p&gt;At both of those sites, the sync_queue INSERT produced by rule mapping and the base accounts row produced by the planner, the NULL is filled in the same way. There is no inconsistency where one site produces NULL and the other produces some other value. That is why the rewriter does not bother building an explicit NULL expression for the targetList. Inserting a NULL TargetEntry and leaving the slot empty have the same end result, so PostgreSQL chose to leave the slot empty and keep the tree lighter.&lt;/p&gt;

&lt;h2&gt;
  
  
  The &lt;code&gt;DEFAULT&lt;/code&gt; keyword arrives as a SetToDefault placeholder and gets resolved
&lt;/h2&gt;

&lt;p&gt;The previous section covered automatic filling when the user &lt;em&gt;omits&lt;/em&gt; a column. The user can also write the &lt;code&gt;DEFAULT&lt;/code&gt; keyword explicitly to say "fill this slot with the default expression."&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;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;accounts&lt;/span&gt; &lt;span class="k"&gt;VALUES&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;DEFAULT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'bob'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When this SQL passes through the analyzer, each &lt;code&gt;DEFAULT&lt;/code&gt; written by the user becomes a &lt;code&gt;SetToDefault&lt;/code&gt; placeholder node in the Query tree. SetToDefault is just a temporary dummy placed where an expression node belongs; it is not an executable expression. The node definition comment in PostgreSQL says this directly.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight c"&gt;&lt;code&gt;&lt;span class="cm"&gt;/*
 * Placeholder node for a DEFAULT marker in an INSERT or UPDATE command.
 *
 * This is not an executable expression: it must be replaced by the actual
 * column default expression during rewriting.
 */&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The expression that replaces a SetToDefault is built by the same responsibility function shown earlier. The same three-step priority applies, so each &lt;code&gt;DEFAULT&lt;/code&gt; slot receives the corresponding identity / &lt;code&gt;pg_attrdef&lt;/code&gt; / type-default expression. The core mechanism is the same up to this point. What differs is &lt;em&gt;where&lt;/em&gt; the placeholder lives in the tree, and that depends on the form of the INSERT.&lt;/p&gt;

&lt;h3&gt;
  
  
  Single-row INSERT is substituted directly in the targetList
&lt;/h3&gt;

&lt;p&gt;For the single-row INSERT case above, since there is only one row, the four values (&lt;code&gt;DEFAULT&lt;/code&gt;, &lt;code&gt;'bob'&lt;/code&gt;, &lt;code&gt;DEFAULT&lt;/code&gt;, &lt;code&gt;DEFAULT&lt;/code&gt;) go straight into the Query's targetList. The three SetToDefault nodes sit at positions 1, 3, and 4 of the targetList. The rewriter walks the targetList column by column, and whenever it encounters a SetToDefault, it substitutes the expression produced by the responsibility function in place. In terms of the Query tree structure from 1.3.1, one row in the targetList represents the expression for one column, and that expression flips from SetToDefault to the actual default expression.&lt;/p&gt;

&lt;h3&gt;
  
  
  Multi-row INSERT runs through a VALUES RTE in between
&lt;/h3&gt;

&lt;p&gt;For multi-row INSERT, which inserts several rows in one statement, the Query tree shape is slightly different.&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;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;accounts&lt;/span&gt; &lt;span class="k"&gt;VALUES&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;DEFAULT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'bob'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="k"&gt;DEFAULT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
                            &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;DEFAULT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'carol'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;     &lt;span class="k"&gt;DEFAULT&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
                            &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;DEFAULT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'dave'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="k"&gt;DEFAULT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'2026-01-01'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With three rows, the targetList cannot hold three rows at once (targetList is shaped as one expression per column). Instead, PostgreSQL bundles the rows into a separate node and attaches it to the Query tree's from-list. That node is called a &lt;strong&gt;VALUES RTE&lt;/strong&gt;. RTE is short for &lt;code&gt;RangeTblEntry&lt;/code&gt; (introduced in 1.2.3), a single data-source unit referenced from the from-list (a table, a sub-query, a function, and here, the VALUES bundle). A VALUES RTE holds a list of rows internally, and each row in turn holds a list of values. The seven SetToDefault placeholders from the INSERT above are scattered across positions 1, 3, and 4 of the three rows inside the VALUES RTE.&lt;/p&gt;

&lt;p&gt;Why does the tree shape differ for what looks like the same INSERT? A single-row INSERT is the case where producing one row &lt;em&gt;is&lt;/em&gt; the entire result of the Query, while a multi-row INSERT is closer to "produce a set of rows and pour them into the table." PostgreSQL handles the latter the same way it handles a regular sub-query. A VALUES RTE sits in the same kind of slot a SELECT's result set would, an "inline data source." That is how &lt;code&gt;INSERT INTO accounts SELECT ...&lt;/code&gt; and &lt;code&gt;INSERT INTO accounts VALUES (...), (...)&lt;/code&gt; end up sharing the same rewriter and planner code paths.&lt;/p&gt;

&lt;p&gt;The multi-row path walks each value of each row inside the VALUES RTE, and whenever it sees a SetToDefault, it substitutes the expression produced by the same responsibility function. The expression produced is identical to the single-row case; only the location it lands in differs.&lt;/p&gt;

&lt;p&gt;There is one small optimization. A VALUES RTE may carry 100 or 1,000 rows. If none of them contain a SetToDefault, the rewriter skips the expensive list rebuild entirely. Rather than reconstructing all those rows, it passes the original list through to the next stage. The reason PostgreSQL has this fast path is the practical observation that multi-row INSERT is typically used for bulk loads, where the DEFAULT keyword almost never appears.&lt;/p&gt;

&lt;h2&gt;
  
  
  UPDATE only tidies what the user wrote; DELETE leaves the targetlist alone
&lt;/h2&gt;

&lt;p&gt;UPDATE goes through the same normalization function as INSERT, but it does much less work than INSERT does. There is no need to look for missing columns to fill. The SET clause of an UPDATE only puts the columns the user explicitly listed into the targetList, and the columns the user did not list must keep the existing row's values. So there is nothing to fill in for empty slots. The two things the rewriter does for UPDATE are: first, resolve any &lt;code&gt;DEFAULT&lt;/code&gt; keyword the user wrote into the actual expression (the same SetToDefault substitution mechanism as in INSERT); second, combine separate-line updates of &lt;em&gt;parts&lt;/em&gt; of the same column into a single expression. The second item needs a brief explanation.&lt;/p&gt;

&lt;p&gt;A column in PostgreSQL can be more than a simple scalar (&lt;code&gt;int&lt;/code&gt;, &lt;code&gt;text&lt;/code&gt;, etc.); it can be a composite type or an array. You can write a SET clause that updates only &lt;em&gt;part&lt;/em&gt; of such a column. For example, if &lt;code&gt;address&lt;/code&gt; is a composite-type column shaped like &lt;code&gt;(street, city, zip)&lt;/code&gt;, you can update two different fields of the same column in one UPDATE.&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;UPDATE&lt;/span&gt; &lt;span class="n"&gt;accounts&lt;/span&gt;
   &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;address&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;street&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'101 Pine St'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;address&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;city&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'Seattle'&lt;/span&gt;
   &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;id&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or you can update different subscripts of an array column &lt;code&gt;tags&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;UPDATE&lt;/span&gt; &lt;span class="n"&gt;accounts&lt;/span&gt;
   &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;tags&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="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'premium'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;tags&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'verified'&lt;/span&gt;
   &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;id&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The two SET items look like the same column name (&lt;code&gt;address&lt;/code&gt;, &lt;code&gt;tags&lt;/code&gt;) appearing twice in the SQL text, but they really refer to &lt;em&gt;different parts&lt;/em&gt; of the column. PostgreSQL combines the two lines into a &lt;em&gt;single&lt;/em&gt; expression that operates on the column as a whole. &lt;code&gt;address.street = '101 Pine St'&lt;/code&gt; followed by &lt;code&gt;address.city = 'Seattle'&lt;/code&gt; becomes "take the original address, change street first, then take the result and change city" as one expression. As a result, even though the column name appears twice in the SET clause, at execution time the column is updated once.&lt;/p&gt;

&lt;p&gt;When the &lt;em&gt;entire&lt;/em&gt; column is assigned twice (&lt;code&gt;UPDATE t SET address = X, address = Y&lt;/code&gt;, replacing the whole column twice), that is no longer a partial update but a genuine conflict, and the rewriter throws a syntax error. The combining only happens when the two SET items point to &lt;em&gt;different parts&lt;/em&gt; of the same column.&lt;/p&gt;

&lt;p&gt;DELETE is simpler still. In the DML branch, DELETE has a one-line case body that reads "Nothing to do here." DELETE produces no column values. It only decides which rows to remove, so there is nothing to do to the targetList itself. None of the things we have seen so far (INSERT's default filling, the DEFAULT keyword substitution for INSERT/UPDATE, UPDATE's partial-update combining) apply to DELETE. The amount of work the rewriter does is set by how much responsibility the user's query has for producing row data.&lt;/p&gt;

&lt;p&gt;That covers the DML's targetlist normalization. We have walked through the &lt;em&gt;filling-in-empty-slots&lt;/em&gt; half of DML rewriting. The other half, &lt;em&gt;redirecting references to point at different places&lt;/em&gt;, is covered in 1.3.4 through RETURNING mapping and DML-on-view transformation.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this means in practice
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;First, when an INSERT feels slow, default expressions are the first thing to suspect.&lt;/strong&gt; Even if the user does not name a column, the default expression for it still runs on every INSERT. If an expression in &lt;code&gt;pg_attrdef&lt;/code&gt; calls a function (&lt;code&gt;now()&lt;/code&gt;, &lt;code&gt;uuid_generate_v4()&lt;/code&gt;) or a sub-query, that function or sub-query runs once per missing column on every INSERT. Sequence consumption, statistics counters, side-effect function calls, all of these happen exactly as if the user had provided the value explicitly. When INSERT performance looks off, the fastest first check is to inspect what each default expression evaluates to via &lt;code&gt;\d+ &amp;lt;table&amp;gt;&lt;/code&gt;. Avoiding heavy sub-queries in default expressions follows the same reasoning.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Second, RULE definitions can safely reference &lt;code&gt;NEW.&amp;lt;column&amp;gt;&lt;/code&gt; even for columns without a default.&lt;/strong&gt; With a rule like the &lt;code&gt;forward_deleted_at&lt;/code&gt; example from the body text, there is no need to separately check whether the user named &lt;code&gt;deleted_at&lt;/code&gt;. If they did, the value flows through as written; if they did not, NULL flows through. Missing data is uniformly carried as NULL, so the rule author does not have to branch on every possible combination of "which columns did the user actually write." This safety guarantee shows up often in INSERT triggers and audit rules.&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>database</category>
      <category>internals</category>
      <category>rewriter</category>
    </item>
    <item>
      <title>1.3.2 How RLS Rewrites Queries (Mechanism)</title>
      <dc:creator>JoongHyuk Shin</dc:creator>
      <pubDate>Sun, 17 May 2026 10:53:10 +0000</pubDate>
      <link>https://dev.to/joonghyukshin/132-how-rls-rewrites-queries-mechanism-1eaa</link>
      <guid>https://dev.to/joonghyukshin/132-how-rls-rewrites-queries-mechanism-1eaa</guid>
      <description>&lt;p&gt;Once you put row-level security (RLS) on a table, queries that read from that table don't run the way the user wrote them. A single line &lt;code&gt;SELECT * FROM accounts&lt;/code&gt; comes out of the rewriter looking more like &lt;code&gt;SELECT * FROM accounts WHERE owner = current_user&lt;/code&gt;. The user never wrote a WHERE clause, but it's there. What RLS as a feature does is covered in 7.5. Here we look at what that "silently changes" actually is, inside Postgres.&lt;/p&gt;

&lt;h2&gt;
  
  
  The qual the rewriter adds
&lt;/h2&gt;

&lt;p&gt;The core of RLS is simpler than it sounds. It takes the expression written in the policy and grafts it onto the target table as a qual. That's the whole mechanism. What PG calls a qual is a shorthand for a boolean expression that filters rows, the kind of thing that lives in a WHERE clause (short for "qualification", and the same concept as what the SQL standard usually calls a predicate). If a query is the whole tree carrying SELECT/FROM/WHERE and so on, a qual is one expression node inside that tree. RLS doesn't run a separate enforcement engine. It plants the policy's expression into the query tree and deforms the tree itself.&lt;/p&gt;

&lt;p&gt;This work happens at the same site as view expansion (covered in 1.3.1). The RIR pass, driven by &lt;code&gt;fireRIRrules&lt;/code&gt;, finishes everything else first (CTE handling, view expansion, sublink recursion) and then, as the &lt;strong&gt;last&lt;/strong&gt; step, applies RLS policies. It walks the query's range table one more time and, for each RTE that points at a regular relation, attaches the policy's conditions.&lt;/p&gt;

&lt;p&gt;The place those conditions go is the &lt;code&gt;securityQuals&lt;/code&gt; field on the RTE. The comment in the PG source describes the field this way:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight c"&gt;&lt;code&gt;&lt;span class="cm"&gt;/*
 * securityQuals is a list of security barrier quals (boolean expressions),
 * to be tested in the listed order before returning a row from the
 * relation.  It is always NIL in parser output.  Entries are added by the
 * rewriter to implement security-barrier views and/or row-level security.
 */&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things stand out. First, &lt;code&gt;securityQuals&lt;/code&gt; is filled by the rewriter, not the parser. Conditions appear on the RTE that weren't in the user's query at all. Second, RLS and security-barrier views share the same container. A security-barrier view (defined with &lt;code&gt;CREATE VIEW ... WITH (security_barrier)&lt;/code&gt;) is one whose filter conditions the planner is forbidden from reordering against the user's WHERE clauses. Its purpose is essentially the same as RLS, so PG implements both on top of the same machinery. Even though 1.3.1 showed view expansion turning a view reference into a sub-query, the conditions of a security-barrier view and the conditions of an RLS policy end up in the very same &lt;code&gt;securityQuals&lt;/code&gt; list. (The leaky view attack scenarios that security-barrier views guard against, and the operational pitfalls, are covered in 7.5.3.)&lt;/p&gt;

&lt;p&gt;RLS fills the same container the same way. A "policy" in RLS is a catalog object created by &lt;code&gt;CREATE POLICY&lt;/code&gt;. A single policy bundles five things together: a name, which commands it applies to (SELECT/INSERT/UPDATE/DELETE or ALL), which roles it applies to, the USING expression that filters existing rows, and the WITH CHECK expression that validates new rows. It's stored as one row in the &lt;code&gt;pg_policy&lt;/code&gt; catalog, and it rides along when the table's relcache is loaded. The rewriter pulls the USING and WITH CHECK expressions out of that catalog row and grafts them onto the query tree.&lt;/p&gt;

&lt;p&gt;Suppose the &lt;code&gt;accounts&lt;/code&gt; table has this policy:&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="n"&gt;POLICY&lt;/span&gt; &lt;span class="n"&gt;account_owner&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;accounts&lt;/span&gt;
    &lt;span class="k"&gt;FOR&lt;/span&gt; &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;owner&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;current_user&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The user runs &lt;code&gt;SELECT * FROM accounts WHERE balance &amp;gt; 0&lt;/code&gt;. After the analyzer, the Query tree's RTE for &lt;code&gt;accounts&lt;/code&gt; has an empty &lt;code&gt;securityQuals&lt;/code&gt;. When the rewriter's RLS pass visits that RTE, it pulls the policy's USING expression &lt;code&gt;owner = current_user&lt;/code&gt;, copies it, fixes up the Var references so they point at the right relation, and drops it into &lt;code&gt;securityQuals&lt;/code&gt;. What the planner ends up seeing, conceptually, is something like:&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;accounts&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;owner&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;current_user&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;balance&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;owner = current_user&lt;/code&gt; is a condition the user never wrote. The rewriter pulled it out of the policy and stuck it in.&lt;/p&gt;

&lt;p&gt;One thing to flag, though. That SQL above is a conceptual view. PG isn't actually rewriting the user's SQL text into that form. The text the user submitted stays as it was. What gets transformed is the Query tree it was parsed into. The policy condition lands on the &lt;code&gt;accounts&lt;/code&gt; RTE's &lt;code&gt;securityQuals&lt;/code&gt; field inside that tree. The SQL above is just a human-readable rendering of the tree-level change. What &lt;code&gt;pg_stat_statements&lt;/code&gt; records and what shows up in the logs is the original SQL the user typed. The RLS transformation happens at the tree level, not at the text level.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the rewriter, not somewhere else
&lt;/h2&gt;

&lt;p&gt;Doing RLS as a query transformation is itself a design choice. Row-level access control can be built in other ways. One alternative is to add a separate filter stage in the execution engine that evaluates the policy condition as each row comes off the relation. PG didn't go that route. It does the work in the rewriter, planting the condition into the query tree.&lt;/p&gt;

&lt;p&gt;The reason lies one step downstream, in the planner. Once the rewriter has folded the policy condition in as a normal qual, the planner doesn't see it as anything special. It looks like just another condition in the query. So if there's an index on the column, the planner can pick an index scan. If the condition can be pushed below a join, the planner can push it. It estimates selectivity from statistics like any other qual. The RLS condition benefits from the planner's full optimization machinery.&lt;/p&gt;

&lt;p&gt;A separate runtime filter wouldn't get any of this. The filter tends to bolt onto the tail of an already-built plan, which means the table gets fully scanned before the policy condition is applied. There's no path to index the policy condition. Putting RLS in the rewriter is the choice that keeps access control inside the part of the query the planner can optimize.&lt;/p&gt;

&lt;p&gt;There's also a reason &lt;code&gt;fireRIRrules&lt;/code&gt; saves RLS for last. A policy's condition can contain a sub-query (for example, &lt;code&gt;USING (owner IN (SELECT ...))&lt;/code&gt;), and adding such a condition means the infinite-recursion check needs to run again on the new condition. If RLS were folded into the view-expansion loop above, the tree walk for view expansion would end up visiting the sub-queries inside RLS conditions twice. Pulling RLS out into a separate pass after view expansion avoids that.&lt;/p&gt;

&lt;h2&gt;
  
  
  The qual that filters and the qual that checks
&lt;/h2&gt;

&lt;p&gt;A policy can carry two kinds of condition, and the two land in different parts of the query tree and get enforced in different ways. This distinction is the most important point in the whole RLS mechanism.&lt;/p&gt;

&lt;p&gt;The USING condition decides &lt;strong&gt;which of the existing rows in the table are visible&lt;/strong&gt;. The &lt;code&gt;owner = current_user&lt;/code&gt; from before is a USING. It goes into &lt;code&gt;securityQuals&lt;/code&gt; and behaves like a WHERE clause. Rows that don't match are simply absent from the result. They disappear silently.&lt;/p&gt;

&lt;p&gt;The WITH CHECK condition decides &lt;strong&gt;which new rows are allowed in&lt;/strong&gt;. Rows added by INSERT or UPDATE have to satisfy it. This condition doesn't go into &lt;code&gt;securityQuals&lt;/code&gt;. It goes into a separate list on the Query node called &lt;code&gt;withCheckOptions&lt;/code&gt;, and it gets enforced differently. When a row violates the condition, the row isn't quietly dropped. PG &lt;strong&gt;raises an error&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Why is one silent and the other loud? The scenarios make it obvious. Suppose the user runs &lt;code&gt;SELECT * FROM accounts&lt;/code&gt; and there happen to be 100 accounts they don't own. It's natural for the USING condition to remove those from the result. The user asked for "rows I can see," and the unseen rows being missing is the expected behavior. It's not an error.&lt;/p&gt;

&lt;p&gt;Now suppose the user runs &lt;code&gt;INSERT INTO accounts VALUES (...)&lt;/code&gt; trying to insert an account owned by someone else. If that row were silently dropped, the user would believe the INSERT succeeded while the data isn't actually there. The command reported success, but there's no result behind it. That's the worst kind of bug to leave for the application. So PG raises an error on WITH CHECK violations. The comment in rowsecurity.c spells out this intent: the check option exists specifically to make sure a policy violation is signaled as an error, because otherwise rows you were trying to add could silently disappear.&lt;/p&gt;

&lt;p&gt;So USING is a visibility filter and WITH CHECK is a write gate. One is silent, the other is loud. The same RLS policy gets unpacked into different containers and different enforcement on the read path versus the write path.&lt;/p&gt;

&lt;p&gt;There's one convenience. If a policy doesn't specify an explicit WITH CHECK, PG reuses the USING condition as the WITH CHECK. The reasoning is that "only rows you can read can be written" is a reasonable default in most cases. Specifying a separate WITH CHECK lets you write policies where the read condition and the write condition differ.&lt;/p&gt;

&lt;h2&gt;
  
  
  How policies get combined
&lt;/h2&gt;

&lt;p&gt;A single table can carry multiple policies. By what rule does the rewriter combine them into one condition?&lt;/p&gt;

&lt;p&gt;Policies come in two flavors: permissive and restrictive. A permissive policy says "allow if this condition is met," and multiple permissive policies are combined with OR. Any one of them passing is enough to make the row visible. A restrictive policy says "must satisfy this condition," and they're combined with AND. Violating any single restrictive policy hides the row. The rewriter filters the policies by command type and role, then ORs the permissive ones into a single chunk and ANDs the restrictive ones onto it.&lt;/p&gt;

&lt;p&gt;Imagine the &lt;code&gt;accounts&lt;/code&gt; table has three policies. One is a permissive policy that lets users see accounts they own, with the condition &lt;code&gt;owner = current_user&lt;/code&gt;. Another is a permissive policy that also lets users see accounts flagged as public, with &lt;code&gt;is_public = true&lt;/code&gt;. The third is a restrictive policy that blocks deleted accounts no matter what, with &lt;code&gt;deleted_at IS NULL&lt;/code&gt;. When the user runs a SELECT, the rewriter combines the three into the following condition on &lt;code&gt;accounts&lt;/code&gt;'s &lt;code&gt;securityQuals&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="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;owner&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;current_user&lt;/span&gt; &lt;span class="k"&gt;OR&lt;/span&gt; &lt;span class="n"&gt;is_public&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;deleted_at&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The two permissive conditions OR together to form an allow-channel ("either I own it, or it's public"), and the single restrictive condition ANDs on top to enforce "and also, not deleted." Adding more permissive policies widens the allow-channel, so more rows become visible. Adding more restrictive policies adds more gates that have to pass, so fewer rows become visible. The general shape is &lt;code&gt;(permissive1 OR permissive2 OR ...) AND restrictive1 AND restrictive2 AND ...&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;There's one rule worth highlighting. &lt;strong&gt;If there are no permissive policies at all, no row is visible.&lt;/strong&gt; That holds even if there are plenty of restrictive policies on the table. The reason is that permissive policies are the grounds for visibility, and restrictive policies are constraints that trim what's already allowed. With no grounds for visibility, there's nothing to trim. The rewriter handles this by putting a single &lt;code&gt;false&lt;/code&gt; constant into &lt;code&gt;securityQuals&lt;/code&gt;. The effective condition becomes &lt;code&gt;WHERE false&lt;/code&gt;, and no row passes.&lt;/p&gt;

&lt;p&gt;That's RLS's default-deny behavior. If you run &lt;code&gt;ALTER TABLE ... ENABLE ROW LEVEL SECURITY&lt;/code&gt; on a table without creating a single policy, no one (except the table owner or a user with BYPASSRLS) sees a single row. It might feel counterintuitive that not writing a WHERE clause results in zero rows, but RLS doesn't sit on the same layer as the user's WHERE. It's an access-control gate sitting above that layer. The gate's default being deny is the same fail-closed principle behind firewalls and filesystem ACLs. Zero policies means zero rules granting passage, so zero rows pass through, which is internally consistent. Turning RLS on is in itself a declaration that "anything not explicitly allowed is forbidden," and that's the safe default that prevents one forgotten policy from leaking data.&lt;/p&gt;

&lt;h2&gt;
  
  
  Security barrier: quals have an order
&lt;/h2&gt;

&lt;p&gt;That &lt;code&gt;securityQuals&lt;/code&gt; is not just a list but an &lt;strong&gt;ordered&lt;/strong&gt; list is the last piece of the RLS mechanism. The field comment said "tested in the listed order," and that order matters.&lt;/p&gt;

&lt;p&gt;Why does it matter? After the rewriter plants the policy conditions, the planner picks each element of &lt;code&gt;securityQuals&lt;/code&gt; out (in &lt;code&gt;process_security_barrier_quals&lt;/code&gt;) and moves it into the rel's qual list. As it does so, it assigns each element a distinct security_level. Earlier elements get a lower level, later elements a higher level. Conditions from the user's own WHERE clause get an even higher level than any of those.&lt;/p&gt;

&lt;p&gt;The level enforces evaluation order. Lower-level conditions have to be evaluated before higher-level ones. The concrete thing it prevents is a function the user wrote in WHERE getting evaluated before the RLS condition.&lt;/p&gt;

&lt;p&gt;Picture 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="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;accounts&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;leaky_function&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;account_number&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Suppose &lt;code&gt;leaky_function&lt;/code&gt; is a function that leaks its argument value somewhere. The leak can take several forms. A PL/pgSQL function might write the argument to the server log with &lt;code&gt;RAISE NOTICE '%', $1&lt;/code&gt;. Or you can write a function that deliberately raises an error for certain argument values, like &lt;code&gt;1 / (CASE WHEN account_number = '...' THEN 0 ELSE 1 END)&lt;/code&gt; triggering a division-by-zero. When PG raises a runtime error like that, the error context includes the call site and the argument expression, so the argument value gets carried out into the user-visible error message or the server log. Any path where information about the argument escapes the function through something other than its return value makes the function leaky. (The opposite is leakproof, a function guaranteed to have no such paths.) PG groups all such functions under the term &lt;strong&gt;leaky functions&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The RLS policy is supposed to only show rows where &lt;code&gt;owner = current_user&lt;/code&gt;. But if the planner, for optimization reasons, evaluates &lt;code&gt;leaky_function&lt;/code&gt; before the RLS condition, the function receives the &lt;code&gt;account_number&lt;/code&gt; of rows the user has no right to see as its argument. Even if the policy then removes those rows from the result, the function already saw the values and leaked them, into the server log or the error message. RLS has been pierced.&lt;/p&gt;

&lt;p&gt;The security_level prevents this. The RLS condition is at a lower level, and the user's WHERE conditions are at a higher level. The planner therefore cannot push a user condition containing a non-leakproof function below the RLS condition. The RLS condition filters first, and only the rows that pass make it to the user's function. This is the substance of the name "security barrier": qual order keeps optimization from crossing the security boundary.&lt;/p&gt;

&lt;p&gt;There's one more thing about the order. The rewriter attaches the RLS conditions to the &lt;strong&gt;front&lt;/strong&gt; of &lt;code&gt;rte-&amp;gt;securityQuals&lt;/code&gt;. If a table has both an RLS policy and conditions coming in from a security-barrier view, the RLS condition on the table itself ends up at the lower level, the position that gets evaluated first. The decision is that policies bound directly to a table take precedence over conditions coming in through a view.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this means in practice
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;First, the performance of queries on an RLS-protected table comes down to whether the policy condition can use an index.&lt;/strong&gt; The RLS condition isn't a separate filter, it's a regular qual merged into the query, so the planner may or may not be able to use an index for it. A simple &lt;code&gt;USING (owner = current_user)&lt;/code&gt; will use an index scan if there's one on &lt;code&gt;owner&lt;/code&gt;. A &lt;code&gt;USING (owner IN (SELECT ... FROM ...))&lt;/code&gt; with a sub-query, on the other hand, attaches that sub-query to every query against the table. If queries on an RLS table feel slow, the first step is to use &lt;code&gt;EXPLAIN&lt;/code&gt; to see how the policy condition is being executed. Policy conditions are part of index design too.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Second, if an INSERT under RLS completes without error, the row actually went in.&lt;/strong&gt; WITH CHECK violations are loud, not silent. Rows blocked by the policy don't vanish behind a success response, so you can trust that on the read side. What's a separate concern is whether the application code is catching that error and handling it properly. Make sure WITH CHECK violation errors are routed through the same handling path as ordinary constraint violations.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Third, turning RLS on without creating a policy locks the table down entirely.&lt;/strong&gt; &lt;code&gt;ENABLE ROW LEVEL SECURITY&lt;/code&gt; is itself a default-deny declaration. If a migration enables RLS in one statement and creates policies in another, and the second statement is missing, any code deployed in between gets empty results. To make matters worse, table owners bypass RLS, so the problem won't surface in a development environment where you connect as the owner; it only shows up in production. The lesson is to put the RLS-enable statement and the policy creation in a single transaction, and to test at least once with a role that isn't the owner.&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>database</category>
      <category>internals</category>
      <category>security</category>
    </item>
    <item>
      <title>1.3.1 Rule system and view expansion</title>
      <dc:creator>JoongHyuk Shin</dc:creator>
      <pubDate>Thu, 14 May 2026 10:49:29 +0000</pubDate>
      <link>https://dev.to/joonghyukshin/131-rule-system-and-view-expansion-2cg1</link>
      <guid>https://dev.to/joonghyukshin/131-rule-system-and-view-expansion-2cg1</guid>
      <description>&lt;p&gt;This section is about how PostgreSQL makes a view reference disappear. When a user writes &lt;code&gt;SELECT * FROM my_view&lt;/code&gt;, the planner no longer sees &lt;code&gt;my_view&lt;/code&gt; in that query. As soon as PostgreSQL receives the query, it moves the SELECT definition that the view name points to into that spot. Where and how that substitution happens, and how the fact that the mechanism rides on PostgreSQL's old RULE system surfaces in application code, is the subject of this section.&lt;/p&gt;

&lt;h2&gt;
  
  
  View and materialized view: what is the difference
&lt;/h2&gt;

&lt;p&gt;Views themselves are worth one paragraph of setup. PostgreSQL has two kinds of views: the plain view and the materialized view.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A plain view holds no data.&lt;/strong&gt; A view definition is a SELECT line (or a larger query), and every time you SELECT from the view, you get back the result of running that definition SELECT freshly. The view itself has no storage. Only the view definition is registered in the catalog, and each time a query comes in, that definition gets expanded to read data from the base tables. &lt;strong&gt;The subject of this section is the expansion of plain views.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A materialized view stores its data.&lt;/strong&gt; It runs the view definition's SELECT once, keeps the result on disk, and later SELECTs read that stored result directly. When the base table data changes, the materialized view's result is not refreshed automatically; the user has to explicitly run &lt;code&gt;REFRESH MATERIALIZED VIEW&lt;/code&gt; to recompute it. So the rewriter leaves materialized views alone, without expanding them.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Aspect&lt;/th&gt;
&lt;th&gt;Plain view&lt;/th&gt;
&lt;th&gt;Materialized view&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Data storage&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;td&gt;Yes (on disk)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Behavior on SELECT&lt;/td&gt;
&lt;td&gt;Runs definition SELECT each time&lt;/td&gt;
&lt;td&gt;Reads stored result&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;When updated&lt;/td&gt;
&lt;td&gt;Reflects base table changes immediately&lt;/td&gt;
&lt;td&gt;Updated only on &lt;code&gt;REFRESH&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Rewriter handling&lt;/td&gt;
&lt;td&gt;Expanded into definition SELECT&lt;/td&gt;
&lt;td&gt;Left as is&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;With this picture in mind, it becomes clear that this section deals with how PostgreSQL moves the definition SELECT into place for plain views.&lt;/p&gt;

&lt;h2&gt;
  
  
  PostgreSQL's RULE system
&lt;/h2&gt;

&lt;p&gt;PostgreSQL has a mechanism called the RULE system. It is the system responsible for &lt;strong&gt;rewriting the query itself, right after the query arrives at the backend and before it is actually executed&lt;/strong&gt;. As noted in the 1.3 chapter introduction, the rewriter is the stage that changes the form of a query while preserving its meaning, and the RULE system is the tool that decides which rules to apply for that form change. (Transformations that change the meaning itself, such as subquery unnesting or predicate push-down, are the planner's responsibility in PostgreSQL and are covered in chapter 1.4.)&lt;/p&gt;

&lt;p&gt;A rule is a bundle of four elements.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Element&lt;/th&gt;
&lt;th&gt;Meaning&lt;/th&gt;
&lt;th&gt;Example&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Target relation&lt;/td&gt;
&lt;td&gt;Which table/view it applies to&lt;/td&gt;
&lt;td&gt;the &lt;code&gt;users&lt;/code&gt; table&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Event type&lt;/td&gt;
&lt;td&gt;Which command triggers it&lt;/td&gt;
&lt;td&gt;one of INSERT, UPDATE, DELETE, SELECT&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Condition&lt;/td&gt;
&lt;td&gt;Under what condition it fires (optional)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;WHERE NEW.role = 'admin'&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Action&lt;/td&gt;
&lt;td&gt;What it does when fired&lt;/td&gt;
&lt;td&gt;replace the original command with another query, or run an additional query&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;This bundle is stored as one row in the &lt;code&gt;pg_rewrite&lt;/code&gt; catalog. A user can create a rule directly with the &lt;code&gt;CREATE RULE&lt;/code&gt; command, and PostgreSQL also registers one rule automatically when a view is created.&lt;/p&gt;

&lt;p&gt;Comparing it to the trigger, which does a similar job, makes the position of the RULE clear. &lt;strong&gt;A trigger acts at the executor stage, hooking into each individual row.&lt;/strong&gt; This row was inserted, so add a row to the audit table, that kind of thing. &lt;strong&gt;A rule rewrites the query as a whole at the rewriter stage.&lt;/strong&gt; Query A came in, so rewrite it as query B and run that, that kind of thing. If a trigger is a "per-row side effect," a rule is a "per-query transformation."&lt;/p&gt;

&lt;p&gt;Here is one example of a rule a user might define directly. Suppose you want to record into an audit table every time an INSERT happens on the &lt;code&gt;users&lt;/code&gt; 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;RULE&lt;/span&gt; &lt;span class="n"&gt;log_user_insert&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt;
    &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="n"&gt;users&lt;/span&gt;
    &lt;span class="k"&gt;DO&lt;/span&gt; &lt;span class="n"&gt;ALSO&lt;/span&gt; &lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;user_audit_log&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ts&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;VALUES&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;NEW&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'INSERT'&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;DO ALSO&lt;/code&gt; means "run the original INSERT as is, and additionally run this audit INSERT too." With this rule in place, when you fire &lt;code&gt;INSERT INTO users (id, name) VALUES (1, 'alice')&lt;/code&gt;, the result of passing through the rewriter is two Query trees. One is the original INSERT into &lt;code&gt;users&lt;/code&gt;, the other is the INSERT into &lt;code&gt;user_audit_log&lt;/code&gt;. Both queries go through the planner and executor and get executed. This is what it looks like for one SQL text to fan out into multiple statements as it passes through the rewriter. (&lt;code&gt;DO INSTEAD&lt;/code&gt; replaces the original query; &lt;code&gt;DO ALSO&lt;/code&gt;, or the default, adds to the original query.)&lt;/p&gt;

&lt;p&gt;A user-defined rule is one use case of the RULE system. But the rule you encounter most often in ordinary applications is not one a user made directly. It is the &lt;strong&gt;SELECT rule that PostgreSQL registers automatically when you create a view&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  A view is a special case of the RULE system
&lt;/h2&gt;

&lt;p&gt;Creating a view in PostgreSQL means registering two things at once.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One is the registration of an empty table.&lt;/strong&gt; When you run &lt;code&gt;CREATE VIEW my_view AS SELECT ...&lt;/code&gt;, PostgreSQL adds a row to &lt;code&gt;pg_class&lt;/code&gt;. It gets an OID like an ordinary table, and column definitions go into &lt;code&gt;pg_attribute&lt;/code&gt; too. The only difference is that the &lt;code&gt;relkind&lt;/code&gt; column is marked 'v' (view). Looking at just this row, a view is an empty table. No file is even created to store data.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The other is the registration of a SELECT substitution promise.&lt;/strong&gt; PostgreSQL adds a row to the &lt;code&gt;pg_rewrite&lt;/code&gt; catalog, and that row is the promise: "when this view is queried with SELECT, instead of returning an empty result, run the SELECT written in the view definition and return that result." The formal name of this promise is the &lt;strong&gt;ON SELECT DO INSTEAD SELECT&lt;/strong&gt; rule. Unpacked word by word:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;ON SELECT&lt;/strong&gt;: which event it fires on. When a query that reads this relation with SELECT comes in.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;DO INSTEAD&lt;/strong&gt;: how the original action is handled. Instead of the original action (querying this empty view as is).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SELECT ...&lt;/strong&gt;: what gets run. The SELECT written in the view definition.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Putting the three parts together gives the promise: "when this view is queried with SELECT, do not return 0 rows from the empty table; run the view definition's SELECT and return that result." PostgreSQL does not keep a view as a separate data structure; it expresses the concept of a view with one empty table and this one-line promise. Mapping it to the general rule definition from the previous section: the view's automatic rule has the view itself as target, SELECT as event, no condition, and the execution of the definition SELECT as action.&lt;/p&gt;

&lt;p&gt;This automatic SELECT rule for views has two constraints. There is exactly one ON SELECT rule per view, and that rule fires unconditionally. A single line of PostgreSQL code captures this fact directly.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"RIR" stands for "Retrieve-Instead-Retrieve", that is an ON SELECT DO INSTEAD SELECT rule (which has to be unconditional and where only one rule can exist on each relation).&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The abbreviation RIR shows up all over the PostgreSQL code, and its identity is in this one line. "retrieve" is the SELECT keyword from the POSTQUEL era, and its trace remains in the abbreviation. Two historical names you will often encounter in PostgreSQL material are worth a brief note.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;POSTGRES&lt;/strong&gt; (all caps): the name of the database project started at UC Berkeley in 1986. Led by Michael Stonebraker as the follow-on research project, it meant the next generation after Ingres.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;POSTQUEL&lt;/strong&gt;: the name of the query language that the POSTGRES project used. It was designed as the successor to QUEL (Ingres's query language). It is not SQL.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;When SQL was adopted in 1995, the project renamed itself to PostgreSQL and POSTQUEL disappeared, but traces of that era remain in abbreviations like RIR and in some function names.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rewriter works in two steps
&lt;/h2&gt;

&lt;p&gt;The rewriter has a single entry point function, but inside it two steps run in sequence.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Step 1&lt;/strong&gt;: applying the INSERT/UPDATE/DELETE rules that the user defined with &lt;code&gt;CREATE RULE&lt;/code&gt;. The audit rule we saw earlier fires at this step. One input query can fan out into zero or several queries.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Step 2&lt;/strong&gt;: applying RIR rules. That is, firing the view's automatic ON SELECT rule to expand a view reference into its definition SELECT. It is applied to each of the queries produced by Step 1.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What the separation of the two steps means is clear. &lt;strong&gt;View expansion always happens last.&lt;/strong&gt; Whether a user-defined rule fans one query into several or changes it into something else, if a view reference remains in the result, it all gets resolved in Step 2. By the time it reaches the planner, no view reference remains anywhere.&lt;/p&gt;

&lt;p&gt;Step 2 does one thing. It walks the query's range table, and when it meets a view reference, it expands the view's definition SELECT into that spot.&lt;/p&gt;

&lt;p&gt;The expansion mechanism resolves cleanly thanks to the unification of the RTE data structure we saw in 1.2.3. In the Query that the analyzer produced, the view reference spot holds an RTE of kind RTE_RELATION (that is, an RTE pointing at an ordinary table or view). After passing through rewriter Step 2, that spot becomes kind RTE_SUBQUERY (an RTE that holds a sub-Query inside it), and the view definition's SELECT sits inside it. The item at slot N of the range table just changes kind and stays in the same spot.&lt;/p&gt;

&lt;p&gt;Suppose a view &lt;code&gt;v_active_users&lt;/code&gt; is defined as &lt;code&gt;SELECT id, name FROM users WHERE deleted_at IS NULL&lt;/code&gt;. Here is what the one line &lt;code&gt;SELECT * FROM v_active_users&lt;/code&gt; looks like before and after the rewriter.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;before rewriter (analyzer output)        after rewriter (planner input)

Query                                    Query
├─ commandType = CMD_SELECT              ├─ commandType = CMD_SELECT
├─ rtable                                ├─ rtable
│   └─ RangeTblEntry                     │   └─ RangeTblEntry
│       (rtekind = RTE_RELATION,         │       (rtekind = RTE_SUBQUERY,
│        relid = OID(v_active_users))    │        subquery = Query{
│                                        │             rtable = [users RTE],
│                                        │             targetList = [id, name],
│                                        │             qual = (deleted_at IS NULL)
│                                        │        })
├─ jointree → RangeTblRef(1)             ├─ jointree → RangeTblRef(1)
└─ targetList: SELECT *                  └─ targetList: SELECT *
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Slot 1 of the range table stays in place. Only the kind changed from RTE_RELATION to RTE_SUBQUERY, and the view definition sits inside it whole. Thanks to this unification, the planner, the stage after the rewriter, does not know the concept of a view at all. The Query the planner receives is just an ordinary SELECT with a sub-query expanded into a slot. This is why, when you look at EXPLAIN output, the view name disappears and base table names show up directly.&lt;/p&gt;

&lt;p&gt;A view definition can contain another view inside it. In that case the expanded sub-Query is recursively passed to the same function to resolve the inner view too. The problem is when a view references itself (either directly or through another view). Left as is, that becomes an infinite recursion of expanding and expanding again. To prevent this, PostgreSQL carries a list of the OIDs of views currently being processed, and when it meets a view already in that list, it throws the error &lt;code&gt;infinite recursion detected in rules for relation "..."&lt;/code&gt; and aborts query execution. It chose to cut things off with a clear error rather than spin forever.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2 exceptions: materialized views and EXCLUDED
&lt;/h2&gt;

&lt;p&gt;The Step 2 view expansion logic has two explicit exceptions.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;First, materialized views are not expanded.&lt;/strong&gt; As we saw earlier, a materialized view reads its stored result directly, so there is no need to expand its definition on every query. Step 2 passes an RTE with relkind 'm' straight through.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Second, the virtual name EXCLUDED in &lt;code&gt;INSERT ... ON CONFLICT&lt;/code&gt; is not expanded.&lt;/strong&gt; To understand EXCLUDED, we need to briefly touch PostgreSQL's UPSERT syntax.&lt;/p&gt;

&lt;p&gt;PostgreSQL has the &lt;code&gt;INSERT ... ON CONFLICT (...) DO UPDATE SET ...&lt;/code&gt; syntax. Commonly called UPSERT, it means "attempt the INSERT, but if it hits a unique constraint or similar and a conflict occurs, run an UPDATE in its place instead." For example, in a table that records a user's login count, you can handle "if it is a new user, add it; if it already exists, increment the count" in one 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;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&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="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;login_count&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;VALUES&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'alice'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;CONFLICT&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;DO&lt;/span&gt; &lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt;
    &lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;EXCLUDED&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="c1"&gt;-- the incoming value 'alice'&lt;/span&gt;
    &lt;span class="n"&gt;login_count&lt;/span&gt; &lt;span class="o"&gt;=&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;login_count&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="c1"&gt;-- the existing row's value + 1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If a row with &lt;code&gt;id = 1&lt;/code&gt; already exists, the INSERT conflicts, and the UPDATE in the ON CONFLICT clause runs against that row. Here the UPDATE must be able to reference both rows: &lt;strong&gt;the row already in the DB&lt;/strong&gt; (referenced as &lt;code&gt;users.column&lt;/code&gt;) and &lt;strong&gt;the row that tried to come in via INSERT but was blocked&lt;/strong&gt; (referenced as &lt;code&gt;EXCLUDED.column&lt;/code&gt;). The name EXCLUDED carries the meaning "the values excluded because of the conflict." In the example above, &lt;code&gt;EXCLUDED.name&lt;/code&gt; is 'alice', and &lt;code&gt;users.login_count&lt;/code&gt; points at the existing count stored in the DB.&lt;/p&gt;

&lt;p&gt;EXCLUDED is neither a real table nor a view. It is a virtual row name that exists only inside the UPSERT syntax. Internally PostgreSQL does represent EXCLUDED as an RTE_RELATION RTE, but the view expansion logic must not wrongly fire and try to expand this RTE (there is no definition to expand). So when the rewriter meets this RTE, it skips it with a separate branch.&lt;/p&gt;

&lt;h2&gt;
  
  
  The RULE system has many pitfalls and is not recommended
&lt;/h2&gt;

&lt;p&gt;The fact that view expansion rides on the RULE system is a trace of PostgreSQL's history. The RULE system existed from the POSTGRES era, and views were implemented as one branch of that system. The idea was that if a user directly defined INSERT/UPDATE/DELETE rules on a view with &lt;code&gt;CREATE RULE&lt;/code&gt;, that view could be used as an update target.&lt;/p&gt;

&lt;p&gt;The RULE system itself is powerful but full of pitfalls. The same rule getting evaluated multiple times, cascading happening differently than intended, permission checks behaving subtly: problems come up often. In PostgreSQL 9.3, the &lt;strong&gt;automatically updatable view&lt;/strong&gt; feature was introduced to sidestep those pitfalls. When a user fires INSERT/UPDATE/DELETE against a simple view (one that, say, just picks some columns from a single base table), PostgreSQL analyzes the view definition and automatically converts it into INSERT/UPDATE/DELETE against the base table, with no rule definition needed. For complex views that cannot meet the automatically-updatable conditions, solving it with an &lt;strong&gt;INSTEAD OF trigger&lt;/strong&gt; is the standard approach today. PostgreSQL's official documentation recommends triggers over rules for INSERT/UPDATE/DELETE.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;In many cases, tasks that could be performed by rules on
INSERT/UPDATE/DELETE are better done with triggers. Triggers are
notationally a bit more complicated, but their semantics are much
simpler to understand. Rules tend to have surprising results when
the original query contains volatile functions: volatile functions
may get executed more times than expected in the process of carrying
out the rules.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The ON SELECT rule generated automatically when a view is created is something users rarely deal with directly. PostgreSQL installs it, and the rewriter expands it. The case where a user faces the RULE system is usually when they want to make a complex view updatable that automatically updatable views cannot handle, and even then an INSTEAD OF trigger is safer and less work.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;In other RDBMS engines, view expansion is usually handled as a separate stage. PostgreSQL's decision was to fold view expansion into one branch of general rule application. The result of that unification is that data structures and code paths gather in one place, but the cost is that the RULE system, an old abstraction, is exposed to users as is.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  What this means in practice
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;First, when a view name disappears from EXPLAIN output, the plan is not wrong.&lt;/strong&gt; Since the view reference gets expanded into its definition SELECT at the rewriter stage, the query the planner receives has no concept of a view at all. It is normal for base table names to show up in EXPLAIN output instead of the view name you wrote. The more complex the view definition, the more you need to look at the base tables in the EXPLAIN output and map them back to the view definition.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Second, when making a view updatable, consider an automatically updatable view or an INSTEAD OF trigger before a RULE.&lt;/strong&gt; For a simple view that meets the automatically-updatable conditions, INSERT/UPDATE/DELETE work with no separate definition. If it cannot meet the conditions, an INSTEAD OF trigger is safer than a user-defined RULE. Defining a RULE directly is the last resort.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Third, when you change a view definition with ALTER, every query that calls that view gets expanded with the new definition at its next analysis.&lt;/strong&gt; A view definition is stored as a SELECT rule in the catalog, and view expansion has the rewriter re-read that catalog entry at execution time and expand it. When the catalog changes, an invalidation message propagates to the plan cache, and on the next execution the result is planned with a fresh expansion using the new definition (see 1.2.3 plan cache). From the application side, this means you can use a view as a code abstraction. Just changing the view definition consistently updates the behavior of every query that uses that view from the next call onward.&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>database</category>
      <category>internals</category>
      <category>views</category>
    </item>
    <item>
      <title>1.3 Rewriter: How a Query is Rewritten</title>
      <dc:creator>JoongHyuk Shin</dc:creator>
      <pubDate>Thu, 14 May 2026 10:49:28 +0000</pubDate>
      <link>https://dev.to/joonghyukshin/13-rewriter-how-a-query-is-rewritten-14im</link>
      <guid>https://dev.to/joonghyukshin/13-rewriter-how-a-query-is-rewritten-14im</guid>
      <description>&lt;p&gt;By the time the analysis stage from 1.2 finishes, the SQL has become a Query tree. Catalog coordinates are baked in, locks (as we saw in 1.2.2) are already held from that point, and the type of every expression is determined. The starting point of this chapter is one fact: &lt;strong&gt;that Query tree does not go straight to the planner.&lt;/strong&gt; There is one more stage in between, and that stage is the rewriter.&lt;/p&gt;

&lt;p&gt;The rewriter's input and output are the same. It takes a Query tree and produces a Query tree. The number of trees that come out can be zero, one, or several. That means a single user SQL can fan out into multiple statements as it passes through the rewriter. The key point is that the input format and the output format are identical. The planner only receives Query trees that the rewriter passed through, and it does not distinguish whether a tree is exactly what the user wrote or the result of a transformation.&lt;/p&gt;

&lt;p&gt;Why does this stage exist on its own? Analysis is the work of connecting the meaning of SQL to the catalog, and planning is the work of deciding how to execute that meaning. The transformation that sits between them, the one that &lt;strong&gt;does not change what the query means but does change the form of the query itself&lt;/strong&gt;, is the rewriter's responsibility. Expanding a view reference into its underlying SELECT, injecting the WHERE condition of a row-level security (RLS) policy into the query, filling in defaults for columns omitted from an INSERT: all of this happens here.&lt;/p&gt;

&lt;p&gt;1.3 splits into three sections.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;1.3.1 Rule system and view expansion&lt;/strong&gt;: the mechanism by which a view reference gets expanded in place into a sub-query. This expansion rides on the RULE system that PostgreSQL has carried since its early days, and we cover how that fact surfaces in application code.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;1.3.2 How RLS rewrites queries&lt;/strong&gt;: when a row-level security policy is defined in the catalog, how the rewriter weaves that policy into the query as a WHERE condition.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;1.3.3 INSERT/UPDATE/DELETE rewriting&lt;/strong&gt;: filling in default values, handling RETURNING, and the flow that turns an automatically updatable view into INSERT/UPDATE/DELETE against the view's base table.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;By the end of this chapter, you should have a clear picture of where and how "the SQL I wrote" and "the SQL the planner saw" diverge. The three places they diverge are view expansion, RLS policy injection, and INSERT/UPDATE/DELETE rewriting. When EXPLAIN output or permission behavior does not match your intuition, recalling these three places is the start of an accurate trace.&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>database</category>
      <category>internals</category>
      <category>rewriter</category>
    </item>
    <item>
      <title>1.2.3 Query tree node types: Query, RangeTblEntry, TargetEntry</title>
      <dc:creator>JoongHyuk Shin</dc:creator>
      <pubDate>Sun, 10 May 2026 08:05:36 +0000</pubDate>
      <link>https://dev.to/joonghyukshin/123-query-tree-node-types-query-rangetblentry-targetentry-3c85</link>
      <guid>https://dev.to/joonghyukshin/123-query-tree-node-types-query-rangetblentry-targetentry-3c85</guid>
      <description>&lt;p&gt;After the Analyze stage from 1.2.2, the raw parse tree has become a Query tree. This section is about the shape of that result. How it differs from a raw parse tree, what abstraction sits at its core, and what assumptions the next stages (rewriter and planner) get to start from when they receive a Query tree.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tokens descend into catalog coordinates
&lt;/h2&gt;

&lt;p&gt;A raw parse tree and a Query tree are both trees, but they sit on different planes. A raw parse tree mirrors the syntactic structure of SQL text, so its nodes look a lot like tokens. Where a table name appeared, you find a node carrying that name as a token. Where a column appeared, you find another name token. The types of expressions are not yet determined.&lt;/p&gt;

&lt;p&gt;A Query tree is what those tokens look like after they have been resolved against the catalog. The same positions hold different representations now. Where a table name was, there is now an RTE carrying that table's OID and lock mode. Where a column name was, there is now an index pair: "the M-th attribute of the N-th item in rtable". Where an operator token sat, there is now an OID pointing to the function that implements that operator. The same shape of tree carries the same kinds of information at the same positions, but the information has dropped one level down from surface SQL text into catalog coordinates.&lt;/p&gt;

&lt;p&gt;For &lt;code&gt;SELECT id, name FROM users WHERE age &amp;gt; 18&lt;/code&gt;, the contrast looks 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;raw parse tree (sketch)          Query tree (sketch)

SelectStmt                       Query
├─ targetList                    ├─ commandType = CMD_SELECT
│   ├─ ColumnRef "id"            ├─ rtable
│   └─ ColumnRef "name"          │   └─ RangeTblEntry
├─ fromClause                    │       (relid = 16384, RTE_RELATION)
│   └─ RangeVar "users"          ├─ jointree (FROM + WHERE)
└─ whereClause                   ├─ targetList
    └─ A_Expr                    │   ├─ TargetEntry(resno=1, expr=Var(rt=1, att=1))
        ('&amp;gt;', ColumnRef "age",   │   └─ TargetEntry(resno=2, expr=Var(rt=1, att=2))
              A_Const 18)        └─ qual: OpExpr(opno=...,
                                            args=[Var(rt=1, att=3), Const(int4=18)])
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The descent into catalog coordinates is right there in the picture. Table names become OIDs, column names become pairs of (rtable index, attribute number), operator tokens become typed expression nodes. The rewriter and planner work on those coordinates and have nothing left to resolve.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;PG's decision to fold every kind of FROM item into a single enum looks unremarkable at first, but its effect compounds. If each kind of input were a different node type, the rewriter and planner would scatter kind-specific handling across every branch they touch. With one node type and an enum tag, transformations that cut across many places (think "scan every RTE and check locks", "inject an RLS policy on every RTE") fit into one place consistently.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Range table: every FROM input in one place
&lt;/h2&gt;

&lt;p&gt;The Query carries a single List that holds every item appearing in the FROM clause. PG calls this list the range table, and an item in it a RangeTblEntry. A plain table, a subquery (&lt;code&gt;FROM (SELECT ...)&lt;/code&gt;), a result of an explicit JOIN, a set-returning function call (&lt;code&gt;FROM generate_series(...)&lt;/code&gt;), an inline row set built with &lt;code&gt;VALUES (...)&lt;/code&gt;, a CTE (&lt;code&gt;WITH ... AS (...)&lt;/code&gt;). Whatever form a FROM input takes, it gets folded into a single entry in this list.&lt;/p&gt;

&lt;p&gt;The kind tag lives in one field, &lt;code&gt;rtekind&lt;/code&gt;, on the RTE. There is no separate node type per kind of input; the same &lt;code&gt;RangeTblEntry&lt;/code&gt; object carries different &lt;code&gt;rtekind&lt;/code&gt; values to distinguish them. The object type is one, and the value of &lt;code&gt;rtekind&lt;/code&gt; decides which auxiliary fields are meaningful. For a plain table the OID and lock mode fields matter; for a subquery the sub-Query field matters; and so on.&lt;/p&gt;

&lt;p&gt;You can see the payoff of this consolidation in the shape of the rewriter and planner code. Both stages, having received a &lt;code&gt;Query&lt;/code&gt;, work to a single model: "take the N items in rtable, glue them together via jointree, and evaluate targetList against the result." A subquery, a JOIN, a function call all flow through the same model. Adding a new kind of input is a matter of adding one line to RTEKind and filling in a few branches.&lt;/p&gt;

&lt;p&gt;The range table plays another role. Column references look at it. In a Query tree, a column is not a name; it is two integers, "attribute M of rtable item N". A column reference's meaning is bound to the order of items in the range table. That is why a rewriter or planner that adds, removes, or reorders rtable items must update the indices in existing column references at the same time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Target list: SELECT, INSERT, UPDATE, RETURNING all in one shape
&lt;/h2&gt;

&lt;p&gt;The other key List on a Query is the target list. The output columns of a SELECT, the values to assign in INSERT/UPDATE, and the columns returned by RETURNING all live in lists of the same shape with the same kind of node. One target list per Query for the SELECT/INSERT/UPDATE body, plus a second one for RETURNING when present.&lt;/p&gt;

&lt;p&gt;The shape is one, but its meaning shifts slightly with context. Each entry has a field called resno (short for result number, indicating where the entry lands in the result), and the interpretation of this number depends on whether you are looking at SELECT, INSERT, or UPDATE. Take this table to compare the two cases.&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="nb"&gt;serial&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="c1"&gt;-- attno 1&lt;/span&gt;
    &lt;span class="n"&gt;name&lt;/span&gt;  &lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;                 &lt;span class="c1"&gt;-- attno 2&lt;/span&gt;
    &lt;span class="n"&gt;age&lt;/span&gt;   &lt;span class="nb"&gt;int&lt;/span&gt;                   &lt;span class="c1"&gt;-- attno 3&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For SELECT, resno is the ordinal position of the output column.&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;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;age&lt;/span&gt; &lt;span class="k"&gt;FROM&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;This SELECT's target list has two entries. The first, holding the &lt;code&gt;name&lt;/code&gt; expression, has resno 1; the second, holding &lt;code&gt;age&lt;/code&gt;, has resno 2. They land in the first and second positions of the result set, respectively. Even though &lt;code&gt;name&lt;/code&gt; has attno 2 and &lt;code&gt;age&lt;/code&gt; has attno 3 in the table, the result positions in SELECT are independent of those attnos: they go 1, 2, in the order they appear in the SELECT list.&lt;/p&gt;

&lt;p&gt;For INSERT, resno is the attribute number of the destination column.&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;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&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;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;age&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;VALUES&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'alice'&lt;/span&gt;&lt;span class="p"&gt;,&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 INSERT's target list also has two entries, but the first's resno is not 1, the second's not 2. They are 2 and 3, the attnos of &lt;code&gt;name&lt;/code&gt; and &lt;code&gt;age&lt;/code&gt; in the table definition. The number that lands in resno is not the order in which the columns were written, but the position of the column in the table itself. UPDATE's SET clause follows the same rule: attno in resno.&lt;/p&gt;

&lt;p&gt;The fact that resno carries different meanings in different contexts feels odd at first, but folding two meanings into one List buys clear leverage. Expression evaluation code, target list traversal, RETURNING handling all assume one node type. The branching shrinks. The context-dependent meaning is settled when the List is built, and downstream stages just read what is already there.&lt;/p&gt;

&lt;p&gt;The target list also carries items invisible to the user. When an expression appears in ORDER BY or GROUP BY but not in the SELECT list, that expression is added to the target list with a "junk" mark. Sorting and grouping need a place to evaluate the value, so the value rides along in the same List. Junk columns get evaluated but stripped from the final output. The visible result is clean from the application side, while internally a few extra columns flow alongside to make sorting work.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the Query goes
&lt;/h2&gt;

&lt;p&gt;Right after a Query tree is built, the rewriter takes it. RULE expansion, view expansion, and row-level security (RLS) policy injection all happen on the Query tree at this stage. The output is still a Query tree (or a list of zero or more, when a rule expands one statement into several). Details are covered in chapter 1.3.&lt;/p&gt;

&lt;p&gt;The rewriter hands the Query off to the planner. The planner's job is to convert a Query tree into a PlannedStmt: the strategy for how this SQL will be executed. Which indexes to use, what JOIN order to take, whether to go parallel. Chapter 1.4 covers this in depth.&lt;/p&gt;

&lt;p&gt;What is interesting is that the next stage, the executor, does not look at the Query tree. The Query definition in &lt;code&gt;parsenodes.h&lt;/code&gt; says it in one line: "the Query structure is not used by the executor." The executor's input is a PlannedStmt, and the Query tree, once turned into a plan, does not flow further down. The Query tree is the common currency of parser/analyzer/rewriter/planner, and that is where its lifetime ends.&lt;/p&gt;

&lt;p&gt;This separation of stages is reflected directly in how prepared statements work. A prepared statement is a mechanism to avoid going through the parser every time the same SQL runs repeatedly, by holding the analyzed result or plan in memory. There are two layers of holding. One layer holds the Query tree (already resolved against the catalog) in memory; the layer above holds the PlannedStmt that has gone through the planner. What is commonly called the plan cache refers to these two layers together. A held Query tree lets the next execution start from the planner; a held PlannedStmt is handed straight to the executor. If the catalog changes in between, an invalidation message arrives, the held results are discarded, and analysis or planning runs again. Because the per-stage outputs of parser, analyzer, rewriter, planner, and executor are cleanly separated, deciding which layer to invalidate and where to restart is straightforward.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this means in practice
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;First, EXPLAIN works in the same shape for SELECT, INSERT, UPDATE, DELETE, and MERGE.&lt;/strong&gt; Five kinds of statement live in the same Query data structure, and the planner produces a single PlannedStmt regardless of kind. From the application side, you don't have to wonder whether you need a different tool to inspect an INSERT plan than a SELECT plan. From the tooling side, monitoring tools can deal with plans through one interface without branching on statement kind.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Second, INSERT/UPDATE remains safe even when only some columns are written.&lt;/strong&gt; The resno in the target list carries the attno of the destination column rather than the order in which the user wrote it. The columns the user wrote land in their attno positions; missing columns are filled in with defaults later by the rewriter, which works on the same target list (covered in 1.3.3). This is the structural reason ORM-generated INSERT/UPDATE statements work correctly even when columns are out of order or only partially specified.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Third, the same SQL can produce different Query trees depending on when it was analyzed.&lt;/strong&gt; Because catalog coordinates get baked into the Query tree, the same SQL text seen at two different times against two different catalog states produces two different trees. A prepared statement that produces different plans across two connections, a plan cache that re-analyzes after a catalog invalidation, the same code pointing at different tables under different &lt;code&gt;search_path&lt;/code&gt; settings: all of these emerge on top of this fact. From the application side, this is a single-line summary of where the intuition "same text, same behavior" breaks.&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>database</category>
      <category>internals</category>
      <category>queryplanner</category>
    </item>
    <item>
      <title>1.2.2 Semantic analysis: name resolution, type checking, catalog lookup</title>
      <dc:creator>JoongHyuk Shin</dc:creator>
      <pubDate>Thu, 07 May 2026 10:57:23 +0000</pubDate>
      <link>https://dev.to/joonghyukshin/122-semantic-analysis-name-resolution-type-checking-catalog-lookup-2699</link>
      <guid>https://dev.to/joonghyukshin/122-semantic-analysis-name-resolution-type-checking-catalog-lookup-2699</guid>
      <description>&lt;p&gt;A raw parse tree captures the structure of SQL text, but not what that structure refers to. Whether the token &lt;code&gt;users&lt;/code&gt; is a table in some schema, whether the two sides of &lt;code&gt;=&lt;/code&gt; have compatible types, whether &lt;code&gt;count&lt;/code&gt; is a function or a column. Filling in those answers and turning a raw parse tree into a meaningful query tree is the job of Semantic analysis. PG code commonly shortens it to "Analyze". The shape of the resulting node structure is covered in 1.2.3.&lt;/p&gt;

&lt;h2&gt;
  
  
  Parser and Analyzer
&lt;/h2&gt;

&lt;p&gt;A note on terminology first. In PG code, "parser" is used in two scopes. Narrowly, it refers to the lexer and grammar that turn SQL text into a raw parse tree. Broadly, it includes the next stage, Analyze, as well. Both stages live under &lt;code&gt;src/backend/parser/&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;This chapter splits them. The raw parser takes SQL text and produces a raw parse tree (1.2.1). The Analyzer takes a raw parse tree and produces a Query tree by consulting the catalog (this section). The two stages differ in their output shape (raw parse tree → Query tree) and in whether they touch the catalog.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the raw parse tree leaves out
&lt;/h2&gt;

&lt;p&gt;The raw parser checks only syntax. The fact that &lt;code&gt;SELECT a FROM t WHERE a = 1&lt;/code&gt; is grammatically well-formed is the limit of what a raw parse tree asserts. Whether &lt;code&gt;t&lt;/code&gt; actually exists, whether &lt;code&gt;a&lt;/code&gt; is a column of that table, whether &lt;code&gt;1&lt;/code&gt; can be coerced to the type of &lt;code&gt;a&lt;/code&gt;: the raw parse tree knows none of this.&lt;/p&gt;

&lt;p&gt;Why split into two stages? A few reasons.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Separation of concerns&lt;/strong&gt;. Mixing syntax checks with semantic checks puts too much responsibility into one stage. Splitting keeps each stage's code simple.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Raw parse must work in an aborted transaction&lt;/strong&gt;. When a transaction is in an aborted state, the catalog cannot be touched (covered at the end of 1.2.1). But commands like &lt;code&gt;COMMIT&lt;/code&gt;/&lt;code&gt;ROLLBACK&lt;/code&gt; still need to be recognized to escape that state. The split lets raw parse identify the command type without catalog access.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reuse of raw parse output&lt;/strong&gt;. A single raw parse tree can be Analyzed multiple times in different contexts (different &lt;code&gt;search_path&lt;/code&gt;, different parameter types). Prepared statements rely on this reuse pattern.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The result is a clean two-stage split. Raw parse is independent of the catalog and only checks syntax. Analyze then aggressively consults the catalog and fills in the blanks. Analyze does four things. It looks up every name in the raw parse tree against the catalog and replaces them with OIDs (object identifiers, the 32-bit integer IDs assigned to every object in the catalog). It determines the type of every expression. It inserts cast nodes wherever types do not match. It packages the result into a new tree called &lt;code&gt;Query&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Name resolution: what does each name in SQL refer to?
&lt;/h2&gt;

&lt;p&gt;Name resolution is the task of figuring out which catalog object each table, column, or function name in the SQL is pointing at.&lt;/p&gt;

&lt;p&gt;Start with table names. As Analyze reads down the FROM clause, it encounters items like &lt;code&gt;FROM users&lt;/code&gt;, looks up the &lt;code&gt;users&lt;/code&gt; table in the catalog, and obtains its OID. It then registers it in an internal list as "the Nth table appearing in this SQL's FROM". PG calls this list the range table. (Why "range"? Covered in 1.2.3.)&lt;/p&gt;

&lt;p&gt;A range table holds more than just plain tables. The FROM clause can contain subqueries (&lt;code&gt;FROM (SELECT ...)&lt;/code&gt;), JOIN results (&lt;code&gt;FROM t1 JOIN t2 ON ...&lt;/code&gt;), and function calls (&lt;code&gt;FROM generate_series(1, 10)&lt;/code&gt;). All of these are equivalent in being "an entry that produces rows", so each gets one slot in the range table. The differences between kinds are covered in 1.2.3.&lt;/p&gt;

&lt;p&gt;When the schema is not specified (&lt;code&gt;FROM users&lt;/code&gt; versus &lt;code&gt;FROM public.users&lt;/code&gt;), PG walks the connection's &lt;code&gt;search_path&lt;/code&gt; in order and picks the first schema that contains a matching table. This decision is locked in at Analyze time. So the same SQL text can refer to different tables across connections with different &lt;code&gt;search_path&lt;/code&gt; settings.&lt;/p&gt;

&lt;p&gt;Column resolution comes next. Column names appearing in WHERE, SELECT, ORDER BY, and similar positions are matched against the range table that was just built, deciding "which column of which table this name refers to". The familiar &lt;code&gt;column reference "a" is ambiguous&lt;/code&gt; error from &lt;code&gt;SELECT a FROM t1 JOIN t2 ON ...&lt;/code&gt; (when &lt;code&gt;a&lt;/code&gt; exists in both &lt;code&gt;t1&lt;/code&gt; and &lt;code&gt;t2&lt;/code&gt;) is raised at this stage.&lt;/p&gt;

&lt;p&gt;Column references can have between one and four dot-separated parts. &lt;code&gt;a&lt;/code&gt; (column only), &lt;code&gt;t.a&lt;/code&gt; (table.column), &lt;code&gt;s.t.a&lt;/code&gt; (schema.table.column), &lt;code&gt;db.s.t.a&lt;/code&gt; (database.schema.table.column). Depending on how many dots are present, different parts are interpreted differently, and the priority rules for that interpretation are baked into this stage.&lt;/p&gt;

&lt;p&gt;Function calls are slightly more involved. PG allows function overloading, so multiple versions of the same function name can be registered with different argument types. For example, &lt;code&gt;length()&lt;/code&gt; has separate versions accepting &lt;code&gt;text&lt;/code&gt; and &lt;code&gt;bytea&lt;/code&gt;, and the right one is chosen based on the argument types at the call site. Analyze gathers all candidates with the same name and picks the best match by argument-type matching. If the match is ambiguous, an ambiguous error is raised. If there is no match, &lt;code&gt;function does not exist&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Type checking and cast insertion
&lt;/h2&gt;

&lt;p&gt;In an expression tree, leaf nodes are one of three things. Constants, column references, or parameters. The constant &lt;code&gt;1&lt;/code&gt; has type integer, the column &lt;code&gt;name&lt;/code&gt; has type text, the parameter &lt;code&gt;$1&lt;/code&gt; has whatever type the caller declared. Analyze walks the expression tree from leaf to root, determining the type of every node along the way. The result type of &lt;code&gt;a + b&lt;/code&gt; is determined only after the types of &lt;code&gt;a&lt;/code&gt; and &lt;code&gt;b&lt;/code&gt; are settled, and the type of &lt;code&gt;(a + b) * 2&lt;/code&gt; is determined from that result.&lt;/p&gt;

&lt;p&gt;When the two sides of an operation differ in type, an automatic cast may be inserted. In &lt;code&gt;WHERE a = '5'&lt;/code&gt; where &lt;code&gt;a&lt;/code&gt; is integer and &lt;code&gt;'5'&lt;/code&gt; is text, PG inserts a cast node converting &lt;code&gt;'5'&lt;/code&gt; to integer so the comparison is well-typed. The authoritative answer for which type-to-type conversions are possible lives in the &lt;code&gt;pg_cast&lt;/code&gt; catalog. It records the source type, target type, conversion method, and which context the cast is automatically applied in. A few representative rows look like this.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;source → target&lt;/th&gt;
&lt;th&gt;Method&lt;/th&gt;
&lt;th&gt;Context&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;int4&lt;/code&gt; → &lt;code&gt;int8&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Function call (&lt;code&gt;int8(int4)&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;implicit&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;int8&lt;/code&gt; → &lt;code&gt;int4&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Function call (&lt;code&gt;int4(int8)&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;assignment&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;int4&lt;/code&gt; → &lt;code&gt;oid&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Binary passthrough&lt;/td&gt;
&lt;td&gt;implicit&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;json&lt;/code&gt; → &lt;code&gt;jsonb&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Text I/O detour&lt;/td&gt;
&lt;td&gt;assignment&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two of the columns capture the heart of how PG does casts. The method is how the source value is turned into the target type, and the context is where that cast is automatically applied.&lt;/p&gt;

&lt;p&gt;The method falls into one of three kinds.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;No conversion needed because the memory representation is the same&lt;/strong&gt;. The &lt;code&gt;int4 → oid&lt;/code&gt; row is this case. Both are 32-bit integers, so the binary representation passes through. The conversion cost is essentially zero.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A cast function is called&lt;/strong&gt;. The &lt;code&gt;int4 → int8&lt;/code&gt; row is this case, with a registered conversion function &lt;code&gt;int8(int4)&lt;/code&gt; that gets called. The cost is one function call.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Output as text and read it back&lt;/strong&gt;. Every type in PG has an output function that serializes its value to a string and an input function that parses a string back into a value of that type. To bridge two types that have no direct cast function, PG runs the source type's output function to produce a string, then feeds that string through the target type's input function. The &lt;code&gt;json → jsonb&lt;/code&gt; row works this way: a &lt;code&gt;json&lt;/code&gt; value is first emitted as a textual form like &lt;code&gt;{"a":1}&lt;/code&gt;, then that text is parsed by the &lt;code&gt;jsonb&lt;/code&gt; input function into the binary JSON representation. This route is inefficient and not used when a direct cast function can be defined, but it serves as a fallback for user-defined types or for bridging two types whose representations differ significantly.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If no method is registered, PG raises &lt;code&gt;cannot cast type X to type Y&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The same cast does not apply automatically in every position. PG splits cast contexts into three groups.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;implicit&lt;/strong&gt;. PG inserts the cast on its own, without a request from the user. Applied in positions like comparisons and arithmetic. The &lt;code&gt;int4 → int8&lt;/code&gt; row is implicit, so &lt;code&gt;WHERE bigint_col = int_col&lt;/code&gt; automatically promotes &lt;code&gt;int4&lt;/code&gt; to &lt;code&gt;int8&lt;/code&gt; for the comparison even though the two sides differ in type.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;assignment&lt;/strong&gt;. Applies when a value is being put into a column. Operates in INSERT, the SET of UPDATE, COPY, and similar positions. Some conversions that are not registered as implicit are registered as assignment. The &lt;code&gt;int8 → int4&lt;/code&gt; row is one such example. Narrowing a wide integer to a narrower one risks precision loss, so applying it automatically in a comparison would be dangerous, but in a position where a value is being placed into a column of a particular type, accepting that loss is closer to the user's intent.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;explicit&lt;/strong&gt;. The user directly requests the cast with &lt;code&gt;CAST(col AS integer)&lt;/code&gt; or &lt;code&gt;col::integer&lt;/code&gt;. PG treats this as an unambiguous statement of intent, so even conversions not registered as implicit or assignment are allowed here.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The three contexts form a relationship in which the set of cast pairs PG applies automatically grows wider. A cast registered as implicit is also automatic in assignment positions, and of course in explicit positions too. Conversely, a cast registered only at the explicit level does not happen automatically in implicit or assignment positions; the user has to spell it out. In set notation, implicit ⊂ assignment ⊂ explicit in terms of which pairs PG converts on your behalf.&lt;/p&gt;

&lt;p&gt;This split keeps PG away from a common pitfall in other RDBMSs where implicit conversions happen too eagerly and produce surprising results. PG deliberately keeps implicit casts narrow. Before 8.3 there was an implicit cast between text and integer, but it caused too many unintended comparisons and was removed in later versions.&lt;/p&gt;

&lt;h2&gt;
  
  
  Catalog lookups and locks
&lt;/h2&gt;

&lt;p&gt;Every decision above involves catalog lookups. An OID lookup per FROM table, an attribute lookup per column, a &lt;code&gt;pg_type&lt;/code&gt; lookup per type in the expression, a &lt;code&gt;pg_proc&lt;/code&gt; lookup per function call. Analyzing one SQL statement can trigger dozens of catalog lookups.&lt;/p&gt;

&lt;p&gt;To keep the cost from blowing up, PG caches lookup results in memory (the syscache, a system catalog cache). A second lookup with the same key in the same connection hits memory. When another connection mutates the catalog with ALTER TABLE or similar, an invalidation message is delivered to the syscache and just the affected entry is dropped.&lt;/p&gt;

&lt;p&gt;The bigger user-visible effect is not the lookup itself but its side effect. When Analyze finds a FROM table in the catalog, it also takes a lock on that table. A plain FROM in a SELECT acquires &lt;code&gt;AccessShareLock&lt;/code&gt; (the read lock). Tables locked by &lt;code&gt;FOR UPDATE/SHARE&lt;/code&gt; get &lt;code&gt;RowShareLock&lt;/code&gt;. The target of an INSERT/UPDATE/DELETE gets &lt;code&gt;RowExclusiveLock&lt;/code&gt;. By the time Analyze finishes, every table touched by the statement is locked. The exact semantics of each lock mode and the conflict matrix between them are covered in 3.5.1 (Heavyweight locks).&lt;/p&gt;

&lt;p&gt;What is interesting is that locks are taken at the Analyze stage. Other designs are possible. Some other RDBMSs delay lock acquisition until plan or execute time. PG chose to take the lock during the catalog visit instead. The effect is clear: once parse finishes, the schema is guaranteed not to change for the rest of plan and execute, on a per-statement basis. The race in which the catalog mutates under ALTER TABLE while planning is in progress, invalidating the plan, is shut out before it can happen.&lt;/p&gt;

&lt;h2&gt;
  
  
  After Analyze finishes
&lt;/h2&gt;

&lt;p&gt;Once Analyze is done, PG does two more things.&lt;/p&gt;

&lt;p&gt;The first is computing a query identifier. When monitoring tools track slow-running queries, calls like &lt;code&gt;WHERE id = 1&lt;/code&gt; and &lt;code&gt;WHERE id = 2&lt;/code&gt; should be grouped together as instances of the same pattern (&lt;code&gt;WHERE id = ?&lt;/code&gt;). Otherwise, a statistic like "this query pattern averages X ms" loses its meaning.&lt;/p&gt;

&lt;p&gt;The catch is in what key the statistics table groups by. The key here is the data-structure key, the same kind of key as in a hash table where matching keys land in the same slot and accumulate. Using the SQL text directly as a key is no good. &lt;code&gt;WHERE id = 1&lt;/code&gt; and &lt;code&gt;WHERE id = 2&lt;/code&gt; are different texts and would land in separate slots, scattering what should be one row of statistics across many. What we want is one row per pattern, not one row per call.&lt;/p&gt;

&lt;p&gt;So at the end of Analyze, PG normalizes the Query tree by stripping out the variable parts (constants and the like, with the same effect as replacing them with &lt;code&gt;?&lt;/code&gt;) and then hashes the remainder into a 64-bit identifier. Same pattern produces the same identifier; same identifier accumulates into the same statistics slot. This process is called query jumble, and the resulting identifier is the key used by monitoring tools to group queries of the same kind.&lt;/p&gt;

&lt;p&gt;The second thing is a hook where extensions plug in. PG places a post-analyze hook at the very end of Analyze, so any extension registered there has its code invoked right after each query is analyzed. Statistics-gathering extensions like &lt;code&gt;pg_stat_statements&lt;/code&gt; register on this hook to collect each query's identifier and execution time.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this means in practice
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;First, the same SQL text can refer to different tables depending on the connection's &lt;code&gt;search_path&lt;/code&gt;.&lt;/strong&gt; This is a direct consequence of table name resolution being locked in at Analyze time and following &lt;code&gt;search_path&lt;/code&gt;. In container or deployment environments where the default &lt;code&gt;search_path&lt;/code&gt; happens to differ, tests pass while production reports "table not found". This is one of the first things to check when debugging such an error.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Second, throwing a SELECT inside a transaction immediately locks every table the statement touches, and those locks are held until the transaction ends.&lt;/strong&gt; Lock acquisition being baked into the Analyze stage shows up directly in how applications behave. Patterns like &lt;code&gt;BEGIN; SELECT ...; (some other work)&lt;/code&gt;, where a transaction is opened early, hold &lt;code&gt;AccessShareLock&lt;/code&gt; on the SELECT's tables for the entire transaction's duration, which can block ALTER TABLE in other sessions. This is one of the reasons long-running transactions are operationally risky (other reasons are covered in chapter 3 on MVCC). Conversely, from the application's point of view, the moment a SELECT is issued, the schema becomes protected against change for the remainder of that statement.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Third, the unit of "query" that monitoring tools group on is not the SQL text but the normalized pattern of the analyzed result.&lt;/strong&gt; When &lt;code&gt;pg_stat_statements&lt;/code&gt; collects slow queries, the key it uses to lump same-shaped queries onto one row is the query identifier computed at the end of analysis. Statements that differ only in constant values but share the same structure accumulate into the same row. So calling &lt;code&gt;WHERE id = 1&lt;/code&gt; a thousand times and calling &lt;code&gt;WHERE id = 1&lt;/code&gt;, &lt;code&gt;WHERE id = 2&lt;/code&gt;, ..., &lt;code&gt;WHERE id = 1000&lt;/code&gt; once each look the same to the monitoring tool: a single row.&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>database</category>
      <category>internals</category>
      <category>sql</category>
    </item>
    <item>
      <title>1.2.1 From SQL Text to Raw Parse Tree</title>
      <dc:creator>JoongHyuk Shin</dc:creator>
      <pubDate>Wed, 06 May 2026 02:06:12 +0000</pubDate>
      <link>https://dev.to/joonghyukshin/121-from-sql-text-to-raw-parse-tree-342c</link>
      <guid>https://dev.to/joonghyukshin/121-from-sql-text-to-raw-parse-tree-342c</guid>
      <description>&lt;p&gt;A line like &lt;code&gt;SELECT name FROM users WHERE id = 1&lt;/code&gt; arrives at the backend. As we saw in 1.1.1, the backend is a child process forked by the postmaster when the client connects, dedicated to that one client. What this process first holds in its hands is just a byte array. It does not yet know where the keywords end, where the identifiers begin, or where the integer constant lives. Turning that byte array into a tree structure is the front half of the second of the five stages, namely raw parsing. This section covers only that front half. The back half (parse analysis), which consults the catalog to attach meaning, belongs to 1.2.2. (The catalog, in case you need a refresher, is the set of internal tables PostgreSQL maintains to describe itself: which tables have which columns, which functions take which argument types, and so on, all stored as rows in these tables.)&lt;/p&gt;

&lt;p&gt;The output of raw parsing is a single &lt;code&gt;RawStmt&lt;/code&gt; node per SQL string (or a List, if multiple statements are joined with &lt;code&gt;;&lt;/code&gt;). This RawStmt wraps a raw node like &lt;code&gt;SelectStmt&lt;/code&gt; for SELECT, &lt;code&gt;InsertStmt&lt;/code&gt; for INSERT, and so on. The name "raw" means it has not seen the catalog. Whether &lt;code&gt;users&lt;/code&gt; is actually an existing table, which column &lt;code&gt;id&lt;/code&gt; refers to, none of that is known yet. All that has been captured is the grammatical structure: a SELECT keyword followed by a column list, a FROM followed by an identifier, a WHERE followed by a comparison expression.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two tools dividing the work: flex and Bison
&lt;/h2&gt;

&lt;p&gt;PostgreSQL's raw parser is a collaboration of two tools. One side is the &lt;strong&gt;lexer&lt;/strong&gt; (lexical analyzer), the other is the &lt;strong&gt;grammar&lt;/strong&gt; (syntactic analyzer). The lexer slices the byte array into tokens. The grammar takes that token sequence and groups it into a tree.&lt;/p&gt;

&lt;p&gt;PostgreSQL does not write these two by hand. It uses standard generator tools brought in from outside. The lexer is &lt;strong&gt;flex&lt;/strong&gt;, the grammar is &lt;strong&gt;Bison&lt;/strong&gt;. Both are code generators. The developer writes rules, and at build time the tool reads those rules and emits actual working lexer/parser C code.&lt;/p&gt;

&lt;p&gt;For flex, a rule means "if this regex pattern comes in, emit this token." For example, "if a letter is followed by alphanumerics, emit an IDENT (identifier) token", "if only digits appear, emit an ICONST (integer constant) token." PostgreSQL keeps these rules in &lt;code&gt;scan.l&lt;/code&gt; (about 1,500 lines on the current PostgreSQL 18). At build time flex reads this file and generates the C function that does the tokenizing.&lt;/p&gt;

&lt;p&gt;For Bison, a rule means "if this token sequence appears, build this tree node." For example, "if SELECT is followed by a column list, then FROM and a table identifier, build a SelectStmt node." PostgreSQL keeps these grammar rules in &lt;code&gt;gram.y&lt;/code&gt; (about 20,000 lines on the current PostgreSQL 18). At build time Bison reads this file and generates the C parser function that groups tokens into a tree.&lt;/p&gt;

&lt;p&gt;The driver that ties these two together is the &lt;code&gt;raw_parser()&lt;/code&gt; function.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight c"&gt;&lt;code&gt;&lt;span class="n"&gt;List&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;
&lt;span class="nf"&gt;raw_parser&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="kt"&gt;char&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;RawParseMode&lt;/span&gt; &lt;span class="n"&gt;mode&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;core_yyscan_t&lt;/span&gt; &lt;span class="n"&gt;yyscanner&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="n"&gt;base_yy_extra_type&lt;/span&gt; &lt;span class="n"&gt;yyextra&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kt"&gt;int&lt;/span&gt;           &lt;span class="n"&gt;yyresult&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="n"&gt;scanner_init&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...);&lt;/span&gt;     &lt;span class="cm"&gt;/* flex init */&lt;/span&gt;
    &lt;span class="n"&gt;parser_init&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;yyextra&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;      &lt;span class="cm"&gt;/* Bison state init */&lt;/span&gt;
    &lt;span class="n"&gt;yyresult&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;base_yyparse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;yyscanner&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="cm"&gt;/* one cycle */&lt;/span&gt;
    &lt;span class="n"&gt;scanner_finish&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;yyscanner&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;yyresult&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;NIL&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;             &lt;span class="cm"&gt;/* syntax error */&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;yyextra&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;parsetree&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="cm"&gt;/* List of RawStmt */&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;base_yyparse()&lt;/code&gt; is the actual parser function Bison generated. Inside it, whenever a token is needed, it calls the lexer and groups tokens into a tree according to grammar rules. The lexer does not run on its own. It is pulled along by the grammar.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the lexer (scan.l) sees
&lt;/h2&gt;

&lt;p&gt;What the lexer does is simple. It scans the byte array from the start, finds the longest pattern that matches one of the regex rules, and hands the corresponding token type and value to the grammar. What does "longest matching pattern" mean? When several rules can match starting at the same position, the lexer picks the one with the longer match. For example, if the input contains &lt;code&gt;&amp;gt;=&lt;/code&gt;, the lexer does not slice off &lt;code&gt;&amp;gt;&lt;/code&gt; alone. It groups &lt;code&gt;&amp;gt;=&lt;/code&gt; into a single token (such as &lt;code&gt;GREATER_EQUALS&lt;/code&gt;). &lt;code&gt;&amp;gt;&lt;/code&gt; alone would also match a comparison-operator rule, but a longer-matching rule for &lt;code&gt;&amp;gt;=&lt;/code&gt; exists, so that one wins. In lex terminology this is called longest match. Almost every lexer behaves this way. So when &lt;code&gt;SELECT name FROM users WHERE id = 1&lt;/code&gt; comes in, the lexer emits tokens in this order.&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="err"&gt;→&lt;/span&gt; &lt;span class="n"&gt;keyword&lt;/span&gt; &lt;span class="k"&gt;SELECT&lt;/span&gt;
&lt;span class="n"&gt;name&lt;/span&gt;       &lt;span class="err"&gt;→&lt;/span&gt; &lt;span class="n"&gt;identifier&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;IDENT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt;       &lt;span class="err"&gt;→&lt;/span&gt; &lt;span class="n"&gt;keyword&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt;
&lt;span class="n"&gt;users&lt;/span&gt;      &lt;span class="err"&gt;→&lt;/span&gt; &lt;span class="n"&gt;identifier&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;IDENT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;"users"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt;      &lt;span class="err"&gt;→&lt;/span&gt; &lt;span class="n"&gt;keyword&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt;
&lt;span class="n"&gt;id&lt;/span&gt;         &lt;span class="err"&gt;→&lt;/span&gt; &lt;span class="n"&gt;identifier&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;IDENT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="o"&gt;=&lt;/span&gt;          &lt;span class="err"&gt;→&lt;/span&gt; &lt;span class="n"&gt;token&lt;/span&gt; &lt;span class="s1"&gt;'='&lt;/span&gt;
&lt;span class="mi"&gt;1&lt;/span&gt;          &lt;span class="err"&gt;→&lt;/span&gt; &lt;span class="nb"&gt;integer&lt;/span&gt; &lt;span class="n"&gt;constant&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ICONST&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;value&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;How does the lexer tell identifiers from keywords? Every identifier candidate (a letter followed by alphanumerics) first matches the same regex rule. After that, the matched string is checked against the keyword table by binary search. If it is in the table, it becomes a keyword token. If not, IDENT.&lt;/p&gt;

&lt;p&gt;The size and categorization of that keyword table matter. On the current PostgreSQL 18 there are 494 keywords, split into four categories.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Category&lt;/th&gt;
&lt;th&gt;Count&lt;/th&gt;
&lt;th&gt;Meaning&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;UNRESERVED&lt;/td&gt;
&lt;td&gt;330&lt;/td&gt;
&lt;td&gt;usable as identifier (e.g. &lt;code&gt;abort&lt;/code&gt;, &lt;code&gt;aggregate&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;COL_NAME&lt;/td&gt;
&lt;td&gt;63&lt;/td&gt;
&lt;td&gt;usable as a column name but not as a function/type name&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;TYPE_FUNC_NAME&lt;/td&gt;
&lt;td&gt;23&lt;/td&gt;
&lt;td&gt;usable as a type or function name&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;RESERVED&lt;/td&gt;
&lt;td&gt;78&lt;/td&gt;
&lt;td&gt;never usable as identifier (e.g. &lt;code&gt;select&lt;/code&gt;, &lt;code&gt;from&lt;/code&gt;, &lt;code&gt;where&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;This split is why a query like &lt;code&gt;SELECT abort FROM ...&lt;/code&gt; works in PostgreSQL: &lt;code&gt;abort&lt;/code&gt; is UNRESERVED, so it can also be used as an identifier. By contrast, &lt;code&gt;SELECT select FROM ...&lt;/code&gt; is a syntax error. RESERVED never gives way. Compatibility differences with other RDBMSes often come from this split.&lt;/p&gt;

&lt;p&gt;There is an interesting comment at the top of scan.l: "rules in this file must be kept in sync with &lt;code&gt;src/fe_utils/psqlscan.l&lt;/code&gt; and &lt;code&gt;src/interfaces/ecpg/preproc/pgc.l&lt;/code&gt;!" In other words, the same SQL lexer rules live in three places. The psql client has its own lexer, ecpg (the embedded SQL preprocessor) has its own. The reason is that each tool has slightly different lexical needs, but the practical consequence is that changing the lexer rules in PostgreSQL means synchronizing three files.&lt;/p&gt;

&lt;h2&gt;
  
  
  The tree the grammar (gram.y) builds
&lt;/h2&gt;

&lt;p&gt;As the lexer emits tokens, the grammar takes them in order, matches them against grammar rules, and assembles a tree. It builds bottom-up. Small subtrees are grouped first, those subtrees are then collected into larger nodes, and finally a single node corresponding to the whole statement is completed. This act of "taking a sequence of tokens or subtrees and reducing them into a single parent node" is called &lt;strong&gt;reduce&lt;/strong&gt; in parser terminology. For example, if the token sequence &lt;code&gt;IDENT '=' ICONST&lt;/code&gt; matches the "binary comparison expression" rule, those three are reduced into a single subtree. That subtree is then reduced as part of a WHERE clause rule. That WHERE clause is reduced as part of a SelectStmt rule. In the end, one SelectStmt node is built and gets wrapped in a RawStmt.&lt;/p&gt;

&lt;p&gt;Here is a picture of the raw parse tree that &lt;code&gt;SELECT name FROM users WHERE id = 1&lt;/code&gt; produces. The tree has the root at the top, leaves toward the bottom.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;                        RawStmt
                           │
                       SelectStmt
              ┌────────────┼─────────────┐
              │            │             │
          targetList   fromClause    whereClause
              │            │             │
            IDENT       RangeVar      A_Expr (=)
            "name"      "users"      ┌──────┴──────┐
                                     │             │
                                   IDENT         ICONST
                                   "id"             1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The RawStmt at the top is the output of the raw parser. Keywords like SELECT, FROM, WHERE are only markers that tell the grammar rule which subtree to reduce into, so they do not survive as separate tree nodes. Only meaningful values (identifiers, constants) from the input tokens remain as leaves.&lt;/p&gt;

&lt;p&gt;PostgreSQL's top rule is &lt;code&gt;stmtmulti&lt;/code&gt;. It expresses that multiple SQL statements may be joined with &lt;code&gt;;&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;stmtmulti:  stmtmulti ';' toplevel_stmt
                { ... lappend($1, makeRawStmt($3, @3)); ... }
          | toplevel_stmt
                { ... list_make1(makeRawStmt($1, @1)); ... }
;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each &lt;code&gt;toplevel_stmt&lt;/code&gt; is one SQL statement, and they all get wrapped by &lt;code&gt;makeRawStmt()&lt;/code&gt; into a RawStmt. That is why the raw parser's output is always a List of &lt;code&gt;RawStmt&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Going down into &lt;code&gt;toplevel_stmt&lt;/code&gt; and then &lt;code&gt;stmt&lt;/code&gt;, you find that &lt;code&gt;stmt&lt;/code&gt; is a giant OR rule.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;stmt:
        AlterEventTrigStmt
      | AlterCollationStmt
      | AlterDatabaseStmt
      | ...
      | SelectStmt
      | InsertStmt
      | ...
;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;More than 120 statement kinds are listed here on the current PostgreSQL 18, one per line. Each then expands into its own sub-rules. &lt;code&gt;SelectStmt&lt;/code&gt; alone has dozens of sub-rules, and every SELECT element such as FROM clause, WHERE clause, GROUP BY, set operation unfolds inside it like a tree. The roughly 20,000 lines of gram.y are the result of this unfolding.&lt;/p&gt;

&lt;p&gt;A new tree node is built inside the reduce action. At the moment &lt;code&gt;SELECT * FROM users&lt;/code&gt; reduces into a SelectStmt node, &lt;code&gt;makeNode(SelectStmt)&lt;/code&gt; allocates the node and fills in target list, FROM clause, WHERE, and so on. At this moment we do not know which OID of which table &lt;code&gt;users&lt;/code&gt; refers to. The identifier string &lt;code&gt;"users"&lt;/code&gt; simply lives inside a &lt;code&gt;RangeVar&lt;/code&gt; node. Catalog lookup is the next stage's job.&lt;/p&gt;

&lt;p&gt;gram.y also happens to be the file that most directly shows PostgreSQL's history of SQL compatibility changes. When a new PostgreSQL version adds a new SQL feature (such as MERGE or JSON_TABLE), the grammar rules grow accordingly, so dozens to hundreds of lines get added to gram.y each time. Following git blame is enough to see which SQL feature entered which PostgreSQL version. Its roughly 20,000-line size is the trace of three decades of SQL standard accumulation in a single file.&lt;/p&gt;

&lt;h2&gt;
  
  
  The lookahead filter: base_yylex
&lt;/h2&gt;

&lt;p&gt;There is one more detail. The parser Bison generates is &lt;strong&gt;LALR(1)&lt;/strong&gt;. Spelling out the acronym, that is Look-Ahead Left-to-right Rightmost-derivation, with 1-token lookahead. The name sounds intimidating, but the gist is simple. The parser scans input from left to right exactly once, and at each step it peeks at exactly one upcoming token ("1-token lookahead") to decide which grammar rule to apply. Being able to decide with one token of lookahead keeps the parser fast and memory-efficient. Almost every mainstream compiler/DB parser works this way.&lt;/p&gt;

&lt;p&gt;The catch is that SQL grammar has a few cases that do not fit cleanly into LALR(1). Multi-word tokens like &lt;code&gt;NULLS FIRST&lt;/code&gt; and &lt;code&gt;WITH ORDINALITY&lt;/code&gt; need to be received by the grammar as single tokens. If NULLS and FIRST were given to the grammar as separate tokens, the grammar could not decide what comes after NULLS based on a single lookahead.&lt;/p&gt;

&lt;p&gt;PostgreSQL solves this by inserting one filter layer between the lexer and the grammar. That filter is &lt;code&gt;base_yylex()&lt;/code&gt;. The actual lexer flex generates is &lt;code&gt;core_yylex()&lt;/code&gt;, but Bison never calls it directly. It always receives tokens through &lt;code&gt;base_yylex()&lt;/code&gt;. base_yylex looks at one token, and if this is a case that needs the next token pulled in and merged, it gives the grammar a single combined token. The result is that the grammar can be written cleanly as LALR(1), and the complexity of multi-word tokens is isolated inside base_yylex.&lt;/p&gt;

&lt;h2&gt;
  
  
  Never touches the catalog
&lt;/h2&gt;

&lt;p&gt;The most important constraint of the raw parser stage is that &lt;strong&gt;it never accesses the system catalog&lt;/strong&gt;. This is not just a convention but a correctness requirement.&lt;/p&gt;

&lt;p&gt;The header comment of &lt;code&gt;pg_parse_query()&lt;/code&gt; explains why.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Analysis and rewriting cannot be done in an aborted transaction, since they require access to database tables. So, we rely on the raw parser to determine whether we've seen a COMMIT or ABORT command; when we are in abort state, other commands are not processed any further than the raw parse stage.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;To follow this comment, you first need to know what "the transaction is in abort state" means. Here is the most intuitive scenario.&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;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&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="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="k"&gt;VALUES&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'a'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&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="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="k"&gt;VALUES&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'b'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;-- unique violation!&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;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 moment the third line violates the unique constraint and errors out, PostgreSQL marks this transaction as "already broken." That is the abort state. The next line, &lt;code&gt;SELECT * FROM users;&lt;/code&gt;, is grammatically fine and the table exists in the catalog, but PostgreSQL refuses to execute it. Instead, every subsequent SQL gets the same one-line error.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ERROR:  current transaction is aborted, commands ignored until end of transaction block
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the situation in &lt;code&gt;psql&lt;/code&gt; where, after one query inside a transaction fails, every following query is rejected with the same message. To unlock it, the client must explicitly send &lt;code&gt;ROLLBACK&lt;/code&gt; (or &lt;code&gt;COMMIT&lt;/code&gt;, which in abort state has the same effect as rollback). In other words, the only SQL that PostgreSQL effectively accepts in abort state is ROLLBACK.&lt;/p&gt;

&lt;p&gt;Now the relationship between the raw parser and the catalog matters. ROLLBACK still arrives as SQL text, so it has to be parsed somehow. But in abort state, even catalog reads are blocked. What if the raw parser depended on the catalog? Parsing ROLLBACK itself would then error out, and the client would have no way to unwind the transaction. Killing and reopening the connection would be the only escape.&lt;/p&gt;

&lt;p&gt;The design choice that avoids this from the start is the raw parser's catalog ban. The raw parser must work in any transaction state, so it never touches the catalog. All identifier-level meaning analysis is deferred to the next stage (parse analysis). 1.2.2 covers that story.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this means in practice
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;First, "parsing is fast" really means raw parsing is fast.&lt;/strong&gt; PostgreSQL's raw parser is pure string work without catalog lookup, so it is very fast. But the bulk of what a prepared statement (1.1.2) caches is not raw parsing. It is &lt;strong&gt;the next stage (parse analysis)&lt;/strong&gt;. Parse analysis is where catalog lookups, function-overload resolution, and type checking happen. When people talk about the per-query parse cost of the simple query protocol being a burden, they almost always mean parse analysis cost, not raw parse cost. To diagnose accurately, separate which stage's cost you are looking at.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Second, keyword categories are a hidden trap in PostgreSQL compatibility and migration.&lt;/strong&gt; A word that other RDBMSes happily allow as a column or function name may be RESERVED in PostgreSQL. If your migration tool does not automatically wrap such names in quotes (&lt;code&gt;"name"&lt;/code&gt;), you get a syntax error. Conversely, identifiers PostgreSQL allows freely (such as &lt;code&gt;abort&lt;/code&gt;) may be blocked in another DB. When reviewing a migration, comparing the keyword tables on both sides is the safe move.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Third, the raw parser only catches syntax errors.&lt;/strong&gt; Errors like "table does not exist", "function signature does not match", "column is ambiguous" are all thrown by the next stage, parse analysis. So when a query has both a syntax error and a semantic error, the syntax error is reported first. The semantic error only surfaces once syntax is clean. If your error message suddenly changes during debugging, that may be a signal that the syntax issue cleared and you are now seeing the next stage's error.&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>database</category>
      <category>internals</category>
      <category>parser</category>
    </item>
    <item>
      <title>1.2 Parser and Analyzer: How SQL Gets Its Meaning</title>
      <dc:creator>JoongHyuk Shin</dc:creator>
      <pubDate>Wed, 06 May 2026 02:05:24 +0000</pubDate>
      <link>https://dev.to/joonghyukshin/12-parser-and-analyzer-how-sql-gets-its-meaning-44e3</link>
      <guid>https://dev.to/joonghyukshin/12-parser-and-analyzer-how-sql-gets-its-meaning-44e3</guid>
      <description>&lt;p&gt;A line like &lt;code&gt;SELECT name FROM users WHERE id = 1&lt;/code&gt; is just text when the client sends it. The first thing the backend does after receiving it is figure out "what do these characters mean." This chapter covers the second of the five stages we saw in 1.1.1: the parser and analyzer.&lt;/p&gt;

&lt;p&gt;By the time this stage finishes, the SQL text has been transformed twice. First into a &lt;strong&gt;raw parse tree&lt;/strong&gt; that captures the grammatical structure, then into a &lt;strong&gt;Query tree&lt;/strong&gt; with meaning attached after consulting the catalog. The catalog, in case you need a refresher, is the set of internal tables that PostgreSQL keeps to describe itself. Which tables exist, what columns they have, what argument types each function accepts, what data types are defined: all of it lives as rows in these tables. PostgreSQL treats user data and metadata uniformly, both as ordinary tables. The raw stage looks only at form (does this follow SELECT syntax, where are the IDENTs). The Query stage looks at substance (this identifier &lt;code&gt;users&lt;/code&gt; is which table in which schema and what is its OID, &lt;code&gt;id&lt;/code&gt; is which column and what is its type, can &lt;code&gt;1&lt;/code&gt; be coerced to that column's type).&lt;/p&gt;

&lt;p&gt;1.2 splits into three sections.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;1.2.1 From SQL text to raw parse tree (lexer, grammar)&lt;/strong&gt;: how the flex-based lexer and Bison-based grammar turn an SQL string into a tree. This stage is pure syntactic work, no catalog access.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;1.2.2 Semantic analysis: name resolution, type checking, catalog lookup&lt;/strong&gt;: take the raw parse tree, dig into the catalog to find what each identifier really refers to, check types, resolve function overloads. This is the body of how PostgreSQL gives meaning to SQL text.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;1.2.3 Query tree node types (Query, RangeTblEntry, TargetEntry)&lt;/strong&gt;: the core nodes of the Query tree, which is the output of semantic analysis. This node structure is the standard input format that rewriter, planner, and executor all consume.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;By the end of this chapter, you should have a clear picture of how SQL text meets the catalog and acquires meaning, and what data structures carry that meaning into the next stage.&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>database</category>
      <category>internals</category>
      <category>parser</category>
    </item>
    <item>
      <title>1.1.3 Optimizable vs Utility</title>
      <dc:creator>JoongHyuk Shin</dc:creator>
      <pubDate>Tue, 05 May 2026 07:36:20 +0000</pubDate>
      <link>https://dev.to/joonghyukshin/113-optimizable-vs-utility-51bl</link>
      <guid>https://dev.to/joonghyukshin/113-optimizable-vs-utility-51bl</guid>
      <description>&lt;p&gt;Inside the five-stage pipeline from 1.1.1, there is another fork right after the parser. PostgreSQL classifies every SQL command into one of two camps. One side holds the &lt;strong&gt;optimizable&lt;/strong&gt; queries, the other holds the &lt;strong&gt;utility&lt;/strong&gt; commands. The classification is decided by a single field on the Query node, &lt;code&gt;commandType&lt;/code&gt;, and from that point on the two camps travel &lt;strong&gt;completely different paths&lt;/strong&gt;. One goes through the rewriter, the planner, and the executor. The other bypasses all three.&lt;/p&gt;

&lt;p&gt;This fork was a single line in the 1.1.1 picture, but it shapes the entire internal structure of PostgreSQL, so it earns its own section.&lt;/p&gt;

&lt;h2&gt;
  
  
  Five optimizables, and everything else
&lt;/h2&gt;

&lt;p&gt;PostgreSQL defines its command types as a single enum.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight c"&gt;&lt;code&gt;&lt;span class="k"&gt;typedef&lt;/span&gt; &lt;span class="k"&gt;enum&lt;/span&gt; &lt;span class="n"&gt;CmdType&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;CMD_UNKNOWN&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;CMD_SELECT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;CMD_UPDATE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;CMD_INSERT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;CMD_DELETE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;CMD_MERGE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;CMD_UTILITY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;CMD_NOTHING&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="n"&gt;CmdType&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Of these, &lt;code&gt;CMD_SELECT&lt;/code&gt;, &lt;code&gt;CMD_INSERT&lt;/code&gt;, &lt;code&gt;CMD_UPDATE&lt;/code&gt;, &lt;code&gt;CMD_DELETE&lt;/code&gt;, and &lt;code&gt;CMD_MERGE&lt;/code&gt; are the &lt;strong&gt;optimizable&lt;/strong&gt; ones. As the name suggests, these are queries the planner can do meaningful work on. It rearranges join order using a cost model, picks indexes, and chooses scan methods.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;CMD_UTILITY&lt;/code&gt; is the catch-all for everything else: &lt;code&gt;CREATE TABLE&lt;/code&gt;, &lt;code&gt;ALTER TABLE&lt;/code&gt;, &lt;code&gt;DROP&lt;/code&gt;, &lt;code&gt;VACUUM&lt;/code&gt;, &lt;code&gt;BEGIN&lt;/code&gt;/&lt;code&gt;COMMIT&lt;/code&gt;/&lt;code&gt;ROLLBACK&lt;/code&gt;, &lt;code&gt;COPY&lt;/code&gt;, &lt;code&gt;NOTIFY&lt;/code&gt;, &lt;code&gt;LISTEN&lt;/code&gt;, &lt;code&gt;CLUSTER&lt;/code&gt;, &lt;code&gt;REINDEX&lt;/code&gt;, &lt;code&gt;GRANT&lt;/code&gt;, &lt;code&gt;SET&lt;/code&gt;, &lt;code&gt;SHOW&lt;/code&gt;, &lt;code&gt;TRUNCATE&lt;/code&gt;, &lt;code&gt;LOCK&lt;/code&gt;, &lt;code&gt;FETCH&lt;/code&gt;, &lt;code&gt;CHECKPOINT&lt;/code&gt;, &lt;code&gt;PREPARE TRANSACTION&lt;/code&gt;, &lt;code&gt;CREATE INDEX&lt;/code&gt;, &lt;code&gt;CREATE FUNCTION&lt;/code&gt;, and many more. What they share is a single property: &lt;strong&gt;the planner has no room to produce a better plan via cost comparison&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;A command like &lt;code&gt;CREATE TABLE foo (id int)&lt;/code&gt; cannot have two different paths. It just inserts a few rows into the system catalog and asks the storage manager to allocate a new relfilenode. &lt;code&gt;BEGIN&lt;/code&gt; similarly nudges the transaction state by one step; there is no choice in "how to BEGIN." &lt;code&gt;VACUUM&lt;/code&gt; walks a target table page by page and cleans up dead tuples through a fixed procedure. The point is that cost comparison is meaningless here.&lt;/p&gt;

&lt;p&gt;The two camps are wired through different code paths. The first split happens in the analyzer, right after the parser hands over a raw parse tree.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fork lives in transformStmt's switch
&lt;/h2&gt;

&lt;p&gt;When the raw parse tree arrives, &lt;code&gt;transformStmt()&lt;/code&gt; runs a large switch on the node tag (&lt;code&gt;src/backend/parser/analyze.c&lt;/code&gt;).&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight c"&gt;&lt;code&gt;&lt;span class="k"&gt;switch&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;nodeTag&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;parseTree&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="cm"&gt;/* Optimizable statements */&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="n"&gt;T_InsertStmt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;transformInsertStmt&lt;/span&gt;&lt;span class="p"&gt;(...);&lt;/span&gt;
        &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="n"&gt;T_SelectStmt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;transformSelectStmt&lt;/span&gt;&lt;span class="p"&gt;(...);&lt;/span&gt;
        &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="cm"&gt;/* ... UPDATE, DELETE, MERGE ... */&lt;/span&gt;

    &lt;span class="cm"&gt;/* Special cases (utility wrappers around an optimizable inside) */&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="n"&gt;T_DeclareCursorStmt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="n"&gt;T_ExplainStmt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="n"&gt;T_CreateTableAsStmt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="n"&gt;T_CallStmt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="cm"&gt;/* transform the inner query separately */&lt;/span&gt;
        &lt;span class="p"&gt;...&lt;/span&gt;

    &lt;span class="nl"&gt;default:&lt;/span&gt;
        &lt;span class="cm"&gt;/* every other utility */&lt;/span&gt;
        &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;makeNode&lt;/span&gt;&lt;span class="p"&gt;(&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;result&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;commandType&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;CMD_UTILITY&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;utilityStmt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;parseTree&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="k"&gt;break&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;PostgreSQL does meaningful semantic analysis on the five optimizable statement types and a handful of special cases. Everything else falls through to the default branch, gets stamped &lt;code&gt;commandType = CMD_UTILITY&lt;/code&gt;, and the raw parse tree is stored verbatim in the &lt;code&gt;utilityStmt&lt;/code&gt; field. Nothing was actually analyzed; a Query shell was wrapped around the raw tree with a "this is utility" sticker.&lt;/p&gt;

&lt;p&gt;The next stage, the rewriter, also reads that sticker (&lt;code&gt;src/backend/tcop/postgres.c&lt;/code&gt;).&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight c"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;commandType&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;CMD_UTILITY&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;querytree_list&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;list_make1&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="cm"&gt;/* don't rewrite utilities */&lt;/span&gt;
&lt;span class="k"&gt;else&lt;/span&gt;
    &lt;span class="n"&gt;querytree_list&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;QueryRewrite&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the query is utility, the rewriter does not touch it. The rule system, view expansion, and RLS policy application all live on the optimizable side and have no meaning for utility commands.&lt;/p&gt;

&lt;p&gt;The planner is the same.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight c"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;commandType&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;CMD_UTILITY&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="cm"&gt;/* Utility commands require no planning. */&lt;/span&gt;
    &lt;span class="n"&gt;stmt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;makeNode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;PlannedStmt&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;stmt&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;commandType&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;CMD_UTILITY&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="n"&gt;stmt&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;utilityStmt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;utilityStmt&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;else&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;stmt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pg_plan_query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...);&lt;/span&gt;   &lt;span class="cm"&gt;/* invoke the planner */&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Utility commands never call into the planner. An empty &lt;code&gt;PlannedStmt&lt;/code&gt; wrapper is built, and the raw parse tree is dropped into it. A PlannedStmt with no plan tree.&lt;/p&gt;

&lt;p&gt;The executor stage is split too. Optimizable statements feed their plan tree into the executor proper, which produces rows. Utility statements get handed off to &lt;code&gt;ProcessUtility()&lt;/code&gt;, which dispatches to a per-statement handler. The dispatch logic and the individual handlers belong to later chapters (DDL in 1.6, transaction commands in chapter 4).&lt;/p&gt;

&lt;p&gt;When you lay out the four stages side by side, the asymmetry is sharp.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Stage&lt;/th&gt;
&lt;th&gt;Optimizable (5 types)&lt;/th&gt;
&lt;th&gt;Utility (everything else)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Parse analysis&lt;/td&gt;
&lt;td&gt;Dedicated transform function&lt;/td&gt;
&lt;td&gt;Wrapped in a Query shell&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Rewriter&lt;/td&gt;
&lt;td&gt;Rule system applied&lt;/td&gt;
&lt;td&gt;Skipped (passes through)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Planner&lt;/td&gt;
&lt;td&gt;Plan tree generated&lt;/td&gt;
&lt;td&gt;Skipped (empty PlannedStmt holding the raw tree)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Executor&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;ExecutorRun()&lt;/code&gt; (walks the plan tree)&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;ProcessUtility()&lt;/code&gt; (per-statement handler)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The whole sophistication of the planner exists for those five types; utility bypasses it entirely. This is not an efficiency choice. It is a structural asymmetry, because utility commands have no alternative paths to compare.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two species sharing one system
&lt;/h2&gt;

&lt;p&gt;Once you see how this asymmetry is wired, the two camps look almost like two species inside the same engine.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Hooks live on different paths.&lt;/strong&gt; A hook in PostgreSQL is a function pointer exposed at a key point in the execution path so that external code (typically an extension) can plug in. An extension installs its own function address into the hook, and PostgreSQL calls it whenever the hook is non-null at the appropriate moment. Which camp the hook applies to depends on where it sits. &lt;code&gt;planner_hook&lt;/code&gt; is invoked just before the planner runs, so it only affects optimizable queries. Utility never enters the planner, so &lt;code&gt;planner_hook&lt;/code&gt; never fires for utility. On the other side, &lt;code&gt;ProcessUtility_hook&lt;/code&gt; is invoked just before &lt;code&gt;ProcessUtility()&lt;/code&gt; runs, so it only applies to utility commands. That is how an audit logging extension like pgaudit intercepts DDL and DCL. You need both hooks together to cover every SQL execution path. If you write extensions, you have to know up front which camp your hook is intercepting.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Statistics live in a single channel, but the metrics mean different things.&lt;/strong&gt; PostgreSQL accumulates usage patterns about which SQL ran how often and for how long, and DBAs use that data to find slow queries and pick tuning targets. By default (&lt;code&gt;pg_stat_statements.track_utility = on&lt;/code&gt;), &lt;code&gt;pg_stat_statements&lt;/code&gt; tracks both optimizable and utility commands, gathering execution counts and accumulated time into one place. Plan-level metrics (plan time, plan cache hits, and so on) only carry meaning for optimizable commands where a plan actually exists. For utility rows those columns are empty or zero. The two camps appear in the same table, but which metric means what for which camp has to be kept in mind.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Prepared statements are an optimizable-only tool.&lt;/strong&gt; The prepared statements from 1.1.2 cache plans for optimizable queries. SQL &lt;code&gt;PREPARE&lt;/code&gt; grammar itself does not accept utility commands. PostgreSQL only allows SELECT, INSERT, UPDATE, DELETE, MERGE, and VALUES as &lt;code&gt;PREPARE&lt;/code&gt; targets; &lt;code&gt;PREPARE p AS ALTER TABLE ...&lt;/code&gt; is rejected as a syntax error. Utility commands run through &lt;code&gt;ProcessUtility&lt;/code&gt; directly on every call, with no plan cache layer to step in.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;EXPLAIN's reach is asymmetric.&lt;/strong&gt; &lt;code&gt;EXPLAIN SELECT ...&lt;/code&gt; draws a plan tree. &lt;code&gt;EXPLAIN ALTER TABLE ...&lt;/code&gt; does not work; there is no plan tree to draw. The exception is the special-case group from the switch above (&lt;code&gt;T_DeclareCursorStmt&lt;/code&gt;, &lt;code&gt;T_ExplainStmt&lt;/code&gt;, &lt;code&gt;T_CreateTableAsStmt&lt;/code&gt;, &lt;code&gt;T_CallStmt&lt;/code&gt;). They are classified as utility on the outside but contain an optimizable query that goes through the regular pipeline. That hybrid path is why &lt;code&gt;EXPLAIN ANALYZE INSERT INTO ... SELECT ...&lt;/code&gt; works. The outer wrapper is utility; the SELECT and INSERT inside are optimizable.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;I once tried to debug "why is this long ALTER TABLE so slow" by analyzing the plan. The plan had no answer. The bulk of the cost lived outside the plan tree: lock waits, catalog updates, full-table rewrites, and WAL volume. That was when I learned why utility needs its own dedicated path. Some costs are invisible to the plan-level cost model, and to see them you need a different stage of tools.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  What this means in practice
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;First, planner-related tuning and monitoring tools have no meaning for utility.&lt;/strong&gt; &lt;code&gt;EXPLAIN&lt;/code&gt;, plan-level metrics in &lt;code&gt;pg_stat_statements&lt;/code&gt;, &lt;code&gt;auto_explain&lt;/code&gt;, &lt;code&gt;plan_cache_mode&lt;/code&gt;, all of these are optimizable-side tools. Looking at the plan to debug a slow DDL or DCL is pointless because there is no plan. Utility commands grab and release catalog locks while running directly on every call. Slow utility is almost always &lt;strong&gt;lock contention&lt;/strong&gt; or &lt;strong&gt;I/O cost&lt;/strong&gt;, not a plan choice issue, so the diagnostic path is to set &lt;code&gt;log_min_duration_statement&lt;/code&gt; to capture timings, watch lock waits in &lt;code&gt;pg_stat_activity&lt;/code&gt;, and look at &lt;code&gt;wait_events&lt;/code&gt; to see where the command is stuck.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Second, audit and security requirements split into two camps and attach to different mechanisms.&lt;/strong&gt; Tracking "who changed which schema and when" lives on the utility side. pgaudit captures ALTER/CREATE/DROP/GRANT into an audit log because it sits on &lt;code&gt;ProcessUtility_hook&lt;/code&gt;. Row-level access control like "this user can only see a subset of rows in this table" lives on the optimizable side. RLS (Row-Level Security) runs in the rewriter, automatically attaching policy conditions to optimizable queries. The two requirements get bundled under the same security umbrella, but they hook into completely different stages of the pipeline. RLS cannot stop a schema change, and &lt;code&gt;ProcessUtility_hook&lt;/code&gt; cannot filter rows. When a compliance requirement comes in, the first task is to classify it as schema-level tracking versus row-level access control. Only then do the candidate tools fall into place, and you almost always need both mechanisms together to leave no gaps.&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>database</category>
      <category>internals</category>
      <category>sql</category>
    </item>
    <item>
      <title>1.1.2 Simple vs Extended</title>
      <dc:creator>JoongHyuk Shin</dc:creator>
      <pubDate>Tue, 05 May 2026 04:35:38 +0000</pubDate>
      <link>https://dev.to/joonghyukshin/112-simple-vs-extended-4l5l</link>
      <guid>https://dev.to/joonghyukshin/112-simple-vs-extended-4l5l</guid>
      <description>&lt;p&gt;The fork visible in 1.1.1 (simple query protocol on one side, extended on the other) is the subject of this section, one level deeper. 1.1.1 set the skeleton: simple is one message, extended is four. The job here is to show how that split translates into four distinct outcomes: plan reuse, parameter safety, pipelining, and error handling.&lt;/p&gt;

&lt;h2&gt;
  
  
  Message sequence: the shape of one cycle is different
&lt;/h2&gt;

&lt;p&gt;Putting the message sequences side by side makes the difference visible at a glance.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Simple&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Client                          Server
  │                               │
  │── 'Q' (SQL text) ────────────▶│
  │                               │ parse → analyze/rewrite → plan
  │                               │ → create portal → execute → drop portal
  │◀── RowDescription, DataRow*, CommandComplete, ReadyForQuery
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Extended&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Client                          Server
  │                               │
  │── 'P' (SQL template) ────────▶│ parse + analyze, store prepared statement
  │◀── ParseComplete                
  │                               │
  │── 'B' (parameter values) ────▶│ choose plan, create portal
  │◀── BindComplete                
  │                               │
  │── 'E' (execute) ─────────────▶│ run portal, send rows
  │◀── DataRow*, CommandComplete   
  │                               │
  │── 'S' (Sync) ────────────────▶│ close transaction
  │◀── ReadyForQuery               
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Simple finishes one cycle in a single message. Extended slices the cycle into four messages, and that slicing is what produces the four capabilities below.&lt;/p&gt;

&lt;h2&gt;
  
  
  Capability one: execution plans get reused
&lt;/h2&gt;

&lt;p&gt;The central concept that lets extended split the stages is the &lt;strong&gt;prepared statement&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;A prepared statement is a SQL template that has already been parsed and analyzed. The places where values would go are left blank with placeholders like &lt;code&gt;$1&lt;/code&gt;, &lt;code&gt;$2&lt;/code&gt;, and at execution time only the actual values get plugged into those slots. Take &lt;code&gt;INSERT INTO users (id, name) VALUES ($1, $2)&lt;/code&gt;. Once you turn that into a prepared statement, you can run it later by sending only the values: &lt;code&gt;(1, 'Alice')&lt;/code&gt;, &lt;code&gt;(2, 'Bob')&lt;/code&gt;. The full SQL text isn't reparsed each time. Give it a name and it becomes a &lt;strong&gt;named prepared statement&lt;/strong&gt; you can call back during the session. Send it without a name and it's an &lt;strong&gt;unnamed prepared statement&lt;/strong&gt;, automatically discarded the moment the next &lt;code&gt;'P'&lt;/code&gt; arrives.&lt;/p&gt;

&lt;p&gt;The four messages of the extended protocol are exactly that flow, sliced.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Message&lt;/th&gt;
&lt;th&gt;What it does&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;'P'&lt;/code&gt; Parse&lt;/td&gt;
&lt;td&gt;Take the SQL template, finish parse and analysis, store as a prepared statement&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;'B'&lt;/code&gt; Bind&lt;/td&gt;
&lt;td&gt;Bind actual parameter values to the prepared statement and prepare for execution (create a portal)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;'E'&lt;/code&gt; Execute&lt;/td&gt;
&lt;td&gt;Run the prepared portal and send result rows&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;'S'&lt;/code&gt; Sync&lt;/td&gt;
&lt;td&gt;End of the cycle, send ReadyForQuery&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;What this means is that the same prepared statement can be re-executed many times with different parameters by repeating just &lt;code&gt;'B' + 'E'&lt;/code&gt;. Take inserting 1,000 users.&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="c1"&gt;# Driver pseudocode: 1000 INSERTs via a prepared statement
&lt;/span&gt;&lt;span class="n"&gt;stmt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;prepare&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;INSERT INTO users (id, name) VALUES ($1, $2)&lt;/span&gt;&lt;span class="sh"&gt;"&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;i&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;stmt&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="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;user&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;conn.prepare(...)&lt;/code&gt; corresponds to a single 'P' message. Parsing and analysis of the SQL text happen there. Each of the 1000 &lt;code&gt;stmt.execute(...)&lt;/code&gt; calls corresponds to a 'B' + 'E' pair. Parse and analyze run only once on the first call; the remaining 999 do bind and execute only. With simple query, the same INSERT text would be sent 1000 times and reparsed 1000 times.&lt;/p&gt;

&lt;p&gt;Internally, a prepared statement is held in a structure called &lt;code&gt;CachedPlanSource&lt;/code&gt;, which keeps the raw parse tree and the analysis result. When the same prepared statement gets another &lt;code&gt;'B' + 'E'&lt;/code&gt;, the backend starts from the saved &lt;code&gt;CachedPlanSource&lt;/code&gt;, only redecides the execution plan, and runs. Parsing and analysis are skipped.&lt;/p&gt;

&lt;h3&gt;
  
  
  Generic plan vs custom plan
&lt;/h3&gt;

&lt;p&gt;One step further. Plan reuse is real, but to be precise there are two kinds of plan.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Custom plan&lt;/strong&gt;: recomputed every time using the bound parameter values. Helpful when the optimal path differs by value. Take &lt;code&gt;WHERE status = $1&lt;/code&gt;. Suppose &lt;code&gt;status='pending'&lt;/code&gt; matches 1% of rows and &lt;code&gt;status='completed'&lt;/code&gt; matches 99%. A distribution where the value-by-value ratios are this lopsided is what's usually called a &lt;strong&gt;skewed distribution&lt;/strong&gt;. Index scan is fast for 'pending'; sequential scan is fast for 'completed'. Custom plan looks at the value on every call and picks the path that fits it. (Plan construction is the entire subject of chapter 1.4; the kinds and behavior of scan nodes are covered in 1.5.2.)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Generic plan&lt;/strong&gt;: planned once without knowing the parameters and cached. Every EXECUTE from then on reuses the cached plan, so from each call's point of view the cost of "planning this one" is zero. The trade-off is that the same path is forced for every parameter value.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;PostgreSQL decides between the two on every EXECUTE. The decision function is &lt;code&gt;choose_custom_plan()&lt;/code&gt;, and the default policy is:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;For the first 5 EXECUTEs, always use a custom plan. Collect actual cost measurements.&lt;/li&gt;
&lt;li&gt;From the 6th onward, compare the average custom plan cost against the generic plan cost. The custom average includes the cost of planning every time, while the generic side has that cost as zero (for the reason above), so the comparison is intentionally asymmetric.&lt;/li&gt;
&lt;li&gt;If generic is cheaper, switch to generic. Otherwise, stay on custom.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The decision can be forced via the &lt;code&gt;plan_cache_mode&lt;/code&gt; GUC. &lt;code&gt;auto&lt;/code&gt; (default) runs the policy above; &lt;code&gt;force_custom_plan&lt;/code&gt; always uses custom; &lt;code&gt;force_generic_plan&lt;/code&gt; always uses generic.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Working on another RDBMS engine, the first time I saw the "5 customs, then start comparing" rule I spent a while looking for the reason behind that 5. The conclusion: it's an arbitrary constant. The PG source comment literally says "until we have done at least 5 (arbitrary)". Other engines tend to be stricter with plan cache policy (e.g. lock the first plan in as the generic one) and let you override via a knob, while PG chose to decide dynamically on every call. The result is that a PG prepared statement isn't simply a "plan cache"; it's "automatic switching driven by statistics." This is one reason that even ORM code that uses prepared statements automatically can show much less plan caching than people expect: if a statement is called fewer than 5 times, it gets recomputed as a custom plan every time.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Capability two: SQL injection is structurally blocked
&lt;/h2&gt;

&lt;p&gt;In simple query, putting a parameter into a query means embedding the value inside the SQL text, something like &lt;code&gt;f"SELECT * FROM users WHERE id = {user_input}"&lt;/code&gt;. If &lt;code&gt;user_input&lt;/code&gt; is untrusted, you've just opened the door to SQL injection.&lt;/p&gt;

&lt;p&gt;Extended separates the SQL template from the parameter values into different messages. &lt;code&gt;'P'&lt;/code&gt; carries only the template, like &lt;code&gt;SELECT * FROM users WHERE id = $1&lt;/code&gt;. &lt;code&gt;'B'&lt;/code&gt; carries the values that fill those slots, in binary or text form. Those values never go through the SQL parser. They're plugged into the already-parsed plan tree as data.&lt;/p&gt;

&lt;p&gt;When JDBC &lt;code&gt;PreparedStatement&lt;/code&gt;, libpq &lt;code&gt;PQexecParams&lt;/code&gt;, or psycopg2 supports &lt;code&gt;?&lt;/code&gt; or &lt;code&gt;$1&lt;/code&gt; placeholders, that's the path being used internally. The real mechanism for SQL injection prevention lives here. It isn't "remember to escape on the client"; it's a structure where the parser has no chance to interpret a user-supplied value as a SQL token.&lt;/p&gt;

&lt;h2&gt;
  
  
  Capability three: messages can be batched (pipelining)
&lt;/h2&gt;

&lt;p&gt;Simple sends ReadyForQuery back the moment each &lt;code&gt;'Q'&lt;/code&gt; is processed. The client can't send the next query until that response arrives. One query equals one round-trip.&lt;/p&gt;

&lt;p&gt;Extended only sends ReadyForQuery when an &lt;code&gt;'S'&lt;/code&gt; (Sync) arrives. That means a sequence like &lt;code&gt;'P', 'B', 'E', 'B', 'E', 'B', 'E', 'S'&lt;/code&gt; can go out as a single batch. 100 INSERTs in one round-trip. In environments with significant network latency (cross-region cloud calls, for instance), the throughput difference is large.&lt;/p&gt;

&lt;p&gt;Built on top of this mechanism, PG 14 introduced an official pipeline mode in libpq (&lt;code&gt;PQpipelineSync&lt;/code&gt;, &lt;code&gt;PQenterPipelineMode&lt;/code&gt;, etc.). The wire-level capability existed before, but the libpq client API for it wasn't clean.&lt;/p&gt;

&lt;h2&gt;
  
  
  Capability four: a partial error doesn't break the whole batch
&lt;/h2&gt;

&lt;p&gt;Simple, on error, immediately sends ErrorResponse plus ReadyForQuery. The cycle closes right away and the backend is ready for the next &lt;code&gt;'Q'&lt;/code&gt;. As noted above, simple is a 1-round-trip structure, so when the backend returns to normal mode there's nothing queued in the buffer behind the failed message. Closing out and waiting for the next &lt;code&gt;'Q'&lt;/code&gt; is enough.&lt;/p&gt;

&lt;p&gt;Where extended runs into real trouble is the batch case. As we saw in capability three, a typical client pushes &lt;code&gt;'B', 'E', 'B', 'E', ..., 'S'&lt;/code&gt; into the wire all at once. Suppose you send 100 INSERTs by pipelining: one 'P', followed by 100 pairs of 'B' + 'E' and a single 'S' all line up in the backend's buffer. While the backend is processing the 1st 'B', the 51st and 70th messages are already sitting in that buffer waiting their turn.&lt;/p&gt;

&lt;p&gt;Now suppose the 50th 'B' fails with something like a unique violation. If the backend behaved like simple (immediately sending ErrorResponse + ReadyForQuery and returning to normal mode), it would pull the 51st 'B' out of the buffer and start processing it next. But that 51st 'B' was sent by the client under the assumption that the first 50 had succeeded. The transaction is already aborted, so processing the 51st errors out too. Same for 52, 53, ..., 100. The client ends up tracking the original error plus 50 more downstream errors.&lt;/p&gt;

&lt;p&gt;PG avoids this chaos with a different strategy. The moment an error occurs, the backend enters a special state called &lt;strong&gt;ignore_till_sync&lt;/strong&gt;. While in that state, every message that arrives is dropped without being processed until the client explicitly sends an &lt;code&gt;'S'&lt;/code&gt; (Sync). No additional error responses go out. Once &lt;code&gt;'S'&lt;/code&gt; arrives, the backend finally sends ReadyForQuery and starts accepting messages normally again.&lt;/p&gt;

&lt;p&gt;The result is that the client receives exactly two responses: one ErrorResponse (the 50th failure) and one ReadyForQuery (in reply to &lt;code&gt;'S'&lt;/code&gt;). A clean boundary forms: "the batch failed somewhere, and everything past that point was discarded." ignore_till_sync is, in essence, the byproduct that makes pipelining safe.&lt;/p&gt;

&lt;h2&gt;
  
  
  All four in one table
&lt;/h2&gt;

&lt;p&gt;Compressing the four capabilities into a single comparison.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Area&lt;/th&gt;
&lt;th&gt;Simple&lt;/th&gt;
&lt;th&gt;Extended&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Message count&lt;/td&gt;
&lt;td&gt;1 (&lt;code&gt;'Q'&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;4+ (&lt;code&gt;'P'&lt;/code&gt;, &lt;code&gt;'B'&lt;/code&gt;, &lt;code&gt;'E'&lt;/code&gt;, &lt;code&gt;'S'&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Plan reuse&lt;/td&gt;
&lt;td&gt;None (parse + plan every time)&lt;/td&gt;
&lt;td&gt;Yes (&lt;code&gt;CachedPlanSource&lt;/code&gt; + auto generic/custom)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Parameters&lt;/td&gt;
&lt;td&gt;Inline in SQL text&lt;/td&gt;
&lt;td&gt;Separated as data at bind time&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SQL injection&lt;/td&gt;
&lt;td&gt;Client is responsible for escaping&lt;/td&gt;
&lt;td&gt;Prevented at the protocol level&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Round-trips&lt;/td&gt;
&lt;td&gt;1 per query&lt;/td&gt;
&lt;td&gt;Batched (1 per Sync)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Error handling&lt;/td&gt;
&lt;td&gt;Immediate ReadyForQuery&lt;/td&gt;
&lt;td&gt;ignore_till_sync (wait until Sync)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  What this means in practice
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;First, don't assume that "the ORM uses prepared statements" means you're getting full plan caching.&lt;/strong&gt; PG plans a custom plan for the first 5 EXECUTEs of every prepared statement. If a statement is called only once or twice inside a short transaction, the plan caching benefit is essentially zero. The real benefit shows up in workloads that call the same prepared statement dozens to hundreds of times with different parameters. The ratio of &lt;code&gt;calls&lt;/code&gt; to &lt;code&gt;plans&lt;/code&gt; in &lt;code&gt;pg_stat_statements&lt;/code&gt;, plus a forced &lt;code&gt;plan_cache_mode&lt;/code&gt; setting, are the two diagnostic tools.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Second, the answer to "why isn't my prepared statement going generic?" is the wall of 5.&lt;/strong&gt; Forcing &lt;code&gt;plan_cache_mode = force_generic_plan&lt;/code&gt; brings planning cost to zero but locks every parameter value to the same path. With skewed data this can actually be slower. The opposite, &lt;code&gt;force_custom_plan&lt;/code&gt;, pays planning cost every time. The default &lt;code&gt;auto&lt;/code&gt;, which decides dynamically from the 6th call, is usually safest, but there are environments where explicitly choosing generic is worth the GUC tweak. For example, environments where prepared statements have very short lifetimes due to PgBouncer transaction pooling.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Third, in environments with significant network latency, pipelining is the real lever.&lt;/strong&gt; Cross-region RDS calls in the cloud, or even same-region setups where there's millisecond-level latency between application and database, will turn 100 simple INSERTs into 100 round-trips. libpq pipeline mode, or JDBC &lt;code&gt;addBatch()&lt;/code&gt; + &lt;code&gt;executeBatch()&lt;/code&gt;, can collapse that to a single round-trip. Just keep in mind that the error-handling complexity goes up (you need to understand what ignore_till_sync means), so it pays to design batch-level transaction boundaries and a retry policy at the same time as the pipelining itself.&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>database</category>
      <category>internals</category>
      <category>protocol</category>
    </item>
    <item>
      <title>1.1.1 Life of a Query</title>
      <dc:creator>JoongHyuk Shin</dc:creator>
      <pubDate>Mon, 04 May 2026 10:13:22 +0000</pubDate>
      <link>https://dev.to/joonghyukshin/111-life-of-a-query-1phg</link>
      <guid>https://dev.to/joonghyukshin/111-life-of-a-query-1phg</guid>
      <description>&lt;p&gt;This section is the map for the rest of the book. The five stages introduced in the 1.1 chapter overview (message dispatch, parser + analyzer, rewriter, planner, executor) are traced here through the actual code: which functions implement each stage, and in what order they get called. The mechanics of each of the five stages are unpacked in later chapters. Here, only the skeleton matters: how a backend starts up, how it receives messages, and where the first fork in the road appears.&lt;/p&gt;

&lt;p&gt;One term to settle first. In this book, a &lt;strong&gt;statement&lt;/strong&gt; refers to a single unit of SQL command that the client sends to the backend. One &lt;code&gt;SELECT * FROM users&lt;/code&gt;, one &lt;code&gt;INSERT INTO users ...&lt;/code&gt;, each is one statement. A single request may carry several statements joined by &lt;code&gt;;&lt;/code&gt;, and in that case each is handled as a separate statement. Every section from here on uses "statement" in this sense.&lt;/p&gt;

&lt;h2&gt;
  
  
  One backend process owns one query
&lt;/h2&gt;

&lt;p&gt;Every time a client connects, PostgreSQL forks a &lt;strong&gt;backend process&lt;/strong&gt; for it (the parent is &lt;code&gt;postmaster&lt;/code&gt;). That process stays alive until the client disconnects, and it handles every query that client sends, by itself. Unlike the thread-pool model common in other RDBMSs, PG uses one OS process per connection. The reasons behind that decision are taken up in 6.1.1.&lt;/p&gt;

&lt;p&gt;The actual entry point of that backend is a function called &lt;code&gt;PostgresMain&lt;/code&gt;. The name is grand; what it does is unexpectedly simple. Two things, then off it goes.&lt;/p&gt;

&lt;p&gt;First, &lt;strong&gt;it installs signal handlers&lt;/strong&gt;. Signals are asynchronous notifications the OS delivers to a process (for example, &lt;code&gt;SIGTERM&lt;/code&gt; is a request to shut down, &lt;code&gt;SIGUSR1&lt;/code&gt; is for PG-internal communication). A backend has to react to signals from &lt;code&gt;postmaster&lt;/code&gt; and from other backends, so each signal is wired to a handler ahead of time. Signals and IPC in general are covered in 6.3.&lt;/p&gt;

&lt;p&gt;Second, &lt;strong&gt;it initializes the transaction system&lt;/strong&gt;. Every SQL statement in PG, even without an explicit &lt;code&gt;BEGIN&lt;/code&gt;, runs inside some transaction. The transaction system is the core PG machinery that tracks &lt;code&gt;BEGIN&lt;/code&gt;/&lt;code&gt;COMMIT&lt;/code&gt; boundaries, MVCC visibility, XID assignment, and so on. Transactions and MVCC are the subject of all of chapter 3. For now, it's enough to know that this machinery is set up before the backend ever sees a SQL statement.&lt;/p&gt;

&lt;p&gt;Once those two preparations are done, the real work of the backend begins. An infinite loop.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight c"&gt;&lt;code&gt;&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="p"&gt;(;;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="p"&gt;...&lt;/span&gt;
    &lt;span class="n"&gt;ReadyForQuery&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;whereToSendOutput&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;...&lt;/span&gt;
    &lt;span class="n"&gt;firstchar&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ReadCommand&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;input_message&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;...&lt;/span&gt;
    &lt;span class="k"&gt;switch&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;firstchar&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="n"&gt;PqMsg_Query&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;        &lt;span class="c1"&gt;// 'Q', simple query&lt;/span&gt;
            &lt;span class="n"&gt;exec_simple_query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query_string&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="n"&gt;PqMsg_Parse&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;        &lt;span class="c1"&gt;// 'P', extended: parse&lt;/span&gt;
            &lt;span class="n"&gt;exec_parse_message&lt;/span&gt;&lt;span class="p"&gt;(...);&lt;/span&gt;
            &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="n"&gt;PqMsg_Bind&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;         &lt;span class="c1"&gt;// 'B', extended: bind&lt;/span&gt;
            &lt;span class="n"&gt;exec_bind_message&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;input_message&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="n"&gt;PqMsg_Execute&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;      &lt;span class="c1"&gt;// 'E', extended: execute&lt;/span&gt;
            &lt;span class="n"&gt;exec_execute_message&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;portal_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;max_rows&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="n"&gt;PqMsg_Sync&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;         &lt;span class="c1"&gt;// 'S', end of an extended cycle&lt;/span&gt;
            &lt;span class="n"&gt;finish_xact_command&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
            &lt;span class="n"&gt;send_ready_for_query&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
            &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="p"&gt;...&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;This loop is the entire life of a backend.&lt;/p&gt;

&lt;p&gt;"Announce that I'm ready, read one message, dispatch on its type." Repeat forever. When the client closes the connection, an &lt;code&gt;'X'&lt;/code&gt; (Terminate) message arrives, the loop exits, and the process dies.&lt;/p&gt;

&lt;p&gt;The first fork in the road is visible right here. There's the &lt;code&gt;'Q'&lt;/code&gt; path and the &lt;code&gt;'P' / 'B' / 'E'&lt;/code&gt; path. That split is the difference between the simple query protocol and the extended query protocol.&lt;/p&gt;

&lt;h2&gt;
  
  
  Simple vs extended
&lt;/h2&gt;

&lt;p&gt;Simple is the case where a single message contains the SQL text in full. Type &lt;code&gt;SELECT 1;&lt;/code&gt; into &lt;code&gt;psql&lt;/code&gt; and hit enter, and that's what flies across the wire. The backend receives that one message and runs the full five-stage cycle (dispatch, parser + analyzer, rewriter, planner, executor) before returning the result.&lt;/p&gt;

&lt;p&gt;Extended does the same job but splits it into four messages (&lt;code&gt;'P'&lt;/code&gt;, &lt;code&gt;'B'&lt;/code&gt;, &lt;code&gt;'E'&lt;/code&gt;, &lt;code&gt;'S'&lt;/code&gt;). Splitting the stages opens up plan reuse, parameter safety, and pipelining. The semantic differences between the two protocols and how they play out in practice are unpacked in section 1.1.2.&lt;/p&gt;

&lt;h2&gt;
  
  
  Optimizable vs utility
&lt;/h2&gt;

&lt;p&gt;Everything described so far assumes &lt;strong&gt;optimizable statements&lt;/strong&gt;: &lt;code&gt;SELECT/INSERT/UPDATE/DELETE&lt;/code&gt;. These have paths to optimize. The planner decides between sequential and index scan, hash join and nested loop, one join order or another.&lt;/p&gt;

&lt;p&gt;But statements like &lt;code&gt;CREATE TABLE&lt;/code&gt;, &lt;code&gt;VACUUM&lt;/code&gt;, &lt;code&gt;SET&lt;/code&gt;, and &lt;code&gt;BEGIN&lt;/code&gt; (the &lt;strong&gt;utility statements&lt;/strong&gt;) are different. There's nothing for a cost model to optimize. They're DDL or system commands, with no path to choose. In that case the planner produces only an empty shell of a plan and hands the actual work to a utility-statement handler. The executor never gets called on this path.&lt;/p&gt;

&lt;p&gt;The detailed branching is the subject of 1.1.3. The takeaway here is just one thing: not every query in PG goes through the planner.&lt;/p&gt;

&lt;h2&gt;
  
  
  The big picture
&lt;/h2&gt;

&lt;p&gt;We can now compress the journey of a SQL line into a single diagram.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Client
   │
   │  'Q' (or 'P' + 'B' + 'E')
   ▼
PostgresMain main loop
   │
   ▼
exec_simple_query
   │
   ├─ pg_parse_query           → raw parse tree     (1.2.1, 1.2.3)
   │
   ├─ pg_analyze_and_rewrite   → list of Query nodes (1.2.2, 1.3)
   │
   ├─ pg_plan_queries          → execution plan      (1.4 chapter)
   │     └─ utility produces an empty shell          (1.1.3)
   │
   ├─ PortalStart + PortalRun  → tuple pulling       (1.5)
   │
   └─ PortalDrop + finish_xact_command
   │
   ▼
ReadyForQuery → back to the top of the loop
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each box in this diagram corresponds to a chapter in the book. 1.2 is parser and analyzer, 1.3 is rewriter, 1.4 is planner, 1.5 is the executor. All of part 1 is essentially one zoomed-in view of this diagram.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Working on another RDBMS engine, I once found this aspect of PG surprising. PG accepts a multi-statement query like &lt;code&gt;SELECT 1; SELECT 2;&lt;/code&gt; as a single simple-query message. What's even more surprising is the transaction handling. Without an explicit &lt;code&gt;BEGIN&lt;/code&gt;/&lt;code&gt;COMMIT&lt;/code&gt;, all those statements get bundled into a single implicit transaction block, and if even one of them fails, the whole batch rolls back.&lt;/p&gt;

&lt;p&gt;At first I assumed this was just standard behavior. Comparing the client protocols of other major databases made it clear this is a PG-specific decision. MySQL has &lt;code&gt;CLIENT_MULTI_STATEMENTS&lt;/code&gt; off by default, so multi-statement queries are simply rejected (you have to flip the flag explicitly because of SQL injection risk). Even with the flag on, statements are processed sequentially, and because autocommit is the default, each one commits as its own transaction. Oracle accepts only one statement per OCI call, so to bundle multiple statements you have to wrap them in an anonymous PL/SQL block (&lt;code&gt;BEGIN ... END;&lt;/code&gt;). SQL Server accepts multiple statements in a T-SQL batch, but atomic handling still requires an explicit &lt;code&gt;BEGIN TRANSACTION&lt;/code&gt;. None of the three does what PG does: bundle automatically as soon as the message arrives.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  What this means in practice
&lt;/h2&gt;

&lt;p&gt;This five-stage skeleton turns out to be the foundation for two diagnostic tools you'll use in operations.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;First, you can see exactly where EXPLAIN's output comes from.&lt;/strong&gt; &lt;code&gt;EXPLAIN&lt;/code&gt; runs only as far as stage 4 (plan); it skips stage 5 (execute). &lt;code&gt;EXPLAIN ANALYZE&lt;/code&gt; actually runs through stage 5 and measures it. That's why &lt;code&gt;EXPLAIN ANALYZE&lt;/code&gt; produces real load and shouldn't be casually run in production: an &lt;code&gt;EXPLAIN ANALYZE UPDATE ...&lt;/code&gt; actually updates rows. The familiar &lt;code&gt;BEGIN; EXPLAIN ANALYZE UPDATE ...; ROLLBACK&lt;/code&gt; idiom exists for exactly this reason.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Second, the fact that one backend means one process means one query at a time explains why connection pooling matters so much.&lt;/strong&gt; A backend's main loop is essentially single-threaded. While one client runs a long query, that backend can't do anything else. Connection counts therefore drive memory and scheduling costs linearly, and a pooler like PgBouncer becomes effectively mandatory. The answer to "why are PostgreSQL connections so expensive?" lives inside this one-line main loop.&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>database</category>
      <category>internals</category>
      <category>protocol</category>
    </item>
    <item>
      <title>1.1 Where Does a Query Go?</title>
      <dc:creator>JoongHyuk Shin</dc:creator>
      <pubDate>Mon, 04 May 2026 09:57:44 +0000</pubDate>
      <link>https://dev.to/joonghyukshin/11-where-does-a-query-go-1bka</link>
      <guid>https://dev.to/joonghyukshin/11-where-does-a-query-go-1bka</guid>
      <description>&lt;p&gt;Suppose a client sends &lt;code&gt;SELECT * FROM users WHERE id = 1&lt;/code&gt;. The path that single line travels before coming back as a result row is longer than you might expect. Inside the PostgreSQL backend, that SQL goes through a five-stage pipeline. The five stages are exactly the five chapters of Chapter 1.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;1.1 Where Does a Query Go?&lt;/strong&gt;: the backend decides which processing path the client message should follow.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;1.2 Parser and Analyzer: How SQL Gets Its Meaning&lt;/strong&gt;: the SQL text is parsed, and the catalog is consulted to give it meaning.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;1.3 Rewriter: How a Query is Rewritten&lt;/strong&gt;: the RULE system expands views, injects RLS policies, and otherwise transforms the query tree.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;1.4 Planner: Which Path to Take&lt;/strong&gt;: a cost model explores possible execution paths and picks the best one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;1.5 Executor: How Results Come Back&lt;/strong&gt;: the chosen plan is walked, pulling tuples up and sending them to the client.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Chapter 1.1, the one you're reading, splits into three sections.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;1.1.1 Life of a Query&lt;/strong&gt;: compresses all five stages into a single diagram. The map for the rest of the book.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;1.1.2 Simple vs Extended&lt;/strong&gt;: looks at the semantic difference between the two protocols.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;1.1.3 Optimizable vs Utility&lt;/strong&gt;: shows how &lt;code&gt;SELECT/INSERT/...&lt;/code&gt; and &lt;code&gt;CREATE/VACUUM/...&lt;/code&gt; take different paths.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;After this chapter, it should be clear how the backend's main loop receives a client message and dispatches it to the right function.&lt;/p&gt;

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