← All projects

Project 3 of 5

Pakistan E-Commerce Pipeline

584,524 order items from an Excel export that was 44% blank padding, accounted for row by row.

What I didBuilt end to end: ingestion, Spark jobs, warehouse, tests and CI.

PythonPySparkPostgreSQLS3 API (RustFS)Docker ComposeAirflowSQLGitHub Actions

Every number on this page links to the file it came from, pinned to pk-ecommerce-pipeline @ 14c5147.

Headline results

584,524rows read, all accounted for574,758 loaded, 9,766 quarantined, none dropped silentlySee where it comes from ↗
PKR 965.2Mcompleted revenuematches an independent Python implementation for all 26 monthsSee where it comes from ↗
~4 minfull 26-month backfillon a laptop, with one commandSee where it comes from ↗
99.5%of the quarantine is one rule9,713 of 9,766 rows have a discount larger than the item's valueSee where it comes from ↗

The short version

The problem

A data engineering role asks for PySpark, SQL, Docker, cloud storage and data quality. I wanted one project that shows all five on real, messy data, and that I could defend line by line.

What I built

A batch pipeline over Pakistan’s largest public e-commerce dataset: 584,524 order items from July 2016 to August 2018, in a 106 MB CSV that began life as an Excel export. Ingest splits it into monthly files, as if each month arrived separately, and lands them untouched in an S3 data lake with a manifest. PySpark reads each month with an explicit schema, cleans it and checks 16 data-quality rules. Clean rows load into a Postgres star schema in one transaction per month. Rows that fail a rule go to a quarantine table with every reason code. SQL views answer the business questions.

How I tested it

Fifty unit tests and an end-to-end test run in CI. Every stage proves its row counts add up and stops the pipeline if they do not. The final results were also checked month by month against an independent plain-Python implementation of the same rules, and all 26 months match to the paisa.

What I would do next

Upgrade to Spark 4, which retires the accepted dependency risk in the security review and moves the job to the AWS SDK v2.

How it works

Pick a request and watch it go through the system. The red ones are the requests I use to attack it. They end where the system is supposed to stop them.

cleanfailedKaggle CSV584,524 order itemsIngestsplit by month, add manifestraw/CSV per month, never modifiedClean with PySparkschema, de-dup, 16 rulessilver/Parquet, clean rowsquarantine/bad rows plus reasonsdq tablesreasons and run logLoadJDBC to staging, one txnStar schemafact_order_items + 4 dimsSQL viewsrevenue, cohorts, payments

One row per order item, loaded in one transaction per month. Re-running a month never creates duplicates.

  1. Kaggle CSV
  2. Ingest
  3. raw/
  4. Clean with PySpark
  5. silver/
  6. Load
  7. Star schema
  8. SQL views

A clean row travels to the warehouse. A bad row is set aside in quarantine with its reasons. Raw files are never modified, so any bug can be replayed.

Test results

11 claims, each with the bar I held it to and what I measured. 4 failed and 0 are unclear. They stay in, because a list where everything passes tells you nothing.

Show
ResultClaimExpectedWhat I measuredSource
PASSEvery row is accounted forrows read = clean + quarantined + duplicates584,524 read = 574,758 loaded + 9,766 quarantinedREADME.md ↗
@ 14c5147
PASSRevenue matches an independent implementationn/aall 26 months match a plain-Python check exactly, to the paisaREADME.md ↗
@ 14c5147
PASSThe reconciliation checks catch real bugsn/athe row-count check caught line breaks inside 11 quoted SKUs splitting rows in twoREADME.md ↗
@ 14c5147
PASSRe-running a month never duplicates rows50 unit tests plus an end-to-end test run in CI.idempotent loadsone transaction per month, and the gold load is serialisedREADME.md ↗
@ 14c5147
PASSA full backfill is practical to runn/a26 months in about 4 minutes on a laptop, with one commandREADME.md ↗
@ 14c5147
PASSThe quarantine has an explanationn/aone rule, DISCOUNT_EXCEEDS_LINE, accounts for 9,713 of 9,766 rows (99.5%)README.md ↗
@ 14c5147
PASSServices are locked down by defaultn/aports on localhost only, no secrets in git, checksummed jars, dependencies scanned in CIREADME.md ↗
@ 14c5147
FAILThe source file can be loaded as deliveredThe data was profiled before anything was designed, and each problem became a rule or a design decision.n/ano; it has 464,051 blank padding lines, Excel errors (#REF! and #N/A), and a grand_total repeated on every itemREADME.md ↗
@ 14c5147
FAILCompleted revenue alone shows the real trendA naive revenue chart would show sales falling to zero. The value is there, filed as in progress.n/ano; June to August 2018 show 0.0 because almost no orders were marked complete at export timeREADME.md ↗
@ 14c5147
FAILSpark is the right size of tool for 0.5M rowsI chose Spark so the design scales, and wrote the trade-off down instead of hiding it (D7).n/ano; at 0.5M rows pandas would be faster, and Spark was chosen so the design scalesREADME.md ↗
@ 14c5147
FAILDependencies have no known vulnerabilitiesThe job only parses trusted batch files, and its one network service is on localhost.n/ano; PySpark 3.5's bundled jars and AWS SDK v1 are an accepted risk until a Spark 4 upgradedocs/SECURITY.md ↗
@ 14c5147

The numbers

Each headline is the conclusion. Open “View the data” under a chart for the exact values.

White Friday 2017 alone is 31% of 26 months of completed revenue.

Completed revenue by month, PKR millions

075150225300Jul 16: 8.3Jul 16Aug 16: 15.1Sep 16: 22.5Oct 16: 17.3Oct 16Nov 16: 69.9Dec 16: 19.6Jan 17: 23.6Jan 17Feb 17: 19Mar 17: 28.7Apr 17: 22.2Apr 17May 17: 36.4Jun 17: 27.9Jul 17: 12.4Jul 17Aug 17: 20.7Sep 17: 5.8Oct 17: 21.6Oct 17Nov 17: 295.6Dec 17: 17.3Jan 18: 20.9Jan 18Feb 18: 122.2Mar 18: 100.8Apr 18: 15.7Apr 18May 18: 21.4Jun 18: 0Jul 18: 0Jul 18Aug 18: 0White Friday 2016: 69.9White Friday 2017: 295.6

From June 2018 almost no orders were marked complete when the data was exported, so those months show 0.0. That is not a sales collapse; the value is filed as in progress.

Source: README.md @ 14c5147 ↗
View the data
ItemValueNote
Jul 168.3
Aug 1615.1
Sep 1622.5
Oct 1617.3
Nov 1669.9
Dec 1619.6
Jan 1723.6
Feb 1719
Mar 1728.7
Apr 1722.2
May 1736.4
Jun 1727.9
Jul 1712.4
Aug 1720.7
Sep 175.8
Oct 1721.6
Nov 17295.6
Dec 1717.3
Jan 1820.9
Feb 18122.2
Mar 18100.8
Apr 1815.7
May 1821.4
Jun 180
Jul 180
Aug 180

Prepaid orders are cancelled almost 8 times as often as cash on delivery.

Order outcomes by payment type

CompletedIn progressRefundedCancelledPrepaid220,274 ordersPrepaid · Completed: 26.9%26.9%Prepaid · In progress: 8.2%8.2%Prepaid · Refunded: 4%Prepaid · Cancelled: 60.9%60.9%Cash on delivery182,503 ordersCash on delivery · Completed: 58.5%58.5%Cash on delivery · In progress: 9.8%9.8%Cash on delivery · Refunded: 23.8%23.8%Cash on delivery · Cancelled: 7.9%7.9%Store credit5,264 ordersStore credit · Completed: 60.6%60.6%Store credit · In progress: 15.2%15.2%Store credit · Refunded: 23.3%23.3%Store credit · Cancelled: 0.9%

Share of orders by final status. Cash on delivery is refunded more, which fits returns after delivery.

Source: README.md @ 14c5147 ↗
View the data
RowCompletedIn progressRefundedCancelled
Prepaid (220,274 orders)26.9%8.2%4%60.9%
Cash on delivery (182,503 orders)58.5%9.8%23.8%7.9%
Store credit (5,264 orders)60.6%15.2%23.3%0.9%

One rule explains 99.5% of the quarantine.

Why 9,766 rows were quarantined

DISCOUNT_EXCEEDS_LINE: 9,713DISCOUNT_EXCEEDS_LINE9,713MISSING_SKU: 20MISSING_SKU20MISSING_STATUS: 19MISSING_STATUS19INVALID_CUSTOMER_ID: 11INVALID_CUSTOMER_ID11NEGATIVE_DISCOUNT: 3NEGATIVE_DISCOUNT3

A discount larger than price times quantity gives negative revenue, so those lines are set aside rather than guessed at.

Source: README.md @ 14c5147 ↗
View the data
ItemValueNote
DISCOUNT_EXCEEDS_LINE9,713
MISSING_SKU20
MISSING_STATUS19
INVALID_CUSTOMER_ID11
NEGATIVE_DISCOUNT3

What went wrong

FIXED

Spark split 11 rows into 22 broken records

How it surfaced
The row-count check between ingest and Spark failed. Eleven SKUs contain line breaks inside quotes, so Spark turned each of those rows into two broken records.
What I did
The CSV reader now sets multiLine. Raw files are immutable, so the fix was replayed from the original data rather than patched downstream.
FIXED

grand_total would have counted a 3-item order three times

How it surfaced
grand_total is the order total repeated on every item, so summing it overstates revenue for every multi-item order.
What I did
Revenue is computed per item as price × qty − discount, and the fact table has one row per order item.
FIXED

9,713 discounts were larger than the item they discounted

How it surfaced
Profiling found lines whose discount exceeds price × quantity, which gives negative revenue.
What I did
Those rows go to quarantine with the reason code DISCOUNT_EXCEEDS_LINE, visible and reprocessable, instead of being dropped or silently clamped.
FIXED

The object store vanished two weeks before the build

How it surfaced
The project was designed on MinIO. MinIO deleted its images from Docker Hub on 11 September 2026.
What I did
Because the code only speaks the S3 API, switching to RustFS was a one-line change in docker-compose.yml, and so is moving to AWS.
ACCEPTED RISK

The security review found vulnerable jars

How it surfaced
The Postgres JDBC driver 42.7.4 had three HIGH CVEs, and the AWS SDK bundle 1.12.262 shipped old Jackson, Ion and Netty with 19 HIGH or CRITICAL CVEs.
What I did
Upgraded to JDBC 42.7.13, which has no known CVEs, and SDK 1.12.797. Twelve findings remain, all in shaded Netty, and are written up as an accepted risk until a Spark 4 upgrade.
ACCEPTED RISK

Data quality fell off a cliff in January 2018

How it surfaced
Quarantined rows jump from a handful a month to between 257 and 2,934 a month. That looks like a change in the source system.
What I did
Nothing to fix upstream. The quarantine table makes the change visible, measurable and reprocessable instead of silently dropped.

Choices I would defend

  • Keep raw data immutable

    Any bug can be fixed and replayed from the original files. That was used for real to fix the CSV bug (D3).

  • Model revenue at the item grain

    grand_total is order-level, so the fact table has one row per order item and revenue is computed from price, quantity and discount (D18).

  • Quarantine bad rows, never drop them

    A failing row is set aside with every reason code and its raw values, so quality problems stay visible and reprocessable.

  • Speak only the S3 API

    Storage and compute are decoupled, so swapping the object store took one line when MinIO disappeared (D4).

  • Make every stage prove its counts

    The pipeline stops if Spark reads a different number of rows than ingest wrote, if read does not equal clean plus quarantined plus duplicates, or if loaded does not equal staged.