<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Aniket Abhishek Soni</title>
    <description>The latest articles on DEV Community by Aniket Abhishek Soni (@aniketsoni).</description>
    <link>https://dev.to/aniketsoni</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3954381%2Fc17f147f-e19b-4160-be20-e2d4dd2af1dd.png</url>
      <title>DEV Community: Aniket Abhishek Soni</title>
      <link>https://dev.to/aniketsoni</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/aniketsoni"/>
    <language>en</language>
    <item>
      <title>Don't Let Unity Catalog Migration Become Your Resume Generating Event</title>
      <dc:creator>Aniket Abhishek Soni</dc:creator>
      <pubDate>Thu, 24 Sep 2026 14:29:20 +0000</pubDate>
      <link>https://dev.to/aniketsoni/dont-let-unity-catalog-migration-become-your-resume-generating-event-bjb</link>
      <guid>https://dev.to/aniketsoni/dont-let-unity-catalog-migration-become-your-resume-generating-event-bjb</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Why I chose this topic:&lt;/strong&gt; I spent three days last quarter fixing a "simple" metadata sync that locked 200 production jobs out of their own tables because of a misconfigured metastore credential. I’m writing this so you don’t have to drink the same lukewarm coffee at 3:00 AM that I did.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Two years ago, a junior engineer on my team ran a &lt;code&gt;GRANT ALL&lt;/code&gt; script on the wrong Hive metastore schema. It took forty minutes for the monitoring alerts to fire, and by then, the ETL pipelines had already overwritten four months of regulatory reporting data with nulls. We spent a week restoring from snapshots and explaining to the CISO why our lineage tracking looked like a crime scene.&lt;/p&gt;

&lt;p&gt;Migrating to Unity Catalog (UC) is the industry-standard fix for this chaos, but the migration itself is a minefield. You aren't just moving pointers; you are fundamentally changing how Spark identifies data. If you treat this like a simple "lift and shift," you’re going to wake up to a wall of &lt;code&gt;TABLE_OR_VIEW_NOT_FOUND&lt;/code&gt; errors across your entire job fleet.&lt;/p&gt;

&lt;h2&gt;
  
  
  The real problem
&lt;/h2&gt;

&lt;p&gt;The problem isn't the technology—it's the namespace. Hive metastore uses a two-level namespace: &lt;code&gt;database.table&lt;/code&gt;. Unity Catalog introduces a three-level namespace: &lt;code&gt;catalog.schema.table&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;When you migrate, you aren't just changing a config; you are rewriting the identity of every single asset in your environment. Most teams try to do this by alias-shifting or partial migrations. Don't. You will end up with "split-brain" syndrome, where your legacy jobs are looking at the Hive metastore while your modern jobs are looking at UC, and your internal governance team is having a heart attack because they can’t find the lineage for 40% of the data.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fimages.unsplash.com%2Fphoto-1585650719567-bc182ab616b7%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3w5NzU0MjJ8MHwxfHNlYXJjaHw5fHxicm9rZW4lMjBjaXJjdWl0JTIwYnJlYWtlcnxlbnwwfDB8fHwxNzkwMTk4NjIwfDA%26ixlib%3Drb-4.1.0%26q%3D80%26w%3D1080" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fimages.unsplash.com%2Fphoto-1585650719567-bc182ab616b7%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3w5NzU0MjJ8MHwxfHNlYXJjaHw5fHxicm9rZW4lMjBjaXJjdWl0JTIwYnJlYWtlcnxlbnwwfDB8fHwxNzkwMTk4NjIwfDA%26ixlib%3Drb-4.1.0%26q%3D80%26w%3D1080" alt="Photo by Werzk Luuuuuuu on Unsplash" width="1080" height="720"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Photo by &lt;a href="https://unsplash.com/@berzk?utm_source=articles_pipeline&amp;amp;utm_medium=referral" rel="noopener noreferrer"&gt;Werzk Luuuuuuu&lt;/a&gt; on &lt;a href="https://unsplash.com/?utm_source=articles_pipeline&amp;amp;utm_medium=referral" rel="noopener noreferrer"&gt;Unsplash&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  Step One: Inventory and namespace mapping
&lt;/h2&gt;

&lt;p&gt;You cannot migrate what you cannot count. Do not start by moving tables. Start by dumping your existing Hive metastore into a CSV. You need a source-of-truth mapping file that links your legacy &lt;code&gt;db.table&lt;/code&gt; to your new &lt;code&gt;catalog.schema.table&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;I use a simple PySpark script to iterate through the legacy catalog and generate a &lt;code&gt;JSON&lt;/code&gt; manifest. If a table doesn't have a clear home in the new structure, delete it. If you have legacy junk, use this migration as the excuse to purge it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Extract current state to mapping file
&lt;/span&gt;&lt;span class="n"&gt;databases&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;spark&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;catalog&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;listDatabases&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;mapping&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;

&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;databases&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;tables&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;spark&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;catalog&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;listTables&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;tables&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;mapping&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;legacy_name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;.&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;new_name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;prod_catalog.&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;.&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="p"&gt;})&lt;/span&gt;

&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;
&lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;migration_map.json&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;w&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dump&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;mapping&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step Two: Testing the UC connectivity layer
&lt;/h2&gt;

&lt;p&gt;Before you touch the production jobs, verify your cluster can actually talk to the UC-enabled workspace. Unity Catalog requires specific service principal permissions. If your cluster is still using Instance Profiles, you are going to hit an authentication wall.&lt;/p&gt;

&lt;p&gt;Ensure your &lt;code&gt;spark-conf&lt;/code&gt; includes the necessary authorization settings. If you’re using Databricks, verify that your cluster is running a runtime version that supports UC—anything below 11.3 LTS is a non-starter.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Verify spark configuration for UC&lt;/span&gt;
spark.databricks.io.cache.enabled &lt;span class="nb"&gt;true
&lt;/span&gt;spark.sql.catalog.spark_catalog com.databricks.sql.managedcatalog.UnityCatalog
spark.databricks.acl.dfAcls.enabled &lt;span class="nb"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you don't set &lt;code&gt;spark.sql.catalog.spark_catalog&lt;/code&gt;, your jobs will keep trying to default to the legacy Hive metastore, and you will spend hours debugging why your &lt;code&gt;SELECT&lt;/code&gt; statements are returning empty results for tables you know exist.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step Three: The phased transition with aliases
&lt;/h2&gt;

&lt;p&gt;Don't do a "Big Bang" migration. Use a phased approach where you re-point the &lt;code&gt;spark_catalog&lt;/code&gt; to UC, but keep a legacy catalog alias available for a fallback. &lt;/p&gt;

&lt;p&gt;Modify your production job deployment pipeline to accept a &lt;code&gt;CATALOG_NAME&lt;/code&gt; variable. If something goes sideways, you can flip the environment variable back to &lt;code&gt;hive_metastore&lt;/code&gt; in the CI/CD pipeline and redeploy in seconds, rather than manually editing 200 jobs.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# CI/CD variable structure&lt;/span&gt;
&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;etl_process&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;parameters&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;target_catalog&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;prod_catalog"&lt;/span&gt;
    &lt;span class="na"&gt;spark_conf&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;spark.sql.catalog.my_legacy_hive&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;hive_metastore"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In your code, switch from &lt;code&gt;SELECT * FROM db.table&lt;/code&gt; to &lt;code&gt;SELECT * FROM {target_catalog}.db.table&lt;/code&gt;. This abstraction layer is the only thing that saved my team from a total outage during our migration.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step Four: Validating the lineage and permissions
&lt;/h2&gt;

&lt;p&gt;Once the jobs are running on UC, you need to verify that your IAM roles moved correctly. Hive metastore is notoriously lax; Unity Catalog is strict. A common failure mode is that your job has permission to read the data in S3/ADLS but lacks the &lt;code&gt;USE CATALOG&lt;/code&gt; or &lt;code&gt;USE SCHEMA&lt;/code&gt; permissions in UC.&lt;/p&gt;

&lt;p&gt;Run this audit script immediately after your first successful job run. It checks if the principal actually has access to the underlying storage volume.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Permission audit snippet
&lt;/span&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;check_access&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;catalog&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;schema&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;table&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;spark&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sql&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;DESCRIBE TABLE &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;catalog&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;.&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;schema&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;.&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;table&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;show&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;
    &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;Exception&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;FAILED ACCESS: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;catalog&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;.&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;schema&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;.&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;table&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; - &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Lessons learned from production
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The "External Location" Trap:&lt;/strong&gt; If you don't explicitly define your S3/ADLS storage paths as External Locations in UC, your jobs will fail with a cryptic &lt;code&gt;PERMISSION_DENIED&lt;/code&gt; error, even if the underlying cloud IAM role is correct. You must grant &lt;code&gt;CREATE EXTERNAL TABLE&lt;/code&gt; on the location to the metastore admin.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Views are not Tables:&lt;/strong&gt; If you have complex Hive views, they will not migrate automatically. You have to recreate the DDL. If you try to copy the definition directly, you will hit issues with Hive-specific UDFs that don't exist in the UC namespace.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The Metadata Sync Lag:&lt;/strong&gt; When you sync your legacy metastore to UC, there is a synchronization window. Do not run any writes during this window. I once had a job write data to the legacy path while the sync was moving the schema to UC, resulting in a dual-path write that corrupted the partition metadata. &lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Global Temp Views:&lt;/strong&gt; If your jobs rely on &lt;code&gt;GLOBAL_TEMP_VIEW&lt;/code&gt;, be aware that these are not automatically migrated. They are session-bound in the legacy metastore. You need to refactor these into temporary tables or persistent schema tables.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;Migrating 200 production jobs is not a technical challenge; it is a discipline challenge. If you automate your mapping, abstract your catalog names, and test your permissions before you flip the switch, you can do this without downtime. If you try to hard-code your way through it, you’ll be the one explaining to management why the dashboard is blank on Monday morning.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Try it:&lt;/strong&gt; Take your lowest-traffic, non-critical production job today. Refactor it to support a variable &lt;code&gt;catalog&lt;/code&gt; parameter. If you can move that one job to Unity Catalog without breaking the output, you have a blueprint for the other 199.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Tags:&lt;/strong&gt; #databricks #spark #engineering #migration&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Cover photo by &lt;a href="https://unsplash.com/@tylergm?utm_source=articles_pipeline&amp;amp;utm_medium=referral" rel="noopener noreferrer"&gt;Tyler&lt;/a&gt; on &lt;a href="https://unsplash.com/?utm_source=articles_pipeline&amp;amp;utm_medium=referral" rel="noopener noreferrer"&gt;Unsplash&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>databricks</category>
      <category>spark</category>
      <category>engineering</category>
      <category>migration</category>
    </item>
    <item>
      <title>The Snowflake Iceberg Pivot: Why Your Data Warehouse Should Be a Storage Engine</title>
      <dc:creator>Aniket Abhishek Soni</dc:creator>
      <pubDate>Tue, 22 Sep 2026 10:18:24 +0000</pubDate>
      <link>https://dev.to/aniketsoni/the-snowflake-iceberg-pivot-why-your-data-warehouse-should-be-a-storage-engine-18kn</link>
      <guid>https://dev.to/aniketsoni/the-snowflake-iceberg-pivot-why-your-data-warehouse-should-be-a-storage-engine-18kn</guid>
      <description>&lt;p&gt;Ninety percent of the "performance" you pay for in Snowflake is actually just a tax on your inability to move your data. If you’re like most engineers I’ve worked with in fintech, you’ve convinced yourself that moving to an open table format like Iceberg means you have to go build a bespoke Spark/Trino stack on top of an S3 bucket and deal with the operational headache of catalog synchronization.&lt;/p&gt;

&lt;p&gt;You are wrong. And that laziness is costing your company a fortune.&lt;/p&gt;

&lt;p&gt;For years, we treated Snowflake as both the engine and the prison. We stored data in proprietary micro-partitions that were functionally invisible to every other tool in the stack. If you wanted to run a quick Python script to do some ML training, you had to clone the table, unload to CSV/Parquet, or pay for an expensive Snowflake compute cluster just to read a few gigabytes of rows. &lt;/p&gt;

&lt;p&gt;Snowflake-managed Iceberg tables change the math. They allow you to maintain the convenience of a managed service while storing the actual data files—the Parquet files—in your own cloud storage. You aren't just saving on storage costs; you are effectively decoupling your state from your compute.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the common approach falls short
&lt;/h2&gt;

&lt;p&gt;The "classic" Snowflake workflow is a black box. You &lt;code&gt;CREATE TABLE&lt;/code&gt; and hope for the best. When you leave, you’re forced to perform an &lt;code&gt;UNLOAD&lt;/code&gt; operation, which is essentially a massive export job that burns compute credits, messes with your time-travel metadata, and leaves you with a pile of flat files that lack the schema evolution capabilities your downstream apps need.&lt;/p&gt;

&lt;p&gt;I’ve seen junior engineers try to "optimize" this by spinning up AWS Glue crawlers and manual Athena partitions. The failure mode is predictable: the Snowflake metadata and the Glue catalog drift apart within three weeks. You end up with a "source of truth" that isn't true, a bunch of broken dashboards, and a panicked Friday afternoon spent patching manifest files.&lt;/p&gt;

&lt;p&gt;The common approach relies on the vendor’s proprietary binary format. If Snowflake goes down—or, more likely, if your bill hits a threshold where Finance starts asking questions—you are locked. You can't just point a Trino cluster at your S3 bucket and expect it to work. You need a migration strategy. With managed Iceberg tables, that migration strategy is just a standard SQL command.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fimages.unsplash.com%2Fphoto-1725381610848-a849b1e6a776%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3w5NzU0MjJ8MHwxfHNlYXJjaHwyNnx8aWNlYmVyZyUyMGluJTIwZGFyayUyMHdhdGVyfGVufDB8MHx8fDE3OTAwMjc2NjB8MA%26ixlib%3Drb-4.1.0%26q%3D80%26w%3D1080" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fimages.unsplash.com%2Fphoto-1725381610848-a849b1e6a776%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3w5NzU0MjJ8MHwxfHNlYXJjaHwyNnx8aWNlYmVyZyUyMGluJTIwZGFyayUyMHdhdGVyfGVufDB8MHx8fDE3OTAwMjc2NjB8MA%26ixlib%3Drb-4.1.0%26q%3D80%26w%3D1080" alt="Photo by Andris Gangis on Unsplash" width="1080" height="720"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Photo by &lt;a href="https://unsplash.com/@andrisgangis?utm_source=articles_pipeline&amp;amp;utm_medium=referral" rel="noopener noreferrer"&gt;Andris Gangis&lt;/a&gt; on &lt;a href="https://unsplash.com/?utm_source=articles_pipeline&amp;amp;utm_medium=referral" rel="noopener noreferrer"&gt;Unsplash&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  The mechanics of the shift
&lt;/h2&gt;

&lt;p&gt;Implementing this isn't a complex migration. It's a configuration change. You move from the default &lt;code&gt;TABLE&lt;/code&gt; type to an &lt;code&gt;ICEBERG&lt;/code&gt; table managed by Snowflake.&lt;/p&gt;

&lt;p&gt;When you execute:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;my_iceberg_table&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="n"&gt;STRING&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;event_time&lt;/span&gt; &lt;span class="nb"&gt;TIMESTAMP&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;payload&lt;/span&gt; &lt;span class="n"&gt;VARIANT&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="n"&gt;ICEBERG&lt;/span&gt;
&lt;span class="k"&gt;CATALOG&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'SNOWFLAKE'&lt;/span&gt;
&lt;span class="n"&gt;EXTERNAL_VOLUME&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'my_s3_external_volume'&lt;/span&gt;
&lt;span class="n"&gt;BASE_LOCATION&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'my_iceberg_data'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You are essentially telling Snowflake: "I want you to handle the heavy lifting of metadata management, compaction, and schema evolution, but keep the actual Parquet files in my bucket."&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;EXTERNAL_VOLUME&lt;/code&gt; is the key here. It defines the bridge between Snowflake and your S3/GCS bucket. Once this is set up, Snowflake acts as the "Engine," but the "Storage" lives in your VPC. If you decide to add a Trino cluster or a local DuckDB instance for quick testing, you just point them at the same S3 bucket. Because the table is using the Iceberg spec, your downstream engines understand the manifest files, the snapshot history, and the schema evolution without you needing to do a single &lt;code&gt;EXPORT&lt;/code&gt; job.&lt;/p&gt;

&lt;h2&gt;
  
  
  Real-world failure modes and how to fix them
&lt;/h2&gt;

&lt;p&gt;I’ve seen teams try to force this into every table they own. Don't do that. Managed Iceberg tables come with trade-offs.&lt;/p&gt;

&lt;p&gt;First, the "Snowflake-managed" label means exactly that. If you manually delete a Parquet file from your S3 bucket, Snowflake’s catalog will get corrupted. You cannot treat your underlying S3 storage as a playground. You must use Snowflake’s &lt;code&gt;ALTER TABLE ... REFRESH&lt;/code&gt; command to sync metadata if you’ve been messing with the files outside of the Snowflake engine.&lt;/p&gt;

&lt;p&gt;Second, consider your write patterns. If you have a high-frequency streaming ingestion (think 50,000 inserts per second), Snowflake’s native format is still optimized for that. Iceberg introduces overhead because it has to generate new manifest files for every transaction. If you try to run massive, small-batch transactions against an Iceberg table, you will see performance degradation compared to standard Snowflake tables. &lt;/p&gt;

&lt;p&gt;The strategy I use is "Tiered Persistence." My high-velocity staging tables remain in the proprietary format. Once the data reaches a state of "rest" (e.g., daily aggregates or cleaned facts), I move them into Iceberg managed tables for long-term storage and cross-engine accessibility.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fimages.unsplash.com%2Fphoto-1710981855156-1dd4b48e668d%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3w5NzU0MjJ8MHwxfHNlYXJjaHw0fHxkaWdpdGFsJTIwdmF1bHQlMjBkb29yfGVufDB8MHx8fDE3OTAwMjc2NjF8MA%26ixlib%3Drb-4.1.0%26q%3D80%26w%3D1080" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fimages.unsplash.com%2Fphoto-1710981855156-1dd4b48e668d%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3w5NzU0MjJ8MHwxfHNlYXJjaHw0fHxkaWdpdGFsJTIwdmF1bHQlMjBkb29yfGVufDB8MHx8fDE3OTAwMjc2NjF8MA%26ixlib%3Drb-4.1.0%26q%3D80%26w%3D1080" alt="Photo by David Trinks on Unsplash" width="1080" height="718"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Photo by &lt;a href="https://unsplash.com/@dtrinksrph?utm_source=articles_pipeline&amp;amp;utm_medium=referral" rel="noopener noreferrer"&gt;David Trinks&lt;/a&gt; on &lt;a href="https://unsplash.com/?utm_source=articles_pipeline&amp;amp;utm_medium=referral" rel="noopener noreferrer"&gt;Unsplash&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The objections (and my answers)
&lt;/h2&gt;

&lt;p&gt;The most common pushback I hear is, "But if I use Iceberg, I lose the proprietary Snowflake performance features like Search Optimization or Clustering Keys."&lt;/p&gt;

&lt;p&gt;Yes, you do. And that's the point. If you need Search Optimization for a dataset, that dataset is likely part of your core product loop. You should pay the premium for that. But for 80% of the data sitting in your warehouse—the cold data, the audit logs, the historical facts—you aren't actually using those features. You’re paying for them because you didn't have a choice. Now you do.&lt;/p&gt;

&lt;p&gt;Another objection: "It introduces complexity in IAM roles and bucket policies." &lt;/p&gt;

&lt;p&gt;True, setting up the &lt;code&gt;STORAGE INTEGRATION&lt;/code&gt; and the &lt;code&gt;EXTERNAL VOLUME&lt;/code&gt; is more work than just running &lt;code&gt;CREATE TABLE&lt;/code&gt;. You need to manage bucket policies, trust relationships, and VPC endpoints. But welcome to being a senior engineer. If you’re complaining about setting up an IAM role once to save your company 40% on their storage bill and gain absolute portability, you’re in the wrong seat.&lt;/p&gt;

&lt;p&gt;Finally, some fear that Snowflake will "deprioritize" Iceberg performance to push users back to native tables. My read on the market is the opposite. Snowflake is terrified of the "Databricks effect." They know they have to play nice with open standards or they become the new Oracle. Supporting Iceberg as a first-class citizen is their way of keeping you from leaving entirely.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;The goal of a senior engineer isn't to be a fanboy of a specific platform; it's to build a resilient system. Using Snowflake-managed Iceberg tables gives you the best of both worlds: the operational ease of a SaaS warehouse and the architectural sovereignty of open storage.&lt;/p&gt;

&lt;p&gt;Stop paying for storage hostage-taking. Start moving your production tables to Iceberg, configure your external volumes, and give your organization the option to pivot when the economics stop making sense. You get to keep your tooling, your query history, and your dashboards, but you stop being an indentured servant to a single vendor's storage format.&lt;/p&gt;

&lt;p&gt;In this business, control is the only currency that matters. If you can’t move your data without a massive, multi-week project, you don't own your data. Fix that today.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Cover photo by &lt;a href="https://unsplash.com/@a_pudov?utm_source=articles_pipeline&amp;amp;utm_medium=referral" rel="noopener noreferrer"&gt;Alex Pudov&lt;/a&gt; on &lt;a href="https://unsplash.com/?utm_source=articles_pipeline&amp;amp;utm_medium=referral" rel="noopener noreferrer"&gt;Unsplash&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>snowflake</category>
      <category>iceberg</category>
      <category>data</category>
      <category>architecture</category>
    </item>
    <item>
      <title>Stop trying to make Airflow work for Medallion pipelines</title>
      <dc:creator>Aniket Abhishek Soni</dc:creator>
      <pubDate>Mon, 21 Sep 2026 01:21:03 +0000</pubDate>
      <link>https://dev.to/aniketsoni/stop-trying-to-make-airflow-work-for-medallion-pipelines-15ge</link>
      <guid>https://dev.to/aniketsoni/stop-trying-to-make-airflow-work-for-medallion-pipelines-15ge</guid>
      <description>&lt;p&gt;The "universal orchestrator" is a myth that keeps data engineers awake at 3:00 AM chasing zombie DAGs and orphaned Spark clusters. You’ve been told that if you just pick the right tool—usually the one with the most GitHub stars—your medallion architecture will magically materialize into a self-healing, performant pipeline. &lt;/p&gt;

&lt;p&gt;I’ve spent six years cleaning up the aftermath of this delusion. I’ve seen healthcare pipelines stall because a Postgres metadata database hit its connection limit, and financial reconciliation jobs fail because a Lambda function timed out during a state transition. You don't need a "universal" tool; you need the right tool for the specific failure mode you’re willing to debug. Here is the field guide to keeping your bronze, silver, and gold layers moving without losing your mind.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Airflow is for people who love debugging Python environments
&lt;/h2&gt;

&lt;p&gt;If your organization has a massive infrastructure team dedicated solely to keeping the Airflow scheduler alive, fine. Use it. But for the rest of us, Airflow is a dependency hell machine. Between &lt;code&gt;pip&lt;/code&gt; conflicts in your worker nodes and the inherent fragility of the scheduler’s heartbeat, you are spending 40% of your time managing the orchestrator instead of the data. &lt;/p&gt;

&lt;p&gt;In a medallion pipeline, you want atomicity. Airflow doesn’t give you that; it gives you a task-based graph that fails halfway through a Bronze-to-Silver merge, leaving you to write custom cleanup logic that inevitably fails as well.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# The classic Airflow "oops" - dependency bloat
&lt;/span&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;airflow.operators.python&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;PythonOperator&lt;/span&gt;
&lt;span class="c1"&gt;# Oh, your task needs pandas 1.5 but the DAG next door needs 2.0? 
# Good luck with your virtualenv hell.
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fimages.unsplash.com%2Fphoto-1783683783819-e6cb806bba69%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3w5NzU0MjJ8MHwxfHNlYXJjaHwyOXx8c2VydmVyJTIwcmFja3xlbnwwfDB8fHwxNzg5ODQ5NjIyfDA%26ixlib%3Drb-4.1.0%26q%3D80%26w%3D1080" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fimages.unsplash.com%2Fphoto-1783683783819-e6cb806bba69%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3w5NzU0MjJ8MHwxfHNlYXJjaHwyOXx8c2VydmVyJTIwcmFja3xlbnwwfDB8fHwxNzg5ODQ5NjIyfDA%26ixlib%3Drb-4.1.0%26q%3D80%26w%3D1080" alt="Photo by Manuel Luikenga on Unsplash" width="1080" height="720"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Photo by &lt;a href="https://unsplash.com/@manuel_luikenga?utm_source=articles_pipeline&amp;amp;utm_medium=referral" rel="noopener noreferrer"&gt;Manuel Luikenga&lt;/a&gt; on &lt;a href="https://unsplash.com/?utm_source=articles_pipeline&amp;amp;utm_medium=referral" rel="noopener noreferrer"&gt;Unsplash&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  2. Step Functions are the ultimate "Set and Forget" for AWS shops
&lt;/h2&gt;

&lt;p&gt;If your Medallion architecture lives in S3 and interacts with Glue or EMR, AWS Step Functions are the only orchestrator that doesn't feel like a side project. You aren't managing a server; you're managing a state machine. The beauty here is &lt;code&gt;Wait for Callback&lt;/code&gt;. When I trigger a long-running Spark job, the state machine enters a paused state until the job reports back via an API call.&lt;/p&gt;

&lt;p&gt;Failure mode: You will eventually hit the execution history limit. If you have a pipeline that iterates through 10,000 files, you will blow past the 25,000-event limit for a single execution. Chunk your data, or you’ll be staring at a cryptic &lt;code&gt;ExecutionLimitExceeded&lt;/code&gt; error in the middle of a Friday deployment.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Task"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Resource"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"arn:aws:states:::elasticmapreduce:addStep.sync"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Parameters"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"ClusterId.$"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"$.ClusterId"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"Step"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;...&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Retry"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"ErrorEquals"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"States.TaskFailed"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"IntervalSeconds"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"MaxAttempts"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  3. Databricks Workflows is the only "Native" choice
&lt;/h2&gt;

&lt;p&gt;If you are running a Medallion pipeline on Delta Lake, stop using an external orchestrator. Databricks Workflows (the Jobs API) is built for this. It handles the cluster lifecycle, the notebook/JAR execution, and—most importantly—the underlying Delta commits. &lt;/p&gt;

&lt;p&gt;When you use an external tool like Airflow, you are disconnected from the cluster's health. When you use Databricks Workflows, the orchestration is aware of the cluster status. If a node goes down, the job retries efficiently. You avoid the "orphan cluster" problem where your orchestrator thinks a job succeeded, but the cluster died during the final &lt;code&gt;VACUUM&lt;/code&gt; command.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Don't build "God DAGs"
&lt;/h2&gt;

&lt;p&gt;The biggest mistake I see in medallion architectures is the monolithic pipeline. A single DAG that runs Bronze, Silver, and Gold in one long serial chain is a ticking time bomb. If your Gold transformation fails, you have to restart the whole damn thing, effectively re-processing Bronze and Silver again.&lt;/p&gt;

&lt;p&gt;Use decoupled triggers. Bronze completes, it emits a &lt;code&gt;FileArrived&lt;/code&gt; event (or a Databricks Job completion signal), and that triggers Silver. Use a simple event-driven model. If Silver fails, you fix the logic and trigger Silver again. You don't touch the Bronze layer.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. The Metadata Store is your source of truth, not the Orchestrator
&lt;/h2&gt;

&lt;p&gt;Never use your orchestrator’s database to track your data quality. I’ve seen people write custom Airflow variables to track &lt;code&gt;last_processed_timestamp&lt;/code&gt;. This is a disaster waiting to happen when you need to backfill or re-run a day of data.&lt;/p&gt;

&lt;p&gt;Instead, keep your state in the data itself. Use a &lt;code&gt;_metadata&lt;/code&gt; table in your Delta lake.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- Pattern: Watermarking in the Medallion layer&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;bronze&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;events&lt;/span&gt; 
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;ingestion_timestamp&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;MAX&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;processed_until&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;silver&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;watermark_table&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;By keeping the state in the table, the orchestrator becomes "dumb." It just says "Run the job," and the job asks the metadata table "Where did we leave off?" This makes your pipeline orchestrator-agnostic. You could rip out Airflow and replace it with a Cron job, and your data wouldn't care.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fimages.unsplash.com%2Fphoto-1612939684314-5dd23b244817%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3w5NzU0MjJ8MHwxfHNlYXJjaHwyNHx8bWVzc3klMjBzcGFnaGV0dGl8ZW58MHwwfHx8MTc4OTg0OTYyM3ww%26ixlib%3Drb-4.1.0%26q%3D80%26w%3D1080" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fimages.unsplash.com%2Fphoto-1612939684314-5dd23b244817%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3w5NzU0MjJ8MHwxfHNlYXJjaHwyNHx8bWVzc3klMjBzcGFnaGV0dGl8ZW58MHwwfHx8MTc4OTg0OTYyM3ww%26ixlib%3Drb-4.1.0%26q%3D80%26w%3D1080" alt="Photo by Fahmi Anwar on Unsplash" width="1080" height="721"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Photo by &lt;a href="https://unsplash.com/@fahmisamidi?utm_source=articles_pipeline&amp;amp;utm_medium=referral" rel="noopener noreferrer"&gt;Fahmi Anwar&lt;/a&gt; on &lt;a href="https://unsplash.com/?utm_source=articles_pipeline&amp;amp;utm_medium=referral" rel="noopener noreferrer"&gt;Unsplash&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  6. Observability is not "Success/Failure"
&lt;/h2&gt;

&lt;p&gt;Stop measuring success by the green checkmark in the UI. A job can finish "successfully" while loading empty data or corrupting a partition. Your orchestrator needs to gate-keep based on Data Quality (DQ) thresholds. &lt;/p&gt;

&lt;p&gt;In Databricks, use &lt;code&gt;dbt&lt;/code&gt; or &lt;code&gt;Great Expectations&lt;/code&gt; as a task in your workflow. If the DQ check fails, the job fails. Don't let the pipeline proceed to the Gold layer if the Silver layer has null IDs.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Databricks Workflows JSON config&lt;/span&gt;
&lt;span class="na"&gt;tasks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;task_key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;dq_check"&lt;/span&gt;
    &lt;span class="na"&gt;notebook_task&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;notebook_path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/Tests/Silver_Validation"&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;task_key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;gold_load"&lt;/span&gt;
    &lt;span class="na"&gt;depends_on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;task_key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;dq_check"&lt;/span&gt; &lt;span class="c1"&gt;# This is your primary circuit breaker&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;The orchestrator you choose matters less than how you decouple your logic. If you are deeply invested in Databricks, use their native Workflows—it’s the path of least resistance. If you are doing multi-cloud, multi-service orchestration, use Step Functions for their durability. If you are in a massive Python-heavy org that already has an Airflow platform team, stay there, but keep your pipelines granular and stateless.&lt;/p&gt;

&lt;p&gt;The real question isn't "Which tool is best?" but rather: how much of your pipeline logic is stuck in your orchestrator, and how fast can you delete it when the tool inevitably goes out of fashion? Are you building a data platform, or are you just building a very expensive Airflow configuration file?&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Cover photo by &lt;a href="https://unsplash.com/@kmitchhodge?utm_source=articles_pipeline&amp;amp;utm_medium=referral" rel="noopener noreferrer"&gt;K. Mitch Hodge&lt;/a&gt; on &lt;a href="https://unsplash.com/?utm_source=articles_pipeline&amp;amp;utm_medium=referral" rel="noopener noreferrer"&gt;Unsplash&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>data</category>
      <category>airflow</category>
      <category>databricks</category>
      <category>aws</category>
    </item>
    <item>
      <title>How Databricks Serverless Compute Cost My Team $14k in One Weekend</title>
      <dc:creator>Aniket Abhishek Soni</dc:creator>
      <pubDate>Mon, 21 Sep 2026 01:20:58 +0000</pubDate>
      <link>https://dev.to/aniketsoni/how-databricks-serverless-compute-cost-my-team-14k-in-one-weekend-1f9g</link>
      <guid>https://dev.to/aniketsoni/how-databricks-serverless-compute-cost-my-team-14k-in-one-weekend-1f9g</guid>
      <description>&lt;p&gt;It’s Sunday, 2:14 AM. The PagerDuty alert hits my phone with that specific, jarring frequency that makes your stomach drop before you’ve even opened your eyes. My Databricks billing alert wasn’t a standard "usage threshold reached" notification; it was the "you’ve hit 80% of your monthly cloud spend in 48 hours" panic text.&lt;/p&gt;

&lt;p&gt;I sat up, opened the Databricks console, and stared at the Billing page. Our &lt;code&gt;sql_warehouse_prod_v2&lt;/code&gt; was burning DBU (Databricks Units) like it was a crypto-mining operation. We had shipped a new pipeline on Friday, it passed CI, the integration tests looked green, and the data landed on time. But while we were sleeping, the bill was growing faster than our debt in college.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we saw
&lt;/h2&gt;

&lt;p&gt;The symptom was simple: our bill went vertical. The dashboard showed a steady, flat line for the past three months, followed by a spike that looked like the edge of a cliff.&lt;/p&gt;

&lt;p&gt;My first assumption was a runaway loop in a Python job. I checked the cluster logs for &lt;code&gt;dbr 13.3 LTS&lt;/code&gt;. Nothing. Then I checked the &lt;code&gt;spark_query_history&lt;/code&gt;. Nothing out of the ordinary—just the standard daily ingestion patterns.&lt;/p&gt;

&lt;p&gt;The false lead was the "Auto-stop" setting. I looked at the UI for the Serverless SQL Warehouse and saw &lt;code&gt;Auto-stop: 10 minutes&lt;/code&gt;. In my head, this was bulletproof. If the warehouse isn't doing anything, it shuts down. The math seemed solid: 10 minutes of idle time is negligible. &lt;/p&gt;

&lt;p&gt;But I was looking at the wrong metric. I was looking at the &lt;em&gt;cluster&lt;/em&gt; state, not the &lt;em&gt;session&lt;/em&gt; state. The warehouse wasn't idling; it was being kept alive by a ghost.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fimages.unsplash.com%2Fphoto-1620109177589-677c8d0ceba6%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3w5NzU0MjJ8MHwxfHNlYXJjaHwyOHx8ZW1wdHklMjB3YWxsZXR8ZW58MHwwfHx8MTc4OTY3OTU3M3ww%26ixlib%3Drb-4.1.0%26q%3D80%26w%3D1080" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fimages.unsplash.com%2Fphoto-1620109177589-677c8d0ceba6%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3w5NzU0MjJ8MHwxfHNlYXJjaHwyOHx8ZW1wdHklMjB3YWxsZXR8ZW58MHwwfHx8MTc4OTY3OTU3M3ww%26ixlib%3Drb-4.1.0%26q%3D80%26w%3D1080" alt="Photo by Kisetsu Co on Unsplash" width="1080" height="737"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Photo by &lt;a href="https://unsplash.com/@kisetsuco?utm_source=articles_pipeline&amp;amp;utm_medium=referral" rel="noopener noreferrer"&gt;Kisetsu Co&lt;/a&gt; on &lt;a href="https://unsplash.com/?utm_source=articles_pipeline&amp;amp;utm_medium=referral" rel="noopener noreferrer"&gt;Unsplash&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Root cause
&lt;/h2&gt;

&lt;p&gt;The culprit was a hidden interaction between our BI tool (a standard Tableau integration) and the Databricks Serverless SQL Warehouse. We had migrated to Serverless because we wanted the "instant-on" experience. &lt;/p&gt;

&lt;p&gt;In a traditional cluster, the warehouse would have eventually hit a resource contention limit or a timeout. But Serverless is designed to stay available. We had a dashboard connection string configured with &lt;code&gt;Catalog&lt;/code&gt; and &lt;code&gt;Schema&lt;/code&gt; settings that were hitting a &lt;code&gt;system.information_schema&lt;/code&gt; query on a heartbeat interval.&lt;/p&gt;

&lt;p&gt;Because we used a service principal with broad &lt;code&gt;CAN USE&lt;/code&gt; permissions, the connection remained active. The Databricks Serverless SQL Warehouse interpreted these heartbeat pings as "active queries." &lt;/p&gt;

&lt;p&gt;Crucially, the &lt;code&gt;Auto-stop&lt;/code&gt; setting only triggers when the warehouse is truly idle. Because the heartbeat hit the SQL warehouse every 8 minutes, the 10-minute timer reset itself into infinity. The warehouse never hit its idle threshold. It wasn't "stuck" in a loop; it was being held hostage by a silent, low-latency heartbeat that didn't even show up in our main performance monitoring because it was sub-millisecond.&lt;/p&gt;

&lt;p&gt;We were paying for a "Large" size warehouse—which runs at a significantly higher DBU rate—to serve a heartbeat ping that could have been handled by a "Starter" size or, better yet, a cached metadata call.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fimages.unsplash.com%2Fphoto-1535320903710-d993d3d77d29%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3w5NzU0MjJ8MHwxfHNlYXJjaHwxNHx8ZmluYW5jaWFsJTIwZGFzaGJvYXJkJTIwY2hhcnR8ZW58MHwwfHx8MTc4OTY3OTU3NHww%26ixlib%3Drb-4.1.0%26q%3D80%26w%3D1080" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fimages.unsplash.com%2Fphoto-1535320903710-d993d3d77d29%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3w5NzU0MjJ8MHwxfHNlYXJjaHwxNHx8ZmluYW5jaWFsJTIwZGFzaGJvYXJkJTIwY2hhcnR8ZW58MHwwfHx8MTc4OTY3OTU3NHww%26ixlib%3Drb-4.1.0%26q%3D80%26w%3D1080" alt="Photo by m. on Unsplash" width="1080" height="720"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Photo by &lt;a href="https://unsplash.com/@m_____me?utm_source=articles_pipeline&amp;amp;utm_medium=referral" rel="noopener noreferrer"&gt;m.&lt;/a&gt; on &lt;a href="https://unsplash.com/?utm_source=articles_pipeline&amp;amp;utm_medium=referral" rel="noopener noreferrer"&gt;Unsplash&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;

&lt;p&gt;The immediate fix was to kill the connection from the BI tool side and force the warehouse to scale down. I manually set the &lt;code&gt;Auto-stop&lt;/code&gt; to 1 minute to ensure it would die instantly once the connection was severed.&lt;/p&gt;

&lt;p&gt;Then, we had to redefine the warehouse configuration. We abandoned the single large warehouse for BI and split the workloads. We created a "Serverless-Small" for the heartbeat-heavy dashboarding and kept the "Large" warehouse strictly for ad-hoc analyst queries and heavy ELT transformations.&lt;/p&gt;

&lt;p&gt;We also updated our connection string in the BI tool to point to a specific &lt;code&gt;Unity Catalog&lt;/code&gt; schema that didn't require the broad &lt;code&gt;information_schema&lt;/code&gt; scanning that was triggering our phantom queries.&lt;/p&gt;

&lt;p&gt;Finally, I implemented a &lt;code&gt;Tag&lt;/code&gt; policy. Databricks allows you to add &lt;code&gt;custom_tags&lt;/code&gt; to your SQL warehouses. I added &lt;code&gt;CostCenter: Finance&lt;/code&gt; and &lt;code&gt;Owner: DataEng&lt;/code&gt;. While this didn't stop the spending, it allowed me to isolate the DBU consumption at the warehouse level in the billing export CSVs within minutes, rather than waiting for the bill to aggregate.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we changed so it never happens again
&lt;/h2&gt;

&lt;p&gt;We stopped trusting the "Serverless is magic" marketing. Serverless means you don't manage the nodes, but you absolutely have to manage the session lifecycle.&lt;/p&gt;

&lt;p&gt;First, we implemented a strict "Warehouse Sizing" policy. No production warehouse is allowed to be larger than "Medium" unless it has an explicit, documented exemption in the Terraform repo. If you want a "Large" or "X-Large" warehouse, you have to open a PR, and the CI pipeline runs a cost-estimate check using the Databricks Billing API to flag the daily run-rate.&lt;/p&gt;

&lt;p&gt;Second, we moved away from generic service principals for BI tools. We now use scoped service principals with &lt;code&gt;READ ONLY&lt;/code&gt; access to specific schemas. This prevents the BI tool from querying the wider &lt;code&gt;information_schema&lt;/code&gt; or &lt;code&gt;system&lt;/code&gt; catalogs that trigger those hidden, expensive backend processes.&lt;/p&gt;

&lt;p&gt;Third, we set up a "Budget Alarm" using a Lambda function that polls the Databricks billing data every 6 hours. If the daily burn rate exceeds a 20% variance from the 7-day rolling average, it fires a high-priority alert into our Slack &lt;code&gt;#ops-alerts&lt;/code&gt; channel. &lt;/p&gt;

&lt;p&gt;Lastly, I learned to never, ever set an &lt;code&gt;Auto-stop&lt;/code&gt; to anything longer than 5 minutes for non-critical workloads. In the world of Serverless, that 5-minute window is the difference between a productive team and a very uncomfortable conversation with your CFO.&lt;/p&gt;

&lt;p&gt;You ship the code, the tests pass, and the data is correct. That’s the easy part. The hard part is ensuring that the infrastructure—the invisible, elastic, "serverless" part—doesn't treat your credit card like a bottomless well. Keep your warehouses small, your permissions tight, and your heartbeats monitored.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Cover photo by &lt;a href="https://unsplash.com/@kevinache?utm_source=articles_pipeline&amp;amp;utm_medium=referral" rel="noopener noreferrer"&gt;Kevin Ache&lt;/a&gt; on &lt;a href="https://unsplash.com/?utm_source=articles_pipeline&amp;amp;utm_medium=referral" rel="noopener noreferrer"&gt;Unsplash&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>databricks</category>
      <category>cloud</category>
      <category>finops</category>
      <category>data</category>
    </item>
    <item>
      <title>Is Your Data Lake Actually A Landfill?</title>
      <dc:creator>Aniket Abhishek Soni</dc:creator>
      <pubDate>Wed, 16 Sep 2026 10:25:13 +0000</pubDate>
      <link>https://dev.to/aniketsoni/is-your-data-lake-actually-a-landfill-12g1</link>
      <guid>https://dev.to/aniketsoni/is-your-data-lake-actually-a-landfill-12g1</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Why I chose this topic:&lt;/strong&gt; I spent three weeks debugging a "simple" read query that took four minutes to return ten rows because the metadata layer had ballooned to 200,000 snapshots. I’m writing this so you don’t have to explain to your CTO why the data platform is hemorrhaging AWS credits.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;You ship the job. It passes CI. Your dbt tests are green, and the data lands in S3 right on time. Then, three weeks later, your Spark jobs start failing with &lt;code&gt;java.lang.OutOfMemoryError&lt;/code&gt; during metadata initialization, and your query latency looks like a jagged mountain range.&lt;/p&gt;

&lt;p&gt;You aren’t experiencing a "data growth" problem. You are experiencing a "neglect" problem. Apache Iceberg is elegant, but it isn’t magic. If you treat it like a traditional Hive table—set and forget—you are effectively building a digital landfill.&lt;/p&gt;

&lt;p&gt;Every time you commit a transaction, Iceberg creates a new snapshot. Every time you overwrite a partition, you leave behind orphaned files in your object storage. Left unchecked, the metadata layer becomes so heavy that your catalog service will choke, and your cloud provider will send you a bill that makes you wish you’d stayed in the finance sector.&lt;/p&gt;

&lt;h2&gt;
  
  
  The real problem
&lt;/h2&gt;

&lt;p&gt;The problem isn't the data volume itself; it’s the fragmentation and the metadata overhead. When you perform frequent streaming writes or micro-batching, you create hundreds of tiny files. These files are the death of performance. Your query engine’s planner has to do more work just to resolve the table state than it does to actually scan the data.&lt;/p&gt;

&lt;p&gt;Furthermore, Iceberg doesn't automatically delete the data you tell it to "remove." It marks it as expired in the metadata. If you don't prune those snapshots and physically delete the files, you are paying for storage that no longer exists in your current view of reality. It’s a classic "zombie file" scenario.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fimages.unsplash.com%2Fphoto-1730933922133-15f6d196659e%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3w5NzU0MjJ8MHwxfHNlYXJjaHwxM3x8Z2xvd2luZyUyMHNlcnZlciUyMHJhY2tzfGVufDB8MHx8fDE3ODk1MDY1Njh8MA%26ixlib%3Drb-4.1.0%26q%3D80%26w%3D1080" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fimages.unsplash.com%2Fphoto-1730933922133-15f6d196659e%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3w5NzU0MjJ8MHwxfHNlYXJjaHwxM3x8Z2xvd2luZyUyMHNlcnZlciUyMHJhY2tzfGVufDB8MHx8fDE3ODk1MDY1Njh8MA%26ixlib%3Drb-4.1.0%26q%3D80%26w%3D1080" alt="Photo by Anton Acosta on Unsplash" width="1080" height="720"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Photo by &lt;a href="https://unsplash.com/@ottelo?utm_source=articles_pipeline&amp;amp;utm_medium=referral" rel="noopener noreferrer"&gt;Anton Acosta&lt;/a&gt; on &lt;a href="https://unsplash.com/?utm_source=articles_pipeline&amp;amp;utm_medium=referral" rel="noopener noreferrer"&gt;Unsplash&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  Step: Compacting the small file disaster
&lt;/h2&gt;

&lt;p&gt;Small files are the primary reason your S3 &lt;code&gt;LIST&lt;/code&gt; calls take forever. Use Spark’s &lt;code&gt;rewriteDataFiles&lt;/code&gt; procedure. Don't run this on every single commit unless you want to crash your cluster. Schedule it. &lt;/p&gt;

&lt;p&gt;I prefer the &lt;code&gt;bin-pack&lt;/code&gt; strategy for general purpose, but use &lt;code&gt;sort&lt;/code&gt; if you have high-cardinality filters in your &lt;code&gt;WHERE&lt;/code&gt; clauses.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CALL&lt;/span&gt; &lt;span class="k"&gt;catalog&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;system&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;rewrite_data_files&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="k"&gt;table&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'db.my_table'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;strategy&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'sort'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;sort_order&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'event_timestamp DESC'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="k"&gt;options&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s1"&gt;'min-input-files'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'5'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'max-concurrent-file-group-rewrites'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'10'&lt;/span&gt;
  &lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you’re running this on EMR or Databricks, monitor the &lt;code&gt;rewrite-data-files&lt;/code&gt; job memory. If it fails with OOM, increase your executor memory; don't just reduce the number of files. You want fewer, larger files (aim for 128MB to 512MB), not more, smaller ones.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step: Expiring snapshots with a death clock
&lt;/h2&gt;

&lt;p&gt;Metadata accumulates linearly. If you are doing 50 writes a day, you have 18,000+ snapshots a year. Your catalog (Glue, Nessie, or Postgres) will eventually hit a latency wall trying to load the table state. &lt;/p&gt;

&lt;p&gt;You need a strict retention policy. I typically default to a 7-day retention period. If you need to "time travel" further back than a week, you’re doing audit logs wrong—keep those in a separate, immutable table.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CALL&lt;/span&gt; &lt;span class="k"&gt;catalog&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;system&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;expire_snapshots&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="k"&gt;table&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'db.my_table'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;older_than&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;timestamp&lt;/span&gt; &lt;span class="s1"&gt;'2023-10-20 00:00:00.000'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;retain_last&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note the &lt;code&gt;retain_last =&amp;gt; 5&lt;/code&gt;. This ensures that even if you accidentally drop the table or run an aggressive expiry, you always have a safety net of the most recent commits. Never set this to zero unless you are decommissioning the table.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step: Cleaning up the orphans
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;expire_snapshots&lt;/code&gt; removes the reference, but the physical files might still exist in S3 if you don't run the cleanup process. If you have "orphaned" files—files that exist in the storage layer but aren't in the metadata—you’re burning money.&lt;/p&gt;

&lt;p&gt;Use &lt;code&gt;remove_orphan_files&lt;/code&gt;. Be careful with the &lt;code&gt;older_than&lt;/code&gt; parameter. If you run this while a long-running read query is still scanning the table, you might delete files that the reader is currently trying to access, leading to a &lt;code&gt;FileNotFoundException&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CALL&lt;/span&gt; &lt;span class="k"&gt;catalog&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;system&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;remove_orphan_files&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="k"&gt;table&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'db.my_table'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;older_than&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;current_timestamp&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;interval&lt;/span&gt; &lt;span class="s1"&gt;'3'&lt;/span&gt; &lt;span class="n"&gt;days&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I always leave a 72-hour buffer here. It’s safer to pay for three days of ghost storage than to have a production job fail because a partition was mid-read when the janitor job kicked in.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fimages.unsplash.com%2Fphoto-1543470373-e055b73a8f29%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3w5NzU0MjJ8MHwxfHNlYXJjaHwxfHxpY2ViZXJnJTIwaW4lMjBvY2VhbnxlbnwwfDB8fHwxNzg5NTA2NTY5fDA%26ixlib%3Drb-4.1.0%26q%3D80%26w%3D1080" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fimages.unsplash.com%2Fphoto-1543470373-e055b73a8f29%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3w5NzU0MjJ8MHwxfHNlYXJjaHwxfHxpY2ViZXJnJTIwaW4lMjBvY2VhbnxlbnwwfDB8fHwxNzg5NTA2NTY5fDA%26ixlib%3Drb-4.1.0%26q%3D80%26w%3D1080" alt="Photo by Annie Spratt on Unsplash" width="1080" height="720"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Photo by &lt;a href="https://unsplash.com/@anniespratt?utm_source=articles_pipeline&amp;amp;utm_medium=referral" rel="noopener noreferrer"&gt;Annie Spratt&lt;/a&gt; on &lt;a href="https://unsplash.com/?utm_source=articles_pipeline&amp;amp;utm_medium=referral" rel="noopener noreferrer"&gt;Unsplash&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Lessons learned from production
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Glue Catalog Throttling:&lt;/strong&gt; If you are using AWS Glue as your Iceberg catalog, frequent maintenance triggers will hit the Glue API limits. Increase your Glue throughput quotas before you scale your maintenance jobs, or expect constant 400/500 errors.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The &lt;code&gt;write.format.default&lt;/code&gt; trap:&lt;/strong&gt; If you are migrating from Parquet to Iceberg, ensure your maintenance jobs aren't silently converting back to Parquet via session defaults. Always verify the output format in your &lt;code&gt;DESCRIBE DETAIL&lt;/code&gt; output.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Maintenance shouldn't be "on-write":&lt;/strong&gt; Everyone wants to set &lt;code&gt;write.metadata.delete-after-commit.enabled = true&lt;/code&gt;. Don't. It makes your ingestion jobs brittle. Decouple maintenance into a separate, scheduled Airflow DAG.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Monitoring is non-negotiable:&lt;/strong&gt; If your maintenance job fails, it doesn't alert you by default unless you hook it into your logging stack. Pipe the Spark logs from these maintenance procedures into Datadog/CloudWatch. A silent failure here is a ticking time bomb for your storage costs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Know your partition evolution:&lt;/strong&gt; If you change your partitioning scheme, your compaction strategy must change. Compacting across old and new partition specs can be computationally expensive. Read the docs on &lt;code&gt;rewrite_position_delete_files&lt;/code&gt; if you’re doing heavy deletions or updates.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;Iceberg is a powerful tool, but it is not a "set-and-forget" database. It is a complex distributed file system management task disguised as a SQL table. If you don't treat your maintenance jobs with the same rigor as your ingestion pipelines, you’re not an engineer—you’re just a temporary occupant waiting for the system to collapse under its own weight.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Try it:&lt;/strong&gt; Run &lt;code&gt;SELECT count(*) FROM "db.my_table.snapshots"&lt;/code&gt; in your query engine today. If the number is in the thousands, stop what you are doing, write a cleanup script, and schedule it immediately. Your cloud bill will thank you.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Cover photo by &lt;a href="https://unsplash.com/@bodegasupply?utm_source=articles_pipeline&amp;amp;utm_medium=referral" rel="noopener noreferrer"&gt;Bodega&lt;/a&gt; on &lt;a href="https://unsplash.com/?utm_source=articles_pipeline&amp;amp;utm_medium=referral" rel="noopener noreferrer"&gt;Unsplash&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>iceberg</category>
      <category>data</category>
      <category>engineering</category>
      <category>spark</category>
    </item>
    <item>
      <title>Why are you still debugging Spark on your host machine?</title>
      <dc:creator>Aniket Abhishek Soni</dc:creator>
      <pubDate>Mon, 14 Sep 2026 22:54:06 +0000</pubDate>
      <link>https://dev.to/aniketsoni/why-are-you-still-debugging-spark-on-your-host-machine-141e</link>
      <guid>https://dev.to/aniketsoni/why-are-you-still-debugging-spark-on-your-host-machine-141e</guid>
      <description>&lt;p&gt;Last October, a "minor" dependency bump in a PySpark job cost us four hours of downtime and roughly $12,000 in cloud compute credits. A developer had tested a new UDF locally using an older version of &lt;code&gt;delta-spark&lt;/code&gt; than what we ran on our EMR cluster. Locally, the serialization worked fine. In production, the different Scala/Java versions in the underlying runtime caused a &lt;code&gt;java.io.NotSerializableException&lt;/code&gt; that only manifested when the data volume hit a specific shuffle threshold. &lt;/p&gt;

&lt;p&gt;We spent hours tailing logs and staring at obscure stack traces while the pipeline backed up. I swore then that if a dev’s laptop didn't look exactly like the cluster, they weren't allowed to ship.&lt;/p&gt;

&lt;p&gt;You are currently deciding between two paths: staying in the "my-machine-is-special" hell of managing local Java/Scala/Python versions, or biting the bullet to containerize your local development environment. You think the latter is overkill. You think it's too much configuration overhead. You're wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  The contenders
&lt;/h2&gt;

&lt;p&gt;You have two real options for local Spark/Delta Lake development. &lt;/p&gt;

&lt;p&gt;The first is the &lt;strong&gt;"Host-Native" approach&lt;/strong&gt;: You install &lt;code&gt;openjdk@11&lt;/code&gt;, &lt;code&gt;python 3.9&lt;/code&gt;, &lt;code&gt;spark 3.3.2&lt;/code&gt;, and &lt;code&gt;delta-spark 2.2.0&lt;/code&gt; directly on your macOS or Ubuntu machine. You use &lt;code&gt;pyenv&lt;/code&gt; and &lt;code&gt;sdkman&lt;/code&gt; to try and mimic the cluster. It feels fast, but it’s a lie.&lt;/p&gt;

&lt;p&gt;The second is the &lt;strong&gt;"Containerized Replica" approach&lt;/strong&gt;: You build a Dockerfile that mirrors your base image (likely &lt;code&gt;amazoncorretto:11&lt;/code&gt; or a specific Spark-provided image) and mount your code into a container. You use &lt;code&gt;docker-compose&lt;/code&gt; to spin up a local MinIO instance to act as your S3-compatible storage for Delta tables. You are essentially carrying a mini-cluster in a container.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fimages.unsplash.com%2Fphoto-1675790462725-8d6a7889caf3%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3w5NzU0MjJ8MHwxfHNlYXJjaHwxOHx8ZG9ja2VyJTIwd2hhbGUlMjBpY29ufGVufDB8MHx8fDE3ODkzMzE4OTV8MA%26ixlib%3Drb-4.1.0%26q%3D80%26w%3D1080" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fimages.unsplash.com%2Fphoto-1675790462725-8d6a7889caf3%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3w5NzU0MjJ8MHwxfHNlYXJjaHwxOHx8ZG9ja2VyJTIwd2hhbGUlMjBpY29ufGVufDB8MHx8fDE3ODkzMzE4OTV8MA%26ixlib%3Drb-4.1.0%26q%3D80%26w%3D1080" alt="Photo by Karl Paul Baldacchino on Unsplash" width="1080" height="720"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Photo by &lt;a href="https://unsplash.com/@karlpb?utm_source=articles_pipeline&amp;amp;utm_medium=referral" rel="noopener noreferrer"&gt;Karl Paul Baldacchino&lt;/a&gt; on &lt;a href="https://unsplash.com/?utm_source=articles_pipeline&amp;amp;utm_medium=referral" rel="noopener noreferrer"&gt;Unsplash&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The burden of parity
&lt;/h2&gt;

&lt;p&gt;In the Host-Native camp, the ops burden is invisible until it isn't. You spend 30 minutes every few weeks "syncing" your local versions. You will eventually run into a mismatch between your local &lt;code&gt;pyspark&lt;/code&gt; package and the &lt;code&gt;spark-submit&lt;/code&gt; environment. When your local PySpark uses a different &lt;code&gt;py4j&lt;/code&gt; version than the driver, you get weird, non-deterministic failures. &lt;/p&gt;

&lt;p&gt;With the Containerized approach, the burden is front-loaded. You spend two hours writing a &lt;code&gt;Dockerfile&lt;/code&gt; once. You define the &lt;code&gt;SPARK_HOME&lt;/code&gt;, the &lt;code&gt;HADOOP_CONF_DIR&lt;/code&gt;, and the &lt;code&gt;AWS_ACCESS_KEY_ID&lt;/code&gt; for your local MinIO. Now, when you run &lt;code&gt;docker-compose up&lt;/code&gt;, you know that if it runs on your machine, it runs in EMR or Databricks. The ops burden shifts from "fumbling with paths" to "writing one clean Dockerfile."&lt;/p&gt;

&lt;h2&gt;
  
  
  Failure modes and debugging
&lt;/h2&gt;

&lt;p&gt;If you’re running locally on your host, you are prone to the "Global Namespace" problem. You’ve likely got a dozen versions of Java installed. Maybe a global &lt;code&gt;~/.ivy2&lt;/code&gt; cache is corrupted. When a Spark job fails, you never know if it’s your code, a library mismatch, or a local environment setting.&lt;/p&gt;

&lt;p&gt;In a container, you have a clean slate. If the job fails, you can exec into the container and inspect the &lt;code&gt;/opt/spark/work&lt;/code&gt; directory, check the environment variables with &lt;code&gt;env&lt;/code&gt;, and confirm the exact &lt;code&gt;CLASSPATH&lt;/code&gt;. If you need to debug a Delta Lake write failure, you can inspect the &lt;code&gt;_delta_log&lt;/code&gt; files directly in your mounted local folder. You aren't guessing; you're observing.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fimages.unsplash.com%2Fphoto-1544006659-f0b21884ce1d%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3w5NzU0MjJ8MHwxfHNlYXJjaHwyOXx8bGFwdG9wJTIwb24lMjBkZXNrfGVufDB8MHx8fDE3ODkzMzE4OTZ8MA%26ixlib%3Drb-4.1.0%26q%3D80%26w%3D1080" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fimages.unsplash.com%2Fphoto-1544006659-f0b21884ce1d%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3w5NzU0MjJ8MHwxfHNlYXJjaHwyOXx8bGFwdG9wJTIwb24lMjBkZXNrfGVufDB8MHx8fDE3ODkzMzE4OTZ8MA%26ixlib%3Drb-4.1.0%26q%3D80%26w%3D1080" alt="Photo by Štefan Štefančík on Unsplash" width="1080" height="721"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Photo by &lt;a href="https://unsplash.com/@cikstefan?utm_source=articles_pipeline&amp;amp;utm_medium=referral" rel="noopener noreferrer"&gt;Štefan Štefančík&lt;/a&gt; on &lt;a href="https://unsplash.com/?utm_source=articles_pipeline&amp;amp;utm_medium=referral" rel="noopener noreferrer"&gt;Unsplash&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The cost of velocity
&lt;/h2&gt;

&lt;p&gt;People argue that Docker slows down the inner loop of development. They’re usually doing it wrong. Yes, building a 2GB container image takes time. But you don't rebuild the image every time you change a line of code. &lt;/p&gt;

&lt;p&gt;You write a &lt;code&gt;docker-compose.yml&lt;/code&gt; that mounts your source code directory as a volume. You run your Spark job in the container. When you hit save in your IDE, the change is reflected inside the container instantly. You get the portability of the production environment with the speed of local execution. The "cost" is effectively zero after the initial setup. Compare that to the cost of one failed production deploy because a local environment was "fast and easy."&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd pick, and why
&lt;/h2&gt;

&lt;p&gt;I’d pick the containerized approach every single time. &lt;/p&gt;

&lt;p&gt;My recommendation: Use a multi-stage Dockerfile. In the first stage, install your build dependencies. In the final stage, use a slim JRE (like &lt;code&gt;eclipse-temurin:11-jre-focal&lt;/code&gt;) to keep the image size manageable. Use &lt;code&gt;docker-compose&lt;/code&gt; to link your service to a MinIO container. &lt;/p&gt;

&lt;p&gt;Set your &lt;code&gt;DELTA_SPARK_VERSION&lt;/code&gt; and &lt;code&gt;SPARK_VERSION&lt;/code&gt; as build arguments (&lt;code&gt;ARG&lt;/code&gt;) in your Dockerfile. When the platform team updates the production cluster, you change two lines in your &lt;code&gt;.env&lt;/code&gt; file, run &lt;code&gt;docker-compose build --no-cache&lt;/code&gt;, and you’re synced.&lt;/p&gt;

&lt;p&gt;The caveat: This is not for beginners. If you don't understand how &lt;code&gt;spark.driver.host&lt;/code&gt; or Hadoop filesystem configurations work, Docker will be a black box that frustrates you. You need to understand the network bridge between your host and the container. You need to understand how to map ports so your local Spark UI (usually port 4040) is actually accessible from your browser. &lt;/p&gt;

&lt;p&gt;However, if you're working on production financial or healthcare data, "it works on my machine" is a fireable offense. Containerize it, or keep paying the bill when your pipeline dies at 3 AM.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Tags:&lt;/strong&gt; #spark #docker #data #engineering&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Cover photo by &lt;a href="https://unsplash.com/@fslfsl?utm_source=articles_pipeline&amp;amp;utm_medium=referral" rel="noopener noreferrer"&gt;Domaintechnik&lt;/a&gt; on &lt;a href="https://unsplash.com/?utm_source=articles_pipeline&amp;amp;utm_medium=referral" rel="noopener noreferrer"&gt;Unsplash&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>spark</category>
      <category>docker</category>
      <category>data</category>
      <category>engineering</category>
    </item>
    <item>
      <title>Is your Data Lakehouse actually a HIPAA liability?</title>
      <dc:creator>Aniket Abhishek Soni</dc:creator>
      <pubDate>Sat, 12 Sep 2026 05:36:55 +0000</pubDate>
      <link>https://dev.to/aniketsoni/is-your-data-lakehouse-actually-a-hipaa-liability-18jm</link>
      <guid>https://dev.to/aniketsoni/is-your-data-lakehouse-actually-a-hipaa-liability-18jm</guid>
      <description>&lt;p&gt;Ninety-two percent of healthcare data breaches occur because of misconfigurations in the storage layer, yet most engineers I interview still think "encryption at rest" is the end of the conversation. If you think your S3 bucket policy is enough to satisfy an auditor, you’re about to have a very bad quarter.&lt;/p&gt;

&lt;p&gt;In the world of HIPAA-compliant data engineering, the "security by obscurity" mindset is not just lazy; it’s a legal hazard. We treat PHI (Protected Health Information) like a radioactive isotope, yet we insist on dumping it into the same raw landing zones as our clickstream data and log files. &lt;/p&gt;

&lt;p&gt;Stop doing that.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the common approach falls short
&lt;/h2&gt;

&lt;p&gt;The industry-standard failure mode is the "God-mode service account." You know the one—the generic &lt;code&gt;data-eng-prod&lt;/code&gt; role that has read/write access to &lt;code&gt;s3://company-datalake/raw/&lt;/code&gt;. When a junior engineer accidentally writes a Spark job that spills unmasked patient names into a development environment, the audit trail shows the service account did it, but it provides zero granularity on who triggered the job or why.&lt;/p&gt;

&lt;p&gt;Most teams try to fix this by masking data at the BI layer (Looker, Tableau). That’s a mistake. If your data lakehouse allows a data scientist to run a &lt;code&gt;SELECT *&lt;/code&gt; on the raw table and get back unmasked PHI because they have read permissions on the bucket, you have failed the "Minimum Necessary" standard of HIPAA. You are trusting your downstream application to be the gatekeeper. That is not a security architecture; that is a prayer.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fimages.unsplash.com%2Fphoto-1607976973585-a6c285b90ef5%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3w5NzU0MjJ8MHwxfHNlYXJjaHwyNHx8c2hhdHRlcmVkJTIwZ2xhc3N8ZW58MHwwfHx8MTc4OTE1OTg0Nnww%26ixlib%3Drb-4.1.0%26q%3D80%26w%3D1080" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fimages.unsplash.com%2Fphoto-1607976973585-a6c285b90ef5%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3w5NzU0MjJ8MHwxfHNlYXJjaHwyNHx8c2hhdHRlcmVkJTIwZ2xhc3N8ZW58MHwwfHx8MTc4OTE1OTg0Nnww%26ixlib%3Drb-4.1.0%26q%3D80%26w%3D1080" alt="Photo by Laura Rivera on Unsplash" width="1080" height="720"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Photo by &lt;a href="https://unsplash.com/@laurar1vera?utm_source=articles_pipeline&amp;amp;utm_medium=referral" rel="noopener noreferrer"&gt;Laura Rivera&lt;/a&gt; on &lt;a href="https://unsplash.com/?utm_source=articles_pipeline&amp;amp;utm_medium=referral" rel="noopener noreferrer"&gt;Unsplash&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  Hard-partitioning at the ingestion boundary
&lt;/h2&gt;

&lt;p&gt;If you want to survive an audit, you need to stop thinking about data as a single bucket. You need to treat your landing zone as a triage center.&lt;/p&gt;

&lt;p&gt;I implement a "Zero-PHI-Landing" policy. When data arrives from a source system (like a FHIR API or an EMR dump), it hits an ingestion Lambda or a Fargate task that immediately inspects the schema. If a field is tagged as PHI, it gets routed through a deterministic masking or tokenization service before it ever touches the persistent storage layer.&lt;/p&gt;

&lt;p&gt;Here is what that looks like in a real Spark implementation. Don’t use generic &lt;code&gt;udf&lt;/code&gt;s; use Column-level security via Delta Lake’s &lt;code&gt;GRANT&lt;/code&gt; syntax.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- The only way to handle PHI is to restrict the column itself&lt;/span&gt;
&lt;span class="c1"&gt;-- Do not rely on view-based security, which is easily bypassed by anyone with table access&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;patient_records&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="n"&gt;STRING&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="n"&gt;STRING&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;dob&lt;/span&gt; &lt;span class="nb"&gt;DATE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;ssn&lt;/span&gt; &lt;span class="n"&gt;STRING&lt;/span&gt; &lt;span class="n"&gt;MASKED&lt;/span&gt; &lt;span class="k"&gt;WITH&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;FUNCTION&lt;/span&gt; &lt;span class="s1"&gt;'mask_ssn'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;By binding the masking logic to the schema definition, you move the security responsibility from the developer’s intent to the storage engine’s configuration. If the developer doesn't have the &lt;code&gt;UNMASK&lt;/code&gt; privilege—which should be restricted to a single, audit-logged service principal—they simply cannot see the data.&lt;/p&gt;

&lt;h2&gt;
  
  
  Immutable audit trails and the identity problem
&lt;/h2&gt;

&lt;p&gt;The biggest gap I see in production pipelines is the lack of identity propagation. When you run a Spark job on EMR or Databricks, the cluster usually runs as a single IAM role. If you have 50 engineers with access to that cluster, you have zero accountability. &lt;/p&gt;

&lt;p&gt;You need to implement fine-grained access control (FGAC) that maps the user identity to the storage layer. If you are on Databricks, stop using shared clusters for production. Use "Single User" mode or Unity Catalog with Attribute-Based Access Control (ABAC).&lt;/p&gt;

&lt;p&gt;Tag your data. If a table contains PHI, tag it &lt;code&gt;PII=True&lt;/code&gt; in your metadata catalog. Then, write a policy that enforces:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Access to &lt;code&gt;PII=True&lt;/code&gt; resources requires MFA.&lt;/li&gt;
&lt;li&gt;Every &lt;code&gt;SELECT&lt;/code&gt; statement on a &lt;code&gt;PII=True&lt;/code&gt; table must be logged to an immutable S3 bucket with Object Lock enabled.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Here is a snippet of a Terraform policy for your metadata store that actually keeps auditors happy:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_iam_policy"&lt;/span&gt; &lt;span class="s2"&gt;"phi_access_policy"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"phi-access-restriction"&lt;/span&gt;
  &lt;span class="nx"&gt;policy&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;jsonencode&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="nx"&gt;Version&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"2012-10-17"&lt;/span&gt;
    &lt;span class="nx"&gt;Statement&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
      &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;Action&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"lakeformation:GetDataAccess"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
        &lt;span class="nx"&gt;Effect&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Deny"&lt;/span&gt;
        &lt;span class="nx"&gt;Resource&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"*"&lt;/span&gt;
        &lt;span class="nx"&gt;Condition&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="nx"&gt;StringNotLike&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="s2"&gt;"aws:PrincipalTag/Role"&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"data-steward"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"compliance-officer"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
          &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This prevents accidental exposure. Even if an engineer manages to gain access to the raw files, the Lake Formation layer will intercept the call and deny it because their IAM tag doesn't match the required security clearance.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fimages.unsplash.com%2Fphoto-1774898989484-0b9becf69efb%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3w5NzU0MjJ8MHwxfHNlYXJjaHw0fHxkaWdpdGFsJTIwcGFkbG9ja3xlbnwwfDB8fHwxNzg5MTU5ODQ2fDA%26ixlib%3Drb-4.1.0%26q%3D80%26w%3D1080" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fimages.unsplash.com%2Fphoto-1774898989484-0b9becf69efb%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3w5NzU0MjJ8MHwxfHNlYXJjaHw0fHxkaWdpdGFsJTIwcGFkbG9ja3xlbnwwfDB8fHwxNzg5MTU5ODQ2fDA%26ixlib%3Drb-4.1.0%26q%3D80%26w%3D1080" alt="Photo by Sasun Bughdaryan on Unsplash" width="1080" height="720"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Photo by &lt;a href="https://unsplash.com/@sasun1990?utm_source=articles_pipeline&amp;amp;utm_medium=referral" rel="noopener noreferrer"&gt;Sasun Bughdaryan&lt;/a&gt; on &lt;a href="https://unsplash.com/?utm_source=articles_pipeline&amp;amp;utm_medium=referral" rel="noopener noreferrer"&gt;Unsplash&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The objections (and my answers)
&lt;/h2&gt;

&lt;p&gt;"But this kills developer velocity." &lt;/p&gt;

&lt;p&gt;Yes, it does. That is the point. HIPAA compliance is not supposed to be "fast." If you prioritize the speed of a data scientist over the privacy of a patient, you are in the wrong industry. You can build a sandbox environment with synthetic, anonymized data for experimentation. If they need real PHI, they file a ticket, the data steward reviews the request, and the access is granted for a limited duration.&lt;/p&gt;

&lt;p&gt;"We use encryption at rest, isn't that enough?"&lt;/p&gt;

&lt;p&gt;Encryption at rest protects you if the physical hard drive is stolen from a data center. It does absolutely nothing when someone with legitimate internal network access writes a &lt;code&gt;SELECT *&lt;/code&gt; and exports the patient database to a CSV. Encryption is the floor, not the ceiling. If you are relying solely on KMS keys, you are missing the entire point of the HIPAA Privacy Rule.&lt;/p&gt;

&lt;p&gt;"The cloud provider handles this for us."&lt;/p&gt;

&lt;p&gt;The cloud provider handles the &lt;em&gt;infrastructure&lt;/em&gt; security. They are not responsible for your data governance. If you grant &lt;code&gt;s3:*&lt;/code&gt; to your production role, AWS will happily help you leak 10 million patient records. The "Shared Responsibility Model" is not a suggestion; it’s the legal boundary where your liability begins and the provider's ends.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;Building a HIPAA-compliant data lakehouse isn't about buying the right tool; it's about enforcing a strict hierarchy of access. Move the logic as close to the storage as possible, use deterministic masking that is tied to the schema, and stop sharing credentials.&lt;/p&gt;

&lt;p&gt;If your pipeline doesn't break when a user tries to access data they shouldn't, you haven't built a security boundary—you’ve built a data leak waiting to happen. The audit isn't a formality; it’s a stress test. If your current architecture allows a developer to bypass your masking logic with a simple &lt;code&gt;DROP VIEW&lt;/code&gt; command, you’re not compliant. You’re just lucky.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Cover photo by &lt;a href="https://unsplash.com/@tylergm?utm_source=articles_pipeline&amp;amp;utm_medium=referral" rel="noopener noreferrer"&gt;Tyler&lt;/a&gt; on &lt;a href="https://unsplash.com/?utm_source=articles_pipeline&amp;amp;utm_medium=referral" rel="noopener noreferrer"&gt;Unsplash&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>data</category>
      <category>security</category>
      <category>healthcare</category>
      <category>engineering</category>
    </item>
    <item>
      <title>Why our multi-million row join cost $4,000 in one hour</title>
      <dc:creator>Aniket Abhishek Soni</dc:creator>
      <pubDate>Fri, 11 Sep 2026 13:47:27 +0000</pubDate>
      <link>https://dev.to/aniketsoni/why-our-multi-million-row-join-cost-4000-in-one-hour-4b4h</link>
      <guid>https://dev.to/aniketsoni/why-our-multi-million-row-join-cost-4000-in-one-hour-4b4h</guid>
      <description>&lt;p&gt;You ship the job. It passes CI. The data quality checks return green. Then, at 3:14 AM, the PagerDuty alert fires. Your GCP billing dashboard is currently reporting a "spend anomaly," and your Databricks SQL Warehouse is throwing an &lt;code&gt;OUT_OF_MEMORY&lt;/code&gt; error that is currently cascading into a service-wide outage.&lt;/p&gt;

&lt;p&gt;I’ve been here. Twice. Once on BigQuery, once on Databricks. They aren't the same beast, and treating them as interchangeable "SQL engines" is exactly how you end up explaining a $4,000 hourly burn rate to a VP who doesn't care about your partition pruning strategy.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we saw
&lt;/h2&gt;

&lt;p&gt;It started with a simple request: join our 4TB &lt;code&gt;events&lt;/code&gt; table with a 500GB &lt;code&gt;user_metadata&lt;/code&gt; table. In our staging environment, which used a 10% sample of the data, the query finished in 42 seconds. In production, it didn't finish. It hung for 45 minutes, then the BigQuery slot usage spiked to 10,000+ slots, and the Databricks SQL Warehouse (a 2XL Serverless instance) simply evaporated, dumping a &lt;code&gt;java.lang.OutOfMemoryError: Java heap space&lt;/code&gt; into the logs.&lt;/p&gt;

&lt;p&gt;The false lead was the index. My junior dev thought we missed a partition key on &lt;code&gt;user_id&lt;/code&gt;. We spent two hours adding a &lt;code&gt;CLUSTER BY&lt;/code&gt; on the BigQuery side and a Z-Order on the Databricks side. It changed absolutely nothing. The execution plan remained a disaster. The symptoms—massive latency and OOM errors—weren't about data locality. They were about the engine trying to perform a broadcast join on a table that was too damn big to fit in a single worker's memory.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fimages.unsplash.com%2Fphoto-1669136229979-102bef519e4e%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3w5NzU0MjJ8MHwxfHNlYXJjaHw1fHxzaGF0dGVyZWQlMjBnbGFzc3xlbnwwfDB8fHwxNzg4OTg2OTc0fDA%26ixlib%3Drb-4.1.0%26q%3D80%26w%3D1080" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fimages.unsplash.com%2Fphoto-1669136229979-102bef519e4e%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3w5NzU0MjJ8MHwxfHNlYXJjaHw1fHxzaGF0dGVyZWQlMjBnbGFzc3xlbnwwfDB8fHwxNzg4OTg2OTc0fDA%26ixlib%3Drb-4.1.0%26q%3D80%26w%3D1080" alt="Photo by Salah Ait Mokhtar on Unsplash" width="1080" height="720"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Photo by &lt;a href="https://unsplash.com/@motosha?utm_source=articles_pipeline&amp;amp;utm_medium=referral" rel="noopener noreferrer"&gt;Salah Ait Mokhtar&lt;/a&gt; on &lt;a href="https://unsplash.com/?utm_source=articles_pipeline&amp;amp;utm_medium=referral" rel="noopener noreferrer"&gt;Unsplash&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Root cause
&lt;/h2&gt;

&lt;p&gt;In BigQuery, the culprit is almost always "Shuffling." When BigQuery can't perform a map-side join, it triggers a massive shuffle phase. I saw the job details: &lt;code&gt;Bytes shuffled: 12.4 TB&lt;/code&gt;. BigQuery charges for data processed, but when you hit a join that forces a massive shuffle, you aren't just paying for the read; you're paying for the compute slots spinning in circles trying to sort and move data across the network fabric. &lt;/p&gt;

&lt;p&gt;On Databricks, the mechanism is different but equally lethal. We were using a Databricks SQL Warehouse with &lt;code&gt;AUTO&lt;/code&gt; join optimization. The Catalyst optimizer looked at our 500GB metadata table and incorrectly estimated it could fit in the memory of a single node for a broadcast join. It couldn't. The executor tried to pull the entire 500GB into memory, hit the heap limit, and triggered a hard OOM. The &lt;code&gt;spark.sql.autoBroadcastJoinThreshold&lt;/code&gt; default is 10MB, but our specific configuration had been bumped to 1GB by a previous team trying to "speed up" smaller queries. It was a ticking time bomb.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fimages.unsplash.com%2Fphoto-1783113938335-ba83ccde7f5f%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3w5NzU0MjJ8MHwxfHNlYXJjaHwxOHx8ZmluYW5jaWFsJTIwc3ByZWFkc2hlZXR8ZW58MHwwfHx8MTc4ODk4Njk3NXww%26ixlib%3Drb-4.1.0%26q%3D80%26w%3D1080" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fimages.unsplash.com%2Fphoto-1783113938335-ba83ccde7f5f%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3w5NzU0MjJ8MHwxfHNlYXJjaHwxOHx8ZmluYW5jaWFsJTIwc3ByZWFkc2hlZXR8ZW58MHwwfHx8MTc4ODk4Njk3NXww%26ixlib%3Drb-4.1.0%26q%3D80%26w%3D1080" alt="Photo by Gorilla ROI Data Connector on Unsplash" width="1080" height="810"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Photo by &lt;a href="https://unsplash.com/@gorillaroi?utm_source=articles_pipeline&amp;amp;utm_medium=referral" rel="noopener noreferrer"&gt;Gorilla ROI Data Connector&lt;/a&gt; on &lt;a href="https://unsplash.com/?utm_source=articles_pipeline&amp;amp;utm_medium=referral" rel="noopener noreferrer"&gt;Unsplash&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;

&lt;p&gt;We stopped trying to outsmart the optimizers and forced the physical plan. &lt;/p&gt;

&lt;p&gt;For BigQuery, we implemented a sub-query re-write. Instead of a standard &lt;code&gt;JOIN&lt;/code&gt;, we used a &lt;code&gt;JOIN&lt;/code&gt; with a &lt;code&gt;WHERE&lt;/code&gt; clause that explicitly limited the join keys to a pre-filtered subset of the data. We also switched from &lt;code&gt;SELECT *&lt;/code&gt; to specific columns—a boring, manual fix that reduced our shuffle volume from 12TB down to 800GB. We also had to switch the job to &lt;code&gt;INTERACTIVE&lt;/code&gt; priority; the &lt;code&gt;BATCH&lt;/code&gt; queue was holding onto slots for too long, causing a resource deadlock.&lt;/p&gt;

&lt;p&gt;For Databricks, we had to disable the broadcast join for that specific query. We added the hint &lt;code&gt;/*+ MERGE(events, user_metadata) */&lt;/code&gt; to the SQL. This forced the engine to use a Shuffle Sort-Merge Join, which is much slower than a broadcast join but significantly more stable. It spilled to disk instead of exploding the heap. The query took 12 minutes instead of failing, but it finished. I'll take a 12-minute success over a $4,000 failed job any day.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we changed so it never happens again
&lt;/h2&gt;

&lt;p&gt;First, we implemented a "Query Size Budget." We now have a post-commit hook in our CI pipeline that runs &lt;code&gt;EXPLAIN&lt;/code&gt; on every SQL file. If the estimated bytes processed in BigQuery exceed 1TB, or if the Databricks plan shows a &lt;code&gt;BroadcastExchange&lt;/code&gt; on a table larger than 500MB, the PR is automatically blocked. You don't get to deploy code that could bankrupt the department.&lt;/p&gt;

&lt;p&gt;Second, we stopped using &lt;code&gt;AUTO&lt;/code&gt; settings for warehouse sizing. We now use specific warehouse tags for specific workloads. If a job is heavy on joins, it runs on a warehouse with &lt;code&gt;enable_serverless_compute = true&lt;/code&gt; but with &lt;code&gt;min_serverless_worker_count&lt;/code&gt; set to a higher floor. We treat the warehouse like a dedicated resource, not a magic bucket.&lt;/p&gt;

&lt;p&gt;Third, we enforced a culture of looking at the "Query Profile." In BigQuery, that means checking the "Wait time" and "Shuffle stage" metrics in the UI. In Databricks, it means looking at the "Metrics" tab in the SQL query history to see how much data was spilled to disk. If you see "Spilled to disk," you haven't failed; you've just found the physical limit of your cluster. &lt;/p&gt;

&lt;p&gt;Finally, I stopped trusting the "Big Data" marketing. BigQuery and Databricks are incredible tools, but they assume you know how a distributed join works. If you treat them like a MySQL instance on a laptop, they will treat your credit card like an unlimited ATM. Don't look at the execution time. Look at the data movement. Data movement is the tax you pay for poor query design. Keep the data local, keep the shuffles minimal, and for the love of god, keep an eye on your broadcast thresholds.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Cover photo by &lt;a href="https://unsplash.com/@albertstoynov?utm_source=articles_pipeline&amp;amp;utm_medium=referral" rel="noopener noreferrer"&gt;Albert Stoynov&lt;/a&gt; on &lt;a href="https://unsplash.com/?utm_source=articles_pipeline&amp;amp;utm_medium=referral" rel="noopener noreferrer"&gt;Unsplash&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>bigquery</category>
      <category>databricks</category>
      <category>data</category>
      <category>sql</category>
    </item>
    <item>
      <title>How I stopped fearing the 3 AM pager by forcing idempotency everywhere</title>
      <dc:creator>Aniket Abhishek Soni</dc:creator>
      <pubDate>Tue, 08 Sep 2026 06:48:56 +0000</pubDate>
      <link>https://dev.to/aniketsoni/how-i-stopped-fearing-the-3-am-pager-by-forcing-idempotency-everywhere-4jf1</link>
      <guid>https://dev.to/aniketsoni/how-i-stopped-fearing-the-3-am-pager-by-forcing-idempotency-everywhere-4jf1</guid>
      <description>&lt;p&gt;If your pipeline isn't idempotent, it isn't production-ready; it’s just a fragile script waiting to ruin your weekend. Most engineers treat "idempotency" as an academic concept for distributed systems papers, but in the trenches of fintech and healthcare, it’s the difference between a minor blip and a regulatory filing. If you can’t run your job five times in a row with the exact same input and get the exact same state, you aren't doing data engineering—you're doing data gambling.&lt;/p&gt;

&lt;p&gt;I’ve spent six years cleaning up the messes left by "append-only" thinking. I’ve seen millions of dollars in duplicate ACH transactions and patient records corrupted by "just one more retry" logic. This guide covers the patterns I use to make sure that when the scheduler kicks off at 3 AM, I can sleep through the alarm because the system knows how to fix itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Stop relying on "Append" mode
&lt;/h2&gt;

&lt;p&gt;The biggest sin in data engineering is assuming that your destination table is a clean slate. When a job fails halfway through, you don't want a partial load sitting in your production warehouse. Never use &lt;code&gt;INSERT INTO&lt;/code&gt; blindly. If you are using BigQuery, Snowflake, or Databricks, use &lt;code&gt;MERGE&lt;/code&gt; or overwrite-on-partition.&lt;/p&gt;

&lt;p&gt;If you are using SQL-based ELT, write your transformations to stage data in a transient table before swapping it into production. Never push directly to the target.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- The wrong way: INSERT INTO target_table SELECT * FROM staging&lt;/span&gt;
&lt;span class="c1"&gt;-- The right way: Use an atomic swap or a MERGE statement&lt;/span&gt;
&lt;span class="n"&gt;MERGE&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;production&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;transactions&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;T&lt;/span&gt;
&lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="n"&gt;staging&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;transactions&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;S&lt;/span&gt;
&lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;T&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;transaction_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;S&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;transaction_id&lt;/span&gt;
&lt;span class="k"&gt;WHEN&lt;/span&gt; &lt;span class="n"&gt;MATCHED&lt;/span&gt; &lt;span class="k"&gt;THEN&lt;/span&gt; &lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;T&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;amount&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;S&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;T&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;S&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;
&lt;span class="k"&gt;WHEN&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="n"&gt;MATCHED&lt;/span&gt; &lt;span class="k"&gt;THEN&lt;/span&gt; &lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;transaction_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;VALUES&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;S&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;transaction_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;S&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;S&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fimages.unsplash.com%2Fphoto-1636642824085-17b710cd2b48%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3w5NzU0MjJ8MHwxfHNlYXJjaHwxN3x8c2hhdHRlcmVkJTIwZ2xhc3N8ZW58MHwwfHx8MTc4ODgxNjI4NHww%26ixlib%3Drb-4.1.0%26q%3D80%26w%3D1080" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fimages.unsplash.com%2Fphoto-1636642824085-17b710cd2b48%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3w5NzU0MjJ8MHwxfHNlYXJjaHwxN3x8c2hhdHRlcmVkJTIwZ2xhc3N8ZW58MHwwfHx8MTc4ODgxNjI4NHww%26ixlib%3Drb-4.1.0%26q%3D80%26w%3D1080" alt="Photo by 🇻🇪 Jose G. Ortega Castro 🇲🇽 on Unsplash" width="1080" height="720"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Photo by &lt;a href="https://unsplash.com/@j0rt?utm_source=articles_pipeline&amp;amp;utm_medium=referral" rel="noopener noreferrer"&gt;🇻🇪 Jose G. Ortega Castro 🇲🇽&lt;/a&gt; on &lt;a href="https://unsplash.com/?utm_source=articles_pipeline&amp;amp;utm_medium=referral" rel="noopener noreferrer"&gt;Unsplash&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  2. Partitioning is your safety net
&lt;/h2&gt;

&lt;p&gt;If your pipeline runs daily, your data must be partitioned by that day. If you are loading data without a partition key (or worse, appending to a single massive table), you are doomed. By using partition overwrites, you turn a complex "delete and re-insert" logic into a simple atomic operation.&lt;/p&gt;

&lt;p&gt;In Spark, this is trivial. If your job fails, you just re-run the whole partition. If you overwrite the partition, you guarantee the state is consistent.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Spark example for overwriting a specific partition
&lt;/span&gt;&lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;write&lt;/span&gt; \
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;mode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;overwrite&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; \
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;partitionBy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;event_date&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; \
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;option&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;partitionOverwriteMode&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;dynamic&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; \
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;saveAsTable&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;prod.daily_metrics&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  3. Deterministic execution IDs
&lt;/h2&gt;

&lt;p&gt;When debugging a failure at 3 AM, you need to know exactly which run produced which record. I add a &lt;code&gt;job_run_id&lt;/code&gt; and an &lt;code&gt;ingestion_timestamp&lt;/code&gt; to every row. This allows me to query the state of the world at any specific point in time. &lt;/p&gt;

&lt;p&gt;More importantly, if you use a surrogate key, generate it using a hash of the natural keys rather than an auto-incrementing integer. If you re-run the job, the hash stays the same, ensuring that the row is updated rather than duplicated.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;hashlib&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;generate_id&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data_dict&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="c1"&gt;# Deterministic ID based on business logic
&lt;/span&gt;    &lt;span class="n"&gt;seed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;data_dict&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;user_id&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;-&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;data_dict&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;event_date&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;hashlib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;md5&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;seed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;()).&lt;/span&gt;&lt;span class="nf"&gt;hexdigest&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  4. The "No Side Effects" rule
&lt;/h2&gt;

&lt;p&gt;A pipeline job should only do one thing: move data from A to B. If your data pipeline is also sending Slack alerts, updating a cache, or calling an external API, you have a problem. Side effects are not idempotent.&lt;/p&gt;

&lt;p&gt;If you must trigger an external action, do it downstream using a "state machine" pattern. Write the data first, then have a separate observer process look for the completion marker and trigger the side effect. If the observer fails, you can re-run it safely because it only checks the existence of the final record.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Embrace the "Delete-Before-Insert" pattern
&lt;/h2&gt;

&lt;p&gt;Sometimes &lt;code&gt;MERGE&lt;/code&gt; is too slow or complex for your specific warehouse engine. In those cases, don't over-engineer. Use a transaction block to delete the data for your target time range before inserting the new batch. &lt;/p&gt;

&lt;p&gt;In Postgres or Snowflake, wrap this in a transaction. If the insert fails, the delete rolls back. The system is back to its original state, ready for another attempt.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;BEGIN&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;DELETE&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;daily_reports&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;report_date&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'2023-10-27'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;daily_reports&lt;/span&gt; &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;staging_reports&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;report_date&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'2023-10-27'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;COMMIT&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  6. Idempotent API consumers
&lt;/h2&gt;

&lt;p&gt;If your pipeline fetches data from an API, don't just dump the raw response. If you are hitting a REST endpoint, use the &lt;code&gt;ETag&lt;/code&gt; or &lt;code&gt;Last-Modified&lt;/code&gt; headers to decide if you even need to pull the data. &lt;/p&gt;

&lt;p&gt;For the data processing side, use a local cache (like Redis) to store the IDs of records already processed in the current window. If the pipeline dies and restarts, check the cache before hitting the sink. It saves costs and prevents duplicate processing.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. Configuration as Code, not Magic
&lt;/h2&gt;

&lt;p&gt;I’ve seen too many pipelines fail because a developer manually updated a variable in a UI dashboard. Your pipeline configuration—the source paths, the target tables, the look-back windows—should be in a version-controlled config file (YAML).&lt;/p&gt;

&lt;p&gt;If you need to re-run a job, you shouldn't have to guess the parameters. You should be able to check out the repo at the commit hash from 3 AM, look at the config, and understand exactly what the job was trying to do.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# config.yaml&lt;/span&gt;
&lt;span class="na"&gt;job_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;daily_user_sync&lt;/span&gt;
&lt;span class="na"&gt;lookback_days&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;
&lt;span class="na"&gt;destination&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;prod.users&lt;/span&gt;
&lt;span class="na"&gt;partition_key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;event_date&lt;/span&gt;
&lt;span class="na"&gt;enable_merge&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fimages.unsplash.com%2Fphoto-1671113156047-e646134facf7%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3w5NzU0MjJ8MHwxfHNlYXJjaHwxMHx8Y2xvY2slMjBhdCUyMG5pZ2h0fGVufDB8MHx8fDE3ODg4MTYyODV8MA%26ixlib%3Drb-4.1.0%26q%3D80%26w%3D1080" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fimages.unsplash.com%2Fphoto-1671113156047-e646134facf7%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3w5NzU0MjJ8MHwxfHNlYXJjaHwxMHx8Y2xvY2slMjBhdCUyMG5pZ2h0fGVufDB8MHx8fDE3ODg4MTYyODV8MA%26ixlib%3Drb-4.1.0%26q%3D80%26w%3D1080" alt="Photo by Yuta Koike on Unsplash" width="1080" height="720"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Photo by &lt;a href="https://unsplash.com/@yuta129?utm_source=articles_pipeline&amp;amp;utm_medium=referral" rel="noopener noreferrer"&gt;Yuta Koike&lt;/a&gt; on &lt;a href="https://unsplash.com/?utm_source=articles_pipeline&amp;amp;utm_medium=referral" rel="noopener noreferrer"&gt;Unsplash&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  8. Monitor for drift, not just failure
&lt;/h2&gt;

&lt;p&gt;A pipeline can "succeed" but still produce wrong data. Idempotency helps you fix things, but you need to know &lt;em&gt;when&lt;/em&gt; to fix them. I implement "data quality contracts" using something like Great Expectations or even simple SQL queries that run immediately after the load.&lt;/p&gt;

&lt;p&gt;If the row count for the current partition is 0, or if the sum of revenue is negative (in a domain where that's impossible), raise an alert. Because your pipeline is idempotent, the fix is just to trigger the re-run.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;Making a pipeline idempotent isn't about writing more code; it's about removing the "statefulness" that makes systems unpredictable. By forcing atomic writes, deterministic keys, and transaction-safe operations, you stop managing failures and start managing outcomes. You stop being a firefighter and start being an architect.&lt;/p&gt;

&lt;p&gt;When the 3 AM alert goes off, will you be scrambling to delete duplicate rows from a production table, or will you be confident enough to just hit "retry" and go back to sleep?&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Cover photo by &lt;a href="https://unsplash.com/@tylergm?utm_source=articles_pipeline&amp;amp;utm_medium=referral" rel="noopener noreferrer"&gt;Tyler&lt;/a&gt; on &lt;a href="https://unsplash.com/?utm_source=articles_pipeline&amp;amp;utm_medium=referral" rel="noopener noreferrer"&gt;Unsplash&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>data</category>
      <category>engineering</category>
      <category>pipelines</category>
      <category>reliability</category>
    </item>
    <item>
      <title>Are You Shipping a Data Warehouse or a Malware Delivery Vehicle?</title>
      <dc:creator>Aniket Abhishek Soni</dc:creator>
      <pubDate>Tue, 08 Sep 2026 06:48:35 +0000</pubDate>
      <link>https://dev.to/aniketsoni/are-you-shipping-a-data-warehouse-or-a-malware-delivery-vehicle-1a5k</link>
      <guid>https://dev.to/aniketsoni/are-you-shipping-a-data-warehouse-or-a-malware-delivery-vehicle-1a5k</guid>
      <description>&lt;p&gt;Ninety-eight percent of the production container images I audit in financial services contain at least one critical vulnerability, and nearly half of those vulnerabilities have a fix available that the engineering team simply hasn't bothered to apply.&lt;/p&gt;

&lt;p&gt;It matters because when you’re pulling down a &lt;code&gt;python:3.11-buster&lt;/code&gt; image, you aren't just getting an interpreter. You’re getting a Debian distribution, a shell, a package manager, and enough attack surface to keep a red team busy for a month. In a regulated environment, that’s not just tech debt; that’s a liability that will get you a stern email from compliance during your next SOC2 audit.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Why I chose this topic:&lt;/strong&gt; I spent three weeks last quarter cleaning up a Log4j-style mess that only existed because a legacy data job was pulling a bloated, unpatched base image. I’m writing this because I’m tired of seeing production clusters running bloated images that act as a buffet for bad actors.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;You’re currently facing a binary choice: continue to ship heavy, "convenient" images that make debugging easy but security impossible, or embrace the friction of minimal, hardened artifacts that keep you out of the headlines.&lt;/p&gt;

&lt;h2&gt;
  
  
  The contenders
&lt;/h2&gt;

&lt;p&gt;Most data engineers in my circles land on one of three paths when containerizing their PySpark or Pandas workloads.&lt;/p&gt;

&lt;p&gt;First, there’s the &lt;strong&gt;"Standard Distro"&lt;/strong&gt; approach. This is &lt;code&gt;FROM python:3.11-slim&lt;/code&gt; or &lt;code&gt;FROM ubuntu:22.04&lt;/code&gt;. It’s familiar, it has &lt;code&gt;apt&lt;/code&gt;, and you can &lt;code&gt;pip install&lt;/code&gt; anything without breaking a sweat.&lt;/p&gt;

&lt;p&gt;Second, we have the &lt;strong&gt;"Distroless"&lt;/strong&gt; camp. This is Google’s &lt;code&gt;gcr.io/distroless/python3&lt;/code&gt;. It contains absolutely nothing but your app and its runtime dependencies. No shell, no package manager, no local tools.&lt;/p&gt;

&lt;p&gt;Third, there is the &lt;strong&gt;"Alpine/Musl"&lt;/strong&gt; route. This is &lt;code&gt;FROM python:3.11-alpine&lt;/code&gt;. It’s tiny, but it swaps the standard glibc for musl, which is a recipe for disaster if your data science libraries rely on C-extensions.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fimages.unsplash.com%2Fphoto-1632443526253-761d7361af31%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3w5NzU0MjJ8MHwxfHNlYXJjaHw2fHxkaWdpdGFsJTIwdmF1bHQlMjBsb2NrfGVufDB8MHx8fDE3ODg2MzkyMDZ8MA%26ixlib%3Drb-4.1.0%26q%3D80%26w%3D1080" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fimages.unsplash.com%2Fphoto-1632443526253-761d7361af31%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3w5NzU0MjJ8MHwxfHNlYXJjaHw2fHxkaWdpdGFsJTIwdmF1bHQlMjBsb2NrfGVufDB8MHx8fDE3ODg2MzkyMDZ8MA%26ixlib%3Drb-4.1.0%26q%3D80%26w%3D1080" alt="Photo by CHUTTERSNAP on Unsplash" width="1080" height="720"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Photo by &lt;a href="https://unsplash.com/@chuttersnap?utm_source=articles_pipeline&amp;amp;utm_medium=referral" rel="noopener noreferrer"&gt;CHUTTERSNAP&lt;/a&gt; on &lt;a href="https://unsplash.com/?utm_source=articles_pipeline&amp;amp;utm_medium=referral" rel="noopener noreferrer"&gt;Unsplash&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The hidden cost of "easy" images
&lt;/h2&gt;

&lt;p&gt;If you’re using &lt;code&gt;python:3.11-slim&lt;/code&gt;, you’re deploying roughly 800MB to 1.2GB of junk. In a high-frequency data pipeline, that translates to real money. &lt;/p&gt;

&lt;p&gt;Let’s look at the math. If you spin up 500 Spark executor pods a day, and each image pull takes 45 seconds because your image is 1GB, you are wasting over 6 hours of cumulative pod startup time daily. That’s compute cost, but it’s also latency. When a node fails and Kubernetes tries to reschedule, that bloat is the difference between a sub-second recovery and a cascading failure.&lt;/p&gt;

&lt;p&gt;The "Standard Distro" path is a trap. You think you’re saving time, but you’re actually paying a "security tax" every time a CVE scan hits. When &lt;code&gt;apt-get upgrade&lt;/code&gt; flags a critical vulnerability in &lt;code&gt;libssl&lt;/code&gt;, you have to rebuild, re-push, and redeploy. With a 1GB image, your registry storage costs balloon, and your CI pipeline slows to a crawl as you push massive layers across the network.&lt;/p&gt;

&lt;h2&gt;
  
  
  The reliability tax of musl vs. glibc
&lt;/h2&gt;

&lt;p&gt;The Alpine approach looks tempting because it’s usually under 100MB. But here is the concrete failure mode: &lt;code&gt;pandas&lt;/code&gt; or &lt;code&gt;numpy&lt;/code&gt; or &lt;code&gt;pyarrow&lt;/code&gt; will eventually crash on Alpine. Why? Because they are compiled against &lt;code&gt;glibc&lt;/code&gt;, and Alpine uses &lt;code&gt;musl libc&lt;/code&gt;. &lt;/p&gt;

&lt;p&gt;I’ve watched junior engineers spend three days debugging a &lt;code&gt;Segmentation fault&lt;/code&gt; that only happens in production. It’s because &lt;code&gt;pyarrow&lt;/code&gt; tried to call a memory allocation function that behaves differently in &lt;code&gt;musl&lt;/code&gt;. You end up recompiling the world from source just to get your data job to run. If you think you’re saving time by using Alpine, you’re just front-loading the pain into the debugging phase. It’s a false economy.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fimages.unsplash.com%2Fphoto-1687300172792-68a13c4e149a%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3w5NzU0MjJ8MHwxfHNlYXJjaHw4fHxzZXJ2ZXIlMjByYWNrJTIwY2FibGVzfGVufDB8MHx8fDE3ODg2MzkyMDZ8MA%26ixlib%3Drb-4.1.0%26q%3D80%26w%3D1080" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fimages.unsplash.com%2Fphoto-1687300172792-68a13c4e149a%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3w5NzU0MjJ8MHwxfHNlYXJjaHw4fHxzZXJ2ZXIlMjByYWNrJTIwY2FibGVzfGVufDB8MHx8fDE3ODg2MzkyMDZ8MA%26ixlib%3Drb-4.1.0%26q%3D80%26w%3D1080" alt="Photo by Ivan N on Unsplash" width="1080" height="810"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Photo by &lt;a href="https://unsplash.com/@_ivann?utm_source=articles_pipeline&amp;amp;utm_medium=referral" rel="noopener noreferrer"&gt;Ivan N&lt;/a&gt; on &lt;a href="https://unsplash.com/?utm_source=articles_pipeline&amp;amp;utm_medium=referral" rel="noopener noreferrer"&gt;Unsplash&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The "Distroless" security reality check
&lt;/h2&gt;

&lt;p&gt;"Distroless" is the gold standard for security, but it’s a nightmare for the developer experience. If a job fails in production, you cannot &lt;code&gt;kubectl exec&lt;/code&gt; into the pod to check a config file or run a quick &lt;code&gt;ping&lt;/code&gt; to test connectivity. The shell simply isn't there.&lt;/p&gt;

&lt;p&gt;In a regulated environment, this is a feature, not a bug. If an attacker gains entry to your pod, they can’t run &lt;code&gt;nmap&lt;/code&gt; or &lt;code&gt;curl&lt;/code&gt; to pivot to your internal database. But you have to be ready to support your team when they scream that they can't debug their code. You need robust observability—OpenTelemetry traces, structured logs, and remote debugging tools—or your engineers will revert to &lt;code&gt;FROM ubuntu&lt;/code&gt; just so they can sleep at night.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd pick, and why
&lt;/h2&gt;

&lt;p&gt;If I’m building a production data platform today, I’m using &lt;strong&gt;Wolfi&lt;/strong&gt; (the un-distro) or a strictly controlled &lt;code&gt;debian-slim&lt;/code&gt; base that I own. &lt;/p&gt;

&lt;p&gt;Here is my hierarchy of needs:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Multi-stage builds are non-negotiable.&lt;/strong&gt; I build in a heavy image with all the compilers and headers, then copy only the artifact (the virtual environment) into a lean runtime image. If you are doing &lt;code&gt;pip install&lt;/code&gt; in your final production image, you’ve already failed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SBOMs are the new baseline.&lt;/strong&gt; I use &lt;code&gt;syft&lt;/code&gt; to generate an SBOM at build time and &lt;code&gt;grype&lt;/code&gt; to scan it. If the scan returns a &lt;code&gt;Critical&lt;/code&gt; or &lt;code&gt;High&lt;/code&gt; CVE that has a fix, the build fails. Period. No exceptions.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The "Distroless" ideal, with a twist.&lt;/strong&gt; I use a "debug" sidecar pattern. My production container has no shell. If I need to inspect it, I attach a temporary sidecar container to the pod that has all the diagnostic tools I need. It’s the best of both worlds: a hardened production environment and the ability to debug when things go sideways.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;My recommendation: Stop treating your container images as "virtual machines." They are artifacts of a specific process. If you find yourself wanting to install &lt;code&gt;vim&lt;/code&gt; or &lt;code&gt;git&lt;/code&gt; inside your container, you are building the wrong thing. Push that logic into your CI/CD pipeline or your infrastructure configuration.&lt;/p&gt;

&lt;p&gt;The goal isn't just a small image. The goal is an immutable, verifiable, and minimal set of instructions for your data to execute. In finance and healthcare, we don't get to be "lazy" with the supply chain. Every extra package you leave in that container is a liability you’re signing your name to. Clean it up, lock it down, and stop shipping your problems to production.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Cover photo by &lt;a href="https://unsplash.com/@zoshuacolah?utm_source=articles_pipeline&amp;amp;utm_medium=referral" rel="noopener noreferrer"&gt;Zoshua Colah&lt;/a&gt; on &lt;a href="https://unsplash.com/?utm_source=articles_pipeline&amp;amp;utm_medium=referral" rel="noopener noreferrer"&gt;Unsplash&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>docker</category>
      <category>security</category>
      <category>devops</category>
      <category>containers</category>
    </item>
    <item>
      <title>Migrating Petabyte-Scale Parquet to Iceberg Without Dropping a Single Row</title>
      <dc:creator>Aniket Abhishek Soni</dc:creator>
      <pubDate>Fri, 04 Sep 2026 11:09:57 +0000</pubDate>
      <link>https://dev.to/aniketsoni/migrating-petabyte-scale-parquet-to-iceberg-without-dropping-a-single-row-3k9g</link>
      <guid>https://dev.to/aniketsoni/migrating-petabyte-scale-parquet-to-iceberg-without-dropping-a-single-row-3k9g</guid>
      <description>&lt;p&gt;Roughly 70% of companies that migrate their data lakes to Iceberg end up with "zombie" datasets: half-migrated, out-of-sync, and consuming double the storage costs. They treat the migration like a switch-flip, and that is exactly how you cause a P0 incident.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Why I chose this topic:&lt;/strong&gt; I’ve been burned by "big bang" migrations in healthcare environments where downtime is measured in lost compliance certifications. After three failed attempts at manual synchronization, I settled on a shadow-table strategy that treats the migration as a background process, not a deployment window.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Most engineers interact with the Hive metastore daily. They treat it like a source of truth, but it’s actually a glorified index of files that has no idea if a Parquet file is corrupted, moved, or partially deleted. We rely on it to tell us where our data lives, yet we treat it as an immutable oracle. When you move to Iceberg, you aren’t just changing a table format; you’re replacing a loose collection of files with a transactional state machine.&lt;/p&gt;

&lt;h2&gt;
  
  
  How it actually works
&lt;/h2&gt;

&lt;p&gt;You don't migrate by moving data. You migrate by shadowing the write path.&lt;/p&gt;

&lt;p&gt;The strategy is simple: keep your existing Parquet pipeline as the "Primary," and introduce a secondary "Shadow" sink that writes to an Iceberg table simultaneously.&lt;/p&gt;

&lt;p&gt;First, you need a dual-write mechanism. If you’re using Spark, don't try to manage this in your application logic. Use a Kafka Connect sink or a structured streaming job that reads from your source (e.g., Kinesis or Kafka) and commits to both targets. &lt;/p&gt;

&lt;p&gt;In your Spark job, keep your Parquet sink as-is. Add the Iceberg sink:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight scala"&gt;&lt;code&gt;&lt;span class="c1"&gt;// The primary Parquet path&lt;/span&gt;
&lt;span class="nv"&gt;df&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="py"&gt;write&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="py"&gt;format&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"parquet"&lt;/span&gt;&lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="py"&gt;mode&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"append"&lt;/span&gt;&lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="py"&gt;save&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;parquetPath&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;// The shadow Iceberg path&lt;/span&gt;
&lt;span class="nv"&gt;df&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="py"&gt;writeTo&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"iceberg_db.shadow_table"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
  &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="py"&gt;tableProperty&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"write.format.default"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"parquet"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
  &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="py"&gt;tableProperty&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"format-version"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"2"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
  &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="py"&gt;append&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The magic happens in the metadata. Iceberg creates a &lt;code&gt;metadata/&lt;/code&gt; folder inside your table directory. This is where the snapshot IDs live. Even if your source data is identical, the two tables will eventually diverge if your schema evolution isn't handled perfectly. &lt;/p&gt;

&lt;p&gt;To ensure parity, you need an automated reconciliation job. Run a daily Spark job that performs a &lt;code&gt;MINUS&lt;/code&gt; or &lt;code&gt;EXCEPT&lt;/code&gt; operation between the two datasets:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- Reconciliation check&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;parquet_table&lt;/span&gt; &lt;span class="k"&gt;EXCEPT&lt;/span&gt; &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;iceberg_table&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If that query returns rows, your shadow write failed or your schema translation is buggy. Do not proceed to the switch-over phase until that query returns zero rows for seven consecutive days.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fimages.unsplash.com%2Fphoto-1633098096956-afdc8bcc8552%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3w5NzU0MjJ8MHwxfHNlYXJjaHw0fHxhYnN0cmFjdCUyMGRhdGElMjBmbG93fGVufDB8MHx8fDE3ODg0Njg3Nzl8MA%26ixlib%3Drb-4.1.0%26q%3D80%26w%3D1080" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fimages.unsplash.com%2Fphoto-1633098096956-afdc8bcc8552%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3w5NzU0MjJ8MHwxfHNlYXJjaHw0fHxhYnN0cmFjdCUyMGRhdGElMjBmbG93fGVufDB8MHx8fDE3ODg0Njg3Nzl8MA%26ixlib%3Drb-4.1.0%26q%3D80%26w%3D1080" alt="Photo by Nicolas Arnold on Unsplash" width="1080" height="608"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Photo by &lt;a href="https://unsplash.com/@nicolasarnold?utm_source=articles_pipeline&amp;amp;utm_medium=referral" rel="noopener noreferrer"&gt;Nicolas Arnold&lt;/a&gt; on &lt;a href="https://unsplash.com/?utm_source=articles_pipeline&amp;amp;utm_medium=referral" rel="noopener noreferrer"&gt;Unsplash&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The tradeoffs nobody mentions
&lt;/h2&gt;

&lt;p&gt;This strategy is not free. &lt;/p&gt;

&lt;p&gt;First, the storage cost. You are effectively doubling your storage footprint for the duration of the migration. In a healthcare context, this means keeping two copies of PII-encrypted data. Ensure your cloud lifecycle policies are set to expire the Parquet files only after you have fully decommissioned the old table.&lt;/p&gt;

&lt;p&gt;Second, the "Ghost Latency." If your Spark job is writing to two different destinations, the overall job duration will be capped by the slower of the two. Iceberg’s commit process—which involves checking for conflicts in the catalog—can add 5-15 seconds to your job. If you have sub-second SLA requirements for your pipelines, this might be a non-starter.&lt;/p&gt;

&lt;p&gt;Third, the metadata overhead. If you are migrating a table with 100,000+ partitions, the initial metadata load in Iceberg can cause your Driver OOM (Out of Memory) errors. You’ll need to set &lt;code&gt;spark.driver.memory&lt;/code&gt; significantly higher than you think, often into the 16GB-32GB range, just to handle the snapshot state management during the initial transition.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fimages.unsplash.com%2Fphoto-1528412220509-1e7cd09cca82%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3w5NzU0MjJ8MHwxfHNlYXJjaHwyNXx8dmludGFnZSUyMG1haW5mcmFtZSUyMGhhcmR3YXJlfGVufDB8MHx8fDE3ODg0Njg3ODB8MA%26ixlib%3Drb-4.1.0%26q%3D80%26w%3D1080" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fimages.unsplash.com%2Fphoto-1528412220509-1e7cd09cca82%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3w5NzU0MjJ8MHwxfHNlYXJjaHwyNXx8dmludGFnZSUyMG1haW5mcmFtZSUyMGhhcmR3YXJlfGVufDB8MHx8fDE3ODg0Njg3ODB8MA%26ixlib%3Drb-4.1.0%26q%3D80%26w%3D1080" alt="Photo by Celine Nadon on Unsplash" width="1080" height="720"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Photo by &lt;a href="https://unsplash.com/@celinen?utm_source=articles_pipeline&amp;amp;utm_medium=referral" rel="noopener noreferrer"&gt;Celine Nadon&lt;/a&gt; on &lt;a href="https://unsplash.com/?utm_source=articles_pipeline&amp;amp;utm_medium=referral" rel="noopener noreferrer"&gt;Unsplash&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  When to reach for it (and when not to)
&lt;/h2&gt;

&lt;p&gt;Reach for the shadow-table strategy if you have a "live" table—one that is being queried by BI tools or downstream microservices 24/7. This is non-negotiable in financial services where a missing row in a ledger report is a regulatory disaster.&lt;/p&gt;

&lt;p&gt;Don't reach for this if your data is static. If you have a table that only gets updated once a month, just perform an &lt;code&gt;INSERT OVERWRITE&lt;/code&gt; into a new Iceberg table during a maintenance window. The shadow strategy is complex because it’s meant for high-velocity, high-concurrency environments. If you don't have the write volume to justify the complexity, you are just inviting more failure points into your architecture.&lt;/p&gt;

&lt;p&gt;Also, avoid this if your Parquet files are highly fragmented (millions of tiny 1KB files). Migrating "bad" data into Iceberg just gives you a "clean" format for "dirty" data. Use the migration window as an opportunity to perform a compaction job before you point your production queries to the new Iceberg table.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;Migrating to Iceberg is less about the technical transition and more about the psychological shift from "file-based data" to "transactional data." By shadowing your writes, you buy yourself the time to catch the edge cases—like subtle schema mismatches or timezone shifts in timestamp columns—that would otherwise blow up in your face on a Monday morning. &lt;/p&gt;

&lt;p&gt;The goal isn't to be fast. The goal is to reach a state where you can point your production traffic to the new table, delete the old Parquet files, and have your stakeholders remain completely unaware that anything happened under the hood. &lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Tags:&lt;/strong&gt; #data #engineering #iceberg #migration&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Cover photo by &lt;a href="https://unsplash.com/@valentinlacoste?utm_source=articles_pipeline&amp;amp;utm_medium=referral" rel="noopener noreferrer"&gt;Valentin Lacoste&lt;/a&gt; on &lt;a href="https://unsplash.com/?utm_source=articles_pipeline&amp;amp;utm_medium=referral" rel="noopener noreferrer"&gt;Unsplash&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>dataengineering</category>
      <category>iceberg</category>
      <category>infrastructure</category>
      <category>databricks</category>
    </item>
    <item>
      <title>Don't put an LLM in charge of your production database</title>
      <dc:creator>Aniket Abhishek Soni</dc:creator>
      <pubDate>Fri, 04 Sep 2026 11:09:23 +0000</pubDate>
      <link>https://dev.to/aniketsoni/dont-put-an-llm-in-charge-of-your-production-database-1o9e</link>
      <guid>https://dev.to/aniketsoni/dont-put-an-llm-in-charge-of-your-production-database-1o9e</guid>
      <description>&lt;p&gt;Last June, a junior analyst pushed a "helpful" GenAI assistant to our internal Tableau-connected lakehouse. Within forty minutes, the agent generated a &lt;code&gt;SELECT *&lt;/code&gt; across a 40-terabyte partitioned table joined against a cross-region S3 bucket. The query didn't just fail; it locked the Databricks SQL Warehouse, blew our monthly compute budget in a single afternoon, and triggered a PagerDuty incident that ruined my kid’s birthday dinner. That query cost us $4,200 in DBU burn and an hour of downtime for our actual business stakeholders.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Why I chose this topic:&lt;/strong&gt; I’m tired of seeing engineers treat Text-to-SQL as a magic wand rather than a dangerous, non-deterministic interface. I wrote this because production-grade data governance requires moving past "prompt engineering" into hard, infrastructure-level constraints.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The decision you are facing isn't whether to use LLMs for data—it's whether you want to build an expensive, unreliable hallucination engine or a system that actually respects your data perimeter. You are currently choosing between three architectural patterns: the "Raw Agent" (blind trust), the "Semantic Layer" (the guardrail approach), and the "Frozen Schema" (the brute-force approach).&lt;/p&gt;

&lt;h2&gt;
  
  
  The contenders
&lt;/h2&gt;

&lt;p&gt;The "Raw Agent" is what happens when you just point a LangChain &lt;code&gt;SQLDatabaseChain&lt;/code&gt; at your Unity Catalog metastore. It’s the "move fast and break things" approach, except you're breaking your company's P&amp;amp;L. &lt;/p&gt;

&lt;p&gt;The "Semantic Layer" uses an intermediate abstraction—think dbt Semantic Layer or a specialized metric store—that acts as a firewall between the LLM and the raw SQL. The LLM talks to the model, not the table.&lt;/p&gt;

&lt;p&gt;The "Frozen Schema" is the nuclear option. You don't give the LLM the entire catalog. You give it a strictly curated, subsetted DDL definition of exactly three tables, with no write permissions, and a &lt;code&gt;LIMIT&lt;/code&gt; clause hard-coded into the underlying execution proxy.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fimages.unsplash.com%2Fphoto-1762329402620-16e540c8df8a%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3w5NzU0MjJ8MHwxfHNlYXJjaHwxNXx8YnJva2VuJTIwY2lyY3VpdCUyMGJyZWFrZXJ8ZW58MHwwfHx8MTc4ODI5NjEzMHww%26ixlib%3Drb-4.1.0%26q%3D80%26w%3D1080" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fimages.unsplash.com%2Fphoto-1762329402620-16e540c8df8a%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3w5NzU0MjJ8MHwxfHNlYXJjaHwxNXx8YnJva2VuJTIwY2lyY3VpdCUyMGJyZWFrZXJ8ZW58MHwwfHx8MTc4ODI5NjEzMHww%26ixlib%3Drb-4.1.0%26q%3D80%26w%3D1080" alt="Photo by Zulfugar Karimov on Unsplash" width="1080" height="720"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Photo by &lt;a href="https://unsplash.com/@zulfugarkarimov?utm_source=articles_pipeline&amp;amp;utm_medium=referral" rel="noopener noreferrer"&gt;Zulfugar Karimov&lt;/a&gt; on &lt;a href="https://unsplash.com/?utm_source=articles_pipeline&amp;amp;utm_medium=referral" rel="noopener noreferrer"&gt;Unsplash&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Latency and the cost of non-determinism
&lt;/h2&gt;

&lt;p&gt;In production, latency is a feature, not a bug. If your Text-to-SQL agent takes 15 seconds to parse, plan, and execute, your business users will go back to asking the data team for CSVs.&lt;/p&gt;

&lt;p&gt;The Raw Agent is a nightmare here. If you provide a schema with 500 tables, the token count for the system prompt alone will set you back significant latency on every single request. Using &lt;code&gt;gpt-4o&lt;/code&gt;, you’re looking at 2-3 seconds of TTFT (Time To First Token) just to generate a &lt;code&gt;JOIN&lt;/code&gt; that will likely fail because it missed a join key on a non-indexed column.&lt;/p&gt;

&lt;p&gt;The Semantic Layer wins on efficiency. By exposing only core metrics—&lt;code&gt;revenue_by_region&lt;/code&gt;, &lt;code&gt;churn_rate_monthly&lt;/code&gt;—the LLM has a search space of 20 variables instead of 20,000 columns. You can cache these responses in Redis for common queries. With a 300ms retrieval time from cache, you’re looking at a sub-second user experience that feels like a real product.&lt;/p&gt;

&lt;h2&gt;
  
  
  Failure modes and the "Hallucination Gap"
&lt;/h2&gt;

&lt;p&gt;The biggest failure mode in Text-to-SQL is the "Syntactically Correct, Semantically Wrong" query. The LLM might write a perfect &lt;code&gt;JOIN&lt;/code&gt; statement that executes without error, but calculates &lt;code&gt;gross_margin&lt;/code&gt; by subtracting &lt;code&gt;shipping_costs&lt;/code&gt; from &lt;code&gt;revenue&lt;/code&gt; when your actual business logic requires &lt;code&gt;(revenue - cost_of_goods_sold) - shipping_costs&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Raw Agents fail silently. They give the user the wrong number, and the user makes a million-dollar decision based on it. There is no error message because the SQL is valid.&lt;/p&gt;

&lt;p&gt;The Semantic Layer forces the LLM to use pre-defined logic. You move the source of truth from the LLM’s "knowledge" into your dbt project. When the LLM asks for "margin," it hits a view that already contains the logic. If the LLM tries to add a column that doesn't exist, the query parser throws an &lt;code&gt;AnalysisException&lt;/code&gt; before the data is even touched.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fimages.unsplash.com%2Fphoto-1771789642845-bc6dd4d8f50e%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3w5NzU0MjJ8MHwxfHNlYXJjaHwxNXx8YWJzdHJhY3QlMjBiaW5hcnklMjBjb2RlfGVufDB8MHx8fDE3ODgyOTYxMzF8MA%26ixlib%3Drb-4.1.0%26q%3D80%26w%3D1080" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fimages.unsplash.com%2Fphoto-1771789642845-bc6dd4d8f50e%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3w5NzU0MjJ8MHwxfHNlYXJjaHwxNXx8YWJzdHJhY3QlMjBiaW5hcnklMjBjb2RlfGVufDB8MHx8fDE3ODgyOTYxMzF8MA%26ixlib%3Drb-4.1.0%26q%3D80%26w%3D1080" alt="Photo by Logan Voss on Unsplash" width="1080" height="608"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Photo by &lt;a href="https://unsplash.com/@loganvoss?utm_source=articles_pipeline&amp;amp;utm_medium=referral" rel="noopener noreferrer"&gt;Logan Voss&lt;/a&gt; on &lt;a href="https://unsplash.com/?utm_source=articles_pipeline&amp;amp;utm_medium=referral" rel="noopener noreferrer"&gt;Unsplash&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Infrastructure and operational burden
&lt;/h2&gt;

&lt;p&gt;Governance is where most of these projects die. If you aren't using Row-Level Security (RLS) and Column-Level Security (CLS), you are one prompt away from a data breach. &lt;/p&gt;

&lt;p&gt;With the Raw Agent, you have to implement RLS on the warehouse side, but you’re still exposing your entire schema. You need to keep your &lt;code&gt;INFORMATION_SCHEMA&lt;/code&gt; restricted. If you use Databricks, you’re looking at complex &lt;code&gt;GRANT&lt;/code&gt; hierarchies. It’s brittle. If you add a new column for PII, you have to remember to hide it from the LLM’s system prompt, or you're leaking sensitive data.&lt;/p&gt;

&lt;p&gt;The Semantic Layer shifts the burden to the platform team. You maintain the semantic layer, and the agent acts as a client of that layer. This is the only way to scale. You don't grant the LLM a connection to the raw tables; you grant it a service account connection to the semantic interface. The service account has &lt;code&gt;SELECT&lt;/code&gt; access only to the views you've blessed. &lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd pick, and why
&lt;/h2&gt;

&lt;p&gt;I’d pick the Semantic Layer every single time. If you’re building in a regulated industry—healthcare or finance—you don't have a choice.&lt;/p&gt;

&lt;p&gt;Here is my non-negotiable stack:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The Guardrail:&lt;/strong&gt; A dedicated Python intermediary (FastAPI) that sits between the LLM and the SQL engine.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The Constraint:&lt;/strong&gt; Strict &lt;code&gt;LIMIT&lt;/code&gt; enforcement on every query. If the LLM doesn't include &lt;code&gt;LIMIT 100&lt;/code&gt;, the FastAPI layer injects it automatically before sending it to the warehouse.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The Identity:&lt;/strong&gt; Never use a generic "data" service account. Use short-lived tokens mapped to the user requesting the data. If the user doesn't have access to the &lt;code&gt;patients&lt;/code&gt; table, the agent can't generate a query that hits it.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The caveat? It’s harder to build. You have to write the code that maps natural language intent to specific semantic metrics. It’s not "plug and play." But in production, "plug and play" just means "plug in and pray." &lt;/p&gt;

&lt;p&gt;Don't let an LLM write raw SQL against your production lakehouse. Build a layer, wrap it in strict permissions, and force the agent to play by your rules. Your PagerDuty rotation will thank you.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Cover photo by &lt;a href="https://unsplash.com/@tylergm?utm_source=articles_pipeline&amp;amp;utm_medium=referral" rel="noopener noreferrer"&gt;Tyler&lt;/a&gt; on &lt;a href="https://unsplash.com/?utm_source=articles_pipeline&amp;amp;utm_medium=referral" rel="noopener noreferrer"&gt;Unsplash&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>sql</category>
      <category>llm</category>
      <category>data</category>
      <category>platform</category>
    </item>
  </channel>
</rss>
