<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Data Engineering]]></title><description><![CDATA[Data Engineering]]></description><link>https://data-engineering-ahm.hashnode.dev</link><generator>RSS for Node</generator><lastBuildDate>Sat, 07 Nov 2026 09:32:04 GMT</lastBuildDate><atom:link href="https://data-engineering-ahm.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Building VitalPulse —
Real-Time Patient Monitoring
at Data Engineering Scale]]></title><description><![CDATA[Every second, a patient in an ICU generates dozens of physiological signals. The gap between a dangerous arrhythmia and a clinical response can be measured…]]></description><link>https://data-engineering-ahm.hashnode.dev/building-vitalpulse-real-time-patient-monitoring-at-data-engineering-scale</link><guid isPermaLink="true">https://data-engineering-ahm.hashnode.dev/building-vitalpulse-real-time-patient-monitoring-at-data-engineering-scale</guid><dc:creator><![CDATA[Ahmed Ghannam]]></dc:creator><pubDate>Tue, 31 Mar 2026 09:11:06 GMT</pubDate><content:encoded><![CDATA[<p>Every second, a patient in an ICU generates dozens of physiological signals. The gap between a dangerous arrhythmia and a clinical response can be measured in minutes. This project asks: what would it take to shrink that gap to seconds — using the full modern data engineering stack?</p>
<p>VitalPulse is a fully end-to-end streaming data pipeline I built as a capstone project. It simulates the kind of real-time IoT monitoring system a hospital network would run — ingesting synthetic ECG and heart rate data, processing it through a Medallion Architecture on Google Cloud Platform, and surfacing anomalies on a live dashboard. What started as a graduation project concept became a deep exercise in production data engineering: stream processing, infrastructure-as-code, incremental transformations, and warehouse optimization.</p>
<p>This post walks through every architectural decision, the trade-offs I made, and the lessons learned from building this in a GitHub Codespace from scratch.</p>
<p>◆ ◆ ◆</p>
<h2>The Problem Worth Solving</h2>
<p>Hospitals generate enormous volumes of time-series telemetry. A single monitored patient produces continuous heart rate, blood oxygen (SpO₂), and ECG readings. Across a 500-bed ICU, that's potentially hundreds of thousands of data points per minute. Two cardiac conditions in particular demand immediate detection:</p>
<p>Bradycardia</p>
<h4>Heart rate &lt; 60 BPM</h4>
<p>Can signal cardiac arrest, severe hypothyroidism, or medication toxicity. In sedated ICU patients, sustained bradycardia is a pre-arrest warning sign.</p>
<p>Tachycardia</p>
<h4>Heart rate &gt; 100 BPM</h4>
<p>May indicate sepsis, pulmonary embolism, or uncontrolled arrhythmia. Sustained tachycardia dramatically increases cardiac workload and mortality risk.</p>
<p>Existing hospital systems often rely on batch exports, manual nursing observation, or proprietary monitoring hardware with no programmatic access. VitalPulse demonstrates what a modern, cloud-native streaming pipeline looks like — one that could realistically serve as the data backbone for an alerting system.</p>
<p>Design constraint</p>
<p>The pipeline must detect and expose anomalies within 30 seconds of a reading being generated. This drives every architectural decision downstream — from micro-batch sizing in Spark to how the Gold layer is materialized in BigQuery.</p>
<p>10+ messages / second</p>
<p>30s max latency target</p>
<p>3 medallion layers</p>
<p>◆ ◆ ◆</p>
<h2>Architecture Overview</h2>
<p>The pipeline follows a classic Lambda-adjacent pattern: a real-time ingestion path from Kafka through Spark into GCS, followed by a batch transformation path from GCS through dbt into BigQuery. Orchestration sits above both paths via Prefect.</p>
<p>pipeline-architecture.txt</p>
<pre><code class="language-plaintext">  [ Python Producer ]  —  Synthetic ECG @ 10 msg/s
          │
          ▼  publish
  [ Kafka Topic: patient-vitals ]  (Docker, localhost:9092)
          │
          ▼  subscribe (Spark Structured Streaming)
  [ Spark Streaming Job ]  —  30s micro-batch trigger
          │
          ▼  sink as JSON
  [ GCS Bronze Layer ]  gs://...vitalpulse-bronze/bronze/vitals/
          │
          ▼  dbt source → incremental model
  [ BigQuery Silver ]  vitalpulse_silver.stg_vitals
          │         └── partitioned by event_time (hourly)
          │         └── clustered by patient_id, status
          ▼  dbt ref()
  [ BigQuery Gold ]   vitalpulse_gold.anomalies
                     vitalpulse_gold.avg_hr_per_minute
          │
          ▼  SQL queries
  [ Streamlit Dashboard ]  — 2 tiles, auto-refresh 30s
</code></pre>
<p><code>Prefect ────────────────────────────────────────────────▶ orchestrates dbt runs (cron: */5)   Terraform ──────────────────────────────────────────────▶ provisions GCS + BigQuery</code></p>
<p>A key design choice here: Kafka runs locally inside the Codespace via Docker during development. This is intentional — it eliminates cloud messaging costs while developing, keeps latency minimal for testing, and mirrors the production topology. In a real deployment you'd swap the broker for GCP Pub/Sub with a single config change.</p>
<p>◆ ◆ ◆</p>
<h2>The Medallion Architecture</h2>
<p>Rather than landing data directly into a query-ready table, VitalPulse uses three progressive refinement layers. Each layer has a clear contract: Bronze never loses data, Silver guarantees quality and type safety, Gold guarantees query performance.</p>
<p>Bronze</p>
<h4>Raw Signals</h4>
<p>Immutable JSON files on GCS. Every Kafka message lands here unmodified — the audit trail. If a downstream job corrupts data, you can replay from Bronze.</p>
<p>Silver</p>
<h4>Cleaned Vitals</h4>
<p>Parsed timestamps, validated heart rate ranges (20–250 BPM), null-filtered, UUID primary keys. Incremental — only new rows since <code>MAX(event_time)</code> are loaded.</p>
<p>Gold</p>
<h4>Anomalies + Aggregations</h4>
<p>Pre-aggregated for the dashboard. Anomalies include a 10-reading rolling average per patient to surface sustained conditions, not single-spike noise.</p>
<p>The Bronze-to-Silver boundary is where most data quality work happens. A common mistake is to skip Bronze and write "cleaned" data directly from Spark — which means you can never re-process historical data if your cleaning logic changes. The immutable Bronze layer is insurance.</p>
<p>The immutable Bronze layer isn't overhead — it's the guarantee that a future version of your pipeline can replay the entire history of every heartbeat ever recorded, with any transformation logic you choose.</p>
<p>◆ ◆ ◆</p>
<h2>Tech Stack &amp; Why Each Tool</h2>
<table>
<thead>
<tr>
<th>Tool</th>
<th>Role</th>
<th>Why not the alternative</th>
</tr>
</thead>
<tbody><tr>
<td>Apache Kafkastream</td>
<td>Message broker for ECG signals</td>
<td>Kinesis locks you to AWS. Pub/Sub is excellent but adds GCP IAM complexity during development. Kafka runs locally in Docker with identical API semantics to production.</td>
</tr>
<tr>
<td>Spark Structured Streamingstream</td>
<td>Consumes Kafka, sinks to GCS</td>
<td>Flink has lower latency but a steeper operational curve. Dataflow is elegant but expensive. PySpark's micro-batch model with 30s triggers is the right trade-off for 30s latency targets.</td>
</tr>
<tr>
<td>BigQuerycloud</td>
<td>Silver + Gold data warehouse</td>
<td>Snowflake is excellent but costs more at this scale. BigQuery's serverless model means zero infrastructure to manage and near-instant query start times.</td>
</tr>
<tr>
<td>dbttransform</td>
<td>SQL transformations with lineage</td>
<td>Raw SQL scripts in Airflow have no lineage, testing, or documentation. dbt gives you incremental models, ref() dependency graphs, and built-in data tests for free.</td>
</tr>
<tr>
<td>TerraformIaC</td>
<td>Provisions GCS + BigQuery</td>
<td>Clicking through the GCP console is not reproducible. Terraform means the entire infrastructure can be recreated with <code>terraform apply</code> in a fresh account.</td>
</tr>
<tr>
<td>Prefectorchestration</td>
<td>Schedules dbt runs every 5 min</td>
<td>Airflow is powerful but heavy — the setup overhead for a single-developer project is enormous. Prefect's Python-native flows and built-in UI make it the better choice here.</td>
</tr>
</tbody></table>
<p>◆ ◆ ◆</p>
<h2>The Data Producer: Simulating Reality</h2>
<p>Good synthetic data is harder to generate than it looks. A random heart rate between 0 and 200 would be statistically useless — it would make every anomaly detection algorithm look trivially accurate. The VitalPulse producer uses a weighted distribution that mirrors real clinical populations:</p>
<p>python<br />producer/vitals_producer.py  </p>
<pre><code class="language-plaintext">def generate_vital() -&gt; dict:
    hr = random.choices(
        [
            random.randint(40, 59),    # bradycardia zone
            random.randint(60, 100),   # normal sinus rhythm
            random.randint(101, 150),  # tachycardia zone
        ],
        weights=[0.10, 0.80, 0.10]   # 10% / 80% / 10% split
    )[0]
    return {
        "patient_id": random.choice(PATIENT_IDS),
        "heart_rate": hr,
        "spo2":       round(random.uniform(94.0, 100.0), 1),
        "status":     classify(hr),
        "timestamp":  datetime.now(timezone.utc).isoformat(),
    }
</code></pre>
<p>The 10/80/10 split is clinically inspired — roughly 10% of hospitalized patients at any given time will have a notable arrhythmia. This keeps anomaly detection non-trivial while making the Normal category dominant, which is realistic and which stresses the pipeline's ability to filter signal from noise.</p>
<p>◆ ◆ ◆</p>
<h2>The Big Engineering Bet: BigQuery Partitioning</h2>
<p>This is the decision that separates a toy pipeline from a production-grade one. The Silver table <code>stg_vitals</code> is partitioned by <code>event_time</code> at hourly granularity and clustered by <code>patient_id</code> and <code>status</code>. Here's why this matters enormously:</p>
<p>At 10 messages per second, one day of data is ~864,000 rows. One month is ~26 million. Without partitioning, the dashboard's "last 60 minutes" query scans the entire table on every page load. With hourly partitioning, it scans 1–2 partitions — roughly 3,600 rows. BigQuery bills by bytes scanned. The cost difference at production scale is the difference between a viable product and an unaffordable one.</p>
<p>sql<br />dbt/models/silver/stg_vitals.sql  </p>
<pre><code class="language-plaintext">-- dbt config block: declares partitioning + clustering
{{ config(
    materialized  = 'incremental',
    partition_by  = {
        "field":       "event_time",
        "data_type":   "timestamp",
        "granularity": "hour"          -- key decision
    },
    cluster_by    = ["patient_id", "status"],
    unique_key    = "row_id"
) }}
</code></pre>
<p><code>SELECT   GENERATE_UUID() AS row_id,   patient_id,   CAST(heart_rate AS INT64) AS heart_rate,   PARSE_TIMESTAMP('%Y-%m-%dT%H:%M:%E*SZ', timestamp) AS event_time,   status   FROM {{ source('bronze', 'vitals_raw') }}   WHERE heart_rate BETWEEN 20 AND 250 -- data quality gate</code></p>
<p><code>{% if is_incremental() %}   AND PARSE_TIMESTAMP(...) &gt; (SELECT MAX(event_time) FROM {{ this }})   {% endif %}</code></p>
<p>The <code>is_incremental()</code> block is the other critical piece. On the first run, dbt loads all historical data. On every subsequent run — triggered by Prefect every 5 minutes — it only processes rows newer than the current <code>MAX(event_time)</code>. This makes the dbt run O(new data) rather than O(all data), which is the difference between a 2-second run and a 4-minute one at scale.</p>
<p>◆ ◆ ◆</p>
<h2>Anomaly Detection: Two Layers of Signal</h2>
<p>A single elevated heart rate reading can be noise — a patient who just walked to the bathroom, or a sensor artifact. Clinically meaningful tachycardia is sustained. VitalPulse catches anomalies at two levels:</p>
<p>01</p>
<p>Point classification (Producer)</p>
<p>Every reading is tagged at the source with <code>Bradycardia</code>, <code>Normal</code>, or <code>Tachycardia</code> based on instantaneous heart rate thresholds. This classification rides with the message through Kafka and lands in the Bronze and Silver layers.</p>
<p>02</p>
<p>Rolling average (Gold dbt model)</p>
<p>The Gold <code>anomalies</code> model computes a 10-reading rolling average per patient using a <code>ROWS BETWEEN 9 PRECEDING AND CURRENT ROW</code> window function. A patient whose instantaneous reading is 105 BPM but whose rolling average is 72 BPM is very different from one whose rolling average is also 105 BPM.</p>
<p>sql<br />dbt/models/gold/anomalies.sql  </p>
<pre><code class="language-plaintext">SELECT
    patient_id,
    status,
    heart_rate,
    event_time,
    AVG(heart_rate) OVER (
        PARTITION BY patient_id
        ORDER BY event_time
        ROWS BETWEEN 9 PRECEDING AND CURRENT ROW
    ) AS rolling_avg_10   -- sustained vs. spike anomalies
FROM {{ ref('stg_vitals') }}
WHERE status != 'Normal'
ORDER BY event_time DESC
</code></pre>
<p>◆ ◆ ◆</p>
<h2>Infrastructure as Code: Why Terraform is Non-Negotiable</h2>
<p>Many data engineering tutorials skip IaC and tell you to "create a bucket in the console." This is fine for learning but catastrophic for anything resembling production. If your GCP project gets accidentally deleted, your pipeline is unrecoverable without IaC. If a teammate needs to spin up an identical environment, they can't.</p>
<p>VitalPulse provisions everything with Terraform. The entire cloud infrastructure — GCS bucket, Silver dataset, Gold dataset — is created with a single command:</p>
<p>bash<br />terminal  </p>
<pre><code class="language-plaintext">cd terraform
terraform init
terraform apply -var="project_id=$GCP_PROJECT_ID" -auto-approve
</code></pre>
<p><code># Output:   # Apply complete! Resources: 3 added, 0 changed, 0 destroyed.   # bronze_bucket = "my-project-vitalpulse-bronze"   # silver_dataset = "vitalpulse_silver"   # gold_dataset = "vitalpulse_gold"</code></p>
<p>The reproducibility implications are significant: anyone can fork this repository, open it in a new GitHub Codespace, add their GCP credentials, and have the entire pipeline running in under 15 minutes. That's what full marks on the reproducibility criterion actually requires.</p>
<p>◆ ◆ ◆</p>
<h2>The Dashboard: Two Tiles That Tell the Story</h2>
<p>The dashboard has two visualization tiles, each addressing a distinct analytical question a clinical team would ask:</p>
<p><strong>Tile 1 — Temporal: Average Heart Rate per Minute.</strong> A line chart across the last 60 minutes. This answers "is the population trending toward stress or stability?" It uses the pre-aggregated <code>avg_hr_per_minute</code> Gold table so the query is near-instant regardless of how much data is in Silver.</p>
<p><strong>Tile 2 — Categorical: Status Distribution.</strong> A bar chart showing the count of Normal, Bradycardia, and Tachycardia readings in the last hour. This answers "what fraction of my patient population is currently in a concerning state?" A sudden spike in Bradycardia readings could indicate a systemic issue — a medication administration error, for example.</p>
<p>The dashboard auto-refreshes every 30 seconds via Streamlit's <code>st.rerun()</code>, which keeps the latency from sensor to screen within the 30-second design target.</p>
<p>◆ ◆ ◆</p>
<h2>Lessons Learned</h2>
<h3>What worked extremely well</h3>
<p>The Medallion Architecture's separation of concerns paid off immediately when I needed to update the Silver cleaning logic. Because Bronze is immutable, I could drop and rebuild Silver with the corrected dbt model without losing any historical data. This would have been impossible with a single-table architecture.</p>
<p>Prefect's Python-native approach to orchestration was the right call for a solo project. Defining a DAG as a decorated Python function — rather than a YAML file or an Airflow DAG object — reduced the cognitive overhead dramatically.</p>
<h3>What I'd change</h3>
<p>The Spark micro-batch window of 30 seconds is a reasonable starting point but introduces a fixed 0–30 second jitter in latency. In a production system, you'd tune this aggressively — possibly down to 5 seconds — or switch to a genuine streaming mode with Spark's <code>trigger(continuous="1 second")</code> option. For clinical alerting, that jitter is the difference between catching an event and missing it.</p>
<p>I'd also replace the local Docker Kafka with GCP Pub/Sub in any real deployment. The operational simplicity of a fully managed broker — no Zookeeper, no replication factor management, no partition rebalancing — is worth the trade-off in flexibility.</p>
<h3>The Codespace advantage</h3>
<p>Building entirely in GitHub Codespaces forced me to think carefully about reproducibility from day one. When your development environment is defined in a <code>devcontainer.json</code> file, you can never fall into the "works on my machine" trap. Every collaborator — and every peer reviewer — gets the exact same environment. This constraint made the project significantly better.</p>
<p>◆ ◆ ◆</p>
<h2>What's Next</h2>
<p>VitalPulse in its current form is a solid proof-of-concept. The obvious next steps toward a production-grade system would be: replacing Docker Kafka with GCP Pub/Sub for fully managed messaging; adding a real-time alerting layer using Cloud Functions triggered on Gold table writes; building out a proper CI/CD pipeline with GitHub Actions running dbt tests on every pull request; and extending the anomaly detection to include SpO₂ thresholds for hypoxemia detection.</p>
<p>The data engineering foundation — the Medallion Architecture, the incremental dbt models, the partitioned and clustered BigQuery tables — is designed to support exactly these extensions without requiring a rewrite. That's the hallmark of a well-designed pipeline: it grows without breaking.</p>
<p>Key takeaway</p>
<p>The best streaming pipeline is not the one with the lowest latency or the most impressive tech stack. It is the one that is reproducible, observable, testable, and whose data can be trusted at every layer. Get those four things right, and the rest is refinement.</p>
<p>#DataEngineering<br />#ApacheKafka<br />#SparkStreaming<br />#dbt<br />#BigQuery<br />#Terraform<br />#GCP<br />#Prefect<br />#MedallionArchitecture<br />#IoT<br />#HealthcareData<br />#RealTimeStreaming  </p>
<h4>Explore the full project</h4>
<p>Complete source code, Terraform configs, dbt models, and setup guide on GitHub.</p>
<p><a href="https://github.com">View on GitHub</a></p>
]]></content:encoded></item><item><title><![CDATA[Processing 4 Million NYC Taxi Trips with Apache Spark — DE Zoomcamp Week 6]]></title><description><![CDATA[Week 6 of the Data Engineering Zoomcamp by DataTalksClub pushed me into distributed batch processing with Apache Spark. This post walks through every…]]></description><link>https://data-engineering-ahm.hashnode.dev/processing-4-million-nyc-taxi-trips-with-apache-spark-de-zoomcamp-week-6</link><guid isPermaLink="true">https://data-engineering-ahm.hashnode.dev/processing-4-million-nyc-taxi-trips-with-apache-spark-de-zoomcamp-week-6</guid><dc:creator><![CDATA[Ahmed Ghannam]]></dc:creator><pubDate>Fri, 13 Mar 2026 23:48:10 GMT</pubDate><content:encoded><![CDATA[<blockquote>
<p><strong>Week 6 of the Data Engineering Zoomcamp by DataTalksClub</strong> pushed me into distributed batch processing with Apache Spark. This post walks through every question, every line of code, and every lesson I took away from it.</p>
</blockquote>
<hr />
<h2>🧠 The Context</h2>
<p>After five weeks of SQL, Docker, dbt, Airflow, and cloud infrastructure — week six landed differently. This wasn't about orchestrating pipelines or modelling data. It was about <strong>scale</strong>: what happens when your dataset is too big to think about row-by-row, and you need a distributed engine to plan, optimise, and execute the computation for you.</p>
<p>The dataset: <strong>NYC Yellow Taxi trips for November 2025</strong> — over 4 million rows, ~400MB of raw Parquet.</p>
<p>The tool: <strong>Apache Spark 3.5.3</strong> via PySpark.</p>
<p>The mission: answer 6 analytical questions using DataFrames, SQL, and file I/O — from scratch.</p>
<hr />
<h2>🏗️ Project Structure</h2>
<p>Before touching any data, I set up a clean, reproducible repo:</p>
<pre><code class="language-plaintext">de-zoomcamp-2026/
└── module-6-spark/
    ├── homework.py          ← all 6 questions in one script
    ├── requirements.txt     ← pyspark==3.5.3
    ├── README.md
    ├── .gitignore
    ├── data/                ← gitignored (downloaded at runtime)
    │   ├── yellow_tripdata_2025-11.parquet
    │   └── taxi_zone_lookup.csv
    └── output/              ← gitignored (generated at runtime)
        └── yellow_2025_11_repartitioned/
            ├── part-00000-*.parquet
            ├── part-00001-*.parquet
            ├── part-00002-*.parquet
            └── part-00003-*.parquet
</code></pre>
<p>Raw data files are never committed — the script downloads them automatically on first run. <code>.gitkeep</code> placeholders keep the folder structure visible in the repo.</p>
<hr />
<h2>⚙️ Environment Setup</h2>
<p>Spark runs on the JVM, so Java is a hard requirement before anything else.</p>
<pre><code class="language-bash"># 1. Install Java
sudo apt-get install default-jdk -y      # Linux
brew install openjdk                      # macOS

# 2. Create a virtual environment
python3 -m venv .venv
source .venv/bin/activate

# 3. Install PySpark
pip install pyspark==3.5.3

# 4. Verify
python3 -c "import pyspark; print(pyspark.__version__)"
# → 3.5.3
</code></pre>
<hr />
<h2>🔥 The Questions — and How I Solved Each One</h2>
<h3>Question 1 — What is <code>spark.version</code>?</h3>
<p><strong>Answer:</strong> <code>3.5.3</code></p>
<p>The first question is a warm-up: spin up a local SparkSession and print the version.</p>
<pre><code class="language-python">from pyspark.sql import SparkSession

spark = SparkSession.builder \
    .master("local[*]") \
    .appName("de-zoomcamp-hw6") \
    .config("spark.sql.shuffle.partitions", "4") \
    .getOrCreate()

print(spark.version)
# → 3.5.3
</code></pre>
<p><code>local[*]</code> tells Spark to use all available CPU cores on the local machine — no cluster needed. This is the standard development mode for PySpark.</p>
<p>Setting <code>spark.sql.shuffle.partitions</code> to <code>4</code> avoids the default of 200 shuffle partitions, which is wildly oversized for a local workload.</p>
<hr />
<h3>Question 2 — Average Parquet File Size After Repartitioning to 4?</h3>
<p><strong>Answer: ~25 MB</strong></p>
<p>Read the November 2025 Yellow Taxi parquet, repartition to exactly 4 partitions, write back to parquet, then measure the output files.</p>
<pre><code class="language-python">import os

PARQUET_URL  = "https://d37ci6vzurychx.cloudfront.net/trip-data/yellow_tripdata_2025-11.parquet"
PARQUET_FILE = "data/yellow_tripdata_2025-11.parquet"
OUTPUT_DIR   = "output/yellow_2025_11_repartitioned"

# Download if not already present
import urllib.request
if not os.path.exists(PARQUET_FILE):
    urllib.request.urlretrieve(PARQUET_URL, PARQUET_FILE)

# Read → repartition → write
df = spark.read.parquet(PARQUET_FILE)
df.repartition(4).write.mode("overwrite").parquet(OUTPUT_DIR)

# Measure each output file
parquet_files = [f for f in os.listdir(OUTPUT_DIR) if f.endswith(".parquet")]
sizes_mb      = [os.path.getsize(f"{OUTPUT_DIR}/{f}") / (1024 ** 2) for f in parquet_files]
avg_mb        = sum(sizes_mb) / len(sizes_mb)

print(f"Average size: {avg_mb:.2f} MB")
# → ~25 MB
</code></pre>
<p><strong>What's happening here:</strong> <code>repartition(4)</code> triggers a <strong>full shuffle</strong> — every row is redistributed across 4 evenly-sized partitions. Writing to Parquet produces one file per partition. The raw dataset is ~400MB uncompressed; Snappy compression (Parquet's default) brings each of the 4 files down to roughly 25MB.</p>
<blockquote>
<p>💡 <strong>Production tip:</strong> <code>repartition()</code> is expensive because it shuffles all data. When <em>reducing</em> partition count, prefer <code>coalesce()</code> — it avoids the shuffle by merging local partitions. Only reach for <code>repartition()</code> when you need perfectly balanced output files.</p>
</blockquote>
<hr />
<h3>Question 3 — How Many Trips on November 15th?</h3>
<p><strong>Answer: 62,610</strong></p>
<p>Filter the DataFrame to only rows where the pickup date is 2025-11-15.</p>
<pre><code class="language-python">from pyspark.sql.functions import col, to_date

count_nov15 = (
    df.filter(to_date(col("tpep_pickup_datetime")) == "2025-11-15")
      .count()
)

print(f"Trips on Nov 15: {count_nov15:,}")
# → 62,610
</code></pre>
<p><strong>Why</strong> <code>to_date()</code> <strong>and not a string comparison?</strong> The raw column is a timestamp. Comparing it directly to a date string would fail or silently return wrong results. <code>to_date()</code> extracts just the date part, making the filter clean and correct.</p>
<p><strong>Predicate pushdown:</strong> Because we're reading Parquet, Spark pushes this filter <em>into the file scan</em> — it skips entire row groups that can't match, rather than loading all data and then filtering. This is one of Parquet's core performance advantages.</p>
<hr />
<h3>Question 4 — Longest Trip in Hours?</h3>
<p><strong>Answer: 90.6 hours</strong></p>
<p>Compute the duration of each trip using <code>unix_timestamp</code> arithmetic, then find the maximum.</p>
<pre><code class="language-python">from pyspark.sql.functions import unix_timestamp, max as spark_max

df_duration = df.withColumn(
    "trip_duration_hours",
    (unix_timestamp("tpep_dropoff_datetime") -
     unix_timestamp("tpep_pickup_datetime")) / 3600.0
)

max_hours = (
    df_duration
    .select(spark_max("trip_duration_hours"))
    .collect()[0][0]
)

print(f"Longest trip: {max_hours:.1f} hours")
# → 90.6 hours
</code></pre>
<p><code>unix_timestamp()</code> converts datetime columns to seconds-since-epoch integers. Subtracting gives duration in seconds; dividing by 3600 converts to hours. Then <code>spark_max()</code> (aliased to avoid shadowing Python's built-in <code>max</code>) finds the longest trip across all partitions.</p>
<p>90.6 hours is almost 4 days — almost certainly a data quality issue (a meter left running), but the question asks for the mathematical maximum, not a filtered one.</p>
<hr />
<h3>Question 5 — Which Port Does the Spark UI Run On?</h3>
<p><strong>Answer: 4040</strong></p>
<p>No code needed for this one — it's a Spark configuration fact. The web dashboard for any running Spark application is available at:</p>
<pre><code class="language-plaintext">http://localhost:4040
</code></pre>
<p>If 4040 is occupied, Spark increments to 4041, 4042, and so on — it'll tell you which port it bound to in the startup logs.</p>
<p>The UI shows:</p>
<ul>
<li><p><strong>Jobs</strong> — execution status of each action</p>
</li>
<li><p><strong>Stages</strong> — breakdown of tasks within each job</p>
</li>
<li><p><strong>Storage</strong> — cached RDDs and DataFrames</p>
</li>
<li><p><strong>SQL</strong> — query plans with physical and logical DAGs</p>
</li>
<li><p><strong>Environment</strong> — all Spark configuration values</p>
</li>
</ul>
<blockquote>
<p>💡 <strong>Pro tip:</strong> Add <code>input("Press Enter to stop Spark...")</code> just before <code>spark.stop()</code> to keep the UI alive after your script finishes — invaluable for inspecting completed job DAGs.</p>
</blockquote>
<hr />
<h3>Question 6 — Least Frequent Pickup Zone?</h3>
<p><strong>Answer: Governor's Island / Ellis Island / Liberty Island</strong></p>
<p>This question requires joining the trip data with a zone lookup CSV, then aggregating by pickup zone.</p>
<pre><code class="language-python">import urllib.request

ZONES_URL  = "https://d37ci6vzurychx.cloudfront.net/misc/taxi_zone_lookup.csv"
ZONES_FILE = "data/taxi_zone_lookup.csv"

if not os.path.exists(ZONES_FILE):
    urllib.request.urlretrieve(ZONES_URL, ZONES_FILE)

# Load zones CSV
zones = spark.read.option("header", "true").csv(ZONES_FILE)

# Register both as SQL temp views
df.createOrReplaceTempView("trips")
zones.createOrReplaceTempView("zones")

# SQL join + aggregation
result = spark.sql("""
    SELECT
        z.Zone,
        COUNT(*) AS pickup_count
    FROM trips t
    JOIN zones z
      ON CAST(t.PULocationID AS INT) = CAST(z.LocationID AS INT)
    GROUP BY z.Zone
    ORDER BY pickup_count ASC
    LIMIT 10
""")

result.show(truncate=False)
</code></pre>
<p>Output (abridged):</p>
<pre><code class="language-plaintext">+-----------------------------------------------+------------+
|Zone                                           |pickup_count|
+-----------------------------------------------+------------+
|Governor's Island/Ellis Island/Liberty Island  |1           |
|Arden Heights                                  |5           |
|Rikers Island                                  |8           |
...
</code></pre>
<p><strong>Why the explicit</strong> <code>CAST</code><strong>?</strong> <code>PULocationID</code> is stored as a long integer in the Parquet schema, but <code>LocationID</code> comes in as a string from the CSV. Without the cast, Spark would either throw a type error or — worse — perform a cross-join that produces no matches silently. Always cast explicitly when joining across inferred-schema sources.</p>
<p><strong>Why temp views over DataFrame joins?</strong> Either works, but <code>createOrReplaceTempView</code> lets you write plain SQL for aggregation-heavy queries. The optimizer handles both identically under the hood.</p>
<hr />
<h2>📊 Answer Summary</h2>
<table>
<thead>
<tr>
<th>#</th>
<th>Question</th>
<th>Answer</th>
</tr>
</thead>
<tbody><tr>
<td>Q1</td>
<td>Spark version</td>
<td><strong>3.5.3</strong></td>
</tr>
<tr>
<td>Q2</td>
<td>Avg parquet file size (4 partitions)</td>
<td><strong>~25 MB</strong></td>
</tr>
<tr>
<td>Q3</td>
<td>Trips on November 15, 2025</td>
<td><strong>62,610</strong></td>
</tr>
<tr>
<td>Q4</td>
<td>Longest trip in hours</td>
<td><strong>90.6 hrs</strong></td>
</tr>
<tr>
<td>Q5</td>
<td>Spark UI port</td>
<td><strong>4040</strong></td>
</tr>
<tr>
<td>Q6</td>
<td>Least frequent pickup zone</td>
<td><strong>Governor's Island/Ellis Island/Liberty Island</strong></td>
</tr>
</tbody></table>
<hr />
<h2>🗂️ The Complete Script</h2>
<p>Here's the full <code>homework.py</code> — all 6 questions in one self-contained, runnable file:</p>
<pre><code class="language-python">"""
Module 6 Homework — Batch Processing with Spark
Data Engineering Zoomcamp 2026
"""

import os
import urllib.request
from pyspark.sql import SparkSession
from pyspark.sql.functions import col, to_date, unix_timestamp, max as spark_max

# ── Config ────────────────────────────────────────────────
PARQUET_URL  = "https://d37ci6vzurychx.cloudfront.net/trip-data/yellow_tripdata_2025-11.parquet"
ZONES_URL    = "https://d37ci6vzurychx.cloudfront.net/misc/taxi_zone_lookup.csv"
PARQUET_FILE = "data/yellow_tripdata_2025-11.parquet"
ZONES_FILE   = "data/taxi_zone_lookup.csv"
OUTPUT_DIR   = "output/yellow_2025_11_repartitioned"

def download_if_missing(url: str, dest: str) -&gt; None:
    os.makedirs(os.path.dirname(dest), exist_ok=True)
    if not os.path.exists(dest):
        print(f"Downloading {dest} ...")
        urllib.request.urlretrieve(url, dest)

# ── Spark Session ─────────────────────────────────────────
spark = SparkSession.builder \
    .master("local[*]") \
    .appName("de-zoomcamp-hw6") \
    .config("spark.sql.shuffle.partitions", "4") \
    .getOrCreate()

spark.sparkContext.setLogLevel("WARN")

# Q1 — Version
print(f"\nQ1 | Spark version: {spark.version}")

# ── Download ──────────────────────────────────────────────
download_if_missing(PARQUET_URL, PARQUET_FILE)
download_if_missing(ZONES_URL,   ZONES_FILE)

# ── Read ──────────────────────────────────────────────────
df = spark.read.parquet(PARQUET_FILE)
print(f"Rows loaded: {df.count():,}")

# Q2 — Repartition + measure
df.repartition(4).write.mode("overwrite").parquet(OUTPUT_DIR)
files    = [f for f in os.listdir(OUTPUT_DIR) if f.endswith(".parquet")]
sizes_mb = [os.path.getsize(f"{OUTPUT_DIR}/{f}") / (1024 ** 2) for f in files]
print(f"\nQ2 | Avg parquet size: {sum(sizes_mb)/len(sizes_mb):.2f} MB")

# Q3 — Nov 15 count
count = df.filter(to_date(col("tpep_pickup_datetime")) == "2025-11-15").count()
print(f"\nQ3 | Trips on 2025-11-15: {count:,}")

# Q4 — Longest trip
df_dur = df.withColumn(
    "hours",
    (unix_timestamp("tpep_dropoff_datetime") -
     unix_timestamp("tpep_pickup_datetime")) / 3600.0
)
max_h = df_dur.select(spark_max("hours")).collect()[0][0]
print(f"\nQ4 | Longest trip: {max_h:.1f} hours")

# Q5 — UI port
print("\nQ5 | Spark UI port: 4040  →  http://localhost:4040")

# Q6 — Least frequent zone
zones = spark.read.option("header", "true").csv(ZONES_FILE)
df.createOrReplaceTempView("trips")
zones.createOrReplaceTempView("zones")
result = spark.sql("""
    SELECT z.Zone, COUNT(*) AS pickup_count
    FROM   trips t
    JOIN   zones z ON CAST(t.PULocationID AS INT) = CAST(z.LocationID AS INT)
    GROUP  BY z.Zone
    ORDER  BY pickup_count ASC
    LIMIT  10
""")
print("\nQ6 | Least frequent pickup zones:")
result.show(truncate=False)

spark.stop()
print("\nDone ✅")
</code></pre>
<hr />
<h2>🚀 Reproduce It in 4 Commands</h2>
<pre><code class="language-bash">git clone https://github.com/&lt;your-username&gt;/de-zoomcamp-2026.git
cd de-zoomcamp-2026/module-6-spark
pip install -r requirements.txt
python homework.py
</code></pre>
<p>Data downloads automatically. All 6 answers print to stdout.</p>
<hr />
<h2>💡 Key Lessons from Week 6</h2>
<p><strong>1. Spark doesn't run your code — it plans it.</strong> Every transformation (<code>filter</code>, <code>withColumn</code>, <code>join</code>) is lazy. Nothing executes until you call an action (<code>count</code>, <code>collect</code>, <code>show</code>, <code>write</code>). This lets the Catalyst optimizer reorder, prune, and push operations before a single byte of data moves.</p>
<p><strong>2.</strong> <code>repartition</code> <strong>vs</strong> <code>coalesce</code> <strong>matters in production.</strong> <code>repartition(N)</code> always shuffles. <code>coalesce(N)</code> (when reducing) merges local partitions without a shuffle. For writing balanced output files, <code>repartition</code> is correct. For everything else, <code>coalesce</code> is faster.</p>
<p><strong>3. Schema type mismatches are silent killers.</strong> Joining a Parquet integer column to a CSV string column without explicit <code>CAST</code> produces empty results or type errors — not a helpful error message. Always inspect schemas before joining across source types.</p>
<p><strong>4. The Spark UI at port 4040 is non-optional.</strong> It tells you which stages are slow, which tasks are skewed, how much data shuffled, and what the physical query plan actually looks like. Reading it turned abstract "Spark concepts" into concrete observations.</p>
<p><strong>5. Parquet columnar format + predicate pushdown = fast filters.</strong> The November 15th count query didn't scan all 4M rows. Spark read only the row groups that could contain Nov 15 data, skipping the rest at the file level. This is why Parquet beats CSV at scale.</p>
<hr />
<h2>🔗 Resources</h2>
<ul>
<li><p>📦 <a href="https://github.com/">Full source code on GitHub</a></p>
</li>
<li><p>🎓 <a href="https://github.com/DataTalksClub/data-engineering-zoomcamp">DataTalksClub DE Zoomcamp</a></p>
</li>
<li><p>📝 <a href="https://courses.datatalks.club/de-zoomcamp-2026/homework/hw6">Submit Homework</a></p>
</li>
<li><p>📄 <a href="https://www.nyc.gov/site/tlc/about/tlc-trip-record-data.page">NYC TLC Trip Data</a></p>
</li>
<li><p>🔥 <a href="https://spark.apache.org/docs/latest/">Apache Spark Docs</a></p>
</li>
<li><p>🐍 <a href="https://spark.apache.org/docs/latest/api/python/">PySpark API Reference</a></p>
</li>
</ul>
<hr />
<h2>🏷️ Tags</h2>
<p><code>apache-spark</code> · <code>pyspark</code> · <code>data-engineering</code> · <code>python</code> · <code>de-zoomcamp</code> · <code>batch-processing</code> · <code>parquet</code> · <code>big-data</code> · <code>datatalksclub</code> · <code>zoomcamp2026</code></p>
<hr />
<p><em>This post is part of my public learning journey through the</em> <a href="https://github.com/DataTalksClub/data-engineering-zoomcamp"><em>Data Engineering Zoomcamp 2026</em></a> <em>by DataTalksClub. Following along? Drop a comment — I'd love to compare notes.</em></p>
]]></content:encoded></item><item><title><![CDATA[Building Production-Ready Data Pipelines with Bruin: A Deep Dive into NYC Taxi Analytics]]></title><description><![CDATA[A comprehensive guide to modern data engineering using Bruin, DuckDB, and best practices from the Data Engineering Zoomcamp In the ever-evolving landscape…]]></description><link>https://data-engineering-ahm.hashnode.dev/building-production-ready-data-pipelines-with-bruin-a-deep-dive-into-nyc-taxi-analytics</link><guid isPermaLink="true">https://data-engineering-ahm.hashnode.dev/building-production-ready-data-pipelines-with-bruin-a-deep-dive-into-nyc-taxi-analytics</guid><dc:creator><![CDATA[Ahmed Ghannam]]></dc:creator><pubDate>Sat, 28 Feb 2026 21:23:11 GMT</pubDate><content:encoded><![CDATA[<p><em>A comprehensive guide to modern data engineering using Bruin, DuckDB, and best practices from the Data Engineering Zoomcamp</em></p>
<hr />
<h2><strong>🚀 Introduction</strong></h2>
<p>In the ever-evolving landscape of data engineering, the tools we choose can make or break our productivity. Recently, I completed an exciting project as part of the Data Engineering Zoomcamp, where I built a complete ELT pipeline for NYC taxi data using <strong>Bruin</strong> - a revolutionary data platform tool that's changing how we think about pipeline development.</p>
<p>This blog post walks through my journey, the architecture decisions, and the powerful features that make Bruin a game-changer for data engineers.</p>
<hr />
<h2><strong>🏗️ The Problem: NYC Taxi Analytics at Scale</strong></h2>
<p>The NYC Taxi and Limousine Commission releases millions of trip records monthly. Processing this data efficiently requires:</p>
<ul>
<li><p>Reliable data ingestion</p>
</li>
<li><p>Incremental processing strategies</p>
</li>
<li><p>Data quality enforcement</p>
</li>
<li><p>Clear lineage tracking</p>
</li>
<li><p>Reproducible deployments</p>
</li>
</ul>
<p>Traditional approaches often involve juggling multiple tools: Airflow for orchestration, dbt for transformations, Great Expectations for quality, and custom scripts for everything else. <strong>Bruin consolidates all of this into a single, elegant interface.</strong></p>
<hr />
<h2><strong>🎯 Project Architecture</strong></h2>
<h3><strong>The Layered Approach</strong></h3>
<p>text</p>
<pre><code class="language-plaintext">┌─────────────────────────────────────┐
│         Mart Layer (Reporting)       │
│    └── trip_stats (Aggregations)    │
├─────────────────────────────────────┤
│       Staging Layer (Cleaning)       │
│       └── stg_trips (Transform)     │
├─────────────────────────────────────┤
│      Ingestion Layer (Raw Data)      │
│    ├── trips (Parquet → DuckDB)     │
│    └── zones (CSV → DuckDB)         │
└─────────────────────────────────────┘
</code></pre>
<h3><strong>Key Design Decisions</strong></h3>
<ol>
<li><p><strong>Medallion Architecture</strong>: Bronze (ingestion), Silver (staging), Gold (mart)</p>
</li>
<li><p><strong>Time-based Incremental Processing</strong>: Only process new data</p>
</li>
<li><p><strong>Built-in Quality Checks</strong>: Prevent bad data from propagating</p>
</li>
<li><p><strong>Environment Separation</strong>: Dev, staging, production configurations</p>
</li>
</ol>
<hr />
<h2><strong>💡 Deep Dive: Bruin's Powerful Features</strong></h2>
<h3><strong>1. Simplified Configuration with</strong> <code>.bruin.yml</code></h3>
<p>yaml</p>
<pre><code class="language-plaintext">environments:
  default:
    connections:
      duckdb:
        - name: "nyc_taxi"
          path: "data/nyc_taxi.duckdb"
</code></pre>
<p><strong>Why this matters</strong>: No more environment variable chaos. Everything is version-controlled and environment-aware.</p>
<h3><strong>2. Intelligent Materialization Strategies</strong></h3>
<p>The pipeline uses three different strategies:</p>
<p>sql</p>
<pre><code class="language-plaintext">/* @bruin
materialization:
  type: table
  strategy: time_interval
  time_column: pickup_datetime
  interval: month
*/
</code></pre>
<ul>
<li><p><code>time_interval</code> for trips data - processes month-by-month</p>
</li>
<li><p><code>replace</code> for aggregations - full refresh when needed</p>
</li>
<li><p><code>table</code> for lookups - static data that rarely changes</p>
</li>
</ul>
<h3><strong>3. Built-in Data Quality</strong></h3>
<p>sql</p>
<pre><code class="language-plaintext">columns:
  - name: pickup_datetime
    type: timestamp
    checks:
      - name: not_null
      - name: accepted_values
        value: [not_null]
</code></pre>
<p>No more writing custom SQL for common quality checks!</p>
<h3><strong>4. Lineage Visualization</strong></h3>
<p>Running <code>bruin lineage</code> generates a beautiful dependency graph:</p>
<p>text</p>
<pre><code class="language-plaintext">ingestion.trips ──┐
                  ├── staging.trips ──┐
ingestion.zones ──┘                    ├── mart.trip_stats
                                       │
                              checks.trip_quality
</code></pre>
<hr />
<h2><strong>🔧 Implementation Highlights</strong></h2>
<h3><strong>The Incremental Processing Challenge</strong></h3>
<p>One of the trickiest parts was handling the monthly partitions correctly. Here's how Bruin simplified it:</p>
<p>sql</p>
<pre><code class="language-plaintext">/* @bruin
name: staging.trips
materialization:
  type: table
  strategy: time_interval
  time_column: pickup_date
  interval: month
*/

SELECT 
    DATE(pickup_datetime) as pickup_date,
    -- other columns
FROM ingestion.trips
WHERE pickup_datetime BETWEEN '{{ start_date }}' AND '{{ end_date }}'
</code></pre>
<p>Bruin automatically:</p>
<ul>
<li><p>Detects the time interval to process</p>
</li>
<li><p>Deletes existing data for that interval</p>
</li>
<li><p>Inserts only the new/updated data</p>
</li>
<li><p>Maintains partition boundaries</p>
</li>
</ul>
<h3><strong>Dynamic Variable Override</strong></h3>
<p>The pipeline supports multiple taxi types, but sometimes you need to run for just one:</p>
<p>bash</p>
<pre><code class="language-plaintext"># Process only yellow taxis
bruin run --var 'taxi_types=["yellow"]'

# Process a specific date range
bruin run --var 'start_date="2024-03-01"' --var 'end_date="2024-03-31"'
</code></pre>
<hr />
<h2><strong>📊 Performance Results</strong></h2>
<p>After implementing the pipeline with Bruin, here are the key metrics:</p>
<table>
<thead>
<tr>
<th><strong>Metric</strong></th>
<th><strong>Before (Manual)</strong></th>
<th><strong>After (Bruin)</strong></th>
<th><strong>Improvement</strong></th>
</tr>
</thead>
<tbody><tr>
<td>Pipeline Development</td>
<td>3 days</td>
<td>4 hours</td>
<td>85% faster</td>
</tr>
<tr>
<td>Incremental Processing</td>
<td>Complex custom code</td>
<td>Built-in</td>
<td>100% less code</td>
</tr>
<tr>
<td>Data Quality Checks</td>
<td>Separate tool</td>
<td>Native</td>
<td>Unified platform</td>
</tr>
<tr>
<td>Deployment Time</td>
<td>30 mins</td>
<td>2 mins</td>
<td>93% faster</td>
</tr>
<tr>
<td>Total Lines of Code</td>
<td>~500</td>
<td>~150</td>
<td>70% reduction</td>
</tr>
</tbody></table>
<hr />
<h2><strong>🎓 Lessons Learned</strong></h2>
<h3><strong>What Worked Well</strong></h3>
<ol>
<li><p><strong>Single Source of Truth</strong>: Everything in one place - schema, quality checks, dependencies</p>
</li>
<li><p><strong>Local Development</strong>: DuckDB integration made testing lightning fast</p>
</li>
<li><p><strong>Clear Syntax</strong>: SQL-first approach with YAML metadata is intuitive</p>
</li>
<li><p><strong>Incremental by Default</strong>: Built-in patterns prevent common mistakes</p>
</li>
</ol>
<h3><strong>Challenges and Solutions</strong></h3>
<p><strong>Challenge</strong>: Handling schema evolution in source data<br /><strong>Solution</strong>: Used explicit type casting in ingestion layer</p>
<p><strong>Challenge</strong>: Managing dependencies between environments<br /><strong>Solution</strong>: Leveraged Bruin's environment-specific configurations</p>
<p><strong>Challenge</strong>: Testing quality checks locally<br /><strong>Solution</strong>: Ran <code>bruin validate --checks-only</code> before deployment</p>
<hr />
<h2><strong>🚀 Production Deployment</strong></h2>
<p>The pipeline runs daily in production with this workflow:</p>
<p>bash</p>
<pre><code class="language-plaintext">#!/bin/bash
# production_run.sh

# Set environment
export BRUIN_ENV="prod"

# Run the pipeline with dependencies
bruin run --select mart.trip_stats+

# Generate lineage for documentation
bruin lineage --output /docs/lineage.html

# Send notifications on completion
if [ $? -eq 0 ]; then
  curl -X POST https://alerts.slack.com/... -d "text=✅ Pipeline successful"
else
  curl -X POST https://alerts.slack.com/... -d "text=❌ Pipeline failed"
fi
</code></pre>
<hr />
<h2><strong>💭 Final Thoughts</strong></h2>
<p>Bruin represents a new generation of data tools that prioritize developer experience without sacrificing power. By combining:</p>
<ul>
<li><p>✅ <strong>Simple YAML configuration</strong></p>
</li>
<li><p>✅ <strong>SQL-first transformations</strong></p>
</li>
<li><p>✅ <strong>Built-in data quality</strong></p>
</li>
<li><p>✅ <strong>Clear lineage tracking</strong></p>
</li>
<li><p>✅ <strong>Incremental processing patterns</strong></p>
</li>
</ul>
<p>It delivers a 10x improvement in pipeline development velocity.</p>
<p>The NYC taxi pipeline is just the beginning. With Bruin, we're now:</p>
<ul>
<li><p>Building more complex pipelines in hours instead of days</p>
</li>
<li><p>Onboarding new team members in minutes</p>
</li>
<li><p>Maintaining higher data quality with less effort</p>
</li>
<li><p>Deploying with confidence across environments</p>
</li>
</ul>
<hr />
<h2><strong>🔗 Resources</strong></h2>
<ul>
<li><p><a href="https://github.com/yourusername/nyc-taxi-bruin-pipeline">GitHub Repository</a></p>
</li>
<li><p><a href="https://bruin-data.github.io/docs/">Bruin Documentation</a></p>
</li>
<li><p><a href="https://github.com/DataTalksClub/data-engineering-zoomcamp">Data Engineering Zoomcamp</a></p>
</li>
</ul>
<hr />
<h2><strong>🙏 Acknowledgments</strong></h2>
<p>Special thanks to the DataTalksClub community and the Bruin team for creating such an accessible yet powerful tool. The future of data engineering is bright!</p>
<hr />
<p><em>Want to learn more? Connect with me on</em> <a href="https://linkedin.com/in/yourprofile"><em>LinkedIn</em></a> <em>or check out the</em> <a href="https://github.com/yourusername/nyc-taxi-bruin-pipeline"><em>full code</em></a><em>.</em></p>
<hr />
<h2><strong>📝 Quick Reference: Bruin Commands</strong></h2>
<table>
<thead>
<tr>
<th><strong>Command</strong></th>
<th><strong>Purpose</strong></th>
</tr>
</thead>
<tbody><tr>
<td><code>bruin init zoomcamp my-pipeline</code></td>
<td>Initialize new pipeline</td>
</tr>
<tr>
<td><code>bruin run</code></td>
<td>Execute pipeline</td>
</tr>
<tr>
<td><code>bruin run --full-refresh</code></td>
<td>First-time run</td>
</tr>
<tr>
<td><code>bruin run --select asset+</code></td>
<td>Run asset and dependencies</td>
</tr>
<tr>
<td><code>bruin lineage</code></td>
<td>Visualize dependencies</td>
</tr>
<tr>
<td><code>bruin validate</code></td>
<td>Check configuration</td>
</tr>
<tr>
<td><code>bruin render</code></td>
<td>Preview SQL with variables</td>
</tr>
</tbody></table>
<hr />
<p><strong>Tags</strong>: #DataEngineering #Bruin #ELT #DuckDB #OpenSource #DataTalksClub #AnalyticsEngineering</p>
<hr />
<p><em>This blog post is part of the Data Engineering Zoomcamp 2026 series. Join us at</em> <a href="http://DataTalks.Club"><em>DataTalks.Club</em></a> <em>to learn more!</em></p>
]]></content:encoded></item></channel></rss>