Back to portfolio
~14 min read · Updated July 2026
Case Study · Featured · GCP + Serverless + ELT

World Football Insights: Cloud-native serverless ELT
on Google Cloud Platform

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.

Featured case study Cloud Composer 2.17.7 Apache Airflow 2.11.1 Dataproc Serverless 2.1 BigQuery lakehouse GCS medallion
14
orchestrated tasks
3
entity streams
6
BigQuery tables
11m 53s
optimized runtime
84%
runtime cut after debug
0
clusters managed by hand
01 The Problem

A sports analytics company outgrows manual reporting

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.

The honest scope

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.

02 Architecture

A serverless topology with 14 orchestrated tasks

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.

EXTRACT
extract_and_upload_rawPythonOperator · async aiohttp + GCSHook
↓ fans out
GATE 1 · file existence
wait_teamsGCSObjectExistenceSensor
wait_standingsGCSObjectExistenceSensor
wait_matchesGCSObjectExistenceSensor
↓ converge
BRONZE → SILVER
bronze_to_silverDataprocCreateBatchOperator · PySpark 2.1
GATE 2 · data quality
validate_silver_dataPythonOperator · row + column checks
↓ fans out
LOAD SILVER TO BIGQUERY
load_silver_teamsGCSToBigQueryOperator
load_silver_standingsGCSToBigQueryOperator
load_silver_matchesGCSToBigQueryOperator
↓ converge
SILVER → GOLD
silver_to_goldDataprocCreateBatchOperator · PySpark 2.1
↓ fans out
LOAD GOLD TO BIGQUERY
load_gold_teamsGCSToBigQueryOperator
load_gold_standingsGCSToBigQueryOperator
load_gold_matchesGCSToBigQueryOperator

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.

The honest bucket sprawl

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.

03 Schema and storage

Six BigQuery tables, three GCS layers, one honest medallion

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.

GCS lakehouse layout
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.

BigQuery tables in worldcup_dataset
worldcup_dataset · Silver + Gold external tables
All six tables materialize as BigQuery external tables pointing at GCS Parquet. No warehouse storage duplication. Backing data is written by Dataproc Serverless PySpark jobs; BigQuery reads it transparently.
silver_teams
32 rows · gs://.../silver/teams/*.parquet · Cleaned team profiles, one row per national team, schema faithful to TheSportsDB
silver_standings
32 rows · gs://.../silver/standings/*.parquet · Group-stage standings, one row per team per group, STRING-typed counts
silver_matches
64 rows · gs://.../silver/matches/*.parquet · All fixtures with fixed schema, home/away teams, scores, venue detail
gold_teams
32 rows · gs://.../gold/teams/*.parquet · Distinct team profiles for analytical joins, curated with performance metrics
gold_standings
32 rows · gs://.../gold/standings/*.parquet · Enriched standings: points_per_game, win_percentage, running_total_points via window functions
gold_matches
64 rows · gs://.../gold/matches/*.parquet · Match analytics with result classification, total_goals, home/away goal differentials

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.

Why STRING, not INT, for count fields

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.

04 Engineering

Async extraction, quality gates, and explicit schemas

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.

dags/worldcup_elt_pipeline.py · async extraction with backoff
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.

dags/worldcup_elt_pipeline.py · Silver quality gate
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.

dags/worldcup_elt_pipeline.py · explicit schema declaration (gold_standings excerpt)
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()
}
05 Decisions

Ten decisions I made, and honest tradeoffs on each

01

Bronze stays in the lake, not the warehouse

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.

02

Dataproc Serverless over managed clusters

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.

03

External tables over native BigQuery tables

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.

04

Explicit schemas, no autodetect

The 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.

05

Reschedule-mode sensors over poke-mode

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.

06

Validation gate between Silver and Gold, not at end

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.

07

WRITE_OVERWRITE for idempotent loads

Every 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.

08

Async extraction with retries and rate-limit handling

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.

09

Empty execution_config: {} after machine-type debug

An 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.

10

Fan-out then converge, at every layer

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.

06 Impact and runtime

Three runs, one debugging story, honest numbers

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.

Run 1 · initial deployment
1h 13m 25s
First end-to-end run. Ran but slowly. Dataproc Serverless was auto-selecting an 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.
Run 2 · after execution_config: {} fix
11m 53s
I added an empty execution_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.
Run 3 · after explicit BigQuery schemas
24m 53s
I replaced 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.

On honest cost

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.

07 Tech Stack

The tools, and what each one does here.

Cloud Composer 2.17.7 Managed Airflow on GKE · hosts the 14-task DAG
Apache Airflow 2.11.1 Orchestration · fan-out/converge/validate/fan-out shape
Dataproc Serverless 2.1 Ephemeral PySpark batches · zero clusters managed
PySpark 3.x Distributed transform · Bronze to Silver, Silver to Gold
Google Cloud Storage Lakehouse · Bronze NDJSON + Silver/Gold Parquet
Google BigQuery Warehouse · 6 external tables with explicit schemas
aiohttp + asyncio Async API extraction · HTTP 429 backoff retry
pandas + pyarrow Python-side Parquet validation · quality gate
Parquet + snappy Columnar storage · Silver and Gold layers
NDJSON Newline-delimited JSON · raw Bronze layer
GCS Existence Sensor File-readiness gates · reschedule mode
GCSToBigQueryOperator Loads to BigQuery · WRITE_OVERWRITE idempotency
Dataproc Batch Operator Serverless batch submission · templated batch_id
SchemaField (BigQuery) Explicit schema declarations · no autodetect
TheSportsDB API Public REST API · teams, standings, matches endpoints
Python 3.11 Extract, validate, DAG code · Composer-provided runtime

If I were scaling this up

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.

08 Honest limits

What this project does not do (yet)

No unit tests

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.

No incremental loads

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.

Numeric fields stored as STRING

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.

No dbt on top

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.

Only one league, one season

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.

No downstream consumer story

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.

Let's talk

Need a data engineer who ships cloud-native pipelines?

I'm currently looking for Data Engineer / Analytics Engineer roles. UK-based, open to remote or hybrid.