<?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>Stop relying on hope: why data contracts need CI enforcement</title>
      <dc:creator>Aniket Abhishek Soni</dc:creator>
      <pubDate>Wed, 14 Oct 2026 02:05:01 +0000</pubDate>
      <link>https://dev.to/aniketsoni/stop-relying-on-hope-why-data-contracts-need-ci-enforcement-1gci</link>
      <guid>https://dev.to/aniketsoni/stop-relying-on-hope-why-data-contracts-need-ci-enforcement-1gci</guid>
      <description>&lt;p&gt;You ship the job. It passes CI. Your dbt tests show green. You push to production, grab a coffee, and feel like a competent engineer. Then, at 3:00 AM, the downstream dashboard team wakes up to a PagerDuty alert because your service decided that &lt;code&gt;user_id&lt;/code&gt; should now be a string instead of an integer. The schema registry was updated, but nobody read the Slack announcement, and your pipeline just broke three critical financial reporting models.&lt;/p&gt;

&lt;p&gt;This is the state of most data engineering teams today. We call it "coordination," but it’s really just a polite term for guessing what the other team did. Data contracts aren't just fancy YAML files; they are the only thing standing between you and a weekend of debugging JSON parsing errors.&lt;/p&gt;

&lt;p&gt;The real question isn't whether you need contracts, but how you enforce them. You have two real paths: you either integrate the contract into your CI/CD pipeline using schema-as-code, or you rely on runtime checks that catch the fire after the house is already burning down.&lt;/p&gt;

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

&lt;p&gt;On one side, you have &lt;strong&gt;Schema Registry-based enforcement&lt;/strong&gt; (e.g., Confluent/Avro or AWS Glue Schema Registry). This approach treats the contract as a gatekeeper at the ingestion layer. If the producer doesn't match the registry, the message is dropped or rejected at the broker level.&lt;/p&gt;

&lt;p&gt;On the other, you have &lt;strong&gt;CI-level contract testing&lt;/strong&gt; (e.g., Great Expectations, dbt-checkpoint, or custom JSON Schema validation in GitHub Actions). This approach forces the producer to validate their data against a versioned contract &lt;em&gt;before&lt;/em&gt; the code is even allowed to merge. You catch the breaking change at the PR stage, not in the Kafka topic.&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-1643461394487-7e2856b215b8%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3w5NzU0MjJ8MHwxfHNlYXJjaHw2fHxicm9rZW4lMjBnbGFzc3xlbnwwfDB8fHwxNzkxOTMwNzk1fDA%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-1643461394487-7e2856b215b8%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3w5NzU0MjJ8MHwxfHNlYXJjaHw2fHxicm9rZW4lMjBnbGFzc3xlbnwwfDB8fHwxNzkxOTMwNzk1fDA%26ixlib%3Drb-4.1.0%26q%3D80%26w%3D1080" alt="Photo by Eyasu Etsub on Unsplash" width="1080" height="720"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Photo by &lt;a href="https://unsplash.com/@jphotography2012?utm_source=articles_pipeline&amp;amp;utm_medium=referral" rel="noopener noreferrer"&gt;Eyasu Etsub&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 failure
&lt;/h2&gt;

&lt;p&gt;If you use a Schema Registry, your failure mode is "data loss." A producer updates their service, the registry rejects the message because of a minor type mismatch, and your data is effectively incinerated at the source. In a healthcare context, this is a HIPAA nightmare—you are failing to capture data that might be clinically significant.&lt;/p&gt;

&lt;p&gt;With CI-level enforcement, the failure mode is "developer friction." The CI build fails, the PR stays blocked, and the developer has to fix the contract or the code before they can ship. It’s annoying, but it’s predictable. I’d take a blocked PR over a production outage any day.&lt;/p&gt;

&lt;p&gt;In terms of cost, Schema Registries incur a per-message latency tax. If you’re high-frequency trading or doing real-time IoT ingestion, that millisecond overhead adds up. CI checks are "free" in terms of production overhead because they execute during development. You pay in compute minutes on GitHub Actions or GitLab CI, but those costs are negligible compared to the time spent on manual schema reconciliation.&lt;/p&gt;
&lt;h2&gt;
  
  
  Operational burden and complexity
&lt;/h2&gt;

&lt;p&gt;Schema Registries are a massive operational lift. You are now managing a stateful service. If your registry goes down or experiences a partition, your entire ingestion pipeline stalls. I’ve spent countless hours debugging Kafka producer configurations where the registry URL was misconfigured in the environment variables, leading to weird &lt;code&gt;SerializationException&lt;/code&gt; errors that are nearly impossible to trace in production logs.&lt;/p&gt;

&lt;p&gt;CI-level enforcement is significantly cleaner because it’s stateless. You define a contract in a &lt;code&gt;contracts/&lt;/code&gt; directory in your repository. In your &lt;code&gt;.github/workflows/main.yml&lt;/code&gt;, you run:&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="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 Contract&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;npm install -g ajv-cli&lt;/span&gt;
    &lt;span class="s"&gt;ajv validate -s contracts/user-event-v1.json -d data/sample-payload.json&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the JSON doesn't match, the build fails. No state, no registry, no infrastructure to patch. You are just using the same tools you use for linting code. It’s portable, it’s version-controlled, and it’s transparent.&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-1743090661056-e51700546169%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3w5NzU0MjJ8MHwxfHNlYXJjaHw5fHx0ZXJtaW5hbCUyMGNvZGUlMjBzY3JlZW58ZW58MHwwfHx8MTc5MTkzMDc5NXww%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-1743090661056-e51700546169%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3w5NzU0MjJ8MHwxfHNlYXJjaHw5fHx0ZXJtaW5hbCUyMGNvZGUlMjBzY3JlZW58ZW58MHwwfHx8MTc5MTkzMDc5NXww%26ixlib%3Drb-4.1.0%26q%3D80%26w%3D1080" alt="Photo by ANOOF C on Unsplash" width="1080" height="720"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Photo by &lt;a href="https://unsplash.com/@anoofc?utm_source=articles_pipeline&amp;amp;utm_medium=referral" rel="noopener noreferrer"&gt;ANOOF C&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 "Version 2.0" trap
&lt;/h2&gt;

&lt;p&gt;The biggest issue with the Schema Registry approach is the "version bump" dance. You have to ensure that every downstream consumer is ready to handle the new schema version, or you have to support backward compatibility forever. The Schema Registry forces you into a specific evolution strategy—usually AVRO's &lt;code&gt;BACKWARD_TRANSITIVE&lt;/code&gt; compatibility.&lt;/p&gt;

&lt;p&gt;When you enforce contracts in CI, you can implement custom logic that the registry can't handle. For example, you can write a script that scans your downstream dbt models to see if a field is actually being used before allowing a breaking change.&lt;/p&gt;

&lt;p&gt;I once worked on a team where we used a simple Python script in our CI pipeline to check for field deletions. If a field was marked &lt;code&gt;deprecated&lt;/code&gt; in the contract, we’d allow the removal. If it wasn't, the CI build would fail with a link to the downstream data warehouse impact report. This level of context-aware enforcement is impossible if you’re just relying on a Kafka registry.&lt;/p&gt;

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

&lt;p&gt;If you’re building a greenfield platform today, skip the heavy-weight Schema Registries unless you are working in a highly regulated, high-throughput environment where the broker &lt;em&gt;must&lt;/em&gt; be the source of truth. For 90% of data teams, CI-based contract enforcement is the superior choice.&lt;/p&gt;

&lt;p&gt;My recommendation: use &lt;strong&gt;JSON Schema or Protobuf definitions stored in the same repository as the producer code&lt;/strong&gt;. Add a CI step that validates the sample payloads against these schemas. Use a tool like &lt;code&gt;dbt-checkpoint&lt;/code&gt; if you’re deep in the warehouse stack.&lt;/p&gt;

&lt;p&gt;The caveat? This requires discipline. If your team doesn't maintain the &lt;code&gt;contracts/&lt;/code&gt; directory, the system decays immediately. You have to treat the contract like production code. If a test fails, you don't "force push" to bypass it; you update the contract and notify the downstream teams. &lt;/p&gt;

&lt;p&gt;Treating contracts as a shared library is the only way to scale. When you move the enforcement point to the PR, you turn "data quality" from a frantic, reactive firefighting exercise into a boring, routine part of the development lifecycle. And boring is exactly what you want when you’re managing mission-critical data.&lt;/p&gt;

&lt;p&gt;Don’t wait for the schema registry to tell you that you’ve broken the world. Catch it when you commit. Your on-call self will thank you.&lt;/p&gt;

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

</description>
      <category>data</category>
      <category>engineering</category>
      <category>devops</category>
      <category>pipelines</category>
    </item>
    <item>
      <title>How I stopped cloning datasets for Vertex AI pipelines</title>
      <dc:creator>Aniket Abhishek Soni</dc:creator>
      <pubDate>Mon, 12 Oct 2026 15:22:25 +0000</pubDate>
      <link>https://dev.to/aniketsoni/how-i-stopped-cloning-datasets-for-vertex-ai-pipelines-304m</link>
      <guid>https://dev.to/aniketsoni/how-i-stopped-cloning-datasets-for-vertex-ai-pipelines-304m</guid>
      <description>&lt;p&gt;You ship the job. It passes CI. Then, the SRE team pings you on Slack because your pipeline just swallowed 400GB of storage in an hour, and your cost projection for the quarter looks like a down payment on a house. You’re duplicating data again.&lt;/p&gt;

&lt;p&gt;Most of us treat the Lakehouse as a write-once repository. We ETL, we transform, and then we export the "features" into a brand-new table or GCS bucket so Vertex AI can ingest them. It’s a habit born of convenience, but it’s a death sentence for your budget and your data consistency. In regulated environments like healthcare or finance, having two versions of a truth—one in your production warehouse and one in your training set—is a compliance nightmare waiting to happen.&lt;/p&gt;

&lt;p&gt;The secret is simple: stop moving the data. If your features live in BigQuery or a Delta Lake, let them stay there. Use BigQuery as your source of truth and connect Vertex AI Pipelines directly to your raw or processed tables.&lt;/p&gt;

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

&lt;p&gt;The goal is to treat your existing warehouse as the feature store. We do this by leveraging BigQuery DataFrames (BQF) or direct SQL execution within the Kubeflow Pipelines (KFP) components. &lt;/p&gt;

&lt;p&gt;Instead of writing a Spark job to dump features into a CSV, you write a KFP component that executes a &lt;code&gt;CREATE OR REPLACE TABLE&lt;/code&gt; or, better yet, a &lt;code&gt;CREATE VIEW&lt;/code&gt; that points directly to your refined features.&lt;/p&gt;

&lt;p&gt;Here is what the pattern looks like in a standard &lt;code&gt;kfp.dsl.component&lt;/code&gt; using the Google Cloud AI Platform SDK:&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;kfp.dsl&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;component&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Input&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Output&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Dataset&lt;/span&gt;

&lt;span class="nd"&gt;@component&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;base_image&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;python:3.9&lt;/span&gt;&lt;span class="sh"&gt;"&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;train_model_op&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;project_id&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;query&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="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;bigquery&lt;/span&gt;

    &lt;span class="c1"&gt;# Do not materialize. Query the existing lakehouse table directly.
&lt;/span&gt;    &lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;bigquery&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Client&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;project&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;project_id&lt;/span&gt;&lt;span class="p"&gt;)&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;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;)&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;job&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;to_dataframe&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="c1"&gt;# Proceed to training
&lt;/span&gt;    &lt;span class="c1"&gt;# ...
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The real magic happens when you move from &lt;code&gt;to_dataframe()&lt;/code&gt; to BigQuery ML (BQML) or Vertex AI’s &lt;code&gt;BigqueryTabularDataset&lt;/code&gt;. By using &lt;code&gt;aiplatform.TabularDataset.create(display_name=..., bq_source=...)&lt;/code&gt;, you create a pointer. You are not creating a copy; you are creating a URI that tells Vertex AI: "Go look at this BigQuery table when you need to train."&lt;/p&gt;

&lt;p&gt;When your pipeline runs, it doesn't wait for a data export job to finish. It simply validates that the schema exists. If you are using BigQuery Omni, this even works across clouds. Your pipeline triggers, reads the metadata, and maps the features into the training container. Your cost stays centered on compute, not storage.&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-1616401970421-d7533dbc1300%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3w5NzU0MjJ8MHwxfHNlYXJjaHwyNnx8c2hhdHRlcmVkJTIwZ2xhc3N8ZW58MHwwfHx8MTc5MTc1NTA5NXww%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-1616401970421-d7533dbc1300%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3w5NzU0MjJ8MHwxfHNlYXJjaHwyNnx8c2hhdHRlcmVkJTIwZ2xhc3N8ZW58MHwwfHx8MTc5MTc1NTA5NXww%26ixlib%3Drb-4.1.0%26q%3D80%26w%3D1080" alt="Photo by Wilhelm Gunkel on Unsplash" width="1080" height="720"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Photo by &lt;a href="https://unsplash.com/@wilhelmgunkel?utm_source=articles_pipeline&amp;amp;utm_medium=referral" rel="noopener noreferrer"&gt;Wilhelm Gunkel&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;If this were a silver bullet, everyone would do it. But there are jagged edges here that vendors love to gloss over.&lt;/p&gt;

&lt;p&gt;First, performance is non-deterministic. If your feature engineering logic involves complex joins across massive partition-pruned tables, running that query during the training loop means you’re fighting the warehouse for concurrency slots. If a DBA runs a heavy &lt;code&gt;ANALYZE&lt;/code&gt; command while your pipeline is trying to materialize features, your training job will hang. I’ve seen 4-hour training windows stretch to 12 hours because of resource contention in the underlying warehouse.&lt;/p&gt;

&lt;p&gt;Second, the "Data Snapshot" problem. If you don't materialize, you are training on live data. This is usually what we want, but it’s dangerous. If your upstream pipeline updates a feature table mid-run, you’ve just introduced non-deterministic bias into your model. You need to implement strict partitioning—if you aren't using &lt;code&gt;_PARTITIONTIME&lt;/code&gt; or a specific &lt;code&gt;run_id&lt;/code&gt; as a partition key in your BigQuery tables, you are effectively training on a moving target. &lt;/p&gt;

&lt;p&gt;Third, the Vertex AI SDK versioning is... volatile. If you are using &lt;code&gt;google-cloud-aiplatform&amp;gt;=1.30.0&lt;/code&gt;, the way it handles &lt;code&gt;bq_source&lt;/code&gt; in the &lt;code&gt;Dataset&lt;/code&gt; resource is different from older versions. I’ve spent entire afternoons debugging an &lt;code&gt;InvalidArgument&lt;/code&gt; error that turned out to be a deprecation of a specific BigQuery metadata mapping in the SDK. Always pin your SDK versions in your &lt;code&gt;requirements.txt&lt;/code&gt; inside your container image. Do not rely on "latest".&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-1617696795782-cedb140e2f0b%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3w5NzU0MjJ8MHwxfHNlYXJjaHwyOHx8ZGF0YSUyMHBpcGVsaW5lJTIwZGlhZ3JhbXxlbnwwfDB8fHwxNzkxNzU1MDk2fDA%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-1617696795782-cedb140e2f0b%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3w5NzU0MjJ8MHwxfHNlYXJjaHwyOHx8ZGF0YSUyMHBpcGVsaW5lJTIwZGlhZ3JhbXxlbnwwfDB8fHwxNzkxNzU1MDk2fDA%26ixlib%3Drb-4.1.0%26q%3D80%26w%3D1080" alt="Photo by Markus Spiske on Unsplash" width="1080" height="720"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Photo by &lt;a href="https://unsplash.com/@markusspiske?utm_source=articles_pipeline&amp;amp;utm_medium=referral" rel="noopener noreferrer"&gt;Markus Spiske&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 the "no-duplication" approach when your feature engineering is idempotent and your warehouse is the primary source of truth. If your BigQuery setup is already using dbt (data build tool) to manage dependencies, your Vertex AI pipeline should simply be an orchestrator that triggers a model build after the &lt;code&gt;dbt run&lt;/code&gt; completes. This keeps the lineage clean and ensures you aren't reinventing the wheel.&lt;/p&gt;

&lt;p&gt;Don't reach for this when you need extremely low-latency feature retrieval. If you are serving a model that requires sub-millisecond lookups for features, the overhead of querying a lakehouse—even with BigQuery’s caching—is too high. For that, you need a dedicated feature store like Feast or the Vertex AI Feature Store. The "don't duplicate" pattern is for training/batch inference, not for real-time inference.&lt;/p&gt;

&lt;p&gt;Also, avoid this if your data scientists are still in the "exploration" phase. If they are constantly dropping columns, renaming features, and blowing up schemas, you don't want them messing with your production BigQuery tables. Let them work in a sandbox, duplicate all they want, and only transition to the "no-duplication" pipeline once the feature logic is hardened into a production dbt model.&lt;/p&gt;

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

&lt;p&gt;Data duplication is a tax on your engineering team and your cloud bill. By treating BigQuery or your Lakehouse as an API for your training pipeline rather than a storage locker, you regain control over the lineage. It forces you to be disciplined about schemas and partitioning, which is a good thing for your sanity. &lt;/p&gt;

&lt;p&gt;Ship the pipeline. Keep the data in place. Stop paying for the same bytes twice.&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>vertexai</category>
      <category>databricks</category>
      <category>bigquery</category>
      <category>machinelearning</category>
    </item>
    <item>
      <title>Is Your Text-to-SQL Agent Just a Fancy Way to Drop Your Production Tables?</title>
      <dc:creator>Aniket Abhishek Soni</dc:creator>
      <pubDate>Sat, 10 Oct 2026 04:03:00 +0000</pubDate>
      <link>https://dev.to/aniketsoni/is-your-text-to-sql-agent-just-a-fancy-way-to-drop-your-production-tables-1c7h</link>
      <guid>https://dev.to/aniketsoni/is-your-text-to-sql-agent-just-a-fancy-way-to-drop-your-production-tables-1c7h</guid>
      <description>&lt;p&gt;The most dangerous myth in the current AI hype cycle is that a RAG-based Text-to-SQL agent is "smart enough" to know the difference between a read-only reporting schema and your customer PII tables. Spoiler: it isn't, and it doesn't care.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Why I chose this topic:&lt;/strong&gt; I spent the last three months watching an LLM try to "helpfully" join our &lt;code&gt;users&lt;/code&gt; table with our &lt;code&gt;transaction_logs&lt;/code&gt; in a way that would have leaked HIPAA-regulated data if the database user permissions hadn't been tighter than a vault. We need to stop pretending that prompt engineering replaces architectural guardrails.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;If you believe that a clever system prompt and a few-shot examples are enough to put a Text-to-SQL agent in front of your business analysts, you are one hallucinated &lt;code&gt;JOIN&lt;/code&gt; away from a compliance nightmare. I’ve seen teams ship agents that, when asked "How many users do we have?", helpfully executed a &lt;code&gt;SELECT *&lt;/code&gt; on the entire metadata catalog, nearly crashing the warehouse in the process.&lt;/p&gt;

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

&lt;p&gt;Most tutorials on this topic focus on the "magic" of getting the SQL right. They talk about LangChain agents, vectorizing schema definitions, and tweaking temperature settings. This is the wrong layer of the stack.&lt;/p&gt;

&lt;p&gt;The common failure mode is treating the LLM as an authorized user. If your agent connects to your Snowflake or Databricks cluster using a service account with &lt;code&gt;SELECT&lt;/code&gt; access to the entire &lt;code&gt;RAW&lt;/code&gt; schema, you have effectively turned your LLM into a high-speed injection attack vector. &lt;/p&gt;

&lt;p&gt;When you define a table context for an LLM—even with sophisticated RAG—you are essentially giving it a map of your house. If you don't lock the doors to the rooms that hold the sensitive data, the LLM will eventually walk into them because the user asked it to "find all info related to customer X." &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-1620092802344-83ac90398e7c%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3w5NzU0MjJ8MHwxfHNlYXJjaHwyN3x8dmF1bHQlMjBsb2NrJTIwbWVjaGFuaXNtfGVufDB8MHx8fDE3OTE1ODUwODB8MA%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-1620092802344-83ac90398e7c%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3w5NzU0MjJ8MHwxfHNlYXJjaHwyN3x8dmF1bHQlMjBsb2NrJTIwbWVjaGFuaXNtfGVufDB8MHx8fDE3OTE1ODUwODB8MA%26ixlib%3Drb-4.1.0%26q%3D80%26w%3D1080" alt="Photo by REGINE THOLEN on Unsplash" width="1080" height="720"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Photo by &lt;a href="https://unsplash.com/@designbytholen?utm_source=articles_pipeline&amp;amp;utm_medium=referral" rel="noopener noreferrer"&gt;REGINE THOLEN&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 view-based gatekeeper pattern
&lt;/h2&gt;

&lt;p&gt;Never point your agent at base tables. Ever. If you are doing &lt;code&gt;SELECT * FROM production.users&lt;/code&gt;, stop. You need a dedicated "AI-Consumption" schema.&lt;/p&gt;

&lt;p&gt;Create a specific layer of views that exist solely for the agent. In Snowflake, I use a combination of &lt;code&gt;ROW_ACCESS_POLICY&lt;/code&gt; and &lt;code&gt;SECURE VIEW&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- The only view the LLM sees&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;OR&lt;/span&gt; &lt;span class="k"&gt;REPLACE&lt;/span&gt; &lt;span class="n"&gt;SECURE&lt;/span&gt; &lt;span class="k"&gt;VIEW&lt;/span&gt; &lt;span class="n"&gt;ai_reporting&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;daily_transactions&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; 
    &lt;span class="n"&gt;transaction_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; 
    &lt;span class="n"&gt;transaction_amount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; 
    &lt;span class="n"&gt;transaction_date&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; 
    &lt;span class="n"&gt;category&lt;/span&gt; 
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;prod&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;transactions&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;transaction_date&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;DATEADD&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;month&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;CURRENT_DATE&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;By forcing the agent to query only this view, you achieve two things: you limit the scope of available columns to only what is necessary, and you apply a hard temporal filter. If the LLM tries to query &lt;code&gt;customer_ssn&lt;/code&gt;, it literally can't find it. The schema doesn't exist to it. Do not rely on the LLM to "ignore" columns; rely on the database engine to "not provide" them.&lt;/p&gt;

&lt;h2&gt;
  
  
  The circuit breaker at the middleware layer
&lt;/h2&gt;

&lt;p&gt;Even with restricted views, LLMs are prone to "runaway queries." An agent might decide that the best way to answer "What is the average transaction value?" is to pull 50 million rows into memory because it doesn't understand the performance implications of a table scan on an unclustered column.&lt;/p&gt;

&lt;p&gt;You need a hard circuit breaker between the agent and the database. I use a custom wrapper in Python that intercepts the generated SQL before it hits the &lt;code&gt;execute()&lt;/code&gt; method.&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;validate_sql&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sql_query&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="c1"&gt;# Prohibit dangerous keywords
&lt;/span&gt;    &lt;span class="n"&gt;forbidden&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;DROP&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;TRUNCATE&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;ALTER&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;GRANT&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;DELETE&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;any&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;keyword&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;sql_query&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;upper&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;keyword&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;forbidden&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;PermissionError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Illegal operation detected.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="c1"&gt;# Force a limit if one isn't present
&lt;/span&gt;    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;LIMIT&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;sql_query&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;upper&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;sql_query&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt; LIMIT 100&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;sql_query&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Is this a hack? Yes. Does it prevent a junior engineer's LLM agent from nuking a production table? Absolutely. In a production environment, I also inject a &lt;code&gt;QUERY_TAG&lt;/code&gt; into the SQL comment block so I can track the agent’s behavior in the warehouse query history: &lt;code&gt;/* AGENT_ID: finance_bot_v2 */&lt;/code&gt;. If I see a spike in compute costs, I know exactly which agent is acting up.&lt;/p&gt;

&lt;h2&gt;
  
  
  The semantic proxy layer
&lt;/h2&gt;

&lt;p&gt;If you are using something like LlamaIndex or LangChain's &lt;code&gt;SQLDatabaseChain&lt;/code&gt;, you are likely passing the entire DDL to the prompt. This is a recipe for context window bloat and hallucination. &lt;/p&gt;

&lt;p&gt;Instead, build a "Semantic Proxy." Create a YAML or JSON manifest that describes your data in human terms, but keep the actual column names abstracted.&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;# semantic_manifest.yaml&lt;/span&gt;
&lt;span class="na"&gt;tables&lt;/span&gt;&lt;span class="pi"&gt;:&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;tx_summary&lt;/span&gt;
    &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Contains&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;aggregated&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;transaction&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;data&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;for&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;the&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;last&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;30&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;days."&lt;/span&gt;
    &lt;span class="na"&gt;columns&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;alias&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;total_revenue"&lt;/span&gt;
        &lt;span class="na"&gt;column_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;amount_usd"&lt;/span&gt;
        &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;numeric"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The LLM queries against &lt;code&gt;total_revenue&lt;/code&gt;, and your proxy translates that to &lt;code&gt;amount_usd&lt;/code&gt; before hitting the database. This decouples your internal schema changes from your agent’s capabilities. When you rename &lt;code&gt;amount_usd&lt;/code&gt; to &lt;code&gt;gross_revenue_base&lt;/code&gt;, you update the manifest, not the prompt. The agent doesn't even know the underlying column name changed.&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-1558459654-c430be5b0a44%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3w5NzU0MjJ8MHwxfHNlYXJjaHwyN3x8c3FsJTIwY29kZSUyMG9uJTIwbW9uaXRvcnxlbnwwfDB8fHwxNzkxNTg1MDgwfDA%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-1558459654-c430be5b0a44%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3w5NzU0MjJ8MHwxfHNlYXJjaHwyN3x8c3FsJTIwY29kZSUyMG9uJTIwbW9uaXRvcnxlbnwwfDB8fHwxNzkxNTg1MDgwfDA%26ixlib%3Drb-4.1.0%26q%3D80%26w%3D1080" alt="Photo by Markus Spiske on Unsplash" width="1080" height="720"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Photo by &lt;a href="https://unsplash.com/@markusspiske?utm_source=articles_pipeline&amp;amp;utm_medium=referral" rel="noopener noreferrer"&gt;Markus Spiske&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;&lt;em&gt;Objection: "But this limits the power of the LLM. If I restrict the schema, the agent can't answer complex cross-functional questions."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;My answer: That is a feature, not a bug. In financial services, "cross-functional" usually means "compliance violation." If you need to join HR data with marketing data, that should be a curated data product, not an ad-hoc query generated by a probabilistic model. If the answer isn't in the governed view, the agent should return "I don't have access to that information" rather than trying to guess a join path.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Objection: "Isn't the cost of maintaining these views and manifest files too high? Why not just use a semantic layer like dbt or Cube?"&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;My answer: You should absolutely use dbt or Cube. If you’re already using them, you’re halfway there. But those tools are for human analysts. An LLM doesn't have the context of why a table was built. You still need an explicit, LLM-optimized subset of those models. Don't dump your entire dbt &lt;code&gt;dbt_prod&lt;/code&gt; schema into an LLM context window and call it a day.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Objection: "What if the LLM generates syntactically correct but logically wrong SQL (e.g., summing a column that shouldn't be summed)?"&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;My answer: This is the hardest problem to solve. My solution is the "Human-in-the-loop" validation for non-idempotent queries. If the query is just a &lt;code&gt;SELECT&lt;/code&gt;, let it rip. If the agent generates a query that implies a complex aggregation on a sensitive metric, I force the agent to output the SQL to a UI where a human has to click "Approve" before it runs. If you aren't doing this for business-critical reporting, you're playing Russian Roulette with your KPIs.&lt;/p&gt;

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

&lt;p&gt;Text-to-SQL is only viable if you treat the LLM as an untrusted user. Stop trying to make the LLM "smarter" and start making your database "dumber"—by which I mean, simpler, constrained, and incapable of executing anything outside of a predefined, safe sandbox. &lt;/p&gt;

&lt;p&gt;If you aren't ready to build the middleware, the proxy, and the view layer, you aren't ready to deploy. Keep the LLM in the playground until you’ve built the fences.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Tags:&lt;/strong&gt; #sql #llm #governance #data&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Cover photo by &lt;a href="https://unsplash.com/@brice_cooper18?utm_source=articles_pipeline&amp;amp;utm_medium=referral" rel="noopener noreferrer"&gt;Brice Cooper&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>governance</category>
      <category>data</category>
    </item>
    <item>
      <title>Death to the Hive Metastore: Why Iceberg REST Catalogs Are Your New Reality</title>
      <dc:creator>Aniket Abhishek Soni</dc:creator>
      <pubDate>Thu, 08 Oct 2026 12:24:29 +0000</pubDate>
      <link>https://dev.to/aniketsoni/death-to-the-hive-metastore-why-iceberg-rest-catalogs-are-your-new-reality-o37</link>
      <guid>https://dev.to/aniketsoni/death-to-the-hive-metastore-why-iceberg-rest-catalogs-are-your-new-reality-o37</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Why I chose this topic:&lt;/strong&gt; Three years ago, I spent an entire weekend debugging a corrupted Hive Metastore (HMS) that locked up our daily reconciliation pipeline. I'm writing this because the industry is finally moving toward decoupled, REST-based cataloging, and I want you to avoid the migration headaches I dealt with by learning the specific tradeoffs of the big three providers.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Two years ago, your data stack likely relied on a Hive Metastore (HMS) sitting behind a Thrift server. You know the pain: the "Thrift connection reset" errors at 3 AM, the sheer agony of schema mismatches, and the desperate need to perform &lt;code&gt;MSCK REPAIR TABLE&lt;/code&gt; every time a bucket was touched. It was a brittle, monolithic bottleneck that made multi-engine access feel like a high-stakes gamble.&lt;/p&gt;

&lt;p&gt;Today, life looks different. With the Apache Iceberg REST specification (OpenAPI), the "catalog" is no longer a database you babysit; it's an API layer that mediates between your compute engines (Spark, Trino, Flink) and your storage (S3, GCS, ADLS). You point your engine at a URL, provide an OAuth token, and the metadata just works. No more manual partition scanning. No more proprietary lock-in at the catalog layer.&lt;/p&gt;

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

&lt;p&gt;The industry calls this "decoupling," but let's be honest: it’s about avoiding the "I can't believe this engine just corrupted my data" conversation with your VP of Engineering.&lt;/p&gt;

&lt;p&gt;The real problem with traditional metastores isn't just the protocol; it's the lack of fine-grained access control and the inability to handle concurrent writes safely. When you move to an Iceberg REST catalog, you aren't just changing a URL. You are moving to a world where the catalog defines the "source of truth" via atomic commits. If your catalog backend doesn't support the full Iceberg REST spec (version 1.0.0 and above), you’re just putting lipstick on a pig.&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-1556075798-4825dfaaf498%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3w5NzU0MjJ8MHwxfHNlYXJjaHwxMHx8ZGF0YWJhc2UlMjBzY2hlbWElMjBkaWFncmFtfGVufDB8MHx8fDE3OTE0MTM5MDh8MA%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-1556075798-4825dfaaf498%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3w5NzU0MjJ8MHwxfHNlYXJjaHwxMHx8ZGF0YWJhc2UlMjBzY2hlbWElMjBkaWFncmFtfGVufDB8MHx8fDE3OTE0MTM5MDh8MA%26ixlib%3Drb-4.1.0%26q%3D80%26w%3D1080" alt="Photo by Yancy Min on Unsplash" width="1080" height="716"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Photo by &lt;a href="https://unsplash.com/@yancymin?utm_source=articles_pipeline&amp;amp;utm_medium=referral" rel="noopener noreferrer"&gt;Yancy Min&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: Deploying Apache Polaris for the pure-play approach
&lt;/h2&gt;

&lt;p&gt;If you want to own your infrastructure and avoid vendor lock-in, Apache Polaris is the new gold standard. It’s built by Snowflake, but it’s open-source and specifically designed for the Iceberg REST spec. &lt;/p&gt;

&lt;p&gt;When you deploy Polaris, you’re running a Java-based service that handles authorization and metadata management. You don’t need a backing RDBMS if you don’t want one—you can configure it to use local storage or cloud object stores. Here is the minimal configuration to get a Polaris service running in a containerized environment:&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;# polaris-config.yaml&lt;/span&gt;
&lt;span class="na"&gt;server&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;default&lt;/span&gt;
  &lt;span class="na"&gt;applicationConnectors&lt;/span&gt;&lt;span class="pi"&gt;:&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;http&lt;/span&gt;
      &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;8181&lt;/span&gt;
  &lt;span class="na"&gt;adminConnectors&lt;/span&gt;&lt;span class="pi"&gt;:&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;http&lt;/span&gt;
      &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;8182&lt;/span&gt;

&lt;span class="na"&gt;storage&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;s3&lt;/span&gt;
  &lt;span class="na"&gt;bucket&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;my-iceberg-data-bucket&lt;/span&gt;
  &lt;span class="na"&gt;region&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;us-east-1&lt;/span&gt;

&lt;span class="na"&gt;auth&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;oauth2&lt;/span&gt;
  &lt;span class="na"&gt;issuer&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;https://my-auth-provider.com/&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The key here is the &lt;code&gt;auth&lt;/code&gt; section. Polaris forces you to handle security properly. If you aren't using an OIDC provider, don't even bother starting. Polaris expects a token in the &lt;code&gt;Authorization&lt;/code&gt; header, and it will reject any write request that isn't signed correctly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step: Leveraging Unity Catalog for the "it just works" path
&lt;/h2&gt;

&lt;p&gt;If you are already in the Databricks ecosystem, Unity Catalog (UC) is the pragmatic choice. Unity Catalog has evolved to support the Iceberg REST spec, allowing you to use non-Databricks engines (like Trino or Starburst) against the same tables you manage in Databricks.&lt;/p&gt;

&lt;p&gt;This is the "Unified Governance" play. You define your catalog and schema in the Unity Catalog UI, then pass the connection details to your external engine.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Example Spark configuration for connecting to Unity Catalog as an Iceberg REST provider&lt;/span&gt;
spark.sql.catalog.uc_iceberg &lt;span class="o"&gt;=&lt;/span&gt; org.apache.iceberg.spark.SparkCatalog
spark.sql.catalog.uc_iceberg.catalog-impl &lt;span class="o"&gt;=&lt;/span&gt; org.apache.iceberg.rest.RESTCatalog
spark.sql.catalog.uc_iceberg.uri &lt;span class="o"&gt;=&lt;/span&gt; https://&amp;lt;databricks-workspace-url&amp;gt;/api/2.0/unity-catalog/iceberg
spark.sql.catalog.uc_iceberg.token &lt;span class="o"&gt;=&lt;/span&gt; &amp;lt;your-databricks-pat&amp;gt;
spark.sql.catalog.uc_iceberg.warehouse &lt;span class="o"&gt;=&lt;/span&gt; &amp;lt;catalog-name&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The failure mode here is usually the token expiration. Databricks Personal Access Tokens (PATs) have a shelf life. If you’re running production pipelines, swap this for a Service Principal. If your pipeline suddenly stops writing, 9 times out of 10, the token rotated and your CI/CD environment variables didn't update.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step: AWS Glue as the managed REST catalog
&lt;/h2&gt;

&lt;p&gt;AWS Glue is the "lazy" choice, and I mean that in the best way possible. If your infrastructure is 100% AWS, using Glue as your Iceberg REST catalog backend eliminates the need to manage any servers. You get the benefit of IAM integration for free.&lt;/p&gt;

&lt;p&gt;Glue now has a native Iceberg REST endpoint. Instead of the old &lt;code&gt;GlueCatalog&lt;/code&gt; implementation that relied on the Hive-compatible &lt;code&gt;GetTable&lt;/code&gt; API, you use the REST-based endpoint.&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;# Glue connection setup via Boto3/Environment
&lt;/span&gt;&lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ICEBERG_REST_URI&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://glue.us-east-1.amazonaws.com&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ICEBERG_CATALOG_TYPE&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;rest&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;AWS_REGION&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;us-east-1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

&lt;span class="c1"&gt;# In your Spark session:
&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;conf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;spark.sql.catalog.glue_catalog&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;org.apache.iceberg.spark.SparkCatalog&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;spark&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;conf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;spark.sql.catalog.glue_catalog.catalog-impl&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;org.apache.iceberg.rest.RESTCatalog&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;spark&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;conf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;spark.sql.catalog.glue_catalog.uri&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ICEBERG_REST_URI&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;Warning: AWS Glue has hard limits on request rates. If you have a massive Flink job performing thousands of small commits per minute, you will hit the &lt;code&gt;ThrottlingException&lt;/code&gt; wall. You’ll need to request a quota increase from AWS support before you go live, or you'll spend your first production day watching your pipelines crash.&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-1773349807434-374473797148%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3w5NzU0MjJ8MHwxfHNlYXJjaHwyM3x8bWluaW1hbGlzdCUyMHRlcm1pbmFsJTIwc2NyZWVufGVufDB8MHx8fDE3OTE0MTM5MDh8MA%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-1773349807434-374473797148%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3w5NzU0MjJ8MHwxfHNlYXJjaHwyM3x8bWluaW1hbGlzdCUyMHRlcm1pbmFsJTIwc2NyZWVufGVufDB8MHx8fDE3OTE0MTM5MDh8MA%26ixlib%3Drb-4.1.0%26q%3D80%26w%3D1080" alt="Photo by Harshit Katiyar on Unsplash" width="1080" height="720"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Photo by &lt;a href="https://unsplash.com/@harshitkatiyar?utm_source=articles_pipeline&amp;amp;utm_medium=referral" rel="noopener noreferrer"&gt;Harshit Katiyar&lt;/a&gt; on &lt;a href="https://unsplash.com/?utm_source=articles_pipeline&amp;amp;utm_medium=referral" rel="noopener noreferrer"&gt;Unsplash&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The "Namespace" trap:&lt;/strong&gt; Iceberg REST catalogs handle namespaces differently than HMS. If you’re migrating, don't assume &lt;code&gt;my_db.my_table&lt;/code&gt; in Hive will map 1:1 without testing the catalog-to-storage path. Use &lt;code&gt;CALL system.migrate('db.table')&lt;/code&gt; carefully in a staging environment first.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Metadata location:&lt;/strong&gt; Always keep your metadata files (&lt;code&gt;.metadata.json&lt;/code&gt;) in the same bucket as your data. If you split them and your catalog backend loses connection to the metadata store, you are looking at a manual recovery process that involves editing manifest files by hand. Do not recommend.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Engine compatibility:&lt;/strong&gt; Not all engines support all REST features. If you are using Trino, check the Iceberg version it supports against the specific features (like branching or tagging) you want to use. A version mismatch between the catalog's API response and the engine's client library will manifest as a cryptic &lt;code&gt;404 Not Found&lt;/code&gt; that actually means "I don't understand the JSON schema you just sent me."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cost of observability:&lt;/strong&gt; Unlike the old HMS which was just a MySQL database you could query, these REST catalogs are black boxes. You need to log the API requests. If you aren't logging the &lt;code&gt;POST /v1/catalogs/{catalog}/namespaces/{namespace}/tables&lt;/code&gt; calls, you have zero visibility into what's failing when a write job dies.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;The shift to Iceberg REST is the most significant improvement in data infrastructure since the invention of S3. We are finally moving away from the "database as a filesystem" hack that plagued the Hadoop era. &lt;/p&gt;

&lt;p&gt;Whether you go with the self-hosted flexibility of Polaris, the unified governance of Unity Catalog, or the managed simplicity of AWS Glue, pick one and move your write-heavy workloads over. Your future self, sleeping soundly at 3 AM instead of debugging metastore corruption, will thank you.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Try it:&lt;/strong&gt; Spin up a local instance of Polaris using Docker, hook it up to a local S3-compatible store like MinIO, and run a Spark job to write your first table. If you can move from a local file-based catalog to a REST-based one in under an hour, you're ready for the big leagues.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Tags:&lt;/strong&gt; #iceberg #data #architecture #backend&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Cover photo by &lt;a href="https://unsplash.com/@kirill2020?utm_source=articles_pipeline&amp;amp;utm_medium=referral" rel="noopener noreferrer"&gt;Kirill Sh&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>data</category>
      <category>backend</category>
      <category>apache</category>
    </item>
    <item>
      <title>Stop Choosing Between BigQuery and Databricks Based on Marketing Slides</title>
      <dc:creator>Aniket Abhishek Soni</dc:creator>
      <pubDate>Tue, 06 Oct 2026 16:37:14 +0000</pubDate>
      <link>https://dev.to/aniketsoni/stop-choosing-between-bigquery-and-databricks-based-on-marketing-slides-4f4i</link>
      <guid>https://dev.to/aniketsoni/stop-choosing-between-bigquery-and-databricks-based-on-marketing-slides-4f4i</guid>
      <description>&lt;p&gt;03:42 AM. The PagerDuty alert didn't wake me up—the sound of the laptop fan spinning up to jet-engine levels in my sleep-deprived brain did. &lt;/p&gt;

&lt;p&gt;The myth that "BigQuery and Databricks are both just SQL engines, so they perform the same" is the kind of professional lie we tell ourselves to avoid reading the documentation. I was looking at a dashboard that usually loads in 12 seconds, now spinning for 14 minutes before throwing a &lt;code&gt;RESOURCE_EXHAUSTED&lt;/code&gt; error. We were mid-migration, testing our core reporting layer against 4TB of Parquet files sitting on S3. &lt;/p&gt;

&lt;p&gt;The team assumed that since we were using Databricks SQL (Serverless) and BQ’s BigLake, the abstraction layer would handle the heavy lifting. It didn't. We were trying to join a massive fact table—denormalized, partitioned by day, and sitting in open-format delta tables—against a dimension table that someone had accidentally stored as a thousand tiny JSON files.&lt;/p&gt;

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

&lt;p&gt;The symptom was simple: the query scheduler in Databricks was timing out, and BigQuery was racking up a $400 bill for a single exploratory analysis. &lt;/p&gt;

&lt;p&gt;We fell for the classic trap: "The engine will figure it out." In our Databricks environment, the &lt;code&gt;SQL Warehouse&lt;/code&gt; was set to &lt;code&gt;Pro&lt;/code&gt; with &lt;code&gt;Auto-stop&lt;/code&gt; enabled. When we threw a complex window function over a non-partitioned column, the spill-to-disk metrics hit the roof. We saw &lt;code&gt;io.file.write.bytes&lt;/code&gt; spiking in the Ganglia metrics, but the throughput was abysmal. &lt;/p&gt;

&lt;p&gt;On the BigQuery side, the &lt;code&gt;INFORMATION_SCHEMA.JOBS_BY_PROJECT&lt;/code&gt; view showed us that we were scanning the entire dataset because our partitioning key (&lt;code&gt;event_date&lt;/code&gt;) was buried inside a &lt;code&gt;CASE&lt;/code&gt; statement. The engine wasn't pruning partitions because the optimizer couldn't prove the predicate was constant at planning time. We spent three hours chasing "network latency" and "cold starts," convinced the cloud provider was throttling us. They weren't. We were just writing garbage SQL that assumed the engine had infinite compute and perfect metadata.&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-1742922460481-0c953077308f%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3w5NzU0MjJ8MHwxfHNlYXJjaHwyNXx8c2hhdHRlcmVkJTIwZ2xhc3N8ZW58MHwwfHx8MTc5MTI0NDYzM3ww%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-1742922460481-0c953077308f%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3w5NzU0MjJ8MHwxfHNlYXJjaHwyNXx8c2hhdHRlcmVkJTIwZ2xhc3N8ZW58MHwwfHx8MTc5MTI0NDYzM3ww%26ixlib%3Drb-4.1.0%26q%3D80%26w%3D1080" alt="Photo by luka Bruyninckx on Unsplash" width="1080" height="720"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Photo by &lt;a href="https://unsplash.com/@luka_b?utm_source=articles_pipeline&amp;amp;utm_medium=referral" rel="noopener noreferrer"&gt;luka Bruyninckx&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 failure wasn't the engine; it was the impedance mismatch between our file layout and the compute engine’s execution model. &lt;/p&gt;

&lt;p&gt;In Databricks, we were hitting a classic Z-Order failure. Our &lt;code&gt;date&lt;/code&gt; column was the partition key, but our queries were filtering by &lt;code&gt;user_id&lt;/code&gt;. Without Z-Ordering the data on the &lt;code&gt;user_id&lt;/code&gt; column, the engine performed a full table scan of every partition to find a single user's activity. The offending code looked like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- The culprit: forcing a full scan because of the non-indexed filter&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; 
  &lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; 
  &lt;span class="k"&gt;SUM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;revenue&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;OVER&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;PARTITION&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;transaction_timestamp&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;running_total&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;delta&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;`s3://our-bucket/prod/transactions/`&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'98273-a'&lt;/span&gt; &lt;span class="c1"&gt;-- This column is not Z-Ordered&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On the BigQuery side, the issue was our reliance on &lt;code&gt;BigLake&lt;/code&gt;. We had configured the table to point to S3, but we hadn't defined the metadata cache properly. BigQuery was spending more time listing files in the S3 bucket via the storage API than actually executing the sum. It was a "Metadata Bottleneck." Every time we queried, BigQuery was hitting the S3 &lt;code&gt;ListObjectsV2&lt;/code&gt; API thousands of times. The cost wasn't just the compute; it was the overhead of the cloud provider’s storage interface being treated like a local SSD.&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-1618044733300-9472054094ee%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3w5NzU0MjJ8MHwxfHNlYXJjaHwzfHxmaW5hbmNpYWwlMjBzcHJlYWRzaGVldHxlbnwwfDB8fHwxNzkxMjQ0NjM0fDA%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-1618044733300-9472054094ee%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3w5NzU0MjJ8MHwxfHNlYXJjaHwzfHxmaW5hbmNpYWwlMjBzcHJlYWRzaGVldHxlbnwwfDB8fHwxNzkxMjQ0NjM0fDA%26ixlib%3Drb-4.1.0%26q%3D80%26w%3D1080" alt="Photo by Markus Spiske on Unsplash" width="1080" height="720"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Photo by &lt;a href="https://unsplash.com/@markusspiske?utm_source=articles_pipeline&amp;amp;utm_medium=referral" rel="noopener noreferrer"&gt;Markus Spiske&lt;/a&gt; on &lt;a href="https://unsplash.com/?utm_source=articles_pipeline&amp;amp;utm_medium=referral" rel="noopener noreferrer"&gt;Unsplash&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

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

&lt;p&gt;We stopped trying to make the engine "do it all" and started managing the physical data layout. &lt;/p&gt;

&lt;p&gt;For Databricks, we ran an &lt;code&gt;OPTIMIZE transactions ZORDER BY (user_id)&lt;/code&gt; command. This isn't a silver bullet—it's a maintenance job that costs money—but it immediately reduced the amount of data read by 90%. We moved the &lt;code&gt;user_id&lt;/code&gt; to a clustered index equivalent within the Delta Lake storage layer. &lt;/p&gt;

&lt;p&gt;For the BigQuery side, we forced the issue by enabling &lt;code&gt;metadata_cache_mode = 'AUTOMATIC'&lt;/code&gt; and setting the &lt;code&gt;metadata_cache_ttl&lt;/code&gt; to 60 minutes. We also had to switch from raw S3 URIs to a managed BigQuery external table definition that explicitly defined the partition layout. &lt;/p&gt;

&lt;p&gt;The change in performance was binary. The query dropped from 14 minutes to 4 seconds. The cost dropped from $400 to $0.12. We weren't fighting a platform limitation; we were fighting our own laziness regarding metadata management.&lt;/p&gt;

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

&lt;p&gt;We implemented a strict "No Raw File Access" policy. If a data engineer wants to query raw S3 files, they use a staging layer. If it’s for production, it must be a managed table with defined partitioning and Z-Ordering.&lt;/p&gt;

&lt;p&gt;We also introduced a cost-budgeting script that triggers a Slack alert if any single query exceeds $10 in BigQuery or 30 minutes of compute time in Databricks. We treat these alerts as P1 incidents. No "I'll optimize it later." If it hits the limit, it gets killed, and the developer has to explain the &lt;code&gt;EXPLAIN&lt;/code&gt; plan in the pull request.&lt;/p&gt;

&lt;p&gt;The biggest lesson? Don't trust the marketing slides that say "BigQuery is serverless" or "Databricks handles all the tuning." Both platforms are incredibly powerful, but they both expect you to know how they handle physical storage. &lt;/p&gt;

&lt;p&gt;If you treat a cloud data warehouse like a local Postgres instance, you will get billed for your ignorance. If you treat it like a distributed system, it’s the most powerful tool you have. Stop writing SQL for a laptop and start writing SQL for the cluster. If you don't know where your data lives and how it's partitioned, the engine is just going to scan the entire internet and charge you for the privilege.&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>data</category>
      <category>bigquery</category>
      <category>databricks</category>
      <category>engineering</category>
    </item>
    <item>
      <title>Why Are You Treating Data Drift Like It’s Not A System Crash?</title>
      <dc:creator>Aniket Abhishek Soni</dc:creator>
      <pubDate>Sat, 03 Oct 2026 20:58:06 +0000</pubDate>
      <link>https://dev.to/aniketsoni/why-are-you-treating-data-drift-like-its-not-a-system-crash-229e</link>
      <guid>https://dev.to/aniketsoni/why-are-you-treating-data-drift-like-its-not-a-system-crash-229e</guid>
      <description>&lt;p&gt;Data drift isn't a "model quality issue"—it’s a production outage, and if your team treats it as a polite ticket for the data science squad, you’re failing.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Why I chose this topic:&lt;/strong&gt; I’ve watched too many senior engineers spend hours debugging a pipeline failure in Airflow, only to realize later that the data was flowing perfectly but the model was making absolute garbage predictions. We need to stop pretending these are separate domains.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;It was 3:14 AM on a Tuesday. PagerDuty didn’t just buzz; it screamed. My phone vibrated against the nightstand with an alert from our primary inference service: &lt;code&gt;5xx Error Rate &amp;gt; 5%&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;I cracked open the laptop. The Grafana dashboard was a sea of red. Our Kubernetes cluster was throwing &lt;code&gt;CrashLoopBackOff&lt;/code&gt; errors on the &lt;code&gt;inference-api&lt;/code&gt; pods. My first instinct, forged by years of fighting legacy ETL, was to check the pipeline. I logged into Airflow. The DAG &lt;code&gt;daily_feature_ingestion&lt;/code&gt; had completed successfully at 02:00 UTC. The Snowflake load logs were clean.&lt;/p&gt;

&lt;p&gt;I shifted to the model server logs. &lt;code&gt;K8s&lt;/code&gt; pods were OOM-killing. I checked the memory limits: &lt;code&gt;memory: 4Gi&lt;/code&gt;. We had scaled up recently, so why now? I assumed it was a sudden spike in traffic, so I bumped the resource limits to &lt;code&gt;8Gi&lt;/code&gt;, redeployed via our Helm chart, and watched the pods restart. They stabilized for ten minutes, then slammed into the ceiling again.&lt;/p&gt;

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

&lt;p&gt;The symptoms were deceptive. The metrics showed a massive increase in payload size coming from the feature store. I looked at the feature store audit logs. Everything looked normal—the ingestion job was pulling the same number of rows as it did the day before. &lt;/p&gt;

&lt;p&gt;The false lead was the traffic volume. We assumed a DDOS or a sudden spike in user activity. We spent forty minutes looking at WAF logs, blocking IP ranges, and blaming our upstream marketing API. We were chasing ghosts. Meanwhile, the models were consuming 8GB of RAM just to parse the incoming request objects.&lt;/p&gt;

&lt;p&gt;The P99 latency had gone from 120ms to 4.2s. It wasn't the &lt;em&gt;number&lt;/em&gt; of requests; it was the &lt;em&gt;shape&lt;/em&gt; of the data inside them.&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-1767044202331-c3a0a7cbdf43%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3w5NzU0MjJ8MHwxfHNlYXJjaHwxOHx8c2hhdHRlcmVkJTIwZ2xhc3N8ZW58MHwwfHx8MTc5MTA2MTAxNHww%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-1767044202331-c3a0a7cbdf43%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3w5NzU0MjJ8MHwxfHNlYXJjaHwxOHx8c2hhdHRlcmVkJTIwZ2xhc3N8ZW58MHwwfHx8MTc5MTA2MTAxNHww%26ixlib%3Drb-4.1.0%26q%3D80%26w%3D1080" alt="Photo by Leftfield Corn on Unsplash" width="1080" height="608"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Photo by &lt;a href="https://unsplash.com/@leftfield_corn?utm_source=articles_pipeline&amp;amp;utm_medium=referral" rel="noopener noreferrer"&gt;Leftfield Corn&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;We weren't dealing with a software bug; we were dealing with a silent upstream schema evolution that triggered a massive drift in the input distribution.&lt;/p&gt;

&lt;p&gt;A backend engineer had updated the &lt;code&gt;user_profile&lt;/code&gt; service. They added a &lt;code&gt;nested_json&lt;/code&gt; field called &lt;code&gt;experimental_behavior_log&lt;/code&gt; to the payload. Our feature store, which used a generic &lt;code&gt;JSONB&lt;/code&gt; column in Postgres, happily accepted the ingestion. The feature engineering script—a Frankenstein monster of Pandas code—saw this new, massive JSON blob and decided to flatten it into two thousand individual columns because of a naive &lt;code&gt;pd.json_normalize()&lt;/code&gt; call.&lt;/p&gt;

&lt;p&gt;The model, expecting 42 features, was suddenly receiving 2,042. The input vector was exploding in memory. The feature store wasn't failing; it was doing exactly what we told it to do. The pipeline wasn't failing; it was succeeding at moving garbage. The model didn't throw an error; it just tried to process a dataframe that grew exponentially with every request.&lt;/p&gt;

&lt;p&gt;The offending code in our &lt;code&gt;feature_transformation.py&lt;/code&gt;:&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 culprit
&lt;/span&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;extract_features&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;raw_data&lt;/span&gt;&lt;span class="p"&gt;):&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;pd&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;DataFrame&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;raw_data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="c1"&gt;# This was the death knell
&lt;/span&gt;    &lt;span class="n"&gt;features&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pd&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json_normalize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;payload&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;features&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;We had no schema enforcement. We were treating "data quality" as a post-hoc analysis task rather than a runtime requirement.&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-1561909004-59643c31381b%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3w5NzU0MjJ8MHwxfHNlYXJjaHwyOXx8Y2xvY2slMjBmYWNlfGVufDB8MHx8fDE3OTEwNjEwMTV8MA%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-1561909004-59643c31381b%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3w5NzU0MjJ8MHwxfHNlYXJjaHwyOXx8Y2xvY2slMjBmYWNlfGVufDB8MHx8fDE3OTEwNjEwMTV8MA%26ixlib%3Drb-4.1.0%26q%3D80%26w%3D1080" alt="Photo by Concha Mayo on Unsplash" width="1080" height="720"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Photo by &lt;a href="https://unsplash.com/@conchamayo?utm_source=articles_pipeline&amp;amp;utm_medium=referral" rel="noopener noreferrer"&gt;Concha Mayo&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;I didn't have time for a clean architectural rewrite at 4:00 AM. I needed to stop the bleeding.&lt;/p&gt;

&lt;p&gt;First, I implemented a hard constraint on the feature input using a Pydantic model at the entry point of the transformation service. If the schema didn't match the expected fields, the pipeline now throws a &lt;code&gt;ValidationError&lt;/code&gt; and alerts immediately. &lt;/p&gt;

&lt;p&gt;Second, I added a resource-constrained check on the feature vector size. If the resulting dataframe width exceeds a predefined threshold (in our case, 150 columns), the process kills itself and logs the schema diff.&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 emergency patch
&lt;/span&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;FeatureSchema&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;user_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;
    &lt;span class="n"&gt;session_count&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;
    &lt;span class="c1"&gt;# Explicitly define what we allow
&lt;/span&gt;    &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Config&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;extra&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;forbid&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt; 

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;transform&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;validated_data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;FeatureSchema&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="c1"&gt;# ... proceed with transformation
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I redeployed the service with these constraints. The pods stopped crashing because the pipeline now rejected the bloated payloads at the gate. The downstream model was saved from processing malformed data, and the &lt;code&gt;5xx&lt;/code&gt; errors vanished.&lt;/p&gt;

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

&lt;p&gt;We stopped treating "Pipeline Monitoring" (Is the job running?) and "Model Monitoring" (Is the prediction valid?) as two different jobs.&lt;/p&gt;

&lt;p&gt;We moved to a unified observability stack. We now use Great Expectations integrated directly into our Airflow DAGs, but with a twist: the expectations are checked &lt;em&gt;at the source&lt;/em&gt; and the &lt;em&gt;model input&lt;/em&gt;. If the data distribution—specifically the feature count—drifts beyond a 2-sigma threshold, the pipeline marks the task as &lt;code&gt;FAILED&lt;/code&gt; before the model ever sees it. &lt;/p&gt;

&lt;p&gt;We retired the "let it flow and see" mentality. We treat data schema as code. If an upstream service changes their JSON structure, our CI/CD pipeline now fails during the integration test phase because our &lt;code&gt;schema.json&lt;/code&gt; definitions are pinned and validated.&lt;/p&gt;

&lt;p&gt;We also implemented "Circuit Breakers" in our inference API. If the input data shape deviates from the historical distribution cached in Redis, the API returns a &lt;code&gt;422 Unprocessable Entity&lt;/code&gt; instead of attempting to process the inference. This prevents the OOM-kill cycle entirely.&lt;/p&gt;

&lt;p&gt;If you aren't failing your pipeline when your data distribution changes, you aren't doing observability. You’re just doing logging. And logging is just a way to look back at the wreckage once you’ve already crashed. Fix the schema, enforce the boundaries, and stop pretending that data drift is a "data science" problem. It’s an infrastructure problem. Treat it like one.&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>observability</category>
      <category>data</category>
      <category>monitoring</category>
      <category>engineering</category>
    </item>
    <item>
      <title>Cutting AWS Storage Costs: S3 Tables vs. S3 Express One Zone</title>
      <dc:creator>Aniket Abhishek Soni</dc:creator>
      <pubDate>Thu, 01 Oct 2026 23:13:26 +0000</pubDate>
      <link>https://dev.to/aniketsoni/cutting-aws-storage-costs-s3-tables-vs-s3-express-one-zone-1j0h</link>
      <guid>https://dev.to/aniketsoni/cutting-aws-storage-costs-s3-tables-vs-s3-express-one-zone-1j0h</guid>
      <description>&lt;p&gt;Last year, my team was fighting the "small file problem" in a standard S3 bucket. We were running massive Glue jobs that spent 40% of their execution time just doing &lt;code&gt;LIST&lt;/code&gt; requests to aggregate millions of tiny Parquet files. We had &lt;code&gt;s3:ListBucket&lt;/code&gt; throttled, our EMR clusters were idling during metadata scanning, and our monthly storage bill looked like a cry for help.&lt;/p&gt;

&lt;p&gt;Today, we’ve shifted our hot-path workloads to S3 Express One Zone and our long-term cataloged tables to S3 Tables. The result isn't just "better performance." We went from 30-minute metadata listing times to sub-second responses. More importantly, we stopped paying for the privilege of our own inefficiency.&lt;/p&gt;

&lt;p&gt;Most engineers think of S3 as a simple key-value store. It’s not. It’s a distributed system that behaves fundamentally differently depending on the storage class you pick. If you treat S3 Express One Zone like a standard Standard bucket, you’re setting fire to your AWS credits.&lt;/p&gt;

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

&lt;p&gt;To understand why S3 Tables and S3 Express One Zone change the game, you have to look at the metadata tax. In standard S3, &lt;code&gt;LIST&lt;/code&gt; operations are expensive and eventually consistent. When you have a table with 50 million files, your query engine (Trino, Spark, or Athena) has to crawl that bucket. That’s a network-heavy, latency-riddled nightmare.&lt;/p&gt;

&lt;p&gt;S3 Express One Zone (the &lt;code&gt;S3EX&lt;/code&gt; storage class) changes the architecture by co-locating compute and storage within a single AZ. It uses a custom directory-bucket structure. Instead of the flat namespace of standard S3, you are using a specialized API that provides single-digit millisecond latency. &lt;/p&gt;

&lt;p&gt;The code change is trivial but massive in impact. You move from the standard &lt;code&gt;s3://&lt;/code&gt; URI to &lt;code&gt;s3express://&lt;/code&gt; (or use the bucket name with the &lt;code&gt;--onezone&lt;/code&gt; suffix). &lt;/p&gt;

&lt;p&gt;When you configure your Spark session, you aren't just changing a prefix. You are bypassing the standard S3 global endpoint.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight scala"&gt;&lt;code&gt;&lt;span class="c1"&gt;// The old way: slow, throttled, expensive&lt;/span&gt;
&lt;span class="k"&gt;val&lt;/span&gt; &lt;span class="nv"&gt;df&lt;/span&gt; &lt;span class="k"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;spark&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="py"&gt;read&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="py"&gt;parquet&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"s3a://my-bucket/data/logs/"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;// The new way: S3 Express One Zone&lt;/span&gt;
&lt;span class="k"&gt;val&lt;/span&gt; &lt;span class="nv"&gt;df&lt;/span&gt; &lt;span class="k"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;spark&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="py"&gt;read&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="py"&gt;parquet&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"s3express://my-bucket--use1-az1--x-s3/data/logs/"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;S3 Tables, on the other hand, is the management layer. It’s essentially a managed Apache Iceberg engine built directly into S3. It abstracts the file management. You no longer worry about file compaction jobs because S3 Tables handles the vacuuming and snapshot management for you. You interact with it via the &lt;code&gt;s3tables&lt;/code&gt; API, and it handles the underlying metadata manifest files. You effectively trade control for reduced operational toil.&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-1773053525982-8a9a3195f2c0%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3w5NzU0MjJ8MHwxfHNlYXJjaHwxM3x8YWJzdHJhY3QlMjBkYXRhJTIwZmxvd3xlbnwwfDB8fHwxNzkwODk0MDY0fDA%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-1773053525982-8a9a3195f2c0%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3w5NzU0MjJ8MHwxfHNlYXJjaHwxM3x8YWJzdHJhY3QlMjBkYXRhJTIwZmxvd3xlbnwwfDB8fHwxNzkwODk0MDY0fDA%26ixlib%3Drb-4.1.0%26q%3D80%26w%3D1080" alt="Photo by 灿雄 邱 on Unsplash" width="1080" height="782"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Photo by &lt;a href="https://unsplash.com/@cader_?utm_source=articles_pipeline&amp;amp;utm_medium=referral" rel="noopener noreferrer"&gt;灿雄 邱&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: AWS doesn't give you these performance gains for free. There is a "Single Zone" tax, and it’s not just about durability.&lt;/p&gt;

&lt;p&gt;With S3 Express One Zone, you lose regional redundancy. If your chosen AZ goes dark, your data is unavailable. In healthcare or financial services, that’s a non-starter for PII or critical audit logs unless you have an automated cross-region replication strategy in place—which, by the way, eats up all the cost savings you just gained.&lt;/p&gt;

&lt;p&gt;Then there is the API cost. S3 Express One Zone charges per request, but those costs are bundled differently. If you are doing infrequent, massive sequential reads, standard S3 is still cheaper. If you are doing high-frequency, small-file random access (think real-time features engineering or feature stores), S3 Express One Zone is significantly cheaper because you aren't paying the overhead of the standard S3 request tax for millions of tiny metadata operations.&lt;/p&gt;

&lt;p&gt;S3 Tables has its own headache: lock-in. Once you start using the S3 Tables managed format, moving that data out to a different provider or an on-prem cluster is significantly harder than moving standard Parquet files. You are tied to the AWS-managed Iceberg implementation. If you love open-source portability, this is a bitter pill to swallow.&lt;/p&gt;

&lt;p&gt;Also, be warned about the "One Zone" prefix. If you are running your EMR or Glue jobs in &lt;code&gt;us-east-1a&lt;/code&gt; and your S3 Express bucket is in &lt;code&gt;us-east-1b&lt;/code&gt;, you are going to eat data transfer costs that will destroy your budget. You must ensure your compute and your &lt;code&gt;S3EX&lt;/code&gt; bucket are pinned to the exact same AZ. If you don't have a strict infrastructure-as-code (IaC) policy enforcing this, your cloud bill will fluctuate wildly based on which AZ your spot instances happen to land in.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fimages.unsplash.com%2Fphoto-1639042878611-db29e06620b7%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3w5NzU0MjJ8MHwxfHNlYXJjaHwxNHx8ZGlnaXRhbCUyMHdhcmVob3VzZXxlbnwwfDB8fHwxNzkwODk0MDY0fDA%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-1639042878611-db29e06620b7%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3w5NzU0MjJ8MHwxfHNlYXJjaHwxNHx8ZGlnaXRhbCUyMHdhcmVob3VzZXxlbnwwfDB8fHwxNzkwODk0MDY0fDA%26ixlib%3Drb-4.1.0%26q%3D80%26w%3D1080" alt="Photo by Agil Saputro on Unsplash" width="1080" height="744"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Photo by &lt;a href="https://unsplash.com/@agil_efpee?utm_source=articles_pipeline&amp;amp;utm_medium=referral" rel="noopener noreferrer"&gt;Agil Saputro&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 S3 Express One Zone when your bottleneck is IOPS, not throughput. If your Spark jobs are stuck in "waiting for metadata" or "listing directory," this is the fix. It’s perfect for training machine learning models where you need to stream millions of small image files or feature vectors into a GPU cluster. &lt;/p&gt;

&lt;p&gt;Do not use it for your primary "cold" data lake. Your quarterly historical reports don't need sub-millisecond access. Keeping those in &lt;code&gt;S3EX&lt;/code&gt; is just bad engineering. Keep your cold data in &lt;code&gt;S3 Standard&lt;/code&gt; or &lt;code&gt;S3 Intelligent-Tiering&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Reach for S3 Tables when your team is drowning in Iceberg maintenance. If you find yourself writing custom scripts to run &lt;code&gt;VACUUM&lt;/code&gt; and &lt;code&gt;OPTIMIZE&lt;/code&gt; on your tables, S3 Tables is your escape hatch. It turns the "data engineer as a janitor" role into something more productive.&lt;/p&gt;

&lt;p&gt;However, avoid S3 Tables if you require strict, fine-grained control over your partition evolution or if you have complex custom manifest requirements that the AWS managed service doesn't support yet. If you have a highly customized, non-standard Iceberg implementation, the migration process will be a weekend-ruining experience.&lt;/p&gt;

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

&lt;p&gt;The era of just "dumping everything in a bucket" is over. We’ve been using S3 as a giant, undifferentiated blob store for a decade, and we’re paying for it with massive latency and wasted compute cycles. &lt;/p&gt;

&lt;p&gt;By separating our storage into tiered access—S3 Tables for the managed, queryable lakehouse and S3 Express One Zone for the high-frequency IO path—we’ve actually started to see our storage costs move in the right direction. It requires more thoughtful architecture and a stricter handle on your AZ affinity, but that’s the job. Stop treating your data lake like a digital landfill and start treating it like a distributed database. Your infrastructure costs will thank you.&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>aws</category>
      <category>s3</category>
      <category>data</category>
      <category>engineering</category>
    </item>
    <item>
      <title>Spark Structured Streaming Can’t Save You From Bad Architecture</title>
      <dc:creator>Aniket Abhishek Soni</dc:creator>
      <pubDate>Wed, 30 Sep 2026 03:06:08 +0000</pubDate>
      <link>https://dev.to/aniketsoni/spark-structured-streaming-cant-save-you-from-bad-architecture-173k</link>
      <guid>https://dev.to/aniketsoni/spark-structured-streaming-cant-save-you-from-bad-architecture-173k</guid>
      <description>&lt;p&gt;Roughly 80% of the "exactly-once" guarantees I see implemented in production financial pipelines are actually "at-least-once" disguised by a fragile post-processing cleanup script. We obsess over the Spark checkpointing mechanism while ignoring the fact that our sink is a leaky bucket.&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 "exactly-once" messes that caused multi-million dollar reconciliation failures in healthcare billing systems. If you don't understand the physical limitations of your sink, your streaming job is just a very expensive way to generate duplicate records.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Most of my peers argue that Spark’s &lt;code&gt;checkpointLocation&lt;/code&gt; is the gold standard for fault tolerance. They treat it like a holy relic. The reality is that Spark’s exactly-once guarantee is strictly confined to the boundary between the source offset management and the state store. Once your data hits an external database or a file system that doesn't participate in a distributed transaction with Spark, your "exactly-once" guarantee is effectively vaporware.&lt;/p&gt;

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

&lt;p&gt;The industry standard is to use &lt;code&gt;foreachBatch&lt;/code&gt; to sink data into a relational database or a cloud object store. Developers assume that because Spark retries the batch upon failure, they are protected against duplicates. This is a dangerous fallacy.&lt;/p&gt;

&lt;p&gt;If your Spark executor writes 10,000 rows to a Postgres table and crashes at row 9,999, the driver will restart the task. If you aren’t using idempotent writes—where the sink itself knows how to deduplicate based on a business key—you now have a partial write. If your sink logic involves an &lt;code&gt;INSERT&lt;/code&gt; statement without an &lt;code&gt;ON CONFLICT DO UPDATE&lt;/code&gt; or a similar upsert mechanism, you have just injected garbage into your downstream analytical layer.&lt;/p&gt;

&lt;p&gt;Take a look at a common, broken pattern I see in Spark 3.3.x:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight scala"&gt;&lt;code&gt;&lt;span class="nv"&gt;streamingDF&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="py"&gt;writeStream&lt;/span&gt;
  &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="py"&gt;foreachBatch&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;batchDF&lt;/span&gt;&lt;span class="k"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;DataFrame&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;batchId&lt;/span&gt;&lt;span class="k"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Long&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt;
    &lt;span class="nv"&gt;batchDF&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="py"&gt;write&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="py"&gt;format&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"jdbc"&lt;/span&gt;&lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="py"&gt;mode&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"append"&lt;/span&gt;&lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="py"&gt;save&lt;/span&gt;&lt;span class="o"&gt;(...)&lt;/span&gt;
  &lt;span class="o"&gt;}&lt;/span&gt;
  &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="py"&gt;start&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This code is a lie. If the &lt;code&gt;save()&lt;/code&gt; operation fails midway, Spark reruns the batch. If the database processed 5,000 rows before the crash, those 5,000 rows remain. The retry will attempt to insert all 10,000 rows again. You have just doubled your data. Unless your schema has a strict unique constraint and you’ve configured your JDBC driver to handle the resulting exception, you’ve corrupted your downstream data.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fimages.unsplash.com%2Fphoto-1508061501437-e1b984a4d665%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3w5NzU0MjJ8MHwxfHNlYXJjaHwxNHx8c2hhdHRlcmVkJTIwZ2xhc3N8ZW58MHwwfHx8MTc5MDcxOTY4MHww%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-1508061501437-e1b984a4d665%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3w5NzU0MjJ8MHwxfHNlYXJjaHwxNHx8c2hhdHRlcmVkJTIwZ2xhc3N8ZW58MHwwfHx8MTc5MDcxOTY4MHww%26ixlib%3Drb-4.1.0%26q%3D80%26w%3D1080" alt="Photo by CHUTTERSNAP on Unsplash" width="1080" height="721"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Photo by &lt;a href="https://unsplash.com/@chuttersnap?utm_source=articles_pipeline&amp;amp;utm_medium=referral" rel="noopener noreferrer"&gt;CHUTTERSNAP&lt;/a&gt; on &lt;a href="https://unsplash.com/?utm_source=articles_pipeline&amp;amp;utm_medium=referral" rel="noopener noreferrer"&gt;Unsplash&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The illusion of checkpointing
&lt;/h2&gt;

&lt;p&gt;Spark’s checkpointing is brilliant for maintaining the state of your streaming query. It stores the metadata of processed offsets in HDFS or S3. But people confuse "I know which offsets I’ve processed" with "I have committed the data to the sink."&lt;/p&gt;

&lt;p&gt;When a worker node dies, Spark rolls back the state to the last successful offset recorded in the checkpoint directory. It then re-executes the batch. This is where the physical reality of your sink takes over. If you are writing to S3 using the default file sink, Spark uses a commit protocol that involves renaming temporary files. If that rename operation isn't atomic—and on many S3-compatible object stores, it isn't—you are left with "ghost" files that weren't cleaned up by the failed task but aren't technically part of the final manifest.&lt;/p&gt;

&lt;p&gt;In my experience, relying on &lt;code&gt;checkpointLocation&lt;/code&gt; without a high-performance, atomic storage layer is like trying to build a skyscraper on a swamp. You aren't doing exactly-once; you are doing "at-least-once plus a prayer that the infrastructure handles the retry gracefully."&lt;/p&gt;

&lt;h2&gt;
  
  
  The sink is the bottleneck
&lt;/h2&gt;

&lt;p&gt;True exactly-once in a streaming system requires a two-phase commit protocol or an idempotent sink. Spark Structured Streaming implements this only when both the source and the sink support it natively. For example, if you are reading from Kafka and writing to Kafka, Spark can participate in the transaction.&lt;/p&gt;

&lt;p&gt;But when you write to a sink like Delta Lake, you are relying on the Delta protocol to handle the atomicity. Delta Lake is excellent—it uses a transaction log to ensure that a batch is either fully committed or not at all. If you are using Delta Lake 2.0+, the &lt;code&gt;writeStream&lt;/code&gt; API will correctly ignore duplicate batches because it checks the &lt;code&gt;batchId&lt;/code&gt; against the transaction log before committing.&lt;/p&gt;

&lt;p&gt;The problem arises when engineers try to shoehorn this into non-transactional sinks. I’ve seen teams try to implement "exactly-once" against Redis or a legacy REST API. They write custom logic inside &lt;code&gt;foreachBatch&lt;/code&gt; to track &lt;code&gt;batchId&lt;/code&gt; in a side-table. This is a recipe for a race condition. If your side-table update and your sink write aren't in the same transaction, you are fundamentally broken. You’ve just shifted the failure mode from "duplicate data" to "stale state."&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-1633451238208-11c8e6c1fed4%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3w5NzU0MjJ8MHwxfHNlYXJjaHw5fHxjbG9jayUyMGdlYXJzfGVufDB8MHx8fDE3OTA3MTk2ODF8MA%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-1633451238208-11c8e6c1fed4%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3w5NzU0MjJ8MHwxfHNlYXJjaHw5fHxjbG9jayUyMGdlYXJzfGVufDB8MHx8fDE3OTA3MTk2ODF8MA%26ixlib%3Drb-4.1.0%26q%3D80%26w%3D1080" alt="Photo by Lukas Tennie on Unsplash" width="1080" height="715"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Photo by &lt;a href="https://unsplash.com/@luk10?utm_source=articles_pipeline&amp;amp;utm_medium=referral" rel="noopener noreferrer"&gt;Lukas Tennie&lt;/a&gt; on &lt;a href="https://unsplash.com/?utm_source=articles_pipeline&amp;amp;utm_medium=referral" rel="noopener noreferrer"&gt;Unsplash&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

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

&lt;p&gt;"But the documentation says Spark guarantees exactly-once!"&lt;/p&gt;

&lt;p&gt;The documentation says Spark guarantees exactly-once &lt;em&gt;processing semantics&lt;/em&gt;. That is a technical term of art, not a business guarantee. It means that the &lt;em&gt;state&lt;/em&gt; of the Spark job will be consistent with the &lt;em&gt;offsets&lt;/em&gt; of the source. It explicitly avoids making promises about the side effects of your code. If your side effect is "inserting a row into a database," Spark isn't responsible for that database's internal state.&lt;/p&gt;

&lt;p&gt;"Can't I just use a deduplication buffer?"&lt;/p&gt;

&lt;p&gt;Sure, you can use &lt;code&gt;dropDuplicates()&lt;/code&gt; on your streaming DataFrame. But &lt;code&gt;dropDuplicates()&lt;/code&gt; is stateful. It has to keep a history of keys in memory or in the state store. If your window is large or your data volume is high, you will OOM (Out of Memory) your executors or blow up your RocksDB state store. You’re trading a duplication problem for a scaling problem.&lt;/p&gt;

&lt;p&gt;"What about the newer Spark versions with better fault tolerance?"&lt;/p&gt;

&lt;p&gt;Spark 3.5+ has improved the way it handles state store snapshots and recovery, but the sink problem remains. No matter how much you optimize the Spark engine, if you point it at a sink that doesn't support transactions, you are living in a world of at-least-once. The only way to achieve truly robust pipelines is to embrace idempotent sinks and design your schema so that re-processing the same batch is a no-op, not a disaster.&lt;/p&gt;

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

&lt;p&gt;Stop chasing the ghost of "exactly-once" in your application code. You cannot force a non-transactional system to become transactional just by wrapping it in a Spark job.&lt;/p&gt;

&lt;p&gt;Instead, build for idempotency. Use unique business keys in your database. Use Delta Lake or Hudi if you need transactional file storage. If you are writing to an API, ensure your request includes a client-side idempotency token that the API respects.&lt;/p&gt;

&lt;p&gt;Spark Structured Streaming is a phenomenal tool for stateful processing and offset management. But it is not a magic wand that absolves you from the responsibility of handling data integrity at the sink. If you assume the framework handles everything, you’ll spend your weekends doing manual data reconciliation. Don’t trust the abstraction—trust the physical limitations of your infrastructure.&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>spark</category>
      <category>architecture</category>
      <category>data</category>
      <category>engineering</category>
    </item>
    <item>
      <title>How we stopped breaking production pipelines with Snowflake zero-copy cloning</title>
      <dc:creator>Aniket Abhishek Soni</dc:creator>
      <pubDate>Mon, 28 Sep 2026 13:11:06 +0000</pubDate>
      <link>https://dev.to/aniketsoni/how-we-stopped-breaking-production-pipelines-with-snowflake-zero-copy-cloning-2e33</link>
      <guid>https://dev.to/aniketsoni/how-we-stopped-breaking-production-pipelines-with-snowflake-zero-copy-cloning-2e33</guid>
      <description>&lt;p&gt;Ninety-eight percent of production data pipeline incidents happen because developers tested their code against a "subset" of data that didn’t actually look like production. We spend hours writing synthetic data generators or manual sampling scripts, only to have our transformation logic blow up the second it hits the real-world mess of nulls, schema drifts, and malformed strings waiting in the actual warehouse.&lt;/p&gt;

&lt;p&gt;We treat production data like a radioactive isotope: too dangerous to touch, so we create elaborate, sterile imitations. But in modern data engineering, the most dangerous thing you can do is test against an imitation. If you’re building pipelines in Snowflake and not using zero-copy cloning, you are effectively flying a plane while guessing the altitude.&lt;/p&gt;

&lt;h2&gt;
  
  
  The mechanics of metadata manipulation
&lt;/h2&gt;

&lt;p&gt;Most engineers hear "clone" and think "copy." They imagine a background process chewing through TBs of storage, racking up compute credits, and taking an hour to finish. That is how traditional databases work. That is not how Snowflake works.&lt;/p&gt;

&lt;p&gt;When you run &lt;code&gt;CREATE TABLE clone_table CLONE prod_table&lt;/code&gt;, Snowflake doesn't move a single byte of data. It creates a new entry in the metadata layer that points to the existing micro-partitions of the source table. &lt;/p&gt;

&lt;p&gt;Think of it as a git branch for your data. The clone shares the underlying S3/Azure Blob/GCS storage files with the original table. Only when you perform a DML operation—an &lt;code&gt;UPDATE&lt;/code&gt;, &lt;code&gt;DELETE&lt;/code&gt;, or &lt;code&gt;INSERT&lt;/code&gt;—does Snowflake write new micro-partitions. It’s copy-on-write at the storage layer. &lt;/p&gt;

&lt;p&gt;I’ve cloned 5TB production tables in under three seconds. The command is trivial:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;OR&lt;/span&gt; &lt;span class="k"&gt;REPLACE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;dev_schema&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;orders_clone&lt;/span&gt; 
&lt;span class="n"&gt;CLONE&lt;/span&gt; &lt;span class="n"&gt;prod_schema&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="k"&gt;AT&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;TIMESTAMP&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'2023-10-27 10:00:00'&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;timestamp_ntz&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Because it uses the Time Travel feature, you aren't just cloning the current state; you are cloning a specific moment in time. If a bad job ran at 10:05 AM and corrupted your table, you can clone the table from 09:59 AM, verify your fix, and deploy the pipeline change without ever actually touching the corrupted production data.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fimages.unsplash.com%2Fphoto-1765408217308-333a2e2e734f%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3w5NzU0MjJ8MHwxfHNlYXJjaHwyNXx8YWJzdHJhY3QlMjBkaWdpdGFsJTIwZGF0YXxlbnwwfDB8fHwxNzkwNTQzNjE0fDA%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-1765408217308-333a2e2e734f%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3w5NzU0MjJ8MHwxfHNlYXJjaHwyNXx8YWJzdHJhY3QlMjBkaWdpdGFsJTIwZGF0YXxlbnwwfDB8fHwxNzkwNTQzNjE0fDA%26ixlib%3Drb-4.1.0%26q%3D80%26w%3D1080" alt="Photo by Egor Komarov on Unsplash" width="1080" height="586"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Photo by &lt;a href="https://unsplash.com/@egorkomarov?utm_source=articles_pipeline&amp;amp;utm_medium=referral" rel="noopener noreferrer"&gt;Egor Komarov&lt;/a&gt; on &lt;a href="https://unsplash.com/?utm_source=articles_pipeline&amp;amp;utm_medium=referral" rel="noopener noreferrer"&gt;Unsplash&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

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

&lt;p&gt;If this sounds like magic, it’s because it’s a brilliant abstraction. But like any abstraction, it leaks.&lt;/p&gt;

&lt;p&gt;The first issue is the "Clone Lifecycle." If you drop the source table, the clone loses its reference to the underlying micro-partitions. While Snowflake allows you to keep cloned data via &lt;code&gt;TIME_TRAVEL_RETENTION_PERIOD&lt;/code&gt;, you are essentially holding the source table hostage in the metadata layer. If your DBA team has a strict policy of dropping and recreating staging tables, your clones will break.&lt;/p&gt;

&lt;p&gt;Then there is the "Storage Inflation" trap. Because the clone shares the same micro-partitions as the original, any operation that modifies data—even if it's just a &lt;code&gt;RECLUSTER&lt;/code&gt; or a &lt;code&gt;DELETE&lt;/code&gt;—will cause the micro-partitions to deviate. If you have a clone that sits there for months while the original table undergoes massive churn, the clone will effectively "own" a copy of the old partitions. You won't see this in your compute credits; you’ll see it in your storage bill. I once saw a junior dev clone a massive fact table for a "quick test" and leave it running for six weeks. The storage bill for that account tripled because we were essentially versioning the entire fact table history twice.&lt;/p&gt;

&lt;p&gt;Finally, there is the "Security Blindspot." Cloning is a metadata-level operation. If a user has &lt;code&gt;SELECT&lt;/code&gt; privileges on the source, they can usually clone it. If your organization has strict PII masking policies, you have to be incredibly careful. If you don't have your &lt;code&gt;MASKING POLICY&lt;/code&gt; applied at the account level or correctly assigned to the roles, a clone gives a dev full access to raw, unmasked production data. I’ve seen compliance audits fail specifically because a developer cloned a production table to debug a join issue, inadvertently exposing PII to the development environment.&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-1772305336606-989a457ffbae%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3w5NzU0MjJ8MHwxfHNlYXJjaHw2fHxpbmR1c3RyaWFsJTIwd2FyZWhvdXNlJTIwc3RvcmFnZXxlbnwwfDB8fHwxNzkwNTQzNjE1fDA%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-1772305336606-989a457ffbae%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3w5NzU0MjJ8MHwxfHNlYXJjaHw2fHxpbmR1c3RyaWFsJTIwd2FyZWhvdXNlJTIwc3RvcmFnZXxlbnwwfDB8fHwxNzkwNTQzNjE1fDA%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;
  
  
  When to reach for it (and when not to)
&lt;/h2&gt;

&lt;p&gt;Use zero-copy cloning when you need to perform "destructive" testing. If you are rewriting a complex &lt;code&gt;MERGE&lt;/code&gt; statement or refactoring a DBT model that touches millions of rows, there is no substitute. Cloning allows you to run the job exactly as it would run in production, against the actual data, without risking a single row of live data.&lt;/p&gt;

&lt;p&gt;Use it for "Point-in-Time" forensic analysis. When a pipeline fails at 3 AM, don't try to query the broken state directly. Clone the table to the state exactly one second before the failure. It turns an emergency "fix it in prod" disaster into a calm, reproducible debugging session.&lt;/p&gt;

&lt;p&gt;Do not use it for long-term development environments. If you need a sandbox for a feature that will take three weeks to build, do not use a zero-copy clone. Use a subsetted table or a mocked dataset. Clones are meant to be ephemeral. If you find yourself keeping a clone for more than a few days, you are just building technical debt into your storage costs.&lt;/p&gt;

&lt;p&gt;Avoid it if you have complex, cross-database constraints or if your underlying data is stored in External Tables (S3/GCS buckets). Cloning works beautifully for native Snowflake tables, but it handles external stages differently. You can’t "clone" a file sitting in an S3 bucket; you can only point to it. If the pipeline change involves shifting the underlying file structure, your clone might not behave the way you expect.&lt;/p&gt;

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

&lt;p&gt;Zero-copy cloning is the difference between an amateur pipeline and a professional one. It moves the testing phase from "I hope this works" to "I have verified this against the production state." &lt;/p&gt;

&lt;p&gt;But remember: just because it’s fast and cheap doesn't mean it’s free. Treat your clones with the same lifecycle discipline as your code branches. Create them, test them, verify your logic, and then drop them. If you’re still testing by sampling 1,000 rows and praying, you aren't an engineer—you're a gambler. Stop gambling with your data and start using the tools that let you see the future of your pipeline before you deploy it.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Tags:&lt;/strong&gt; #snowflake #data #pipelines #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>snowflake</category>
      <category>data</category>
      <category>pipelines</category>
      <category>engineering</category>
    </item>
    <item>
      <title>Stop building your own vector index: Why managed services are eating the Postgres ecosystem</title>
      <dc:creator>Aniket Abhishek Soni</dc:creator>
      <pubDate>Mon, 28 Sep 2026 13:10:58 +0000</pubDate>
      <link>https://dev.to/aniketsoni/stop-building-your-own-vector-index-why-managed-services-are-eating-the-postgres-ecosystem-3ji8</link>
      <guid>https://dev.to/aniketsoni/stop-building-your-own-vector-index-why-managed-services-are-eating-the-postgres-ecosystem-3ji8</guid>
      <description>&lt;p&gt;Two years ago, adding semantic search to our internal document analysis tool involved a bloated Python script, a local HNSW index on a disk-heavy EC2 instance, and a prayer that the index wouldn't explode when we hit 500k embeddings. Updating the index meant a full offline rebuild, which resulted in 20 minutes of "Search currently unavailable" errors every Tuesday. &lt;/p&gt;

&lt;p&gt;Today, that same stack uses a managed vector service. I push vectors via API, the index updates in near real-time, and my PagerDuty alerts for "Index Memory Pressure" have vanished. The transition wasn't just about moving to the cloud; it was about accepting that the vector index is a specialized piece of infrastructure, not just another table in your database.&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 fighting with database performance in highly regulated environments where downtime isn't an option. I’m writing this because I keep seeing teams try to shoehorn vector indexing into their monolithic Postgres instances without understanding the operational tax of doing so at scale.&lt;/p&gt;
&lt;/blockquote&gt;

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

&lt;p&gt;We are comparing three distinct philosophies here:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;strong&gt;pgvector (v0.7.0+):&lt;/strong&gt; The "it's just Postgres" approach. It uses the &lt;code&gt;ivfflat&lt;/code&gt; or &lt;code&gt;hnsw&lt;/code&gt; access methods. It’s perfect if your data already lives in RDS/Aurora and your embedding counts are in the low millions.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Pinecone:&lt;/strong&gt; The "serverless-first" managed service. It abstracts away the index type, the shard management, and the hardware provisioning. You pay for throughput and storage, not instances.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Databricks Mosaic AI Vector Search:&lt;/strong&gt; The enterprise-grade engine. This is for the team that already has their data in Unity Catalog. It’s the "I don't want to think about data movement" choice.&lt;/li&gt;
&lt;/ul&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-1727434032773-af3cd98375ba%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3w5NzU0MjJ8MHwxfHNlYXJjaHwyfHxhYnN0cmFjdCUyMGRhdGElMjBub2Rlc3xlbnwwfDB8fHwxNzkwMzcxNTM4fDA%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-1727434032773-af3cd98375ba%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3w5NzU0MjJ8MHwxfHNlYXJjaHwyfHxhYnN0cmFjdCUyMGRhdGElMjBub2Rlc3xlbnwwfDB8fHwxNzkwMzcxNTM4fDA%26ixlib%3Drb-4.1.0%26q%3D80%26w%3D1080" alt="Photo by Luke Jones on Unsplash" width="1080" height="608"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Photo by &lt;a href="https://unsplash.com/@lukejonesdesign?utm_source=articles_pipeline&amp;amp;utm_medium=referral" rel="noopener noreferrer"&gt;Luke Jones&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 operational tax
&lt;/h2&gt;

&lt;p&gt;When you run &lt;code&gt;pgvector&lt;/code&gt;, you are running Postgres. That means you are managing &lt;code&gt;shared_buffers&lt;/code&gt;, &lt;code&gt;work_mem&lt;/code&gt;, and the vacuuming cycle. If you hit a peak in query volume, your vector operations are competing for CPU cycles with your standard transactional &lt;code&gt;UPDATE&lt;/code&gt; and &lt;code&gt;SELECT&lt;/code&gt; statements. &lt;/p&gt;

&lt;p&gt;In version 0.7.0, &lt;code&gt;pgvector&lt;/code&gt; added support for &lt;code&gt;hnsw&lt;/code&gt; indexes, which is a massive performance win. However, if you trigger a reindex on a table with 5 million vectors, your CPU will spike to 100% and your query latency will tank. I’ve seen production databases lock up because of an ill-timed &lt;code&gt;CREATE INDEX CONCURRENTLY&lt;/code&gt; in an environment where the IOPS limit on an AWS &lt;code&gt;gp3&lt;/code&gt; volume was already near saturation.&lt;/p&gt;

&lt;p&gt;Pinecone and Mosaic AI treat the index as a separate service. You aren't managing the memory pressure of the index; the vendor is. When I use Pinecone, I don't care about memory fragmentation or disk latency. I care about the &lt;code&gt;p1&lt;/code&gt; latency of my API calls. &lt;/p&gt;

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

&lt;p&gt;Let’s talk money. &lt;code&gt;pgvector&lt;/code&gt; on AWS RDS is cheap until it isn't. You’ll eventually hit the ceiling of a single instance, and then you’re looking at read replicas or partitioning strategies. Managing Postgres partitioning for vectors is a specialized skill that most teams don't have. If your team’s DBA time costs $200/hr, that "cheap" Postgres extension becomes the most expensive line item in your cloud bill.&lt;/p&gt;

&lt;p&gt;Pinecone is billed by the "pod" or via serverless throughput. It looks expensive on a spreadsheet—until you calculate the labor cost of a single engineer spending three days debugging a hung Postgres process during a peak traffic event.&lt;/p&gt;

&lt;p&gt;Mosaic AI is only "cheap" if you are already in the Databricks ecosystem. If your data is in Delta Lake, using anything else is madness. Exporting, embedding, and syncing to Pinecone adds latency and introduces a point of failure (the ETL pipeline). Mosaic AI stays within the perimeter.&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-1752742111841-f490c48aa668%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3w5NzU0MjJ8MHwxfHNlYXJjaHwxMHx8bmVvbiUyMHNlcnZlciUyMHJhY2t8ZW58MHwwfHx8MTc5MDM3MTUzOHww%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-1752742111841-f490c48aa668%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3w5NzU0MjJ8MHwxfHNlYXJjaHwxMHx8bmVvbiUyMHNlcnZlciUyMHJhY2t8ZW58MHwwfHx8MTc5MDM3MTUzOHww%26ixlib%3Drb-4.1.0%26q%3D80%26w%3D1080" alt="Photo by Markus Stickling on Unsplash" width="1080" height="608"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Photo by &lt;a href="https://unsplash.com/@stikkx?utm_source=articles_pipeline&amp;amp;utm_medium=referral" rel="noopener noreferrer"&gt;Markus Stickling&lt;/a&gt; on &lt;a href="https://unsplash.com/?utm_source=articles_pipeline&amp;amp;utm_medium=referral" rel="noopener noreferrer"&gt;Unsplash&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

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

&lt;p&gt;The nightmare scenario for &lt;code&gt;pgvector&lt;/code&gt; is a corrupted index or a vacuum loop that kills your database performance. Recovery means taking a snapshot of the entire multi-terabyte DB and restoring it to a new instance. &lt;/p&gt;

&lt;p&gt;With Pinecone, the failure mode is usually an API timeout or rate limiting. These are handled with standard retry logic and exponential backoff. It’s a distributed systems problem, not a storage engine problem. If the index fails, I don't care about the underlying state; I just re-sync from my source of truth.&lt;/p&gt;

&lt;p&gt;Mosaic AI’s failure mode is almost always related to the sync trigger between Delta Lake and the vector store. If your Delta table updates, but the vector index doesn't, you have a consistency issue. The fix is usually a &lt;code&gt;TriggerNow&lt;/code&gt; call in the Mosaic console. It’s a cleaner failure mode than a database index crash.&lt;/p&gt;

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

&lt;p&gt;If you have fewer than 1 million vectors and your data is already in Postgres, use &lt;code&gt;pgvector&lt;/code&gt;. Period. The overhead of adding another vendor to your stack isn't worth it. Just make sure you tune your &lt;code&gt;hnsw.ef_search&lt;/code&gt; correctly—start at 40 and move up only if recall drops below your SLA—and keep your vector column dimension count reasonable (e.g., 768 or 1536).&lt;/p&gt;

&lt;p&gt;If you are scaling past 5 million vectors or you need high-concurrency search across multiple microservices, stop using Postgres for this. Move to Pinecone. The serverless performance is predictable, and the lack of operational overhead is a force multiplier for a small engineering team. &lt;/p&gt;

&lt;p&gt;If you are a large enterprise already using Databricks for your ETL and AI lifecycle, use Mosaic AI. The integration with Unity Catalog is the "killer feature." It turns vector search from a separate infrastructure project into a simple data governance task. &lt;/p&gt;

&lt;p&gt;Don’t be a hero. Unless you are at the scale where you need to manage your own hardware for latency reasons (sub-5ms), you shouldn't be managing your own vector indices. Spend that time on your model fine-tuning instead.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Tags:&lt;/strong&gt; #postgres #databases #ai #infrastructure&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>postgres</category>
      <category>database</category>
      <category>ai</category>
      <category>infrastructure</category>
    </item>
    <item>
      <title>Don't Let Unity Catalog Migration Become Your Resume Generating Event</title>
      <dc:creator>Aniket Abhishek Soni</dc:creator>
      <pubDate>Thu, 24 Sep 2026 14:29:20 +0000</pubDate>
      <link>https://dev.to/aniketsoni/dont-let-unity-catalog-migration-become-your-resume-generating-event-bjb</link>
      <guid>https://dev.to/aniketsoni/dont-let-unity-catalog-migration-become-your-resume-generating-event-bjb</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Why I chose this topic:&lt;/strong&gt; I spent three days last quarter fixing a "simple" metadata sync that locked 200 production jobs out of their own tables because of a misconfigured metastore credential. I’m writing this so you don’t have to drink the same lukewarm coffee at 3:00 AM that I did.&lt;/p&gt;
&lt;/blockquote&gt;

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

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

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

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

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

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

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

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

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

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

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

&lt;/div&gt;



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

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

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

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

&lt;/div&gt;



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

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

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

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

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

&lt;/div&gt;



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

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

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

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

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

&lt;/div&gt;



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

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

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

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

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




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

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

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

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

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

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

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

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

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

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

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

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

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

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

&lt;/div&gt;



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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

</description>
      <category>snowflake</category>
      <category>iceberg</category>
      <category>data</category>
      <category>architecture</category>
    </item>
  </channel>
</rss>
