<?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: Saksham Codes</title>
    <description>The latest articles on DEV Community by Saksham Codes (@sakshamghimire).</description>
    <link>https://dev.to/sakshamghimire</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F650603%2F7f649b6c-3672-43ed-9bee-81adf507c191.png</url>
      <title>DEV Community: Saksham Codes</title>
      <link>https://dev.to/sakshamghimire</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/sakshamghimire"/>
    <language>en</language>
    <item>
      <title>Cross-Row Validation Risk in PostgreSQL CHECK Constraints</title>
      <dc:creator>Saksham Codes</dc:creator>
      <pubDate>Mon, 15 Jun 2026 17:28:26 +0000</pubDate>
      <link>https://dev.to/sakshamghimire/cross-row-validation-risk-in-postgresql-check-constraints-4n6m</link>
      <guid>https://dev.to/sakshamghimire/cross-row-validation-risk-in-postgresql-check-constraints-4n6m</guid>
      <description>&lt;p&gt;Check constraints are widely used to enforce data integrity by validating individual rows during inserts or updates. What many don’t realize is that you can also use custom functions with complex logic as check constraints, greatly increasing their flexibility. However, this added power comes with important caveats and risks that every developer should understand — especially when those functions read data beyond the current row. In this article, we’ll explore these hidden dangers and why caution is crucial.&lt;/p&gt;

&lt;p&gt;Let’s start with a simple example: suppose we want to ensure that a value in a column does not appear more than twice in a table. How can we enforce this integrity at the database level? One approach is to use a constraint. Below is a snippet demonstrating a function that attempts to enforce this rule.&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="c1"&gt;-- create table with 2 field, field_id and price&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;example_table&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;field_id&lt;/span&gt; &lt;span class="nb"&gt;INT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;price&lt;/span&gt; &lt;span class="nb"&gt;DECIMAL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;10&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="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;-- custom function that checks if count exceed 2, because constraint are checked at time of insert/update&lt;/span&gt;
&lt;span class="c1"&gt;-- so if it's greater than 1 return false&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;OR&lt;/span&gt; &lt;span class="k"&gt;REPLACE&lt;/span&gt; &lt;span class="k"&gt;FUNCTION&lt;/span&gt; &lt;span class="n"&gt;check_field_id_limit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;val&lt;/span&gt; &lt;span class="nb"&gt;INT&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;RETURNS&lt;/span&gt; &lt;span class="nb"&gt;BOOLEAN&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="err"&gt;$$&lt;/span&gt;
&lt;span class="k"&gt;DECLARE&lt;/span&gt;
    &lt;span class="n"&gt;cnt&lt;/span&gt; &lt;span class="nb"&gt;INT&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;BEGIN&lt;/span&gt;
    &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;COUNT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;cnt&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;example_table&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;field_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;val&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="n"&gt;IF&lt;/span&gt; &lt;span class="n"&gt;cnt&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;THEN&lt;/span&gt; &lt;span class="c1"&gt;-- count should never exceed 2&lt;/span&gt;
        &lt;span class="k"&gt;RETURN&lt;/span&gt; &lt;span class="k"&gt;FALSE&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;ELSE&lt;/span&gt;
        &lt;span class="k"&gt;RETURN&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;END&lt;/span&gt; &lt;span class="n"&gt;IF&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;END&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="err"&gt;$$&lt;/span&gt; &lt;span class="k"&gt;LANGUAGE&lt;/span&gt; &lt;span class="n"&gt;plpgsql&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- Add custom function as constraint to the table&lt;/span&gt;
&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;example_table&lt;/span&gt;
&lt;span class="k"&gt;ADD&lt;/span&gt; &lt;span class="k"&gt;CONSTRAINT&lt;/span&gt; &lt;span class="n"&gt;chk_field_id_limit&lt;/span&gt;
&lt;span class="k"&gt;CHECK&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;check_field_id_limit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;field_id&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;


&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Looks good, right? Now let’s put this to the test.&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;example_table&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="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;-- passes&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;example_table&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="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;-- passes&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;example_table&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="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;-- fails&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The third statement correctly triggers an exception — Error: new row for relation 'example_table' violates check constraint 'chk_field_id_limit'—and seemingly fulfills our requirement to restrict entries based on field_id. This apparent success, however, masks a serious underlying issue that goes against recommended database practices.&lt;/p&gt;

&lt;p&gt;The PostgreSQL documentation offers a stark warning:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;PostgreSQL does not support CHECK constraints that reference table data other than the new or updated row being checked. While a CHECK constraint that violates this rule may appear to work in simple tests, it cannot guarantee that the database will not reach a state in which the constraint condition is false (due to subsequent changes of the other row(s) involved). This would cause a database dump and restore to fail.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This statement highlights a critical lack of guarantee, but without a concrete example of a breaking case, it often feels abstract. Many of us, myself included, can’t truly grasp the danger until we see it fail. So, let’s move beyond theory and demonstrate precisely how such a constraint can lead to an inconsistent state. We’ll now actively create a scenario where this “guarantee” falls apart.&lt;/p&gt;

&lt;p&gt;Consider this scenario involving concurrent transactions&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="c1"&gt;-- Transaction 1&lt;/span&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;example_table&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;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;100&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;example_table&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;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;-- Transaction 2 (started concurrently, before Transaction 1 commits)&lt;/span&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;example_table&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;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;100&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;example_table&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;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;-- Transaction 1 commits its changes&lt;/span&gt;
&lt;span class="k"&gt;COMMIT&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- Transaction 2 commits its changes&lt;/span&gt;
&lt;span class="k"&gt;COMMIT&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here, the final state of the database is now inconsistent; the initially defined constraint no longer holds validity as selecting on &lt;code&gt;example_table&lt;/code&gt; We see 4 records of &lt;code&gt;field_id&lt;/code&gt; 2.&lt;/p&gt;

&lt;p&gt;Here’s where the problem arises: At the moment of each &lt;code&gt;INSERT&lt;/code&gt;, the &lt;code&gt;CHECK&lt;/code&gt; constraint fires. However, because each transaction is operating in &lt;code&gt;READ COMMITTED&lt;/code&gt; isolation, it can only "see" changes that other transactions have already committed. When Transaction 1 inserts its two rows for &lt;code&gt;field_id = 2&lt;/code&gt;, it doesn't see Transaction 2's uncommitted inserts. The same applies to Transaction 2. Both transactions independently validate their inserts against a partial view of the data, and crucially, both pass the &lt;code&gt;CHECK&lt;/code&gt; constraint.&lt;/p&gt;

&lt;p&gt;Once both transactions successfully commit, the final state of &lt;code&gt;example_table&lt;/code&gt; is inconsistent. A simple &lt;code&gt;SELECT&lt;/code&gt; query would reveal four records with &lt;code&gt;field_id = 2&lt;/code&gt;, completely violating our initially defined constraint that aimed to limit entries for that &lt;code&gt;field_id&lt;/code&gt;. The database, despite the &lt;code&gt;CHECK&lt;/code&gt; constraint, has reached an invalid state. Now the backup will fail.&lt;/p&gt;

&lt;p&gt;This is precisely why PostgreSQL doesn’t recommend cross-row reads or cross-row validation within &lt;code&gt;CHECK&lt;/code&gt; constraints. As we've explored, attempting this introduces a fundamental and critical flaw into your database design: while such constraints might deceptively appear to work in simple tests, the inherent nature of concurrent transactions, particularly under default isolation levels, allows them to be bypassed. This leads directly to database inconsistency, where the data violates your intended rules despite the constraint's presence. Consequently, if you are reading cross-row records, you might be exposing your database to unacceptable risks, and proceeding without proper knowledge of isolation levels is strongly not recommended.&lt;/p&gt;

</description>
      <category>database</category>
      <category>postgres</category>
      <category>sql</category>
      <category>tutorial</category>
    </item>
  </channel>
</rss>
