PROJECT
Shopify Synth
Realistic, seed-reproducible Shopify order data for testing analytics tools against how Indian D2C commerce actually behaves.
WHY IT EXISTS
Populated rows are not the same as believable behavior.
I built this while working on a separate analytics product for Indian D2C brands. I needed datasets where cash-on-delivery orders carried real return risk, festival weeks actually moved volume, and running the same seed twice gave me the same output. The tools I could find populated a dev store. That is fine for a screenshot. It does not tell you whether your CAC math survives COD splits, return-to-origin rates, or demand that spikes around Diwali and then drops.
Nothing existing modeled that cleanly, so I built a generator that does. You bring your own catalog. The output is validated against what you asked for, not just what looks plausible on first glance.
WHAT IT DOES
You upload a standard Shopify product export. Orders reference your real SKUs, prices, and product IDs. Thirteen behavioral parameters cover volume, customer mix, payments, returns, discounts, and basket shape. Optional timeline overrides let you spike or slow specific weeks without rewriting your base settings. Same seed, same parameters, same catalog: identical output every run.
Generation, parsing, and export all happen in the browser. The validation table compares expected input against measured output from the actual per-day values used during generation. Export as raw JSON or as a Shopify-native order CSV.
THE DECISION THAT MATTERED
Average order value should not have been an input.
Early versions let you type a target AOV, mean and spread, then forced the generator to hit it. It took three rounds to understand why that kept breaking.
Round one capped unit prices near the target. That failed the moment someone uploaded a real catalog whose prices did not match an arbitrary number. One preset targeted ₹849. A real catalog averaging in the ₹600s could not hit it no matter how I tuned the cap.
Round two rebuilt basket construction to sample actively toward a target. The mean looked right, but I surfaced two new bugs: an uncapped probability field hitting values over 100%, and a validation formula that did not account for a two-stage customer-assignment rule.
Round three fixed those. Standard deviation came in at roughly 16× the target. A small number of outlier baskets were inflating variance while barely moving the mean. I could have patched the tolerance band again. Instead I asked whether AOV should be an input at all.
A merchant does not set average order value directly. It is a consequence of what is for sale and how people shop. I replaced the AOV inputs with two catalog-independent parameters, items per basket and a multi-unit purchase rate, and made AOV a derived, live-computed estimate. The bug class did not get patched. It became structurally impossible.
OTHER CALLS
Require a real catalog upload
I removed all built-in fake product catalogs. Shipping fictional branded data in an open source tool felt wrong, and a catalog nobody can replace does not test anything real anyway.
Layer overrides instead of replacing base settings
Timeline overrides sit on top of your ordinary-day parameters. A two-week festival spike only needs the handful of values that actually change. Everything else keeps reading from base settings.
Build validation into the tool
Debugging the AOV rounds meant manually computing averages by hand each time. That does not scale, and it should not be something a user has to do to trust their own data. The comparison table ships with every run: what you asked for, what the generator actually produced.
UNDER THE HOOD
What's actually running under the hood
Here's the shape of it, before I get into the how and why:
That expected-vs-actual box on the right is the part that mattered most. Almost every bug story below starts with that comparison catching something.
Rolling the dice, but with rules
Nothing in the generator is truly random. Every draw comes from one seeded number generator that I pass around explicitly instead of letting it float around as a global. That sounds like a small technical choice, but it's the whole reason "same seed, same store" works, and it's also why I always knew exactly where randomness was entering the system. If something looked off, I could trace it.
Most of the actual values, unit prices, discounts, daily order counts, come from a bell curve, but a clamped one. A product priced around ₹800 shouldn't occasionally generate at ₹40 or ₹4,000 just because the tail of a normal distribution technically allows it. So I bound everything to a sane range, usually half to one and a half times the average. For the yes-or-no stuff (cash on delivery or not, returned or not, discount applied or not), it's a straight weighted coin flip. And for picking which product lands in someone's basket, I use the same trick loot-box games use: give every product a slice of a wheel sized to its revenue share, spin once.
Giving the calendar a personality
A real store doesn't do the same volume every day. Weekends are different. Festivals are wild. Launch week doesn't look like month three. So each day's numbers aren't just "the average," they're the average adjusted by where that day sits on a trend curve (ramping up, flat, or declining), then nudged again by whether it's a weekend or a festival, then given a small random wobble so it doesn't look robotic. Base number, times the trend, times the season, times a bit of noise. It's the same basic idea as how video game animations ease in and out instead of snapping, just applied to sales instead of motion.
The bug that turned out to be a math problem, not a code problem
This one I'm genuinely proud of, because it took me a while to realize I was debugging the wrong thing.
Every order gets tagged new customer or returning customer. There are two ways an order can end up "new": either it rolls new directly, or it fails a second check for whether the customer is a repeat buyer. Simple enough. But my validation table kept showing a 23-point gap between what I asked for and what I got, and for a while I assumed the generator itself was broken.
It wasn't. My validation math was wrong, not the generation logic. I was just averaging the input rate directly, but the real effective rate needs both paths added together:
new rate + (1 − new rate) × (1 − repeat purchase probability)
Once I fixed the comparison formula instead of the generator, the gap disappeared. It was a good, humbling reminder that when your numbers don't match, the mistake isn't always where you're looking first.
The bug hiding in my own formula
A similar thing happened with how consistent daily order counts were supposed to be. My expected standard deviation kept coming out wrong, and it took me longer than I'd like to admit to realize why: I was only accounting for the natural wobble within a single day, and completely ignoring how much the daily targets themselves swing because of weekends and festivals. There's a name for this, the law of total variance, and it basically says total variance has two parts, not one. I'd built half the formula and wondered why the other half kept disagreeing with me.
Chasing the wrong number
The average order value story is the centerpiece of this project (told in full above), but the statistical root of it is worth calling out on its own. A handful of unusually large orders barely move an average across 6,500 orders, but they completely dominate anything that measures squared distance from the mean, which is exactly what standard deviation does. That's why my average looked perfectly fine while my spread was 16 times off target. It wasn't really a bug. It was me not understanding how sensitive that particular measurement is to outliers, and once that clicked, I stopped trying to patch the symptom.
I did a similar sanity check with something called coefficient of variation, basically asking "is an 18% spread even realistic for order values," and the honest answer was no, real stores are messier than that. I also considered switching from a normal distribution to a log-normal one, since real purchase amounts skew right (lots of small orders, a few big ones), but I set that idea aside. The more honest fix wasn't a better distribution. It was admitting that average order value was never something a merchant sets directly in the first place.
Teaching the basket to stop chasing a number
The earlier version of the order-builder worked backward from a target: pick a number, then greedily add products until you're close enough. It's a reasonable approach, similar to how you'd solve a basic packing problem, and it worked most of the time. But every so often it would overshoot a wide target by 15% or more, and those overshoots are exactly what was wrecking the variance.
The version I replaced it with doesn't aim at anything. It decides how many items go in the basket, picks products weighted by how much revenue they typically drive, rolls a quantity, and adds it up. The order value isn't a goal anymore, it's just whatever falls out of the process, the same way a real customer's cart total is a byproduct of what they picked up, not something they were solving for.
One small detail I liked figuring out: item counts are rarely whole numbers on average, something like 2.3 items per order. Instead of rounding that badly, I split it into a 70% chance of 2 items and a 30% chance of 3, so across enough orders, the average lands exactly on 2.3. It's the same dithering trick used in old-school image and audio processing to fake a value that doesn't exist in whole numbers.
The unglamorous stuff that just has to work
Not everything here is a story. Discount codes get a deterministic prefix from a simple modulo operation, so they're consistent without needing another random draw. Every rate in the system is clamped so nothing sneaks above 100% or below 0. And the product catalog parser groups repeated CSV rows into a single product with multiple variants, ordinary spreadsheet cleanup logic, with a rule that a blank cell means "same as the row above," which I reused later when exporting orders too.
Why I'm writing all this down
None of this was built to look impressive. Every piece here replaced something simpler that broke in a specific, findable way, and most of the fixes ended up simpler than what they replaced, not more complicated. That's the habit I actually care about and the reason I'm documenting it: not whether something works on a clean run, but what it does at the edges, and whether it can tell you honestly when it got something wrong.
WHAT I TOOK FROM IT
This project was less about shipping features and more about noticing when an input is modeling the wrong thing. The first two AOV fixes treated symptoms. The third round forced the question upstream. That is the habit I want in product work: when something keeps breaking in new ways, check whether the framing is wrong before tuning tolerances again.
I also stopped trusting output because it looked reasonable. For a tool whose whole job is to be ground truth for tests, numerical honesty matters more than surface plausibility. Building the validation table in was a product decision, not a polish pass.
I published the reasoning publicly, not just the repo. If you are evaluating me for senior product or growth work, this is the kind of ambiguity I am comfortable sitting in until the model is right.