<?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>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>
    <item>
      <title>Stop Letting LLMs Write Raw SQL Against Your Production Lakehouse</title>
      <dc:creator>Aniket Abhishek Soni</dc:creator>
      <pubDate>Wed, 12 Aug 2026 16:12:56 +0000</pubDate>
      <link>https://dev.to/aniketsoni/stop-letting-llms-write-raw-sql-against-your-production-lakehouse-1li1</link>
      <guid>https://dev.to/aniketsoni/stop-letting-llms-write-raw-sql-against-your-production-lakehouse-1li1</guid>
      <description>&lt;p&gt;You ship the job. It passes CI. The integration tests green-light the agent. Then, at 2:00 AM on a Tuesday, a junior analyst asks the bot, "Show me all transactions for the last year," and your Databricks cluster spins up a 400-node task because the LLM generated a Cartesian join on an unpartitioned 50TB fact table.&lt;/p&gt;

&lt;p&gt;Your boss isn't asking about the "power of GenAI" anymore. They’re asking why the cloud bill spiked by four figures in three hours.&lt;/p&gt;

&lt;p&gt;I’ve spent six years in financial services and healthcare. I’ve seen what happens when you treat an LLM like a junior DBA who doesn't know the schema and definitely doesn't know when to use a &lt;code&gt;LIMIT&lt;/code&gt; clause. If you want to put Text-to-SQL in production, stop building demos and start building guardrails. Here is how you stop the bleeding.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. The Deny-List is Your First Line of Defense
&lt;/h2&gt;

&lt;p&gt;Never let an LLM touch your raw catalog. If you give an agent access to &lt;code&gt;information_schema&lt;/code&gt;, it will eventually try to &lt;code&gt;DROP&lt;/code&gt; or &lt;code&gt;TRUNCATE&lt;/code&gt; something because it hallucinated a table name that sounded like a temp file. Use a hard-coded schema allow-list. If it isn't in the config, the agent doesn't know it exists.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Use a strict Pydantic model for your allowed tables
&lt;/span&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;AgentSchema&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BaseModel&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;allowed_tables&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;List&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;fact_transactions&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;dim_customers&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;forbidden_columns&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Dict&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;List&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;]]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;fact_transactions&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ssn&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;credit_card_number&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="c1"&gt;# Inject this into your system prompt
&lt;/span&gt;&lt;span class="n"&gt;system_prompt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;
You are a read-only SQL assistant.
You have access to: &lt;/span&gt;&lt;span class="si"&gt;{&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="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;AgentSchema&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="n"&gt;allowed_tables&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;.
NEVER query columns: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nc"&gt;AgentSchema&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="n"&gt;forbidden_columns&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;.
&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fimages.unsplash.com%2Fphoto-1759153024456-96670153a84e%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3w5NzU0MjJ8MHwxfHNlYXJjaHwyMHx8c2hhdHRlcmVkJTIwZ2xhc3N8ZW58MHwwfHx8MTc4NjQ3NTU1OHww%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-1759153024456-96670153a84e%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3w5NzU0MjJ8MHwxfHNlYXJjaHwyMHx8c2hhdHRlcmVkJTIwZ2xhc3N8ZW58MHwwfHx8MTc4NjQ3NTU1OHww%26ixlib%3Drb-4.1.0%26q%3D80%26w%3D1080" alt="Photo by Pierre Bamin on Unsplash" width="1080" height="720"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Photo by &lt;a href="https://unsplash.com/@bamin?utm_source=articles_pipeline&amp;amp;utm_medium=referral" rel="noopener noreferrer"&gt;Pierre Bamin&lt;/a&gt; on &lt;a href="https://unsplash.com/?utm_source=articles_pipeline&amp;amp;utm_medium=referral" rel="noopener noreferrer"&gt;Unsplash&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  2. Force the LIMIT Clause at the Engine Level
&lt;/h2&gt;

&lt;p&gt;LLMs love to perform "select star" queries. In a governed lakehouse, that is a death sentence. You cannot trust the LLM to remember to add &lt;code&gt;LIMIT 100&lt;/code&gt;. If you don't enforce this, your compute costs will be the least of your worries—the driver node OOMs will be. Don't rely on the LLM to behave; rely on the SQL proxy.&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;-- Wrap your LLM-generated SQL in a CTE or a view&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="p"&gt;(&lt;/span&gt;
    &lt;span class="cm"&gt;/* LLM OUTPUT HERE */&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;fact_transactions&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;region&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'EMEA'&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Better yet, use Spark session configs to cap the row return if you’re using a notebook-based agent. Set &lt;code&gt;spark.sql.execution.maxOutputRows&lt;/code&gt; to 1000 and let the driver kill the query before it hits the network stack.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. The "Semantic Router" Pattern
&lt;/h2&gt;

&lt;p&gt;Don't let the LLM generate SQL for complex analytical queries. Use a router. If the user asks for "total revenue by region," the LLM should hit a pre-defined, optimized SQL template or a stored procedure, not a raw &lt;code&gt;SELECT SUM(...)&lt;/code&gt;. Raw SQL generation should be reserved for simple data discovery only. If the query requires a window function or a complex join, point the agent toward a curated view.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Column-Level Security is Non-Negotiable
&lt;/h2&gt;

&lt;p&gt;In healthcare, if the LLM hallucinates and includes a &lt;code&gt;WHERE&lt;/code&gt; clause on a &lt;code&gt;patient_dob&lt;/code&gt; column that shouldn't be exposed, you’re looking at a HIPAA violation. Implement an interceptor that validates the generated AST (Abstract Syntax Tree) against a security policy before execution. Use &lt;code&gt;sqlglot&lt;/code&gt; to parse the LLM's output.&lt;br&gt;
&lt;/p&gt;

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

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;validate_query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sql_statement&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;parsed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;sqlglot&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse_one&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sql_statement&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;column&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;parsed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find_all&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;exp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Column&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;column&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ssn&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;medical_record_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
            &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;SecurityException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Illegal column access: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;column&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the LLM tries to touch PII, kill the process. Don't log it; block it.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Human-in-the-Loop for DDL and Mutations
&lt;/h2&gt;

&lt;p&gt;If your agent is capable of running &lt;code&gt;INSERT&lt;/code&gt;, &lt;code&gt;UPDATE&lt;/code&gt;, or &lt;code&gt;DELETE&lt;/code&gt; commands, you have already failed. A production-safe Text-to-SQL agent should be strictly &lt;code&gt;SELECT&lt;/code&gt; only. If you absolutely must have the agent update records, implement a "Human-in-the-Loop" (HITL) step where the SQL is rendered in a UI for a human to click "Approve" before the &lt;code&gt;spark.sql()&lt;/code&gt; command is ever triggered. &lt;/p&gt;

&lt;h2&gt;
  
  
  6. Token Budgeting as a Circuit Breaker
&lt;/h2&gt;

&lt;p&gt;LLMs are verbose. They love to explain their SQL. In a production pipeline, this is just noise. Set a &lt;code&gt;max_tokens&lt;/code&gt; limit on your response, but more importantly, measure the length of the generated SQL string. If your agent generates a 500-line query for a simple question, it’s likely looping or confused. Treat long queries as a failure state and trigger a fallback to a "I don't understand" response.&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-1762242298589-582f5f6c3fb1%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3w5NzU0MjJ8MHwxfHNlYXJjaHwyN3x8Y29kZSUyMG9uJTIwdGVybWluYWx8ZW58MHwwfHx8MTc4NjQ3NTU1OXww%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-1762242298589-582f5f6c3fb1%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3w5NzU0MjJ8MHwxfHNlYXJjaHwyN3x8Y29kZSUyMG9uJTIwdGVybWluYWx8ZW58MHwwfHx8MTc4NjQ3NTU1OXww%26ixlib%3Drb-4.1.0%26q%3D80%26w%3D1080" alt="Photo by Ilnur on Unsplash" width="1080" height="720"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Photo by &lt;a href="https://unsplash.com/@mrw0rld?utm_source=articles_pipeline&amp;amp;utm_medium=referral" rel="noopener noreferrer"&gt;Ilnur&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;
  
  
  7. Audit Logging the Latent Space
&lt;/h2&gt;

&lt;p&gt;You need to log the prompt, the raw generated SQL, and the query execution stats (rows scanned, bytes spilled). When a query takes 10 minutes to run, you need to know exactly what the LLM thought it was doing. Use a decorator to log these to your observability platform (Datadog, Honeycomb, or even a simple ELK stack).&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;log_agent_execution&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;func&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;wrapper&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;kwargs&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;start&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;time&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;func&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;kwargs&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;info&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Query: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; | Duration: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;time&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;start&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;wrapper&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;p&gt;Text-to-SQL is not magic; it’s just a dangerous way to compile natural language into high-cost compute operations. If you don't build these guardrails, you’re essentially giving your entire organization a blank check to empty your data warehouse budget. &lt;/p&gt;

&lt;p&gt;Start by parsing the SQL before it runs, enforce row limits, and keep the agent away from your PII. The LLM is the engine, but the guardrails are the steering wheel. Without them, you’re just driving toward a wall at 100 miles per hour.&lt;/p&gt;

&lt;p&gt;When was the last time your LLM agent generated a query that cost you more than $50 in compute time?&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Tags:&lt;/strong&gt; #sql #llm #data #security&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>security</category>
    </item>
    <item>
      <title>LLM Observability is Broken: Why MLflow 3 is the Only Way Out</title>
      <dc:creator>Aniket Abhishek Soni</dc:creator>
      <pubDate>Mon, 10 Aug 2026 12:31:47 +0000</pubDate>
      <link>https://dev.to/aniketsoni/llm-observability-is-broken-why-mlflow-3-is-the-only-way-out-2o8n</link>
      <guid>https://dev.to/aniketsoni/llm-observability-is-broken-why-mlflow-3-is-the-only-way-out-2o8n</guid>
      <description>&lt;p&gt;Six months ago, debugging our RAG pipeline meant staring at a wall of unstructured CloudWatch logs, trying to figure out which chunk of a 50-page PDF caused the hallucination. It was a digital scavenger hunt where the clues disappeared as soon as the request finished. Today, I look at the MLflow UI, filter by a specific &lt;code&gt;trace_id&lt;/code&gt;, and see the exact RAG retrieval context, the system prompt, and the vector similarity score side-by-side. &lt;/p&gt;

&lt;p&gt;If you’re still using &lt;code&gt;print()&lt;/code&gt; statements and manual CSV tracking for your GenAI prompts, you aren’t building production software; you’re building a ticking time bomb.&lt;/p&gt;

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

&lt;p&gt;Most of my peers in financial services treat LLMs like traditional Scikit-Learn models. They log a few parameters, save a pickle file, and call it a day. That works for a Random Forest where the input is a static feature vector. It fails catastrophically for LLMs.&lt;/p&gt;

&lt;p&gt;In a GenAI system, the "code" is the prompt, the "data" is the retrieved context, and the "model" is a black box that changes its output based on a slight shift in temperature or a system message tweak. When your customer support bot starts telling users it’s okay to bypass compliance, you don’t need a model weight update; you need a prompt versioning history and a way to re-run that specific inference against a regression test suite.&lt;/p&gt;

&lt;p&gt;The "standard" way—logging inputs and outputs to a SQL database—is insufficient because it ignores the call stack. You need to know the latency of the embedding call versus the generation call. You need to see the chain of thought. If you aren't capturing the full trace, you're debugging blind.&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-1483736762161-1d107f3c78e1%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3w5NzU0MjJ8MHwxfHNlYXJjaHwyMnx8c2VydmVyJTIwcm9vbXxlbnwwfDB8fHwxNzg2MzAxNTIxfDA%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-1483736762161-1d107f3c78e1%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3w5NzU0MjJ8MHwxfHNlYXJjaHwyMnx8c2VydmVyJTIwcm9vbXxlbnwwfDB8fHwxNzg2MzAxNTIxfDA%26ixlib%3Drb-4.1.0%26q%3D80%26w%3D1080" alt="Photo by Tobias Fischer on Unsplash" width="1080" height="810"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Photo by &lt;a href="https://unsplash.com/@tofi?utm_source=articles_pipeline&amp;amp;utm_medium=referral" rel="noopener noreferrer"&gt;Tobias Fischer&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;
  
  
  Versioning prompts, not just weights
&lt;/h2&gt;

&lt;p&gt;I’ve seen senior engineers hardcode prompts into their Python logic. &lt;code&gt;prompt = "You are a helpful assistant..."&lt;/code&gt;. This is amateur hour. When the product team wants to tweak the tone of the bot, they have to wait for a full CI/CD cycle, re-testing, and redeployment.&lt;/p&gt;

&lt;p&gt;With MLflow 3, we treat prompts as first-class artifacts. We store them in the MLflow Model Registry, versioned like code.&lt;br&gt;
&lt;/p&gt;

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

&lt;span class="c1"&gt;# In production, we fetch the prompt by alias
&lt;/span&gt;&lt;span class="n"&gt;prompt_template&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;mlflow&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;models&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_model_uri&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;model_uri&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;models:/compliance-bot-prompt/production&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# Using mlflow.log_input to track the exact prompt version
&lt;/span&gt;&lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;mlflow&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;start_run&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;mlflow&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log_param&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;prompt_version&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;v1.4.2&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;mlflow&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;langchain&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log_model&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lc_model&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;model&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When I get a ticket saying the bot is acting weird, I check the &lt;code&gt;mlflow.run&lt;/code&gt; metadata. I see that &lt;code&gt;v1.4.1&lt;/code&gt; was deployed at 10:00 AM, and the latency spikes began at 10:05 AM. I don't guess; I roll back the registry alias to &lt;code&gt;v1.4.0&lt;/code&gt; with one API call. It’s boring, reliable engineering.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tracing is the new unit testing
&lt;/h2&gt;

&lt;p&gt;Evaluation in GenAI is messy because there is no ground truth. Is the answer "correct"? Who knows. But we can measure the &lt;em&gt;components&lt;/em&gt; of the answer. &lt;/p&gt;

&lt;p&gt;We use MLflow Tracing to capture the entire request lifecycle. When we run an evaluation suite, we don't just check the final string; we check the RAG context. Did the retrieval engine pull the right document? If the vector database returned a 0.7 cosine similarity score, that’s a failure mode we can track.&lt;/p&gt;

&lt;p&gt;I’ve set up a custom evaluator that runs automatically on every PR. It pulls a subset of "golden questions," runs them through the trace, and uses an LLM-as-a-judge (usually GPT-4o) to compare the current trace against a known good trace.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# A snippet of our custom evaluation logic
&lt;/span&gt;&lt;span class="n"&gt;eval_results&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;mlflow&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;evaluate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;model_uri&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;runs:/&amp;lt;run_id&amp;gt;/model&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;eval_df&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;targets&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;expected_output&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;evaluators&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;default&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;evaluator_config&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;col_mapping&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;inputs&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;prompt&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;outputs&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;response&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="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The key here is that &lt;code&gt;mlflow.evaluate&lt;/code&gt; isn't just checking accuracy; it’s checking the integrity of the trace. If the trace shows the model didn't use the provided context, the PR fails. No human needs to review the output manually.&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-1560221328-12fe60f83ab8%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3w5NzU0MjJ8MHwxfHNlYXJjaHwxNHx8ZGF0YSUyMGRhc2hib2FyZHxlbnwwfDB8fHwxNzg2MzAxNTIyfDA%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-1560221328-12fe60f83ab8%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3w5NzU0MjJ8MHwxfHNlYXJjaHwxNHx8ZGF0YSUyMGRhc2hib2FyZHxlbnwwfDB8fHwxNzg2MzAxNTIyfDA%26ixlib%3Drb-4.1.0%26q%3D80%26w%3D1080" alt="Photo by Nicholas Cappello on Unsplash" width="1080" height="717"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Photo by &lt;a href="https://unsplash.com/@bash__profile?utm_source=articles_pipeline&amp;amp;utm_medium=referral" rel="noopener noreferrer"&gt;Nicholas Cappello&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 pushback I usually get is: "MLflow adds too much overhead. Why not just use a dedicated LLM observability tool like LangSmith or Arize?"&lt;/p&gt;

&lt;p&gt;My answer is simple: Integration and ownership. &lt;/p&gt;

&lt;p&gt;In a healthcare or fintech environment, sending proprietary PII-heavy traces to a third-party SaaS vendor is a compliance nightmare. You have to go through months of security reviews for every new vendor. MLflow is open-source. We host it on our own Kubernetes cluster. We own the data, the security posture, and the uptime. &lt;/p&gt;

&lt;p&gt;Another objection: "MLflow 3 is heavy. We just need a simple logging dashboard."&lt;/p&gt;

&lt;p&gt;That’s what I said three years ago. Then we had an incident where we needed to audit every single prompt-response pair sent to a customer over a 48-hour period to comply with a regulatory request. If we were using a "simple" tool, we would have been screwed. MLflow’s backend is extensible. We use S3 for artifacts and a Postgres database for metadata. It scales because it relies on infrastructure we already manage.&lt;/p&gt;

&lt;p&gt;Finally, the "it's too complex" argument. Yes, learning how to structure your code for tracing takes a day of reading documentation. But you spend that day once. You spend the rest of your career fixing bugs caused by "simple" logging that didn't tell you the whole story.&lt;/p&gt;

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

&lt;p&gt;Stop building "GenAI apps" that are just glorified CLI scripts wrapped in a web framework. You are shipping production software, and that requires production-grade tooling. &lt;/p&gt;

&lt;p&gt;MLflow 3 gives you the observability, versioning, and evaluation framework that turns "it feels like it works" into "I can prove it works." If you aren't tracing your prompts, you don't know what your model is doing. And in the world of financial services and healthcare, "not knowing" is a liability you cannot afford. &lt;/p&gt;

&lt;p&gt;The tooling exists. Stop making excuses, stop printing logs to stdout, and start versioning your prompts like the rest of your production code. Your future self, debugging a P0 incident at 2:00 AM, will thank you.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Cover photo by &lt;a href="https://unsplash.com/@wooooooooood?utm_source=articles_pipeline&amp;amp;utm_medium=referral" rel="noopener noreferrer"&gt;Wood Hong&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>mlflow</category>
      <category>genai</category>
      <category>ai</category>
      <category>llm</category>
    </item>
    <item>
      <title>Why your Feature Store is a $50k/month glorified Delta table</title>
      <dc:creator>Aniket Abhishek Soni</dc:creator>
      <pubDate>Sat, 08 Aug 2026 14:14:08 +0000</pubDate>
      <link>https://dev.to/aniketsoni/why-your-feature-store-is-a-50kmonth-glorified-delta-table-g66</link>
      <guid>https://dev.to/aniketsoni/why-your-feature-store-is-a-50kmonth-glorified-delta-table-g66</guid>
      <description>&lt;p&gt;03:14 AM. My pager didn't just vibrate; it felt like a tactical nuke went off in my nightstand. The latency alerts for our fraud detection model had spiked from 45ms to 14 seconds. By the time I logged in, our P99s were off the charts, and the transaction approval queue in our core banking gateway was backing up faster than a clogged sink.&lt;/p&gt;

&lt;p&gt;We were six months into a "modern" ML stack overhaul. We had bought into the Feature Store hype—a premium, managed service that promised to bridge the gap between our offline training data and online serving. It was supposed to be the "source of truth." Instead, at 3:14 AM on a Tuesday, it was the graveyard of our user experience.&lt;/p&gt;

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

&lt;p&gt;The symptom was simple: the &lt;code&gt;feature-lookup&lt;/code&gt; microservice was timing out. The logs were a sea of &lt;code&gt;RequestTimeoutException&lt;/code&gt; errors. Naturally, we went down the wrong path first. We blamed the networking team, assuming a VPC peering issue between our Kubernetes cluster and the feature store’s API endpoint. &lt;/p&gt;

&lt;p&gt;We checked the &lt;code&gt;max_connections&lt;/code&gt; configuration in our gateway. It looked healthy. We looked at the feature store’s internal dashboard—the "Health" tab was glowing a cheerful, deceptive green. &lt;/p&gt;

&lt;p&gt;Then we saw it. The &lt;code&gt;GetFeatureValue&lt;/code&gt; requests weren't failing because the network was down. They were failing because the underlying database—a distributed key-value store optimized for high-throughput reads—was choking on a massive batch write job. The feature store provider had scheduled a "Point-in-Time Join" synchronization job that was effectively DDoSing its own serving layer.&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-1724867431886-aa029d2ad504%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3w5NzU0MjJ8MHwxfHNlYXJjaHw1fHxicm9rZW4lMjBjaXJjdWl0JTIwYnJlYWtlcnxlbnwwfDB8fHwxNzg2MTI5NTgzfDA%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-1724867431886-aa029d2ad504%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3w5NzU0MjJ8MHwxfHNlYXJjaHw1fHxicm9rZW4lMjBjaXJjdWl0JTIwYnJlYWtlcnxlbnwwfDB8fHwxNzg2MTI5NTgzfDA%26ixlib%3Drb-4.1.0%26q%3D80%26w%3D1080" alt="Photo by ashinJPG on Unsplash" width="1080" height="810"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Photo by &lt;a href="https://unsplash.com/@ashjpeg?utm_source=articles_pipeline&amp;amp;utm_medium=referral" rel="noopener noreferrer"&gt;ashinJPG&lt;/a&gt; on &lt;a href="https://unsplash.com/?utm_source=articles_pipeline&amp;amp;utm_medium=referral" rel="noopener noreferrer"&gt;Unsplash&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  Root cause
&lt;/h2&gt;

&lt;p&gt;The culprit was our &lt;code&gt;FeatureDefinition&lt;/code&gt; YAML. We had configured a &lt;code&gt;refresh_interval&lt;/code&gt; of 1 minute on a feature vector that spanned 400 million rows.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# The offending config&lt;/span&gt;
&lt;span class="na"&gt;feature_set&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;user_transaction_history&lt;/span&gt;
&lt;span class="na"&gt;refresh_interval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;60s&lt;/span&gt;
&lt;span class="na"&gt;backfill_strategy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;incremental&lt;/span&gt;
&lt;span class="na"&gt;storage_engine&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;optimized_kv_v2&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The feature store was trying to compute a rolling 30-day window of transaction aggregates for every active user in our system, every sixty seconds, and writing the result into the online store. Because of how the provider handled concurrency, the "incremental" backfill wasn't incremental at all; it was locking tables and causing row-level contention that blocked the read API. &lt;/p&gt;

&lt;p&gt;The vendor’s documentation had a footnote on page 42—the one everyone skips—that mentioned &lt;code&gt;optimized_kv_v2&lt;/code&gt; didn't support non-blocking writes for large-scale aggregations. We had effectively built a distributed lock on our most critical path.&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-1686061594225-3e92c0cd51b0%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3w5NzU0MjJ8MHwxfHNlYXJjaHwxNHx8ZGF0YSUyMHdhcmVob3VzZSUyMGRhc2hib2FyZHxlbnwwfDB8fHwxNzg2MTI5NTg0fDA%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-1686061594225-3e92c0cd51b0%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3w5NzU0MjJ8MHwxfHNlYXJjaHwxNHx8ZGF0YSUyMHdhcmVob3VzZSUyMGRhc2hib2FyZHxlbnwwfDB8fHwxNzg2MTI5NTg0fDA%26ixlib%3Drb-4.1.0%26q%3D80%26w%3D1080" alt="Photo by 1981 Digital on Unsplash" width="1080" height="720"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Photo by &lt;a href="https://unsplash.com/@1981digital?utm_source=articles_pipeline&amp;amp;utm_medium=referral" rel="noopener noreferrer"&gt;1981 Digital&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 needed to get the site back up immediately, so we bypassed the feature store entirely. We took the offline training data—which lived in a perfectly healthy Delta table in our S3 bucket—and mounted it as a read-only cache in our &lt;code&gt;feature-lookup&lt;/code&gt; service.&lt;/p&gt;

&lt;p&gt;I pushed a hotfix that changed the service discovery logic. Instead of calling the feature store’s API, the service queried a local sidecar container running a lightweight version of the Delta table.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# The hotfix: bypass the vendor and hit the lake
&lt;/span&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;delta_sharing&lt;/span&gt;
&lt;span class="c1"&gt;# We used the Delta Sharing protocol to query the table directly 
# from the S3 bucket, bypassing the feature store's API.
&lt;/span&gt;&lt;span class="n"&gt;df&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;delta_sharing&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;load_as_pandas&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;table_url&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;lookup_val&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;loc&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;rolling_txn_sum&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The latency dropped from 14 seconds to 80ms instantly. The load on our infrastructure vanished. The feature store remained in its degraded state, completely irrelevant to the actual business requirement of authorizing a transaction.&lt;/p&gt;

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

&lt;p&gt;We stopped using the feature store as a database for high-velocity lookups. It’s a painful lesson: feature stores are excellent at discovery and versioning, but they are often terrible at acting as your primary hot-path storage.&lt;/p&gt;

&lt;p&gt;We moved to a "Lake-First" architecture. All features now live in Delta tables. We treat the Delta table as the source of truth, and we use a simple, low-latency Redis cache for the hot-path lookups. We update the Redis cache via a simple Spark streaming job that listens to our Kafka stream, bypassing the "feature store" abstraction entirely. &lt;/p&gt;

&lt;p&gt;If you are just doing simple lookups, a Delta table with a proper partitioning strategy (e.g., &lt;code&gt;PARTITIONED BY (user_id % 100)&lt;/code&gt;) combined with a fast cache is all you need. You don't need a $50k/month vendor to perform a &lt;code&gt;SELECT * FROM features WHERE id = X&lt;/code&gt;. &lt;/p&gt;

&lt;p&gt;The complexity of "Feature Stores" is almost always a tax on your engineering velocity. We keep the feature store now only for metadata management and lineage—the "catalog" of our features. We no longer let the vendor touch our production traffic.&lt;/p&gt;

&lt;p&gt;In 2026, the best ML infra is the one that's boring. If you find yourself debugging a vendor’s proprietary write-contention lock at 3 AM, you’ve already lost. Use Delta tables for your heavy lifting, Redis for your speed, and keep the "store" as a catalog, not a runtime dependency. The complexity isn't worth the dashboard.&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>architecture</category>
      <category>sre</category>
      <category>machinelearning</category>
      <category>infrastructure</category>
    </item>
    <item>
      <title>Encrypting at Rest is for Amateurs: Building HIPAA-Compliant Lakehouses with Column-Level Access Control</title>
      <dc:creator>Aniket Abhishek Soni</dc:creator>
      <pubDate>Thu, 06 Aug 2026 15:16:06 +0000</pubDate>
      <link>https://dev.to/aniketsoni/encrypting-at-rest-is-for-amateurs-building-hipaa-compliant-lakehouses-with-column-level-access-3fl7</link>
      <guid>https://dev.to/aniketsoni/encrypting-at-rest-is-for-amateurs-building-hipaa-compliant-lakehouses-with-column-level-access-3fl7</guid>
      <description>&lt;p&gt;Most data engineers building for healthcare are dead wrong: they think that if they enable AES-256 at the storage layer, they’ve satisfied HIPAA. They haven't. They’ve just satisfied a checkbox on a low-effort audit. If you aren't governing data access at the column level, you aren't protecting PHI; you’re just waiting for a breach notification letter to hit your desk.&lt;/p&gt;

&lt;p&gt;You are currently standing at a crossroads. You either build a lakehouse where access is controlled via coarse-grained IAM policies, or you build one where security is baked into the storage format itself. &lt;/p&gt;

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

&lt;p&gt;On one side, we have the "Infrastructure-as-Perimeter" approach. This is the classic cloud-native pattern: S3 buckets or Azure ADLS containers guarded by IAM roles. You grant a service account access to a bucket, and they get everything in it. PHI handling is done via "data zoning"—moving sensitive data into a separate, highly restricted bucket.&lt;/p&gt;

&lt;p&gt;On the other side, we have the "Table-Format-as-Perimeter" approach. This uses modern open-table formats like Apache Iceberg or Delta Lake, paired with a metadata-driven access control layer like Unity Catalog or Starburst/Trino. Here, access is defined by SQL policies (&lt;code&gt;GRANT SELECT ON TABLE...&lt;/code&gt;) rather than filesystem paths.&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-1664146158348-6a8c71ac62d1%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3w5NzU0MjJ8MHwxfHNlYXJjaHw2fHxwYWRsb2NrJTIwZGlnaXRhbCUyMGNvZGV8ZW58MHwwfHx8MTc4NTk1OTEzMnww%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-1664146158348-6a8c71ac62d1%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3w5NzU0MjJ8MHwxfHNlYXJjaHw2fHxwYWRsb2NrJTIwZGlnaXRhbCUyMGNvZGV8ZW58MHwwfHx8MTc4NTk1OTEzMnww%26ixlib%3Drb-4.1.0%26q%3D80%26w%3D1080" alt="Photo by max im on Unsplash" width="1080" height="720"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Photo by &lt;a href="https://unsplash.com/@dwa2dwa?utm_source=articles_pipeline&amp;amp;utm_medium=referral" rel="noopener noreferrer"&gt;max im&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 operational complexity
&lt;/h2&gt;

&lt;p&gt;The "data zoning" approach—the bucket-per-sensitivity-level model—is a nightmare that scales linearly with your paranoia. I’ve managed pipelines where we had to maintain three separate copies of the same patient demographics data just to keep the "Research" team out of the "Clinical" bucket. &lt;/p&gt;

&lt;p&gt;You end up paying for storage three times, and your egress costs go up because your Spark jobs are constantly shuffling data across bucket boundaries. More importantly, it kills your agility. Every time a data scientist needs one more column for a longitudinal study, you’re stuck updating Terraform scripts, running a &lt;code&gt;terraform plan&lt;/code&gt; that takes six minutes, and praying you don't break a production IAM policy.&lt;/p&gt;

&lt;p&gt;The table-format approach is more expensive in terms of licensing or specialized compute, but cheaper in terms of "human-in-the-loop." When I use Unity Catalog or similar frameworks, I can tag a column as &lt;code&gt;PHI&lt;/code&gt; and apply a masking policy. If someone queries the column, they get &lt;code&gt;***-**-1234&lt;/code&gt; instead of a real SSN. The cost is a slight overhead on every query, but I don't pay for triple-redundant storage, and I don't pay for the senior engineer’s time spent babysitting bucket policies.&lt;/p&gt;

&lt;h2&gt;
  
  
  Failure modes and audit footprints
&lt;/h2&gt;

&lt;p&gt;When your security is tied to IAM roles, your failure mode is "over-permissioning." It’s the path of least resistance. A developer needs access to a table for a quick bug fix? You grant them the IAM role that covers the whole bucket. Six months later, that developer has left the company, but their role still exists, and they still have access to 400 gigabytes of PHI because nobody remembered to prune the permissions.&lt;/p&gt;

&lt;p&gt;In the table-format approach, the failure mode is "metadata drift." If your catalog isn't perfectly synced with your underlying Iceberg manifests, you might experience a "ghost access" issue where a user can see a file but not the table metadata, or vice-versa. &lt;/p&gt;

&lt;p&gt;However, the audit footprint is where the table format wins. In the IAM model, an auditor asks, "Who accessed this PHI?" and you have to parse through CloudTrail logs. Have you ever tried to map a &lt;code&gt;GetBucket&lt;/code&gt; event to a specific patient record? It’s impossible. You get an IP address and an IAM ARN. In the table-format model, the audit log gives you the identity, the exact SQL statement executed, and the specific column mask applied. If I’m an auditor, I know exactly what you saw.&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-1779784651020-e5d0958b7d1b%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3w5NzU0MjJ8MHwxfHNlYXJjaHwxNXx8YWJzdHJhY3QlMjBkYXRhJTIwc3RyZWFtfGVufDB8MHx8fDE3ODU5NTkxMzN8MA%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-1779784651020-e5d0958b7d1b%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3w5NzU0MjJ8MHwxfHNlYXJjaHwxNXx8YWJzdHJhY3QlMjBkYXRhJTIwc3RyZWFtfGVufDB8MHx8fDE3ODU5NTkxMzN8MA%26ixlib%3Drb-4.1.0%26q%3D80%26w%3D1080" alt="Photo by Anna Evans on Unsplash" width="1080" height="810"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Photo by &lt;a href="https://unsplash.com/@anevans?utm_source=articles_pipeline&amp;amp;utm_medium=referral" rel="noopener noreferrer"&gt;Anna 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 reality of data leakage
&lt;/h2&gt;

&lt;p&gt;The biggest lie in healthcare data engineering is that you can "anonymize" data by stripping identifiers. You can’t. You have to treat everything as PHI until proven otherwise. &lt;/p&gt;

&lt;p&gt;In the bucket-based approach, you eventually reach the "Oops, I accidentally moved raw PHI to the dev environment" stage. It happens. Someone runs a &lt;code&gt;cp&lt;/code&gt; command from the wrong terminal, and suddenly you have production patient data in a non-compliant sandbox. Because the security is at the bucket level, that sandbox is now a massive compliance liability.&lt;/p&gt;

&lt;p&gt;With granular column-level security, even if a dev accidentally pulls raw data into a downstream table, the access policies follow the metadata tags. If the downstream table is tagged as &lt;code&gt;PHI&lt;/code&gt;, the masking policy still triggers. You don't have to rely on the discipline of the developer; you rely on the policy engine. &lt;/p&gt;

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

&lt;p&gt;If you are building a lakehouse today, stop building "data zones." Move to an Iceberg-based architecture with a centralized metadata layer. &lt;/p&gt;

&lt;p&gt;I’d pick Apache Iceberg on S3, governed by a solution like Unity Catalog (if you’re on Databricks) or Starburst/Trino (if you’re running a mix of EMR/Glue). The reason is simple: it allows you to decouple &lt;em&gt;storage&lt;/em&gt; from &lt;em&gt;policy&lt;/em&gt;. &lt;/p&gt;

&lt;p&gt;The caveat—and this is a big one—is that you must treat your access control code as production-grade application code. Do not just use the UI to click "Grant Access." You need to treat your &lt;code&gt;GRANT&lt;/code&gt; and &lt;code&gt;MASK&lt;/code&gt; SQL statements as code, checked into Git, and reviewed by a security engineer. &lt;/p&gt;

&lt;p&gt;If you aren't doing code reviews on your access policies, you are just replacing bucket-level IAM mess with SQL-level IAM mess. The risk profile shifts from "accidental exposure" to "logic error in a policy script." But at least with a policy script, you can write unit tests. You can’t write a unit test for an IAM bucket policy that checks if the policy prevents unauthorized access to a specific column.&lt;/p&gt;

&lt;p&gt;Finally, keep your &lt;code&gt;pii_masking_udf&lt;/code&gt; simple. Don't try to build a complex cryptographic hashing function inside your SQL layer unless you have the crypto-engineering expertise to manage the keys. Keep the logic predictable. A simple regex-based mask for a Social Security number is better than a complex, bespoke hashing algorithm that nobody understands and that breaks when you upgrade your Spark runtime from 3.3 to 3.5. &lt;/p&gt;

&lt;p&gt;Your goal is not to be clever. Your goal is to pass the audit without losing your mind. Move the security to the data, not the server.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Cover photo by &lt;a href="https://unsplash.com/@lawsonlee?utm_source=articles_pipeline&amp;amp;utm_medium=referral" rel="noopener noreferrer"&gt;Lee Lawson&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>hipaa</category>
      <category>security</category>
      <category>devops</category>
    </item>
    <item>
      <title>Kill the Full Refresh: Implementing Delta Lake Change Data Feed for Silver Pipelines</title>
      <dc:creator>Aniket Abhishek Soni</dc:creator>
      <pubDate>Tue, 04 Aug 2026 13:26:08 +0000</pubDate>
      <link>https://dev.to/aniketsoni/kill-the-full-refresh-implementing-delta-lake-change-data-feed-for-silver-pipelines-17ge</link>
      <guid>https://dev.to/aniketsoni/kill-the-full-refresh-implementing-delta-lake-change-data-feed-for-silver-pipelines-17ge</guid>
      <description>&lt;p&gt;The "Bronze-to-Silver full-refresh" is the industry’s most expensive lie. Every time I see a pipeline re-processing ten terabytes of historical patient records just to capture the five hundred updates from the last hour, I see money literally burning in the cloud provider’s furnace.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Why I chose this topic:&lt;/strong&gt; In my last three roles, I’ve had to fix "silver" layers that collapsed under their own weight because they relied on full-table overwrites. Change Data Feed (CDF) is the only way to build pipelines that actually scale, and I'm tired of seeing engineers treat it like it’s too "complex" to touch.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Your current pipeline probably looks like this: A massive batch job kicks off at 2 AM, scans the entire Bronze storage, executes a deduplication &lt;code&gt;window&lt;/code&gt; function over the last 90 days of partition data, and writes a massive &lt;code&gt;OVERWRITE&lt;/code&gt; to the Silver table. It works fine when your data is small. But once you hit the 10-terabyte mark, your Spark driver starts throwing OOM errors, and your finance department starts asking why the Databricks bill for the "ETL Sandbox" is higher than the marketing budget.&lt;/p&gt;

&lt;p&gt;You’re not doing data engineering; you’re doing data laundering. You are taking raw data, washing it in a massive compute cycle, and outputting the same data you already had, just slightly cleaner. It is a loop of technical debt that compounds every time a new source system is ingested.&lt;/p&gt;

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

&lt;p&gt;The problem isn't your code; it's the paradigm of batch-oriented thinking. We treat tables like immutable blobs that must be replaced to be updated. When you run a full refresh, you are essentially telling the cloud provider, "Please ignore the fact that 99% of this data hasn't changed, and charge me for the privilege of re-calculating it."&lt;/p&gt;

&lt;p&gt;Delta Lake’s Change Data Feed (CDF) changes the contract. Instead of asking "What is the state of the world?", you ask "What happened since the last time I checked?" By enabling CDF, Delta writes a sidecar log of row-level changes—inserts, updates, and deletes. You can consume these changes incrementally, transforming only the delta, and merging them into your downstream tables. This is how you go from an 8-hour batch job to a 5-minute incremental micro-batch.&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-1782094673136-5198a372980c%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3w5NzU0MjJ8MHwxfHNlYXJjaHwxfHxzZXJ2ZXIlMjByb29tJTIwcmFja3xlbnwwfDB8fHwxNzg1Nzg2NDE5fDA%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-1782094673136-5198a372980c%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3w5NzU0MjJ8MHwxfHNlYXJjaHwxfHxzZXJ2ZXIlMjByb29tJTIwcmFja3xlbnwwfDB8fHwxNzg1Nzg2NDE5fDA%26ixlib%3Drb-4.1.0%26q%3D80%26w%3D1080" alt="Photo by Winston Chen on Unsplash" width="1080" height="720"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Photo by &lt;a href="https://unsplash.com/@winstonchen?utm_source=articles_pipeline&amp;amp;utm_medium=referral" rel="noopener noreferrer"&gt;Winston Chen&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: Enable the change feed on your bronze tables
&lt;/h2&gt;

&lt;p&gt;Before you can do anything, you need to turn the feature on. This isn't retroactive, so do it today. If you have a legacy table, you’ll need to set the property and then perform a one-time rewrite (the last full-refresh you'll ever do).&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;bronze_patient_data&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="n"&gt;delta&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;enableChangeDataFeed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;-- If the table is already massive, run this to populate the change log&lt;/span&gt;
&lt;span class="c1"&gt;-- effectively from this point forward.&lt;/span&gt;
&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="n"&gt;OVERWRITE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;bronze_patient_data&lt;/span&gt; 
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;bronze_patient_data&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Once this property is set, Delta starts tracking the operations in the &lt;code&gt;_change_data&lt;/code&gt; folder hidden within your table path. Note that this increases storage slightly—usually by about 5–10%—but the cost of that storage is pennies compared to the cost of your redundant compute.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: Read the changes using Spark structured streaming
&lt;/h2&gt;

&lt;p&gt;Now, instead of reading the whole table, you point your reader at the change log. You don't need to manually calculate watermarks or manage state if you stick to the &lt;code&gt;readChangeFeed&lt;/code&gt; API. This is where the magic happens. You treat the changes as a stream, applying your logic—data type casting, PII masking, and deduplication—only to the records that actually changed.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Read the feed as a stream
&lt;/span&gt;&lt;span class="n"&gt;df_changes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;spark&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;readStream&lt;/span&gt; \
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;format&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;delta&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; \
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;option&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;readChangeFeed&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;true&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; \
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;option&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;startingVersion&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;150&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; \
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;table&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;bronze_patient_data&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# Perform your business logic here
# Note: Since this is an incremental stream, 
# your transformations must be idempotent.
&lt;/span&gt;&lt;span class="n"&gt;silver_df&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;df_changes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;patient_name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;status&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;_change_type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# Write to your silver table
&lt;/span&gt;&lt;span class="n"&gt;silver_df&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;writeStream&lt;/span&gt; \
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;format&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;delta&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; \
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;outputMode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;append&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; \
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;option&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;checkpointLocation&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/mnt/delta/checkpoints/silver_patient&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; \
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;table&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;silver_patient_data&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;_change_type&lt;/code&gt; column is your best friend here. It will tell you if the row was an &lt;code&gt;insert&lt;/code&gt;, &lt;code&gt;update_preimage&lt;/code&gt;, &lt;code&gt;update_postimage&lt;/code&gt;, or &lt;code&gt;delete&lt;/code&gt;. You don't need to guess why a row appeared; the audit trail is built-in.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: Implement the upsert logic in silver
&lt;/h2&gt;

&lt;p&gt;Your Silver layer shouldn't just be an append-only log; it should be a mirror of the latest state of your entities. Since you are reading changes, you need to merge those changes into your final Silver table. This is where &lt;code&gt;MERGE INTO&lt;/code&gt; becomes the backbone of your architecture.&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;upsert_to_silver&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;microBatchDF&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;batchId&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;microBatchDF&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createOrReplaceTempView&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;updates&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="c1"&gt;# Merge the micro-batch into the target table
&lt;/span&gt;    &lt;span class="n"&gt;microBatchDF&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_jdf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sparkSession&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;sql&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;
        MERGE INTO silver_patient_data target
        USING updates source
        ON target.id = source.id
        WHEN MATCHED AND source._change_type IN (&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;update_postimage&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="s"&gt;insert&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;) THEN
            UPDATE SET *
        WHEN NOT MATCHED THEN
            INSERT *
        WHEN MATCHED AND source._change_type = &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;delete&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt; THEN
            DELETE
    &lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# Execute using foreachBatch
&lt;/span&gt;&lt;span class="n"&gt;silver_df&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;writeStream&lt;/span&gt; \
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;foreachBatch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;upsert_to_silver&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; \
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;option&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;checkpointLocation&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/checkpoints/silver_patient&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; \
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;start&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 &lt;code&gt;foreachBatch&lt;/code&gt;, you gain full control over the &lt;code&gt;MERGE&lt;/code&gt; logic. You handle the &lt;code&gt;update_postimage&lt;/code&gt; for existing IDs and handle the &lt;code&gt;delete&lt;/code&gt; events cleanly. This keeps your Silver table perfectly in sync with the Bronze source without ever scanning the entire dataset.&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-1583869367058-97af59eef352%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3w5NzU0MjJ8MHwxfHNlYXJjaHwyNnx8ZGlnaXRhbCUyMHBsdW1iaW5nJTIwcGlwZXN8ZW58MHwwfHx8MTc4NTc4NjQyMHww%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-1583869367058-97af59eef352%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3w5NzU0MjJ8MHwxfHNlYXJjaHwyNnx8ZGlnaXRhbCUyMHBsdW1iaW5nJTIwcGlwZXN8ZW58MHwwfHx8MTc4NTc4NjQyMHww%26ixlib%3Drb-4.1.0%26q%3D80%26w%3D1080" alt="Photo by August Phlieger on Unsplash" width="1080" height="658"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Photo by &lt;a href="https://unsplash.com/@augie_ph?utm_source=articles_pipeline&amp;amp;utm_medium=referral" rel="noopener noreferrer"&gt;August Phlieger&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;p&gt;I’ve seen this setup fail in predictable ways. Here is how you avoid the midnight pager duty:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;strong&gt;The Checkpoint Trap:&lt;/strong&gt; Never, ever delete your checkpoint directory. If you do, you lose the state of your stream. You will be forced to reprocess the entire table from version zero, which is exactly what we are trying to avoid. Keep your checkpoints in a highly available, versioned S3 bucket with lifecycle policies that prevent accidental deletion.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;The Deletion Lag:&lt;/strong&gt; If your source systems perform hard deletes and you don't have a reliable way to capture those deletes in the Bronze layer, CDF won't help you with the Silver layer. Ensure your upstream integration (e.g., Debezium, Fivetran) is configured to propagate &lt;code&gt;DELETE&lt;/code&gt; events. If you only see &lt;code&gt;INSERT&lt;/code&gt;s, your Silver layer will become a "hall of mirrors" for deleted records.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Schema Evolution:&lt;/strong&gt; CDF is sensitive to schema changes. If you add a column to your Bronze table, your &lt;code&gt;MERGE&lt;/code&gt; statement in the Silver writer might fail if you are using &lt;code&gt;UPDATE SET *&lt;/code&gt;. Be explicit in your &lt;code&gt;MERGE&lt;/code&gt; column mappings if you expect your Bronze schema to evolve frequently.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;The "Too Many Files" Problem:&lt;/strong&gt; Because you are streaming, you might end up with millions of tiny files in your Silver table. Run &lt;code&gt;OPTIMIZE&lt;/code&gt; and &lt;code&gt;VACUUM&lt;/code&gt; on a schedule (e.g., weekly), but do it outside of your streaming window. If you try to run &lt;code&gt;OPTIMIZE&lt;/code&gt; while a stream is writing to the same partition, you will hit lock contention errors.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;Change Data Feed isn't a "nice to have" feature for high-scale platforms; it's a survival requirement. Once you stop treating your data lake like a collection of static files and start treating it like a stream of events, your infra costs will drop, and your pipeline reliability will skyrocket. It requires more upfront design than a simple &lt;code&gt;INSERT OVERWRITE&lt;/code&gt;, but that's the difference between a junior engineer and a senior architect.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Try it:&lt;/strong&gt; Enable &lt;code&gt;delta.enableChangeDataFeed&lt;/code&gt; on your smallest, most annoying production table today. Use &lt;code&gt;spark.read.format("delta").option("readChangeFeed", "true")&lt;/code&gt; in a local notebook to see exactly what those updates look like. Once you see the audit log, you’ll never go back to full-table refreshes again.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Tags:&lt;/strong&gt; #datalake #spark #delta #engineering&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Cover photo by &lt;a href="https://unsplash.com/@marusyaionova?utm_source=articles_pipeline&amp;amp;utm_medium=referral" rel="noopener noreferrer"&gt;Maria Ionova&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>deltalake</category>
      <category>spark</category>
      <category>devops</category>
      <category>dataengineering</category>
    </item>
    <item>
      <title>Is Exactly-Once Processing in Spark Actually Real?</title>
      <dc:creator>Aniket Abhishek Soni</dc:creator>
      <pubDate>Sun, 02 Aug 2026 19:08:50 +0000</pubDate>
      <link>https://dev.to/aniketsoni/is-exactly-once-processing-in-spark-actually-real-3ia1</link>
      <guid>https://dev.to/aniketsoni/is-exactly-once-processing-in-spark-actually-real-3ia1</guid>
      <description>&lt;p&gt;The "Exactly-Once" guarantee in Spark Structured Streaming is the most dangerous myth in modern data engineering. We are taught to believe that if we just wrap our Kafka source and our Parquet sink in the right configuration, our data counts will magically remain perfect despite network partitions, executor crashes, or node evictions.&lt;/p&gt;

&lt;p&gt;I’ve spent the last six years cleaning up the mess left by engineers who took that claim at face value. Exactly-once is not a mathematical certainty inherent to your code; it is a delicate, fragile contract between your storage layer, your checkpoint directory, and your retry logic. If any of those three pillars is misconfigured, "exactly-once" quickly devolves into "at-least-once" or, worse, "data-loss-forever."&lt;/p&gt;

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

&lt;p&gt;At the heart of the guarantee is the Write-Ahead Log (WAL) mechanism managed via the &lt;code&gt;checkpointLocation&lt;/code&gt;. When you start a Spark Structured Streaming job, it initializes a state store—usually RocksDB or an in-memory map—and records the progress of every micro-batch in the checkpoint directory.&lt;/p&gt;

&lt;p&gt;The magic happens during the commit protocol. When a micro-batch finishes, Spark writes a metadata file to the &lt;code&gt;_spark_metadata&lt;/code&gt; folder in your sink. This file tells the driver: "These are the files I just wrote, and here is the offset I just processed."&lt;/p&gt;

&lt;p&gt;If an executor dies mid-batch, Spark doesn't try to magically recover the partial writes. Instead, it relies on the fact that your sink is &lt;em&gt;idempotent&lt;/em&gt;. If you are writing to S3, Spark uses a temporary staging directory. It writes the files there first, then performs an atomic rename or a move operation to the final destination only &lt;em&gt;after&lt;/em&gt; the batch succeeds.&lt;/p&gt;

&lt;p&gt;If you are using Kafka as a sink, Spark relies on the transaction coordinator. It starts a Kafka transaction, writes the records, and then commits the transaction. If the Spark driver crashes before the commit, the transaction expires, Kafka aborts the writes, and the Spark re-run simply attempts to write that same batch again. The consumer sees no duplicates because it is configured with &lt;code&gt;isolation.level = read_committed&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The code looks deceptively simple:&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="k"&gt;val&lt;/span&gt; &lt;span class="nv"&gt;query&lt;/span&gt; &lt;span class="k"&gt;=&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;writeStream&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="o"&gt;.&lt;/span&gt;&lt;span class="py"&gt;option&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"checkpointLocation"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"s3a://my-bucket/checkpoint/job-name/"&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;span class="s"&gt;"s3a://my-bucket/output-path/"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But notice what is missing: error handling. The "exactly-once" logic is baked into the &lt;code&gt;checkpointLocation&lt;/code&gt;. If that directory is deleted, corrupted, or moved to a different S3 bucket, your exactly-once guarantee evaporates instantly.&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-1646942462469-30267eb9155a%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3w5NzU0MjJ8MHwxfHNlYXJjaHwyNnx8c2hhdHRlcmVkJTIwZ2xhc3N8ZW58MHwwfHx8MTc4NTYxMTk4M3ww%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-1646942462469-30267eb9155a%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3w5NzU0MjJ8MHwxfHNlYXJjaHwyNnx8c2hhdHRlcmVkJTIwZ2xhc3N8ZW58MHwwfHx8MTc4NTYxMTk4M3ww%26ixlib%3Drb-4.1.0%26q%3D80%26w%3D1080" alt="Photo by Carrie Borden on Unsplash" width="1080" height="719"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Photo by &lt;a href="https://unsplash.com/@carrie_borden?utm_source=articles_pipeline&amp;amp;utm_medium=referral" rel="noopener noreferrer"&gt;Carrie Borden&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;The primary cost of exactly-once is latency. You are constantly flushing state to durable storage. Every micro-batch has to wait for the metadata log to be updated in S3 or HDFS. In a high-throughput environment, the &lt;code&gt;fsync&lt;/code&gt; overhead on your metadata storage will eventually become your bottleneck. &lt;/p&gt;

&lt;p&gt;Then there is the issue of schema evolution. If you change your schema and your sink is Parquet, you might break the append operation. If the task fails to write a new file due to a schema mismatch, Spark will retry. If your retry logic isn't perfectly tuned, you end up with infinite retry loops that crash the driver, forcing you to manually clean the checkpoint directory—which almost always results in duplicate data or missed records.&lt;/p&gt;

&lt;p&gt;Another massive pain point is state store growth. If you are doing windowed aggregations with &lt;code&gt;withWatermark&lt;/code&gt;, Spark keeps that state in the &lt;code&gt;checkpointLocation&lt;/code&gt; forever unless you trigger manual compaction or set the watermark correctly. I’ve seen production jobs grind to a halt because the RocksDB state store grew to several terabytes. When the driver tried to read that metadata on restart, it timed out, and the job entered a boot-loop.&lt;/p&gt;

&lt;p&gt;Furthermore, exactly-once only applies if the &lt;em&gt;entire pipeline&lt;/em&gt; is idempotent. If your Spark job calls an external REST API for enrichment, Spark has no way of knowing if that API call succeeded during a failed micro-batch. If the job retries, it hits the API again. Suddenly, you’ve hit your rate limit or, worse, you’ve charged a customer twice for the same event. Exactly-once in Spark is a local guarantee; it does not extend to the outside world.&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-1759210358926-4673cc44d35f%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3w5NzU0MjJ8MHwxfHNlYXJjaHwyN3x8dGVjaG5pY2FsJTIwYmx1ZXByaW50fGVufDB8MHx8fDE3ODU2MTE5ODR8MA%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-1759210358926-4673cc44d35f%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3w5NzU0MjJ8MHwxfHNlYXJjaHwyN3x8dGVjaG5pY2FsJTIwYmx1ZXByaW50fGVufDB8MHx8fDE3ODU2MTE5ODR8MA%26ixlib%3Drb-4.1.0%26q%3D80%26w%3D1080" alt="Photo by Claudio Guglieri on Unsplash" width="1080" height="608"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Photo by &lt;a href="https://unsplash.com/@claudioguglieri?utm_source=articles_pipeline&amp;amp;utm_medium=referral" rel="noopener noreferrer"&gt;Claudio Guglieri&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;You reach for exactly-once when your business logic involves financial accounting, inventory reconciliation, or any case where a missing or duplicate record triggers an audit flag. If you are building a real-time dashboard or a recommendation engine, you probably don't need the overhead.&lt;/p&gt;

&lt;p&gt;If you are dealing with external systems that are not transactional, stop pretending that Spark is doing the heavy lifting for you. You need to implement your own idempotency keys. Write your output to a staging table keyed by &lt;code&gt;(event_id, batch_id)&lt;/code&gt;, then use a &lt;code&gt;MERGE INTO&lt;/code&gt; statement with a &lt;code&gt;WHERE&lt;/code&gt; clause to ensure you never overwrite or duplicate. &lt;/p&gt;

&lt;p&gt;Never rely on the default behavior if your sink is anything other than Parquet or Delta Lake. If you’re writing to a legacy database using &lt;code&gt;JDBC&lt;/code&gt;, Spark’s exactly-once guarantee is practically non-existent because JDBC drivers rarely support the two-phase commit protocol required to roll back an insert if the Spark task crashes after the write but before the metadata update. &lt;/p&gt;

&lt;p&gt;If your data volume is massive, consider trading exactly-once for at-least-once. If you can handle duplicates via a downstream deduplication layer (like a &lt;code&gt;SELECT DISTINCT&lt;/code&gt; or a row-number window function in your warehouse), you will save yourself a massive amount of stress. You can increase the micro-batch interval, reduce the frequency of checkpoint writes, and drastically improve your cluster’s stability.&lt;/p&gt;

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

&lt;p&gt;Exactly-once in Spark Structured Streaming is a tool, not a religion. It is a highly optimized, state-aware commit protocol that works perfectly as long as your storage is fast, your checkpoint directory is immutable, and your sinks are transactional. &lt;/p&gt;

&lt;p&gt;The moment you step outside those bounds—by hitting external APIs, using non-transactional databases, or mismanaging your checkpoint metadata—the guarantee dies. Stop chasing the perfect pipeline. Instead, focus on building idempotent sinks and robust observability. If you can’t verify your data at the sink, it doesn’t matter how many "exactly-once" boxes you checked in the Spark UI.&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/@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>dataengineering</category>
      <category>streaming</category>
      <category>data</category>
      <category>spark</category>
    </item>
    <item>
      <title>Why I stopped trusting your JSON schemas and started enforcing data contracts in CI</title>
      <dc:creator>Aniket Abhishek Soni</dc:creator>
      <pubDate>Fri, 31 Jul 2026 21:10:38 +0000</pubDate>
      <link>https://dev.to/aniketsoni/why-i-stopped-trusting-your-json-schemas-and-started-enforcing-data-contracts-in-ci-3mdp</link>
      <guid>https://dev.to/aniketsoni/why-i-stopped-trusting-your-json-schemas-and-started-enforcing-data-contracts-in-ci-3mdp</guid>
      <description>&lt;p&gt;Schema registries are a waste of time if they don't break your build.&lt;/p&gt;

&lt;p&gt;If you are still relying on a Confluent Schema Registry to "warn" your downstream consumers about a breaking change, you have already lost the war. You are treating data integrity as a runtime request for permission rather than a pre-compilation requirement. In six years of building data platforms for healthcare and fintech, I’ve seen enough production outages caused by "harmless" field deprecations to know that human communication is not a data strategy. &lt;/p&gt;

&lt;p&gt;If it isn't in the CI pipeline, it doesn't exist.&lt;/p&gt;

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

&lt;p&gt;The industry standard is currently "passive enforcement." You publish an Avro schema to a registry, and maybe, if you’ve configured your Kafka producers correctly, you get a compatibility check. But what about the downstream SQL transformation layer? What about the dbt models that expect a &lt;code&gt;user_id&lt;/code&gt; to be a &lt;code&gt;STRING&lt;/code&gt; but suddenly receive a &lt;code&gt;BIGINT&lt;/code&gt; because someone in billing decided to clean up their database?&lt;/p&gt;

&lt;p&gt;Passive registries are decoupled from the lifecycle of the actual code. A producer can change their schema, wait for the registry to return a 200 OK, and deploy their service. The consumer—often a different team entirely—finds out when their Pydantic models explode at 3:00 AM because the &lt;code&gt;total_amount&lt;/code&gt; field is now a nested object instead of a float.&lt;/p&gt;

&lt;p&gt;We treat data like a stream of bytes that might change, rather than an API that must be versioned. If your data pipeline doesn't treat a schema drift as a failed unit test, you aren't doing data engineering; you’re doing data archaeology.&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-1599083644312-1ce7c4aae8fc%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3w5NzU0MjJ8MHwxfHNlYXJjaHwyOXx8c2VydmVyJTIwcmFjayUyMGxpZ2h0c3xlbnwwfDB8fHwxNzg1NTI3MDY5fDA%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-1599083644312-1ce7c4aae8fc%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3w5NzU0MjJ8MHwxfHNlYXJjaHwyOXx8c2VydmVyJTIwcmFjayUyMGxpZ2h0c3xlbnwwfDB8fHwxNzg1NTI3MDY5fDA%26ixlib%3Drb-4.1.0%26q%3D80%26w%3D1080" alt="Photo by okeykat on Unsplash" width="1080" height="719"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Photo by &lt;a href="https://unsplash.com/@okeykat?utm_source=articles_pipeline&amp;amp;utm_medium=referral" rel="noopener noreferrer"&gt;okeykat&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;
  
  
  Moving contracts into the repository
&lt;/h2&gt;

&lt;p&gt;My team moved away from centralized registry-only workflows toward a "Contract-as-Code" model. We define our schemas in YAML or Protobuf within the producer's repository, but we also maintain a &lt;code&gt;contracts/&lt;/code&gt; directory in a shared monorepo that our CI pipelines reference.&lt;/p&gt;

&lt;p&gt;When a producer team wants to make a change, they don't just update their code. They open a PR that includes a change to the contract file. Our CI pipeline runs a tool like &lt;code&gt;data-contract-cli&lt;/code&gt; (or a custom wrapper around &lt;code&gt;jsonschema&lt;/code&gt; and &lt;code&gt;dbt-checkpoint&lt;/code&gt;) to validate that the new schema doesn't break the existing downstream expectations.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# example-contract.yaml&lt;/span&gt;
&lt;span class="na"&gt;dataContract&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;1.0.0&lt;/span&gt;
&lt;span class="na"&gt;servers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;kafka&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;kafka&lt;/span&gt;
    &lt;span class="na"&gt;bootstrapServers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;broker.prod.svc&lt;/span&gt;
&lt;span class="na"&gt;models&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;transactions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;fields&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;transaction_id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;string&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;required&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;true&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
      &lt;span class="na"&gt;amount&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;decimal&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;required&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;true&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
      &lt;span class="na"&gt;currency&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;string&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;required&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;true&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When a developer tries to remove the &lt;code&gt;currency&lt;/code&gt; field, the CI job executes a diff against the current production-deployed contract. If the diff indicates a breaking change—like removing a required field—the build fails. The developer is stopped before they even hit the deploy button. They are forced to either negotiate the change with the consumers or version the topic to &lt;code&gt;transactions_v2&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Building the gatekeeper in CI
&lt;/h2&gt;

&lt;p&gt;The magic happens in the &lt;code&gt;.github/workflows/contract-check.yaml&lt;/code&gt;. You need to treat this job with the same severity as a &lt;code&gt;terraform plan&lt;/code&gt; or a &lt;code&gt;pytest&lt;/code&gt; suite. If the contract check fails, the PR cannot be merged.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;validate-contract&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Install contract tool&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npm install -g @data-contract/cli&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Validate schema compatibility&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
          &lt;span class="s"&gt;datacontract test ./contracts/transactions.yaml \&lt;/span&gt;
            &lt;span class="s"&gt;--source ./schema/transactions.json \&lt;/span&gt;
            &lt;span class="s"&gt;--compatibility backward&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;By setting &lt;code&gt;--compatibility backward&lt;/code&gt;, we ensure that any new deployment is strictly additive. If a developer accidentally changes a type, the CI pipeline throws an exit code 1. This isn't just a linter; this is an automated negotiation. &lt;/p&gt;

&lt;p&gt;In fintech, we dealt with PCI-DSS compliance. If a producer changed a field name that inadvertently exposed PII, our contract checks—which included regex patterns for field names—would catch the leak. By enforcing these rules at the PR level, we moved security and data quality from a reactive "ops" problem to a proactive "dev" problem.&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-1782711927611-d2776a83af5d%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3w5NzU0MjJ8MHwxfHNlYXJjaHwxM3x8Z2xhc3MlMjBjb250cmFjdCUyMHNpZ25pbmd8ZW58MHwwfHx8MTc4NTUyNzA3MHww%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-1782711927611-d2776a83af5d%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3w5NzU0MjJ8MHwxfHNlYXJjaHwxM3x8Z2xhc3MlMjBjb250cmFjdCUyMHNpZ25pbmd8ZW58MHwwfHx8MTc4NTUyNzA3MHww%26ixlib%3Drb-4.1.0%26q%3D80%26w%3D1080" alt="Photo by Brecht Corbeel on Unsplash" width="1080" height="720"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Photo by &lt;a href="https://unsplash.com/@brechtcorbeel?utm_source=articles_pipeline&amp;amp;utm_medium=referral" rel="noopener noreferrer"&gt;Brecht Corbeel&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;"This will slow down our velocity," is the most common pushback I hear. Peer engineers argue that they should be able to iterate on their data structures as fast as they iterate on their services. &lt;/p&gt;

&lt;p&gt;My answer: Velocity without reliability is just moving fast toward a cliff. When you break a consumer in production, you aren't just "moving fast"—you’re spending three days in an incident response war room fixing the downstream dependency chain. That is a massive tax on your velocity. A five-minute wait for a CI check is an investment, not a speed bump.&lt;/p&gt;

&lt;p&gt;"What if we need to make a breaking change?" they ask. Then you version your stream. If you are changing the meaning of your data, you are fundamentally creating a new product. If you change a field type, you are breaking the contract. Create a new topic, publish the new schema, and run them in parallel for a migration window. Yes, it takes more work. That’s because data is a persistent asset, not a transient cache. If you aren't willing to manage the lifecycle of your data, you shouldn't be allowed to expose it to other teams.&lt;/p&gt;

&lt;p&gt;"This is too much overhead for small teams." If you are a team of two, you might think you don't need this. You’re wrong. You’re just smaller, which means the cost of a catastrophic data failure is more likely to kill your startup. Automate the contract check early. It costs almost nothing to set up when you have three services. It’s nearly impossible to retrofit when you have three hundred.&lt;/p&gt;

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

&lt;p&gt;We have to stop treating data producers as kings who can change the shape of the world whenever they feel like it. Data is the foundation of every financial ledger and healthcare record we manage. Treating it like an afterthought is negligence.&lt;/p&gt;

&lt;p&gt;By moving your data contracts into your repository and enforcing them with hard failures in CI, you move the friction to where it belongs: the moment the change is proposed, not the moment it hits production. You don't need a fancy data mesh or a million-dollar governance platform. You need a YAML file, a basic CLI tool, and the backbone to make your CI pipeline block bad code.&lt;/p&gt;

&lt;p&gt;Stop trusting the registry. Start breaking the build. Your downstream consumers will thank you—or at least, they won't be calling you at 3:00 AM.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Cover photo by &lt;a href="https://unsplash.com/@fonsheijnsbroek_amsterdam_photos?utm_source=articles_pipeline&amp;amp;utm_medium=referral" rel="noopener noreferrer"&gt;Fons Heijnsbroek&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>
    <item>
      <title>Stop copying your data for Vertex AI pipelines</title>
      <dc:creator>Aniket Abhishek Soni</dc:creator>
      <pubDate>Thu, 30 Jul 2026 00:43:07 +0000</pubDate>
      <link>https://dev.to/aniketsoni/stop-copying-your-data-for-vertex-ai-pipelines-44oe</link>
      <guid>https://dev.to/aniketsoni/stop-copying-your-data-for-vertex-ai-pipelines-44oe</guid>
      <description>&lt;p&gt;80% of data scientists spend their time cleaning data, but 90% of data engineers spend their time babysitting ETL pipelines that move the exact same data from a Lakehouse into a training-specific bucket. &lt;/p&gt;

&lt;p&gt;We are literally burning cloud credits to store the same Parquet files in two different places because we’re too lazy to build a connector. If you are still running a "data export" step before your Vertex AI pipeline, you are paying a "laziness tax" that kills both your storage budget and your data lineage.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Why I chose this topic:&lt;/strong&gt; In my time handling HIPAA-regulated datasets, I’ve seen teams lose six figures annually in egress costs and storage bloat simply because they didn't trust their Lakehouse to serve a model directly. I’m writing this because I’m tired of seeing "copy-to-GCS" steps in production pipelines that have no business being there.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;We all use &lt;code&gt;BigQuery&lt;/code&gt; or &lt;code&gt;Dataproc&lt;/code&gt; daily, but most of us treat them like glorified file cabinets rather than compute engines. We treat the Lakehouse as a passive sink. That’s a mistake. The real power of the modern stack is that the feature engineering layer should be an extension of your query engine, not a separate shuttle service.&lt;/p&gt;

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

&lt;p&gt;The goal is to stop moving data and start querying it in place using the &lt;code&gt;Vertex AI SDK for Python&lt;/code&gt;. Instead of a pipeline that pulls data into a staging bucket, you should be using BigQuery-backed feature sets or direct integration with your Lakehouse (Databricks or BigQuery) using Vertex AI Feature Store.&lt;/p&gt;

&lt;p&gt;Here is the mechanics of how we do this without a single &lt;code&gt;gsutil cp&lt;/code&gt; command.&lt;/p&gt;

&lt;p&gt;You define your training input as a BigQuery URI rather than a GCS path. When you initialize your &lt;code&gt;CustomJob&lt;/code&gt; or &lt;code&gt;PipelineJob&lt;/code&gt;, you point the &lt;code&gt;input_data_config&lt;/code&gt; directly to your source table.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;google.cloud&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;aiplatform&lt;/span&gt;

&lt;span class="n"&gt;job&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;aiplatform&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;PipelineJob&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;display_name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;feature-eng-pipeline&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;template_path&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pipeline.json&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;parameter_values&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;bq_input_uri&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;bq://my-project.feature_dataset.user_features_v1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;model_output_uri&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;gs://my-bucket/models/&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Inside the pipeline component, you shouldn't be reading CSVs. You should be using the &lt;code&gt;google-cloud-bigquery&lt;/code&gt; client to create a temporary view or a materialized table that represents your training slice. If your feature engineering involves complex window functions or time-series joins, run those as a &lt;code&gt;BigQueryToDataset&lt;/code&gt; operation.&lt;/p&gt;

&lt;p&gt;The magic happens when you use &lt;code&gt;BigQuery&lt;/code&gt; as the direct source for your Vertex AI &lt;code&gt;TrainingPipeline&lt;/code&gt;. By setting the &lt;code&gt;training_task_inputs&lt;/code&gt; to point at a BQ table, you allow Vertex to manage the read operation. If you’re using BigQuery ML (BQML), you aren't even leaving the warehouse. You’re shipping the compute to the data, which is the only way to scale in a financial services environment where data gravity is a real, tangible problem.&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-1758876377882-6ae5b186b6e7%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3w5NzU0MjJ8MHwxfHNlYXJjaHwxNHx8YWJzdHJhY3QlMjBkaWdpdGFsJTIwZGF0YSUyMGZsb3d8ZW58MHwwfHx8MTc4NTM1MzY1OXww%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-1758876377882-6ae5b186b6e7%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3w5NzU0MjJ8MHwxfHNlYXJjaHwxNHx8YWJzdHJhY3QlMjBkaWdpdGFsJTIwZGF0YSUyMGZsb3d8ZW58MHwwfHx8MTc4NTM1MzY1OXww%26ixlib%3Drb-4.1.0%26q%3D80%26w%3D1080" alt="Photo by A Chosen Soul on Unsplash" width="1080" height="608"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Photo by &lt;a href="https://unsplash.com/@a_chosensoul?utm_source=articles_pipeline&amp;amp;utm_medium=referral" rel="noopener noreferrer"&gt;A Chosen Soul&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;Let’s be honest: avoiding data duplication isn't free. &lt;/p&gt;

&lt;p&gt;First, you lose the "snapshot" guarantee of a flat file. If your source table in BigQuery changes while your training job is running, you get non-deterministic results. You are essentially training on a moving target. To fix this, you have to implement proper partition pruning or point-in-time snapshots in your DDL. If you don't use &lt;code&gt;FOR SYSTEM_TIME AS OF&lt;/code&gt; in your BigQuery queries, you will eventually have a production model that fails to reproduce because the underlying data drifted during the training run.&lt;/p&gt;

&lt;p&gt;Second, the &lt;code&gt;BigQuery&lt;/code&gt; read API has throughput limits. If you have a massive dataset (hundreds of terabytes) and you try to pull it into a custom container running on Vertex AI, you will hit the 100MB/s per project limit for the BigQuery Storage Read API. You’ll be scratching your head wondering why your pipeline is taking five hours to initialize when the query itself took ten seconds. You’ll need to explicitly manage your concurrency settings.&lt;/p&gt;

&lt;p&gt;Third, debugging becomes a headache. When you have an error in a CSV on GCS, you can just &lt;code&gt;cat&lt;/code&gt; the file. When you have an error in a BQ-backed pipeline, you’re dealing with IAM permissions, service account scopes, and project-level quotas. If your Vertex AI service account doesn't have &lt;code&gt;bigquery.jobs.create&lt;/code&gt; and &lt;code&gt;bigquery.datasets.get&lt;/code&gt; on the &lt;em&gt;source&lt;/em&gt; dataset, the job will fail silently or hang in a state of perpetual "pending."&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-1667984390527-850f63192709%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3w5NzU0MjJ8MHwxfHNlYXJjaHw1fHxjbG91ZCUyMGluZnJhc3RydWN0dXJlJTIwYXJjaGl0ZWN0dXJlfGVufDB8MHx8fDE3ODUzNTM2NjB8MA%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-1667984390527-850f63192709%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3w5NzU0MjJ8MHwxfHNlYXJjaHw1fHxjbG91ZCUyMGluZnJhc3RydWN0dXJlJTIwYXJjaGl0ZWN0dXJlfGVufDB8MHx8fDE3ODUzNTM2NjB8MA%26ixlib%3Drb-4.1.0%26q%3D80%26w%3D1080" alt="Photo by Growtika on Unsplash" width="1080" height="608"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Photo by &lt;a href="https://unsplash.com/@growtika?utm_source=articles_pipeline&amp;amp;utm_medium=referral" rel="noopener noreferrer"&gt;Growtika&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 in-place feature engineering when your data is already in BigQuery and you are running iterative experiments. If you are doing fast-paced model iteration, you cannot afford to wait for a 20-minute ETL job to shuffle files to GCS every time you want to add a feature.&lt;/p&gt;

&lt;p&gt;Do NOT reach for it if you are dealing with unstructured data. If your Vertex AI pipeline is processing images, audio, or raw binary blobs, keep those in GCS. The overhead of storing binary data in a database as BLOBs is a disaster for performance and cost. Keep your structured features in the Lakehouse and your raw unstructured data in GCS, then use a &lt;code&gt;BigQuery&lt;/code&gt; join or an index file to link them during the training loop.&lt;/p&gt;

&lt;p&gt;Also, avoid this pattern if your team lacks strong SQL skills. If your data scientists are "Python-native" and refuse to touch SQL, you will end up with broken, unoptimized queries that scan the entire table instead of using partitions. You will bankrupt your cloud account in a week. If you can’t write a partition-aware query, stick to your GCS file copies.&lt;/p&gt;

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

&lt;p&gt;The "data duplication" tax is an artifact of a time when compute and storage were tightly coupled. Today, with BigQuery and Vertex AI, they are logically separated but physically close. By treating your Lakehouse as the primary source of truth for your training pipelines, you reduce your attack surface for data drift, lower your storage costs, and simplify your lineage.&lt;/p&gt;

&lt;p&gt;Stop treating your cloud storage like a temporary trash bin for ETL jobs. Start treating your Lakehouse like the compute engine it was designed to be. It’s harder to set up, and the IAM policies will make you pull your hair out for an afternoon, but the result is a clean, scalable pipeline that doesn't duplicate the world every time you want to train a model.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Tags:&lt;/strong&gt; gcp, vertexai, data, engineering&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>gcp</category>
      <category>vertexai</category>
      <category>data</category>
      <category>engineering</category>
    </item>
    <item>
      <title>Stop Betting the Farm on Glue: Why 2026 Spark Runtimes Demand Nuance</title>
      <dc:creator>Aniket Abhishek Soni</dc:creator>
      <pubDate>Mon, 27 Jul 2026 22:54:07 +0000</pubDate>
      <link>https://dev.to/aniketsoni/stop-betting-the-farm-on-glue-why-2026-spark-runtimes-demand-nuance-3f8p</link>
      <guid>https://dev.to/aniketsoni/stop-betting-the-farm-on-glue-why-2026-spark-runtimes-demand-nuance-3f8p</guid>
      <description>&lt;p&gt;In 2021, my "data platform" was a custom-built script suite managing EMR clusters with a 45-minute spin-up time and an S3 bucket that looked like a digital landfill. If a partition skewed, the cluster died, I got a PagerDuty alert at 3:00 AM, and I spent two hours manually resizing instances.&lt;/p&gt;

&lt;p&gt;Today, I’m running thousands of jobs across a mixed-mode architecture. I don't touch cluster sizing for 90% of my pipeline. But the tradeoff? I’m now fighting "black box" performance tuning and bill shock from services that hide the underlying mechanics. The industry tells you these runtimes are plug-and-play. They aren't.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Why I chose this topic:&lt;/strong&gt; I’m tired of reading "comparison tables" that treat these platforms as interchangeable commodities. In financial services, a 10% variance in executor memory allocation isn't just a cost center—it's a compliance failure or a missed SLA.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;We all use Spark, but we treat the underlying runtime like a magic spell. We submit a job, pray to the resource manager, and check the Spark UI if it crashes. Most engineers don't understand that Glue, Databricks, and EMR Serverless are fundamentally different ways of abstracting the same open-source engine.&lt;/p&gt;

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

&lt;p&gt;AWS Glue is essentially a managed Spark environment built on top of a highly abstracted YARN-like layer. When you run a Glue job, you aren't just running Spark; you are running it inside an AWS-managed container that forces specific configurations on you. Glue’s "Worker Type" (G.1X, G.2X, G.8X) is the core abstraction. A G.1X worker gives you 16GB of RAM, but only 4GB is actually usable for your executors after the overhead of the Glue agent and the OS.&lt;/p&gt;

&lt;p&gt;Databricks is a different beast entirely. It uses Photon, a vectorized query engine written in C++. If you’re running standard open-source Spark on Glue, you’re hitting the JVM. If you’re on Databricks, you’re hitting Photon. This is why a query that takes 10 minutes on Glue can take 90 seconds on Databricks. It’s not just the "managed" part; it’s that they’ve essentially rewritten the execution layer to bypass Java’s memory management issues.&lt;/p&gt;

&lt;p&gt;EMR Serverless is the middle ground. It is closer to "vanilla" Spark than Glue but without the cluster management headache. It uses a "pre-initialized" capacity model. When you define an &lt;code&gt;Application&lt;/code&gt; in EMR Serverless, you are defining a set of worker nodes that are kept warm. The magic here is the &lt;code&gt;spark-submit&lt;/code&gt; parity. If you have an existing on-prem Spark job, you can port it to EMR Serverless with minimal changes. You can’t say the same for Glue, where you’re often fighting the &lt;code&gt;glueContext&lt;/code&gt; and the specific way it handles Dynamic Frames.&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-1758073519996-6d3c63b4922c%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3w5NzU0MjJ8MHwxfHNlYXJjaHw1fHxhYnN0cmFjdCUyMGRhdGElMjBmbG93fGVufDB8MHx8fDE3ODUxODE1Mjl8MA%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-1758073519996-6d3c63b4922c%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3w5NzU0MjJ8MHwxfHNlYXJjaHw1fHxhYnN0cmFjdCUyMGRhdGElMjBmbG93fGVufDB8MHx8fDE3ODUxODE1Mjl8MA%26ixlib%3Drb-4.1.0%26q%3D80%26w%3D1080" alt="Photo by jonakoh _ on Unsplash" width="1080" height="608"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Photo by &lt;a href="https://unsplash.com/@jonakoh?utm_source=articles_pipeline&amp;amp;utm_medium=referral" rel="noopener noreferrer"&gt;jonakoh _&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;Let’s talk about the Glue &lt;code&gt;job-bookmark&lt;/code&gt; feature. It’s a lifesaver for incremental loads, but it’s a nightmare when you have to reprocess data. Because the state is stored in the Glue metadata service, "resetting" a bookmark is an opaque process that often fails if your schema evolution is complex. I’ve spent more hours debugging Glue metadata locks than I care to admit.&lt;/p&gt;

&lt;p&gt;Then there is the Databricks "Cloud Provider Tax." You pay for the DBU (Databricks Unit) on top of the underlying EC2 instance cost. In a high-volume environment, that DBU cost will dwarf your AWS bill. If your team isn't using Unity Catalog to optimize governance, you’re paying for a Ferrari and driving it to the grocery store. Also, the cold start time for Databricks Serverless is still a crapshoot. If your pipeline triggers on-demand, you’re looking at a 30-to-60-second latency before a single row of data is processed.&lt;/p&gt;

&lt;p&gt;EMR Serverless is the most "honest" service, but it’s also the most unforgiving. The logging is… verbose. When a job fails in EMR Serverless, you’re often digging through CloudWatch logs that look like a cat walked across a keyboard. Because it lacks the high-level management UI of Databricks or the simplified Glue visual interface, your team needs to be comfortable with &lt;code&gt;spark-submit&lt;/code&gt; arguments like &lt;code&gt;--conf spark.executor.memoryOverhead&lt;/code&gt;. If your engineers don’t understand how to tune the JVM heap versus off-heap memory, EMR Serverless will just burn your budget with OOM (Out Of Memory) errors that provide zero actionable feedback.&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-1760842543741-876d7837fa0b%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3w5NzU0MjJ8MHwxfHNlYXJjaHwxMHx8ZGlnaXRhbCUyMGNpcmN1aXQlMjBjbG9zZXVwfGVufDB8MHx8fDE3ODUxODE1MzF8MA%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-1760842543741-876d7837fa0b%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3w5NzU0MjJ8MHwxfHNlYXJjaHwxMHx8ZGlnaXRhbCUyMGNpcmN1aXQlMjBjbG9zZXVwfGVufDB8MHx8fDE3ODUxODE1MzF8MA%26ixlib%3Drb-4.1.0%26q%3D80%26w%3D1080" alt="Photo by Akshat Sharma on Unsplash" width="1080" height="720"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Photo by &lt;a href="https://unsplash.com/@asphotographypics?utm_source=articles_pipeline&amp;amp;utm_medium=referral" rel="noopener noreferrer"&gt;Akshat Sharma&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;Glue is your go-to for "I have a bunch of small, infrequent, ETL jobs." If you’re doing data movement from S3 to Redshift/Snowflake and you don't want to manage a single line of infrastructure code, Glue is fine. Do &lt;em&gt;not&lt;/em&gt; use Glue for heavy machine learning model training or massive, multi-terabyte joins. The G.8X workers are expensive, and the lack of fine-grained control over the Spark shuffle service will lead to "shuffle block fetch failure" errors that will haunt your weekends.&lt;/p&gt;

&lt;p&gt;Reach for Databricks when you are building a full-scale Data Lakehouse. If your engineering team is large and you need a unified interface for data scientists and data engineers, the DBU cost is justified by the collaborative tooling. Use Databricks when your performance requirements are non-negotiable—specifically, when you have complex SQL queries on massive datasets that Spark’s catalyst optimizer struggles to handle alone. Do &lt;em&gt;not&lt;/em&gt; use Databricks if you are a small startup with a limited budget; the overhead of managing workspaces, clusters, and DBU consumption will kill your runway.&lt;/p&gt;

&lt;p&gt;Use EMR Serverless when you have a well-defined, containerized Spark pipeline that you want to move to the cloud without re-architecting your logic. It is the best choice for "lift and shift" or for organizations that want to maintain a pure open-source Spark stack without the "vendor lock-in" of Databricks-specific APIs. Avoid EMR Serverless if your team is junior or lacks deep Spark internals experience; it is not a "managed" service in the sense that it solves your bad code—it just runs it faster and more reliably.&lt;/p&gt;

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

&lt;p&gt;The "best" runtime is the one that minimizes your team’s cognitive load, not the one with the highest benchmark score. In 2026, the market has commoditized the hardware; the value is now in the abstraction.&lt;/p&gt;

&lt;p&gt;If you’re building for scale and performance, pay the Databricks tax. If you’re building for ease of use and low maintenance, use Glue. If you’re building for portability and fine-grained control, EMR Serverless is your champion. Just stop pretending they’re the same, and stop choosing them based on a marketing slide deck. Pick your poison, tune your memory overhead, and for the love of everything, stop letting jobs run in the default namespace.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Cover photo by &lt;a href="https://unsplash.com/@barkiple?utm_source=articles_pipeline&amp;amp;utm_medium=referral" rel="noopener noreferrer"&gt;John Barkiple&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>data</category>
      <category>cloud</category>
      <category>architecture</category>
    </item>
    <item>
      <title>Why I Chose Snowflake Dynamic Tables Over Databricks DLT (And When I Regret It)</title>
      <dc:creator>Aniket Abhishek Soni</dc:creator>
      <pubDate>Sat, 25 Jul 2026 22:01:38 +0000</pubDate>
      <link>https://dev.to/aniketsoni/why-i-chose-snowflake-dynamic-tables-over-databricks-dlt-and-when-i-regret-it-468i</link>
      <guid>https://dev.to/aniketsoni/why-i-chose-snowflake-dynamic-tables-over-databricks-dlt-and-when-i-regret-it-468i</guid>
      <description>&lt;p&gt;It was 3:14 AM on a Tuesday when the PagerDuty alert fired. Our DLT pipeline, processing HIPAA-sensitive patient telemetry, had stalled. A library conflict in a custom Python UDF—introduced via a transient cluster restart—caused a silent data drift in our gold-layer aggregations. By the time we caught the stale dashboards, we’d pushed downstream reports to three hospital systems with flawed occupancy metrics. The cost wasn’t just the $4,000 in wasted DBU consumption during the retry storm; it was the two weeks of manual reconciliation to regain the trust of our compliance team.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Why I chose this topic:&lt;/strong&gt; I’ve spent the last six years cleaning up broken pipeline architectures in regulated environments. I’m tired of reading marketing fluff that ignores the reality of debugging distributed state; here is how these tools actually behave when the pressure is on.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;You are currently deciding how to build your next data platform. You have two primary paths: letting Snowflake handle the state management of your transformations via Dynamic Tables, or letting Databricks manage your DAGs via Delta Live Tables (DLT). Both promise to replace the "if-this-then-that" imperative spaghetti of Airflow with declarative SQL. Both lie about how easy that is.&lt;/p&gt;

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

&lt;p&gt;Snowflake Dynamic Tables (DTs) are a logical extension of the Snowflake storage engine. You define a &lt;code&gt;TARGET_LAG&lt;/code&gt; (e.g., '1 minute'), and the underlying Snowflake scheduler handles the incremental materialization. It’s essentially a materialized view that doesn’t require you to manually write the refresh logic.&lt;/p&gt;

&lt;p&gt;Databricks DLT is an abstraction layer on top of Spark. It manages the infrastructure (clusters), the dependencies, and the data quality (Expectations). It’s a full-fledged orchestration engine hidden inside a SQL/Python framework. &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-1759771963617-e451d02011d4%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3w5NzU0MjJ8MHwxfHNlYXJjaHwyMXx8YWJzdHJhY3QlMjBkYXRhJTIwZmxvd3xlbnwwfDB8fHwxNzg1MDA3MTM5fDA%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-1759771963617-e451d02011d4%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3w5NzU0MjJ8MHwxfHNlYXJjaHwyMXx8YWJzdHJhY3QlMjBkYXRhJTIwZmxvd3xlbnwwfDB8fHwxNzg1MDA3MTM5fDA%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 cost of abstracting the scheduler
&lt;/h2&gt;

&lt;p&gt;Snowflake’s DT pricing is straightforward but dangerous. You pay for the compute used during the refresh. If your &lt;code&gt;TARGET_LAG&lt;/code&gt; is set to 1 minute, Snowflake’s scheduler is constantly spinning up or using a warehouse to check for changes. If your underlying base table is constantly being ingested into (e.g., streaming Kafka data), that warehouse is running 24/7. I’ve seen junior devs set &lt;code&gt;TARGET_LAG = '1 minute'&lt;/code&gt; on a table with 50 terabytes of data, effectively creating a permanent, expensive compute sink.&lt;/p&gt;

&lt;p&gt;Databricks DLT pricing is more granular but significantly more complex. You have the DLT compute cost (DBUs) plus the underlying cluster cost. Because DLT uses Spark, it’s remarkably efficient at processing massive, partitioned shuffles—things that would make a Snowflake warehouse cry. However, the "Enhanced Autoscaling" feature is a trap. I’ve seen it spin up an &lt;code&gt;r6id.4xlarge&lt;/code&gt; cluster to process a tiny backfill, then hang around for the 15-minute "down-scaling delay," burning money while doing absolutely nothing.&lt;/p&gt;

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

&lt;p&gt;When a Snowflake Dynamic Table fails, you check &lt;code&gt;DYNAMIC_TABLE_REFRESH_HISTORY&lt;/code&gt;. The error codes are generally readable: &lt;code&gt;TABLE_NOT_FOUND&lt;/code&gt; or &lt;code&gt;COMPUTE_RESOURCE_LIMIT&lt;/code&gt;. It’s essentially a black box. If the refresh fails, Snowflake retries based on its own internal logic. You have very little control over the retry backoff or the sequence of operations. It’s "set it and forget it," until it breaks, at which point you are at the mercy of Snowflake Support.&lt;/p&gt;

&lt;p&gt;DLT failure modes are far more "interesting." Because DLT is Spark, you get the full, ugly stack trace. You’ll see &lt;code&gt;AnalysisException&lt;/code&gt;, &lt;code&gt;Py4JJavaError&lt;/code&gt;, and the classic &lt;code&gt;ExecutorLostFailure&lt;/code&gt;. The good news is that DLT's &lt;code&gt;EXPECTATIONS&lt;/code&gt; are a game-changer. You can define &lt;code&gt;CONSTRAINT patient_id_not_null EXPECT (patient_id IS NOT NULL) ON VIOLATION DROP ROW&lt;/code&gt;. This is declarative data quality that actually works. If you try to do this in Snowflake, you’re writing &lt;code&gt;CASE WHEN&lt;/code&gt; statements in your select list like it’s 2012.&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-1771530789155-b1f03fbf82b5%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3w5NzU0MjJ8MHwxfHNlYXJjaHwxMHx8aW5kdXN0cmlhbCUyMHdhcmVob3VzZSUyMHNjYWxlfGVufDB8MHx8fDE3ODUwMDcxMzl8MA%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-1771530789155-b1f03fbf82b5%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3w5NzU0MjJ8MHwxfHNlYXJjaHwxMHx8aW5kdXN0cmlhbCUyMHdhcmVob3VzZSUyMHNjYWxlfGVufDB8MHx8fDE3ODUwMDcxMzl8MA%26ixlib%3Drb-4.1.0%26q%3D80%26w%3D1080" alt="Photo by Craftsman Concrete Floors on Unsplash" width="1080" height="720"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Photo by &lt;a href="https://unsplash.com/@craftsmanconcrete_official?utm_source=articles_pipeline&amp;amp;utm_medium=referral" rel="noopener noreferrer"&gt;Craftsman Concrete Floors&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;
  
  
  Operational overhead and infrastructure
&lt;/h2&gt;

&lt;p&gt;Snowflake wins on pure platform integration. If your data is already in Snowflake, you don't provision anything. You don't manage cluster sizes. You don't worry about node instance types. You enable the &lt;code&gt;DYNAMIC_TABLE_USER&lt;/code&gt; role, grant the permissions, and write your &lt;code&gt;CREATE DYNAMIC TABLE&lt;/code&gt;. It is the "I don't have a dedicated DevOps team" solution.&lt;/p&gt;

&lt;p&gt;Databricks DLT requires you to be a Spark operator. You need to understand &lt;code&gt;spark.sql.shuffle.partitions&lt;/code&gt;, memory overhead, and how to configure your &lt;code&gt;pipelines.yml&lt;/code&gt; to ensure your clusters aren't thrashing. If you have a team that knows how to tune a Spark session, DLT is a Ferrari. If you don’t, it’s a Ferrari with the wheels welded on crooked.&lt;/p&gt;

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

&lt;p&gt;If I’m building a greenfield project for a startup or a mid-sized data team where the primary goal is velocity and standard transformation (JOINs, simple aggregations), I am picking Snowflake Dynamic Tables. The maintenance burden is zero. You can put your entire pipeline in a single &lt;code&gt;schema.sql&lt;/code&gt; file and version control it. The lack of granular control is a feature, not a bug, because it prevents your engineers from over-engineering the shuffle partitions.&lt;/p&gt;

&lt;p&gt;However, if I am working in healthcare or finance where complex window functions, high-volume streaming, and rigorous data quality constraints are non-negotiable, I am picking Databricks DLT. &lt;/p&gt;

&lt;p&gt;The caveat: You must accept that DLT requires a "Platform Engineer" on staff. You cannot treat DLT as a managed service that requires zero intervention. You will spend time monitoring cluster health, tuning storage-to-compute ratios, and debugging the occasional Spark driver OOM (Out of Memory) error. &lt;/p&gt;

&lt;p&gt;If you choose Snowflake and your data volume hits the petabyte scale, you will eventually hit the wall of "warehouse compute limits" and move to DLT anyway. Choose Snowflake for the simplicity, but keep your DLT migration plan in a drawer. You’ll need it sooner than you think.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Cover photo by &lt;a href="https://unsplash.com/@liam_1?utm_source=articles_pipeline&amp;amp;utm_medium=referral" rel="noopener noreferrer"&gt;Liam Briese&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>databricks</category>
      <category>engineering</category>
      <category>data</category>
    </item>
    <item>
      <title>Stop Choosing Between Delta and Iceberg: UniForm is the Pragmatic Exit</title>
      <dc:creator>Aniket Abhishek Soni</dc:creator>
      <pubDate>Thu, 23 Jul 2026 23:36:28 +0000</pubDate>
      <link>https://dev.to/aniketsoni/stop-choosing-between-delta-and-iceberg-uniform-is-the-pragmatic-exit-4i5k</link>
      <guid>https://dev.to/aniketsoni/stop-choosing-between-delta-and-iceberg-uniform-is-the-pragmatic-exit-4i5k</guid>
      <description>&lt;p&gt;It was 3:00 AM on a Tuesday when the PagerDuty alert for our primary billing pipeline hit. We had a job failing on a Spark 3.5 cluster because our upstream vendor decided to switch their export format to Iceberg, while our entire analytical stack was locked into Delta Lake 3.0. The migration cost us six hours of downtime and roughly $45,000 in SLA penalties. The issue wasn’t the data quality; it was a religious war between two metadata layers that refused to speak the same language. &lt;/p&gt;

&lt;p&gt;We treat table formats like sports teams, but they’re just protocols. You don’t need to pledge allegiance to Databricks or the Apache Software Foundation. You need your data to be readable by the specific query engine that actually does the job, whether that’s Trino, DuckDB, or Spark.&lt;/p&gt;

&lt;h2&gt;
  
  
  The illusion of engine-agnosticism
&lt;/h2&gt;

&lt;p&gt;Most engineers assume that because they use an open format, they are portable. That’s a lie. If you write a Delta table, you are tethered to the Delta Standalone reader or a Spark implementation that supports the Delta protocol. If you use Iceberg, you’re at the mercy of the Iceberg Catalog and its specific manifest file structures. &lt;/p&gt;

&lt;p&gt;The thing we rarely look at is the metadata root. In Delta, it’s a directory of JSON logs (&lt;code&gt;_delta_log/&lt;/code&gt;). In Iceberg, it’s a snapshot-based tree structure starting from a metadata file that points to manifest lists. They are fundamentally different ways of tracking state, yet they both aim to solve the same problem: atomic ACID transactions on top of a pile of Parquet files.&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-1743359275182-60aa80a093f1%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3w5NzU0MjJ8MHwxfHNlYXJjaHwxN3x8cnVzdGVkJTIwaW5kdXN0cmlhbCUyMGdlYXJzfGVufDB8MHx8fDE3ODQ4MzUzOTJ8MA%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-1743359275182-60aa80a093f1%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3w5NzU0MjJ8MHwxfHNlYXJjaHwxN3x8cnVzdGVkJTIwaW5kdXN0cmlhbCUyMGdlYXJzfGVufDB8MHx8fDE3ODQ4MzUzOTJ8MA%26ixlib%3Drb-4.1.0%26q%3D80%26w%3D1080" alt="Photo by Paolo Chiabrando on Unsplash" width="1080" height="810"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Photo by &lt;a href="https://unsplash.com/@chiabra?utm_source=articles_pipeline&amp;amp;utm_medium=referral" rel="noopener noreferrer"&gt;Paolo Chiabrando&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;
  
  
  How it actually works
&lt;/h2&gt;

&lt;p&gt;UniForm (Universal Format) is the bridge. Specifically, Delta Lake UniForm allows you to write in Delta format while the engine automatically generates the Iceberg metadata in the background. &lt;/p&gt;

&lt;p&gt;When you enable &lt;code&gt;delta.universalFormat.enabledIceberg&lt;/code&gt; in your table properties, you aren't just tagging metadata; you are triggering an asynchronous background process that translates the Delta log into Iceberg manifests.&lt;/p&gt;

&lt;p&gt;Here is what that looks like in a Spark session:&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;my_production_table&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.enabledIceberg'&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'true'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="s1"&gt;'delta.iceberg.catalogName'&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'my_hive_catalog'&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Behind the scenes, Delta is essentially running a translation layer. Every time a commit happens in the &lt;code&gt;_delta_log/&lt;/code&gt;, a background Spark job (or the writer itself, depending on configuration) maps those file additions and removals into the Iceberg snapshot format. &lt;/p&gt;

&lt;p&gt;For the end user, this is magic. You point your Trino or Starburst cluster at the same S3 prefix you use for Spark, and Trino sees a perfectly valid Iceberg table. You aren't duplicating the data files—the Parquet files remain identical. You are only duplicating the metadata pointers.&lt;/p&gt;

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

&lt;p&gt;If this sounds too good to be true, it’s because it involves operational "hidden" costs. &lt;/p&gt;

&lt;p&gt;First, the background translation job is not free. If you are doing high-frequency streaming writes (e.g., every 30 seconds), the overhead of keeping the Iceberg metadata in sync can cause write latency spikes. I’ve seen commit latencies jump from 200ms to over 2 seconds because the cluster had to lock and update both the Delta log and the Iceberg snapshot history.&lt;/p&gt;

&lt;p&gt;Second, version skew is a real failure mode. If your Delta version is 3.2, but the Iceberg translation logic is lagging behind in the current library version, you might end up in a state where the table is readable, but "time travel" queries fail. I once debugged a case where &lt;code&gt;SELECT * FROM table AS OF VERSION AS OF '2026-05-01'&lt;/code&gt; worked in Spark but returned a &lt;code&gt;TableNotSupported&lt;/code&gt; error in Trino because the Iceberg manifest was missing the specific partition evolution metadata that Delta had handled natively.&lt;/p&gt;

&lt;p&gt;Finally, you are doubling your storage metadata footprint. In a multi-petabyte environment, the &lt;code&gt;_delta_log&lt;/code&gt; and the &lt;code&gt;metadata/&lt;/code&gt; directory for Iceberg will grow to millions of files. If your object store has high latency on LIST operations, your catalog discovery will eventually become the bottleneck, not the data retrieval itself.&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-1516101922849-2bf0be616449%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3w5NzU0MjJ8MHwxfHNlYXJjaHwyN3x8Y2xlYW4lMjBnbG93aW5nJTIwY29kZXxlbnwwfDB8fHwxNzg0ODM1MzkzfDA%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-1516101922849-2bf0be616449%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3w5NzU0MjJ8MHwxfHNlYXJjaHwyN3x8Y2xlYW4lMjBnbG93aW5nJTIwY29kZXxlbnwwfDB8fHwxNzg0ODM1MzkzfDA%26ixlib%3Drb-4.1.0%26q%3D80%26w%3D1080" alt="Photo by Pankaj Patel on Unsplash" width="1080" height="675"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Photo by &lt;a href="https://unsplash.com/@pankajpatel?utm_source=articles_pipeline&amp;amp;utm_medium=referral" rel="noopener noreferrer"&gt;Pankaj Patel&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;Use UniForm if your organization has a split-brain architecture. If you have a legacy Spark-based heavy processing pipeline but your analysts insist on using Trino or Snowflake for ad-hoc exploration, this is the only way to avoid the maintenance nightmare of double-writing data.&lt;/p&gt;

&lt;p&gt;Do not use UniForm if your primary goal is "future proofing" without a clear current need. If your entire stack is already Spark-native, UniForm is just adding complexity and potential points of failure. Stick to pure Delta. If your stack is fully integrated with Iceberg-native tools like Tabular or Nessie, don’t introduce Delta just to "bridge" things.&lt;/p&gt;

&lt;p&gt;The decision comes down to your query engine requirements. If your data must survive a lift-and-shift from a proprietary Databricks environment to a self-managed Trino cluster, UniForm is a lifesaver. If you are a startup with a single query engine, you’re just paying for extra compute cycles to translate metadata that nobody is reading.&lt;/p&gt;

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

&lt;p&gt;We are moving into an era where format-lock is becoming a legacy burden. By using Delta as your primary writer and UniForm to project Iceberg metadata, you get the robust ecosystem support of Delta and the engine interoperability of Iceberg. &lt;/p&gt;

&lt;p&gt;Stop worrying about which company’s "standard" wins the market. Focus on the metadata translation layer that keeps your data accessible. The goal isn't to pick a side; the goal is to ensure that when your primary query engine goes down, you can pivot to another one without having to rewrite your entire data lake. In 2026, the only real technical debt is a siloed format.&lt;/p&gt;




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

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

</description>
      <category>data</category>
      <category>engineering</category>
      <category>delta</category>
      <category>iceberg</category>
    </item>
  </channel>
</rss>
