| name | datatalks-data-engineering-zoomcamp |
| description | Free 9-week data engineering course covering Docker, Terraform, Kestra, BigQuery, dbt, Spark, and Kafka with hands-on projects |
| triggers | ["help me with the data engineering zoomcamp","how do I set up the DE zoomcamp environment","show me how to complete zoomcamp homework","what are the data engineering zoomcamp modules","help with zoomcamp docker setup","configure terraform for zoomcamp GCP","run zoomcamp spark exercises","complete data engineering course project"] |
DataTalks Data Engineering Zoomcamp
Skill by ara.so — Data Skills collection.
Overview
The Data Engineering Zoomcamp is a comprehensive 9-week free course covering production-ready data pipeline development. It includes hands-on modules on containerization (Docker), infrastructure as code (Terraform), workflow orchestration (Kestra), data warehousing (BigQuery), analytics engineering (dbt), data platforms (Bruin), batch processing (Spark), and streaming (Kafka).
The course operates in cohorts (next starts January 2026) but all materials are available for self-paced learning.
Prerequisites
- Basic coding experience
- SQL familiarity
- Python knowledge (helpful but not required)
- Git installed
- Docker Desktop or Docker Engine
- Google Cloud Platform (GCP) account (free tier)
Course Structure
Module 1: Docker & Terraform
Set up containerized PostgreSQL database:
docker network create pg-network
docker run -d \
--name pg-database \
--network pg-network \
-e POSTGRES_USER=root \
-e POSTGRES_PASSWORD=root \
-e POSTGRES_DB=ny_taxi \
-v $(pwd)/ny_taxi_postgres_data:/var/lib/postgresql/data \
-p 5432:5432 \
postgres:13
docker run -d \
--name pgadmin \
--network pg-network \
-e PGADMIN_DEFAULT_EMAIL=admin@admin.com \
-e PGADMIN_DEFAULT_PASSWORD=root \
-p 8080:80 \
dpage/pgadmin4
Docker Compose for entire stack:
services:
pgdatabase:
image: postgres:13
environment:
- POSTGRES_USER=root
- POSTGRES_PASSWORD=root
- POSTGRES_DB=ny_taxi
volumes:
- ./ny_taxi_postgres_data:/var/lib/postgresql/data
ports:
- "5432:5432"
pgadmin:
image: dpage/pgadmin4
environment:
- PGADMIN_DEFAULT_EMAIL=admin@admin.com
- PGADMIN_DEFAULT_PASSWORD=root
ports:
- "8080:80"
docker-compose up -d
docker-compose down
Terraform GCP setup:
# main.tf
terraform {
required_version = ">= 1.0"
backend "local" {}
required_providers {
google = {
source = "hashicorp/google"
}
}
}
provider "google" {
project = var.project
region = var.region
}
# Data Lake Bucket
resource "google_storage_bucket" "data-lake-bucket" {
name = "${local.data_lake_bucket}_${var.project}"
location = var.region
storage_class = var.storage_class
uniform_bucket_level_access = true
versioning {
enabled = true
}
lifecycle_rule {
action {
type = "Delete"
}
condition {
age = 30
}
}
force_destroy = true
}
# BigQuery Dataset
resource "google_bigquery_dataset" "dataset" {
dataset_id = var.BQ_DATASET
project = var.project
location = var.region
}
# variables.tf
locals {
data_lake_bucket = "dtc_data_lake"
}
variable "project" {
description = "Your GCP Project ID"
}
variable "region" {
description = "Region for GCP resources"
default = "europe-west6"
type = string
}
variable "storage_class" {
description = "Storage class type for your bucket"
default = "STANDARD"
}
variable "BQ_DATASET" {
description = "BigQuery Dataset"
type = string
default = "trips_data_all"
}
terraform init
terraform plan
terraform apply
terraform destroy
Module 2: Workflow Orchestration (Kestra)
Example Kestra workflow for data ingestion:
id: ingest_ny_taxi_data
namespace: zoomcamp
tasks:
- id: download_data
type: io.kestra.core.tasks.scripts.Bash
commands:
- wget https://github.com/DataTalksClub/nyc-tlc-data/releases/download/yellow/yellow_tripdata_2021-01.csv.gz
- gunzip yellow_tripdata_2021-01.csv.gz
- id: python_ingest
type: io.kestra.plugin.scripts.python.Script
docker:
image: python:3.9
script: |
import pandas as pd
from sqlalchemy import create_engine
import os
df = pd.read_csv('yellow_tripdata_2021-01.csv', nrows=100000)
engine = create_engine(os.getenv('POSTGRES_CONNECTION'))
df.to_sql('yellow_taxi_data', engine, if_exists='replace', chunksize=10000)
print(f"Inserted {len(df)} rows")
- id: log_completion
type: io.kestra.core.tasks.log.Log
message: "Data ingestion completed successfully"
Python data ingestion script:
import pandas as pd
from sqlalchemy import create_engine
import argparse
from time import time
def main(params):
user = params.user
password = params.password
host = params.host
port = params.port
db = params.db
table_name = params.table_name
url = params.url
csv_name = 'output.csv'
os.system(f"wget {url} -O {csv_name}")
engine = create_engine(f'postgresql://{user}:{password}@{host}:{port}/{db}')
df_iter = pd.read_csv(csv_name, iterator=True, chunksize=100000)
df = next(df_iter)
df.tpep_pickup_datetime = pd.to_datetime(df.tpep_pickup_datetime)
df.tpep_dropoff_datetime = pd.to_datetime(df.tpep_dropoff_datetime)
df.head(n=0).to_sql(name=table_name, con=engine, if_exists='replace')
df.to_sql(name=table_name, con=engine, if_exists='append')
while True:
try:
t_start = time()
df = next(df_iter)
df.tpep_pickup_datetime = pd.to_datetime(df.tpep_pickup_datetime)
df.tpep_dropoff_datetime = pd.to_datetime(df.tpep_dropoff_datetime)
df.to_sql(name=table_name, con=engine, if_exists=)
t_end = time()
( % (t_end - t_start))
StopIteration:
()
__name__ == :
parser = argparse.ArgumentParser(description=)
parser.add_argument(, required=, =)
parser.add_argument(, required=, =)
parser.add_argument(, required=, =)
parser.add_argument(, required=, =)
parser.add_argument(, required=, =)
parser.add_argument(, required=, =)
parser.add_argument(, required=, =)
args = parser.parse_args()
main(args)
python ingest_data.py \
--user=root \
--password=root \
--host=localhost \
--port=5432 \
--db=ny_taxi \
--table_name=yellow_taxi_trips \
--url=https://github.com/DataTalksClub/nyc-tlc-data/releases/download/yellow/yellow_tripdata_2021-01.csv.gz
Module 3: Data Warehouse (BigQuery)
Create partitioned and clustered table:
CREATE OR REPLACE EXTERNAL TABLE `trips_data_all.external_yellow_tripdata`
OPTIONS (
format = 'CSV',
uris = ['gs://nyc-tl-data/trip data/yellow_tripdata_2019-*.csv',
'gs://nyc-tl-data/trip data/yellow_tripdata_2020-*.csv']
);
CREATE OR REPLACE TABLE `trips_data_all.yellow_tripdata_partitioned`
PARTITION BY
DATE(tpep_pickup_datetime) AS
SELECT * FROM `trips_data_all.external_yellow_tripdata`;
CREATE OR REPLACE TABLE `trips_data_all.yellow_tripdata_partitioned_clustered`
PARTITION BY DATE(tpep_pickup_datetime)
CLUSTER BY VendorID AS
SELECT * FROM `trips_data_all.external_yellow_tripdata`;
SELECT DISTINCT(VendorID)
FROM `trips_data_all.yellow_tripdata_partitioned`
WHERE DATE(tpep_pickup_datetime) BETWEEN '2020-06-01' AND '2020-06-30';
Load data from GCS to BigQuery:
from google.cloud import bigquery
import os
os.environ['GOOGLE_APPLICATION_CREDENTIALS'] = 'path/to/credentials.json'
client = bigquery.Client()
table_id = 'your-project.trips_data_all.yellow_tripdata'
job_config = bigquery.LoadJobConfig(
source_format=bigquery.SourceFormat.PARQUET,
write_disposition=bigquery.WriteDisposition.WRITE_TRUNCATE,
)
uri = 'gs://your-bucket/yellow_tripdata_2021-01.parquet'
load_job = client.load_table_from_uri(
uri, table_id, job_config=job_config
)
load_job.result()
print(f"Loaded {load_job.output_rows} rows to {table_id}")
Module 4: Analytics Engineering (dbt)
Project structure:
dbt_project/
├── dbt_project.yml
├── profiles.yml
├── models/
│ ├── staging/
│ │ ├── stg_yellow_tripdata.sql
│ │ └── schema.yml
│ └── core/
│ ├── fact_trips.sql
│ └── dim_zones.sql
└── macros/
└── get_payment_type_description.sql
dbt_project.yml:
name: 'taxi_rides_ny'
version: '1.0.0'
config-version: 2
profile: 'default'
model-paths: ["models"]
analysis-paths: ["analyses"]
test-paths: ["tests"]
seed-paths: ["seeds"]
macro-paths: ["macros"]
snapshot-paths: ["snapshots"]
target-path: "target"
clean-targets:
- "target"
- "dbt_packages"
models:
taxi_rides_ny:
staging:
+materialized: view
core:
+materialized: table
profiles.yml:
default:
outputs:
dev:
type: bigquery
method: service-account
project: "{{ env_var('GCP_PROJECT_ID') }}"
dataset: dbt_dev
threads: 4
keyfile: "{{ env_var('GOOGLE_APPLICATION_CREDENTIALS') }}"
location: EU
prod:
type: bigquery
method: service-account
project: "{{ env_var('GCP_PROJECT_ID') }}"
dataset: production
threads: 4
keyfile: "{{ env_var('GOOGLE_APPLICATION_CREDENTIALS') }}"
location: EU
target: dev
Staging model (models/staging/stg_yellow_tripdata.sql):
{{ config(materialized='view') }}
with tripdata as
(
select *,
row_number() over(partition by vendorid, tpep_pickup_datetime) as rn
from {{ source('staging','yellow_tripdata') }}
where vendorid is not null
)
select
{{ dbt_utils.generate_surrogate_key(['vendorid', 'tpep_pickup_datetime']) }} as tripid,
cast(vendorid as integer) as vendorid,
cast(ratecodeid as integer) as ratecodeid,
cast(pulocationid as integer) as pickup_locationid,
cast(dolocationid as integer) as dropoff_locationid,
cast(tpep_pickup_datetime as timestamp) as pickup_datetime,
cast(tpep_dropoff_datetime as timestamp) as dropoff_datetime,
store_and_fwd_flag,
cast(passenger_count as ) passenger_count,
(trip_distance ) trip_distance,
(fare_amount ) fare_amount,
(extra ) extra,
(mta_tax ) mta_tax,
(tip_amount ) tip_amount,
(tolls_amount ) tolls_amount,
(improvement_surcharge ) improvement_surcharge,
(total_amount ) total_amount,
(payment_type ) payment_type,
{{ get_payment_type_description() }} payment_type_description
tripdata
rn
Core model (models/core/fact_trips.sql):
{{ config(materialized='table') }}
with green_data as (
select *,
'Green' as service_type
from {{ ref('stg_green_tripdata') }}
),
yellow_data as (
select *,
'Yellow' as service_type
from {{ ref('stg_yellow_tripdata') }}
),
trips_unioned as (
select * from green_data
union all
select * from yellow_data
),
dim_zones as (
select * from {{ ref('dim_zones') }}
where borough != 'Unknown'
)
select
trips_unioned.tripid,
trips_unioned.vendorid,
trips_unioned.service_type,
trips_unioned.ratecodeid,
trips_unioned.pickup_locationid,
pickup_zone.borough as pickup_borough,
pickup_zone.zone as pickup_zone,
trips_unioned.dropoff_locationid,
dropoff_zone.borough as dropoff_borough,
dropoff_zone.zone as dropoff_zone,
trips_unioned.pickup_datetime,
trips_unioned.dropoff_datetime,
trips_unioned.store_and_fwd_flag,
trips_unioned.passenger_count,
trips_unioned.trip_distance,
trips_unioned.fare_amount,
trips_unioned.extra,
trips_unioned.mta_tax,
trips_unioned.tip_amount,
trips_unioned.tolls_amount,
trips_unioned.total_amount,
trips_unioned.payment_type,
trips_unioned.payment_type_description
trips_unioned
dim_zones pickup_zone
trips_unioned.pickup_locationid pickup_zone.locationid
dim_zones dropoff_zone
trips_unioned.dropoff_locationid dropoff_zone.locationid
Macro (macros/get_payment_type_description.sql):
{#
This macro returns the description of the payment_type
#}
{% macro get_payment_type_description(payment_type) -%}
case {{ payment_type }}
when 1 then 'Credit card'
when 2 then 'Cash'
when 3 then 'No charge'
when 4 then 'Dispute'
when 5 then 'Unknown'
when 6 then 'Voided trip'
end
{%- endmacro %}
Schema and tests (models/staging/schema.yml):
version: 2
sources:
- name: staging
database: "{{ env_var('GCP_PROJECT_ID') }}"
schema: trips_data_all
tables:
- name: yellow_tripdata
- name: green_tripdata
models:
- name: stg_yellow_tripdata
description: >
Trip made by yellow taxis.
columns:
- name: tripid
description: Primary key for this table, generated with a concatenation of vendorid+pickup_datetime
tests:
- unique:
severity: warn
- not_null:
severity: warn
- name: vendorid
description: >
A code indicating the TPEP provider that provided the record.
[, ]
[, , , , , ]
dbt commands:
dbt deps
dbt run
dbt run --select stg_yellow_tripdata
dbt test
dbt docs generate
dbt docs serve
dbt build
dbt run --target prod
Module 6: Batch Processing (Spark)
PySpark setup:
wget https://archive.apache.org/dist/spark/spark-3.3.2/spark-3.3.2-bin-hadoop3.tgz
tar xzfv spark-3.3.2-bin-hadoop3.tgz
rm spark-3.3.2-bin-hadoop3.tgz
export SPARK_HOME="${HOME}/spark-3.3.2-bin-hadoop3"
export PATH="${SPARK_HOME}/bin:${PATH}"
PySpark script for data processing:
import pyspark
from pyspark.sql import SparkSession
from pyspark.sql import types
from pyspark.sql import functions as F
spark = SparkSession.builder \
.master("local[*]") \
.appName('test') \
.getOrCreate()
schema = types.StructType([
types.StructField('hvfhs_license_num', types.StringType(), True),
types.StructField('dispatching_base_num', types.StringType(), True),
types.StructField('pickup_datetime', types.TimestampType(), True),
types.StructField('dropoff_datetime', types.TimestampType(), True),
types.StructField('PULocationID', types.IntegerType(), True),
types.StructField('DOLocationID', types.IntegerType(), True),
types.StructField('SR_Flag', types.StringType(), True)
])
df = spark.read \
.option("header", "true") \
.schema(schema) \
.csv('fhvhv_tripdata_2021-01.csv')
df.printSchema()
df.repartition(24) \
.write.parquet('fhvhv/2021/01/', mode='overwrite')
df = spark.read.parquet('fhvhv/2021/01/')
df.registerTempTable('fhvhv_2021_01')
spark.sql("""
SELECT
PULocationID AS revenue_zone,
date_trunc('month', pickup_datetime) AS revenue_month,
COUNT(1) AS number_of_trips
FROM
fhvhv_2021_01
WHERE
hvfhs_license_num = 'HV0003'
GROUP BY
1, 2
ORDER BY
1, 2
""").show()
df_result = df \
.withColumn(, F.date_trunc(, )) \
.(F.col() == ) \
.groupBy(, ) \
.agg(F.count().alias()) \
.orderBy(, )
df_result.show()
df_result.coalesce().write.parquet(, mode=)
Spark with Google Cloud Storage:
import pyspark
from pyspark.sql import SparkSession
spark = SparkSession.builder \
.master("local[*]") \
.appName('test') \
.config("spark.jars", "gs://spark-lib/bigquery/spark-bigquery-latest_2.12.jar") \
.getOrCreate()
spark._jsc.hadoopConfiguration().set("google.cloud.auth.service.account.json.keyfile",
"path/to/credentials.json")
df_green = spark.read.parquet('gs://your-bucket/pq/green/*/*')
df_green.write.format('bigquery') \
.option('table', 'trips_data_all.green_tripdata') \
.option('temporaryGcsBucket', 'your-temp-bucket') \
.mode('overwrite') \
.save()
Module 7: Streaming (Kafka)
Docker Compose for Kafka:
version: '3.6'
services:
zookeeper:
image: confluentinc/cp-zookeeper:7.2.0
hostname: zookeeper
container_name: zookeeper
ports:
- "2181:2181"
environment:
ZOOKEEPER_CLIENT_PORT: 2181
ZOOKEEPER_TICK_TIME: 2000
broker:
image: confluentinc/cp-kafka:7.2.0
hostname: broker
container_name: broker
depends_on:
- zookeeper
ports:
- "9092:9092"
- "9101:9101"
environment:
KAFKA_BROKER_ID: 1
KAFKA_ZOOKEEPER_CONNECT: 'zookeeper:2181'
KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: PLAINTEXT:PLAINTEXT,PLAINTEXT_HOST:PLAINTEXT
KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://broker:29092,PLAINTEXT_HOST://localhost:9092
KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1
KAFKA_GROUP_INITIAL_REBALANCE_DELAY_MS: 0
KAFKA_CONFLUENT_SCHEMA_REGISTRY_URL: http://schema-registry:8081
docker-compose -f docker-compose-kafka.yml up -d
Python Kafka producer:
from kafka import KafkaProducer
import json
import time
from datetime import datetime
producer = KafkaProducer(
bootstrap_servers=['localhost:9092'],
value_serializer=lambda v: json.dumps(v).encode('utf-8')
)
for i in range(100):
message = {
'trip_id': i,
'vendor_id': 1,
'pickup_datetime': datetime.now().isoformat(),
'passenger_count': 1,
'trip_distance': 5.2
}
producer.send('rides', value=message)
print(f"Sent message {i}")
time.sleep(1)
producer.flush()
Python Kafka consumer:
from kafka import KafkaConsumer
import json
consumer = KafkaConsumer(
'rides',
bootstrap_servers=['localhost:9092'],
auto_offset_reset='earliest',
enable_auto_commit=True,
group_id='my-group',
value_deserializer=lambda x: json.loads(x.decode('utf-8'))
)
for message in consumer:
print(f"Received: {message.value}")
trip_id = message.value['trip_id']
trip_distance = message.value['trip_distance']
print(f"Trip {trip_id}: {trip_distance} miles")
Kafka Streams example:
from kafka import KafkaProducer, KafkaConsumer
from kafka.admin import KafkaAdminClient, NewTopic
import json
admin_client = KafkaAdminClient(bootstrap_servers=['localhost:9092'])
topic_list = [
NewTopic(name="rides", num_partitions=2, replication_factor=1),
NewTopic(name="rides-pulocationid", num_partitions=2, replication_factor=1)
]
try:
admin_client.create_topics(new_topics=topic_list, validate_only=False)
except Exception as e:
print(f"Topics might already exist: {e}")
from collections import defaultdict
consumer = KafkaConsumer(
'rides',
bootstrap_servers=['localhost:9092'],
value_deserializer=lambda x: json.loads(x.decode('utf-8'))
)
producer = KafkaProducer(
bootstrap_servers=['localhost:9092'],
value_serializer=lambda v: json.dumps(v).encode('utf-8')
)
location_counts = defaultdict(int)
for message in consumer:
ride = message.value
location = ride.get('PULocationID', 'unknown')
location_counts[location] += 1
result = {
'location': location,
'count': location_counts[location]
}
producer.send(, value=result)
()
Common Workflows
Setting Up Development Environment
git clone https://github.com/DataTalksClub/data-engineering-zoomcamp.git
cd data-engineering-zoomcamp
python -m venv venv
source venv/bin/activate
pip install -r requirements.txt
export GOOGLE_APPLICATION_CREDENTIALS="path/to/credentials.json"
export GCP_PROJECT_ID="your-project-id"
Complete Module Workflow
docker-compose up -d
cd 01-docker-terraform/terraform
terraform init
terraform apply
python ingest_data.py --params...
cd 04-analytics-engineering
dbt run
dbt test
spark-submit \
--master local[*] \
spark_processing.py
terraform destroy
docker-compose down
Homework Submission Pattern
cd cohorts/2026/01-docker-terraform
jupyter notebook homework.ipynb
Troubleshooting
Docker Issues
Port already in use: