<?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: saravana kumar</title>
    <description>The latest articles on DEV Community by saravana kumar (@saravanan_techx).</description>
    <link>https://dev.to/saravanan_techx</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%2F4241632%2F9b0e84ab-0828-4b9c-ba6f-c6a6ee6eba9a.jpg</url>
      <title>DEV Community: saravana kumar</title>
      <link>https://dev.to/saravanan_techx</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/saravanan_techx"/>
    <language>en</language>
    <item>
      <title>BigQuery MERGE Is Not a Uniqueness Constraint: A Replay-Safe Tutorial</title>
      <dc:creator>saravana kumar</dc:creator>
      <pubDate>Thu, 12 Nov 2026 19:17:18 +0000</pubDate>
      <link>https://dev.to/saravanan_techx/bigquery-merge-is-not-a-uniqueness-constraint-a-replay-safe-tutorial-32a4</link>
      <guid>https://dev.to/saravanan_techx/bigquery-merge-is-not-a-uniqueness-constraint-a-replay-safe-tutorial-32a4</guid>
      <description>&lt;p&gt;A &lt;code&gt;MERGE&lt;/code&gt; can be atomic and still leave duplicate business keys. The missing ingredient is a clear key and version contract.&lt;/p&gt;

&lt;p&gt;This tutorial builds a &lt;strong&gt;current-state&lt;/strong&gt; orders table, not an event-history table. One row represents one &lt;code&gt;order_id&lt;/code&gt;. It assumes a single writer and a source-provided, monotonically increasing integer version per order. Timestamps alone are not that contract.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the obvious MERGE fails
&lt;/h2&gt;

&lt;p&gt;If two source rows match one existing target row and an update is required, BigQuery can reject the merge. If the key is absent from the target, multiple source rows can instead be inserted: rows inserted by the statement are not matched again within that same statement. Deduplicate the source before merging.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Create an isolated, runnable example
&lt;/h2&gt;

&lt;p&gt;Run this GoogleSQL script in the BigQuery console. It uses temporary tables and does not modify your production tables.&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;TEMP&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;order_id&lt;/span&gt; &lt;span class="no"&gt;INT64&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="no"&gt;STRING&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;source_version&lt;/span&gt; &lt;span class="no"&gt;INT64&lt;/span&gt;
&lt;span class="p"&gt;)&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;orders&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;101&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'created'&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="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TEMP&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;incoming&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;order_id&lt;/span&gt; &lt;span class="no"&gt;INT64&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="no"&gt;STRING&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;source_version&lt;/span&gt; &lt;span class="no"&gt;INT64&lt;/span&gt;
&lt;span class="p"&gt;)&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;incoming&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;101&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'paid'&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="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;101&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'paid'&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;-- identical delivery retry&lt;/span&gt;
  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;102&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'created'&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="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;102&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'paid'&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="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;103&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'created'&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="p"&gt;;&lt;/span&gt;

&lt;span class="no"&gt;ASSERT&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="nb"&gt;COUNT&lt;/span&gt;&lt;span class="cm"&gt;(*) = 0 FROM incoming
        WHERE order_id IS NULL OR source_version IS NULL)
AS 'Null key or version: quarantine this batch';

-- A version must identify one state. Do not pick a random
-- winner when two payloads claim the same key/version.
ASSERT (
  SELECT COUNT(*)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;order_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;source_version&lt;/span&gt;
    &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;incoming&lt;/span&gt;
    &lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;order_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;source_version&lt;/span&gt;
    &lt;span class="k"&gt;HAVING&lt;/span&gt; &lt;span class="nb"&gt;COUNT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;DISTINCT&lt;/span&gt; &lt;span class="no"&gt;TO_JSON_STRING&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="no"&gt;STRUCT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;status&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="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
  &lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="s1"&gt;'Conflicting payloads for the same key/version'&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;TEMP&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;batch_latest&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;order_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;source_version&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;incoming&lt;/span&gt;
&lt;span class="no"&gt;QUALIFY&lt;/span&gt; &lt;span class="nb"&gt;ROW_NUMBER&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;OVER&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="k"&gt;PARTITION&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;order_id&lt;/span&gt; &lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;source_version&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;
&lt;span class="p"&gt;)&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;-- Ties are harmless here only because the assertion above&lt;/span&gt;
&lt;span class="c1"&gt;-- guarantees identical payloads at a given key/version.&lt;/span&gt;
&lt;span class="no"&gt;MERGE&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="no"&gt;T&lt;/span&gt;
&lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="n"&gt;batch_latest&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="no"&gt;S&lt;/span&gt;
&lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="no"&gt;T&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;order_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;S&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;order_id&lt;/span&gt;
&lt;span class="k"&gt;WHEN&lt;/span&gt; &lt;span class="no"&gt;MATCHED&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="no"&gt;S&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;source_version&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="no"&gt;T&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;source_version&lt;/span&gt; &lt;span class="k"&gt;THEN&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;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;S&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
             &lt;span class="n"&gt;source_version&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;S&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;source_version&lt;/span&gt;
&lt;span class="k"&gt;WHEN&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="no"&gt;MATCHED&lt;/span&gt; &lt;span class="k"&gt;THEN&lt;/span&gt;
  &lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;source_version&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="no"&gt;S&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;order_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;S&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;S&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;source_version&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;order_id&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Expected result by inspection:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;order_id&lt;/th&gt;
&lt;th&gt;status&lt;/th&gt;
&lt;th&gt;source_version&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;101&lt;/td&gt;
&lt;td&gt;paid&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;102&lt;/td&gt;
&lt;td&gt;paid&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;103&lt;/td&gt;
&lt;td&gt;created&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  2. Check replay and late arrival
&lt;/h2&gt;

&lt;p&gt;In the same script, repeat only the &lt;code&gt;MERGE&lt;/code&gt; statement against the same temporary tables. The result should stay unchanged: equal versions do not update and existing keys do not insert.&lt;/p&gt;

&lt;p&gt;Next, replace &lt;code&gt;incoming&lt;/code&gt; with &lt;code&gt;(101, 'created', 1)&lt;/code&gt;, rebuild &lt;code&gt;batch_latest&lt;/code&gt;, and merge again. Order 101 must remain &lt;code&gt;paid&lt;/code&gt; at version 2. A delayed older event cannot overwrite newer state.&lt;/p&gt;

&lt;p&gt;Finally, try two different statuses for the same key and version. The conflict assertion should stop the batch. In a real pipeline, persist those conflicting records to a quarantine table with enough metadata for investigation.&lt;/p&gt;

&lt;p&gt;The SQL is an illustrative example; it has not been executed against a BigQuery project for this article. Run it in your own development environment before adapting it.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Close the production gaps
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Existing target duplicates:&lt;/strong&gt; this merge does not repair them. Before adopting it, check &lt;code&gt;GROUP BY order_id HAVING COUNT(*) &amp;gt; 1&lt;/code&gt; and resolve duplicates using an authoritative version policy.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cross-batch conflicts:&lt;/strong&gt; also compare incoming equal-version payloads with the target. A different payload at an already stored version is a source-contract violation, not an ordinary retry. For wider rows, compare a canonical representation of all business fields.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Concurrency:&lt;/strong&gt; keep one writer for the table or serialize overlapping key ranges. This example is not a global uniqueness guarantee for independent concurrent writers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keys:&lt;/strong&gt; reject null keys. SQL null equality does not behave like ordinary equality. Composite keys need all their business-key columns in both the partition and join.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deletes:&lt;/strong&gt; define their semantics explicitly. Physically deleting the row also removes its version guard; an old replay can resurrect it. Retain a versioned deletion marker or a durable version ledger when that matters.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Partition filters:&lt;/strong&gt; do not narrow the target to recent ingestion dates if older rows with the same key can still exist. A missed match becomes an insert.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;BigQuery primary and foreign key constraints are not enforced, so declaring a primary key does not replace these checks.&lt;/p&gt;

&lt;h2&gt;
  
  
  A useful acceptance test
&lt;/h2&gt;

&lt;p&gt;Test a new key with duplicates, an existing key with multiple versions, an exact replay, an older replay, null keys, conflicting equal versions and two overlapping writers. Verify both row count and final state. “The job succeeded” is not a data-quality assertion.&lt;/p&gt;

&lt;p&gt;References: &lt;a href="https://docs.cloud.google.com/bigquery/docs/reference/standard-sql/dml-syntax#merge_statement" rel="noopener noreferrer"&gt;BigQuery MERGE semantics&lt;/a&gt; and &lt;a href="https://docs.cloud.google.com/bigquery/docs/primary-foreign-keys" rel="noopener noreferrer"&gt;BigQuery primary and foreign keys&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Saravana Kumar | TechX. Original tutorial generated by an AI assistant without human editing; example results are reasoned expectations, not a claim of production testing.&lt;/em&gt;&lt;/p&gt;

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