<?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: Vikneshwara R B</title>
    <description>The latest articles on DEV Community by Vikneshwara R B (@vikneshwara_r_b).</description>
    <link>https://dev.to/vikneshwara_r_b</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%2F4176999%2Ff085df7e-72b5-4f88-9075-782991de8348.JPG</url>
      <title>DEV Community: Vikneshwara R B</title>
      <link>https://dev.to/vikneshwara_r_b</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/vikneshwara_r_b"/>
    <language>en</language>
    <item>
      <title>Two Data Sources, Zero Orchestrators — Building an F1 Analytics Pipeline on AWS Glue</title>
      <dc:creator>Vikneshwara R B</dc:creator>
      <pubDate>Tue, 20 Oct 2026 07:49:22 +0000</pubDate>
      <link>https://dev.to/aws-builders/two-data-sources-zero-orchestrators-building-an-f1-analytics-pipeline-on-aws-glue-4hao</link>
      <guid>https://dev.to/aws-builders/two-data-sources-zero-orchestrators-building-an-f1-analytics-pipeline-on-aws-glue-4hao</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Disclaimer:&lt;/strong&gt; I'm not affiliated with or endorsed by AWS. This is a personal hobby project, and some of the behaviour described here was found by trial and error rather than from the docs. Spark Declarative Pipelines is new in AWS Glue 6.0 and may change, so check the &lt;a href="https://docs.aws.amazon.com/glue/latest/dg/spark-declarative-pipelines.html" rel="noopener noreferrer"&gt;official AWS Glue documentation&lt;/a&gt; first.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;In my &lt;a href="https://dev.to/aws-builders/one-glue-job-zero-orchestrators-my-first-spark-declarative-pipeline-on-aws-glue-bn2"&gt;first Spark Declarative Pipelines post&lt;/a&gt; I walked through the basic idea: declare the tables you want, let AWS Glue 6.0 work out the dependency order, and skip the orchestrator entirely. That demo used one CSV source flowing bronze → silver → gold. It worked, but it was a tidy example — one source, one shape, one straight line.&lt;/p&gt;

&lt;p&gt;Real pipelines are messier. So I built one properly, using Formula 1 race data, and deliberately made it harder: data arriving from a live API as deeply nested JSON, a second completely unrelated data source, and dimension tables that need to be deduplicated. This post walks through that build, and the things I only learned by breaking it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;New to SDP?&lt;/strong&gt; The short version: Spark Declarative Pipelines (SDP) is a framework in AWS Glue 6.0 where you &lt;em&gt;declare&lt;/em&gt; tables instead of &lt;em&gt;writing scripts that build them in order&lt;/em&gt;. You reference one table from another by name, and the engine figures out the dependency graph, the execution order, and what can run in parallel — all inside a single Glue job. My first post covers the fundamentals if you want them.&lt;/p&gt;

&lt;h2&gt;
  
  
  First, the use case: what are we actually building?
&lt;/h2&gt;

&lt;p&gt;Let's park AWS for a minute and talk about the data, because the data shape is what drives every design decision later.&lt;/p&gt;

&lt;p&gt;I'm using &lt;a href="https://f1api.dev" rel="noopener noreferrer"&gt;f1api.dev&lt;/a&gt;, a free public Formula 1 API. You ask it for one race at a time:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;GET https://f1api.dev/api/2024/1/race
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's season 2024, round 1 — the Bahrain Grand Prix. What comes back is a single, deeply nested JSON document describing everything about that race. Trimmed down heavily for readability:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"season"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;2024&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"races"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"raceName"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Bahrain Grand Prix"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"date"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2024-03-02"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"round"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"circuit"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"circuitId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"bahrain"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"circuitName"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Bahrain International Circuit"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"city"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Sakhir"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"country"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Bahrain"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"corners"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;15&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"results"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"position"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"grid"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"points"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;26&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"driver"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"driverId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"max_verstappen"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Max"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"surname"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Verstappen"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"number"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"team"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"teamId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"red_bull"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"teamName"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Red Bull Racing"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"position"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"points"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;18&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"driver"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"driverId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"perez"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"..."&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"..."&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"..."&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"..."&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Look at the shape, because this is the first real problem:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Race facts live at the top (name, date, round)&lt;/li&gt;
&lt;li&gt;The circuit is an &lt;strong&gt;array&lt;/strong&gt; nested one level down&lt;/li&gt;
&lt;li&gt;The results are an &lt;strong&gt;array of 20 entries&lt;/strong&gt; — one per driver — and inside &lt;em&gt;each&lt;/em&gt; entry there's a nested &lt;code&gt;driver&lt;/code&gt; object and a nested &lt;code&gt;team&lt;/code&gt; object&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You cannot answer "which driver scored the most points this season?" from a folder full of documents shaped like that. You need flat tables. That flattening is the pipeline's first job.&lt;/p&gt;

&lt;h3&gt;
  
  
  The questions I want answered
&lt;/h3&gt;

&lt;p&gt;The whole point of the pipeline is to produce tables that answer questions like:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Which driver scored the most points, won the most races, and had the best average finishing position?&lt;/li&gt;
&lt;li&gt;Which team scored the most, and how many different drivers did they field?&lt;/li&gt;
&lt;li&gt;Which circuits have been raced at, how many times, and what's the lap record there?&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Why three layers?
&lt;/h3&gt;

&lt;p&gt;This is the &lt;strong&gt;medallion architecture&lt;/strong&gt; — bronze, curated (often called silver), and gold. It sounds formal, but the idea is simple: don't try to go from raw JSON to a polished dashboard table in one step. Do it in three, where each step has one job.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Bronze — land it, then flatten it.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;First, &lt;code&gt;raw_race&lt;/code&gt; stores the API response more or less as it arrived. One row per pipeline run. Nothing cleaned, nothing dropped. If I later discover a bug in my parsing logic, the original data is still sitting here and I can reprocess it.&lt;/p&gt;

&lt;p&gt;Then four tables flatten that nesting into something usable:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Table&lt;/th&gt;
&lt;th&gt;What one row means&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;race_results_unnested&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;One driver's result in one race (20 rows per race)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;race_drivers&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;A driver's profile, as it appeared in that race&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;race_teams&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;A team's profile, once for each of its drivers in that race&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;race_circuits&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The circuit that race was held at&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Feir8a1nky1zvv4bv283h.png" 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%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Feir8a1nky1zvv4bv283h.png" alt="Bronze layer ER diagram: raw_race flattened into four prepared tables, with circuit_track_type shown unconnected" width="800" height="903"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Curated — one row per thing.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Here's the catch with that bronze layer: every single race response re-sends the full profile of every driver and team. Load 24 races and you have 24 copies of Max Verstappen's profile and about 48 copies of Red Bull's team record (one for each of its two drivers, every race) — most of them identical, some slightly different as the season progresses and championship counts change.&lt;/p&gt;

&lt;p&gt;For analytics I want exactly one current row per driver. So the curated layer deduplicates:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;dim_drivers&lt;/code&gt; — one row per driver (the most recent version of their profile)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;dim_teams&lt;/code&gt; — one row per team&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;dim_circuits&lt;/code&gt; — one row per circuit&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;fact_race_results&lt;/code&gt; — one row per (race, driver): the actual &lt;em&gt;measurements&lt;/em&gt; — grid position, finishing position, points scored&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you've seen a star schema before, that's exactly what this is: &lt;strong&gt;dimensions&lt;/strong&gt; describe the who/what/where, and the &lt;strong&gt;fact&lt;/strong&gt; table records what happened, with the numbers.&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%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fw6wfz1d0rqgk6bogkhxz.png" 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%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fw6wfz1d0rqgk6bogkhxz.png" alt="Curated layer ER diagram: three dimension tables joined to the fact_race_results table" width="800" height="784"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Gold — the answers.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Three summary tables, each one a direct answer to one of the questions above: &lt;code&gt;driver_performance_summary&lt;/code&gt;, &lt;code&gt;team_performance_summary&lt;/code&gt;, and &lt;code&gt;circuit_summary&lt;/code&gt;. These are what a dashboard or an Athena query would actually read.&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%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fs31dk5spsvqn2zt7v69v.png" 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%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fs31dk5spsvqn2zt7v69v.png" alt="Gold layer ER diagram: three standalone summary tables" width="800" height="376"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  One run = one race
&lt;/h3&gt;

&lt;p&gt;One design decision worth calling out because it shapes everything: each pipeline run fetches exactly one race. You tell it the season and round when you start the job:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;scripts/run_pipeline.sh &lt;span class="nt"&gt;--profile&lt;/span&gt; my-profile &lt;span class="nt"&gt;--season&lt;/span&gt; 2024 &lt;span class="nt"&gt;--round&lt;/span&gt; 1 &lt;span class="nt"&gt;--mode&lt;/span&gt; run &lt;span class="nt"&gt;--wait&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run it again with &lt;code&gt;--round 2&lt;/code&gt; and the pipeline &lt;em&gt;accumulates&lt;/em&gt; — bronze grows by one more race, and the curated and gold layers recompute to include it. After running rounds 1 and 2, my gold table showed Verstappen on 2 wins and 51 points, Perez on 36, Leclerc on 28 — which matches the real 2024 championship standings after two races — always a nice moment when the numbers come out right.&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%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Flu6iszaxpuq2fmmxgvi1.png" 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%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Flu6iszaxpuq2fmmxgvi1.png" alt="Athena query results: driver standings after two races, Verstappen first on 51 points" width="799" height="412"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The pipeline at a glance
&lt;/h2&gt;

&lt;p&gt;All of the above runs as &lt;strong&gt;one Glue job&lt;/strong&gt;. Not one job per layer, not a Step Functions state machine tying them together — one job, with all tables registered as Apache Iceberg tables in the Glue Data Catalog.&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%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffwqtmnm3rgcavrf3k43k.png" 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%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffwqtmnm3rgcavrf3k43k.png" alt="Glue console Runs tab for f1-sdp-job showing a successful run and its input arguments.The red box shows the job's arguments: --enable-spark-declarative-pipeline is what switches SDP on, and the --conf values are the season and round I passed for this run." width="800" height="318"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;In SDP you write two kinds of tables, and the difference matters:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Streaming tables&lt;/strong&gt; process only what's &lt;em&gt;new&lt;/em&gt; since the last run. Bronze uses these, so re-running never reprocesses an old race.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Materialized views&lt;/strong&gt; fully recompute every run. Curated and gold use these, because "the latest row per driver" and "total points per driver" genuinely need to look at everything each time.&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%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Finu27aa9sm9avcuve02w.png" 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%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Finu27aa9sm9avcuve02w.png" alt="Glue Data Catalog table list for f1_sdp_db showing 13 tables" width="800" height="392"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Adding a second data source (and why F1 needed one)
&lt;/h2&gt;

&lt;p&gt;Here's where I actually tested the "zero orchestration" claim.&lt;/p&gt;

&lt;p&gt;The F1 API tells me a circuit's name, city, country, length, and corner count. It does &lt;strong&gt;not&lt;/strong&gt; tell me whether it's a &lt;em&gt;street circuit&lt;/em&gt; (Monaco, Singapore, Las Vegas — public roads closed for the weekend) or a &lt;em&gt;permanent racetrack&lt;/em&gt; (Silverstone, Spa, Suzuka — purpose-built facilities). That's exactly the kind of attribute you'd want to slice results by — for example, comparing how drivers perform on street circuits versus permanent ones.&lt;/p&gt;

&lt;p&gt;That data simply isn't in the API. It has to come from somewhere else — which makes it a perfect second data source, and a genuinely different &lt;em&gt;kind&lt;/em&gt; of source:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;&lt;code&gt;raw_race&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;&lt;code&gt;circuit_track_type&lt;/code&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Where it comes from&lt;/td&gt;
&lt;td&gt;Live external API&lt;/td&gt;
&lt;td&gt;Static CSV files in S3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;How it arrives&lt;/td&gt;
&lt;td&gt;An HTTP call during the job run&lt;/td&gt;
&lt;td&gt;Uploaded by a deploy script&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Depends on&lt;/td&gt;
&lt;td&gt;Nothing — it's a root&lt;/td&gt;
&lt;td&gt;Nothing — also a root&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two completely independent branches. They never touch in bronze. They meet for the first time in the curated layer, in exactly one place:&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="n"&gt;MATERIALIZED&lt;/span&gt; &lt;span class="k"&gt;VIEW&lt;/span&gt; &lt;span class="n"&gt;dim_circuits&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;rc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;circuit_Id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;rc&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="n"&gt;rc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;city&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;rc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;country&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ctt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;track_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;rc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;length&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;rc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;corners&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ROW_NUMBER&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;circuit_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;race_Date&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;load_date_time&lt;/span&gt; &lt;span class="k"&gt;DESC&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;_rn&lt;/span&gt;
  &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;race_circuits&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;rc&lt;/span&gt;
&lt;span class="k"&gt;LEFT&lt;/span&gt; &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ROW_NUMBER&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;circuit_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;load_date_time&lt;/span&gt; &lt;span class="k"&gt;DESC&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;_rn&lt;/span&gt;
  &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;circuit_track_type&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;ctt&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;rc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;circuit_Id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ctt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;circuit_Id&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;ctt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_rn&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;rc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_rn&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Ignore the &lt;code&gt;ROW_NUMBER()&lt;/code&gt; noise for a second (that's the deduplication I mentioned — "keep the newest row per circuit"). The important line is &lt;code&gt;LEFT JOIN ... circuit_track_type&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;That join is the entire integration.&lt;/strong&gt; I didn't register the new source anywhere. No config file entry, no trigger, no second job, no DAG definition. SDP reads this SQL file, sees a reference to a table called &lt;code&gt;circuit_track_type&lt;/code&gt;, and from that alone works out that it's an upstream dependency of &lt;code&gt;dim_circuits&lt;/code&gt;. The two branches stay independent until this join — and per the AWS docs, SDP runs independent branches in parallel.&lt;/p&gt;

&lt;p&gt;That's the claim from my first article, and this is the first time I'd properly stress-tested it. It held.&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%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fm59gmb2764uyl9xer8mg.png" 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%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fm59gmb2764uyl9xer8mg.png" alt="Athena query results: bahrain is a permanent circuit and jeddah a street circuit" width="799" height="374"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Four lessons the tidy demo didn't teach me
&lt;/h2&gt;

&lt;p&gt;A one-source demo hides most of the real friction. These four cost me the most time. Here they are at a glance, then in detail.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;#&lt;/th&gt;
&lt;th&gt;The lesson&lt;/th&gt;
&lt;th&gt;What I saw&lt;/th&gt;
&lt;th&gt;What worked&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;No MERGE or upsert&lt;/td&gt;
&lt;td&gt;No built-in way to keep a row's history&lt;/td&gt;
&lt;td&gt;Keep only the latest row per key (SCD Type 1)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;Streaming tables need a streaming source&lt;/td&gt;
&lt;td&gt;An &lt;code&gt;INVALID_FLOW_QUERY_TYPE&lt;/code&gt; error on the API data&lt;/td&gt;
&lt;td&gt;Stage the API response in S3, then read it as a stream&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;Fresh deployments can fail&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;PATH_NOT_FOUND&lt;/code&gt; before any data had landed&lt;/td&gt;
&lt;td&gt;Write a tiny &lt;code&gt;.keep&lt;/code&gt; marker file into each watched folder&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;Shared code isn't importable&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ModuleNotFoundError&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;One shared bootstrap file, listed first in the manifest&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  1. There's no MERGE or upsert, so there's no change history
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;The problem.&lt;/strong&gt; Databricks' version of declarative pipelines has a built-in feature for tracking how dimension rows change over time (&lt;code&gt;create_auto_cdc_flow&lt;/code&gt;). AWS Glue 6.0 SDP has no equivalent.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why.&lt;/strong&gt; SDP gives you only two kinds of persisted table to write to: append-only streaming tables and full-recompute materialized views. There's no "update this row" operation.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What worked.&lt;/strong&gt; I settled for &lt;strong&gt;SCD Type 1&lt;/strong&gt;: keep only the current version of each row, with no history. (SCD Type 2 would keep every past version.) Every curated table works out "latest row per key" with a &lt;code&gt;ROW_NUMBER()&lt;/code&gt; window function over the whole bronze history — number each driver's rows newest-first, then keep row 1. It works well. But if you genuinely need history ("what was this team's championship count last month?"), know that going in: there's no built-in escape hatch yet.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. A streaming table can't be fed from a plain API call
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;The problem.&lt;/strong&gt; My first instinct was the obvious one: fetch the JSON from the API, load it into a DataFrame in memory, and return it from the pipeline. Glue rejected it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;AnalysisException: [INVALID_FLOW_QUERY_TYPE.BATCH_RELATION_FOR_STREAMING_TABLE]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Why.&lt;/strong&gt; A streaming table has to be fed by a streaming source: something Spark can read incrementally while remembering how far it got. A DataFrame built in memory is a one-off batch, so it's refused.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What worked.&lt;/strong&gt; Stage the data in S3 first:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Call the API and get the JSON.&lt;/li&gt;
&lt;li&gt;Write it to a uniquely named file in an S3 folder, using plain &lt;code&gt;boto3&lt;/code&gt; (not Spark).&lt;/li&gt;
&lt;li&gt;Read that folder with &lt;code&gt;spark.readStream&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Now it's a genuine streaming source, and Spark's built-in file checkpointing means a re-run never reprocesses yesterday's file. It's an extra moving part that exists purely because a REST API has no natural streaming shape.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. A brand-new deployment fails before it does anything
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;The problem.&lt;/strong&gt; On a fresh deploy, the pipeline failed straight away:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;AnalysisException: [PATH_NOT_FOUND] Path does not exist: s3://.../reference-data/circuit_track_type/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Why.&lt;/strong&gt; Both S3 folders the pipeline watches (the API staging folder and the reference-data folder) are read with &lt;code&gt;spark.readStream(...).load(path)&lt;/code&gt;. Before any data has landed, those folders don't exist, and Spark doesn't shrug and read zero rows. It fails the job.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What worked.&lt;/strong&gt; Make sure each folder always exists by writing a tiny empty marker file, &lt;code&gt;.keep&lt;/code&gt;, into it. My CDK stack does this for the reference-data folder on every deploy, and the pipeline does it itself for the API staging folder. It's cheap insurance against a failure that only appears on day one of a new environment, which is exactly when you're least expecting it.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Shared Python code isn't importable by default
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;The problem.&lt;/strong&gt; I keep schema definitions and API-fetch helpers in a shared Python package next to the transformation files. Importing it failed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ModuleNotFoundError: No module named 'f1_pipeline_lib'
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Why.&lt;/strong&gt; SDP loads each transformation file directly by its file path, and that doesn't add anything to Python's import path (I confirmed this by reading SDP's source). I then tried the documented way to ship extra Python files to a Glue job, the &lt;code&gt;--extra-py-files&lt;/code&gt; parameter. It failed with the same error, most likely because SDP runs as a separate process that the setting doesn't reach. I confirmed both failures by running real Glue jobs and reading the tracebacks.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What worked.&lt;/strong&gt; One tiny shared bootstrap file that fixes the import path, listed &lt;em&gt;first&lt;/em&gt; in the pipeline manifest so it always runs before anything else. One file, one place, instead of the same few lines copy-pasted into every transformation file.&lt;/p&gt;

&lt;h2&gt;
  
  
  Benefits of using SDP in AWS Glue 6.0
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The zero-orchestration claim genuinely holds&lt;/strong&gt;, even with two independent sources and a deduplicating curated layer. One job, no Step Functions, no Airflow.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Connecting a new source takes one line.&lt;/strong&gt; The source still needs its own table definition, but wiring it into the pipeline is just a SQL &lt;code&gt;JOIN&lt;/code&gt; or a &lt;code&gt;.readStream.table("x")&lt;/code&gt; call — there's nothing to register anywhere else.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Independent branches run in parallel automatically&lt;/strong&gt;, according to the AWS docs — no fan-out/fan-in code to write.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SQL and Python mix freely&lt;/strong&gt; in the same pipeline — my bronze layer is Python, curated and gold are plain SQL files.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Streaming tables remember what they've already processed.&lt;/strong&gt; Each one keeps a checkpoint in S3 — a record of which input it has already consumed — so every run picks up only what's new. I checked this directly on the reference-data table: after adding a second CSV file, the checkpoint's log showed the next run read just that new file, not the original again. The same mechanism means running round 2 adds that race alongside round 1 instead of starting over. It isn't free in the cost sense — you still pay for the compute that runs — but you don't write or maintain any "last processed" bookmark yourself, which is easy to get subtly wrong in a hand-rolled pipeline. (Materialized views don't get this; they recompute from scratch every run, which is the trade-off listed under limitations below.)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tables register themselves&lt;/strong&gt; in the Glue Data Catalog — no manual &lt;code&gt;CREATE TABLE&lt;/code&gt; anywhere.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;VALIDATE&lt;/code&gt; mode is a genuinely useful dry run&lt;/strong&gt; that catches dependency and compile errors before you spend money on a full run.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Limitations of using SDP in AWS Glue 6.0
&lt;/h2&gt;

&lt;p&gt;Three of these — materialized views always recomputing, no data quality expectations, and the &lt;code&gt;.withColumn()&lt;/code&gt; gotcha — are also listed under &lt;a href="https://docs.aws.amazon.com/glue/latest/dg/spark-declarative-pipelines.html#spark-declarative-pipelines-considerations" rel="noopener noreferrer"&gt;Considerations and limitations&lt;/a&gt; in the official AWS docs.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No CDC/MERGE/upsert.&lt;/strong&gt; No SCD Type 2 out of the box, as described above.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No data quality expectations.&lt;/strong&gt; Inline data quality annotations are not supported in the current SDP framework. Some other declarative frameworks let you attach rules like "this column must not be null" directly to a table definition; SDP in AWS Glue 6.0 has no equivalent yet, so any validation is hand-written. My pipeline doesn't have a quality layer yet, so a malformed API response simply fails the run.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Materialized views always fully recompute.&lt;/strong&gt; Only streaming tables are incremental — worth budgeting for as your gold aggregations grow.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Materialized views can read as empty while a job is running.&lt;/strong&gt; Every run clears each materialized view first (a &lt;code&gt;TRUNCATE TABLE&lt;/code&gt;), and the new rows only land when that view's own step finishes. So an Athena query during a run returns zero rows — not an error, and not the previous data — until the refresh completes. I checked this on a real run of this pipeline by polling Athena every few seconds: each of my four materialized views read 0 rows for roughly 30 seconds of a two-minute run, with the gold tables coming back last, while a streaming table stayed readable the whole time. I couldn't find this in the AWS Glue or Apache Spark documentation; I found the cause in Spark's open-source SDP code. In Spark 4.1.1, the version Glue 6.0 runs, it &lt;a href="https://github.com/apache/spark/blob/v4.1.1/sql/pipelines/src/main/scala/org/apache/spark/sql/pipelines/graph/DatasetManager.scala#L301-L304" rel="noopener noreferrer"&gt;truncates every non-streaming table at the start of an update&lt;/a&gt;. Plan for it: don't point a dashboard or a downstream job at these tables while a run is in progress.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;External API sources need a staging workaround&lt;/strong&gt; to satisfy the streaming-relation requirement.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Python packaging has sharp edges&lt;/strong&gt; that I couldn't find documented. The import-path issue, and the fact that &lt;code&gt;--extra-py-files&lt;/code&gt; doesn't reach the SDP process, both cost me real debugging time.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A &lt;code&gt;.withColumn()&lt;/code&gt; gotcha.&lt;/strong&gt; If a downstream table reads another pipeline table and adds a derived column with &lt;code&gt;.withColumn(...)&lt;/code&gt;, SDP might fail to detect the dependency on the second and later runs, so the downstream table reads stale data from the previous run (a one-run lag). The documented fix is to build derived columns inside a single &lt;code&gt;.select(...)&lt;/code&gt; instead, and to avoid operations that force plan resolution, such as &lt;code&gt;.schema&lt;/code&gt; or &lt;code&gt;.collect&lt;/code&gt;, inside query functions. I followed this from the start — every derived column in my bronze layer is written in a &lt;code&gt;.select(...)&lt;/code&gt; — so I never saw the stale-data problem itself. I did hit a similar sharp edge in the same place: adding &lt;code&gt;.distinct()&lt;/code&gt; to a streaming read made the pipeline fail with a misleading "table not found" error, and removing it fixed it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;VALIDATE&lt;/code&gt; still runs your code&lt;/strong&gt;; it just doesn't write the results. A live-API source will happily call the real API during a "dry run" unless you guard against it.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What I took away from this
&lt;/h2&gt;

&lt;p&gt;My first article proved the concept worked. This one was about whether it survives something less convenient — a second unrelated source, nested data that needs real flattening, and state that has to stay correct across many runs. It does, and the "add a source, write one join" story held up exactly as advertised. That still impresses me.&lt;/p&gt;

&lt;p&gt;What surprised me is &lt;em&gt;where&lt;/em&gt; the effort actually went. Almost none of it was in the declarative pipeline logic — that part really is as clean as promised. It all went into the handful of places where SDP's abstractions meet the realities of Glue and Spark underneath: the streaming-source rule, the missing-folder failure, and the import path. And none of those were things I could have read my way to. Every one came from running a real job, reading the actual CloudWatch traceback, and occasionally reading the framework's own source code to find out what it was really doing.&lt;/p&gt;

&lt;p&gt;If you're evaluating SDP for anything beyond a demo, that's my main advice: budget time for that kind of hands-on verification. The gap between "the docs say" and "a real Glue 6.0 job does" is still a real gap today. Just know that the core promise — one job, no orchestrator, dependencies figured out for you — does deliver.&lt;/p&gt;

&lt;p&gt;The code for this project is on &lt;a href="https://github.com/vikneshwara-r-b/f1-glue-sdp" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://dev.to/aws-builders/one-glue-job-zero-orchestrators-my-first-spark-declarative-pipeline-on-aws-glue-bn2"&gt;"One Glue Job, Zero Orchestrators — My First Spark Declarative Pipeline on AWS Glue"&lt;/a&gt;, DEV Community&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://aws.amazon.com/blogs/big-data/build-declarative-etl-pipelines-with-aws-glue-6-0/" rel="noopener noreferrer"&gt;"Build declarative ETL pipelines with AWS Glue 6.0"&lt;/a&gt;, AWS Big Data Blog&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.aws.amazon.com/glue/latest/dg/spark-declarative-pipelines.html" rel="noopener noreferrer"&gt;AWS Glue Spark Declarative Pipelines documentation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://f1api.dev" rel="noopener noreferrer"&gt;f1api.dev&lt;/a&gt; — the free Formula 1 API used in this project&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  About the author
&lt;/h2&gt;

&lt;p&gt;I'm an AWS Community Builder in the &lt;strong&gt;Data&lt;/strong&gt; category. I build small, real projects and write up what the documentation didn't cover.&lt;/p&gt;

&lt;p&gt;Questions, or something broke differently for you? Connect with me on &lt;a href="https://www.linkedin.com/in/vikneshwararb/" rel="noopener noreferrer"&gt;LinkedIn&lt;/a&gt; or drop a comment below.&lt;/p&gt;

</description>
      <category>beginners</category>
      <category>tutorial</category>
      <category>python</category>
      <category>architecture</category>
    </item>
    <item>
      <title>One Glue Job, Zero Orchestrators — My First Spark Declarative Pipeline on AWS Glue</title>
      <dc:creator>Vikneshwara R B</dc:creator>
      <pubDate>Mon, 12 Oct 2026 03:24:50 +0000</pubDate>
      <link>https://dev.to/aws-builders/one-glue-job-zero-orchestrators-my-first-spark-declarative-pipeline-on-aws-glue-bn2</link>
      <guid>https://dev.to/aws-builders/one-glue-job-zero-orchestrators-my-first-spark-declarative-pipeline-on-aws-glue-bn2</guid>
      <description>&lt;h2&gt;
  
  
  Disclaimer
&lt;/h2&gt;

&lt;p&gt;This is an independent, personal write-up — not official AWS content, and not&lt;br&gt;
endorsed by or affiliated with AWS. It documents a hobby project built while&lt;br&gt;
learning &lt;a href="https://aws.amazon.com/blogs/big-data/build-declarative-etl-pipelines-with-aws-glue-6-0/" rel="noopener noreferrer"&gt;AWS Glue 6.0's Spark Declarative Pipelines&lt;/a&gt;,&lt;br&gt;
following the walkthrough in that official AWS Big Data blog post. All opinions&lt;br&gt;
below are my own.&lt;/p&gt;

&lt;p&gt;I've tried to explain every piece of jargon the first time it shows up, so if you're&lt;br&gt;
new to data engineering or to AWS Glue, you should be able to follow this end to&lt;br&gt;
end. No prior Glue or Spark experience assumed.&lt;/p&gt;
&lt;h2&gt;
  
  
  The problem, in plain terms
&lt;/h2&gt;

&lt;p&gt;Imagine you need to process data in three steps: clean it up, validate it, then&lt;br&gt;
summarize it for a report. Easy enough on its own. Now imagine step 2 must &lt;em&gt;never&lt;/em&gt;&lt;br&gt;
start before step 1 finishes, and if step 2 fails halfway through, you don't want to&lt;br&gt;
redo all of step 1's work. And now imagine you have to add a step 4 next month.&lt;/p&gt;

&lt;p&gt;That coordination problem — making sure things run in the right order, handling&lt;br&gt;
retries, not redoing work that already succeeded — is called &lt;strong&gt;orchestration&lt;/strong&gt;. It's&lt;br&gt;
not the interesting part of a data pipeline. It's the part you &lt;em&gt;have&lt;/em&gt; to get right&lt;br&gt;
so the interesting part (the actual data transformations) works reliably.&lt;/p&gt;

&lt;p&gt;The classic way to structure a pipeline like this is the &lt;strong&gt;medallion architecture&lt;/strong&gt;:&lt;br&gt;
raw data comes in as &lt;strong&gt;bronze&lt;/strong&gt; (untouched, as-is — think raw ore), gets cleaned and&lt;br&gt;
validated into &lt;strong&gt;silver&lt;/strong&gt; (refined metal), and finally gets aggregated into &lt;strong&gt;gold&lt;/strong&gt;&lt;br&gt;
(the finished product, ready for a dashboard or report). It's a great mental model.&lt;br&gt;
The part that quietly gets expensive is everything &lt;em&gt;around&lt;/em&gt; it: three separate jobs,&lt;br&gt;
an orchestrator (like AWS Step Functions or Apache Airflow) wiring them together in&lt;br&gt;
order, retry logic at every stage, and bookkeeping so a partial failure doesn't force&lt;br&gt;
you to reprocess everything from scratch. None of that is business logic. It's&lt;br&gt;
plumbing that exists purely to make sure step 2 waits for step 1.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;AWS's own blog post has a diagram of exactly this shape — three separate AWS Glue jobs wired together by an Amazon MWAA DAG:&lt;br&gt;
&lt;a href="https://aws.amazon.com/blogs/big-data/build-declarative-etl-pipelines-with-aws-glue-6-0/" rel="noopener noreferrer"&gt;"Three AWS Glue Jobs with orchestration running in Amazon MWAA"&lt;/a&gt;,&lt;br&gt;
from &lt;em&gt;Build declarative ETL pipelines with AWS Glue 6.0&lt;/em&gt; (AWS Big Data Blog). Here's my own version of that same idea:&lt;/p&gt;
&lt;/blockquote&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%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fctbkxdk0avk4thnj4815.png" 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%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fctbkxdk0avk4thnj4815.png" alt="Three Glue jobs orchestrated by Amazon MWAA" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  Enter "declarative" pipelines
&lt;/h2&gt;

&lt;p&gt;You've probably used something declarative before without calling it that. When you&lt;br&gt;
write a SQL &lt;code&gt;SELECT&lt;/code&gt; query, you don't tell the database &lt;em&gt;how&lt;/em&gt; to fetch and join the&lt;br&gt;
rows step by step — you describe &lt;em&gt;what&lt;/em&gt; result you want, and the database figures&lt;br&gt;
out the how. That's the declarative idea: say what you want, not the steps to get&lt;br&gt;
there. (The opposite — a loop that manually fetches, filters, and combines rows&lt;br&gt;
yourself — is called &lt;em&gt;imperative&lt;/em&gt;: you spell out every step.)&lt;/p&gt;

&lt;p&gt;AWS Glue 6.0 brings that same idea to entire pipelines, through a feature called&lt;br&gt;
&lt;strong&gt;Spark Declarative Pipelines (SDP)&lt;/strong&gt;. Worth knowing: this is an Apache Spark 4.1&lt;br&gt;
feature, not something AWS invented just for Glue — AWS Glue 6.0 just ships the&lt;br&gt;
Spark version that includes it.&lt;/p&gt;

&lt;p&gt;With SDP, you write code that says "&lt;code&gt;silver_orders&lt;/code&gt; is bronze_orders, cleaned up&lt;br&gt;
this way" and "&lt;code&gt;gold_sales_summary&lt;/code&gt; is &lt;code&gt;silver_orders&lt;/code&gt;, aggregated this way." You&lt;br&gt;
never write "run bronze, then silver, then gold." The engine reads those table&lt;br&gt;
references in your code, works out that silver depends on bronze (because your&lt;br&gt;
silver code literally reads from the bronze table), and runs everything in the&lt;br&gt;
correct order automatically — as &lt;strong&gt;one single Glue job&lt;/strong&gt;, not three jobs stitched&lt;br&gt;
together by an orchestrator.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;And the "after" picture from the same post:&lt;br&gt;
&lt;a href="https://aws.amazon.com/blogs/big-data/build-declarative-etl-pipelines-with-aws-glue-6-0/" rel="noopener noreferrer"&gt;"AWS Glue 6.0 - Spark Declarative Pipeline (single job)"&lt;/a&gt; —&lt;br&gt;
the same bronze/silver/gold flow, collapsed into one job, with the caption &lt;em&gt;"SDP resolves dependency order automatically — no DAG wiring needed."&lt;/em&gt; That line is the whole pitch, honestly. My own version of that picture:&lt;/p&gt;
&lt;/blockquote&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%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4np4qqbfqyadvipmlzhq.png" 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%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4np4qqbfqyadvipmlzhq.png" alt="One Glue job, SDP resolves dependency order automatically" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I built a small project around the walkthrough in AWS's own blog post to see what&lt;br&gt;
this actually feels like end to end, including the infrastructure-as-code side&lt;br&gt;
(using AWS CDK, a tool for defining cloud infrastructure in regular code instead of&lt;br&gt;
clicking through the AWS console) that the original post doesn't cover. The repo is&lt;br&gt;
here if you want to follow along or just skim the code:&lt;br&gt;
&lt;strong&gt;&lt;a href="https://github.com/vikneshwara-r-b/glue-sdp-demo" rel="noopener noreferrer"&gt;github.com/vikneshwara-r-b/glue-sdp-demo&lt;/a&gt;&lt;/strong&gt;.&lt;/p&gt;
&lt;h2&gt;
  
  
  What SDP actually does for you
&lt;/h2&gt;

&lt;p&gt;A pipeline is just a small YAML file (&lt;code&gt;spark-pipeline.yml&lt;/code&gt;, think of it as the&lt;br&gt;
pipeline's table of contents) plus your SQL/Python transformation code. From those,&lt;br&gt;
SDP:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;figures out the dependency order from table references in your code (e.g. a line
like &lt;code&gt;spark.readStream.table("bronze_orders")&lt;/code&gt; tells it silver needs bronze first)
— you never draw this graph by hand;&lt;/li&gt;
&lt;li&gt;runs independent parts in parallel when it can;&lt;/li&gt;
&lt;li&gt;keeps track of &lt;strong&gt;checkpoints&lt;/strong&gt; (a bookmark of "which data have I already
processed") for tables that update incrementally, so a rerun doesn't redo
everything;&lt;/li&gt;
&lt;li&gt;registers every output table in the Glue Data Catalog (AWS's metadata store — the
thing that lets tools like Athena find and query your tables by name) as soon as
it's created.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;There are three kinds of tables you can define, and picking the right one matters&lt;br&gt;
more than it sounds like it should:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Type&lt;/th&gt;
&lt;th&gt;What it does&lt;/th&gt;
&lt;th&gt;Good for&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Streaming table&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Only processes &lt;em&gt;new&lt;/em&gt; data since last time&lt;/td&gt;
&lt;td&gt;ingesting files as they arrive, change-tracking&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Materialized view&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Recomputes completely, every single run&lt;/td&gt;
&lt;td&gt;summaries, aggregations, reports&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Temporary view&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Exists only during the run, never saved&lt;/td&gt;
&lt;td&gt;a quick intermediate step, like a scratch variable&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;
&lt;h2&gt;
  
  
  The pipeline I built: orders, bronze to gold
&lt;/h2&gt;

&lt;p&gt;The demo reads order CSV files from S3 (object storage — think of it as a folder&lt;br&gt;
that lives in the cloud) and produces three tables, all from one Glue job:&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%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdqn88z3nlry28ish2k6r.png" 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%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdqn88z3nlry28ish2k6r.png" alt="Orders ER diagram" width="800" height="267"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;bronze_orders&lt;/code&gt;&lt;/strong&gt; (streaming table) — the raw CSV, every single column kept as
plain text, no cleanup at all. It's a streaming table on purpose: new order files
should be picked up incrementally as they land, not reprocessed from scratch every
time the job runs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;silver_orders&lt;/code&gt;&lt;/strong&gt; (streaming table) — same data, but now typed properly (amounts
become actual numbers, timestamps become actual timestamps), filtered down to only
completed orders with a positive amount, and with a new &lt;code&gt;amount_band&lt;/code&gt; column
(small/medium/large) added.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;gold_sales_summary&lt;/code&gt;&lt;/strong&gt; (a SQL &lt;strong&gt;materialized view&lt;/strong&gt;, not a streaming table) —
order count, total sales, and average order value, grouped by region. This one's a
materialized view on purpose: it's a pure aggregation, and since materialized
views always fully recompute anyway, there's no incremental benefit being given up
here — it's just the right tool for this specific job.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Here's the bit of code I'd flag if you're following along and writing your own&lt;br&gt;
streaming table, because it's easy to get subtly wrong:&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;pyspark&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;pipelines&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;dp&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pyspark.sql&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;DataFrame&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;SparkSession&lt;/span&gt;

&lt;span class="n"&gt;dp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create_streaming_table&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;silver_orders&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;comment&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;...&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="nd"&gt;@dp.append_flow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;target&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;silver_orders&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;ingest_silver_orders&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;DataFrame&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;spark&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;SparkSession&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;active&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="c1"&gt;# readStream.table, not spark.table -- otherwise every run reprocesses
&lt;/span&gt;    &lt;span class="c1"&gt;# bronze's entire history into silver instead of just what's new.
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;spark&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;readStream&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;table&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;bronze_orders&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;select&lt;/span&gt;&lt;span class="p"&gt;(...)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;dp.create_streaming_table()&lt;/code&gt; paired with &lt;code&gt;@dp.append_flow(target=...)&lt;/code&gt; is the&lt;br&gt;
actual, documented Python API for a streaming table in AWS Glue's docs. You'll see&lt;br&gt;
a simpler-looking &lt;code&gt;@dp.table&lt;/code&gt; decorator mentioned informally in some write-ups&lt;br&gt;
(including the original blog post's prose) — that's not the syntax Glue's own&lt;br&gt;
developer guide specifies, so I stuck with the documented one.&lt;/p&gt;
&lt;h2&gt;
  
  
  The infrastructure side (not covered in the original post)
&lt;/h2&gt;

&lt;p&gt;The original walkthrough is hands-on-the-CLI: you run &lt;code&gt;aws glue create-job&lt;/code&gt;&lt;br&gt;
yourself, zip and upload files by hand. My version wraps all of that in one AWS CDK&lt;br&gt;
(Python) stack, so the whole thing is &lt;code&gt;cdk deploy&lt;/code&gt; and done:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;one private, encrypted S3 bucket holding the input CSV, the pipeline's checkpoint
state, and the Data Catalog's storage location — one bucket, three folders inside
it;&lt;/li&gt;
&lt;li&gt;one IAM role (an AWS identity the Glue job "wears" to get permissions) scoped
tightly to just that bucket, plus the one AWS-managed permission set Glue itself
needs to operate;&lt;/li&gt;
&lt;li&gt;a Glue database definition with an explicit storage location — SDP will register
tables into this database, but it won't create the database itself, so it has to
exist first;&lt;/li&gt;
&lt;li&gt;one Glue job, version 6.0, with the two flags that turn SDP on and wire it up to
the Data Catalog;&lt;/li&gt;
&lt;li&gt;a small packaging script that fills in the bucket/database names into
&lt;code&gt;spark-pipeline.yml&lt;/code&gt; right before deployment, and zips everything up for upload.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  Running it
&lt;/h2&gt;

&lt;p&gt;Once deployed, there are two independent dials you turn via the job's arguments:&lt;br&gt;
whether to actually write data or just check everything compiles, and how much to&lt;br&gt;
reprocess.&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;# Dry run: checks the dependency graph and your SQL/Python compile correctly.&lt;/span&gt;
&lt;span class="c"&gt;# Writes zero data. Good first check after a fresh deploy.&lt;/span&gt;
aws glue start-job-run &lt;span class="nt"&gt;--job-name&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$JOB_NAME&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--arguments&lt;/span&gt; &lt;span class="s1"&gt;'{"--conf":"spark.glue.sdp.jobMode=VALIDATE"}'&lt;/span&gt; &lt;span class="nt"&gt;--region&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$AWS_REGION&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

&lt;span class="c"&gt;# The real thing.&lt;/span&gt;
aws glue start-job-run &lt;span class="nt"&gt;--job-name&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$JOB_NAME&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--arguments&lt;/span&gt; &lt;span class="s1"&gt;'{"--conf":"spark.glue.sdp.jobMode=RUN"}'&lt;/span&gt; &lt;span class="nt"&gt;--region&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$AWS_REGION&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

&lt;span class="c"&gt;# Full refresh: throw away every table (including streaming checkpoints) and&lt;/span&gt;
&lt;span class="c"&gt;# recompute everything from zero. Both settings have to share ONE --conf value,&lt;/span&gt;
&lt;span class="c"&gt;# space-separated -- the underlying API only accepts one --conf entry at a time.&lt;/span&gt;
aws glue start-job-run &lt;span class="nt"&gt;--job-name&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$JOB_NAME&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--arguments&lt;/span&gt; &lt;span class="s1"&gt;'{"--conf":"spark.glue.sdp.jobMode=RUN --conf spark.glue.sdp.runMode=--full-refresh-all"}'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--region&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$AWS_REGION&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I wrapped these in a couple of small shell scripts&lt;br&gt;
(&lt;code&gt;scripts/run_job.sh --validate&lt;/code&gt;, &lt;code&gt;--run --wait&lt;/code&gt;, &lt;code&gt;--full-refresh-all&lt;/code&gt;, and&lt;br&gt;
&lt;code&gt;scripts/deploy.sh --upload&lt;/code&gt; for getting sample data into the bucket) — mostly so I&lt;br&gt;
never have to hand-type a JSON string in the middle of a live demo.&lt;/p&gt;

&lt;p&gt;Here's what a successful &lt;code&gt;RUN&lt;/code&gt; actually looks like in the Glue console:&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%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fm21kd6jkwoqiu6w2d350.png" 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%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fm21kd6jkwoqiu6w2d350.png" alt="AWS Glue job run detail screen showing a SUCCEEDED run of orders-sdp-job" width="800" height="328"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The job configuration/run screen after a successful &lt;code&gt;RUN&lt;/code&gt; — Glue version 6.0, the&lt;br&gt;
SDP job arguments, and a completed run.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;And the three tables it registers, visible in the Glue Data Catalog:&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%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fsr5fgxynlhdkkwpbkzlh.png" 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%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fsr5fgxynlhdkkwpbkzlh.png" alt="AWS Glue Data Catalog table list showing bronze_orders, silver_orders, gold_sales_summary" width="800" height="328"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;&lt;code&gt;bronze_orders&lt;/code&gt;, &lt;code&gt;silver_orders&lt;/code&gt;, and &lt;code&gt;gold_sales_summary&lt;/code&gt; — all three, registered&lt;br&gt;
automatically, from one job run.&lt;/em&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  Querying the results
&lt;/h2&gt;

&lt;p&gt;The whole point of getting data into bronze → silver → gold is being able to&lt;br&gt;
actually query it. Once the tables are in the Data Catalog, Amazon Athena can see&lt;br&gt;
them immediately — no extra wiring:&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;SELECT&lt;/span&gt; &lt;span class="n"&gt;region&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;order_count&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;total_sales&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;average_order_value&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;gold_sales_summary&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;total_sales&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fc8hb4xzcelsl30v7e0ol.png" 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%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fc8hb4xzcelsl30v7e0ol.png" alt="Amazon Athena query editor showing results from gold_sales_summary against real sample data" width="800" height="384"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Real output, from the sample &lt;code&gt;orders.csv&lt;/code&gt; in the repo — not a mockup.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What I took away from this
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The core idea really does hold up.&lt;/strong&gt; Three tables, zero hand-written
orchestration code, dependency order worked out entirely from the table
references already sitting in your transformation code.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Streaming table vs. materialized view is a real design decision&lt;/strong&gt;, not just a
style preference — pick the wrong one for a given table and you'll feel it in
either unnecessary reprocessing or stale data, not in an obvious error up front.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reading about a pattern and building it are genuinely different.&lt;/strong&gt; Several small
details only became obvious once I actually deployed and ran this against real
AWS, not just from reading the walkthrough.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you're newer to data engineering: don't let the jargon (DAG, checkpoint, orchestrator, materialized view) intimidate you out of trying this. Every one of those words describes something pretty ordinary once you've seen it in action once — and a three-table demo like this one is a genuinely good size to see it in action for the first time.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Original walkthrough this project is based on&lt;/strong&gt;:&lt;br&gt;
&lt;a href="https://aws.amazon.com/blogs/big-data/build-declarative-etl-pipelines-with-aws-glue-6-0/" rel="noopener noreferrer"&gt;Build declarative ETL pipelines with AWS Glue 6.0&lt;/a&gt;&lt;br&gt;
&lt;strong&gt;(AWS Big Data Blog). Companion repo, if you'd like to try it yourself:&lt;/strong&gt;&lt;br&gt;
&lt;a href="https://github.com/vikneshwara-r-b/glue-sdp-demo" rel="noopener noreferrer"&gt;github.com/vikneshwara-r-b/glue-sdp-demo&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Have questions or want to discuss about data engineering? Connect with me on &lt;a href="https://www.linkedin.com/in/vikneshwararb/" rel="noopener noreferrer"&gt;Linkedin&lt;/a&gt; or drop a comment below.&lt;/p&gt;

</description>
      <category>aws</category>
      <category>python</category>
      <category>beginners</category>
      <category>tutorial</category>
    </item>
  </channel>
</rss>
