<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0" xmlns:cc="http://cyber.law.harvard.edu/rss/creativeCommonsRssModule.html">
    <channel>
        <title><![CDATA[Stories by Satadru on Medium]]></title>
        <description><![CDATA[Stories by Satadru on Medium]]></description>
        <link>https://medium.com/@satadru1998?source=rss-f1f3c866c4d5------2</link>
        <image>
            <url>https://cdn-images-1.medium.com/fit/c/150/150/0*8TE5fTX4BoyIw0y2</url>
            <title>Stories by Satadru on Medium</title>
            <link>https://medium.com/@satadru1998?source=rss-f1f3c866c4d5------2</link>
        </image>
        <generator>Medium</generator>
        <lastBuildDate>Tue, 10 Nov 2026 05:51:08 GMT</lastBuildDate>
        <atom:link href="https://medium.com/@satadru1998/feed" rel="self" type="application/rss+xml"/>
        <webMaster><![CDATA[yourfriends@medium.com]]></webMaster>
        <atom:link href="http://medium.superfeedr.com" rel="hub"/>
        <item>
            <title><![CDATA[Separation of Duties in Snowflake Tag Based Masking]]></title>
            <link>https://blog.dataengineerthings.org/separation-of-duties-in-snowflake-tag-based-masking-48738e26260e?source=rss-f1f3c866c4d5------2</link>
            <guid isPermaLink="false">https://medium.com/p/48738e26260e</guid>
            <category><![CDATA[data-engineering]]></category>
            <category><![CDATA[data-mesh]]></category>
            <category><![CDATA[snowflake]]></category>
            <category><![CDATA[data-stewardship]]></category>
            <category><![CDATA[data-governance]]></category>
            <dc:creator><![CDATA[Satadru]]></dc:creator>
            <pubDate>Tue, 03 Nov 2026 13:04:13 GMT</pubDate>
            <atom:updated>2026-11-03T19:14:44.438Z</atom:updated>
            <content:encoded><![CDATA[<p>At its core, Snowflake’s approach to data governance is about empowering customers with native capabilities to govern and protect data efficiently at scale i.e. following <a href="https://gdpr-info.eu/issues/privacy-by-design/"><strong><em>Privacy By Design</em></strong></a> since Day 1 using —</p><ul><li><a href="https://youtu.be/SmXfMsD6CMs?si=0yXEHP0f5FztGe-I"><strong><em>Dynamic Data Masking</em></strong></a></li><li><a href="https://youtu.be/hUNWmJG0OkY?si=njOnEH6nCRAprLtL"><strong><em>Row-level Security using Secure View</em></strong></a></li><li><a href="https://youtu.be/GjLW9Vo-0_A?si=eD6IqYcSXiuT97l4"><strong><em>Row Access Policies</em></strong></a><strong><em> </em></strong>etc.</li></ul><h4>Problem with Traditional Data Masking Approach:</h4><p>Let’s take a simple <strong>Column-Level Masking Policy</strong> as an example. The policy is applied directly to a specific column in a table and determines how the data is displayed based on the accessing user’s role. For instance, a column containing Social Security Numbers(SSNs) could be fully visible to an administrator while being partially masked for a support analyst.</p><p>While this works well at an individual table level, it becomes difficult to scale when the same policies need to be configured and maintained manually across hundreds or thousands of tables.</p><p><strong>The real goal should be to move from table-level configuration to centralized, data-driven governance.</strong> Governance teams should be able to define the rules once, classify sensitive data, and have the appropriate protection applied automatically across the entire data estate as data is created, ingested, and classified.</p><h4>Solution:</h4><p><strong>Tag-Based Masking addresses this scalability challenge by decoupling data protection policies from individual tables and columns.</strong> Instead of manually applying a masking policy to every column, governance teams can define a policy once and associate it with a tag. From that point onwards, whenever a column is assigned the corresponding tag, Snowflake automatically applies the designated masking policy.</p><p>For example, consider all columns across your Snowflake account that contain phone numbers. By classifying them with a tag such as PII = &#39;Phone Number&#39; and associating a masking policy with that tag, Snowflake automatically enforces the defined masking rules across all matching columns. This also applies to newly created columns as they get tagged, without requiring governance teams to configure the policy again for every table.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*imUhxuL1mw9EYjWN" /><figcaption><strong>Source</strong>: <a href="https://www.snowflake.com/en/blog/protect-sensitive-data-tag-based-masking/">Protect Sensitive Data Tag Based Masking</a></figcaption></figure><p><em>That’s what makes this approach scalable: define the governance rules once, classify data consistently, and let protection follow the data across the entire account </em><strong>—</strong></p><iframe src="https://cdn.embedly.com/widgets/media.html?src=https%3A%2F%2Fwww.youtube.com%2Fembed%2FFAburxMd91w%3Ffeature%3Doembed&amp;display_name=YouTube&amp;url=https%3A%2F%2Fwww.youtube.com%2Fwatch%3Fv%3DFAburxMd91w&amp;image=https%3A%2F%2Fi.ytimg.com%2Fvi%2FFAburxMd91w%2Fhqdefault.jpg&amp;type=text%2Fhtml&amp;schema=youtube" width="854" height="480" frameborder="0" scrolling="no"><a href="https://medium.com/media/b4167f0ab5c5d3f8100664f31e2bc14a/href">https://medium.com/media/b4167f0ab5c5d3f8100664f31e2bc14a/href</a></iframe><h4>Steps we used in implementing Tag Based Masking:</h4><p>The major steps involved in implementing Tag-Based Masking in Snowflake are:</p><ol><li><strong><em>Create Tags: </em></strong>Define tags to classify sensitive data, such as PII_EMAIL, PII_PHONE, PII_NAME, PII_ADDRESS, etc.</li><li><strong><em>Associate Masking Policies with Tags: </em></strong>Assign appropriate masking policies to each tag to define how sensitive data should be displayed. For example, completely mask the data (*****) or partially mask it by displaying only the first four characters/digits followed by asterisks (1234****).</li><li><strong><em>Apply Tags to Columns: </em></strong>Apply the relevant tags to table columns containing sensitive information. For example, PII_EMAIL to the EMAIL column, PII_PHONE to PHONE_NUMBER, and PII_NAME to EMPLOYEE_NAME.</li><li><strong><em>Generate Data:</em></strong> Create and populate tables with data containing confidential information, such as employee names, email addresses, phone numbers, and salaries.</li><li><strong><em>Securely Consume the Data: </em></strong>Users can view sensitive information only if they have the required permissions, while other users see masked values as configured in step 2</li></ol><h4>Separation of Duties:</h4><p><em>Separation of duties</em> is a design principle where more than one person’s approval or involvement is required to conclude a critical task, and it is an important part of the Well-Architected Framework —</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/700/0*94uHbP5tvgL-_4EZ" /></figure><p>As we move towards implementing <strong>Tag-Based Masking</strong> to make data privacy scalable across the Data Platform, maintaining a clear separation of duties becomes equally important.</p><p>For implementing this Data Governance Feature in Snowflake, we can split the responsibilities across different roles so that no single team has complete control over the entire process.</p><p>Let’s understand the roles first which gets involved in different steps in this implementation &amp; then in the next step, we will understand their responsibilities —</p><ul><li><strong><em>Data Governance Committee or</em></strong> <strong><em>Council: </em></strong>The Data Governance Council is a cross-functional group of senior stakeholders responsible for defining the organization&#39;s data governance strategy, establishing key principles, and ensuring their effective implementation across the organization. Typically this Council include representatives typically from 1–2 levels below the C-suite member.</li></ul><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*Fw5pHVM6SSQeZFN1.png" /><figcaption><strong>Source</strong>: <a href="https://www.collibra.com/blog/data-governance-council-what-is-it-and-why-do-you-need-nne">Data governance council</a></figcaption></figure><blockquote><em>Note: </em>Nothing astonishing, but now a days Data Governance a <strong><em>C-Suite Priority</em></strong>, in a well organized team, we have Chief Data Officer role reporting to the CEO &amp; a cross-functional Data Governance Council — with integrated governance metrics into corporate scorecards and executive evaluations and allocate a dedicated budget as a strategic investment</blockquote><ul><li><strong><em>Data Steward:</em></strong> Let me explain the importance of a Data Steward through a simple scenario. Imagine you are working on a regulatory report and need a specific piece of data. You search for it but cannot find a proper definition or any information about who owns it. You start reaching out to different people across the organization, hoping someone can help. One person directs you to another, who then suggests contacting someone else. Nobody seems sure about the meaning of the data, its quality, or who is responsible for it. After spending days trying to get an answer, you may eventually use whatever information is available and submit the report without being fully confident in its accuracy. This situation highlights why every important data domain needs a clearly identified person who takes responsibility for its definition, quality, and proper use. This person is known as a <strong><em>Data Steward</em></strong>. A Data Steward acts as a point of contact for business users, helps them understand the data, clarifies its meaning, and works with relevant teams to maintain its quality and consistency.</li></ul><figure><img alt="" src="https://cdn-images-1.medium.com/max/438/1*c2y8lwN1xIlcbg4fxkOOcA.png" /><figcaption><strong>Source</strong>: <a href="https://soda.io/blog/what-is-data-stewardship">What is Data Stewardship?</a></figcaption></figure><ul><li><strong><em>Platform Enablement Team:</em></strong> Let’s first understand how platform teams differ from other technical teams. Application development teams build products and internal applications for business units. Platform teams, on the other hand, build and maintain foundational platforms that these teams can use to develop and deliver their products for example, standard process for reusable CI/CD, Cloud Infrastructure Setup using Terraform etc. or defining the way-of-working. In simple terms, <strong>Application development teams are the customers of platform teams</strong>. Unlike application development teams, platform teams are typically centralized and support multiple Application development teams across different business units.</li><li><strong>Data Producer: </strong>They produce the data kind off like <a href="https://youtu.be/igkVJi0CZag?si=7cpsZ0yUzhOYiXh6"><strong><em>Data Product Developer</em></strong></a>, typically <strong>Data Engineers</strong> or <strong>Analytics Engineer</strong>.</li><li><strong>Data Consumer:</strong> A data consumer is anyone who uses the data to perform their work or make decisions. This could include business users, dashboard developers, ML engineers, and other data users —</li></ul><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*cvmgiPO0R1M_NXrn.png" /><figcaption>Source: <a href="https://aws.amazon.com/blogs/industries/how-to-create-a-modern-cpg-data-architecture-with-data-mesh/">How to Create a Modern CPG Data Architecture with Data Mesh</a></figcaption></figure><h4>Responsibility Segregation:</h4><p>Now, let’s look at how the different steps involved in Tag-Based Masking can be divided among the roles discussed above —</p><ul><li><strong>Define the Tag Taxonomy: </strong>Before implementing Tag-Based Masking, the organisation must decide which tags are required to classify data and what values each tag can have. For example, a sensitivity tag could have values such as public, internal, confidential, and restricted, while a pii tag could have values such as email, phone_number, and ssn. This step should be owned by the <strong><em>Data Governance Council</em></strong>, as it requires organization-wide agreement on data classification standards. Having the Council define these tags and allowed values ensures consistency across teams and prevents different teams from introducing their own naming conventions or classifications.</li><li><strong>Identify Data Assets to be tagged:</strong> Once the tag taxonomy is defined, the next step is to identify which tags should be applied to which data assets. This includes specifying the tables and columns that need to be tagged and the appropriate tag values. The <strong><em>Data Steward</em></strong> plays an important role here by understanding the data and identifying, for example, that a particular column contains an SSN and should therefore be tagged as pii : ssn. The steward can also provide the necessary context on how sensitive data should be handled, such as whether the column should be masked and what level of masking is appropriate like full masking or partial masking etc.</li><li><strong>Creating Masking Policy: </strong>Platform Enablement Team can step in this step to create various masking policies &amp; adding them in Tags like —</li></ul><pre>CREATE MASKING POLICY account_name_mask_tag_string<br>AS (val string) RETURNS string -&gt;<br>  CASE<br> WHEN SYSTEM$GET_TAG_ON_CURRENT_COLUMN(&#39;tags.accounting_col_string&#39;) = &#39;visible&#39; THEN val<br> ELSE &#39;***MASKED***&#39;<br>  END;<br><br>CREATE MASKING POLICY account_number_mask_tag_string<br>AS (val number) RETURNS number -&gt;<br>  CASE<br> WHEN SYSTEM$GET_TAG_ON_CURRENT_COLUMN(&#39;tags.accounting_col_string&#39;) = &#39;visible&#39; THEN val<br> ELSE -1<br>  END;<br><br>ALTER TAG pii_classifier SET<br>  MASKING POLICY string_mask,<br>  MASKING POLICY number_mask;</pre><ul><li><strong>Applying Tags:</strong> The Data Product Developer, based on the inputs and guidance provided by the Data Steward, can identify the relevant columns and apply the required tags using simple ALTER commands —</li></ul><pre>ALTER TABLE DEMO_DB.PUBLIC.customer_details <br>    MODIFY COLUMN credit_score SET TAG pii_classifier = &#39;pii_number&#39;;</pre><figure><img alt="" src="https://cdn-images-1.medium.com/max/897/1*ZhN6rq13Wf2kc2UyqELVBQ.jpeg" /></figure><p>This way the important steps for Tag Based Masking can be segregated into different roles —</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*6aVAoO8jpGbMW0ti49tNRg.png" /></figure><p>That’s all for this blog! For more such <strong>interesting and practical content</strong>, follow me on <a href="https://www.youtube.com/@KnowledgeAmplifier1"><strong><em>YouTube</em></strong></a><strong>, </strong><a href="https://www.linkedin.com/in/satadru-mukherjee-a237b41a5/"><strong><em>LinkedIn</em></strong></a><strong>, </strong><a href="https://medium.com/@satadru1998"><strong>Medium</strong></a><strong> &amp; </strong><a href="https://www.instagram.com/knowledgeamplifier/"><strong><em>Instagram</em></strong></a>!</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=48738e26260e" width="1" height="1" alt=""><hr><p><a href="https://blog.dataengineerthings.org/separation-of-duties-in-snowflake-tag-based-masking-48738e26260e">Separation of Duties in Snowflake Tag Based Masking</a> was originally published in <a href="https://blog.dataengineerthings.org">Data Engineer Things</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Celebrating Durga Puja in Kolkata After 2 Long Years❤️]]></title>
            <link>https://medium.com/@satadru1998/celebrating-durga-puja-in-kolkata-after-2-long-years-%EF%B8%8F-57ea6468f4a8?source=rss-f1f3c866c4d5------2</link>
            <guid isPermaLink="false">https://medium.com/p/57ea6468f4a8</guid>
            <category><![CDATA[indian-festival]]></category>
            <category><![CDATA[west-bengal]]></category>
            <category><![CDATA[durga-puja]]></category>
            <category><![CDATA[kolkata]]></category>
            <category><![CDATA[navaratri]]></category>
            <dc:creator><![CDATA[Satadru]]></dc:creator>
            <pubDate>Sun, 25 Oct 2026 06:03:55 GMT</pubDate>
            <atom:updated>2026-10-30T04:44:35.468Z</atom:updated>
            <content:encoded><![CDATA[<p><strong><em>Disclaimer:</em></strong><em> This isn’t going to be another tech blog! This time, I’m taking a break from code and data to share my experience of celebrating Durga Puja in Kolkata in 2026! I’ll also be sharing plenty of videos throughout the blog because the energy, emotions, and vibe of Durga Puja in Kolkata are hard to capture in words alone. Some experiences are better seen and felt than explained!</em></p><p>I’ve been working in Pune, India since April 2024, and unfortunately, I missed celebrating Durga Puja in Kolkata in both 2024 &amp; 2025. Work commitments made it difficult to take enough time off and travel home. My situation was pretty much like this —</p><iframe src="https://cdn.embedly.com/widgets/media.html?src=https%3A%2F%2Fwww.youtube.com%2Fembed%2Fj7oNkwWgyr0%3Ffeature%3Doembed&amp;display_name=YouTube&amp;url=https%3A%2F%2Fwww.youtube.com%2Fshorts%2Fj7oNkwWgyr0%3Fsi%3Dk5uFsfQJ3hZDk9R7&amp;image=https%3A%2F%2Fi.ytimg.com%2Fvi%2Fj7oNkwWgyr0%2Fhq2.jpg&amp;type=text%2Fhtml&amp;schema=youtube" width="640" height="480" frameborder="0" scrolling="no"><a href="https://medium.com/media/5366019a34737de69bdb3588578f0a05/href">https://medium.com/media/5366019a34737de69bdb3588578f0a05/href</a></iframe><p>It was only Pune’s <strong><em>Khadki Kali Bari</em></strong> , which was my little escape during Durga Puja. Whenever I missed the celebrations back home in Kolkata, I would visit the pandal to catch a glimpse of Ma Durga, soak in whatever festive vibes I could, and find a little peace of mind. ❤️</p><p>It wasn’t Kolkata, of course, but it gave me a small sense of connection to the celebrations I was missing back home —</p><iframe src="https://cdn.embedly.com/widgets/media.html?src=https%3A%2F%2Fwww.youtube.com%2Fembed%2F6M9s9x7vMSk%3Ffeature%3Doembed&amp;display_name=YouTube&amp;url=https%3A%2F%2Fwww.youtube.com%2Fshorts%2F6M9s9x7vMSk%3Fsi%3D6LBpKIEP7M8SycIa&amp;image=https%3A%2F%2Fi.ytimg.com%2Fvi%2F6M9s9x7vMSk%2Fhq2.jpg&amp;type=text%2Fhtml&amp;schema=youtube" width="640" height="480" frameborder="0" scrolling="no"><a href="https://medium.com/media/2ac98d405bd92c2d6cf67a41ee04a0c0/href">https://medium.com/media/2ac98d405bd92c2d6cf67a41ee04a0c0/href</a></iframe><p>Even this year, I wasn’t really planning to travel to Kolkata for Durga Puja, mainly because of the workload and all the PI-related activities at work. But then, one day, I paused and asked myself a simple question: <strong><em>What’s the point of working so hard if I can’t even make time to celebrate the festivals that matter to me with my family?</em></strong></p><p>That thought was enough. Without overthinking it any further, I went ahead and booked my flight tickets from Pune to Kolkata✈️❤️</p><p>4th October I booked the flight —</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/897/1*hnBbX1tfFmZYisxwVmLUsA.png" /></figure><p>And on 5th October, I had the flight —</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/861/1*7UQsIUspJQaFp35Uq_d5gw.png" /></figure><p>Sometimes, you just need to remind yourself that work will always be there, but moments with family and the joy of celebrating together are something else entirely!</p><p>And ya, I never miss to have Geela bhel from Ganesh Bhel in Pune Airport —</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*Qb2BtLbOzCXouO4y0FFnKw.jpeg" /></figure><h3>Family Surprise:</h3><p>I landed in Kolkata at around 2:25 AM, and the best part? Neither my mom nor my dad had any idea that I was coming home! 😄</p><p>By 3:05 AM, I had reached home from the airport via Rapido. I called my mom and casually asked, “<strong><em>Hello, Ma, have you gone to sleep?</em></strong>” Hearing me call at such an unusual hour, she sounded worried and asked if everything was okay with me. I could sense the tension in her voice, so I simply said, “<strong><em>Ma, can you open the door?</em></strong>”</p><p>And then came the moment I’ll never forget! ❤️</p><p>The moment she realised I was standing outside, she was literally shaking with excitement, shouting, “You came home! You came home!” 😂❤️ My dad woke up too, and together they rushed to open the door.</p><p>Seeing their faces, their happiness, and almost-tears-of-joy expressions made the entire journey worth it. There is something truly special about surprising your parents like this. No grand plans, no announcements — just showing up at their doorstep and watching their faces light up just before the festival.</p><p><strong>That moment alone was worth every bit of the effort it took to come home</strong>❤️</p><h3>Mahalaya:</h3><p>I had planned to celebrate <strong>Mahalaya</strong> in our village in Burdwan with my grandma. Mahalaya holds a special place in Bengali culture as it marks the end of <em>Pitri Paksha</em>, the fortnight dedicated to remembering our ancestors, and the beginning of <em>Devi Paksha</em>, heralding the arrival of Maa Durga. For me, celebrating this special occasion with my grandma in our village would make it even more meaningful. And guess what? I had another surprise planned!!</p><p>My father had called my grandma beforehand and told her that he would be visiting, so she could prepare lunch for one extra person. But little did she know that I would be going instead of my father!</p><p>When I showed up, her happiness was priceless. She was just as excited as my parents had been when I surprised them at home —</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/782/1*mj6ZGuYjSpN8h34J0C-zDg.jpeg" /></figure><p>Sometimes, the best gifts aren’t things you buy — they’re the unexpected moments you get to spend with the people you love.</p><p>Here is a glimpse of my Mahalaya Journey —</p><iframe src="https://cdn.embedly.com/widgets/media.html?src=https%3A%2F%2Fwww.youtube.com%2Fembed%2F9QxJTGxU9YQ%3Ffeature%3Doembed&amp;display_name=YouTube&amp;url=https%3A%2F%2Fwww.youtube.com%2Fshorts%2F9QxJTGxU9YQ%3Fsi%3DTYYYjGutDMmcAiUI&amp;image=https%3A%2F%2Fi.ytimg.com%2Fvi%2F9QxJTGxU9YQ%2Fhq2.jpg&amp;type=text%2Fhtml&amp;schema=youtube" width="640" height="480" frameborder="0" scrolling="no"><a href="https://medium.com/media/8abec8bb80cb90b8110e659c3f1d41f2/href">https://medium.com/media/8abec8bb80cb90b8110e659c3f1d41f2/href</a></iframe><p>Meanwhile, across West Bengal, the excitement was building as Maa Durga began her journey from Kumartuli, i.e. potters’ locality, to puja committees, societies, and pandals across the state. The countdown to the biggest festival of the year had officially begun! 🙏</p><iframe src="https://cdn.embedly.com/widgets/media.html?src=https%3A%2F%2Fwww.youtube.com%2Fembed%2F7s6tmzQb-cc%3Ffeature%3Doembed&amp;display_name=YouTube&amp;url=https%3A%2F%2Fwww.youtube.com%2Fshorts%2F7s6tmzQb-cc%3Fsi%3DDm4tlYrPAlUC9Kd3&amp;image=https%3A%2F%2Fi.ytimg.com%2Fvi%2F7s6tmzQb-cc%2Fhq2.jpg&amp;type=text%2Fhtml&amp;schema=youtube" width="640" height="480" frameborder="0" scrolling="no"><a href="https://medium.com/media/fe67c9f95edb67225810efa4375f53ef/href">https://medium.com/media/fe67c9f95edb67225810efa4375f53ef/href</a></iframe><h3><strong>Maha Panchami:</strong></h3><p>While Mahalaya marks the formal invitation to the Goddess Durga, the actual cultural celebration, pandal hopping, and public festivities in Bengal kick off in full swing on Maha Panchami.</p><p>The pandal marks the 150th anniversary of the national song <em>Vande Mataram</em>, written by Bankim Chandra Chattopadhyay in 1875 to inspire the freedom movement —</p><iframe src="https://cdn.embedly.com/widgets/media.html?src=https%3A%2F%2Fwww.youtube.com%2Fembed%2FiwxV-UBJ23I%3Ffeature%3Doembed&amp;display_name=YouTube&amp;url=https%3A%2F%2Fwww.youtube.com%2Fwatch%3Fv%3DiwxV-UBJ23I&amp;image=https%3A%2F%2Fi.ytimg.com%2Fvi%2FiwxV-UBJ23I%2Fhqdefault.jpg&amp;type=text%2Fhtml&amp;schema=youtube" width="854" height="480" frameborder="0" scrolling="no"><a href="https://medium.com/media/0df1e653f2658c1b86c0925b21e2a736/href">https://medium.com/media/0df1e653f2658c1b86c0925b21e2a736/href</a></iframe><p>Also we visited Muhammad Ali Park &amp; College Square Durga Puja which is famous for its massive, sparkling central jhumar —</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*zsMCJonBVAQKp3Xk8N9FUg.jpeg" /></figure><p>After a long time, I ate <strong>Bhel Puri</strong> &amp; <strong>Patty </strong>with my mom there :-)</p><p>By Panchami, Maa Durga had arrived at our society pandal too🙏</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*0VrY25G_KCFNVA9KdUyHgg.jpeg" /></figure><h3>Maha Shashti:</h3><p>Our Association shared the Puja and Pushpanjali schedule in advance, which helped us plan each day better and make the most of the celebrations —</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*qvJKoYQbW81dl3mBeCVmaw.png" /></figure><p>I started the day with a 6 km run(couldn’t skip my daily dose of fitness! 🏃‍♂️) —</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*mxF4_l38Ly2jdEPdiUxtzg.jpeg" /></figure><p>After getting back and taking a bath, I headed straight to the Durga Puja pandal to offer my first <em>Pushpanjali</em> of the year —</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*xPjuK9r88JEUzdkZKRyWXQ.jpeg" /></figure><p>There’s a different kind of peace in standing before Maa Durga, offering flowers with folded hands, and feeling the festive energy all around you. The wait was finally over — Durga Puja was here! 🙏❤️</p><p>At noon, I went with my family to <a href="https://en.wikipedia.org/wiki/Shobhabazar_Rajbari"><strong><em>Sovabazar Rajbari</em></strong></a>(one of Kolkata’s oldest and most prestigious traditional aristocratic family celebrations) to have the traditional Durga Puja <em>bhog prasad —</em></p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*lqdEAOPs3-4VHpZUAY-rCw.png" /></figure><p>Here is a glimpse of the same —</p><iframe src="https://cdn.embedly.com/widgets/media.html?src=https%3A%2F%2Fwww.youtube.com%2Fembed%2Fwzj2T0QNHCs%3Ffeature%3Doembed&amp;display_name=YouTube&amp;url=https%3A%2F%2Fwww.youtube.com%2Fwatch%3Fv%3Dwzj2T0QNHCs&amp;image=https%3A%2F%2Fi.ytimg.com%2Fvi%2Fwzj2T0QNHCs%2Fhqdefault.jpg&amp;type=text%2Fhtml&amp;schema=youtube" width="854" height="480" frameborder="0" scrolling="no"><a href="https://medium.com/media/ac197357cee867183981e742f185b988/href">https://medium.com/media/ac197357cee867183981e742f185b988/href</a></iframe><p>At evening, I enjoyed a wonderful lineup of cultural performances, including dancing, singing, and <em>Shruti Natak</em>(a form of audio drama).</p><p>And to wrap up the day? A large cup of tea and some pizza! ☕🍕</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*s2DfzYbsl3dTvMq0leoXmw.jpeg" /></figure><h3>Maha Saptami:</h3><p>Started the day with a 7 KM run as it was Saptami(7th Day of Durga Puja) —</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*yVA7taML9ClNpT6DS2ynDQ.jpeg" /></figure><p>After taking a bath and putting on a new <em>Panjabi</em>, I headed to the puja mandap to witness Maa Durga’s beautiful <em>Aarti</em> and offer my <em>Pushpanjali. </em>There’s something special about dressing up in traditional attire and beginning the day with Maa’s blessings — a feeling that’s hard to put into words —</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*3VniFPVFaW3C-0FHc4FzgA.jpeg" /><figcaption>Maha Saptami Evening</figcaption></figure><p>Something special happened after the <em>Pushpanjali</em>! ❤️ One of the puja committee members approached me and asked if I could help distribute <em>khichuri bhog</em> to around 280 people. I was more than happy to help!</p><p>Along with the other volunteers, I joined in distributing the bhog to everyone. It felt wonderful to be part of something so meaningful — not just celebrating the festival, but also serving others and sharing the blessings of Maa Durga —</p><iframe src="https://cdn.embedly.com/widgets/media.html?src=https%3A%2F%2Fwww.youtube.com%2Fembed%2FpwClz1krBJA%3Ffeature%3Doembed&amp;display_name=YouTube&amp;url=https%3A%2F%2Fwww.youtube.com%2Fshorts%2FpwClz1krBJA%3Fsi%3DLzqPB6vAsEGyksNr&amp;image=https%3A%2F%2Fi.ytimg.com%2Fvi%2FpwClz1krBJA%2Fhq2.jpg&amp;type=text%2Fhtml&amp;schema=youtube" width="640" height="480" frameborder="0" scrolling="no"><a href="https://medium.com/media/b8cbf04ac1bfdce62579c31da519e546/href">https://medium.com/media/b8cbf04ac1bfdce62579c31da519e546/href</a></iframe><p>In the evening, my youngest maternal aunt(<em>Mashi</em>) came over with her family, making the festive evening even more special —</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*fF9-ZRftVp2P4J4NkfLF0g.jpeg" /></figure><p>We wrapped up the day with some comfort food, ordering momos from Wow Momo, pizza &amp; Garlic Bread from Domino’s —</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*8XTEtqjjbWSnX8-dPhACqw.jpeg" /></figure><h3>Maha Ashtami:</h3><p><strong>Maha Ashtami is one of the most significant days of Durga Puja</strong>, when devotees worship Maa Durga in her powerful and fierce form. ❤️🙏</p><p>And this year, we had an extra reason to celebrate! With Maha Ashtami festivities spread across two days as part of an 11-day Navaratri calendar, the celebrations lasted a little longer, giving us even more time to soak in the festive spirit and enjoy every moment with our loved ones.</p><p>As usual, started my day with 8 KM run as it was 8th day of Durga Puja —</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*rSRqjjPVs7R7UpnM1cFszQ.jpeg" /></figure><p>Day was going pretty much same, after taking a bath and putting on a new <em>Panjabi</em>, I headed to the puja mandap to witness Maa Durga’s beautiful <em>Aarti</em> and offer my <em>Pushpanjali —</em></p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*ZuWWrovNU-f7Rj6JbTOULQ.jpeg" /></figure><p>The best part of the day was that my <em>Chotodidi</em> came over to celebrate Durga Ashtami with us! She joined us for lunch and had the <em>bhog prasad</em> at our society pandal, making the occasion even more special —</p><iframe src="https://cdn.embedly.com/widgets/media.html?src=https%3A%2F%2Fwww.youtube.com%2Fembed%2FBk5Z04Gok9c%3Ffeature%3Doembed&amp;display_name=YouTube&amp;url=https%3A%2F%2Fwww.youtube.com%2Fwatch%3Fv%3DBk5Z04Gok9c&amp;image=https%3A%2F%2Fi.ytimg.com%2Fvi%2FBk5Z04Gok9c%2Fhqdefault.jpg&amp;type=text%2Fhtml&amp;schema=youtube" width="854" height="480" frameborder="0" scrolling="no"><a href="https://medium.com/media/0c1a71bc1f9f3fb55b4e40fa9baf1ccd/href">https://medium.com/media/0c1a71bc1f9f3fb55b4e40fa9baf1ccd/href</a></iframe><p>She also brought along a book that my <em>Mama</em>(her son), who lives in California, had sent for me —</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*54HQYi8f70APJQNxxR4zaQ.jpeg" /></figure><h3><strong>Sandhi pujo:</strong></h3><p>Sandhi Pujo is the most sacred and spiritually powerful ritual of Durga Puja, performed during the exact 48-minute transition window between Maha Ashtami and Maha Navami.</p><p>During this juncture or the ‘Sandhikhan’ , Durga is worshipped in her Chamunda form. Devi Durga killed, Chando and Mundo, the two asuras at ‘Sandhikhan’ and thus acquired the name of ‘Chamunda’ at this point in time.</p><p>As officially this day also falling under Ashtami, so I had to run 8 KM, but this day, instead of running on treadmill, I ran in our society &amp; here are my Strava stats —</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/864/1*oIxEKn1oY566FmDtJ_ti2Q.jpeg" /></figure><p>After freshening up, I headed back to the puja mandap to offer my <em>Sandhi Puja Anjali</em>. 🙏❤️ Offering anjali during this auspicious moment made the day even more special.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*3sthDC5Cuf1J7sQVz_H_ng.jpeg" /></figure><p>After the puja, I joined the others in distributing <em>bhog prasad</em> to the devotees —</p><iframe src="https://cdn.embedly.com/widgets/media.html?src=https%3A%2F%2Fwww.youtube.com%2Fembed%2FdlqK2UuLaJQ%3Ffeature%3Doembed&amp;display_name=YouTube&amp;url=https%3A%2F%2Fwww.youtube.com%2Fshorts%2FdlqK2UuLaJQ%3Fsi%3D8WZH4_kViOHhwhHU&amp;image=https%3A%2F%2Fi.ytimg.com%2Fvi%2FdlqK2UuLaJQ%2Fhq2.jpg&amp;type=text%2Fhtml&amp;schema=youtube" width="640" height="480" frameborder="0" scrolling="no"><a href="https://medium.com/media/fa2fbd48b27d4942e2be205a527bd771/href">https://medium.com/media/fa2fbd48b27d4942e2be205a527bd771/href</a></iframe><p>In the evening, we visited our <em>Chotodidi’s</em> home, where we spent some lovely time together, catching up, sharing conversations, and enjoying some delicious food —</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*U1BhfSIFTPYYljkwLGWhcA.jpeg" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*YR3BXU9vWs9TZ4uGAnmPwA.jpeg" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*OwA9inYJOAfuqsolE4AETw.jpeg" /></figure><h3>Maha Navami</h3><p>Though on Maha Navami, the beats of the dhak still bring happiness, <strong>but</strong> somewhere in our hearts, we know there’s <strong>only one day</strong> left :-/</p><p>As usual, my day started with 9 KM run as it was the ninth day of Navaratri —</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/864/1*hcXrP3m1jwGhNju6sJNfnA.jpeg" /></figure><p>After the morning run, I took bath &amp; went to mandap for <strong>Pushpanjali &amp; Aroti </strong>&amp;<strong> to distribute bhog..</strong></p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*LgKjdUUi12DfL7SK5lR6KQ.jpeg" /></figure><p><strong>Navami havan</strong> is a central ritual — Ghee, wood, grains, and sweet offerings (<em>bhog</em>) are fed into the sacred fire while chanting Vedic and Puranic mantras. Devotees use <strong>bilva(bel) leaves</strong> — typically a 108 leaves for Goddess Durga —</p><iframe src="https://cdn.embedly.com/widgets/media.html?src=https%3A%2F%2Fwww.youtube.com%2Fembed%2FgR59vf6SGdc%3Ffeature%3Doembed&amp;display_name=YouTube&amp;url=https%3A%2F%2Fwww.youtube.com%2Fwatch%3Fv%3DgR59vf6SGdc&amp;image=https%3A%2F%2Fi.ytimg.com%2Fvi%2FgR59vf6SGdc%2Fhqdefault.jpg&amp;type=text%2Fhtml&amp;schema=youtube" width="854" height="480" frameborder="0" scrolling="no"><a href="https://medium.com/media/a4961803d90831c92672156534603520/href">https://medium.com/media/a4961803d90831c92672156534603520/href</a></iframe><p>In the evening, we had an open-floor dance session in our society, when Bollywood’s iconic Durandar entry meets the Garba circle🔥</p><iframe src="https://cdn.embedly.com/widgets/media.html?src=https%3A%2F%2Fwww.youtube.com%2Fembed%2FEM05eTb9RjE%3Ffeature%3Doembed&amp;display_name=YouTube&amp;url=https%3A%2F%2Fwww.youtube.com%2Fshorts%2FEM05eTb9RjE%3Ffeature%3Dshare&amp;image=https%3A%2F%2Fi.ytimg.com%2Fvi%2FEM05eTb9RjE%2Fhq2.jpg&amp;type=text%2Fhtml&amp;schema=youtube" width="640" height="480" frameborder="0" scrolling="no"><a href="https://medium.com/media/38b6395d41c6e012c8052dddc27cbf2f/href">https://medium.com/media/38b6395d41c6e012c8052dddc27cbf2f/href</a></iframe><h3>Bijoya Dashami</h3><p>The heavy, bittersweet feeling of sadness on Bijoya Dashami is a shared emotion for every Bengali as Maa Durga leaves her maternal home to return to Kailash.</p><p>Started my day with 10 KM run because it’s 10th day —</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/864/1*56M31OUSujol56o1U8tJyQ.jpeg" /></figure><p>After Pushpanjali, Ma’s Bisorjon began, followed by <strong><em>Mayer Boron</em></strong>(Devi Boron), the traditional farewell ritual where we bid Ma Durga goodbye on Bijoya Dashami —</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/995/1*OnXdBV_PsqKf32z1tTNYqg.png" /></figure><p>Then there was <strong>Sindur Khela</strong>, where married women celebrate by applying sindur to one another, marking the end of the festivities with joy, emotions, and prayers for Ma’s return next year —</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*eVr1MIFMjEl65Ci3S8JgJA.jpeg" /></figure><h3>Conclusion:</h3><p>In the evening, we went to a nearby pond to witness Ma Durga’s Protima Niranjan —</p><iframe src="https://cdn.embedly.com/widgets/media.html?src=https%3A%2F%2Fwww.youtube.com%2Fembed%2FOANIKwvd79w%3Ffeature%3Doembed&amp;display_name=YouTube&amp;url=https%3A%2F%2Fwww.youtube.com%2Fshorts%2FOANIKwvd79w&amp;image=https%3A%2F%2Fi.ytimg.com%2Fvi%2FOANIKwvd79w%2Fhq2.jpg&amp;type=text%2Fhtml&amp;schema=youtube" width="640" height="480" frameborder="0" scrolling="no"><a href="https://medium.com/media/36a3cea566de4eb483fb775be333d578/href">https://medium.com/media/36a3cea566de4eb483fb775be333d578/href</a></iframe><p>The local municipality had arranged a dedicated spot for the immersion, with plenty of police personnel and volunteers managing the crowd. All the immersions were taking place at a single designated location to help prevent water pollution at multiple sites.</p><p>And then we had <em>sweet-sharing custom</em>, where the sadness of the Goddess’s departure is countered by spreading sweetness, joy, and goodwill among family, friends, and the community.</p><p>The next day, with Navaratri finally over, I enjoyed fish again after many days of staying away from non-vegetarian food!</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*SwS1MttK8cyoCHaFdYkWqQ.jpeg" /></figure><p>And with this, the biggest celebration of the year came to an end. We returned to our daily routines —</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*gUdul-LlN0q5UgnuTakJ_w.jpeg" /></figure><p>But we were carrying all the beautiful memories with us and already started counting the days until Ma Durga returns next year! ❤️</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*IPC15-gilQmiEg_D-uEoXw.jpeg" /></figure><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=57ea6468f4a8" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[When you should prefer INSERT INTO over MERGE INTO!]]></title>
            <link>https://blog.dataengineerthings.org/when-you-should-prefer-insert-into-over-merge-into-7f4242a300a5?source=rss-f1f3c866c4d5------2</link>
            <guid isPermaLink="false">https://medium.com/p/7f4242a300a5</guid>
            <category><![CDATA[databricks]]></category>
            <category><![CDATA[data-engineering]]></category>
            <category><![CDATA[query-optimization]]></category>
            <category><![CDATA[sql]]></category>
            <category><![CDATA[broadcast-join]]></category>
            <dc:creator><![CDATA[Satadru]]></dc:creator>
            <pubDate>Mon, 12 Oct 2026 18:50:12 GMT</pubDate>
            <atom:updated>2026-10-13T16:18:13.408Z</atom:updated>
            <content:encoded><![CDATA[<p>One of the common challenges in any data platform is keeping warehouse tables aligned with changes happening in source systems. Customer details get updated, orders change over time, and accounts may become inactive or get deleted. The challenge is to reflect these changes in the target table without losing consistency or creating duplicate records.</p><p>For this kind of incremental data processing, MERGE INTO is often the first option that comes to mind.</p><p>MERGE INTO statement combines data from one or more source tables with a target table, allowing you to perform updates and inserts in a single operation. Based on conditions you define, it determines whether to update existing rows or insert new ones in the target table. This makes it more efficient than using separate INSERT, UPDATE, and DELETE statements.</p><p>Depending on the join conditions, the engine may choose different join strategies behind the scenes. I’ve covered this in detail in <a href="https://medium.com/data-engineer-things/the-hidden-joins-behind-a-snowflake-merge-command-dcafef86d096?sharedUserId=satadru1998"><strong><em>this blog</em></strong></a>. While the examples focus on <a href="https://www.snowflake.com/en/"><strong><em>Snowflake</em></strong></a>, the underlying concepts are relevant to <a href="https://www.databricks.com/"><strong><em>Databricks</em></strong></a> as well.</p><p>In this blog, we’ll look at a specific scenario where a custom INSERT INTO statement can be preferable to a MERGE INTO statement in Databricks: <strong>insert-only merges</strong>.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*11WtO0Ox7wGJFAcw79vI6A.png" /></figure><h3>Let’s dig in:</h3><p>We’ll start with two tables: a large target table and a relatively small source table. This reflects a common MERGE INTO scenario, where the target table is significantly larger than the source —</p><pre>-- Large target: 1 BILLION rows<br>CREATE OR REPLACE TABLE large_target_benchmark<br>USING DELTA<br>AS<br>SELECT<br>    id AS transaction_id,<br>    MOD(id, 10000000) AS customer_id,<br>    RAND() * 1000 AS amount,<br>    CURRENT_DATE() AS created_date<br>FROM RANGE(1000000000);<br><br><br>-- Small source: 100K rows<br>-- First 50K will match existing records<br>-- Next 50K will be new records<br>CREATE OR REPLACE TABLE small_source_benchmark<br>USING DELTA<br>AS<br>SELECT<br>    id AS transaction_id,<br>    MOD(id, 10000000) AS customer_id,<br>    RAND() * 5000 AS amount,<br>    CURRENT_DATE() AS created_date<br>FROM RANGE(999950000, 1000050000);</pre><p>Now we are going to execute a simple insert-only merge statement in it —</p><pre>MERGE INTO large_target_benchmark AS tgt<br>USING small_source_benchmark AS src<br>ON tgt.transaction_id = src.transaction_id<br>WHEN NOT MATCHED THEN<br>    INSERT *;</pre><p>Before checking the query profile, let’s recall how an insert-only MERGE INTO works — it performs a <strong>Left Anti Join</strong>, with the source table on the left and the target table on the right, to identify rows that exist in the source but have no matching records in the target. These are the rows that need to be inserted —</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1015/1*8xNrmm4KwejQqo6n53v-Pw.png" /></figure><p>As we can see in the query profile above, the source table appears on the left and the larger target table on the right. A Left Anti Join identifies the source rows that don’t exist in the target, and the resulting rows are then written to the target table.</p><p>Now, let’s look at this from a <strong><em>query optimization perspective</em></strong> — when the source table is very small compared to the target table, a Broadcast Join is often worth considering &amp; we would broadcast the smaller source table to avoid shuffling the much larger target table.</p><p>But there’s a catch — an insert-only MERGE INTO uses a Left Anti Join, with the source table on the left and the target table on the right. As we discussed earlier, Spark’s <a href="https://medium.com/@satadru1998/the-untold-story-of-broadcast-joins-6be630d3e43e?sharedUserId=satadru1998"><strong><em>broadcast join strategy does not support broadcasting the left side of a Left Join</em></strong></a>. This means we can’t simply broadcast the smaller source table in this case — i.e. though we have a clear scope of optimization!</p><p>And if we click on Join step, we can confirm that, it’s <strong><em>Hash join</em></strong> where the Hash Table is built on Source Table as it’s smaller —</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/587/1*wFrvVp8KnR6jfdNcZf9wQA.png" /></figure><p>Now, let’s take a moment to think about whether we can achieve the same output while leveraging the power of Broadcast Join for better performance.</p><p>Can we use Broadcast Join with a <strong>Left Anti Join</strong>? Absolutely! The key is to make the right-side dataset significantly smaller than the left-side dataset, because <strong><em>Spark can broadcast the right side of the join in left join case</em></strong>.</p><p>So, the idea is simple: <strong>keep the larger dataset on the left and the smaller dataset on the right</strong> to take advantage of broadcast optimization during Left Anti Join &amp; that we can easily achieve using below approach —</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*hvflC3V-a7TlZUkGiK_ROw.png" /></figure><p><strong>Step 1: Identify the intersection or matching pks between the source and target tables — </strong>Use an <strong>Inner Join</strong> to extract the records common to both tables. Since an Inner Join supports broadcasting either side, Databricks can broadcast the source table as mostly it is sufficiently smaller that target table in general —</p><pre>WITH matching AS (<br>    SELECT src.transaction_id<br>    FROM large_target_benchmark AS tgt<br>    INNER JOIN small_source_benchmark AS src<br>        ON tgt.transaction_id = src.transaction_id<br>)</pre><p><strong>Step 2: Perform a Left Anti Join between the source and the intersection data —</strong></p><p>Use the source table as the left side and the intersection dataset as the right side. Since the intersection is generally smaller than the complete source dataset, it becomes a potential candidate for broadcasting. Moreover, the intersection pks sits on the right side of the Left join, aligning with the broadcast join requirement—</p><pre>SELECT<br>    src.*<br>FROM small_source_benchmark AS src<br>LEFT ANTI JOIN matching<br>    ON src.transaction_id = matching.transaction_id;</pre><p>This way, we can potentially leverage Broadcast Join in both cases for optimization while preserving the left-side records needed for the subsequent processing —</p><pre>WITH matching AS (<br>    SELECT src.transaction_id<br>    FROM large_target_benchmark AS tgt<br>    INNER JOIN small_source_benchmark AS src<br>        ON tgt.transaction_id = src.transaction_id<br>)<br>SELECT<br>    src.*<br>FROM small_source_benchmark AS src<br>LEFT ANTI JOIN matching<br>    ON src.transaction_id = matching.transaction_id;</pre><p><strong>Step 3: Insert those records in target table —</strong></p><pre>INSERT INTO large_target_benchmark<br>WITH matching AS (<br>    SELECT src.transaction_id<br>    FROM large_target_benchmark AS tgt<br>    INNER JOIN small_source_benchmark AS src<br>        ON tgt.transaction_id = src.transaction_id<br>)<br>SELECT<br>    src.*<br>FROM small_source_benchmark AS src<br>LEFT ANTI JOIN matching<br>    ON src.transaction_id = matching.transaction_id;</pre><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*Q7UZKSyNP4fTBrKFQFzMkw.png" /></figure><p>As you can see in the Query Profile above, Databricks is leveraging Broadcast Joins in both join steps.</p><p>To validate that our custom INSERT approach produces the same result as the standard MERGE statement, we can run a simple experiment: clone the initial tables, apply the MERGE approach to one set and the custom INSERT approach to the other, and finally compare the results to confirm that both approaches produce identical data —</p><pre>-- Large target: 1 BILLION rows<br>CREATE OR REPLACE TABLE large_target_benchmark<br>USING DELTA<br>AS<br>SELECT<br>    id AS transaction_id,<br>    MOD(id, 10000000) AS customer_id,<br>    RAND() * 1000 AS amount,<br>    CURRENT_DATE() AS created_date<br>FROM RANGE(1000000000);<br><br><br>-- Small source: 100K rows<br>-- First 50K will match existing records<br>-- Next 50K will be new records<br>CREATE OR REPLACE TABLE small_source_benchmark<br>USING DELTA<br>AS<br>SELECT<br>    id AS transaction_id,<br>    MOD(id, 10000000) AS customer_id,<br>    RAND() * 5000 AS amount,<br>    CURRENT_DATE() AS created_date<br>FROM RANGE(999950000, 1000050000);<br><br>-- Deep clone large target table<br>CREATE OR REPLACE TABLE large_target_benchmark_2<br>DEEP CLONE large_target_benchmark;<br><br>-- Deep clone small source table<br>CREATE OR REPLACE TABLE small_source_benchmark_2<br>DEEP CLONE small_source_benchmark;<br><br>MERGE INTO large_target_benchmark AS tgt<br>USING small_source_benchmark AS src<br>ON tgt.transaction_id = src.transaction_id<br>WHEN NOT MATCHED THEN<br>    INSERT *;<br><br><br>INSERT INTO large_target_benchmark_2<br>WITH matching AS (<br>    SELECT src.transaction_id<br>    FROM large_target_benchmark_2 AS tgt<br>    INNER JOIN small_source_benchmark_2 AS src<br>        ON tgt.transaction_id = src.transaction_id<br>)<br>SELECT<br>    src.*<br>FROM small_source_benchmark_2 AS src<br>LEFT ANTI JOIN matching<br>    ON src.transaction_id = matching.transaction_id;<br><br>(<br>    SELECT * FROM large_target_benchmark_2<br>    EXCEPT<br>    SELECT * FROM large_target_benchmark<br>)<br>UNION<br>(<br>    SELECT * FROM large_target_benchmark<br>    EXCEPT<br>    SELECT * FROM large_target_benchmark_2<br>);</pre><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*8OMSKKs_XEPYolRToFIgiA.png" /></figure><p>Since the comparison query returns no rows, we can conclude that both approaches produce the same final target state.</p><p>This confirms that our custom INSERT approach is functionally equivalent to the insert-only MERGE for this test case, while leveraging Broadcast Joins to potentially improve performance and make it more optimized than the standard MERGE approach!</p><h3>Conclusion:</h3><p>So the moral of the story is — When a MERGE operation only inserts records that don’t already exist in the target, an alternative join-based INSERT strategy can sometimes deliver better performance by taking advantage of Broadcast Joins.</p><p>That’s all for this blog! For more such <strong>interesting and practical content</strong>, follow me on <a href="https://www.youtube.com/@KnowledgeAmplifier1"><strong><em>YouTube</em></strong></a><strong>, </strong><a href="https://www.linkedin.com/in/satadru-mukherjee-a237b41a5/"><strong><em>LinkedIn</em></strong></a><strong>, </strong><a href="https://medium.com/@satadru1998"><strong>Medium</strong></a><strong> &amp; </strong><a href="https://www.instagram.com/knowledgeamplifier/"><strong><em>Instagram</em></strong></a>!</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=7f4242a300a5" width="1" height="1" alt=""><hr><p><a href="https://blog.dataengineerthings.org/when-you-should-prefer-insert-into-over-merge-into-7f4242a300a5">When you should prefer INSERT INTO over MERGE INTO!</a> was originally published in <a href="https://blog.dataengineerthings.org">Data Engineer Things</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Pushdown Optimization: Don’t Take It for Granted]]></title>
            <link>https://blog.dataengineerthings.org/pushdown-optimization-dont-take-it-for-granted-f023c2cc1c44?source=rss-f1f3c866c4d5------2</link>
            <guid isPermaLink="false">https://medium.com/p/f023c2cc1c44</guid>
            <category><![CDATA[databricks]]></category>
            <category><![CDATA[data-engineering]]></category>
            <category><![CDATA[cloud-computing]]></category>
            <category><![CDATA[snowflake]]></category>
            <category><![CDATA[sql-optimization]]></category>
            <dc:creator><![CDATA[Satadru]]></dc:creator>
            <pubDate>Thu, 03 Sep 2026 09:09:30 GMT</pubDate>
            <atom:updated>2026-09-11T21:22:36.598Z</atom:updated>
            <content:encoded><![CDATA[<p>If you’ve worked with <strong>distributed computing or modern analytical databases</strong>, you’ve probably come across the idea of <strong>Predicate Pushdown</strong>. It’s one of those fundamental optimizations that happens behind the scenes and can have a significant impact on query performance. But before getting into the interesting part, let’s quickly revisit what it actually means.</p><h3>Predicate Pushdown?</h3><p>At a high level, <strong>Predicate Pushdown</strong> is about applying filters as close to the data source as possible instead of waiting until the data has already travelled through the query execution pipeline.</p><p>The reasoning is pretty straightforward:</p><blockquote><strong><em>If we already know that a row isn’t needed, why spend resources processing it?</em></strong></blockquote><p>Filtering data early can reduce the number of rows that need to be shuffled, processed, or kept in memory, which ultimately helps improve both performance and resource utilization.</p><p>Consider a simple query:</p><pre>SELECT col1<br>FROM tab1<br>WHERE location = &#39;New York&#39;;</pre><p>A naive execution strategy could look something like this:</p><ol><li>Read the data from tab1.</li><li>Bring the rows into memory.</li><li>Evaluate the WHERE condition and discard rows that aren&#39;t from New York.</li><li>Return col1 for the remaining rows.</li></ol><p>In other words, the system essentially <strong>loads the data first and filters it afterward</strong>. While this approach is perfectly valid, it can become expensive when the underlying table contains millions or even billions of records —</p><iframe src="https://cdn.embedly.com/widgets/media.html?src=https%3A%2F%2Fwww.youtube.com%2Fembed%2FU66djkCtYTI%3Ffeature%3Doembed&amp;display_name=YouTube&amp;url=https%3A%2F%2Fwww.youtube.com%2Fwatch%3Fv%3DU66djkCtYTI&amp;image=https%3A%2F%2Fi.ytimg.com%2Fvi%2FU66djkCtYTI%2Fhqdefault.jpg&amp;type=text%2Fhtml&amp;schema=youtube" width="854" height="480" frameborder="0" scrolling="no"><a href="https://medium.com/media/4b269a48894c66de7864ca58bfb126d6/href">https://medium.com/media/4b269a48894c66de7864ca58bfb126d6/href</a></iframe><p>In the example query above, it would be more efficient to tell the table-scanning code not to load records that don’t match the WHERE clause. This doesn’t save filtering time(every row’s location must still be read once), but the biggest advantage is that fewer rows have to make their way through the rest of the execution plan. That can mean less memory consumption, less data to shuffle, and less work for downstream operators.</p><p>Sounds great, right?</p><p><strong>Well, not always!! 👀</strong></p><p>Pushdown is a powerful optimization, but it isn’t something we should blindly assume will happen in every query. Certain operations can prevent a predicate from being pushed further down the execution plan, and in some cases, the position of the filter can make a surprisingly significant difference.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*3bFpd6dMt9SsEPq4U0hqPA.png" /></figure><p>And that’s where things start getting interesting. Let’s check that practically first —</p><h4>Practical Implementation:</h4><p><strong>Step 1: </strong>Create a fresh Database for P.O.C. —</p><pre>create database demotest;<br><br>use demotest;</pre><p><strong>Step 2: </strong>Create a table for experimentation —</p><pre>CREATE OR REPLACE TABLE employees (<br>    department_id INT,<br>    employee_id INT,<br>    employee_name STRING,<br>    salary INT<br>);<br><br>INSERT INTO employees VALUES<br>(10, 101, &#39;Alice&#39;, 60000),<br>(10, 102, &#39;Bob&#39;, 70000),<br>(10, 103, &#39;Charlie&#39;, 80000),<br>(20, 201, &#39;David&#39;, 50000),<br>(20, 202, &#39;Eva&#39;, 55000),<br>(20, 203, &#39;Frank&#39;, 90000);</pre><p><strong>Step 3:</strong> Now, let’s test this intuition with a simple example:</p><pre><br>SELECT<br>    department_id,<br>    max(salary) AS salaries<br>FROM employees<br>GROUP BY department_id<br>HAVING max(salary) &gt; 60000;</pre><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*ag6TbjyVaMhk7u3uu88UWQ.png" /></figure><p>At first glance, this query looks like a perfect candidate for <strong>predicate pushdown</strong>.</p><p>Think about what the query is doing. We first perform a GROUP BY on department_id and calculate the maximum salary for each department. Only after that do we apply the HAVING condition.</p><p>But GROUP BY can be an <strong>expensive operation</strong>, particularly when we&#39;re dealing with a large dataset, as<strong><em> it involves sorting or hashing, and potentially shuffling a significant amount of data across the compute nodes.</em></strong></p><p>So, intuitively, wouldn’t it be better if Snowflake could reduce the amount of data <strong>before</strong> performing the aggregation?</p><p>For example, if a row has a salary of less than or equal to60,000, it can never be responsible for making MAX(salary) &gt; 60,000.</p><p>That naturally leads us to think:</p><blockquote><strong><em>Why process those rows through the </em></strong><strong><em>GROUP BY at all?</em></strong></blockquote><p>If Snowflake could push the predicate down and eliminate those rows before the aggregation, the execution could conceptually become:</p><pre>Scan employees<br>      ↓<br>Filter salary &gt; 60,000<br>      ↓<br>GROUP BY department_id<br>      ↓<br>MAX(salary)</pre><p>Instead of —</p><pre>Scan employees<br>      ↓<br>GROUP BY department_id<br>      ↓<br>MAX(salary)<br>      ↓<br>HAVING MAX(salary) &gt; 60,000</pre><p>Let’s check the Query Profile for the above query in Snowflake —</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*AyZybkXi3xcE-SiElavFYQ.png" /></figure><p>As we can see, Snowflake has pushed the filter of salary&gt;60K towards the source!</p><p>So far so good; now let’s try out another aggregation query, for example, <a href="https://docs.snowflake.com/en/sql-reference/functions/array_agg"><strong><em>array_agg</em></strong></a> &amp; observe whether the filter is also getting pushed down towards the source or not —</p><pre>SELECT<br>    department_id,<br>    ARRAY_AGG(salary) AS salaries<br>FROM employees<br>GROUP BY department_id<br>HAVING ARRAY_SIZE(ARRAY_AGG(salary)) &gt; 2;</pre><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*BvE39hXr1mc8xEfUFmVX1A.png" /></figure><p>From the above query profile, we can understand that this time the filter is not pushed down towards the source!</p><p><strong>Reason:</strong> The array_agg function is non-deterministic. A <strong>nondeterministic function</strong> in SQL is a function that can return different results even when called multiple times with the exact same input values. For example, if you have ever worked with Snowflake Cortex AI functions, you know it relies on underlying large language models(LLMs) that generate probabilistic text responses rather than fixed rule-based outcomes, so we can call it non-deterministic!</p><p>And this is my observation —</p><h3>Filters for aggregations are not pushed through nondeterministic expressions like array_agg, ANY_VALUE, listagg, etc.</h3><p>So make sure your filters are positioned in the right place to have optimized runs!</p><h3>Let’s see what’s going on with Databricks:</h3><p>First, we will try with a Databricks Serverless Cluster —</p><pre>CREATE OR REPLACE TABLE employees (<br>    department_id INT,<br>    employee_id INT,<br>    employee_name STRING,<br>    salary INT<br>);<br><br>INSERT INTO employees VALUES<br>(10, 101, &#39;Alice&#39;, 60000),<br>(10, 102, &#39;Bob&#39;, 70000),<br>(10, 103, &#39;Charlie&#39;, 80000),<br>(20, 201, &#39;David&#39;, 50000),<br>(20, 202, &#39;Eva&#39;, 55000),<br>(20, 203, &#39;Frank&#39;, 90000);<br><br><br>SELECT<br>    department_id,<br>    max(salary) AS salaries<br>FROM employees<br>GROUP BY department_id<br>HAVING max(salary) &gt; 60000;</pre><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*2JypV5VDNTIdzLQ7RBLEyg.png" /></figure><p>For some reason, while running the code in a Databricks Serverless Cluster, the pushdown, even for a deterministic function, is not working :-/</p><p>Even if we run the same query in <a href="https://docs.databricks.com/aws/en/compute/use-compute"><strong><em>Databricks Classic Compute</em></strong></a><strong><em> </em></strong>—</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*avMmydGrc1D8U4xbqSEw4Q.png" /></figure><p>Even for Classic compute, the filter is happening after the <strong>HashAggregate </strong>step, so in these kinds of cases, the Databricks Optimizer is not pushing the filter before aggregation. Make sure your filters are positioned in the right place to have optimized runs for all cases when using Databricks!</p><p>That’s all for this blog! For more such <strong>interesting and practical content</strong>, follow me on <a href="https://www.youtube.com/@KnowledgeAmplifier1"><strong><em>YouTube</em></strong></a><strong>, </strong><a href="https://www.linkedin.com/in/satadru-mukherjee-a237b41a5/"><strong><em>LinkedIn</em></strong></a><strong>, </strong><a href="https://medium.com/@satadru1998"><strong>Medium</strong></a><strong> &amp; </strong><a href="https://www.instagram.com/knowledgeamplifier/"><strong><em>Instagram</em></strong></a>!</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=f023c2cc1c44" width="1" height="1" alt=""><hr><p><a href="https://blog.dataengineerthings.org/pushdown-optimization-dont-take-it-for-granted-f023c2cc1c44">Pushdown Optimization: Don’t Take It for Granted</a> was originally published in <a href="https://blog.dataengineerthings.org">Data Engineer Things</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[This Correlated Subquery SQL Optimization in Snowflake & Databricks has blown up my mind!!]]></title>
            <link>https://blog.dataengineerthings.org/this-correlated-subquery-sql-optimization-in-snowflake-databricks-has-blown-up-my-mind-557538518d13?source=rss-f1f3c866c4d5------2</link>
            <guid isPermaLink="false">https://medium.com/p/557538518d13</guid>
            <category><![CDATA[query-optimization]]></category>
            <category><![CDATA[distributed-systems]]></category>
            <category><![CDATA[databricks]]></category>
            <category><![CDATA[snowflake]]></category>
            <category><![CDATA[data-engineering]]></category>
            <dc:creator><![CDATA[Satadru]]></dc:creator>
            <pubDate>Sat, 22 Aug 2026 05:06:59 GMT</pubDate>
            <atom:updated>2026-08-22T05:06:59.498Z</atom:updated>
            <content:encoded><![CDATA[<p>Every database promises performance. The real differentiator, however, lies in the countless optimizations happening silently beneath the surface — optimizations that most users never notice, yet benefit from every single day.</p><p>Snowflake &amp; Databricks, both have consistently invested in making its query engine smarter, enabling it to execute SQL more efficiently without requiring developers to rewrite their queries. It’s these behind-the-scenes engineering decisions that make the platform remarkably powerful at scale.</p><p>Over the past few weeks, I’ve been exploring how both Platform’s Query optimizer transforms SQL behind the scenes. Some of the fascinating optimizations I’ve already covered include —</p><ul><li><a href="https://blog.dataengineerthings.org/what-the-heck-is-aggregate-pushdown-87bb476a0bf6?sharedUserId=satadru1998"><strong><em>Aggregate Pushdown</em></strong></a></li><li><a href="https://blog.dataengineerthings.org/join-optimization-in-snowflake-the-power-of-bloom-filters-ac78d7bddaea?sharedUserId=satadru1998"><strong><em>Join Optimization using Bloom Filter</em></strong></a></li><li><a href="https://blog.dataengineerthings.org/snowflake-query-optimizations-you-hardly-notice-but-they-make-a-big-difference-860719728bd9?sharedUserId=satadru1998"><strong><em>New Filter generation &amp; New Filter on “Other” side of the Join</em></strong></a></li><li><a href="https://blog.dataengineerthings.org/snowflake-databricks-both-didnt-execute-your-not-exists-clause-ec3bdc05a610?sharedUserId=satadru1998"><strong><em>Optimizing Not Exists Clause</em></strong></a></li><li><a href="https://medium.com/data-engineer-things/the-hidden-joins-behind-a-snowflake-merge-command-dcafef86d096?sharedUserId=satadru1998"><strong><em>Intelligent Join selection depending on Join Clause in Merge statement</em></strong></a> etc.</li></ul><p>In today’s blog, we’ll uncover another subtle yet impressive optimization that Snowflake performs automatically for inefficient Correlated subqueries like below —</p><pre>SELECT<br>    ot.a,<br>    ot.b<br>FROM ot<br>WHERE ot.b &lt; (<br>    SELECT AVG(it.b)<br>    FROM it<br>    WHERE it.a = ot.a<br>);</pre><p>At first glance, the SQL looks perfectly ordinary. But once we inspect the Query Profile, we’ll discover that Snowflake quietly rewrites the execution strategy into something significantly more efficient.</p><h4>Let’s start with practical —</h4><p>First, we’ll create two tables — ot(outer table) and it(inner table)—and populate them with some sample data —</p><pre>create database sql_query_test;<br>use sql_query_test;<br>-- Create tables<br>CREATE OR REPLACE TABLE ot (<br>    a STRING,<br>    b INT<br>);<br><br>CREATE OR REPLACE TABLE it (<br>    a STRING,<br>    b INT<br>);<br><br>-- Insert data into ot<br>INSERT INTO ot (a, b) VALUES<br>(&#39;A&#39;, 10),<br>(&#39;A&#39;, 30),<br>(&#39;A&#39;, 50),<br>(&#39;B&#39;, 20),<br>(&#39;B&#39;, 40),<br>(&#39;C&#39;, 60);<br><br>-- Insert data into it<br>INSERT INTO it (a, b) VALUES<br>(&#39;A&#39;, 20),<br>(&#39;A&#39;, 40),<br>(&#39;B&#39;, 10),<br>(&#39;B&#39;, 30),<br>(&#39;B&#39;, 50),<br>(&#39;C&#39;, 80);</pre><p>Now, suppose our objective is to retrieve all rows from the <strong>outer table (</strong><strong>ot)</strong> where the value of b is <strong>less than the average </strong><strong>b value of the corresponding group in the inner table (</strong><strong>it)</strong>. Since the average depends on the current row&#39;s value of a, we may opt for <strong>correlated subquery —</strong></p><pre>-- Correlated subquery<br>SELECT<br>    ot.a,<br>    ot.b<br>FROM ot<br>WHERE ot.b &lt; (<br>    SELECT AVG(it.b)<br>    FROM it<br>    WHERE it.a = ot.a<br>);</pre><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*m4SqJFUhcK4YOXSrBNeeSw.png" /></figure><h4>But here’s the catch…</h4><p>While the query is logically correct, the <strong>naive execution strategy</strong> can become expensive.</p><p>Conceptually, the database would have to:</p><ol><li>Fetch a row from the outer table (ot).</li><li>Execute the correlated subquery using the current value of ot.a.</li><li>Compare ot.b with the returned average.</li><li>Return the row if the condition is satisfied.</li><li>Repeat the same process for every remaining row in the outer table.</li></ol><p>In other words, if the outer table contains <strong>millions of rows</strong>, it may appear that the correlated subquery also has to be evaluated <strong>millions of times</strong> — making correlated subqueries seem inherently expensive.</p><p>Now the 1000$ question is —</p><p><strong><em>Does Snowflake really execute the subquery once for every row in the outer table? Or is something much smarter happening behind the scenes?</em></strong></p><h4>Let’s inspect the Query Profile —</h4><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*8HoUeORoSnc_AJ_oasWvtg.png" /></figure><p>The first thing that stands out is the complete absence of any row-by-row execution or “for-loop” style processing that we would typically associate with a correlated subquery. Instead, the execution plan is composed of familiar relational operators such as Aggregations, Filters, and Joins.</p><p>Let’s intuitively understand what’s going on — the optimization mantra working in this case is: <strong><em>Correlated subquery of above type = Group By + Join</em></strong></p><h4>Step 1: Aggregate the inner table</h4><p>The correlated condition is:</p><pre>WHERE it.a = ot.a</pre><p>This tells us that for every value of a in ot, we need the average b from it having the same a.</p><p>Rather than calculating that average separately for every row of ot, the optimizer can calculate all of those averages together:</p><pre>SELECT<br>    a,<br>    AVG(b) AS avg_b<br>FROM it<br>GROUP BY a;</pre><p>This is what Snowflake is doing in left side of the above query profile(SQL_QUERY_TEST​.​PUBLIC​.​IT side), the only difference is, instead of calculating avg directly, it’s computing sum and count agregate value from which we can easily calculate the avg whenever needed —</p><pre>SELECT<br>a,<br>SUM(IT.B),<br>COUNT(IT.B)<br>FROM it<br>GROUP BY a;</pre><p>At this point, we have transformed it into a much smaller relation containing the average b for each value of a.</p><h4>Step 2: Join the aggregated result with the outer table</h4><p>We still need to bring this information back to the rows of ot so that we can perform the original comparison:</p><blockquote><em>ot.b &lt; AVG(it.b)</em></blockquote><p>The original correlation condition was:</p><pre>it.a = ot.a</pre><p>So we can simply use the same condition as the join key. This associates each row from ot with the corresponding average calculated from it.</p><p>The rewritten query now becomes:</p><pre>SELECT ot.a, ot.b FROM ot JOIN <br>( SELECT a, AVG(b) AS avg_b FROM it GROUP BY a ) <br>agg ON ot.a = agg.a WHERE ot.b &lt; agg.avg_b;</pre><p>So the original query which was like:</p><pre>For each row in OT <br>      Find AVG(B) from IT where IT.A = OT.A <br>      Compare OT.B with that average</pre><p>But the relational execution can instead look more like:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/282/1*R07bz2fwE7uw4fpFo8nJUg.png" /></figure><p>If you cross check, both pattern will return same result:</p><pre><br>WITH original AS (<br>    SELECT ot.a, ot.b<br>    FROM ot<br>    WHERE ot.b &lt; (<br>        SELECT AVG(it.b)<br>        FROM it<br>        WHERE it.a = ot.a<br>    )<br>),<br>rewritten AS (<br>    SELECT ot.a, ot.b<br>    FROM ot<br>    JOIN (<br>        SELECT a, AVG(b) AS avg_b<br>        FROM it<br>        GROUP BY a<br>    ) agg<br>        ON ot.a = agg.a<br>    WHERE ot.b &lt; agg.avg_b<br>)<br><br>SELECT * FROM original<br>EXCEPT<br>SELECT * FROM rewritten<br><br>UNION<br><br>SELECT * FROM rewritten<br>EXCEPT<br>SELECT * FROM original;</pre><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*YAxIjwKru-dVQ0PhDGjznQ.png" /><figcaption><strong>Both Query return same result, hence this query is returning no result</strong></figcaption></figure><p>In other words, the correlated dependency between ot and it has now been converted into a regular <strong>JOIN!</strong></p><p>And that is the key idea behind <strong>decorrelation!</strong></p><h4>Let’s check what’s going on with Databricks:</h4><pre><br>-- Create tables<br>CREATE OR REPLACE TABLE ot (<br>    a STRING,<br>    b INT<br>);<br><br>CREATE OR REPLACE TABLE it (<br>    a STRING,<br>    b INT<br>);<br><br>-- Insert data into ot<br>INSERT INTO ot (a, b) VALUES<br>(&#39;A&#39;, 10),<br>(&#39;A&#39;, 30),<br>(&#39;A&#39;, 50),<br>(&#39;B&#39;, 20),<br>(&#39;B&#39;, 40),<br>(&#39;C&#39;, 60);<br><br>-- Insert data into it<br>INSERT INTO it (a, b) VALUES<br>(&#39;A&#39;, 20),<br>(&#39;A&#39;, 40),<br>(&#39;B&#39;, 10),<br>(&#39;B&#39;, 30),<br>(&#39;B&#39;, 50),<br>(&#39;C&#39;, 80);<br><br><br>SELECT<br>    ot.a,<br>    ot.b<br>FROM ot<br>WHERE ot.b &lt; (<br>    SELECT AVG(it.b)<br>    FROM it<br>    WHERE it.a = ot.a<br>);</pre><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*i4RwUK0ZJ9KIcDW6FrH8AA.png" /></figure><p>As we can see, Databricks also convert this unoptimised Correlated Subquery to Group By &amp; Inner join! The only difference with Snowflake is, Databricks perform 2 aggregations when we execute any group by operation(and there is an interesting story behind that, which I have covered this <a href="https://medium.com/data-engineer-things/feel-the-distributed-group-by-in-spark-88499cdc5ce6?sharedUserId=satadru1998"><strong><em>this blog</em></strong></a>)</p><h4>Conclusion:</h4><p>Correlated subqueries can become increasingly expensive as data volumes grow, especially when they appear to require repeated execution. But as we saw, Snowflake or Databricks can optimize certain correlated subqueries by transforming them into efficient relational operations such as <strong>Aggregation + Join</strong>.</p><p>So, before rewriting a complex query, it’s worth checking the <strong>Snowflake or Databricks’ Query Profile</strong> to see what is actually happening under the hood.</p><p><strong><em>The SQL may look correlated, but the execution doesn’t necessarily have to be.</em></strong></p><p>That’s all for this blog! For more such <strong>interesting and practical contents</strong>, follow me on <a href="https://www.youtube.com/@KnowledgeAmplifier1"><strong><em>YouTube</em></strong></a><strong>, </strong><a href="https://www.linkedin.com/in/satadru-mukherjee-a237b41a5/"><strong><em>LinkedIn</em></strong></a><strong>, </strong><a href="https://medium.com/@satadru1998"><strong>Medium</strong></a><strong> &amp; </strong><a href="https://www.instagram.com/knowledgeamplifier/"><strong><em>Instagram</em></strong></a>!</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=557538518d13" width="1" height="1" alt=""><hr><p><a href="https://blog.dataengineerthings.org/this-correlated-subquery-sql-optimization-in-snowflake-databricks-has-blown-up-my-mind-557538518d13">This Correlated Subquery SQL Optimization in Snowflake &amp; Databricks has blown up my mind!!</a> was originally published in <a href="https://blog.dataengineerthings.org">Data Engineer Things</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[The Hidden Joins Behind a Snowflake MERGE command]]></title>
            <link>https://blog.dataengineerthings.org/the-hidden-joins-behind-a-snowflake-merge-command-dcafef86d096?source=rss-f1f3c866c4d5------2</link>
            <guid isPermaLink="false">https://medium.com/p/dcafef86d096</guid>
            <category><![CDATA[cloud-computing]]></category>
            <category><![CDATA[snowflake]]></category>
            <category><![CDATA[data-engineering]]></category>
            <category><![CDATA[databricks]]></category>
            <category><![CDATA[data-warehouse]]></category>
            <dc:creator><![CDATA[Satadru]]></dc:creator>
            <pubDate>Wed, 19 Aug 2026 14:54:03 GMT</pubDate>
            <atom:updated>2026-08-20T02:07:57.528Z</atom:updated>
            <content:encoded><![CDATA[<p>One familiar task in most downstream applications is change data capture (CDC) and applying it to its target tables. This task requires examining the source data to determine if it is an update or an insert to existing target data. And here comes our hero — <strong><em>The MERGE Statement!</em></strong></p><p>Without the MERGE command, you needed to test the new dataset against the existing dataset using a business key. When that didn’t match, you inserted new rows in the existing dataset; otherwise, you updated existing dataset rows with new dataset values.</p><p>The <a href="https://docs.snowflake.com/en/sql-reference/sql/merge"><strong>MERGE</strong></a><strong> </strong>command conditionally merges rows from a source table into a target table. Traditionally, this could only be achieved by using multiple insert, update, or delete statements separately. When using multiple statements to update or insert data, there is a risk of inconsistencies between the different operations. Merge operation reduces this risk by ensuring that all operations are performed together in a single transaction —</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*el_WdXlTfVvyXr1w8OCUlg.png" /></figure><p>In general, a Merge statement typically looks like —</p><pre>MERGE INTO {target_table}<br>USING {source_table}<br>ON {join_expr}<br>WHEN MATCHED THEN UPDATE <br>WHEN NOT MATCHED THEN INSERT;</pre><p>Now, in this blog, we won’t dive deep into the syntax or the basic working of the MERGE statement, assuming we’re already familiar with those. Instead, I want to explore an interesting observation I recently came across while digging into the <strong>Query Profile of a MERGE statement in Snowflake</strong>.</p><h4>Let’s start with creating dummy source &amp; target table —</h4><pre>create or replace DATABASE DEMO_TEST;<br>use demo_test;<br>use schema public;<br><br>CREATE OR REPLACE TABLE source_employee (<br>    employee_id NUMBER,<br>    employee_name VARCHAR,<br>    department VARCHAR,<br>    salary NUMBER,<br>    status VARCHAR<br>);<br>INSERT INTO source_employee VALUES<br>(1, &#39;John&#39;,  &#39;IT&#39;,      75000, &#39;ACTIVE&#39;), <br>(2, &#39;Alice&#39;, &#39;HR&#39;,      65000, &#39;ACTIVE&#39;), <br>(5, &#39;Emma&#39;,  &#39;Marketing&#39;, 60000, &#39;ACTIVE&#39;),<br>(4, &#39;David&#39;, &#39;IT&#39;,      75000, &#39;DELETED&#39;);<br><br><br>CREATE OR REPLACE TABLE target_employee (<br>    employee_id NUMBER,<br>    employee_name VARCHAR,<br>    department VARCHAR,<br>    salary NUMBER,<br>    status VARCHAR<br>);<br>INSERT INTO target_employee VALUES<br>(1, &#39;John&#39;,  &#39;IT&#39;,    70000, &#39;ACTIVE&#39;),<br>(2, &#39;Alice&#39;, &#39;HR&#39;,    65000, &#39;ACTIVE&#39;),<br>(3, &#39;Bob&#39;,   &#39;Finance&#39;, 80000, &#39;ACTIVE&#39;),<br>(4, &#39;David&#39;, &#39;IT&#39;,    75000, &#39;ACTIVE&#39;);</pre><p>From the above dataset, we can clearly understand that if we apply the MERGE statement, <strong>Emma (ID 5)</strong> will be inserted because she exists in the source but not in the target, <strong>John’s salary</strong> will be updated from 70,000 to 75,000, <strong>Alice and Bob</strong> will remain unchanged as their records already match, and <strong>David’s status</strong> will change from ACTIVE to DELETED.</p><p>Let’s execute the merge statement now, but different scenarios 1-by-1:</p><h4>Insert Only merge:</h4><pre>MERGE INTO target_employee t<br>USING source_employee s<br>ON t.employee_id = s.employee_id<br><br>WHEN NOT MATCHED THEN<br>    INSERT (<br>        employee_id,<br>        employee_name,<br>        department,<br>        salary,<br>        status<br>    )<br>    VALUES (<br>        s.employee_id,<br>        s.employee_name,<br>        s.department,<br>        s.salary,<br>        s.status<br>    );</pre><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*4vDNPkTJvpNiAvALcdgHeQ.png" /></figure><p>As expected from the data, only 1 row is impacted!</p><p>Now let’s intuitively think how this might have been done for a sec…</p><p>So we want to insert those rows which are present in source table but not in target table, kind off this kind off venn diagram(if you consider Table A as source Table &amp; Table B as target table)—</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/502/0*jYpkucYJB7hshlFb" /></figure><p>And this is nothing but venn diagram of Left Anti Join! Now if you refer the Query Profile, you will observe, Snowflake is doing exactly the same thing —</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*02Vij5w_VJwXe4j5dtajzw.png" /></figure><h4>Update Only merge:</h4><p>Now, let’s start with a simple <strong>UPDATE-only MERGE</strong> statement. Here, we will update the target table only when a matching employee_id is found in the source table —</p><pre>MERGE INTO target_employee t<br>USING source_employee s<br>ON t.employee_id = s.employee_id<br><br>WHEN MATCHED THEN<br>    UPDATE SET<br>        t.employee_name = s.employee_name,<br>        t.department    = s.department,<br>        t.salary        = s.salary,<br>        t.status        = s.status;</pre><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*PcqK409dSyQqa44ZK1wtQQ.png" /></figure><p>Now, let’s intuitively think about how this might have been implemented in the backend for a moment..</p><p>We want to update only those rows that are <strong>present in both the source and target tables</strong>. If we represent the source as Table A and the target as Table B, we are essentially interested in the <strong>intersection of the two tables</strong> —</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/462/1*uNpwPWTg8RY8_33PQ91DeA.png" /></figure><p>And this is nothing but venn diagram of Inner Join!!</p><p>So, intuitively, we might expect Snowflake to perform an <strong>INNER JOIN</strong> between the source and target tables behind the scenes. But is that actually what Snowflake does?</p><p>Let’s look at the <strong>Query Profile</strong> and find out —</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*JbQvKX4hIcM1Bveae-Xvog.png" /></figure><p>And here our intuition is matching with Snowflake Query Profile, it’s doing inner join for Update Only Merge!</p><h4>Both Insert &amp; Update merge:</h4><p>Now, let’s execute the <strong>MERGE statement with both INSERT and UPDATE —</strong></p><pre>MERGE INTO target_employee t<br>USING source_employee s<br>ON t.employee_id = s.employee_id<br><br>WHEN MATCHED THEN<br>    UPDATE SET<br>        t.employee_name = s.employee_name,<br>        t.department    = s.department,<br>        t.salary        = s.salary,<br>        t.status        = s.status<br><br>WHEN NOT MATCHED THEN<br>    INSERT (<br>        employee_id,<br>        employee_name,<br>        department,<br>        salary,<br>        status<br>    )<br>    VALUES (<br>        s.employee_id,<br>        s.employee_name,<br>        s.department,<br>        s.salary,<br>        s.status<br>    );</pre><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*rIWgmzNUE_R1z7YKAdVd6g.png" /></figure><p>Let’s think about what we actually need here. For the <strong>UPDATE</strong> clause, we need the rows that are present in <strong>both the source and target</strong>. For the <strong>INSERT</strong> clause, we need the rows that are present in the <strong>source but not in the target</strong>. If we visualize this using a Venn diagram, we essentially need the <strong>entire Source table</strong>, including both the matching rows and the source-only rows —</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/722/1*Zrew8yAdnHstOhrc9cFucg.png" /></figure><p>And intuitively, this looks very similar to what a <strong>LEFT OUTER JOIN</strong> would produce, with the source table on the left.</p><p>So, could Snowflake be using a <strong>LEFT OUTER JOIN</strong> internally to execute this MERGE?</p><p>Let’s look at the <strong>Query Profile</strong> and find out whether Snowflake actually does the same thing —</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*PdVxbyC3QJI68jkICDpjpQ.png" /></figure><p>And yes, this time as well, <strong>our intuition matches Snowflake’s actual execution!</strong> The Query Profile shows that Snowflake is indeed using a <strong>LEFT OUTER JOIN</strong>, exactly as we anticipated!</p><h3>Conclusion</h3><p>From this POC, one thing is clear: <strong>although we are executing the same </strong><strong>MERGE statement, the join strategy used at the backend can vary depending on the clauses and conditions we specify.</strong></p><p>The bigger question is: <strong>why does Snowflake do this?</strong> One possible reason is that different join strategies can have very different execution costs. An <strong>ANTI JOIN</strong> is generally much cheaper than a <strong>LEFT OUTER JOIN</strong> because it only needs to determine whether a matching row exists in the other table and can discard the matched rows, rather than carrying them forward. Similarly, an <strong>INNER JOIN</strong> can be cheaper than a <strong>LEFT OUTER JOIN</strong> because it only needs to retain matching rows, whereas a <strong>LEFT OUTER JOIN</strong> must preserve <strong>all rows from the left side</strong>, including those that have no match on the right side.</p><p>So, even though a <strong>LEFT OUTER JOIN could theoretically handle all these scenarios</strong>, Snowflake doesn’t necessarily use it everywhere. Instead, it can choose a more targeted join strategy based on what the MERGE actually requires—potentially making the execution more efficient!</p><p>That’s all for this blog! For more such <strong>interesting and practical contents</strong>, follow me on <a href="https://www.youtube.com/@KnowledgeAmplifier1"><strong><em>YouTube</em></strong></a><strong>, </strong><a href="https://www.linkedin.com/in/satadru-mukherjee-a237b41a5/"><strong><em>LinkedIn</em></strong></a><strong>, </strong><a href="https://medium.com/@satadru1998"><strong>Medium</strong></a><strong> &amp; </strong><a href="https://www.instagram.com/knowledgeamplifier/"><strong><em>Instagram</em></strong></a>!</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=dcafef86d096" width="1" height="1" alt=""><hr><p><a href="https://blog.dataengineerthings.org/the-hidden-joins-behind-a-snowflake-merge-command-dcafef86d096">The Hidden Joins Behind a Snowflake MERGE command</a> was originally published in <a href="https://blog.dataengineerthings.org">Data Engineer Things</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Snowflake & Databricks both didn’t execute your NOT EXISTS clause!!]]></title>
            <link>https://blog.dataengineerthings.org/snowflake-databricks-both-didnt-execute-your-not-exists-clause-ec3bdc05a610?source=rss-f1f3c866c4d5------2</link>
            <guid isPermaLink="false">https://medium.com/p/ec3bdc05a610</guid>
            <category><![CDATA[databricks]]></category>
            <category><![CDATA[snowflake]]></category>
            <category><![CDATA[cloud-computing]]></category>
            <category><![CDATA[query-profile]]></category>
            <category><![CDATA[data-engineering]]></category>
            <dc:creator><![CDATA[Satadru]]></dc:creator>
            <pubDate>Fri, 24 Jul 2026 07:38:09 GMT</pubDate>
            <atom:updated>2026-07-25T17:41:35.495Z</atom:updated>
            <content:encoded><![CDATA[<h4>Before diving into the mystery behind Snowflake’s &amp; Databricks’ execution of the subquery with NOT EXISTS clause, let&#39;s first recall quickly what is a Subquery &amp; what NOT EXISTS actually does.</h4><h3>Subquery operators</h3><p>A subquery is a query within another query as simple as that! An example can be like, if I have to find out all those employee who’s salary is greater than average salary of all employees across the organization, I can write a query like below —</p><pre>SELECT name, salary <br>FROM employees <br>WHERE salary &gt; (SELECT AVG(salary) FROM employees);</pre><p><strong>How It Works —</strong></p><ul><li><strong>Inner Query First</strong>: The database runs the query inside the parentheses first, like in this case, first it calculate the average salary of all employees.</li><li><strong>Outer Query Next</strong>: The main query uses the result of the inner query i.e. avg_salary from inner query to finish its job i.e. in this case to filter all records where salary &gt; avg_salary!</li></ul><h3>EXISTS condition</h3><p>EXISTS conditions test for the existence of rows in a subquery, and return true if a subquery returns at least one row. If NOT is specified(i.e. <strong><em>Not Exists</em></strong> clause), the condition returns true if a subquery returns no rows.</p><p>For example, let’s say I have 2 tables — employees, which stores all employee records, and terminated_employees, which stores the emp_id of employees who have left the company —</p><pre>CREATE OR REPLACE TABLE employees (<br>    emp_id INT,<br>    emp_name STRING<br>);<br><br>INSERT INTO employees VALUES<br>(1, &#39;Alice&#39;),<br>(2, &#39;Bob&#39;),<br>(3, &#39;Charlie&#39;),<br>(4, &#39;David&#39;),<br>(5, &#39;Eva&#39;);<br><br>CREATE OR REPLACE TABLE terminated_employees (<br>    emp_id INT<br>);<br><br>INSERT INTO terminated_employees VALUES<br>(2),<br>(4),<br>(4);</pre><p>If I want to fetch only the employees who are <strong>currently active</strong>(i.e., not terminated), I can use a NOT EXISTS clause —</p><pre>SELECT<br>    e.emp_id,<br>    e.emp_name<br>FROM employees e<br>WHERE NOT EXISTS (<br>    SELECT t.emp_id<br>    FROM terminated_employees t<br>    WHERE e.emp_id = t.emp_id<br>);</pre><p>Now, this is an usecase of <strong><em>Correlated Subquery</em></strong> as well <strong><em>where the inner query is evaluated once for every row returned by the outer query</em></strong>!</p><h3>The performance challenge:</h3><p>Traditional execution of correlated subqueries follows this pattern:</p><ol><li>Fetch one row from the outer query(i.e. employees table)</li><li>Execute the subquery with correlated values fetched from step 1</li><li>Return the subquery result</li><li>Repeat for the next outer row</li></ol><p>So, if table employees has 1 million rows, the subquery needs to be evaluated 1 million times😢</p><p>And this kind off <strong>Row-by-row evaluation</strong> breaks <strong>parallel, distributed execution paradigm</strong> as well in a way, so we can clearly understand this will have a <strong>performance impacts!</strong></p><h3>Subquery transformation in Snowflake</h3><p>Instead of running the same subquery repeatedly for each row in your outer table, Snowflake converts this into <strong><em>equivalent joins</em></strong> &amp; then apply parallel, distributed execution(I have already covered how Join works in Distributed System in <a href="https://medium.com/@satadru1998/a-peek-into-internals-of-spark-join-strategy-03776af99c01?sharedUserId=satadru1998"><strong><em>this blog</em></strong></a>)</p><p>For example, in above case, we are trying to find out the employees available in set “employees” that are not in set “terminated_employees” which is a typical “<strong>Anti Join</strong>” use-case —</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*akXd-AKMX4Ruywg-.png" /></figure><p>Let’s see the query profile by clicking on Query ID in Snowflake —</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*y7mnQgbdjjnSvexRgK5hCQ.png" /></figure><p>The Query Profile looks like this —</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*iuAcABgG9YnaZdxNU03KoQ.png" /></figure><p>Observe in the Query Profile, there is no NOT EXISTS operation available, rather, there is a Join step, click on that &amp; you will able to see ANTI join is performed between 2 tables instead of our written clause —</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*ghMtKi3CR_-IJwkexPyrkw.png" /></figure><p><strong>Note: </strong>One aspect of the Snowflake Query Profile that can be a bit confusing is identifying which input represents the <strong>left</strong> and <strong>right</strong> side of the <strong>ANTI Join</strong>, as the visual layout doesn’t explicitly label them.</p><p>However, we can infer this from the flow of rows. If Snowflake is performing a <strong>LEFT ANTI JOIN</strong>, then the <strong>Employees</strong> table(which appears on the right side of the Query Profile) must actually be the <strong>left input</strong>, while <strong>Terminated_Employees</strong> is the <strong>right input</strong>.</p><p>The row counts make this evident:</p><ul><li>The <strong>Employees</strong> table outputs <strong>5 rows</strong>(Employees table has 5 rows, we can confirm from our initial table creation query).</li><li>During the <strong>LEFT ANTI JOIN</strong>, <strong>2 matching rows</strong>(employees found in terminated_employees) are eliminated.</li><li>The join therefore returns the remaining <strong>3 rows</strong>, which are employees that do <strong>not</strong> exist in the terminated_employees table.</li></ul><p>Although the visual placement in the Query Profile may suggest otherwise, the row flow clearly indicates that Snowflake is treating <strong>Employees</strong> as the left side of the anti join and <strong>Terminated_Employees</strong> as the right side.</p><h3>So overall we can conclude, Snowflake optimizes the correlated subquery having Not Exists clause with ANTI Join!</h3><p>Another important optimization what we can observe is there is an aggregate step in the <strong>terminated_employees</strong> side, let’s intuitively try to understand what’s going on there —</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*_lbIlyYENh_ZMcnGbx8UmQ.png" /></figure><p>Although it is called <em>Aggregate</em>, it is <strong>not calculating any SUM, COUNT, or AVG</strong>. Instead, it is simply <strong>removing duplicate </strong><strong>EMP_IDs</strong> from the TERMINATED_EMPLOYEES table.</p><p>In this example:</p><ul><li>The table scan reads <strong>3 rows</strong>.</li><li>Two of those rows have the same EMP_ID.</li><li>The Aggregate step keeps only the <strong>2 unique </strong><strong>EMP_IDs</strong>.</li></ul><p>As a result, the <strong>LEFT ANTI JOIN</strong> only has to compare <strong>2 unique employee IDs instead of 3 rows</strong>. Fewer rows entering the join means <strong>less work for the join operator</strong>, making the execution more efficient.</p><h3>So, before performing the <strong>LEFT ANTI JOIN</strong>, Snowflake first reduces the number of rows that need to participate in the join and hence the aggregate step which is doing distinct operation on ANTI JOIN Key!</h3><h3>Two Cents for the Snowflake Database Engineering Team 💙</h3><ul><li>If Snowflake is already smart enough to execute <strong>NOT EXISTS</strong> as an <strong>ANTI JOIN</strong> under the hood, why not expose <strong>LEFT ANTI JOIN</strong> as a first-class SQL construct as well?</li></ul><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*ChdIm2gWHpiH9BTt0MEZMQ.png" /></figure><ul><li>One more small request regarding the <strong>Query Profile</strong>: it would be incredibly helpful if it explicitly displayed the <strong>type of ANTI JOIN</strong> being executed (e.g., LEFT ANTI JOIN) and clearly identified which input is the <strong>left</strong> table and which is the <strong>right</strong> table. Today, we have to infer this from the row flow, which isn’t always obvious.</li></ul><p>These small enhancements would make the Query Profile much easier to interpret, especially for engineers trying to understand how Snowflake optimizes and executes SQL queries.</p><h3>Let’s see how Databricks execute this</h3><pre><br>CREATE OR REPLACE TABLE employees (<br>    emp_id INT,<br>    emp_name STRING<br>);<br><br>INSERT INTO employees VALUES<br>(1, &#39;Alice&#39;),<br>(2, &#39;Bob&#39;),<br>(3, &#39;Charlie&#39;),<br>(4, &#39;David&#39;),<br>(5, &#39;Eva&#39;);<br><br>CREATE OR REPLACE TABLE terminated_employees (<br>    emp_id INT<br>);<br><br>INSERT INTO terminated_employees VALUES<br>(2),<br>(4),<br>(4);<br><br>SELECT<br>    e.emp_id,<br>    e.emp_name<br>FROM employees e<br>WHERE NOT EXISTS (<br>    SELECT t.emp_id<br>    FROM terminated_employees t<br>    WHERE e.emp_id = t.emp_id<br>);</pre><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*w9BHSsfsYTWlVNGy-B7HbQ.png" /></figure><p>The Query Profile in Databricks looks like this:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1021/1*q9wlTtxizz2pWVz1f9XHPw.png" /></figure><p>As we can clearly see, <strong>Databricks</strong> also executes the query as a <strong>LEFT ANTI JOIN</strong> instead of evaluating the NOT EXISTS clause directly, just like <strong>Snowflake</strong>.</p><p>One notable difference, however, is that <strong>Databricks makes the execution plan much easier to interpret</strong>. It explicitly labels the join as a <strong>Left Anti Join</strong> and clearly places the <strong>Employees</strong> table on the left and <strong>Terminated_Employees</strong> table on the right —</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*BMgxx0f5indxsNt8JCIy5g.png" /></figure><p>This makes it immediately obvious which table is the left input and which is the right input — something that was a bit confusing in Snowflake’s Query Profile.</p><p>On the other hand, notice one interesting optimization that <strong>Snowflake</strong> performs but <strong>Databricks</strong> doesn’t appear to —</p><p>Before executing the <strong>LEFT ANTI JOIN</strong>, Snowflake applies a <strong>DISTINCT</strong> operation on the join keys from the <strong>right-hand table</strong>. This is a smart optimization because, for an anti join, it doesn’t matter whether a key appears once or multiple times in the right table. As long as a matching key exists, the corresponding row from the left table will be eliminated.</p><p>By removing duplicate keys upfront, Snowflake reduces the number of rows flowing into the join operator. This means the join has less data to process, potentially improving execution efficiency without changing the query’s result.</p><p>Also it’s worth highlighting, Databricks support Left Anti Join Syntax as well which currently Snowflake don’t —</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/457/1*YqGml5-XLxc0BXYPwjHmQw.png" /></figure><h3>Conclusion:</h3><p>In this post, you have seen how the correlated subquery optimizations available in Snowflake &amp; Databricks can significantly improve performance through the technique: A<strong><em>utomatic Subquery Transformations</em></strong></p><p>That’s all for this blog! For more such <strong>interesting and practical contents</strong>, follow me on <a href="https://www.youtube.com/@KnowledgeAmplifier1"><strong><em>YouTube</em></strong></a><strong>, </strong><a href="https://www.linkedin.com/in/satadru-mukherjee-a237b41a5/"><strong><em>LinkedIn</em></strong></a><strong>, and </strong><a href="https://medium.com/@satadru1998"><strong>Medium</strong></a>!</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=ec3bdc05a610" width="1" height="1" alt=""><hr><p><a href="https://blog.dataengineerthings.org/snowflake-databricks-both-didnt-execute-your-not-exists-clause-ec3bdc05a610">Snowflake &amp; Databricks both didn’t execute your NOT EXISTS clause!!</a> was originally published in <a href="https://blog.dataengineerthings.org">Data Engineer Things</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Architecting secure & compliant Unstructured Data(File) Sharing solution in minutes with Snowflake]]></title>
            <link>https://blog.dataengineerthings.org/architecting-secure-compliant-unstructured-data-file-sharing-solution-in-minutes-with-snowflake-a0427d6beb72?source=rss-f1f3c866c4d5------2</link>
            <guid isPermaLink="false">https://medium.com/p/a0427d6beb72</guid>
            <category><![CDATA[cloud-computing]]></category>
            <category><![CDATA[file-sharing]]></category>
            <category><![CDATA[snowflake]]></category>
            <category><![CDATA[design-systems]]></category>
            <category><![CDATA[data-engineering]]></category>
            <dc:creator><![CDATA[Satadru]]></dc:creator>
            <pubDate>Fri, 17 Jul 2026 05:00:05 GMT</pubDate>
            <atom:updated>2026-07-20T12:51:29.471Z</atom:updated>
            <content:encoded><![CDATA[<p>Let’s consider a business problem. Your organization heavily relies on Snowflake as its enterprise data platform, and unstructured documents from multiple source systems are continuously landing in the data lake. The objective is to build a scalable <strong><em>file-sharing solution</em></strong> that enables different business teams to securely access the documents while enforcing proper governance, fine-grained access control, and centralized management.</p><p>To keep things simple, let’s assume there are three departments: <strong>HR</strong>, <strong>Marketing</strong>, and <strong>Technology</strong>.</p><p>The source system continuously ingesting documents into dedicated folders based on the owning department:</p><ul><li>HR-related documents were ingested into the <strong>HR</strong> folder.</li><li>Marketing-related documents were ingested into the <strong>Marketing</strong> folder.</li><li>Technology-related documents were ingested into the <strong>Tech </strong>folder.</li></ul><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*JuMpD3mOeAe6SPyAvIRMyw.png" /></figure><p>The requirement is to implement a secure file-sharing mechanism where each department can access only its own documents.</p><p>For example:</p><ul><li>The <strong>HR</strong> team should be able to access only the <strong>HR</strong> folder.</li><li>The <strong>Marketing</strong> team should be able to access only the <strong>Marketing</strong> folder.</li><li>The <strong>Technology</strong> team should be able to access only the <strong>Tech</strong> folder.</li></ul><p>In other words, users must be prevented from viewing or accessing documents belonging to any other department. The solution should enforce this access centrally while remaining scalable as new departments, users, and documents are added over time.</p><p>There’s one more important constraint. Since the organization’s vision is to build solutions around Snowflake, the architect prefers a native Snowflake-based approach rather than introducing external services such as SFTP servers etc.</p><p>One obvious solution — but not the one we want — is to create a <strong>separate Snowflake External Stage</strong> for each department folder and grant access to the corresponding role. While this approach works, it quickly becomes difficult to manage. As the number of departments (or folders) grows, you’ll have to continuously create and maintain new External Stages, making the solution operationally cumbersome and far from scalable.</p><p>Let’s start with the second <strong>most intuitive solution</strong> that would naturally come to mind…</p><p><strong>Step 1:</strong> First thing is, we need <strong>external stage</strong> to work with Snowflake &amp; S3, so let’s build it(and to play with file-level metadata in better way, let’s opt for <a href="https://youtu.be/Kld2ievPvK4?si=YsKzFvVCC-YxAFOk"><strong><em>Directory Table</em></strong></a>)—</p><pre>DROP DATABASE IF EXISTS snowflake_file_sharing_poc;<br>CREATE Database snowflake_file_sharing_poc;<br>use snowflake_file_sharing_poc;<br><br>create or replace stage snowflake_file_sharing_poc.PUBLIC.snwoflake_parent_directory url=&quot;s3://filesharingsystemdemo/&quot; <br>credentials=(aws_key_id=&#39;{Put your AWS Access Key ID here}&#39;<br>aws_secret_key=&#39;{Put your AWS Secret Access Key here}&#39;)<br>Directory=(ENABLE=TRUE);<br><br>ALTER STAGE snowflake_file_sharing_poc.PUBLIC.snwoflake_parent_directory REFRESH;</pre><p>Let’s query the Directory Table —</p><pre>select * from DIRECTORY(@snowflake_file_sharing_poc.PUBLIC.snwoflake_parent_directory);</pre><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*q0fgaxHRIu18atPiE-Q6IA.png" /></figure><p><strong>Step 2: </strong>Now, if users need to access the raw unstructured files from Snowflake — whether they are PDFs, text files, images, videos, or any other file type — a common solution is to generate <a href="https://youtu.be/JhDjvA-KATk?si=FHnpOcuM5oPIln9z"><strong><em>pre-signed URLs</em></strong></a>—</p><pre>select RELATIVE_PATH, FILE_URL, GET_PRESIGNED_URL(@snowflake_file_sharing_poc.PUBLIC.snwoflake_parent_directory, relative_path, 60) AS presigned_url, LAST_MODIFIED<br>from DIRECTORY(@snowflake_file_sharing_poc.PUBLIC.snwoflake_parent_directory)</pre><p><strong>Step 3: </strong>Now, as we all know, pre-signed URLs have a limited validity period. The requirement is to ensure that this validity period starts <strong>when the user executes the query</strong>, rather than when the URL is generated beforehand.</p><p>For example, if the URL is configured to be valid for <strong>10 minutes</strong> and a user queries Snowflake at <strong>5:00 AM</strong>, the generated URL should remain valid until <strong>5:10 AM</strong> — regardless of when the underlying metadata was ingested.</p><p>This is where <strong>SQL Views</strong> become particularly useful. Since a view is evaluated at query execution time rather than being precomputed, the pre-signed URL can be generated on demand, ensuring that every user receives a fresh URL with the full validity period —</p><pre>CREATE OR REPLACE  VIEW snowflake_file_sharing_poc.PUBLIC.file_sharing_view AS<br>select RELATIVE_PATH, FILE_URL, GET_PRESIGNED_URL(@snowflake_file_sharing_poc.PUBLIC.snwoflake_parent_directory, relative_path, 60) AS presigned_url, LAST_MODIFIED<br>from DIRECTORY(@snowflake_file_sharing_poc.PUBLIC.snwoflake_parent_directory);<br><br>select * from snowflake_file_sharing_poc.PUBLIC.file_sharing_view;</pre><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*lkbUoX5JowhJeGpii_6ZWA.png" /></figure><p><strong>Step 4:</strong> At this point, only one challenge remains: <strong>fine-grained access control</strong>. If we simply grant users access to the view shown above, every user — regardless of whether they belong to the <strong>HR</strong>, <strong>Marketing</strong>, or <strong>Technology</strong> team — will receive pre-signed URLs for <strong>all</strong> the files stored in S3. Since a pre-signed URL grants direct access to the underlying object, users would effectively be able to access documents belonging to every department, completely bypassing the intended security model.</p><p>This is exactly what we want to avoid!</p><p>Solution? Simple — <a href="https://youtu.be/hUNWmJG0OkY?si=uefOjbgCiZzfgbbj"><strong><em>Row-Level Access Control</em></strong></a><strong><em> </em></strong>using a<strong><em> Secure View</em></strong> 🙂</p><p>The idea is actually quite straightforward. We can create dedicated Snowflake roles for each department — for example, <strong>HR</strong>, <strong>MARKETING</strong>, and <strong>TECH</strong> — mirroring the folder structure in the data lake.</p><p>Then, instead of exposing all the records through the view, we can leverage a <strong>Secure View</strong> that dynamically filters the data based on the role of the user executing the query. The filtering logic simply ensures that users only receive records where the department(or folder name) matches their current Snowflake role.</p><p>For example, a user with the <strong>HR</strong> role will only receive rows corresponding to the <strong>HR</strong> folder, a <strong>MARKETING</strong> user will only see <strong>Marketing</strong> documents, and a <strong>TECH</strong> user will only receive records for the <strong>Tech</strong> folder. Since the pre-signed URLs are generated only for the rows returned by the Secure View, users can never obtain URLs for documents belonging to other departments —</p><pre>CREATE OR REPLACE SECURE VIEW snowflake_file_sharing_poc.PUBLIC.file_sharing_view AS<br>select RELATIVE_PATH, FILE_URL, GET_PRESIGNED_URL(@snowflake_file_sharing_poc.PUBLIC.snwoflake_parent_directory, relative_path, 60) AS presigned_url, LAST_MODIFIED<br>from DIRECTORY(@snowflake_file_sharing_poc.PUBLIC.snwoflake_parent_directory)<br>where upper(SPLIT_PART(RELATIVE_PATH, &#39;/&#39;,  0)) = upper(current_role());</pre><p><strong>Step 5: </strong>With the implementation in place, it’s time to validate the solution..</p><p>To simulate a real-world scenario, let’s create three department-specific roles — <strong>HR</strong>, <strong>MARKETING</strong>, and <strong>TECH</strong> — grant them access to the warehouse, database, schema, and the Secure View, and then create one user for each department. This setup allows us to verify that users only receive pre-signed URLs for documents belonging to their own department.</p><p>The following script creates the required roles, grants the necessary permissions, and provisions three test users:</p><pre>create or replace role HR;<br>create or replace role TECH;<br>create or replace role MARKETING;<br><br>grant usage on warehouse compute_Wh to role HR;<br>grant usage on warehouse compute_Wh to role TECH;<br>grant usage on warehouse compute_Wh to role MARKETING;<br><br>grant usage on database snowflake_file_sharing_poc to role HR;<br>grant usage on database snowflake_file_sharing_poc to role TECH;<br>grant usage on database snowflake_file_sharing_poc to role MARKETING;<br><br>grant usage on schema snowflake_file_sharing_poc.public to role HR;<br>grant usage on schema snowflake_file_sharing_poc.public to role TECH;<br>grant usage on schema snowflake_file_sharing_poc.public to role MARKETING;<br><br>grant select on view snowflake_file_sharing_poc.PUBLIC.file_sharing_view to role HR;<br>grant select on view snowflake_file_sharing_poc.PUBLIC.file_sharing_view to role TECH;<br>grant select on view snowflake_file_sharing_poc.PUBLIC.file_sharing_view to role MARKETING;<br><br><br>create or replace user jadu_hr password = &#39;123456&#39;;<br>grant role HR to user jadu_hr;<br><br>create or replace user mimo_marketing password = &#39;456789&#39;;<br>grant role MARKETING to user mimo_marketing;<br><br>create or replace user jimo_tech password = &#39;147258&#39;;<br>grant role TECH to user jimo_tech;</pre><p><strong>Step 6:</strong> Open the same Snowflake account in an <strong>Incognito window</strong>(or a <strong>different browser</strong>) and sign in as the <strong>HR</strong> user using the credentials below:</p><p><strong>Username:</strong> jadu_hr<br><strong>Password:</strong> 123456</p><p>Execute the below queries:</p><pre>use role HR;<br>use warehouse COMPUTE_WH;</pre><p>Now the HR Employee with username jadu_hr is ready to query our Snowflake Secure view —</p><pre>select * from snowflake_file_sharing_poc.PUBLIC.file_sharing_view;</pre><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*BJrNaiSX-slqmwJR5F97zg.png" /></figure><p>Observe the beauty of this approach — because the <strong>Secure View</strong> applies a row-level filter based on the user’s role, the HR user receives pre-signed URLs <strong>only</strong> for documents stored in the <strong>HR</strong> folder.</p><p>This gives us true <strong>row-level access control</strong>. Any number of users can be assigned the <strong>HR</strong> role, and they will only be able to view or download HR documents through the generated pre-signed URLs. They will never receive URLs for documents belonging to the Marketing or Technology departments, ensuring secure and scalable data sharing.</p><p>Another major advantage of this approach is that <strong>Secure Views</strong> prevent users from inspecting their underlying definition. Even if users have access to query the view, they cannot view its DDL or the SQL logic used to define it. This means there’s no risk of someone discovering — or attempting to bypass — the row-level filtering logic, such as:</p><pre>WHERE UPPER(SPLIT_PART(RELATIVE_PATH, &#39;/&#39;, 0)) = UPPER(CURRENT_ROLE())</pre><p>Since the Secure View definition is hidden from consumers, the access control logic remains protected, making the solution both secure and governance-friendly —</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*436TKg_LNWtjP63KTCxbPQ.png" /></figure><p>You can verify the same behavior by logging in with the other two users — <strong>mimo_marketing</strong> and <strong>jimo_tech</strong>. In each case, you’ll notice that the Secure View returns only the rows belonging to the user’s respective department, and consequently, only the corresponding pre-signed URLs are generated.</p><p>From a scalability perspective, this approach is equally elegant. Once the initial setup is in place, onboarding new users becomes extremely simple —</p><ul><li>If a user needs access to an existing department, the administrator only needs to grant the corresponding Snowflake role.</li><li>Similarly, onboarding a new department requires minimal effort. Simply create a new role with the same name as the department’s folder in the data lake, assign that role to the relevant users, and the existing Secure View logic automatically enforces the correct access control. No changes to the application logic or the view definition are required.</li></ul><p>In other words, day-to-day administration is reduced to role management, making the solution easy to operate and highly scalable as the organization grows —</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*iQmxR8tLHIzh1F_2wbCozQ.png" /></figure><h3>Conclusion</h3><p>In this blog, we explored how to build a scalable and secure file-sharing solution for unstructured data using native Snowflake capabilities. By combining <strong>pre-signed URLs</strong>, <strong>Secure Views</strong>, and <strong>role-based access control</strong>, we ensured that users can securely access only the documents they are authorized to view, while keeping the solution simple to govern and easy to scale as new users and departments are onboarded.</p><p>And if the same files need to be shared across multiple Snowflake accounts, you can simply create a <a href="https://docs.snowflake.com/en/user-guide/data-sharing-intro"><strong>Snowflake Data Share</strong></a> on top of the Secure View, extending the same security model seamlessly across accounts.</p><p>That’s all for this blog! For more such <strong>interesting and practical contents</strong>, follow me on <a href="https://www.youtube.com/@KnowledgeAmplifier1"><strong><em>YouTube</em></strong></a><strong>, </strong><a href="https://www.linkedin.com/in/satadru-mukherjee-a237b41a5/"><strong><em>LinkedIn</em></strong></a><strong>, and </strong><a href="https://medium.com/@satadru1998"><strong>Medium</strong></a>!</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=a0427d6beb72" width="1" height="1" alt=""><hr><p><a href="https://blog.dataengineerthings.org/architecting-secure-compliant-unstructured-data-file-sharing-solution-in-minutes-with-snowflake-a0427d6beb72">Architecting secure &amp; compliant Unstructured Data(File) Sharing solution in minutes with Snowflake</a> was originally published in <a href="https://blog.dataengineerthings.org">Data Engineer Things</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Join Order Matters! Understanding how Modern Query Optimizers Think]]></title>
            <link>https://blog.dataengineerthings.org/join-order-matters-understanding-how-modern-query-optimizers-think-a8b71745371d?source=rss-f1f3c866c4d5------2</link>
            <guid isPermaLink="false">https://medium.com/p/a8b71745371d</guid>
            <category><![CDATA[data-engineering]]></category>
            <category><![CDATA[cloud-computing]]></category>
            <category><![CDATA[query-optimization]]></category>
            <category><![CDATA[databricks]]></category>
            <category><![CDATA[snowflake]]></category>
            <dc:creator><![CDATA[Satadru]]></dc:creator>
            <pubDate>Fri, 10 Jul 2026 18:52:16 GMT</pubDate>
            <atom:updated>2026-08-07T17:56:44.031Z</atom:updated>
            <content:encoded><![CDATA[<p>Okay, I could have started this blog with a question: <strong>“<em>Does the order of tables in a SQL JOIN matter?</em>”</strong></p><p>But if you’re reading this blog, I’m pretty sure you already have some doubts about it. So, instead of keeping you in suspense until the end, let’s start with the answer itself.</p><p>Now, rather than simply accepting that answer, we’ll try to understand <strong>why it’s true</strong>. Throughout this blog, we’ll practically explore this with real scenarios, inspect the query execution plans, and build an intuition around whether the order in which we write our JOINs really matters!</p><h4>Theoretical Intuition:</h4><p>Let’s say we have three tables:</p><ul><li><strong>Table_A</strong> → 50 million rows</li><li><strong>Table_B</strong> → 30 million rows</li><li><strong>Table_C</strong> → 10 thousand rows</li></ul><p>And we’ve written the following query:</p><pre>Table_A INNER JOIN Table_B INNER JOIN Table_C</pre><p>So the default query engine will first join <strong>Table_A</strong> with <strong>Table_B</strong>, and then join the resulting dataset with <strong>Table_C</strong>, simply because that’s the order in which the joins are written —</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/897/1*BvxIVf7r8GZL-Nd4CoJ1Zg.png" /></figure><p>In other words, many developers expect the execution flow to look like this:</p><pre>(Table_A ⋈ Table_B) ⋈ Table_C</pre><p>Now, one of the most important factors in query performance is the <strong>size of the intermediate data</strong> produced during joins.</p><p>In our example, the query engine starts by joining <strong>Table_A(50 million rows)</strong> with <strong>Table_B(30 million rows)</strong>. Since both are large tables, there’s a good chance that a significant number of rows will match. Let’s assume this join produces an <strong>intermediate result of around 25 million rows</strong>.</p><p>Next, this <strong>25 million-row intermediate dataset</strong> is joined with <strong>Table_C</strong>, which contains only <strong>10 thousand rows</strong>. After applying the final join condition, suppose the query eventually returns just <strong>50 thousand rows</strong>.</p><p>So, the execution flow would look something like this:</p><pre>Table_A (50M)<br>      ⋈<br>Table_B (30M)<br>      │<br>      ▼<br>Intermediate Result (25M)<br>      ⋈<br>Table_C (10K)<br>      │<br>      ▼<br>Final Output (50K)</pre><p>Notice that although the final result contains only <strong>50 thousand rows</strong>, the engine had to process an <strong>intermediate dataset of 25 million rows</strong> before reaching that output. Processing, shuffling, and joining such a large intermediate result can significantly increase the query execution time.</p><p>Now, let’s consider a different execution plan for the <strong>same query —</strong></p><p>Instead of joining <strong>Table_A</strong> with <strong>Table_B</strong> first, imagine the optimizer decides to join <strong>Table_A</strong> with <strong>Table_C</strong>. Since <strong>Table_C</strong> contains only <strong>10 thousand rows</strong>, let’s assume this join produces an intermediate result of just <strong>80 thousand rows</strong> because only a small subset of rows from <strong>Table_A</strong> matches with <strong>Table_C</strong>!</p><p>Next, this <strong>80 thousand-row intermediate dataset</strong> is joined with <strong>Table_B</strong>(30 million rows). As expected, the final output is still <strong>50 thousand rows</strong>.</p><p>So, the execution flow becomes:</p><pre>Table_A (50M)<br>      ⋈<br>Table_C (10K)<br>      │<br>      ▼<br>Intermediate Result (80K)<br>      ⋈<br>Table_B (30M)<br>      │<br>      ▼<br>Final Output (50K)</pre><p>Notice that <strong>the final result is exactly the same</strong>, which is expected because we’re executing the same SQL query. However, the amount of <strong>intermediate data</strong> that the engine needs to process has been reduced dramatically — from <strong>25 million rows to just 80 thousand rows</strong>.</p><p>This reduction can have a significant impact on performance. Since one side of the second join is now very small, the optimizer may even choose a <strong>Broadcast Join</strong> instead of a more expensive <strong>Sort Merge Join</strong> or <strong>Shuffle Hash Join</strong>. A Broadcast Join avoids shuffling the large table across the cluster by broadcasting the smaller dataset to all executors, which can substantially reduce network I/O and overall query execution time.</p><p>This is precisely why modern cost-based optimizers don’t blindly follow the order in which joins are written. Instead, they look for a join order that minimizes intermediate data and enables more efficient join strategies whenever possible!</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*QznVUyZ0hN-3U8dJIorZnQ.png" /></figure><p>From this example, it may seem like we can draw two straightforward conclusions:</p><ol><li><strong>Join order matters!</strong></li><li><strong>When writing queries with multiple joins, always join the smallest tables first and then join the larger tables.</strong></li></ol><p>The intuition behind the second point is simple. If you join two smaller tables first, there’s a good chance that the resulting intermediate dataset will also remain relatively small. A smaller intermediate result means less data to process in subsequent joins and also increases the likelihood that the optimizer can choose a <strong>Broadcast Join</strong> instead of a more expensive <strong>Sort Merge Join</strong> or <strong>Shuffle Hash Join</strong>, leading to better overall performance.</p><h4>Let’s Validate It Practically</h4><p>Theory is great, but as data engineers, we should never stop there. Let’s validate whether the optimizer actually changes the join order in practice.</p><p>For this demonstration, we’ll use <strong>Snowflake</strong> and create three tables with significantly different sizes:</p><ul><li><strong>fact_sales</strong> – <strong>50 million</strong> rows (large fact table)</li><li><strong>dim_store</strong> – <strong>100 thousand</strong> rows (medium-sized dimension table)</li><li><strong>dim_city</strong> – <strong>100</strong> rows (small dimension table)</li></ul><p>This setup closely resembles a typical star schema that we often encounter in real-world data warehouses.</p><p>Let’s create the sample data first —</p><pre>CREATE OR REPLACE DATABASE demo_test;<br><br>USE demo_test;<br><br>-- City DIMENSION with 100 rows<br>CREATE OR REPLACE TABLE dim_city AS<br>SELECT<br>    seq4()+1 AS city_id,<br>    CASE<br>        WHEN seq4()=0 THEN &#39;Mumbai&#39;<br>        WHEN seq4()=1 THEN &#39;Delhi&#39;<br>        WHEN seq4()=2 THEN &#39;Bengaluru&#39;<br>        WHEN seq4()=3 THEN &#39;Hyderabad&#39;<br>        WHEN seq4()=4 THEN &#39;Chennai&#39;<br>        ELSE CONCAT(&#39;City_&#39;, seq4()+1)<br>    END AS city_name,<br>    CASE<br>        WHEN seq4()&lt;5 THEN &#39;Y&#39;<br>        ELSE &#39;N&#39;<br>    END AS is_metro<br>FROM TABLE(GENERATOR(ROWCOUNT=&gt;100));<br><br>--Store DIMENSION with 1 rows<br>CREATE OR REPLACE TABLE dim_store AS<br>SELECT<br>    seq4()+1 AS store_id,<br>    CONCAT(&#39;Store_&#39;, seq4()+1) AS store_name,<br>    UNIFORM(1,101,RANDOM()) AS city_id<br>FROM TABLE(GENERATOR(ROWCOUNT=&gt;100000));<br><br>--Sales Fact with 5 Millions rows<br>CREATE OR REPLACE TABLE fact_sales AS<br>SELECT<br>    seq4()+1 AS sale_id,<br>    UNIFORM(1,100001,RANDOM()) AS store_id,<br>    ROUND(UNIFORM(500,50000,RANDOM()),2) AS amount,<br>    UNIFORM(1,6,RANDOM()) AS quantity,<br>    DATEADD(<br>        day,<br>        -UNIFORM(0,365,RANDOM()),<br>        CURRENT_DATE()<br>    ) AS sale_date<br>FROM TABLE(GENERATOR(ROWCOUNT=&gt;50000000));</pre><p>To establish a baseline, let’s <strong>force Snowflake to follow the join order exactly as we’ve written it</strong>. We’ll do this using the <a href="https://docs.snowflake.com/en/release-notes/2025/other/2025-08-07-directed-join"><strong><em>DIRECTED</em></strong></a><strong><em> </em></strong>keyword, which prevents the optimizer from reordering the joins.</p><p>This allows us to observe the execution plan <strong>without any join-order optimization</strong>, making it much easier to compare with the optimizer-driven plan later —</p><pre>SELECT *<br>FROM fact_sales s<br>inner directed JOIN dim_store st<br>    ON s.store_id = st.store_id<br>inner directed JOIN dim_city c<br>    ON st.city_id = c.city_id<br>WHERE c.is_metro=&#39;Y&#39;;</pre><p>Check the Query Profile for the query —</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*RDa1xzO-pCKiMmhq31d8xg.png" /></figure><p>From Query Profile, we can have 2 important observations —</p><ul><li><strong>Total Query duration:</strong> 5.4 sec</li><li><strong>The first join took 50.3% of total time</strong> as both side data is huge, so most possibly there was no scope of Broadcast Join &amp; Snowflake compute had to go with huge data shuffle which took such time…</li></ul><p>Execute below queries to make sure cached data is not used —</p><pre>ALTER WAREHOUSE COMPUTE_WH SUSPEND;<br>ALTER WAREHOUSE COMPUTE_WH RESUME;<br>ALTER SESSION SET USE_CACHED_RESULT = FALSE;</pre><p>Now let’s execute the same query without directed keyword —</p><pre>SELECT *<br>FROM fact_sales s<br>JOIN dim_store st<br>    ON s.store_id = st.store_id<br>JOIN dim_city c<br>    ON st.city_id = c.city_id<br>WHERE c.is_metro=&#39;Y&#39;;</pre><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*a9BW-FJwj6FJs-NKLP9WTA.png" /></figure><p>From Query Profile, we can have 2 important observations —</p><ul><li><strong>Total Query duration:</strong> 1.2 sec</li><li>Snowflake Compute has changed the Join Order itself &amp; performed join between smaller DIM_CITY &amp; DIM_STORE together first &amp; then joined with bigger FACT_SALES Table(which is aligned with our theoretical intuition that, w<strong>hen writing queries with multiple joins, always join the smallest tables first and then join the larger tables!</strong></li><li>Due to very small data in 1 side of both the Join, Snowflake Compute might have got the chance to execute Broadcast Join eliminating the complex Shuffle Operation of Big Data, so this time, both join ran pretty fast!</li><li>Also we can not ignore the contribution of <a href="https://medium.com/data-engineer-things/join-optimization-in-snowflake-the-power-of-bloom-filters-ac78d7bddaea?sharedUserId=satadru1998"><strong><em>Bloom Filter</em></strong></a> in eliminating huge number of mismatching rows from large Sales Table in this case!!</li><li>Last but not the least, from the explanation itself, you can intuitively understand, <strong><em>Join reordering will be useful mostly for INNER and CROSS joins.</em></strong></li></ul><h4>Conclusion:</h4><p>So, in short,<strong><em> join order does matter!</em></strong> The good news is that modern query engines are smart enough to reorder joins and choose the most efficient execution plan whenever possible. However, the optimization strategy differs across compute platforms, and each engine has its own approach to join reordering.</p><p>If you’d like to dive deeper, I highly recommend reading the Snowflake engineering blog on <a href="https://www.snowflake.com/en/blog/engineering/query-acceleration-smarter-join-decisions/"><strong><em>Adaptive Join Strategy</em></strong></a>, where the Snowflake team explains their optimization approach in detail.</p><p>That’s all for this blog! For more such <strong>interesting and practical contents</strong>, follow me on <a href="https://www.youtube.com/@KnowledgeAmplifier1"><strong><em>YouTube</em></strong></a><strong>, </strong><a href="https://www.linkedin.com/in/satadru-mukherjee-a237b41a5/"><strong><em>LinkedIn</em></strong></a><strong>, and </strong><a href="https://medium.com/@satadru1998"><strong>Medium</strong></a>!</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=a8b71745371d" width="1" height="1" alt=""><hr><p><a href="https://blog.dataengineerthings.org/join-order-matters-understanding-how-modern-query-optimizers-think-a8b71745371d">Join Order Matters! Understanding how Modern Query Optimizers Think</a> was originally published in <a href="https://blog.dataengineerthings.org">Data Engineer Things</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Snowflake Query Optimizations You Hardly Notice - but They Make a Big Difference]]></title>
            <link>https://blog.dataengineerthings.org/snowflake-query-optimizations-you-hardly-notice-but-they-make-a-big-difference-860719728bd9?source=rss-f1f3c866c4d5------2</link>
            <guid isPermaLink="false">https://medium.com/p/860719728bd9</guid>
            <category><![CDATA[data-engineering]]></category>
            <category><![CDATA[snowflake]]></category>
            <category><![CDATA[query-optimization]]></category>
            <category><![CDATA[sql]]></category>
            <dc:creator><![CDATA[Satadru]]></dc:creator>
            <pubDate>Sat, 27 Jun 2026 19:31:14 GMT</pubDate>
            <atom:updated>2026-06-27T19:31:14.042Z</atom:updated>
            <content:encoded><![CDATA[<p>Modern data platforms process millions — even billions — of rows every day. As data volumes continue to grow, the efficiency of SQL queries becomes increasingly important. An inefficient query can consume more compute resources, scan unnecessary data, and increase execution time, all of which contribute to higher costs and slower performance.</p><p>Fortunately, modern query engines such as <a href="https://www.snowflake.com/en/"><strong><em>Snowflake</em></strong></a> don’t simply execute the SQL exactly as it is written. Before execution, the optimizer analyzes the query and performs a series of transformations to generate a more efficient execution plan. Many of these optimizations happen automatically and are completely transparent to the user.</p><p>In this article, we’ll take a simple SQL query and inspect its Query Profile to understand what Snowflake is doing behind the scenes. We’ll see how the optimizer rewrites parts of the execution plan, why those transformations improve performance, and how understanding them can help us write more efficient SQL and better interpret Query Profiles.</p><h3>Initial Setup:</h3><p>Let’s create a sample DB &amp; few Tables to explore automatic Query Optimization done by Snowflake compute —</p><pre>CREATE OR REPLACE DATABASE demo_test;<br><br>USE demo_test;<br><br>-- departments<br>CREATE OR REPLACE TABLE departments (<br>    dept_id INT,<br>    dept_name STRING<br>);<br><br>INSERT INTO departments VALUES<br>(1, &#39;Engineering&#39;),<br>(2, &#39;Marketing&#39;),<br>(3, &#39;Finance&#39;);<br><br>-- employees<br>CREATE OR REPLACE TABLE employees (<br>    emp_id INT,<br>    dept_id INT,<br>    salary NUMBER(10,2)<br>);<br><br>INSERT INTO employees VALUES<br>(101, 1, 60000),<br>(102, 1, 75000),<br>(103, 1, 82000),<br>(104, 1, 91000),<br>(105, 2, 55000),<br>(106, 2, 62000),<br>(107, 2, 68000),<br>(108, 3, 70000),<br>(109, 3, 76000),<br>(110, 3, 83000),<br>(111, NULL, 50000);</pre><p>Now let’s execute the below query &amp; observe the <strong>Query Profile</strong>:</p><pre>SELECT<br>    e.emp_id,<br>    d.dept_name,<br>    e.salary<br>FROM<br>(<br>    SELECT<br>        emp_id,<br>        dept_id,<br>        dept_id AS dept_id_new,<br>        salary<br>    FROM employees<br>    WHERE dept_id &gt; 1<br>) e<br>JOIN departments d<br>ON e.dept_id_new = d.dept_id and <br>e.dept_id &gt; 1;</pre><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*Yp7topxOEiMDUNKXXjDcBA.png" /></figure><h3><strong>Observation 1: Join Filter</strong></h3><figure><img alt="" src="https://cdn-images-1.medium.com/max/738/1*H3pxP0trJrHOjmXcIjbcEA.png" /></figure><p>Since the <strong>Departments</strong> table is significantly smaller than the <strong>Employees</strong> table, Snowflake can construct a <a href="https://youtu.be/06EnbT2eiY8?si=e-qXOj9BV35O4mUm"><strong>Bloom filter</strong></a><strong> </strong>using the DEPT_ID values from the Departments table. This Bloom filter is then applied while scanning the Employees table, allowing Snowflake to quickly eliminate rows whose DEPT_ID definitely does not exist in the Departments table. As a result, fewer rows participate in the join, reducing the amount of data processed and improving query performance! (This concept we explored in detail in <a href="https://medium.com/data-engineer-things/join-optimization-in-snowflake-the-power-of-bloom-filters-ac78d7bddaea?sharedUserId=satadru1998"><strong><em>this blog</em></strong></a>)</p><h3>Observation 2: New Filter generation</h3><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*dmwUvcOyBlqdiZ-CQWNYlQ.png" /></figure><p>If you observe our query, we explicitly applied only one filter on the <strong>Employees</strong> table:</p><pre>dept_id &gt; 1</pre><p>However, if you inspect the Query Profile, you’ll notice that Snowflake automatically introduces another predicate:</p><pre>EMPLOYEES.DEPT_ID IS NOT NULL</pre><p>This optimization is perfectly logical. Since we’re performing an <strong>INNER JOIN</strong> on DEPT_ID, any row where EMPLOYEES.DEPT_ID is NULL can never find a matching row in the Departments table. In SQL, NULL is not equal to any value—not even another NULL—so such rows are guaranteed to be excluded from the final result.</p><p>Recognizing this, Snowflake pushes the IS NOT NULL predicate down to the table scan itself. By filtering out these rows as early as possible, the optimizer reduces the amount of data that flows into the join operator, resulting in less work during query execution and improved overall performance!</p><h3>Observation 3: New Filter on “Other” side of the Join</h3><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*wO9io1o_ExP7iKo2mbL7EQ.png" /></figure><p>In our query, we applied the filter dept_id &gt; 1 only on the <strong>Employees</strong> table before joining it with the <strong>Departments</strong> table. At first glance, it may seem that this filter affects only the Employees table. However, the optimizer recognizes an important fact: since the join condition is Employees.dept_id = Departments.dept_id, any department with dept_id &lt;= 1 can never find a matching employee because those employee rows have already been filtered out.</p><p>Based on this observation, Snowflake automatically propagates the filter to the other side of the join by applying an equivalent predicate on the <strong>Departments</strong> table. As a result, rows with dept_id &lt;= 1 are discarded from the Departments table <strong>before</strong> the join takes place.</p><p>This optimization reduces the amount of data flowing into the join operator, allowing Snowflake to perform the join more efficiently. By eliminating rows that are guaranteed not to participate in the join, the optimizer minimizes unnecessary processing, leading to lower compute usage and faster query execution — all without requiring any changes to the SQL written by the user —</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1002/1*IEBUiwh2CQ4wPnXKp8qD1Q.png" /></figure><h3>Observation 4: Remove Redundant Filter</h3><p>If you look closely at the query, the predicate dept_id &gt; 1 is already applied inside the inner subquery on the <strong>Employees</strong> table. Therefore, by the time the rows reach the outer query, every record already satisfies this condition. Reapplying the same filter (e.dept_id &gt; 1) in the outer query serves no purpose. Snowflake&#39;s optimizer detects this redundancy and intelligently removes the duplicate predicate from the execution plan, resulting in a simpler and more efficient execution strategy while preserving the query semantics.</p><h3>Conclusion:</h3><p>Although we typically focus on writing correct SQL, there’s an equally important component working behind the scenes — the query optimizer. As we’ve seen throughout this article, Snowflake doesn’t simply execute the SQL exactly as it’s written. Instead, it analyzes the query, identifies opportunities to simplify execution, propagates predicates where possible, removes redundant filters, and eliminates unnecessary work before the query even starts running.</p><p>These optimizations may seem small in isolation, but together they can significantly reduce the amount of data processed, lower compute consumption, and improve query performance — all without requiring any changes to your SQL.</p><p>The next time you’re troubleshooting a slow query or exploring the Query Profile, don’t just look at the execution time. Take a moment to understand how Snowflake has transformed your query behind the scenes. You’ll often discover that the optimizer is doing far more work than meets the eye, and understanding these transformations can help you become a better SQL developer and a more effective Snowflake practitioner.</p><p>For more such <strong>interesting and practical contents</strong>, follow me on <a href="https://www.youtube.com/@KnowledgeAmplifier1"><strong><em>YouTube</em></strong></a><strong>, </strong><a href="https://www.linkedin.com/in/satadru-mukherjee-a237b41a5/"><strong><em>LinkedIn</em></strong></a><strong>, and </strong><a href="https://medium.com/@satadru1998"><strong>Medium</strong></a>!</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=860719728bd9" width="1" height="1" alt=""><hr><p><a href="https://blog.dataengineerthings.org/snowflake-query-optimizations-you-hardly-notice-but-they-make-a-big-difference-860719728bd9">Snowflake Query Optimizations You Hardly Notice - but They Make a Big Difference</a> was originally published in <a href="https://blog.dataengineerthings.org">Data Engineer Things</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
    </channel>
</rss>