<?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>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>
    <item>
      <title>Is Your 'Exactly-Once' Streaming Pipeline Actually Lying To You?</title>
      <dc:creator>Aniket Abhishek Soni</dc:creator>
      <pubDate>Tue, 01 Sep 2026 03:13:16 +0000</pubDate>
      <link>https://dev.to/aniketsoni/is-your-exactly-once-streaming-pipeline-actually-lying-to-you-27g7</link>
      <guid>https://dev.to/aniketsoni/is-your-exactly-once-streaming-pipeline-actually-lying-to-you-27g7</guid>
      <description>&lt;p&gt;Three years ago, I spent two weeks debugging a reconciliation issue where a healthcare provider’s balance sheet was off by exactly $42,000 every Tuesday. We were running Spark 2.4, blindly trusting that &lt;code&gt;checkpointLocation&lt;/code&gt; was a magical "undo" button for any downstream failure. We had duplicate writes during network partitions, and our downstream SQL database was a mess of upserts gone wrong.&lt;/p&gt;

&lt;p&gt;Today, if a node dies, my pipeline recovers, reprocesses the micro-batch, and the end state of my data remains identical to a clean run. I stopped trusting the marketing copy on the Spark docs and started reading the source code of the connectors. If you think "exactly-once" is a toggle you flip in your Spark configuration, you’re about to lose your job—or at least your sleep.&lt;/p&gt;

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

&lt;p&gt;Most of my peers treat &lt;code&gt;outputMode("append")&lt;/code&gt; and a checkpoint path as a set-it-and-forget-it deployment strategy. They assume that because the Spark UI says "Exactly-Once," the entire system—from Kafka to the final OLAP table—is bulletproof. &lt;/p&gt;

&lt;p&gt;It isn’t. Spark’s "exactly-once" guarantee is strictly internal. It guarantees that the &lt;em&gt;state&lt;/em&gt; of your streaming query is consistent. If Spark crashes, it can resume from the last offset stored in your HDFS or S3 checkpoint directory. The problem is that Spark doesn't control the sink. If your sink isn't idempotent, Spark will write the same data twice during a retry, and your "exactly-once" pipeline just turned into an "at-least-twice" nightmare.&lt;/p&gt;

&lt;p&gt;Consider a standard write to S3 as Parquet. If a task fails halfway through writing a file, Spark leaves behind a partial file. When the task retries, it writes again. Unless you are using the &lt;code&gt;S3A&lt;/code&gt; committers correctly—specifically the &lt;code&gt;DirectoryStagingCommitter&lt;/code&gt;—you end up with garbage data that ruins your downstream partition. I’ve seen production pipelines where the cumulative error from "zombie" files grew to terabytes, slowing down queries by an order of magnitude.&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-1680691257251-5fead813b73e%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3w5NzU0MjJ8MHwxfHNlYXJjaHwyMXx8c2VydmVyJTIwcmFja3xlbnwwfDB8fHwxNzg4MjE2MjA2fDA%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-1680691257251-5fead813b73e%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3w5NzU0MjJ8MHwxfHNlYXJjaHwyMXx8c2VydmVyJTIwcmFja3xlbnwwfDB8fHwxNzg4MjE2MjA2fDA%26ixlib%3Drb-4.1.0%26q%3D80%26w%3D1080" alt="Photo by Dimitri Karastelev on Unsplash" width="1080" height="720"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Photo by &lt;a href="https://unsplash.com/@dkfra19?utm_source=articles_pipeline&amp;amp;utm_medium=referral" rel="noopener noreferrer"&gt;Dimitri Karastelev&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 illusion of atomicity in sinks
&lt;/h2&gt;

&lt;p&gt;If you’re writing to a standard RDBMS using &lt;code&gt;foreachBatch&lt;/code&gt;, you are effectively on your own. Spark doesn't know about your SQL transaction. You have to handle it manually.&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="nv"&gt;df&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="py"&gt;writeStream&lt;/span&gt;
  &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="py"&gt;foreachBatch&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;batchDF&lt;/span&gt;&lt;span class="k"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;DataFrame&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;batchId&lt;/span&gt;&lt;span class="k"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Long&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt;
    &lt;span class="nv"&gt;batchDF&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="py"&gt;persist&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
    &lt;span class="nv"&gt;batchDF&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;"jdbc"&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="nv"&gt;batchDF&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="py"&gt;unpersist&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
  &lt;span class="o"&gt;}&lt;/span&gt;
  &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="py"&gt;start&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the &lt;code&gt;save&lt;/code&gt; operation succeeds but the Spark driver crashes before it can commit the offset to the checkpoint, the stream will restart, re-read the batch, and write the data again. Your JDBC sink now has duplicates. &lt;/p&gt;

&lt;p&gt;To fix this, you must implement a "batch-id-aware" write pattern. You need a metadata table in your database that stores the latest &lt;code&gt;batchId&lt;/code&gt; processed. Inside &lt;code&gt;foreachBatch&lt;/code&gt;, you wrap the write in a transaction:&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;// Pseudocode for the pattern that actually works&lt;/span&gt;
&lt;span class="nv"&gt;batchDF&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;jdbc&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"dest_table"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;properties&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="nv"&gt;metadataDF&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;jdbc&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"processed_batches"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;properties&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you don't atomize the write and the metadata update, you are just praying that the network gods are in a good mood. Relying on "exactly-once" without managing your sink's idempotency is like locking your front door but leaving the garage wide open.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Kafka-to-Kafka trap
&lt;/h2&gt;

&lt;p&gt;When you’re doing stream-to-stream processing (e.g., Kafka to Kafka), Spark Structured Streaming is actually quite good at exactly-once, provided you use the built-in Kafka source and sink. It uses the Kafka transactional producer API.&lt;/p&gt;

&lt;p&gt;However, the moment you deviate from the "supported" path, you break the chain. I once saw a team try to enrich Kafka data by calling a REST API inside a &lt;code&gt;mapPartitions&lt;/code&gt; block. When a retry occurred, the API was called again. If that API performed an action (like charging a credit card), the user got charged twice.&lt;/p&gt;

&lt;p&gt;Exactly-once in Spark is a state-management feature, not a global distributed transaction protocol. If your pipeline involves external side effects, you must implement your own idempotency keys. I force every single upstream producer to generate a &lt;code&gt;UUID&lt;/code&gt; for every event. In the Spark job, I use that &lt;code&gt;UUID&lt;/code&gt; to check against a Redis cache before performing any side effect. If the key exists, I drop the event. It’s the only way to sleep at night.&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-1765445665639-f59239859d62%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3w5NzU0MjJ8MHwxfHNlYXJjaHwyNXx8ZGlnaXRhbCUyMGdsaXRjaHxlbnwwfDB8fHwxNzg4MjE2MjA2fDA%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-1765445665639-f59239859d62%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3w5NzU0MjJ8MHwxfHNlYXJjaHwyNXx8ZGlnaXRhbCUyMGdsaXRjaHxlbnwwfDB8fHwxNzg4MjE2MjA2fDA%26ixlib%3Drb-4.1.0%26q%3D80%26w%3D1080" alt="Photo by Egor Komarov on Unsplash" width="1080" height="586"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Photo by &lt;a href="https://unsplash.com/@egorkomarov?utm_source=articles_pipeline&amp;amp;utm_medium=referral" rel="noopener noreferrer"&gt;Egor Komarov&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 common pushback I hear is: "But the Spark documentation says it guarantees exactly-once, so why should I build all this extra infrastructure?"&lt;/p&gt;

&lt;p&gt;My answer is simple: Spark’s guarantee is scoped to the Spark application's internal state. It is not a contract with your external database, your external API, or your storage layer. If you treat it as a universal guarantee, you are ignoring the physics of distributed systems. The "distributed snapshot" that Spark takes is only useful if the external system can participate in that snapshot. Most cannot.&lt;/p&gt;

&lt;p&gt;Another argument I hear is that the performance hit of idempotency checks is too high. "We can’t do a Redis lookup for every single event in a high-throughput stream."&lt;/p&gt;

&lt;p&gt;If you can’t afford an idempotency check, you can’t afford a duplicate. In healthcare or financial services, "exactly-once" isn't a performance optimization; it’s a regulatory requirement. If you’re processing insurance claims and you duplicate a payment, the cost of fixing that data is a thousand times higher than the cost of a few milliseconds of Redis latency. Optimize for correctness first, then optimize for throughput.&lt;/p&gt;

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

&lt;p&gt;Stop using the term "exactly-once" as a shorthand for "I don't have to worry about data quality." You always have to worry. &lt;/p&gt;

&lt;p&gt;Spark Structured Streaming is a phenomenal engine, but it is not a magical black box that prevents business logic errors. Exactly-once is an internal property of how Spark manages offsets and state. Once that data leaves the Spark executor to hit a sink, the "exactly-once" guarantee is only as good as your integration.&lt;/p&gt;

&lt;p&gt;Use &lt;code&gt;foreachBatch&lt;/code&gt; to control your sink. Use idempotency keys for side effects. Check your checkpoint directories for garbage files. If you aren't doing these things, your pipeline is not exactly-once; it’s just a broken system waiting for a high-volume day to reveal its flaws.&lt;/p&gt;




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

&lt;p&gt;&lt;em&gt;Cover photo by &lt;a href="https://unsplash.com/@jilburr?utm_source=articles_pipeline&amp;amp;utm_medium=referral" rel="noopener noreferrer"&gt;Jilbert Ebrahimi&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>streaming</category>
      <category>data</category>
      <category>engineering</category>
    </item>
    <item>
      <title>Is Your Silver Layer Just a Slow-Motion Train Wreck?</title>
      <dc:creator>Aniket Abhishek Soni</dc:creator>
      <pubDate>Sun, 30 Aug 2026 05:46:19 +0000</pubDate>
      <link>https://dev.to/aniketsoni/is-your-silver-layer-just-a-slow-motion-train-wreck-1f0h</link>
      <guid>https://dev.to/aniketsoni/is-your-silver-layer-just-a-slow-motion-train-wreck-1f0h</guid>
      <description>&lt;p&gt;It was 3:15 AM on a Tuesday when the PagerDuty alerts started screaming. Our Bronze-to-Silver processing job, which usually took forty minutes, had been running for six hours. The cluster was pegged, the spill-to-disk metrics were off the charts, and we were locking out the downstream BI dashboard for our compliance team. By the time I manually killed the job and truncated the Silver table to perform a full-refresh, we were three hours behind our SLA. That incident cost the firm roughly $40,000 in regulatory reporting penalties and a very awkward meeting with the CTO. The culprit? A "simple" merge operation on a 5TB table that finally hit a partition skew tipping point.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Why I chose this topic:&lt;/strong&gt; I spent three years watching engineers treat Delta tables like static files, only to be surprised when their jobs collapsed under volume. I’m writing this because incremental processing isn't a "nice to have" anymore—it’s the only way to keep your sanity in production environments.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Most of you are currently standing at a crossroads. You have a massive Bronze (raw) layer, and you need to feed a Silver (cleansed) layer. You’re either running full-table overwrites, which is a financial death trap as your data grows, or you’re hacking together custom watermarks that break the second a schema changes or a late-arriving record shows up. You’re choosing between "simple but expensive" and "complex but fragile."&lt;/p&gt;

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

&lt;p&gt;You have three primary ways to move data from Bronze to Silver in the Delta ecosystem.&lt;/p&gt;

&lt;p&gt;First, the &lt;strong&gt;Full Overwrite&lt;/strong&gt;. You read the entire source, apply your logic, and &lt;code&gt;df.write.mode("overwrite")&lt;/code&gt; the target. It’s clean, it’s idempotent, and it’s mathematically guaranteed to bankrupt you if your data volume doubles annually.&lt;/p&gt;

&lt;p&gt;Second, the &lt;strong&gt;Custom Watermark&lt;/strong&gt;. You track a &lt;code&gt;max_processed_timestamp&lt;/code&gt; in a control table, filter your source by &lt;code&gt;event_time &amp;gt; last_processed&lt;/code&gt;, and perform a &lt;code&gt;merge&lt;/code&gt; into the target. It’s the "classic" approach. It works until you have an update to a record that happened three days ago, which your watermark filter will conveniently ignore.&lt;/p&gt;

&lt;p&gt;Third, &lt;strong&gt;Change Data Feed (CDF)&lt;/strong&gt;. You enable &lt;code&gt;delta.enableChangeDataFeed = true&lt;/code&gt; on your Delta table. Delta Lake then tracks every row-level change (inserts, updates, deletes) in a hidden log. You treat the table like a stream, consuming only the deltas.&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-1768329787929-788f9d71a2af%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3w5NzU0MjJ8MHwxfHNlYXJjaHwxOHx8cnVzdHklMjBnZWFyc3xlbnwwfDB8fHwxNzg4MDM2NTcxfDA%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-1768329787929-788f9d71a2af%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3w5NzU0MjJ8MHwxfHNlYXJjaHwxOHx8cnVzdHklMjBnZWFyc3xlbnwwfDB8fHwxNzg4MDM2NTcxfDA%26ixlib%3Drb-4.1.0%26q%3D80%26w%3D1080" alt="Photo by Michael Evans on Unsplash" width="1080" height="720"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Photo by &lt;a href="https://unsplash.com/@michael_jay_photography?utm_source=articles_pipeline&amp;amp;utm_medium=referral" rel="noopener noreferrer"&gt;Michael Evans&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 doing business
&lt;/h2&gt;

&lt;p&gt;Let’s talk about the bill. Full Overwrites are the most expensive because you pay for the compute to shuffle the entire dataset for every run. If you have 10TB of data and only 1GB changed, you are paying for the other 9.999TB of sorting and shuffling. &lt;/p&gt;

&lt;p&gt;Custom Watermarks feel cheaper, but the hidden cost is the &lt;code&gt;merge&lt;/code&gt; operation. When you run a &lt;code&gt;MERGE INTO&lt;/code&gt;, Spark has to perform a full shuffle of the target table to identify the files containing matching keys. If your Silver table is partitioned by &lt;code&gt;date&lt;/code&gt;, and your updates are scattered across two years of data, a single &lt;code&gt;MERGE&lt;/code&gt; will force Spark to rewrite every single partition in your table.&lt;/p&gt;

&lt;p&gt;CDF is the clear winner here. Because CDF gives you a stream of &lt;em&gt;only the changes&lt;/em&gt;, your Silver layer transformation becomes a pure append-only operation if you design it right. You are reading a few hundred megabytes of deltas instead of terabytes of static data. Your compute clusters can be 1/10th the size, and your job duration drops from hours to minutes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Ops burden and the "missing update" problem
&lt;/h2&gt;

&lt;p&gt;The real nightmare in production is the "missing update." With Watermarks, if a record arrives late or an upstream system triggers a retroactive correction, your job will never see it unless you implement a "look-back" window—which basically forces you back into a partial full-refresh, re-processing data you’ve already handled. &lt;/p&gt;

&lt;p&gt;CDF solves this by design. Because CDF captures the &lt;em&gt;transaction&lt;/em&gt; log, it doesn't care if an update comes in two hours late. It will show up in the change feed as a new version of the row. You don't have to write complex logic to handle late-arriving data; you just process the stream.&lt;/p&gt;

&lt;p&gt;However, CDF comes with an ops tax. You must manage table properties. If you’re using Databricks or Delta OSS, you need to ensure &lt;code&gt;delta.enableChangeDataFeed&lt;/code&gt; is set at table creation. If you try to enable it on a massive, pre-existing table, you’re looking at a rewrite of that table's metadata, which can be a blocking operation. You also need to manage your &lt;code&gt;delta.deletedFileRetentionDuration&lt;/code&gt; and &lt;code&gt;delta.logRetentionDuration&lt;/code&gt;. If your job fails for three days and your retention is only two days, you’ve lost your pointer to the stream. You’ll be doing a full rebuild anyway.&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-1579519772836-2732b96a6306%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3w5NzU0MjJ8MHwxfHNlYXJjaHwzfHxkaWdpdGFsJTIwdHJhZmZpYyUyMGphbXxlbnwwfDB8fHwxNzg4MDM2NTcyfDA%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-1579519772836-2732b96a6306%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3w5NzU0MjJ8MHwxfHNlYXJjaHwzfHxkaWdpdGFsJTIwdHJhZmZpYyUyMGphbXxlbnwwfDB8fHwxNzg4MDM2NTcyfDA%26ixlib%3Drb-4.1.0%26q%3D80%26w%3D1080" alt="Photo by Jacek Dylag on Unsplash" width="1080" height="720"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Photo by &lt;a href="https://unsplash.com/@dylu?utm_source=articles_pipeline&amp;amp;utm_medium=referral" rel="noopener noreferrer"&gt;Jacek Dylag&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;
  
  
  Failure modes and recovery
&lt;/h2&gt;

&lt;p&gt;The failure mode for Full Overwrites is simple: the job takes too long and times out. Recovery is easy but painful—you just rerun it.&lt;/p&gt;

&lt;p&gt;The failure mode for Watermarks is silent data corruption. If your control table update fails after the &lt;code&gt;merge&lt;/code&gt; finishes but before the commit metadata is written, you might double-process or lose data on the next run. Tracking the state of your pipeline becomes a distributed systems problem that you probably aren't qualified to solve (neither am I).&lt;/p&gt;

&lt;p&gt;CDF has a cleaner failure mode. Since it’s integrated with Spark Structured Streaming, you get checkpointing for free. If the job dies, it restarts from the exact offset in the transaction log. You don't have to guess where you left off. The failure mode isn't "data corruption"; it's "the job stopped." You restart it, it catches up, and you move on.&lt;/p&gt;

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

&lt;p&gt;I’ve reached a point where I refuse to build a production pipeline that doesn’t use CDF. It is the only way to move from "managing data" to "managing streams."&lt;/p&gt;

&lt;p&gt;If you’re sitting on a massive, legacy Silver layer, don't try to migrate it all at once. Start by enabling CDF on your new Bronze ingestions. Build a "Silver-Incremental" table alongside your old "Silver-Legacy" table. Once you prove the latency gains, point your downstream BI tools to the new table and drop the old one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The caveats:&lt;/strong&gt; &lt;br&gt;
First, check your storage costs. CDF creates extra files (the change data). It increases your storage footprint by about 10–15%. In the world of cloud storage, that’s pennies compared to the hundreds of dollars an hour you’re burning on massive Spark clusters.&lt;/p&gt;

&lt;p&gt;Second, be careful with schema evolution. If you add a column to your Bronze table, your Silver job needs to handle that. When using CDF, you’re often casting and transforming in a stream. If a schema change isn't backwards compatible, your streaming job will crash. You need to implement schema evolution (&lt;code&gt;spark.databricks.delta.schema.autoMerge.enabled = true&lt;/code&gt;) on your write operations, but even then, test your Silver logic in a dev environment first.&lt;/p&gt;

&lt;p&gt;Third, don't over-partition. A common mistake is partitioning by &lt;code&gt;customer_id&lt;/code&gt; or &lt;code&gt;transaction_id&lt;/code&gt;. If you have a high-cardinality column, your partitions will be too small (the "small file problem"). Stick to partitioning by date or region, and let Delta’s Z-Ordering handle the performance optimization.&lt;/p&gt;

&lt;p&gt;Stop fighting the infrastructure. The tools to handle incremental data are already built into the Delta protocol. If you’re still doing full refreshes, you’re not an engineer—you’re a janitor cleaning up after a bad architecture. Stop the bleeding, enable the feed, and save your sleep.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Cover photo by &lt;a href="https://unsplash.com/@arashasghari?utm_source=articles_pipeline&amp;amp;utm_medium=referral" rel="noopener noreferrer"&gt;Arash&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>delta</category>
      <category>dataengineering</category>
      <category>pipelines</category>
    </item>
    <item>
      <title>The Orchestration Fallacy: Why Your Medallion Architecture Doesn't Need a Swiss Army Knife</title>
      <dc:creator>Aniket Abhishek Soni</dc:creator>
      <pubDate>Fri, 28 Aug 2026 13:00:36 +0000</pubDate>
      <link>https://dev.to/aniketsoni/the-orchestration-fallacy-why-your-medallion-architecture-doesnt-need-a-swiss-army-knife-1d64</link>
      <guid>https://dev.to/aniketsoni/the-orchestration-fallacy-why-your-medallion-architecture-doesnt-need-a-swiss-army-knife-1d64</guid>
      <description>&lt;p&gt;The most persistent myth in data engineering is that you need a dedicated, heavy-duty orchestrator to manage a medallion architecture. If I hear one more architect argue that we need a complex DAG to move data from Bronze to Silver, I’m going to assume they’ve never had to debug a zombie process at 3:00 AM on a Sunday.&lt;/p&gt;

&lt;p&gt;You don't need a complex orchestration layer. You need a reliable state machine that triggers your jobs when the data lands. Most of the complexity we build into our pipelines—retries, backfills, complex branching—is a direct result of choosing the wrong tool for the wrong scale.&lt;/p&gt;

&lt;p&gt;You are likely staring at a familiar problem: you have a landing zone in S3 or ADLS, a Databricks workspace, and a business requirement that says the Silver table needs to be updated within fifteen minutes of the Bronze ingestion. You’re trying to decide whether to pay for the managed overhead of a workflow engine or just hack together some cloud-native glue.&lt;/p&gt;

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

&lt;p&gt;First, there’s AWS Step Functions. It’s the ultimate "I don't want to manage a server" play. You define your pipeline in Amazon States Language (ASL), essentially a JSON-based state machine. It integrates with everything in AWS, but it treats your data pipeline like a series of API calls.&lt;/p&gt;

&lt;p&gt;Then, there’s Apache Airflow. It’s the industry standard for a reason: it’s Python. If you can write a script, you can write a DAG. But it’s a high-maintenance beast. You’re managing an environment—whether it’s MWAA, Cloud Composer, or a self-hosted Kubernetes cluster—and you’re fighting the "executor" war.&lt;/p&gt;

&lt;p&gt;Finally, there’s Databricks Workflows. This is the "keep it in the house" option. It’s built into the platform where your compute already lives. It’s not just a job scheduler; it’s an integrated execution engine that knows the difference between a cluster startup failure and a Delta Lake transaction conflict.&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-1596902362438-e8516a972fb5%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3w5NzU0MjJ8MHwxfHNlYXJjaHwyMHx8c2hhdHRlcmVkJTIwZ2xhc3N8ZW58MHwwfHx8MTc4Nzg4Mjg1Mnww%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-1596902362438-e8516a972fb5%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3w5NzU0MjJ8MHwxfHNlYXJjaHwyMHx8c2hhdHRlcmVkJTIwZ2xhc3N8ZW58MHwwfHx8MTc4Nzg4Mjg1Mnww%26ixlib%3Drb-4.1.0%26q%3D80%26w%3D1080" alt="Photo by Kellie Shepherd Moeller on Unsplash" width="1080" height="810"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Photo by &lt;a href="https://unsplash.com/@kmoeller?utm_source=articles_pipeline&amp;amp;utm_medium=referral" rel="noopener noreferrer"&gt;Kellie Shepherd Moeller&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;
  
  
  Cost and the hidden tax of complexity
&lt;/h2&gt;

&lt;p&gt;People often look at the price tag of a single execution and ignore the operational tax. &lt;/p&gt;

&lt;p&gt;Step Functions looks cheap until you reach scale. At $25 per million state transitions, a complex pipeline with thousands of tasks will bleed your budget dry. More importantly, the cost of debugging a complex ASL JSON file is astronomical. When a &lt;code&gt;TaskFailed&lt;/code&gt; event fires, you’re parsing CloudWatch logs and trying to reconstruct state from a visual graph that looks like a bowl of spaghetti.&lt;/p&gt;

&lt;p&gt;Airflow is a hidden cost center. If you’re running it on a managed service like MWAA, you’re paying for the base instance price, which is rarely under $200-$300 a month before you even run a single job. If you’re self-hosting on EKS, factor in the engineering time. I’ve seen teams spend 20% of their "data engineering" time just keeping the Airflow scheduler and web server alive. That’s a massive salary tax to pay for the privilege of writing &lt;code&gt;PythonOperator&lt;/code&gt; stubs.&lt;/p&gt;

&lt;p&gt;Databricks Workflows is arguably the cheapest for medallion pipelines because of its integration with the compute layer. You aren't paying for a separate orchestration cluster. You’re using the existing Databricks compute, and the job orchestration is effectively free or negligible. You stop paying for the orchestrator the moment the job finishes. In a medallion architecture, where you’re often running jobs in sequence, this tight coupling is a feature, not a bug.&lt;/p&gt;

&lt;h2&gt;
  
  
  Failure modes and the "retry" trap
&lt;/h2&gt;

&lt;p&gt;Let’s talk about real-world failure. In healthcare pipelines, "fail fast" is a requirement, not a suggestion.&lt;/p&gt;

&lt;p&gt;In Airflow, the biggest failure mode is the "scheduler heartbeat." When your DAGs get too numerous or complex, the scheduler lags. You wake up to a dashboard showing nothing running, while your actual data is missing its SLA. You’re left digging through &lt;code&gt;airflow-scheduler.log&lt;/code&gt; to find out why the heartbeats timed out. It’s a distributed system problem that you now own.&lt;/p&gt;

&lt;p&gt;Step Functions has a "max duration" limit of one year, but it struggles with long-running, stateful processes. If your transformation job takes four hours and the underlying Lambda or Batch job hangs, Step Functions will keep the execution open, eating costs and potentially causing downstream concurrency limit issues. Retrying a failed step is easy, but retrying a failed &lt;em&gt;data state&lt;/em&gt; (like a partial Delta write) is a nightmare you have to code manually.&lt;/p&gt;

&lt;p&gt;Databricks Workflows has a massive advantage here: it understands the Delta Lake state. If a job fails because of a &lt;code&gt;ConcurrentAppendException&lt;/code&gt; or a cluster start timeout, the platform handles the retries internally. It’s aware of the underlying compute, so if a node goes down, it doesn't just re-run the task; it re-provisions the cluster. It’s the only one of the three that doesn't treat your data pipeline like an abstract black box.&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-1560185127-9828d9c26238%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3w5NzU0MjJ8MHwxfHNlYXJjaHwzMHx8bW9kZXJuJTIwb2ZmaWNlJTIwZGVza3xlbnwwfDB8fHwxNzg3ODgyODUzfDA%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-1560185127-9828d9c26238%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3w5NzU0MjJ8MHwxfHNlYXJjaHwzMHx8bW9kZXJuJTIwb2ZmaWNlJTIwZGVza3xlbnwwfDB8fHwxNzg3ODgyODUzfDA%26ixlib%3Drb-4.1.0%26q%3D80%26w%3D1080" alt="Photo by Francesca Tosolini on Unsplash" width="1080" height="720"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Photo by &lt;a href="https://unsplash.com/@fromitaly?utm_source=articles_pipeline&amp;amp;utm_medium=referral" rel="noopener noreferrer"&gt;Francesca Tosolini&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 operations burden and the "Python" illusion
&lt;/h2&gt;

&lt;p&gt;Everyone loves Airflow because they love Python. It’s a trap. &lt;/p&gt;

&lt;p&gt;When you write an Airflow DAG, you aren't writing data engineering code; you’re writing infrastructure code. You’re managing dependencies, &lt;code&gt;requirements.txt&lt;/code&gt; files, and &lt;code&gt;venv&lt;/code&gt; isolation. If you want to upgrade your Spark version, you might have to migrate your entire Airflow environment. It’s a classic case of the abstraction leaking.&lt;/p&gt;

&lt;p&gt;Step Functions requires you to become an expert in Amazon States Language. It’s a declarative nightmare. You’ll find yourself writing nested &lt;code&gt;Choice&lt;/code&gt; states just to handle a simple conditional logic that would have been a single &lt;code&gt;if&lt;/code&gt; statement in any other language. You’re trading Python flexibility for AWS-specific lock-in.&lt;/p&gt;

&lt;p&gt;Databricks Workflows allows you to define your pipeline in code—using &lt;code&gt;Databricks Asset Bundles (DABs)&lt;/code&gt;—but it doesn't force you to manage the infrastructure. You define your task dependencies in YAML or Python, and the platform handles the deployment and the execution. It’s the only one that feels like a modern developer experience rather than a 2015-era sysadmin chore.&lt;/p&gt;

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

&lt;p&gt;If you are building a medallion pipeline today, stop looking at Airflow. Unless you have a massive, heterogeneous environment where you need to orchestrate non-Databricks tasks (like triggering a legacy mainframe job or an on-prem file transfer), Airflow is overkill. It’s a platform for general-purpose workflow management, not a specialized tool for Delta Lake pipelines.&lt;/p&gt;

&lt;p&gt;Step Functions is for when you are deep in the AWS ecosystem and need to trigger event-driven microservices. It’s not for heavy-duty ETL. If your pipeline involves moving gigabytes or terabytes through a Silver-to-Gold transformation, Step Functions will eventually become a bottleneck for your sanity.&lt;/p&gt;

&lt;p&gt;I pick Databricks Workflows every single time for medallion architectures. &lt;/p&gt;

&lt;p&gt;The caveat? You are locking yourself into the Databricks ecosystem. If your CTO decides to pivot to Snowflake or BigQuery tomorrow, you’re rewriting your orchestration layer. But let’s be honest: you’re already locked into Delta Lake. If you’re going to be locked in, you might as well use the tools that make your life easier.&lt;/p&gt;

&lt;p&gt;Databricks Workflows with Databricks Asset Bundles (DABs) is the current gold standard. It gives you the CI/CD pipeline, the version control, and the observability you need without the "scheduler maintenance" headache of Airflow or the JSON-hell of Step Functions. &lt;/p&gt;

&lt;p&gt;Use the right tool for the job. You’re a data engineer, not an Airflow cluster administrator. Spend your time fixing your data quality issues instead of babysitting a scheduler.&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>orchestration</category>
      <category>engineering</category>
      <category>cloud</category>
    </item>
    <item>
      <title>Snowpark vs. Spark: The 'Free' Compute Trap and Why You’re Probably Wrong</title>
      <dc:creator>Aniket Abhishek Soni</dc:creator>
      <pubDate>Wed, 26 Aug 2026 13:00:12 +0000</pubDate>
      <link>https://dev.to/aniketsoni/snowpark-vs-spark-the-free-compute-trap-and-why-youre-probably-wrong-5dgc</link>
      <guid>https://dev.to/aniketsoni/snowpark-vs-spark-the-free-compute-trap-and-why-youre-probably-wrong-5dgc</guid>
      <description>&lt;p&gt;Six months ago, our data pipeline was a sprawling Frankenstein of EMR clusters running Apache Spark 3.3.1, held together by custom Terraform modules and a prayer. We spent 20 hours a week just babysitting memory allocation, tuning &lt;code&gt;spark.executor.memoryOverhead&lt;/code&gt;, and praying that a rogue join wouldn't trigger an OOM (Out of Memory) error at 3:00 AM on a Sunday.&lt;/p&gt;

&lt;p&gt;Today, we run the same logic in Snowpark. Our infra team hasn't touched a node configuration in months. But—and this is a big, expensive but—our monthly Snowflake consumption bill for the dev environment alone would make a CFO weep.&lt;/p&gt;

&lt;p&gt;You are currently deciding whether to keep your Spark jobs in a dedicated cluster or migrate them into the Snowflake ecosystem. Don't let the marketing decks fool you. This isn't just a syntax switch; it’s a trade-off between "operational misery" and "financial opacity."&lt;/p&gt;

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

&lt;p&gt;On one side, we have Apache Spark, specifically the 3.5.x line. It’s the industry standard for a reason: it’s battle-tested, highly tunable, and completely agnostic to where it runs. You can run it on EMR, Databricks, or a collection of dusty laptops under your desk. It’s a distributed computing framework that demands you understand the physics of your data.&lt;/p&gt;

&lt;p&gt;On the other, we have Snowpark (Python API). It’s not a framework in the traditional sense; it’s an abstraction layer that translates your DataFrame operations into SQL, which then runs on Snowflake’s proprietary compute engine. It looks like Spark, it smells like Spark, but under the hood, it’s just Snowflake doing what Snowflake does best: pushing compute to where the data lives.&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-1733590634512-66186b83ad07%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3w5NzU0MjJ8MHwxfHNlYXJjaHw0fHxzaGF0dGVyZWQlMjBnbGFzc3xlbnwwfDB8fHwxNzg3NjgzNzM5fDA%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-1733590634512-66186b83ad07%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3w5NzU0MjJ8MHwxfHNlYXJjaHw0fHxzaGF0dGVyZWQlMjBnbGFzc3xlbnwwfDB8fHwxNzg3NjgzNzM5fDA%26ixlib%3Drb-4.1.0%26q%3D80%26w%3D1080" alt="Photo by Heather Newsom on Unsplash" width="1080" height="720"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Photo by &lt;a href="https://unsplash.com/@native7photo?utm_source=articles_pipeline&amp;amp;utm_medium=referral" rel="noopener noreferrer"&gt;Heather Newsom&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 convenience
&lt;/h2&gt;

&lt;p&gt;Spark is cheap until you factor in the "human" cost. If you’re running an EMR cluster, you aren't just paying for EC2 instances. You’re paying for the IAM roles, the VPC peering, the metadata store (Glue Catalog), and the inevitable hours an engineer spends debugging a &lt;code&gt;java.lang.OutOfMemoryError&lt;/code&gt;. &lt;/p&gt;

&lt;p&gt;However, Snowpark’s pricing model is dangerous for those who view it as a drop-in replacement. When you call &lt;code&gt;session.table("large_table").join(...)&lt;/code&gt; in Snowpark, you are at the mercy of Snowflake’s Warehouse sizing. If your transformation requires a massive spill to disk, Snowflake will spin up a larger warehouse to handle the memory pressure. That isn't just an extra 5 minutes of compute; it’s a jump from an X-Small to a Large warehouse, effectively quadrupling your hourly cost instantly.&lt;/p&gt;

&lt;p&gt;In my experience, a workload that costs $50/day on EMR can easily spike to $150/day on Snowpark if you aren't vigilant about &lt;code&gt;warehouse_size&lt;/code&gt; settings. The benefit? You stop caring about garbage collection tuning. The downside? You start sweating every time a developer hits "Run" on a notebook without checking the join cardinality.&lt;/p&gt;

&lt;h2&gt;
  
  
  Operational burden and failure modes
&lt;/h2&gt;

&lt;p&gt;Spark failure modes are predictable but annoying. When a Spark job dies, you get a beautiful, cryptic wall of Java stack traces. You’ll be hunting through the Spark UI, looking at executor logs, and trying to figure out why your &lt;code&gt;spark.sql.shuffle.partitions&lt;/code&gt; was set too low for the data skew. You are the architect of your own failure.&lt;/p&gt;

&lt;p&gt;Snowpark failure modes are different. When Snowpark breaks, it usually fails at the compilation phase. Because it’s translating to SQL, you’ll occasionally hit a "SQL compilation error" that is completely detached from the Python code you wrote. &lt;/p&gt;

&lt;p&gt;Furthermore, let’s talk about libraries. In Spark, if you need a specific version of &lt;code&gt;scikit-learn&lt;/code&gt; or a custom C-extension, you build a Docker image, push it to ECR, and point your job there. It’s tedious, but it’s deterministic. In Snowpark, you are constrained by what Snowflake allows in their Anaconda channel. If a library isn't there or requires a specific C-dependency not supported in the restricted Snowpark environment, you are stuck. You &lt;em&gt;can&lt;/em&gt; upload custom packages to a stage, but performance takes a hit, and it feels like a hack.&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-1711344397160-b23d5deaa012%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3w5NzU0MjJ8MHwxfHNlYXJjaHwzfHxjYWxjdWxhdG9yJTIwbWF0aHxlbnwwfDB8fHwxNzg3NjgzNzQwfDA%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-1711344397160-b23d5deaa012%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3w5NzU0MjJ8MHwxfHNlYXJjaHwzfHxjYWxjdWxhdG9yJTIwbWF0aHxlbnwwfDB8fHwxNzg3NjgzNzQwfDA%26ixlib%3Drb-4.1.0%26q%3D80%26w%3D1080" alt="Photo by 404 on Unsplash" width="1080" height="810"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Photo by &lt;a href="https://unsplash.com/@unsplash_official?utm_source=articles_pipeline&amp;amp;utm_medium=referral" rel="noopener noreferrer"&gt;404&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;
  
  
  Performance parity
&lt;/h2&gt;

&lt;p&gt;If you are doing simple ETL—filtering, renaming, basic aggregations—Snowpark is often faster than Spark. Why? Because you’re avoiding the overhead of moving data between S3 and the compute nodes. The data is already sitting in Snowflake’s micro-partitions. &lt;/p&gt;

&lt;p&gt;But for heavy, iterative machine learning or complex graph processing, Spark wins. Spark’s ability to cache RDDs and keep data in memory across stages is far superior to Snowpark’s approach, which is fundamentally tied to the execution plan of the underlying SQL. If your logic requires massive shuffles or complex cross-joins, Snowpark can struggle because it’s effectively limited by the size of the temporary storage assigned to the warehouse. &lt;/p&gt;

&lt;p&gt;I’ve seen jobs that took 40 minutes on Spark finish in 10 on Snowpark, but I’ve also seen jobs that ran fine on Spark fail with a "Query too complex" error on Snowpark because the generated SQL became a recursive monster that Snowflake’s query optimizer couldn't untangle.&lt;/p&gt;

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

&lt;p&gt;If you have a dedicated platform team and your data volume is in the multi-petabyte range, stay on Spark. You need the granular control over memory management and the ability to optimize shuffles. You need to be able to run on spot instances and use Graviton processors to keep costs sane. &lt;/p&gt;

&lt;p&gt;If you are a lean team, or if your data already lives in Snowflake and you’re tired of the "S3-to-Spark-to-S3" tax, choose Snowpark. The developer velocity is undeniable. Being able to write Python that compiles to highly optimized SQL is a superpower. You will save hundreds of hours in infrastructure maintenance, and for many companies, that time is worth more than the premium you’ll pay on the Snowflake compute bill.&lt;/p&gt;

&lt;p&gt;My caveat? Use Snowpark only if you have strict guardrails. Implement mandatory warehouse tagging and automated alerts for warehouse resizing. Treat your Snowpark code like production application code, not like a script. If you treat it like a sandbox, you’ll wake up to a bill that will make your CEO ask why we’re paying $400 for a simple customer aggregation.&lt;/p&gt;

&lt;p&gt;The era of manual cluster management is dying, but the era of "set and forget" compute is just a different kind of trap. Choose your poison carefully.&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>snowflake</category>
      <category>spark</category>
      <category>data</category>
      <category>engineering</category>
    </item>
    <item>
      <title>How I standardized PySpark deployments with multi-stage Docker builds</title>
      <dc:creator>Aniket Abhishek Soni</dc:creator>
      <pubDate>Mon, 24 Aug 2026 18:22:31 +0000</pubDate>
      <link>https://dev.to/aniketsoni/how-i-standardized-pyspark-deployments-with-multi-stage-docker-builds-281j</link>
      <guid>https://dev.to/aniketsoni/how-i-standardized-pyspark-deployments-with-multi-stage-docker-builds-281j</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Why I chose this topic:&lt;/strong&gt; I’ve spent too many 2:00 AM outages debugging "it works on my machine" failures caused by mismatched Python versions or missing shared libraries in PySpark executors. This pattern is the only way I’ve found to force consistency across the entire SDLC.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;You ship the job. It passes CI. Then it hits the cluster, and the executor pods crash-loop with an &lt;code&gt;ImportError&lt;/code&gt; because your local &lt;code&gt;pandas&lt;/code&gt; version is 2.2.0, but the base image on your EMR cluster is pinning an ancient build of 1.3.5. You spend three hours SSHing into nodes or digging through CloudWatch logs, only to realize the environment variables are different, the &lt;code&gt;LD_LIBRARY_PATH&lt;/code&gt; is missing, or someone updated a private package in Artifactory without telling you.&lt;/p&gt;

&lt;p&gt;It’s a miserable loop. You’re managing infrastructure drift rather than writing data pipelines. We treat our application code like a first-class citizen, but we treat our execution environment like a neglected basement.&lt;/p&gt;

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

&lt;p&gt;The problem isn't your code; it’s the disconnect between the build-time environment and the runtime environment. Most PySpark deployments rely on "bootstrap scripts" or "init actions" to install dependencies on the fly. This is a recipe for disaster. Every node in your cluster tries to &lt;code&gt;pip install&lt;/code&gt; simultaneously, resulting in network throttling, race conditions, or partial installs that fail midway through a 4-hour job.&lt;/p&gt;

&lt;p&gt;If you aren't shipping a single, immutable container image that contains your OS, your Python runtime, your dependencies, and your job code, you aren't doing reproducible data engineering. You’re just gambling.&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-1775994121064-e75fa6f3e84c%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3w5NzU0MjJ8MHwxfHNlYXJjaHw5fHx0ZXJtaW5hbCUyMGNvbW1hbmQlMjBsaW5lfGVufDB8MHx8fDE3ODc1MTAzMjJ8MA%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-1775994121064-e75fa6f3e84c%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3w5NzU0MjJ8MHwxfHNlYXJjaHw5fHx0ZXJtaW5hbCUyMGNvbW1hbmQlMjBsaW5lfGVufDB8MHx8fDE3ODc1MTAzMjJ8MA%26ixlib%3Drb-4.1.0%26q%3D80%26w%3D1080" alt="Photo by Bernd 📷 Dittrich on Unsplash" width="1080" height="720"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Photo by &lt;a href="https://unsplash.com/@hdbernd?utm_source=articles_pipeline&amp;amp;utm_medium=referral" rel="noopener noreferrer"&gt;Bernd 📷 Dittrich&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 1: Defining the build stage
&lt;/h2&gt;

&lt;p&gt;We need to keep the final image slim. We don’t need compilers, C++ headers, or git credentials in the production image. We use a multi-stage build to isolate the "messy" build tools from the "clean" runtime.&lt;/p&gt;

&lt;p&gt;I start with an official Python slim image. Why slim? Because &lt;code&gt;alpine&lt;/code&gt; with PySpark is a nightmare of &lt;code&gt;musl&lt;/code&gt; vs &lt;code&gt;glibc&lt;/code&gt; incompatibilities that will eventually break your C-extensions like &lt;code&gt;pyarrow&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="c"&gt;# Stage 1: Build dependencies&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;python:3.10-slim&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;builder&lt;/span&gt;

&lt;span class="k"&gt;RUN &lt;/span&gt;apt-get update &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; apt-get &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; &lt;span class="nt"&gt;--no-install-recommends&lt;/span&gt; &lt;span class="se"&gt;\
&lt;/span&gt;    build-essential &lt;span class="se"&gt;\
&lt;/span&gt;    libpq-dev &lt;span class="se"&gt;\
&lt;/span&gt;    gcc &lt;span class="se"&gt;\
&lt;/span&gt;    &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;rm&lt;/span&gt; &lt;span class="nt"&gt;-rf&lt;/span&gt; /var/lib/apt/lists/&lt;span class="k"&gt;*&lt;/span&gt;

&lt;span class="k"&gt;WORKDIR&lt;/span&gt;&lt;span class="s"&gt; /app&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; requirements.txt .&lt;/span&gt;

&lt;span class="c"&gt;# Install to a local folder so we can copy it easily&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;pip &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;--no-cache-dir&lt;/span&gt; &lt;span class="nt"&gt;--user&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; requirements.txt
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 2: Constructing the runtime stage
&lt;/h2&gt;

&lt;p&gt;Now we move to the runtime stage. We copy only the installed packages from the &lt;code&gt;builder&lt;/code&gt; stage. I prefer to keep the Python site-packages in a predictable location and ensure the &lt;code&gt;PYTHONPATH&lt;/code&gt; is set correctly. Note that I am not including the job code here yet; I like to keep the environment static and the code dynamic (or baked in, if your CD pipeline allows).&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="c"&gt;# Stage 2: Final runtime&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="s"&gt; python:3.10-slim&lt;/span&gt;

&lt;span class="c"&gt;# Install runtime-only system dependencies (e.g., libpq for psycopg2)&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;apt-get update &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; apt-get &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; &lt;span class="nt"&gt;--no-install-recommends&lt;/span&gt; &lt;span class="se"&gt;\
&lt;/span&gt;    libpq5 &lt;span class="se"&gt;\
&lt;/span&gt;    &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;rm&lt;/span&gt; &lt;span class="nt"&gt;-rf&lt;/span&gt; /var/lib/apt/lists/&lt;span class="k"&gt;*&lt;/span&gt;

&lt;span class="c"&gt;# Copy installed packages from builder&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; --from=builder /root/.local /root/.local&lt;/span&gt;
&lt;span class="k"&gt;ENV&lt;/span&gt;&lt;span class="s"&gt; PATH=/root/.local/bin:$PATH&lt;/span&gt;
&lt;span class="k"&gt;ENV&lt;/span&gt;&lt;span class="s"&gt; PYTHONPATH=/root/.local/lib/python3.10/site-packages:$PYTHONPATH&lt;/span&gt;

&lt;span class="c"&gt;# Set up non-root user for security&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;useradd &lt;span class="nt"&gt;-m&lt;/span&gt; sparkuser
&lt;span class="k"&gt;USER&lt;/span&gt;&lt;span class="s"&gt; sparkuser&lt;/span&gt;
&lt;span class="k"&gt;WORKDIR&lt;/span&gt;&lt;span class="s"&gt; /home/sparkuser/app&lt;/span&gt;

&lt;span class="c"&gt;# Copy your source code&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; --chown=sparkuser:sparkuser ./src ./src&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 3: Integrating with Spark configuration
&lt;/h2&gt;

&lt;p&gt;Once you have your image pushed to your container registry (ECR, GCR, or ACR), you need to tell Spark to actually use it. The configuration keys are non-negotiable. If you aren't running on K8s, the logic is similar for YARN, but Kubernetes is where this pattern truly shines.&lt;/p&gt;

&lt;p&gt;When submitting your job via &lt;code&gt;spark-submit&lt;/code&gt; or a K8s manifest, point directly to your image.&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;# Example spark-submit command&lt;/span&gt;
spark-submit &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--master&lt;/span&gt; k8s://https://&amp;lt;k8s-api-server&amp;gt;:6443 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--deploy-mode&lt;/span&gt; cluster &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--conf&lt;/span&gt; spark.kubernetes.container.image&lt;span class="o"&gt;=&lt;/span&gt;your-registry/repo/pyspark-job:latest &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--conf&lt;/span&gt; spark.kubernetes.container.image.pullPolicy&lt;span class="o"&gt;=&lt;/span&gt;Always &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--conf&lt;/span&gt; spark.executorEnv.PYTHONPATH&lt;span class="o"&gt;=&lt;/span&gt;/home/sparkuser/app/src &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nb"&gt;local&lt;/span&gt;:///home/sparkuser/app/src/main.py
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One subtle trap: &lt;code&gt;spark.kubernetes.container.image.pullPolicy&lt;/code&gt;. If you tag images as &lt;code&gt;latest&lt;/code&gt; (which you shouldn't do in production, but we all do in dev), set this to &lt;code&gt;Always&lt;/code&gt;. If you use immutable tags like &lt;code&gt;v1.2.3&lt;/code&gt;, set it to &lt;code&gt;IfNotPresent&lt;/code&gt;.&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-1705877883472-82f6b3a59105%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3w5NzU0MjJ8MHwxfHNlYXJjaHwxM3x8Y29tcGxleCUyMGRhdGElMjBwaXBlbGluZXxlbnwwfDB8fHwxNzg3NTEwMzIyfDA%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-1705877883472-82f6b3a59105%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3w5NzU0MjJ8MHwxfHNlYXJjaHwxM3x8Y29tcGxleCUyMGRhdGElMjBwaXBlbGluZXxlbnwwfDB8fHwxNzg3NTEwMzIyfDA%26ixlib%3Drb-4.1.0%26q%3D80%26w%3D1080" alt="Photo by BoliviaInteligente on Unsplash" width="1080" height="675"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Photo by &lt;a href="https://unsplash.com/@boliviainteligente?utm_source=articles_pipeline&amp;amp;utm_medium=referral" rel="noopener noreferrer"&gt;BoliviaInteligente&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;The C-Extension Trap:&lt;/strong&gt; If your job relies on &lt;code&gt;pyarrow&lt;/code&gt; or &lt;code&gt;pandas&lt;/code&gt;, verify that your Docker build is using the same &lt;code&gt;glibc&lt;/code&gt; version as the base Spark image. If you mix and match, you will get obscure &lt;code&gt;segmentation fault&lt;/code&gt; errors that appear only on 1 out of every 50 executors.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Size Matters:&lt;/strong&gt; If your image is over 2GB, your pod startup time will skyrocket. If you’re pulling 2GB per executor on a 100-node cluster, you are effectively performing a self-inflicted DDoS attack on your container registry. Keep it lean, or use a local pull-through cache.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The &lt;code&gt;User&lt;/code&gt; Problem:&lt;/strong&gt; Spark containers often default to &lt;code&gt;root&lt;/code&gt;. If your corporate security policy requires non-root users, you must explicitly set &lt;code&gt;USER sparkuser&lt;/code&gt; in the Dockerfile and ensure your K8s &lt;code&gt;SecurityContext&lt;/code&gt; doesn't conflict with that user ID.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dependency Locking:&lt;/strong&gt; Always use &lt;code&gt;pip-compile&lt;/code&gt; or &lt;code&gt;poetry&lt;/code&gt; to generate a &lt;code&gt;requirements.txt&lt;/code&gt; with hashes. A simple &lt;code&gt;pip install&lt;/code&gt; without version pinning is a ticking time bomb. I’ve seen production jobs break because a sub-dependency released a "patch" that broke the Spark context initialization.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;Standardizing on multi-stage builds isn't just about "best practices." It's about reclaiming your time. By defining the environment in Docker, you make the environment part of the code review process. If a developer needs a new library, they modify the &lt;code&gt;Dockerfile&lt;/code&gt; or &lt;code&gt;requirements.txt&lt;/code&gt;, which triggers a CI build that tests that change before it ever touches production. No more surprises, no more bootstrap scripts, and no more guessing why the job failed at 2:00 AM.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Try it:&lt;/strong&gt; Take your most unstable PySpark job, write a multi-stage Dockerfile for it, and deploy it to a staging environment. Compare the startup time and the frequency of "environment-related" failures against your current deployment method. You won’t go back.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Tags:&lt;/strong&gt; #docker #pyspark #dataengineering #devops&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Cover photo by &lt;a href="https://unsplash.com/@jonassmith?utm_source=articles_pipeline&amp;amp;utm_medium=referral" rel="noopener noreferrer"&gt;Jonas Smith&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>pyspark</category>
      <category>dataengineering</category>
      <category>devops</category>
    </item>
    <item>
      <title>Stop Choosing Between Delta Lake and Iceberg: How UniForm Ends the Format Wars</title>
      <dc:creator>Aniket Abhishek Soni</dc:creator>
      <pubDate>Sat, 22 Aug 2026 04:52:36 +0000</pubDate>
      <link>https://dev.to/aniketsoni/stop-choosing-between-delta-lake-and-iceberg-how-uniform-ends-the-format-wars-3oli</link>
      <guid>https://dev.to/aniketsoni/stop-choosing-between-delta-lake-and-iceberg-how-uniform-ends-the-format-wars-3oli</guid>
      <description>&lt;p&gt;Two years ago, I spent six weeks migrating a 400TB churn-prediction dataset from Delta Lake 2.4 to Apache Iceberg 1.3 just because our new executive hire insisted on using Trino for ad-hoc SQL. We broke three upstream Spark jobs, corrupted a partition manifest, and spent a weekend manually editing JSON metadata files in S3. &lt;/p&gt;

&lt;p&gt;Today, that same pipeline is running on Delta 4.2 with UniForm enabled. My Trino users query the Iceberg-compatible metadata, my Spark jobs use the native Delta logs, and I haven't touched a manifest file in eight months. The "format war" is officially a waste of your time.&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 senior engineers treat table formats like sports teams. We are infrastructure providers, not fanatics, and the technical debt of format migration is a tax your balance sheet shouldn't be paying in 2026.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Stop acting like your choice of table format is a foundational architectural decision. It isn't. It’s a storage implementation detail that should be abstracted away from your compute engines. If you are currently sitting in a meeting debating whether to "standardize on Iceberg" while your primary ETL workloads are locked into Delta, you are prioritizing dogma over delivery.&lt;/p&gt;

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

&lt;p&gt;The "pick a side" strategy ignores the reality of modern polyglot data stacks. In the financial services sector, I see teams force-feeding Iceberg into systems that don't need it because they want "openness," only to find that their Spark-based streaming ingestions struggle with Iceberg's commit overhead. Conversely, I see teams stick to Delta and effectively orphan themselves from the best-of-breed query engines that prioritize Iceberg’s mature REST catalog support.&lt;/p&gt;

&lt;p&gt;The common failure mode is the "dual-write" pattern. Engineering teams try to maintain two copies of the same data—one as a Delta table, one as an Iceberg table. This is a disaster waiting to happen. You get drift. You get one table updated at 02:00 UTC and the other failing at 02:05 UTC because of a transient networking blip. Then your dashboard shows different numbers for the same metric, and suddenly you’re explaining to a VP of Finance why the company’s revenue shifted by $40k overnight.&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-1599231190518-65504d1c17ff%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3w5NzU0MjJ8MHwxfHNlYXJjaHwyMHx8c2hhdHRlcmVkJTIwZ2xhc3MlMjBwdXp6bGV8ZW58MHwwfHx8MTc4NzMzODAzOHww%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-1599231190518-65504d1c17ff%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3w5NzU0MjJ8MHwxfHNlYXJjaHwyMHx8c2hhdHRlcmVkJTIwZ2xhc3MlMjBwdXp6bGV8ZW58MHwwfHx8MTc4NzMzODAzOHww%26ixlib%3Drb-4.1.0%26q%3D80%26w%3D1080" alt="Photo by Frames For Your Heart on Unsplash" width="1080" height="894"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Photo by &lt;a href="https://unsplash.com/@framesforyourheart?utm_source=articles_pipeline&amp;amp;utm_medium=referral" rel="noopener noreferrer"&gt;Frames For Your Heart&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 unified layer
&lt;/h2&gt;

&lt;p&gt;UniForm (Universal Format) effectively makes the choice of format a logical abstraction rather than a physical one. By enabling &lt;code&gt;delta.universalFormat.enabledFilesystems&lt;/code&gt; and &lt;code&gt;delta.universalFormat.icebergCompatVersion&lt;/code&gt; in your table properties, you are telling the Delta log writer to perform the heavy lifting of generating Iceberg metadata on the fly.&lt;/p&gt;

&lt;p&gt;When you issue a &lt;code&gt;MERGE INTO&lt;/code&gt; or a standard &lt;code&gt;INSERT&lt;/code&gt; in Spark 4.0, the engine writes the Parquet data files and the Delta &lt;code&gt;_delta_log&lt;/code&gt; JSON/Checkpoint files. With UniForm, it simultaneously generates the Iceberg manifest lists and snapshot files. You aren't duplicating data; you are duplicating the &lt;em&gt;metadata&lt;/em&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;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;prod&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;customer_transactions&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;TBLPROPERTIES&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="s1"&gt;'delta.universalFormat.enabledFilesystems'&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'s3'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="s1"&gt;'delta.universalFormat.icebergCompatVersion'&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'2'&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Once that property is set, any engine that understands Iceberg—Trino, Flink, StarRocks—points to the same S3 prefix. The Delta log is the source of truth, and the Iceberg metadata acts as a read-optimized projection. You don't need to perform an &lt;code&gt;msck repair&lt;/code&gt; or a full table migration. You just need to ensure your compute engine supports the Iceberg version you’ve negotiated.&lt;/p&gt;

&lt;h2&gt;
  
  
  The reality of failure modes
&lt;/h2&gt;

&lt;p&gt;Do not think this is a "set it and forget it" panacea. The primary risk isn't the data; it’s the consistency of the metadata. If you are running an older version of a query engine that doesn't fully support Iceberg V2 features (like row-level deletes or complex partitioning), it will choke on the UniForm-generated metadata. &lt;/p&gt;

&lt;p&gt;I once saw a Flink job crash because it expected the Iceberg metadata to reflect a snapshot state that hadn't been fully flushed to the Delta log yet. We solved this by adjusting the &lt;code&gt;delta.checkpointInterval&lt;/code&gt; and monitoring the latency of the Iceberg metadata generation. If your &lt;code&gt;delta-to-iceberg-conversion&lt;/code&gt; lag exceeds your business SLA for fresh data, you haven't fixed the problem—you've just moved it into a background process.&lt;/p&gt;

&lt;p&gt;You must treat the Iceberg metadata as a materialized view of your Delta table. If the view is stale, your query engines will be wrong. Monitor the file modification times in your &lt;code&gt;metadata/&lt;/code&gt; folder. If they aren't updating in lockstep with your main Delta logs, your conversion job is failing silently.&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-1600476086547-c30b4d55afac%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3w5NzU0MjJ8MHwxfHNlYXJjaHwyfHxnb2xkZW4lMjBicmlkZ2UlMjBjb25zdHJ1Y3Rpb258ZW58MHwwfHx8MTc4NzMzODAzOXww%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-1600476086547-c30b4d55afac%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3w5NzU0MjJ8MHwxfHNlYXJjaHwyfHxnb2xkZW4lMjBicmlkZ2UlMjBjb25zdHJ1Y3Rpb258ZW58MHwwfHx8MTc4NzMzODAzOXww%26ixlib%3Drb-4.1.0%26q%3D80%26w%3D1080" alt="Photo by Daniel Lloyd Blunk-Fernández on Unsplash" width="1080" height="645"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Photo by &lt;a href="https://unsplash.com/@blunkorama?utm_source=articles_pipeline&amp;amp;utm_medium=referral" rel="noopener noreferrer"&gt;Daniel Lloyd Blunk-Fernández&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 what about vendor lock-in?" &lt;/p&gt;

&lt;p&gt;This is the most common objection I hear. The argument is that by using Delta's UniForm, you are still "locked in" to the Delta protocol. My response is simple: define "locked in." If you can point Trino, Flink, and DuckDB at your data and query it without a specialized connector, you aren't locked in. The data is in Parquet. The metadata is standards-compliant Iceberg. If Delta disappeared tomorrow, you have a perfectly valid Iceberg table sitting in your bucket. The "lock-in" is purely in the writer engine, and let's be honest—you're going to keep using Spark or Flink anyway.&lt;/p&gt;

&lt;p&gt;"Won't this increase storage costs?"&lt;/p&gt;

&lt;p&gt;It does, slightly. Generating Iceberg metadata files takes up space. But let's look at the math. If you have 400TB of data, the metadata files account for maybe a few gigabytes. If you are worried about the storage cost of an extra 0.001% of your footprint, you have much bigger problems in your cloud billing department. The cost of manual migration, inconsistent data, and engineering time wasted on format parity is orders of magnitude higher than the storage cost of a few extra JSON and Avro files.&lt;/p&gt;

&lt;p&gt;"Is this production-ready for mission-critical healthcare data?"&lt;/p&gt;

&lt;p&gt;I’ve been running this for 14 months in a HIPAA-compliant environment. The key is to keep your Delta version current. Using &lt;code&gt;delta-spark&lt;/code&gt; 4.2+ is non-negotiable. The stability of the UniForm implementation is tied directly to the version of the Delta kernel. Don't try to run this on legacy Databricks runtimes from 2022 and expect it to behave. If your platform team isn't willing to keep the stack updated, then yes, stay away from it. But if you’re maintaining your infrastructure, it’s arguably safer than maintaining two separate, competing table formats.&lt;/p&gt;

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

&lt;p&gt;The era of choosing sides is dead. We are moving toward a world where the file format is an internal implementation detail of the storage layer, and the table format is just a view. By leveraging UniForm, you stop being a caretaker of format-specific metadata and start being an architect of data flow.&lt;/p&gt;

&lt;p&gt;Stop the migrations. Stop the dual-writing. Stop the "Iceberg vs. Delta" religious wars. Enable the compatibility layers, point your engines at the unified metadata, and spend your time building features that actually generate value for your users. In 2026, the best engineer in the room isn't the one who knows every obscure nuance of the Iceberg manifest spec; it’s the one who makes the entire storage layer invisible to the rest of the company.&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>datalake</category>
      <category>bigdata</category>
      <category>engineering</category>
      <category>cloud</category>
    </item>
    <item>
      <title>Stop Pretending BigQuery and Databricks Are Just 'SQL Warehouses</title>
      <dc:creator>Aniket Abhishek Soni</dc:creator>
      <pubDate>Thu, 20 Aug 2026 15:19:15 +0000</pubDate>
      <link>https://dev.to/aniketsoni/stop-pretending-bigquery-and-databricks-are-just-sql-warehouses-2mc2</link>
      <guid>https://dev.to/aniketsoni/stop-pretending-bigquery-and-databricks-are-just-sql-warehouses-2mc2</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Why I chose this topic:&lt;/strong&gt; I’m tired of seeing architecture decisions made based on marketing whitepapers that ignore the reality of a 3 AM pipeline failure. Most engineers pick their platform based on hype; I’m writing this to force you to pick based on your actual ops budget.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;You ship the job. It passes CI. Then, the P99 latency on your dashboard spikes from 400ms to 12 seconds, and the CFO is asking why the "serverless" bill just hit five figures for a simple join. You’re left staring at a query profile that tells you absolutely nothing useful.&lt;/p&gt;

&lt;p&gt;The industry wants you to believe that BigQuery and Databricks SQL are interchangeable commodities. They aren't. Choosing between them is a choice between trading away your time for convenience (BigQuery) or trading away your sanity for control (Databricks).&lt;/p&gt;

&lt;p&gt;Most of my peers argue that "it’s all just SQL, so pick the cheapest one." That is a dangerous simplification. In financial services, where I spend most of my time, the cost of a failed compliance report is higher than the cost of a slightly inefficient query. Treating these platforms as simple SQL interfaces ignores the underlying storage engines, the concurrency models, and the reality of how they handle partition evolution.&lt;/p&gt;

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

&lt;p&gt;The "benchmark first" mentality is the primary reason projects go off the rails. You see a vendor-sponsored TPC-DS benchmark showing 2x speed on Databricks, so you migrate. Six months later, you realize you’re spending 40% of your engineering time tuning &lt;code&gt;Z-ORDER&lt;/code&gt; clusters and managing &lt;code&gt;VACUUM&lt;/code&gt; commands.&lt;/p&gt;

&lt;p&gt;The common approach assumes that performance is purely a function of the engine. In practice, performance is a function of &lt;em&gt;maintenance&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;In BigQuery, you don't manage vacuuming. You don't manage cluster sizes. You don't manage the &lt;code&gt;spark.sql.shuffle.partitions&lt;/code&gt; setting. If you’re coming from a world where you need to optimize your data layout to hit your SLAs, BigQuery feels like magic. But that magic has a price tag that is often opaque. When you run &lt;code&gt;SELECT *&lt;/code&gt; on a petabyte-scale table without a &lt;code&gt;WHERE&lt;/code&gt; clause in BigQuery, you aren't just slowing down the system; you’re literally burning cash. If you don't have rigid project-level quotas, you’re one rogue intern away from a six-figure invoice.&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-1695198970319-a67a44476ac5%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3w5NzU0MjJ8MHwxfHNlYXJjaHwxMnx8bWVzc3klMjBjYWJsZXN8ZW58MHwwfHx8MTc4NzE2NTA2Nnww%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-1695198970319-a67a44476ac5%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3w5NzU0MjJ8MHwxfHNlYXJjaHwxMnx8bWVzc3klMjBjYWJsZXN8ZW58MHwwfHx8MTc4NzE2NTA2Nnww%26ixlib%3Drb-4.1.0%26q%3D80%26w%3D1080" alt="Photo by Igor Omilaev on Unsplash" width="1080" height="608"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Photo by &lt;a href="https://unsplash.com/@omilaev?utm_source=articles_pipeline&amp;amp;utm_medium=referral" rel="noopener noreferrer"&gt;Igor Omilaev&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 illusion of "Serverless" simplicity
&lt;/h2&gt;

&lt;p&gt;I once worked on a migration where we moved a massive ETL pipeline from a custom Spark cluster to BigQuery. The "serverless" aspect was the selling point. For three weeks, it was great. Then, the &lt;code&gt;INFORMATION_SCHEMA.JOBS_BY_PROJECT&lt;/code&gt; started showing a pattern: our complex, nested JSON parsing was hitting the 100GB per-query shuffle limit repeatedly.&lt;/p&gt;

&lt;p&gt;In BigQuery, you hit a wall. When you hit a query limit, your only recourse is to rewrite the SQL into multiple stages or utilize BigQuery Scripting. You are at the mercy of Google’s internal query optimizer. You can’t "tune" the engine—you can only tune your own code.&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;-- BigQuery: You're stuck with their optimizer&lt;/span&gt;
&lt;span class="c1"&gt;-- If this hits the 100GB limit, you have to break it apart&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt;
  &lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;ARRAY_AGG&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;STRUCT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;timestamp&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;events&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="nv"&gt;`prod.events`&lt;/span&gt;
&lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Contrast this with Databricks SQL. If a query is failing or underperforming, I can jump into the Spark UI. I can see the exact task skew. I can look at the physical plan, identify a broadcast join that’s failing, and force a hint.&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;-- Databricks: You have the levers&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="cm"&gt;/*+ BROADCAST(dim_users) */&lt;/span&gt;
  &lt;span class="n"&gt;fact&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;dim_users&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;fact_table&lt;/span&gt; &lt;span class="n"&gt;fact&lt;/span&gt;
&lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;dim_users&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;fact&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;dim_users&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In Databricks, the "failure mode" is that you spend time fixing the query. In BigQuery, the "failure mode" is that the query just fails, and your hands are tied by the API.&lt;/p&gt;

&lt;h2&gt;
  
  
  The operational tax of the Lakehouse
&lt;/h2&gt;

&lt;p&gt;Databricks SQL is fundamentally a lakehouse play. You are managing the underlying Delta Lake tables. This gives you immense power. You can travel back in time with &lt;code&gt;VERSION AS OF&lt;/code&gt;, you can &lt;code&gt;MERGE&lt;/code&gt; data with transactional integrity, and you can control how your files are laid out on S3 or ADLS.&lt;/p&gt;

&lt;p&gt;But let’s be honest: &lt;code&gt;VACUUM&lt;/code&gt; is a pain. If you forget to run it, your storage costs spiral because you’re keeping infinite history of every tiny update. If you don't &lt;code&gt;OPTIMIZE&lt;/code&gt; your tables, your read performance degrades as your data evolves. This is "operational tax."&lt;/p&gt;

&lt;p&gt;In healthcare, we handle PII and HIPAA-regulated data. Databricks allows me to control the storage layer access through Unity Catalog at a granular level. I can move data between regions or cloud providers without re-ingesting everything because it’s just Parquet/Delta. BigQuery is a walled garden. Once your data is in BigQuery Storage, you are using the BigQuery API. You are locked in. Moving out requires an egress bill that will make your finance department cry.&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-1585152001872-1c1bc66b8ca3%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3w5NzU0MjJ8MHwxfHNlYXJjaHwxOHx8c3BlZWQlMjBtZXRlcnxlbnwwfDB8fHwxNzg3MTY1MDY2fDA%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-1585152001872-1c1bc66b8ca3%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3w5NzU0MjJ8MHwxfHNlYXJjaHwxOHx8c3BlZWQlMjBtZXRlcnxlbnwwfDB8fHwxNzg3MTY1MDY2fDA%26ixlib%3Drb-4.1.0%26q%3D80%26w%3D1080" alt="Photo by Oxa Roxa on Unsplash" width="1080" height="720"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Photo by &lt;a href="https://unsplash.com/@oxaroxa?utm_source=articles_pipeline&amp;amp;utm_medium=referral" rel="noopener noreferrer"&gt;Oxa Roxa&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 biggest pushback I get is: "But BigQuery is cheaper for ad-hoc analysts." &lt;/p&gt;

&lt;p&gt;My answer? Only if you have perfect governance. BigQuery is a "spend-now, pay-later" platform. It’s excellent for teams that don't have a dedicated data engineer to manage infrastructure. If your organization is small, and you don't have the headcount to manage a Databricks workspace, BigQuery is the only logical choice. You pay the premium for the lack of operational overhead.&lt;/p&gt;

&lt;p&gt;Another objection: "Databricks SQL is too expensive because of the compute clusters." &lt;/p&gt;

&lt;p&gt;My answer: Databricks Serverless SQL warehouses are getting better, but yes, they cost more than a raw BigQuery query if they sit idle. However, the cost of an engineer’s time to fix a BigQuery performance bottleneck that can't be tuned—because the optimizer is a black box—is far higher than a 15% delta in compute costs. If you are at a scale where you are running massive, repetitive ETL jobs, you should not be paying for the "serverless" convenience of BigQuery. You should be paying for the "control" of Databricks.&lt;/p&gt;

&lt;p&gt;Finally, people argue that BigQuery’s ML integration (BigQuery ML) is a game changer. It is, until you need a custom library that Google doesn't support. Then you’re back to exporting your data to a vertex AI pipeline, which defeats the purpose. Databricks handles the transition from SQL to Python/MLflow seamlessly because it’s the same underlying Spark environment.&lt;/p&gt;

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

&lt;p&gt;If you are a lean startup or a mid-sized team with one or two data generalists, choose BigQuery. The lack of infrastructure management is worth the risk of an occasional surprise bill. You want to query, visualize, and get out.&lt;/p&gt;

&lt;p&gt;If you are in an enterprise environment, especially in finance or healthcare, where data governance, auditability, and custom optimization are non-negotiable, choose Databricks. You need the ability to reach under the hood when things inevitably break. &lt;/p&gt;

&lt;p&gt;Don't choose based on a benchmark. Choose based on how much time you want to spend being a database administrator versus a data engineer. I choose the control, even if it means I have to run a &lt;code&gt;VACUUM&lt;/code&gt; command at 3 AM once in a while. At least I can fix it.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Cover photo by &lt;a href="https://unsplash.com/@marcinjozwiak?utm_source=articles_pipeline&amp;amp;utm_medium=referral" rel="noopener noreferrer"&gt;Marcin Jozwiak&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>cloud</category>
      <category>datascience</category>
      <category>analytics</category>
    </item>
    <item>
      <title>How Unity Catalog’s Attribute-Based Access Control Saved My Audit Trail</title>
      <dc:creator>Aniket Abhishek Soni</dc:creator>
      <pubDate>Tue, 18 Aug 2026 11:31:42 +0000</pubDate>
      <link>https://dev.to/aniketsoni/how-unity-catalogs-attribute-based-access-control-saved-my-audit-trail-4p25</link>
      <guid>https://dev.to/aniketsoni/how-unity-catalogs-attribute-based-access-control-saved-my-audit-trail-4p25</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Why I chose this topic:&lt;/strong&gt; In financial services, "Role-Based Access Control" (RBAC) is a ticking time bomb that inevitably explodes once your headcount hits double digits. I’m writing this because I spent three weeks cleaning up a PII leak caused by a developer who "just needed" a role that had too much power, and I never want to do that again.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The PagerDuty alert hit at 3:14 AM on a Tuesday: &lt;code&gt;HIGH PRIORITY: Data Governance Violation - Unauthorized PII Exposure&lt;/code&gt;. &lt;/p&gt;

&lt;p&gt;My logs showed that a junior analyst running a notebook in a sandbox environment had successfully queried the &lt;code&gt;transactions_prod&lt;/code&gt; table. They shouldn’t have seen the &lt;code&gt;customer_ssn&lt;/code&gt; or &lt;code&gt;credit_card_number&lt;/code&gt; columns. But there they were, printed out in cleartext in the &lt;code&gt;stdout&lt;/code&gt; of a cluster that had no business touching production data. The cost? A mandatory report to our DPO, a week of manual audit logs review, and a very awkward conversation with our internal compliance team. &lt;/p&gt;

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

&lt;p&gt;The initial panic was centered on the &lt;code&gt;GRANT&lt;/code&gt; statements. We checked &lt;code&gt;sys.access&lt;/code&gt; in the Unity Catalog (UC) metastore. Everything looked correct. The analyst’s user group, &lt;code&gt;analyst_sandbox_role&lt;/code&gt;, did not have &lt;code&gt;SELECT&lt;/code&gt; on the &lt;code&gt;transactions_prod&lt;/code&gt; table. &lt;/p&gt;

&lt;p&gt;We chased shadows for four hours. We checked if the user had elevated their privileges via a service principal. We looked for rogue &lt;code&gt;GRANT&lt;/code&gt; commands in the audit logs. We even scrutinized the cluster configuration, wondering if someone had bypassed the &lt;code&gt;spark.databricks.passthrough.enabled&lt;/code&gt; setting.&lt;/p&gt;

&lt;p&gt;Nothing. The permissions were locked down. The user technically didn't have access. But they still had 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-1765157684231-7dc85de7743d%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3w5NzU0MjJ8MHwxfHNlYXJjaHwyOHx8YnJva2VuJTIwbG9ja3xlbnwwfDB8fHwxNzg2OTkyNTU2fDA%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-1765157684231-7dc85de7743d%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3w5NzU0MjJ8MHwxfHNlYXJjaHwyOHx8YnJva2VuJTIwbG9ja3xlbnwwfDB8fHwxNzg2OTkyNTU2fDA%26ixlib%3Drb-4.1.0%26q%3D80%26w%3D1080" alt="Photo by Josh Snader on Unsplash" width="1080" height="720"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Photo by &lt;a href="https://unsplash.com/@joshsnader?utm_source=articles_pipeline&amp;amp;utm_medium=referral" rel="noopener noreferrer"&gt;Josh Snader&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 wasn't a bypass of the security layer; it was a "Role Explosion" bug combined with a classic "God-mode" service principal. &lt;/p&gt;

&lt;p&gt;We had an automated pipeline that synced our Active Directory (AD) groups to Databricks. The analyst was part of a group called &lt;code&gt;global_data_science&lt;/code&gt;. That group had been granted &lt;code&gt;SELECT&lt;/code&gt; access to a view called &lt;code&gt;transactions_masked&lt;/code&gt;. This view, however, was built on top of a table where column-level security had been misconfigured. &lt;/p&gt;

&lt;p&gt;Specifically, the view relied on a hard-coded &lt;code&gt;CASE&lt;/code&gt; statement to nullify PII. When a senior engineer updated the underlying table schema to add a new PII field, they forgot to update the &lt;code&gt;SELECT&lt;/code&gt; clause in the view. The view effectively defaulted to "show all" for any new columns added to the base table. Because the &lt;code&gt;global_data_science&lt;/code&gt; role inherited broad read permissions from a legacy environment, the analyst just queried the underlying table directly. &lt;/p&gt;

&lt;p&gt;Our RBAC model was brittle. We had thousands of granular roles, and the logic was "who you are" rather than "what the data is." We were managing permissions like we were still using SQL Server 2008.&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-1631864033538-00ceb373906c%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3w5NzU0MjJ8MHwxfHNlYXJjaHwxNHx8ZGlnaXRhbCUyMHNlY3VyaXR5JTIwc2hpZWxkfGVufDB8MHx8fDE3ODY5OTI1NTd8MA%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-1631864033538-00ceb373906c%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3w5NzU0MjJ8MHwxfHNlYXJjaHwxNHx8ZGlnaXRhbCUyMHNlY3VyaXR5JTIwc2hpZWxkfGVufDB8MHx8fDE3ODY5OTI1NTd8MA%26ixlib%3Drb-4.1.0%26q%3D80%26w%3D1080" alt="Photo by Morthy Jameson on Unsplash" width="1080" height="608"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Photo by &lt;a href="https://unsplash.com/@theothermorthy?utm_source=articles_pipeline&amp;amp;utm_medium=referral" rel="noopener noreferrer"&gt;Morthy Jameson&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 nuked the old role-based approach and moved to Attribute-Based Access Control (ABAC) using Unity Catalog’s &lt;code&gt;TAG&lt;/code&gt; system and dynamic masking functions.&lt;/p&gt;

&lt;p&gt;Instead of managing who gets to see what table, we defined a policy based on the data’s properties. We tagged the sensitive columns in our UC catalog:&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;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;transactions_prod&lt;/span&gt; &lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;COLUMN&lt;/span&gt; &lt;span class="n"&gt;customer_ssn&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;TAGS&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'pii'&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'high'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;transactions_prod&lt;/span&gt; &lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;COLUMN&lt;/span&gt; &lt;span class="n"&gt;credit_card_number&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;TAGS&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'pii'&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'high'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then, we wrote a standard User-Defined Function (UDF) that masks data based on the presence of these tags. The core of the fix was applying a &lt;code&gt;MASK&lt;/code&gt; to the column, which is evaluated at query time:&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;OR&lt;/span&gt; &lt;span class="k"&gt;REPLACE&lt;/span&gt; &lt;span class="k"&gt;FUNCTION&lt;/span&gt; &lt;span class="n"&gt;mask_pii&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;col&lt;/span&gt; &lt;span class="n"&gt;STRING&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;RETURN&lt;/span&gt; &lt;span class="k"&gt;CASE&lt;/span&gt; &lt;span class="k"&gt;WHEN&lt;/span&gt; &lt;span class="n"&gt;IS_ACCOUNT_GROUP_MEMBER&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'pii_authorized_team'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;THEN&lt;/span&gt; &lt;span class="n"&gt;col&lt;/span&gt;
  &lt;span class="k"&gt;ELSE&lt;/span&gt; &lt;span class="s1"&gt;'***-**-****'&lt;/span&gt; &lt;span class="k"&gt;END&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;transactions_prod&lt;/span&gt; &lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;COLUMN&lt;/span&gt; &lt;span class="n"&gt;customer_ssn&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;MASK&lt;/span&gt; &lt;span class="n"&gt;mask_pii&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now, the permission isn't attached to the user’s role; it’s attached to the &lt;em&gt;data attribute&lt;/em&gt; itself. If you aren't in the &lt;code&gt;pii_authorized_team&lt;/code&gt;, the UDF fires automatically. It doesn't matter what role the user has, what notebook they are using, or if they are on a sandbox or production cluster. The UC engine enforces the function at the metadata level.&lt;/p&gt;

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

&lt;p&gt;We stopped thinking about "permissions" and started thinking about "data contracts."&lt;/p&gt;

&lt;p&gt;First, we implemented a mandatory tag-enforcement policy. We use the Databricks Terraform provider to ensure that any new table created in the &lt;code&gt;prod&lt;/code&gt; catalog must have a &lt;code&gt;sensitivity&lt;/code&gt; tag. If a developer tries to create a table without tagging columns as &lt;code&gt;public&lt;/code&gt;, &lt;code&gt;internal&lt;/code&gt;, or &lt;code&gt;pii&lt;/code&gt;, the CI/CD pipeline fails the deployment. You cannot create a table in production without explicitly telling the system what kind of data lives inside it.&lt;/p&gt;

&lt;p&gt;Second, we moved away from managing access via individual role membership. We use &lt;code&gt;IS_ACCOUNT_GROUP_MEMBER&lt;/code&gt; inside our masking UDFs. This decouples the security policy from the underlying object-level permissions. Even if someone accidentally grants &lt;code&gt;SELECT&lt;/code&gt; access to the &lt;code&gt;transactions_prod&lt;/code&gt; table to an intern, the masking UDF will still render the PII as &lt;code&gt;***-**-****&lt;/code&gt;. The data is protected by its metadata, not by the user's role.&lt;/p&gt;

&lt;p&gt;Third, we automated our audit process. We use the Databricks &lt;code&gt;system.access&lt;/code&gt; logs to monitor for any queries that trigger the masking function. If an unauthorized user attempts to query a column tagged with &lt;code&gt;pii='high'&lt;/code&gt;, an alert is sent to Slack. We don't wait for a compliance audit to tell us we're bleeding data; we see the attempts in real-time.&lt;/p&gt;

&lt;p&gt;The biggest lesson here is that in complex financial environments, RBAC is a maintenance nightmare that scales linearly with chaos. When you add a new team, you have to update a dozen roles. When you add a new column, you have to update a dozen views. &lt;/p&gt;

&lt;p&gt;ABAC forces you to classify your data once. Once the data is classified, the security policy is global and immutable. The developer can’t "forget" to include a column in a mask because the mask is tied to the column attribute, not the SQL view definition. &lt;/p&gt;

&lt;p&gt;I’d rather spend three days setting up a robust tagging strategy than three hours explaining to a regulator why the PII is sitting in a text file on a sandbox cluster. If your security model requires you to remember to do something, it’s already broken. Move to attributes, let the catalog do the heavy lifting, and get some sleep.&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>databricks</category>
      <category>security</category>
      <category>data</category>
      <category>governance</category>
    </item>
    <item>
      <title>Stop Z-ordering your tables unless you love wasting money</title>
      <dc:creator>Aniket Abhishek Soni</dc:creator>
      <pubDate>Sun, 16 Aug 2026 14:24:26 +0000</pubDate>
      <link>https://dev.to/aniketsoni/stop-z-ordering-your-tables-unless-you-love-wasting-money-25ee</link>
      <guid>https://dev.to/aniketsoni/stop-z-ordering-your-tables-unless-you-love-wasting-money-25ee</guid>
      <description>&lt;p&gt;Data teams waste roughly 30% of their compute spend on recurring &lt;code&gt;OPTIMIZE&lt;/code&gt; jobs that move data around just to keep queries from crawling. If you are still running &lt;code&gt;ZORDER BY&lt;/code&gt; on every high-cardinality column in your schema, you are essentially paying a "legacy tax" to the cloud provider for the privilege of manually managing data distribution. &lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Why I chose this topic:&lt;/strong&gt; I spent three weeks troubleshooting a 4-hour &lt;code&gt;OPTIMIZE&lt;/code&gt; job in a healthcare pipeline that was failing due to OOM errors, only to realize the distribution was static while our query patterns had drifted. We need to stop treating data layout like a manual maintenance chore and start treating it like a dynamic resource.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The common industry advice—that Z-ordering is the gold standard for multi-dimensional filtering—is dangerously outdated.&lt;/p&gt;

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

&lt;p&gt;The "Z-order everything" dogma assumes two things that are rarely true in modern production environments: that your query patterns are static, and that you have the time to babysit your partitioning strategy. &lt;/p&gt;

&lt;p&gt;When you run &lt;code&gt;OPTIMIZE table_name ZORDER BY (user_id, event_date)&lt;/code&gt;, you are baking a specific search pattern into the physical layout of your Parquet files. If a business analyst decides to start filtering by &lt;code&gt;region_id&lt;/code&gt; or &lt;code&gt;device_type&lt;/code&gt; next month, your Z-order becomes a liability. Your query engine will perform a full table scan because the data is physically organized for a query that no one is running anymore.&lt;/p&gt;

&lt;p&gt;Furthermore, Z-ordering is a global operation. To maintain the Z-curve, you have to rewrite the entire partition. If you have 50TB of data, you aren't just "optimizing"; you are thrashing your storage layer. Every time you trigger that command, you are incurring a massive I/O penalty, inflating your cloud storage bill, and potentially locking up your tables for hours.&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-1771011726573-60f32b69d63b%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3w5NzU0MjJ8MHwxfHNlYXJjaHwyM3x8YWJzdHJhY3QlMjBnZW9tZXRyaWMlMjBncmlkfGVufDB8MHx8fDE3ODY4MTkxMDR8MA%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-1771011726573-60f32b69d63b%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3w5NzU0MjJ8MHwxfHNlYXJjaHwyM3x8YWJzdHJhY3QlMjBnZW9tZXRyaWMlMjBncmlkfGVufDB8MHx8fDE3ODY4MTkxMDR8MA%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;
  
  
  The case for Liquid Clustering
&lt;/h2&gt;

&lt;p&gt;Liquid Clustering, introduced in Databricks Runtime 13.3 LTS, is a fundamental shift in how we handle data layout. Unlike Z-ordering, which is a rigid, global operation, Liquid Clustering is a native, incremental approach to data organization.&lt;/p&gt;

&lt;p&gt;When you define a table with &lt;code&gt;CLUSTER BY (user_id, event_date)&lt;/code&gt;, you aren't creating a static map. You are giving the Delta Lake engine permission to reorganize the data as it lands. It is a declarative, not imperative, approach.&lt;/p&gt;

&lt;p&gt;In a recent production migration of a healthcare claims dataset, we moved from a weekly Z-order job to Liquid Clustering. The Z-order job cost us ~$450 in compute per run and took 210 minutes. With Liquid Clustering, the background maintenance is handled by the engine, keeping the data performant without the massive, monolithic "all-at-once" rewrite. We saw a 40% reduction in our monthly compute bill because we stopped rewriting files that didn't need touching.&lt;/p&gt;

&lt;p&gt;The syntax is cleaner, but the real magic is under the hood. You can change your clustering columns at any time without needing to rewrite the entire history of the 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="c1"&gt;-- The old way: The "hope it stays relevant" approach&lt;/span&gt;
&lt;span class="n"&gt;OPTIMIZE&lt;/span&gt; &lt;span class="n"&gt;claims_data&lt;/span&gt; &lt;span class="n"&gt;ZORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;patient_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;claim_date&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;-- The new way: The "let the engine handle it" approach&lt;/span&gt;
&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;claims_data&lt;/span&gt; &lt;span class="k"&gt;CLUSTER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;patient_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;claim_date&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Once you run that &lt;code&gt;ALTER&lt;/code&gt; statement, new data written to the table is automatically clustered. Old data is reorganized lazily. You stop fighting the engine and start letting it work for you.&lt;/p&gt;

&lt;h2&gt;
  
  
  Measuring the success of your layout
&lt;/h2&gt;

&lt;p&gt;If you aren't measuring your data layout efficiency, you’re just guessing. Most engineers look at query duration and call it a day. That’s a vanity metric.&lt;/p&gt;

&lt;p&gt;To actually measure if your clustering is working, you need to look at &lt;strong&gt;Data Skipping Stats&lt;/strong&gt;. You can check this by running &lt;code&gt;DESCRIBE DETAIL table_name&lt;/code&gt;. Look at the &lt;code&gt;minValues&lt;/code&gt; and &lt;code&gt;maxValues&lt;/code&gt; in your file metadata. If you are filtering by &lt;code&gt;user_id&lt;/code&gt; and the min/max ranges in your files are massive, your clustering is failing.&lt;/p&gt;

&lt;p&gt;Beyond that, use the &lt;code&gt;EXPLAIN&lt;/code&gt; command in Spark. If you see &lt;code&gt;DataSkipping&lt;/code&gt; enabled but your &lt;code&gt;scan_files&lt;/code&gt; count is still high, it means your files are not pruned effectively. I prefer to pull these metrics into a simple dashboard using the &lt;code&gt;delta.history()&lt;/code&gt; command. If I see the number of files read consistently exceeding the number of files that should contain the target data, I know it’s time to re-evaluate the clustering keys.&lt;/p&gt;

&lt;p&gt;Don't just watch the clock. Watch the I/O. If your &lt;code&gt;bytes_read&lt;/code&gt; is significantly higher than the size of the result set, your physical layout is actively sabotaging your costs.&lt;/p&gt;

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

&lt;p&gt;The pushback I hear most often is: "Liquid Clustering is Databricks-specific; I want to keep my data portable." &lt;/p&gt;

&lt;p&gt;Fair enough. If you are running an open-source Delta Lake stack on your own managed Kubernetes cluster without the Databricks optimization layer, Liquid Clustering isn't available to you. But be honest—if you are building a production-grade data platform in 2024, are you really doing it to avoid vendor lock-in, or are you doing it because you haven't calculated the cost of maintaining your own infrastructure? The complexity of manual Z-ordering at scale is a "hidden cost" that far outweighs the cost of the platform.&lt;/p&gt;

&lt;p&gt;The other objection is "lack of control." Engineers hate giving up the manual &lt;code&gt;ZORDER&lt;/code&gt; knob because they feel like they lose precision. But here’s the truth: your manual "precision" is just an optimization for the query you wrote yesterday. The data lifecycle is dynamic. Your tables should be, too. If you think you know better than an engine that can analyze petabytes of access patterns in real-time, you’re likely overestimating your intuition.&lt;/p&gt;

&lt;p&gt;Lastly, some argue that Liquid Clustering is "magic" and therefore unpredictable. I’ll take "predictably good enough" over "unpredictably perfect but expensive to maintain" any day of the week.&lt;/p&gt;

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

&lt;p&gt;Stop treating your data layout as a permanent architectural decision. It’s a transient optimization that should evolve with your business requirements. &lt;/p&gt;

&lt;p&gt;If you are dealing with tables that grow daily and query patterns that change monthly, Z-ordering is a dead end. It forces you into a cycle of expensive, global rewrites that provide diminishing returns. Liquid Clustering represents the shift toward infrastructure that actually manages itself.&lt;/p&gt;

&lt;p&gt;Start by auditing your &lt;code&gt;OPTIMIZE&lt;/code&gt; jobs. If you see a job that takes more than an hour to run and touches the same columns every single time, swap it for a &lt;code&gt;CLUSTER BY&lt;/code&gt; configuration. You’ll save money, you’ll stop babysitting your pipelines, and your query performance will be more resilient to the inevitable changes in how your users interact with your data. &lt;/p&gt;

&lt;p&gt;The era of manual data maintenance is over. Stop paying for it.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Cover photo by &lt;a href="https://unsplash.com/@iantalmacs?utm_source=articles_pipeline&amp;amp;utm_medium=referral" rel="noopener noreferrer"&gt;Ian Talmacs&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>deltalake</category>
      <category>bigdata</category>
      <category>engineering</category>
    </item>
    <item>
      <title>Your data pipeline isn't idempotent until you prove it breaks and recovers</title>
      <dc:creator>Aniket Abhishek Soni</dc:creator>
      <pubDate>Fri, 14 Aug 2026 14:29:13 +0000</pubDate>
      <link>https://dev.to/aniketsoni/your-data-pipeline-isnt-idempotent-until-you-prove-it-breaks-and-recovers-13od</link>
      <guid>https://dev.to/aniketsoni/your-data-pipeline-isnt-idempotent-until-you-prove-it-breaks-and-recovers-13od</guid>
      <description>&lt;p&gt;The "Exactly Once" myth is the most expensive fairy tale in data engineering. We tell ourselves that if we just buy a high-end orchestrator or use a specific streaming library, the system will magically handle retries without duplicating a single row. It won't. If you aren't designing for idempotency at the storage layer, you’re just waiting for a primary key violation to wake you up at 3 AM.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Why I chose this topic:&lt;/strong&gt; I spent four years in healthcare fintech cleaning up "duplicate event" disasters caused by naive pipeline retries. I’m writing this because I’m tired of seeing engineers treat retry logic as an afterthought rather than a core requirement of every single data job.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;It was 3:14 AM on a Tuesday. PagerDuty didn't just beep; it screamed. Our financial reconciliation pipeline, which processes millions of daily transactions, had failed. The dashboard showed a massive spike in &lt;code&gt;500 Internal Server Error&lt;/code&gt; responses from our downstream ledger API. &lt;/p&gt;

&lt;p&gt;The Airflow UI was a sea of red. My first instinct—the one I’d been trained to do—was to hit "Clear" on the failed DAG tasks. I assumed the transient network blip had cleared, and hitting "Clear" would simply pick up where it left off. I hit the button. I went back to sleep. I was wrong.&lt;/p&gt;

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

&lt;p&gt;When I logged back in at 8:00 AM, the ledger was in total chaos. The reconciliation report showed a $12M discrepancy in our accounts payable. The logs were a mess of &lt;code&gt;Duplicate Key&lt;/code&gt; exceptions. &lt;/p&gt;

&lt;p&gt;We thought it was an API timeout issue. We spent three hours chasing the load balancer configuration, convinced that our &lt;code&gt;max_retries&lt;/code&gt; setting of 3 was somehow causing a race condition in the API gateway. We checked the AWS X-Ray traces, we grepped the Nginx logs for &lt;code&gt;upstream_response_time&lt;/code&gt;, and we even blamed the infrastructure team for a silent network partition. &lt;/p&gt;

&lt;p&gt;Everything looked like a connectivity problem. Nothing looked like a logic problem. We were looking for a broken pipe, but the water was actually being pumped into the same bucket twice.&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-1620562423895-ad4924643d43%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3w5NzU0MjJ8MHwxfHNlYXJjaHwxOHx8YnJva2VuJTIwY2xvY2t8ZW58MHwwfHx8MTc4NjY0ODMyMXww%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-1620562423895-ad4924643d43%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3w5NzU0MjJ8MHwxfHNlYXJjaHwxOHx8YnJva2VuJTIwY2xvY2t8ZW58MHwwfHx8MTc4NjY0ODMyMXww%26ixlib%3Drb-4.1.0%26q%3D80%26w%3D1080" alt="Photo by Henrique Ferreira on Unsplash" width="1080" height="607"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Photo by &lt;a href="https://unsplash.com/@rickpsd?utm_source=articles_pipeline&amp;amp;utm_medium=referral" rel="noopener noreferrer"&gt;Henrique Ferreira&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 root cause was buried in a Python script that pushed processed batches to PostgreSQL. Our insert logic looked like this:&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="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;load_data&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;batch&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;record&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;batch&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;cursor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;INSERT INTO ledger_entries (tx_id, amount, status) VALUES (%s, %s, &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;PENDING&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;)&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="n"&gt;record&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;record&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;amount&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]))&lt;/span&gt;
    &lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;commit&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The pipeline was configured with a &lt;code&gt;retries: 3&lt;/code&gt; and &lt;code&gt;retry_delay: 300&lt;/code&gt; in our Airflow task definition. When the network blip hit, the task failed mid-batch. But because the database connection didn't technically close immediately, some of those &lt;code&gt;INSERT&lt;/code&gt; statements had already hit the wire and succeeded before the network interruption severed the connection.&lt;/p&gt;

&lt;p&gt;When Airflow retried, it re-ran the &lt;em&gt;entire&lt;/em&gt; task. It didn't know which records had already been inserted and which hadn't. It just blindly tried to insert the same &lt;code&gt;tx_id&lt;/code&gt; values again. Our database had a &lt;code&gt;PRIMARY KEY&lt;/code&gt; on &lt;code&gt;tx_id&lt;/code&gt;, so the second attempt crashed. But on the third attempt, a different, partial set of records succeeded, creating a Swiss-cheese distribution of data in the production ledger. &lt;/p&gt;

&lt;p&gt;We were relying on the "All or Nothing" promise of a transaction block, but our batching strategy was too large and our retry logic was too dumb.&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-1771011726530-45b0f51401dc%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3w5NzU0MjJ8MHwxfHNlYXJjaHw2fHxhYnN0cmFjdCUyMGNpcmN1aXR8ZW58MHwwfHx8MTc4NjY0ODMyMnww%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-1771011726530-45b0f51401dc%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3w5NzU0MjJ8MHwxfHNlYXJjaHw2fHxhYnN0cmFjdCUyMGNpcmN1aXR8ZW58MHwwfHx8MTc4NjY0ODMyMnww%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;
  
  
  The fix
&lt;/h2&gt;

&lt;p&gt;We stopped treating the entire batch as a single, fragile unit. Instead, we implemented a "Load-Stage-Merge" pattern using a staging table.&lt;/p&gt;

&lt;p&gt;First, we changed the load process to move data into a temporary, un-logged staging table that shared the same schema as the production ledger.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- Create temp table per execution&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TEMP&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;stage_ledger_entries&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;LIKE&lt;/span&gt; &lt;span class="n"&gt;ledger_entries&lt;/span&gt; &lt;span class="k"&gt;INCLUDING&lt;/span&gt; &lt;span class="k"&gt;ALL&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="k"&gt;COMMIT&lt;/span&gt; &lt;span class="k"&gt;DROP&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- Bulk copy from S3 to stage&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt; &lt;span class="n"&gt;stage_ledger_entries&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="s1"&gt;'s3://bucket/data.csv'&lt;/span&gt; &lt;span class="k"&gt;WITH&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;FORMAT&lt;/span&gt; &lt;span class="n"&gt;csv&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;-- Atomic UPSERT into production&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;ledger_entries&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tx_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;SELECT&lt;/span&gt; &lt;span class="n"&gt;tx_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="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;stage_ledger_entries&lt;/span&gt;
&lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;CONFLICT&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tx_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;DO&lt;/span&gt; &lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt;
    &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;EXCLUDED&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;amount&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;EXCLUDED&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;By using the &lt;code&gt;ON CONFLICT&lt;/code&gt; clause (the &lt;code&gt;UPSERT&lt;/code&gt; pattern), we made the operation inherently idempotent. Whether the job runs once, five times, or a hundred times, the final state of the &lt;code&gt;ledger_entries&lt;/code&gt; table remains identical. If a task fails, we don't care how much data made it through before the crash. We just run the task again. The database handles the logic of ignoring duplicates or updating existing records.&lt;/p&gt;

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

&lt;p&gt;We stopped allowing "blind" retries. We now enforce a set of rules for every data pipeline we deploy. &lt;/p&gt;

&lt;p&gt;First, we moved away from row-by-row inserts. Row-by-row is slow and makes partial failures almost impossible to untangle. We now use standard bulk loading tools like &lt;code&gt;COPY&lt;/code&gt; (Postgres) or &lt;code&gt;bcp&lt;/code&gt; (SQL Server), which are transactionally safer and significantly faster.&lt;/p&gt;

&lt;p&gt;Second, we implemented "Versioned File Naming." Every output file in our S3 buckets is now suffixed with a hash of the content or a unique execution ID. If a pipeline runs, it writes to a specific path. If we retry, it writes to the same path, overwriting the previous partial attempt. This prevents the "junk data" problem where multiple failed runs litter your data lake with partial, corrupt files.&lt;/p&gt;

&lt;p&gt;Third, we introduced an observability layer specifically for idempotency. We added a check in our CI/CD pipeline that scans for &lt;code&gt;INSERT&lt;/code&gt; statements that lack an &lt;code&gt;ON CONFLICT&lt;/code&gt; or a &lt;code&gt;WHERE NOT EXISTS&lt;/code&gt; clause. If you’re writing an insert, you have to justify why it isn't idempotent. If you can’t, the build fails.&lt;/p&gt;

&lt;p&gt;Finally, we adopted a "State-First" mindset. We treat our destination database as the source of truth for the job’s progress. Before any task starts, it queries the target table to see which &lt;code&gt;tx_id&lt;/code&gt; values already exist. It then filters those out of the source batch. We basically turned the job into a self-pruning process.&lt;/p&gt;

&lt;p&gt;You will have failures. The network will drop, the API will time out, and the power will flicker. Don’t build a system that needs human intervention to clean up the mess at 3 AM. Build a system that, when it wakes you up, allows you to say "just hit restart" and go back to sleep. If you can't restart your job without fear, you aren't doing engineering; you're doing crisis management.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Cover photo by &lt;a href="https://unsplash.com/@markkoenig?utm_source=articles_pipeline&amp;amp;utm_medium=referral" rel="noopener noreferrer"&gt;Mark König&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>devops</category>
      <category>dataengineering</category>
      <category>pipelines</category>
      <category>ai</category>
    </item>
  </channel>
</rss>
