Hong Kong grocery price intelligence
Two million grocery prices. One honest wall.
My wife noticed no single Hong Kong shop carries everything she wants. I noticed foodpanda often charges more than the shop's own website. So I read the publicly listed price of nearly everything, eleven retailers deep, and let the numbers talk.
Then I parked it, on purpose. This whole dashboard runs inside your browser tab. No server. No database humming in a closet. Just SQL on Parquet, right here. Scroll down and poke it.
The convenience tax
Walk, or let foodpanda walk for you?
Same product. Same shop. Two prices. One for your feet, one for your couch. For every product we could match between a chain's own website and its foodpanda delivery page, we measured the gap. That gap is the channel premiumChannel premium: how much more the same item costs on foodpanda delivery versus buying it direct from the retailer's own website. Measured per matched product, then summarised per chain..
Bars = the average markup and the painful 90th-percentile markup, per chain. The couch is rarely free.
Delivery markup by chain
foodpanda price vs chain-direct price, per matched product family
The shop scoreboard
Start here. Which chains lean on the couch tax, ranked by average markup. The shop is the story; the products are the evidence.
| Shop | Avg markup | Worst 10% | Items dearer on delivery | Products compared |
|---|---|---|---|---|
| querying… | ||||
city'super and M&S bridge by exact product id (not name), so their rows are dead reliable: city'super tacks a flat fifth onto almost everything; M&S charges you the same whether you walk or wait.
…and the products that prove it
Biggest single-item gaps we found (direct shelf price vs foodpanda median, same retailer, matched products)
| Product | Chain | Category | Direct | foodpanda | Markup |
|---|---|---|---|---|---|
| querying… | |||||
foodpanda price minus the shelf price, over the shelf price. That one percentage is the whole section. city'super charges 20% of it; M&S, basically nothing.
Visual explainer slot — a plain-language illustration of this concept lands here.
The hunt
Why you can't win the basket in one shop
It started with my wife. It's not that she can't find things in one place. It's that the cheapest version of each thing keeps landing in a different shop. I wanted to prove or kill that hunch. So I took six real items and checked the walk-in shelf price at two shops. Not delivery, not promotions. Just the price you'd pay standing in the aisle.
Watch the cheaper shop flip on every single line. And these two shops are owned by the same company.
One basket, two shops, the winner flips every row
real walk-in shelf prices · 2026-05-26 snapshot · Wellcome and Market Place by Jasons (both DFI)
| Item | Wellcome | Market Place | Cheaper at |
|---|---|---|---|
| 555 Fried Sardines 155g | $9.0 | $17.5 | Wellcome |
| Cobs Sea Salt Popcorn 80g | $45.0 | $25.0 | Market Place |
| Nestlé All-Purpose Cream 250ml | $20.0 | $37.5 | Wellcome |
| Walkers Shortbread 150g | $55.0 | $33.0 | Market Place |
| Oishi Crackers 90g | $12.0 | $21.0 | Wellcome |
| French Cellars Cabernet 250ml | $34.0 | $17.0 | Market Place |
Splitting the basket beats the cheaper single shop by 23%. And these are sister shops, same owner. Now picture doing it across real rivals — except they won't publish a barcode to let anyone compare (that's the wall at the bottom). So you can't optimise. You hunt by memory and luck. That isn't chaos in the data. That's the strategy working exactly as designed.
Add up the cheapest version of each item and you beat the best single shop almost every time. Here: $116 split versus $151 at one shop. That gap is your whole reward for hunting — and why one trip never wins.
Today's bait
real markdowns pulled from the snapshot — the hooks that get you through the door
The evidence: every chain has a personality
x = price level vs market · y = how much prices wobble (CVCoefficient of variation: standard deviation ÷ mean. Low = steady, predictable prices. High = constant promotions and reversals, i.e. Hi-Lo.) · bubble = SKUs compared
Visual explainer slot — a plain-language illustration of this concept lands here.
Promo intensity
Who is always “on sale”?
A big red SALE sticker on half the shelf is a strategy, not a coincidence. Share of each chain's catalogue carrying a discount on snapshot day.
Share of catalogue on promotion
percent of priced SKUs marked down · snapshot 2026-05-26
The tell
Read the last digit, read the shop
A price ending in .90 is a small trick played on your eye: you read “$9 and change,” not “$10.” (Hermann Simon: the further right a digit sits, the less your brain weighs it.) So how a shop ends its prices is a quiet confession of who it's pretending to be. This is charm pricingCharm pricing: setting a price just under a round number ($9.90 not $10) so it feels cheaper. A round ending ($10.00) signals the opposite — quality, confidence, no gimmick. Hong Kong skips the Western .99 and splits between .90 and fully round..
Two camps fall out of the data, and they're exactly who you'd guess.
How prices end, by chain
share of prices ending in .90 (deal-feel), round .00 (premium), and the rare .99
Visual explainer slot — a plain-language illustration of this concept lands here.
Ghosts in the data
When a 2-litre bottle of water costs $9,999
Sift two million prices and you meet the ghosts. 26 listings priced at exactly HK$9,999. Nobody is paying ten thousand dollars for two litres of Kirin water. It's a placeholder — a price typed by a human who didn't want anyone to click buy.
Exactly how the dashboard ignores those ghosts: it scores each price against the median (which a typo can't drag) over Qn, a spread ruler that still works when half the data is junk. More than 3.5 units out and it's a fluke, not a deal.
Volatility by aisle
Where prices swing the most
Toilet paper: boring, everyone charges about the same. Hot pot and fresh seafood: a casino. The price spreadPrice spread: how far apart the cheapest and most expensive store are for the same product, as a percentage. Big spread = shopping around actually pays. within a category tells you where it's worth shopping around.
Median price spread by category
how much the same item varies store-to-store · higher = shop around
When an aisle looks pricey, is it the shop or the product? We split each price into a baseline, a “this shop” effect, a “this aisle everywhere” effect, and a leftover — the tell, the only honest signal that a shop specifically marks up that aisle.
Live, in your browser
Look up anything you buy
Type a product. We check 79,630 comparable items across the shops and show you the direct price, the foodpanda price, and who's cheapest. This query runs against a Parquet file in this tab. No server is involved. Try “yakult”, “ribena”, “pampers”, “coke”.
| Product | Category | Stores | Direct | foodpanda | Cheapest | Cheapest at |
|---|---|---|---|---|---|---|
| Start typing above. | ||||||
If a product shows up at only a few shops, its “average” is a rumour. We pull thin-sample prices toward the category norm (a 3-shop price counts at half strength) until enough shops agree.
The whole haystack
Two million rows. In this tab. Right now.
The charts above ran on tidy little summaries. This runs on everything: 2,027,565 raw price observations, a single 33 MB ParquetParquet: a columnar file format. It stores data by column instead of by row, which compresses far better and lets a reader grab just the columns and chunks it needs. Our 10 GB of raw listings became one 290 MB Parquet, then 33 MB once trimmed. file sitting on object storage. Your browser reaches in and grabs only the bytes it needs, using HTTP range requestsHTTP range request: asking a server for just bytes 1,000–2,000 of a file instead of the whole thing. DuckDB uses the Parquet file's index to fetch only the chunks a query touches, so a query over 2M rows might download a few MB, not 33..
This is DuckDB-WASMDuckDB-WASM: the DuckDB analytical database compiled to WebAssembly (~3.5 MB), running entirely in your browser. Real SQL, no backend, no API. This is the 2026 way to ship a data product with zero servers. doing real analytical SQL with nothing behind it. Edit the query. Hit run. Watch a laptop-grade database chew through two million rows from a static file.
| — |
|---|
| Run a query to pull live rows from the firehose. |
The verdicts
The Hong Kong Grocery Awards
Here's the thing we can't hand out: a “cheapest shop” trophy. On the items we can compare, every chain sits within a few percent of the others on price — nobody is meaningfully cheaper across the board. The differences worth changing your habits over aren't about price level. They're about behaviour: who gouges you on delivery, whose sales are theatre, who just plays it straight. So those are the awards we could actually back with the numbers.
Before calling anything “cheapest” we reshuffle the basket 500 times, redraw the items at random, and re-run the contest. If a shop only wins sometimes, we don't crown it — we say so. That's why there's no “cheapest shop” trophy above.
A sticker shouts “50% off.” The honest question is 50% off what. Subtract what you actually save versus the going rate from the claimed discount. Some sellers here claim 50% off and save you 0% — the whole “discount” is that gap. That's how Market Place's theatre got caught.
So my wife was right all along. No single shop wins on price, no app will fix it, and the reason it can't is sitting one section down: the shops hide the one number that would let anyone compare. Until that changes, the hunt is the job — but now at least you know which shops to trust, which to time, and which to walk past.
Follow the ownership
Half these “rival” shops are the same company
Wellcome, Mannings and Market Place by Jasons are all DFI Retail Group. PARKnSHOP and Watsons are both AS Watson. They aren't competing for you. They're one company wearing several masks, each priced for a different wallet so the masks don't eat each other's lunch.
This isn't an accident, it's the textbook. As Hermann Simon puts it, a cheaper line is “often marketed under a second brand, in order to differentiate it clearly from the primary brand and reduce the risk of cannibalization.” Each group runs one mass-grocery banner and one health-and-beauty banner at a totally different price personality. So when our pipeline “matched the same product across two shops,” it was almost always two banners of the same parent. That's not competition you can shop against — and it's exactly why a true cross-company price map is so hard to build (next).
Why it's frozen
The honest part: where I stopped
Comparing the same product across different companies needs a shared barcodeBarcode / EAN / SKU: the 13-digit GS1 number on a product. It's the only bulletproof way to know two listings are literally the same item across different retailers.. Most chains don't hand that out. So I matched by normalised name instead (a family_idfamily_id: a fingerprint built from a cleaned-up product name, so ‘Yakult 5x100ml’ and ‘Yakult LT 500ml [random delivery]’ collapse into one comparable family even without a barcode.). That works, until you notice almost every cross-chain match is the same parent company wearing a different hat.
The match funnel
families that survive each honesty filter
Wellcome, Mannings and Market Place by Jasons are all DFI. PARKnSHOP and Watsons are all AS Watson. Strip the same-parent banners out and a 14,325-family mountain becomes a 131-family hill. To climb past it you need the loyalty-app price feeds (yuu, MoneyBack). That's a different, much bigger project. I chose to stop. Knowing where the diminishing returns start is the actual skill.
The local model earned its keep
messy merchant categories, mapped once, on my own laptop
Thousands of junk category strings (“$10 Flash Sale”, “3:15 PM Tea Break”) refused to map. Instead of paying an API per row, qwen2.5:7b on Ollama classified each distinct string exactly once into a durable map (632 of them), then never ran again. A second local model, bge-m3, matched Chinese names to their English twins where barcodes were missing. Two million rows, normalised privately, for the price of electricity.
Visual explainer slot — a plain-language illustration of this concept lands here.