A common mistake in food delivery pricing analysis is scraping the checkout total and treating it as the price. That number is useful, but it hides the reason the price changed. Was it the restaurant markup, delivery fee, service fee, a promotion, subscription eligibility, surge pricing, or a different address? If you only store the final total, every downstream dashboard turns into guesswork.
Food delivery pricing is a stack. Your data model needs to preserve the stack.
The price is not one field
For the same basket, two platforms can differ by 20% at checkout while showing similar menu prices. The difference usually comes from layers that move independently:
- Restaurant item prices and markups
- Platform service fees
- Delivery fees
- Small order fees
- Promotions and credits
- Subscription benefits
- Driver or demand incentives that affect availability and delivery estimates
- Taxes and local fees
If your scraper or ingestion job flattens this into total_price, you lose the ability to answer basic questions later.
A better raw table stores both the observed total and each component you can identify:
create table delivery_quotes (
quote_id text primary key,
platform text not null,
restaurant_id text not null,
restaurant_name text not null,
captured_at timestamptz not null,
country text not null,
city text not null,
postal_code text not null,
latitude numeric,
longitude numeric,
basket_hash text not null,
subscriber boolean not null,
item_subtotal_cents integer,
menu_markup_cents integer,
service_fee_cents integer,
delivery_fee_cents integer,
small_order_fee_cents integer,
promo_discount_cents integer,
tax_cents integer,
tip_cents integer,
total_cents integer not null,
delivery_eta_min integer,
available boolean not null,
raw_payload jsonb not null
);
The raw_payload column matters. Delivery platforms change labels, reorder fee rows, and sometimes collapse fees into vague names like other fees. If you only store parsed fields, parser bugs become permanent data loss.
Normalize baskets before comparing platforms
Competitive pricing data only works when the basket stays stable. Comparing a burger meal on one platform with a different combo on another platform creates noise that looks like pricing strategy.
One practical approach is to define canonical baskets, then map platform-specific item IDs into them.
from dataclasses import dataclass
@dataclass(frozen=True)
class BasketItem:
canonical_sku: str
quantity: int
@dataclass(frozen=True)
class Basket:
basket_id: str
items: tuple[BasketItem, ...]
LUNCH_BASKET = Basket(
basket_id="burger_lunch_v1",
items=(
BasketItem("cheeseburger", 1),
BasketItem("fries_regular", 1),
BasketItem("coke_500ml", 1),
),
)
PLATFORM_SKU_MAP = {
"ubereats": {
"cheeseburger": "item_82716",
"fries_regular": "item_19302",
"coke_500ml": "item_77291",
},
"doordash": {
"cheeseburger": "sku_cb_001",
"fries_regular": "sku_fr_003",
"coke_500ml": "sku_dr_014",
},
}
def build_platform_cart(platform: str, basket: Basket) -> list[dict]:
sku_map = PLATFORM_SKU_MAP[platform]
return [
{"platform_item_id": sku_map[item.canonical_sku], "quantity": item.quantity}
for item in basket.items
]
This mapping looks boring, but it is where a lot of pricing analysis succeeds or fails. You need versioned baskets because menus change. If a restaurant replaces coke_500ml with coke_330ml, you should create burger_lunch_v2, not silently mutate historical comparisons.
Geography needs to be below city level
City-level averages hide the interesting part. Delivery fees and promotions often vary by postal code, neighborhood, traffic corridor, or even address. A platform can look competitive across New York while being consistently expensive in a high-value set of Manhattan postal codes.
For data collection, treat location as part of the primary key. These two observations are not duplicates:
platform=ubereats restaurant_id=abc basket=burger_lunch_v1 postal_code=10001 captured_at=2026-08-18T18:00Z
platform=ubereats restaurant_id=abc basket=burger_lunch_v1 postal_code=10011 captured_at=2026-08-18T18:00Z
They may return different delivery fees, ETAs, availability, and promotions.
This is also where scraping jobs become fragile. You need stable address fixtures, session handling, and checks that the platform actually priced the quote for the intended location. Tools like Wire sit in this part of the stack: geo-aware extraction, promotion capture, and failure handling for delivery platforms whose pages change often.
Promotions expire faster than weekly reporting
If you collect data once per week, you are not monitoring promotions. You are sampling history.
Flash discounts and cuisine-specific offers can appear for a few hours during peak demand. If a competitor runs 30% off sushi from 6pm to 9pm on Friday, a Monday morning scrape will miss the entire event.
For promotion monitoring, store every observed offer separately from the quote:
create table delivery_promotions (
promo_observation_id text primary key,
platform text not null,
restaurant_id text,
postal_code text not null,
captured_at timestamptz not null,
promo_type text not null,
description text not null,
discount_percent numeric,
discount_cents integer,
minimum_order_cents integer,
funded_by text,
expires_at timestamptz,
raw_payload jsonb not null
);
Then query by demand window, not calendar week:
select
platform,
postal_code,
count(*) as promo_count,
avg(discount_percent) as avg_discount_percent
from delivery_promotions
where captured_at >= '2026-08-14 17:00:00+00'
and captured_at < '2026-08-14 22:00:00+00'
and promo_type = 'percentage_discount'
group by platform, postal_code;
The failure mode here is quiet. Nothing crashes. Your dashboard just says there were no promotions because your crawler slept through them.
Segment subscriber and non-subscriber prices
Subscriptions change the meaning of price. A non-subscriber may see a $3.99 delivery fee while a subscriber sees zero. If you average those together, you get a number nobody actually paid.
Collect both views when possible:
- Logged out or non-subscriber session
- Subscriber session
- Same basket
- Same restaurant
- Same address
- Same time window
Store subscriber as a dimension, not as metadata. Analysts will need to filter on it constantly.
This also changes promotion analysis. Free delivery promotions barely matter to users who already get free delivery, but percentage discounts still do. If your model does not segment subscriptions, you can overestimate or underestimate the effect of a competitor promotion.
Build checks for bad quotes
Delivery pricing data has plenty of bad states. Some are obvious, like HTTP 403s. Others look valid unless you check them.
Examples:
- Restaurant is closed, but the page still returns menu prices
- Address geocodes to the wrong city
- Basket item is unavailable and the cart silently drops it
- Promotion requires a minimum order your basket does not meet
- Currency changes because the platform redirects to another market
- Subscriber session expires and starts returning non-subscriber fees
Add validation before writing analytics tables:
def validate_quote(q: dict) -> list[str]:
errors = []
if not q["available"]:
errors.append("restaurant_unavailable")
if q["currency"] != q["expected_currency"]:
errors.append("currency_mismatch")
if q["requested_postal_code"] != q["priced_postal_code"]:
errors.append("location_mismatch")
if q["cart_item_count"] != q["expected_item_count"]:
errors.append("basket_incomplete")
component_sum = sum(q.get(k, 0) or 0 for k in [
"item_subtotal_cents",
"service_fee_cents",
"delivery_fee_cents",
"small_order_fee_cents",
"tax_cents",
]) - (q.get("promo_discount_cents", 0) or 0)
if abs(component_sum - q["total_cents"]) > 2:
errors.append("total_does_not_match_components")
return errors
Keep invalid observations, but mark them. They are useful for debugging collection quality, not for pricing decisions. Wire is relevant here because reliable delivery extraction is less about fetching pages and more about detecting these bad quote states before they pollute the dataset.
The useful benchmark is explainable
A checkout total tells you who is cheaper. A layered quote tells you why.
That difference matters when someone asks what to do next. Lowering delivery fees, funding a restaurant promo, changing subscription benefits, and adjusting commission terms are different actions. They should not come from the same flattened metric.
If you are building this internally, start with one market, three postal codes, two baskets, and two collection windows per day. Store raw payloads, parsed fee components, subscriber state, and validation errors from the beginning. It is much easier to widen a clean dataset than to explain six months of totals that cannot be decomposed.
Top comments (0)