A cloud-native serverless ELT pipeline I built on Google Cloud Platform for a global sports analytics scenario. Cloud Composer 2 orchestrates 14 tasks with parallel fan-out and quality gates. Dataproc Serverless runs the PySpark medallion transformations without any cluster management. Bronze raw data stays in a Google Cloud Storage lakehouse; Silver and Gold materialize as BigQuery external tables. Async aiohttp extraction from TheSportsDB API. Explicit BigQuery schemas replace autodetect. Real production deployment: managed infrastructure, real cost, real runtime data.
World Football Insights (WFI) is a global sports analytics company that provides football statistics, tournament insights, and historical performance data to broadcasters, sports journalists, football federations, and research institutions. Their existing reporting process meant analysts spent considerable time manually extracting football data from public sources, consolidating into spreadsheets, and preparing datasets before analysis could begin.
The brief was direct. WFI wanted a cloud-native ELT platform that could automatically extract football data from a REST API, land raw JSON in Google Cloud Storage, transform it into analytical datasets, and make the results queryable through BigQuery. Cloud Composer would orchestrate. The infrastructure had to be scalable, fault-tolerant, and generate trusted analytical tables that support historical comparisons and future predictive analytics initiatives.
Two constraints made this different from previous projects I have built. First, the platform had to be fully cloud-native on GCP: no on-premise components, no locally-managed clusters, no infrastructure I had to maintain by hand. Second, the pattern had to be ELT (extract, load, transform) rather than ETL. Raw data lands first, then transformations happen in a compute layer separate from the warehouse. That is a meaningfully different discipline from the on-premise ETL work I had shipped before.
This is my first cloud-native serverless data engineering project. It complements Nova Retail (on-premise, Docker Compose, PySpark on a single host writing to PostgreSQL) with the opposite architectural pattern: managed GCP services, no infrastructure I own, Dataproc Serverless replacing self-managed Spark, BigQuery replacing PostgreSQL, and a lakehouse-medallion pattern where Bronze stays in object storage while Silver and Gold materialize in the warehouse. Together they show two credible production shapes for a modern data platform.
The whole pipeline runs on managed GCP services. Cloud Composer (which is managed Apache Airflow on GKE) hosts the DAG. Dataproc Serverless runs the PySpark transformations without me creating or managing a single cluster. Google Cloud Storage stores raw NDJSON, Silver Parquet, and Gold Parquet. BigQuery materializes Silver and Gold as external tables. No servers. No clusters. No infrastructure I have to maintain.
The DAG has a fan-out / merge / validate / fan-out / merge / fan-out shape. Three entity streams (teams, standings, matches) flow through in parallel where they can, and converge at the two Dataproc jobs and the quality gate.
The Bronze layer is deliberately absent from BigQuery. It stays in GCS as raw NDJSON. This is the honest lakehouse pattern: raw data lives in object storage as the single source of truth, only cleaned and enriched layers live in the warehouse. Analysts who need to audit raw responses can query GCS Parquet through BigQuery External Tables, but the warehouse itself does not carry duplicate storage cost.
Managed GCP services create their own supporting buckets. My project ended up with four:
worldcup-football-bucket (my main lakehouse),
us-central1-worldcup-footba-... (Composer's DAG and log bucket),
dataproc-staging-us-central1-..., and dataproc-temp-us-central1-.... Only
the first is mine to think about. The other three are the honest cost of running on managed
infrastructure: Composer and Dataproc each need their own scratch space to stage jobs and cache
logs. Worth naming because it is exactly the kind of thing tutorials skip and production diagrams
gloss over.
The worldcup-football-bucket holds four top-level folders visible in the GCS browser:
raw/, silver/, gold/, and scripts/. Raw NDJSON files
land in raw/ from the extract task. The two Dataproc Serverless jobs read from GCS and
write partitioned Parquet to silver/ and gold/. The scripts/
folder holds the PySpark job files that Dataproc executes.
gs://worldcup-football-bucket/
├── raw/
│ ├── worldcup_teams.ndjson
│ ├── worldcup_standings.ndjson
│ └── worldcup_matches.ndjson
├── silver/
│ ├── teams/ ← partitioned Parquet, snappy compression
│ ├── standings/ ← partitioned Parquet, snappy compression
│ └── matches/ ← partitioned Parquet, snappy compression
├── gold/
│ ├── teams/ ← curated Parquet, snappy compression
│ ├── standings/ ← curated Parquet, snappy compression
│ └── matches/ ← curated Parquet, snappy compression
└── scripts/
├── bronze_to_silver.py
└── silver_to_gold.py
BigQuery materializes six tables in the worldcup_dataset dataset: three Silver, three Gold.
Every table is an external table pointing at its GCS Parquet folder, which means the warehouse never
duplicates storage. When Dataproc writes a new Parquet file, the next BigQuery query sees the latest
data automatically.
Every schema is declared explicitly in the DAG. No autodetect=True. Silver keeps most
fields as STRING to stay faithful to TheSportsDB API's raw form. Gold introduces FLOAT-typed derived
metrics (points_per_game, win_percentage, total_goals,
home_goal_diff, away_goal_diff, running_total_points) that come
from PySpark division and window aggregation. Every table has an ingested_at or
gold_loaded_at TIMESTAMP for traceability.
TheSportsDB API returns numeric fields (goals scored, points, played games) as strings. Silver
honestly reflects this. In Gold, PySpark's implicit string-to-double coercion makes arithmetic work
correctly ("2" + "1" produces 3.0, not "21"), which I verified with a spot-check query against
gold_matches. The tradeoff: analysts running SUM or AVG on a raw count field need to
CAST to INT64 first. Casting to numeric types across the board would be more correct but adds real
churn. I chose to accept the imperfection and document it here rather than optimize prematurely.
The extract task is an aiohttp async loop that fetches three endpoints from TheSportsDB:
all teams in the competition, the league table (standings), and all fixtures for the season. Each
response is normalized into newline-delimited JSON and uploaded to GCS via GCSHook. The
loop has built-in retry with exponential backoff on HTTP 429 rate limits and non-200 responses. Between
requests, it sleeps for a configurable delay to avoid burst-triggering the API.
async def fetch_json(url): for attempt in range(RETRIES): try: async with aiohttp.ClientSession( timeout=aiohttp.ClientTimeout(total=30) ) as session: async with session.get(url) as response: if response.status == 200: data = await response.json() if any(data.values()): return data elif response.status == 429: wait_time = DELAY * (2 ** attempt) logging.warning(f"Rate limited, wait {wait_time}s") await asyncio.sleep(wait_time) else: logging.warning(f"HTTP {response.status}, retry {attempt+1}") await asyncio.sleep(DELAY) except Exception as error: wait_time = DELAY * (2 ** attempt) logging.error(f"Error: {error}, retry {attempt+1}") await asyncio.sleep(wait_time) return None
Three GCSObjectExistenceSensor tasks then wait in parallel for each raw file to land.
Sensor mode is reschedule, which releases the worker slot between pokes so a slow API
upstream does not tie up Composer resources. Poke interval is 15 seconds, timeout is 5 minutes. If any
file fails to arrive, the downstream Bronze-to-Silver job never starts.
Bronze-to-Silver runs as a Dataproc Serverless PySpark batch. It reads NDJSON, explodes the nested API
arrays, renames fields to snake_case, casts data types where appropriate, and writes partitioned Parquet
with snappy compression to gs://.../silver/. Silver-to-Gold does the same, plus adds
derived metrics through Spark SQL functions and window aggregations for group standings.
Between Silver writes and Silver-to-Gold, a Python validation task reads each Silver Parquet from GCS, checks row count is non-zero, and confirms required columns exist. If any check fails, the whole DAG fails before Gold is touched. This is the "validate before load" principle applied to a cloud pipeline: if Silver is broken, do not let broken data propagate to the analytics layer.
def validate_silver(**context): gcs = GCSHook() errors = [] for entity in ["teams", "standings", "matches"]: folder_prefix = f"{PATHS['silver']}/{entity}/" all_files = gcs.list(BUCKET, prefix=folder_prefix) parquet_files = [p for p in all_files if p.endswith(".parquet")] if not parquet_files: errors.append(f"{entity}: no Parquet files found") continue file_bytes = gcs.download(BUCKET, parquet_files[0]) df = pq.read_table(BytesIO(file_bytes)).to_pandas() if len(df) == 0: errors.append(f"{entity}: zero rows") required = REQUIRED_COLUMNS[entity] for col in required: if col not in df.columns: errors.append(f"{entity}: missing column {col}") if errors: raise ValueError(f"Validation failed: {errors}")
The final loads to BigQuery use GCSToBigQueryOperator with external_table=True
and schema_fields=... pointing at pre-declared dictionaries derived from
SchemaField objects. Every column has its type and mode declared before the load runs.
BigQuery does not infer anything.
GOLD_SCHEMAS = {
"standings": [
SchemaField("standing_id", "STRING", mode="NULLABLE"),
SchemaField("position", "STRING", mode="NULLABLE"),
SchemaField("team_id", "STRING", mode="NULLABLE"),
SchemaField("team_name", "STRING", mode="NULLABLE"),
# ... 12 more fields ...
SchemaField("points_per_game", "FLOAT", mode="NULLABLE"),
SchemaField("win_percentage", "FLOAT", mode="NULLABLE"),
SchemaField("goals_scored_per_game", "FLOAT", mode="NULLABLE"),
SchemaField("goals_conceded_per_game", "FLOAT", mode="NULLABLE"),
SchemaField("running_total_points", "FLOAT", mode="NULLABLE"),
SchemaField("gold_loaded_at", "TIMESTAMP", mode="NULLABLE"),
],
# teams and matches schemas omitted for brevity
}
# Convert SchemaField objects to JSON-safe dicts for the operator
GOLD_SCHEMA_DICTS = {
name: [field.to_api_repr() for field in schema_list]
for name, schema_list in GOLD_SCHEMAS.items()
}
An earlier iteration loaded raw NDJSON into BigQuery as bronze_* external tables. I
removed those loads in a targeted refactor. Raw data lives in gs://.../raw/ as
newline-delimited JSON. Only Silver and Gold materialize in BigQuery. The tradeoff: analysts
cannot directly SQL-query raw responses. The gain: no duplicated storage cost and a cleaner
medallion story where the lake and warehouse have clear separate roles.
The obvious cloud-native Spark choice would have been Dataproc classic (persistent clusters). I picked Dataproc Serverless instead: I submit a batch, GCP provisions ephemeral compute, runs the PySpark job, tears it down. No cluster to size, no autoscaling to configure, no cost when idle. The tradeoff: cold starts add 2 to 3 minutes per batch to my end-to-end runtime, which is real. Worth it for a pipeline that runs on a schedule, not continuously.
Every load task uses external_table=True. BigQuery stores metadata pointing at GCS
Parquet, not the data itself. Any Dataproc-written change appears in the next query without a
copy. The tradeoff: query performance is slightly slower than native BigQuery storage because
reads pull from GCS. For analytics volumes at WFI's scale, the tradeoff is invisible; for a
high-throughput dashboard, native tables would be better.
autodetectThe pipeline first ran with autodetect=True on every load. It worked, but
production-grade means deterministic. I declared SchemaField objects for every
column of every Silver and Gold table, converted them to dicts via to_api_repr()
for provider compatibility, and passed them to the operators. The tradeoff: end-to-end runtime
went from 11m 53s to 24m 53s (about 2x) because BigQuery now validates every Parquet field
against the declared schema instead of quickly inferring types. I accepted the slowdown for the
correctness gain.
All three GCS file sensors use mode="reschedule", not mode="poke".
Reschedule releases the Airflow worker slot between checks; poke holds the slot for the entire
sensor lifetime. With three parallel sensors and a Composer environment on a modest tier, poke
mode would starve the executor pool. Reschedule keeps the pool free for other work.
The validate_silver_data task sits between the Silver loads and Silver-to-Gold. If
Silver is bad, the Gold job never runs and Gold tables never get overwritten with garbage.
Placing validation at the end (after Gold loads) would mean bad data reaches analysts before I
catch it. Placing it here means the pipeline fails loudly at the right moment.
WRITE_OVERWRITE for idempotent loadsEvery GCSToBigQueryOperator uses write_disposition="WRITE_OVERWRITE".
Re-runs produce deterministic table state: run the DAG twice and the tables look identical. This
is the correct posture for a nightly-shape pipeline: the truth is what the latest source data
says, not an accumulated append log. Note: WRITE_OVERWRITE is not a documented
BigQuery disposition (Google lists WRITE_TRUNCATE, WRITE_APPEND, WRITE_EMPTY only) but the
Airflow Google provider accepts it and it works as intended.
The extract task uses aiohttp with asyncio, not the more common
requests library. Three endpoints get fetched with retry logic that specifically
handles HTTP 429 with exponential backoff. Delay-between-requests is configurable via
DELAY = 7. For three endpoints this is overkill (I could hit them synchronously),
but the pattern generalizes to hundreds of endpoints and the correct rate-limit hygiene reads as
production-grade even at small scale.
execution_config: {} after machine-type debugAn early run failed with
The specified machine type 'e4-custom-4-15872' does not exist in zone 'us-central1-a'.
Dataproc Serverless was auto-selecting a machine family that GCP had rolled out to some zones
but not mine. Adding an empty execution_config: {} in the batch config forced
Dataproc to use zone-appropriate defaults, and the batch provisioned cleanly. The fix cut
end-to-end runtime from 1h 13m to 12m; it was the difference between a broken pipeline and one
that ships.
Three entity streams (teams, standings, matches) run in parallel where they can. Both Dataproc batches process all three entities together (converging the streams because they need cross-references). Both BigQuery load stages fan back out to three parallel loads. The final DAG shape shows the medallion architecture visually: streams that can be independent run independently, streams that need cross-reference converge just long enough.
The pipeline shipped end-to-end on Cloud Composer. Below are three real runs I captured, in the order I made them. Each row is a real Composer run ID; each duration is what Airflow measured.
e4-custom-4-15872 machine that turned out to be unavailable in
us-central1-a. The provisioning failed and Dataproc fell back to slower defaults
with retries baked in. Real spend, real completion, but not production-shape.execution_config: {} fixexecution_config: {} to both Dataproc
batches. Dataproc stopped trying to auto-select bleeding-edge machine families and used
zone-appropriate defaults instead. The pipeline dropped from 73 minutes to 12 minutes: an 84%
reduction from a two-line config change. This is the runtime I would put on a CV or interview
slide.autodetect=True with
schema_fields=... on all six load tasks. BigQuery now validates each Parquet field
against a declared schema on every load, which added roughly 13 minutes across the six parallel
loads. This is the tradeoff of explicit schemas: correctness costs runtime. For a nightly
pipeline, the tradeoff is trivial. For a real-time pipeline it would need optimization. I chose
to accept it and document it here.The pattern is real and worth naming honestly. First run: 1h 13m. Fix a two-line config bug: 11m 53s. Add production-grade schemas: 24m 53s. Each move was a genuine engineering decision with a genuine tradeoff. The final production shape (Run 3) is slower than the peak-optimized run, and that is the correct answer for what the pipeline needs to do.
Cloud Composer is not free. My environment burned roughly $1 to $2 per end-to-end run in Dataproc Serverless compute plus Composer's ambient overhead. This is a real portfolio piece with real GCP spend, funded from the free trial credit. It also complements Nova Retail's on-premise Docker approach: Nova runs at zero marginal cost on my laptop; WFI runs on managed infrastructure with real cost. A modern data platform typically needs both stories.
For production against a broader competition catalog, the natural evolutions would be:
parameterized league and season so the same DAG runs per league and consolidates at
Gold; incremental loads replacing WRITE_OVERWRITE with partition-by-date
and merge-on-key so growing data does not rebuild the entire warehouse on every run; numeric
casting at the Spark layer so analysts can SUM(points) without a
CAST; pytest coverage on the extract logic, PySpark transformations, and
schema declarations to catch regressions in CI, not at runtime; dbt models on top of
Silver for analyst-authored Gold transformations with version-controlled SQL and built-in
testing; watermarks and idempotency keys on the load tasks to handle late-arriving data
gracefully; and Looker Studio dashboards or a business API layer to close the loop from
raw NDJSON all the way to decisions.
Neither the DAG code nor the PySpark jobs have pytest coverage. Data quality is enforced through
the runtime validate_silver_data gate, which catches broken Silver before Gold
runs. But there is no CI-time check that the extract logic, the Spark transformations, or the
schema declarations behave correctly against fixtures. This is the next honest improvement.
Every run does WRITE_OVERWRITE. That is right for a pipeline pulling a bounded
competition (32 teams, 64 fixtures), but wrong for a growing data source. Extending this to a
live-season pipeline would need partition-by-date, watermarks, and merge-on-key logic in the
Spark jobs.
Silver keeps most fields as STRING to match TheSportsDB's raw form. Gold introduces FLOATs for
derived metrics only. Base fields like points, played_games,
goals_for remain STRING. Analysts running SUM or AVG on those need to CAST to INT64
first. Casting at the Spark layer would be the correct production fix; I chose not to do it in
this iteration to avoid churn.
Gold tables are the analytics surface. In a mature analytics stack, dbt would sit on top of
Silver and produce Gold through SQL-authored, version-controlled, tested transformations. Right
now Gold logic is baked into silver_to_gold.py as PySpark code. Migrating to dbt
would separate business logic from compute and give analysts the ability to author
transformations themselves.
Hardcoded to LEAGUE_ID = "4429" and SEASON = "2026". A production
platform for a client like WFI would parameterize league and season, run the DAG per league, and
consolidate across leagues at the Gold layer. This is genuinely the next iteration.
Gold tables land in BigQuery. From there, downstream would look like: Looker Studio dashboards, a business-facing API layer, or embedded charts inside WFI's product. None of that exists yet. The pipeline is a foundation, not the whole platform.
I'm currently looking for Data Engineer / Analytics Engineer roles. UK-based, open to remote or hybrid.