<?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: Maliik Bryan</title>
    <description>The latest articles on DEV Community by Maliik Bryan (@maliikb).</description>
    <link>https://dev.to/maliikb</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%2F3985691%2Ffd698d3f-f354-4f51-a840-af923594d740.jpeg</url>
      <title>DEV Community: Maliik Bryan</title>
      <link>https://dev.to/maliikb</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/maliikb"/>
    <language>en</language>
    <item>
      <title>PostgREST can't upsert against partial unique indexes</title>
      <dc:creator>Maliik Bryan</dc:creator>
      <pubDate>Mon, 15 Jun 2026 15:00:47 +0000</pubDate>
      <link>https://dev.to/maliikb/postgrest-cant-upsert-against-partial-unique-indexes-4c0g</link>
      <guid>https://dev.to/maliikb/postgrest-cant-upsert-against-partial-unique-indexes-4c0g</guid>
      <description>&lt;h2&gt;
  
  
  The bug
&lt;/h2&gt;

&lt;p&gt;I split a 3-column unique constraint into two partial unique indexes to support a new nullable column. The next day, every &lt;code&gt;.upsert()&lt;/code&gt; against that table started failing with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;there is no unique or exclusion constraint matching the ON CONFLICT specification
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The upsert call hadn't changed. The data shape was identical. The error message was technically correct and completely useless: the constraint did exist, it was just partial.&lt;/p&gt;

&lt;p&gt;This post is the unblock for anyone hitting that error on a Supabase table that uses partial unique indexes.&lt;/p&gt;




&lt;h2&gt;
  
  
  The schema change
&lt;/h2&gt;

&lt;p&gt;The original table had a flat 3-column unique constraint:&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;profile_subscriptions&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="n"&gt;UUID&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;gen_random_uuid&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="n"&gt;profile_id&lt;/span&gt; &lt;span class="n"&gt;UUID&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="n"&gt;profiles&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;ON&lt;/span&gt; &lt;span class="k"&gt;DELETE&lt;/span&gt; &lt;span class="k"&gt;CASCADE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;category_type&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;subcategory&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;enabled&lt;/span&gt; &lt;span class="nb"&gt;BOOLEAN&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="k"&gt;TRUE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;...&lt;/span&gt;
  &lt;span class="k"&gt;UNIQUE&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;profile_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;category_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;subcategory&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;I added a nullable &lt;code&gt;source_agency&lt;/code&gt; column. The intent: NULL means "this subscription applies to all agencies" (a default mode), and a non-NULL value means "this subscription is scoped to a specific agency."&lt;/p&gt;

&lt;p&gt;Uniqueness now had two cases:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;For NULL-agency rows, unique on &lt;code&gt;(profile_id, category_type, subcategory)&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;For non-NULL-agency rows, unique on &lt;code&gt;(profile_id, category_type, subcategory, source_agency)&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A naive 4-column unique constraint doesn't work because PostgreSQL treats NULLs as "always distinct" by default. You'd get unlimited duplicate &lt;code&gt;(profile, category, subcategory, NULL)&lt;/code&gt; rows.&lt;/p&gt;

&lt;p&gt;The right tool is two partial unique indexes:&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;UNIQUE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;idx_profile_subs_null_agency&lt;/span&gt;
  &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;profile_subscriptions&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;profile_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;category_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;subcategory&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;source_agency&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;UNIQUE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;idx_profile_subs_with_agency&lt;/span&gt;
  &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;profile_subscriptions&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;profile_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;category_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;subcategory&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;source_agency&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;source_agency&lt;/span&gt; &lt;span class="k"&gt;IS&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This works at the database level. Both indexes enforce the right uniqueness. Direct SQL &lt;code&gt;INSERT&lt;/code&gt; and &lt;code&gt;UPDATE&lt;/code&gt; against the table behave correctly. The migration ran clean. Tests passed. Everything looked fine.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why PostgREST breaks
&lt;/h2&gt;

&lt;p&gt;The Supabase JS client builds upsert requests like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;supabase&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;profile_subscriptions&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;upsert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;onConflict&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;profile_id,category_type,subcategory,source_agency&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
  &lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;PostgREST translates that into roughly:&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;profile_subscriptions&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;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;profile_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;category_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;subcategory&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;source_agency&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="p"&gt;...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This SQL is syntactically valid. It still fails. PostgreSQL's &lt;code&gt;ON CONFLICT&lt;/code&gt; requires the inference column list to &lt;strong&gt;match an existing unique constraint or unique index exactly, including any WHERE predicate&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Your unique index has &lt;code&gt;WHERE source_agency IS NOT NULL&lt;/code&gt;. The upsert's &lt;code&gt;ON CONFLICT (...)&lt;/code&gt; doesn't include the predicate. PostgreSQL can't find a matching constraint. You get:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;there is no unique or exclusion constraint matching the ON CONFLICT specification
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;PostgREST won't generate &lt;code&gt;ON CONFLICT (cols) WHERE source_agency IS NOT NULL&lt;/code&gt; from the client API. There's no way to express the predicate through &lt;code&gt;upsert&lt;/code&gt;'s &lt;code&gt;onConflict&lt;/code&gt; option.&lt;/p&gt;

&lt;p&gt;The obvious workaround, "just put a &lt;code&gt;COALESCE(source_agency, '')&lt;/code&gt; in the index and make it non-partial," also doesn't work. PostgREST can't upsert against expression-based indexes either, for the same root reason.&lt;/p&gt;




&lt;h2&gt;
  
  
  The fix: delete-matching + insert
&lt;/h2&gt;

&lt;p&gt;Rather than fight &lt;code&gt;ON CONFLICT&lt;/code&gt;, I dropped down to two operations: delete the matching row(s) by exact predicate, then insert the new state. From &lt;code&gt;apps/api/src/routes/subscriptions.ts:248&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// PostgREST cannot upsert against partial unique indexes (no WHERE clause&lt;/span&gt;
&lt;span class="c1"&gt;// support in ON CONFLICT). Use delete-matching + insert instead.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;nullAgencySubs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;subscriptions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;source_agency&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;agencySubs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;subscriptions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;source_agency&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;allResults&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ProfileSubscription&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;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;nullAgencySubs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&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;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// Delete existing matching rows first&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nx"&gt;nullAgencySubs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
      &lt;span class="nx"&gt;supabaseAdmin&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;profile_subscriptions&lt;/span&gt;&lt;span class="dl"&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;delete&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;eq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;profile_id&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;profileId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;eq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;category_type&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;category_type&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;eq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;subcategory&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;subcategory&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;is&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;source_agency&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;null&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;rows&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;nullAgencySubs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;profile_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;profileId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;category_type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;category_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;subcategory&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;subcategory&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;enabled&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;enabled&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;inferred&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;inferred&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;source_agency&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;updated_at&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;now&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;}));&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;supabaseAdmin&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;profile_subscriptions&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;insert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;select&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="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* handle error */&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nx"&gt;allResults&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(...((&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="p"&gt;[])&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;ProfileSubscription&lt;/span&gt;&lt;span class="p"&gt;[]));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// agency-set bucket follows the same pattern with&lt;/span&gt;
&lt;span class="c1"&gt;// .eq('source_agency', sub.source_agency!) instead of .is('source_agency', null)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The pattern in three steps:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Split the input rows by whether the partial-index predicate applies (null-agency vs agency-set).&lt;/li&gt;
&lt;li&gt;For each bucket, delete the matching existing rows. Use &lt;code&gt;.is('source_agency', null)&lt;/code&gt; for the null bucket, because &lt;code&gt;.eq()&lt;/code&gt; will never match NULL in PostgREST.&lt;/li&gt;
&lt;li&gt;Insert the new rows in one batch per bucket.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This avoids &lt;code&gt;ON CONFLICT&lt;/code&gt; entirely. The trade-offs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You're doing roughly 2N round trips instead of N. For a small batch (these payloads are usually under 30 rows) it's fine. For high-volume writes, move it into a PL/pgSQL function and call it through Supabase RPC.&lt;/li&gt;
&lt;li&gt;The window between DELETE and INSERT is not transactionally protected at the client level. If your write pattern allows two clients to race on the same &lt;code&gt;(profile_id, category_type, subcategory)&lt;/code&gt;, wrap the whole thing in a Supabase RPC and let Postgres serialize.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For our use case the writes happen during a quiz wizard flow where the user is the only writer on their own subscription rows. The race window is negligible.&lt;/p&gt;




&lt;h2&gt;
  
  
  The deeper trap
&lt;/h2&gt;

&lt;p&gt;ORM abstractions over Postgres leak whenever your schema uses anything beyond column-level constraints. Partial unique indexes are common in production schemas. Anywhere you have NULL-with-meaning, the natural answer is a partial unique index:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Soft-deleted rows where the unique constraint should only apply to live rows&lt;/li&gt;
&lt;li&gt;Multi-tenant uniqueness where &lt;code&gt;tenant_id&lt;/code&gt; is nullable for global rows&lt;/li&gt;
&lt;li&gt;Draft-vs-published states where the slug is unique only among published rows&lt;/li&gt;
&lt;li&gt;Versioned rows where the index applies only to the latest version&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The moment you reach for one, you lose:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;.upsert()&lt;/code&gt; against the index via PostgREST or any client that wraps it&lt;/li&gt;
&lt;li&gt;Any framework that emits &lt;code&gt;INSERT ... ON CONFLICT (cols)&lt;/code&gt; without a predicate (most of them)&lt;/li&gt;
&lt;li&gt;Codegen tools that scan the schema for unique constraints. Partial indexes often get treated as not-a-constraint from the codegen's perspective.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The fix is rarely interesting. The trap is that the failure mode is silent in the most damaging way: the test suite passes, the local SQL queries work, the migration runs clean. The bug only fires on the first real client call, and the error message points at the constraint as if it were missing, not partial.&lt;/p&gt;

&lt;p&gt;If you've added a nullable column with meaningful semantics and the upserts go quiet, check whether you migrated the unique constraint into a partial index. Then pick one:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Drop down to delete + insert, like above&lt;/li&gt;
&lt;li&gt;Move the operation into a PL/pgSQL function and call it via Supabase RPC&lt;/li&gt;
&lt;li&gt;Petition PostgREST to support &lt;code&gt;ON CONFLICT ... WHERE&lt;/code&gt;. It's a known gap: &lt;a href="https://github.com/PostgREST/postgrest/issues/2123" rel="noopener noreferrer"&gt;PostgREST/postgrest#2123&lt;/a&gt; tracks index-predicate and exclusion-constraint upserts in core, and &lt;a href="https://github.com/supabase/postgrest-js/issues/403" rel="noopener noreferrer"&gt;postgrest-js#403&lt;/a&gt; mirrors it on the JS client. Both are still open.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Notes
&lt;/h2&gt;

&lt;p&gt;This post grew out of fixing a real production bug at &lt;a href="https://trynibble.app" rel="noopener noreferrer"&gt;Nibble&lt;/a&gt;, where the schema in question backs per-agency subscription preferences across 13 countries of recall data. The shipped fix has been in production since Feb 2026 with no observed race issues.&lt;/p&gt;

&lt;p&gt;If you've hit the same trap from a different direction (PostgREST + composite uniqueness + nullable columns), I'd be curious to hear it: github.com/Maliik-B or on X @Don_adijah.&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>supabase</category>
      <category>typescript</category>
      <category>database</category>
    </item>
  </channel>
</rss>
