How to Scrape Zomato and Swiggy Data (Menus and Prices)
Scrape Zomato and Swiggy data that means something: pin the delivery coordinates, key every row by restaurant, pin and timestamp, and split the fee stack.

To scrape Zomato and Swiggy data that is worth anything, fix the delivery pin before you fix the parser, because both platforms resolve menus, prices, fees and availability from the coordinates carried in your session, which makes every row you store valid for exactly one combination of restaurant, pin and timestamp.
Most guides skip that. They show a menu endpoint, dump the item array, and hand you a price table with no location column and no clock. A week later nobody can tell whether a price moved or whether the scraper landed on a different default pin. This guide runs the other way round: coordinates first, then the internal JSON APIs both apps call, then a schema that separates item price from the fee stack, then the churn handling.
Why every food delivery number is location scoped
Open the same restaurant page from two pins four kilometres apart and you get two different pages. Not slightly different. Different delivery fee, different delivery estimate, sometimes a different set of live items, sometimes no listing at all. Both platforms treat the delivery coordinate as a primary input to pricing and catalog assembly, not a display detail.
Four things move underneath you.
The delivery pin. Distance drives the base delivery fee and the long distance surcharge, and it drives serviceability: a restaurant three kilometres out shows up, the same brand's outlet nine kilometres out does not. In a dense metro like Bengaluru or Mumbai, two pins in the same postal code can hit different outlets of the same chain, with different menus and different prices.
The clock. Menus are time gated. Breakfast items disappear at 11:00 IST, restaurants toggle open and closed, and surge or rain fees appear at peak dinner hours and through monsoon. India runs a single time zone, Asia/Kolkata at UTC+05:30 with no daylight saving, one of the few things here that makes life easier. Store timestamps in UTC, derive IST for analysis, and take the zone from the IANA time zone database rather than hardcoding an offset.
Promotions. Percentage off banners, free delivery thresholds, bank card offers and membership tiers all rewrite the visible price. Strike through price and effective price can differ by 60 percent on the same item at the same moment.
Session state. A logged out guest, a session with a delivery membership, and a session carrying a first order coupon see three different fee stacks. Scrape as an anonymous guest and stay consistent, or your series mixes populations no amount of cleaning separates later.
A price captured without the pin and the capture time is not a data point. It is an anecdote. Everything below exists so you never store one.
What you can collect, and where the DPDP Act stops you
Restaurant names, addresses, cuisine tags, menus, item prices, fee components, delivery estimates and offer banners are commercial information about a business. Collecting them from public pages for price research or competitive tracking is the ordinary use case, and the one this guide covers.
Reviewer names, profile photos, profile links and order histories are a different category in India. The Digital Personal Data Protection Act, 2023 governs digital personal data about identifiable individuals, and the implementing rules notified in late 2025 phase the operational obligations in over the following months. The Act carries no broad research or journalism carve out of the kind people assume from GDPR habits, and consent is the default lawful ground. Harvesting reviewer identities at scale to build profiles is exactly what that framework targets.
So draw the line in the schema, not in a policy document nobody reads.
- Collect restaurant, menu, price, fee and availability attributes. Those describe a business.
- Do not create a reviewer identity column. No
reviewer_name, noreviewer_id, noreviewer_url, no avatar URL. For sentiment, store the aggregate rating and rating count, which the platforms publish as business metadata, and stop there. - If you genuinely need review text, store it detached from any identifier and truncate it. Free text still carries names, so treat it as a hazard rather than a free field.
- Both platforms prohibit automated collection and publish crawl directives. Read swiggy.com/robots.txt, zomato.com/robots.txt, the Zomato terms and the Swiggy terms first. Public accessibility is not permission. Two questions, two answers.
- Never place, hold or cancel an order as part of a scrape. Reading a published fee is research. Pushing a cart through checkout to reveal one puts real cost on a restaurant.
One legitimate non scraping source deserves a mention. ONDC, the Open Network for Digital Commerce, defines an open protocol for catalog and price discovery across Indian commerce including food, and participating sellers expose catalogs through it by design. If you need catalog coverage rather than what Zomato and Swiggy specifically charge, that network is the cleaner input, with no bot wall in front of it.
This is engineering guidance, not legal advice. If the output feeds a commercial product, have an Indian data protection lawyer read the schema first.
Scraping at scale? Skip the blocks.
Fast, unblockable datacentre proxies with unlimited bandwidth.
The data model: key everything by restaurant, pin and time
Decide the key before writing a parser. The composite key for a food delivery observation is (platform, restaurant_id, pin_id, captured_at), with item rows hanging off it. Two tables keep item prices and fees from contaminating each other.
-- Item level: what the menu said, at this pin, at this moment.
CREATE TABLE menu_observation (
observation_id BIGSERIAL PRIMARY KEY,
platform TEXT NOT NULL, -- 'swiggy' | 'zomato'
restaurant_id TEXT NOT NULL, -- the platform's own id
outlet_label TEXT, -- e.g. 'Indiranagar'
pin_id TEXT NOT NULL, -- your pin registry key
pin_lat NUMERIC(9,6) NOT NULL,
pin_lng NUMERIC(9,6) NOT NULL,
captured_at TIMESTAMPTZ NOT NULL, -- store UTC, render IST
daypart TEXT NOT NULL, -- 'breakfast'|'lunch'|'snack'|'dinner'|'late'
item_id TEXT NOT NULL,
item_name TEXT NOT NULL,
item_category TEXT,
is_veg BOOLEAN,
variant_label TEXT, -- 'Half' / 'Full' / NULL
base_price_inr NUMERIC(10,2), -- list price, before offers
effective_price_inr NUMERIC(10,2), -- after item level discount
in_stock BOOLEAN,
menu_hash TEXT NOT NULL -- fingerprint of the whole menu
);
-- Order context: the fee stack, once per restaurant per pin per capture.
CREATE TABLE order_context (
observation_id BIGSERIAL PRIMARY KEY,
platform TEXT NOT NULL,
restaurant_id TEXT NOT NULL,
pin_id TEXT NOT NULL,
captured_at TIMESTAMPTZ NOT NULL,
is_open BOOLEAN,
eta_minutes INT,
distance_km NUMERIC(6,2),
delivery_fee_inr NUMERIC(8,2),
surge_fee_inr NUMERIC(8,2),
packaging_fee_inr NUMERIC(8,2),
platform_fee_inr NUMERIC(8,2),
tax_inr NUMERIC(8,2),
discount_inr NUMERIC(8,2),
offer_text TEXT,
min_order_inr NUMERIC(8,2),
fssai_licence TEXT, -- published on the menu, business data
aggregate_rating NUMERIC(3,2),
rating_count INT
);
Notice what is absent. Neither table has a reviewer column. aggregate_rating and rating_count are restaurant properties the platforms publish as business metadata, and that is where the personal data boundary sits. A constraint your loader cannot physically cross beats a rule in a wiki page.
Two more choices worth defending. base_price_inr and effective_price_inr stay separate because a discount is an event, not a price change, and collapsing them destroys any measure of promotional intensity. menu_hash fingerprints the sorted (item_id, base_price, in_stock) tuples, answering "did the menu change" without diffing thousands of rows.
Take the fssai_licence column. Indian food businesses display an FSSAI licence number and both platforms surface it on the menu page, which makes it the most reliable join key between a listing and a real legal entity. Better than fuzzy matching outlet names, and almost nobody collects it.
Set up the SparkProxy Scraping API
Both targets sit behind commercial bot management and per IP rate limits, and both punish a foreign exit IP. The SparkProxy Scraping API takes a target URL, routes it through a rotating proxy with optional browser rendering, and returns the response. Five parameters carry this job:
country_code=IN: exit through an Indian IP. Both platforms are India centric, and a US exit gets you a redirect, a stripped payload or a challenge. This is the single most important parameter here.premium_proxy=true: residential IPs. Datacenter ranges draw challenges quickly on both targets.render_js:falsefor the internal JSON endpoints, the fast and cheap path, andtrueonly where a page has to bootstrap a session.cookies: pre inject the location and session cookies carrying the delivery pin. This is what makes location control possible without running a browser.forward_headers: attach the CSRF token and the app style headers the internal APIs expect.
The base endpoint is https://scrape.sparkproxy.io/api/v1 and auth is a single X-API-Key header. A minimal call:
curl -G "https://scrape.sparkproxy.io/api/v1" \
-H "X-API-Key: YOUR_API_KEY" \
--data-urlencode "url=https://www.swiggy.com/dapi/restaurants/list/v5?lat=12.9784&lng=77.6408&page_type=DESKTOP_WEB_LISTING" \
--data-urlencode "premium_proxy=true" \
--data-urlencode "country_code=IN" \
--data-urlencode "render_js=false"
The full parameter list and credit costs are in the Scraping API docs. Everything below reuses one helper:
import json, requests
API = "https://scrape.sparkproxy.io/api/v1"
API_KEY = "YOUR_API_KEY"
def fetch(url, render=False, cookies=None, headers=None, timeout=90):
params = {
"url": url,
"render_js": "true" if render else "false",
"premium_proxy": "true",
"country_code": "IN",
}
if cookies:
# list of {"name", "value", "domain"} objects
params["cookies"] = json.dumps(cookies)
if headers:
params["forward_headers"] = json.dumps(headers)
r = requests.get(API, params=params,
headers={"X-API-Key": API_KEY}, timeout=timeout)
r.raise_for_status()
return r.text
def fetch_json(url, **kw):
return json.loads(fetch(url, **kw))
Cost matters at panel scale. A premium request costs 25 credits rendered and 10 unrendered, and a 200 restaurant panel across 8 pins is 1,600 calls per sweep. Bootstrap with rendering once per pin, run the JSON endpoints without it, and the sweep gets roughly 2.5x cheaper.
Building a pin registry and resolving coordinates
A pin registry is a small, version controlled table of the coordinates you sample. Treat it like a survey panel definition, because that is what it is. Pick pins that mean something: a dense restaurant cluster, a residential belt three to five kilometres out, and an edge of serviceability pin where fees turn interesting.
PINS = {
"blr-indiranagar": {"lat": 12.9784, "lng": 77.6408, "city": "bengaluru"},
"blr-whitefield": {"lat": 12.9698, "lng": 77.7500, "city": "bengaluru"},
"blr-jayanagar": {"lat": 12.9250, "lng": 77.5938, "city": "bengaluru"},
"mum-bandra-w": {"lat": 19.0596, "lng": 72.8295, "city": "mumbai"},
"mum-powai": {"lat": 19.1176, "lng": 72.9060, "city": "mumbai"},
"del-hauz-khas": {"lat": 28.5494, "lng": 77.2001, "city": "delhi"},
"hyd-gachibowli": {"lat": 17.4401, "lng": 78.3489, "city": "hyderabad"},
}
Six decimal places is roughly 0.1 metre of precision, more than enough and, more usefully, reproducible. Never let a scraper discover its own coordinates from an IP geolocation lookup. The exit IP moves, the panel silently redefines itself, and every dashboard keeps rendering as if nothing happened.
Swiggy exposes a two step place resolution flow mirroring the address picker in the app. Send a text query, get a place identifier, exchange it for coordinates. Useful when pins are defined by locality name rather than hand picked coordinates.
from urllib.parse import quote
def swiggy_resolve(query):
"""Locality text -> {lat, lng, formatted_address} via Swiggy's own picker."""
ac = fetch_json(
"https://www.swiggy.com/dapi/misc/place-autocomplete?input=" + quote(query)
)
preds = ac.get("data", [])
if not preds:
return None
place_id = preds[0]["place_id"]
detail = fetch_json(
"https://www.swiggy.com/dapi/misc/address-recommend?place_id=" + quote(place_id)
)
row = detail["data"][0]
geo = row["geometry"]["location"]
return {"lat": geo["lat"], "lng": geo["lng"],
"formatted_address": row.get("formatted_address")}
Response shapes on these internal endpoints shift between app releases. Open DevTools, filter the network tab by dapi, and confirm key names against a live response before trusting any parser. That habit separates a pipeline that survives a deploy from one that quietly returns empty lists.
Zomato: session bootstrap and the webroutes payload
Zomato is stricter. The consumer brand is still Zomato, though the listed parent renamed itself Eternal Limited in 2025, which matters only if you join this data against financial filings.
Two things make Zomato harder. The delivery location lives in a cookie rather than a query parameter, so a session bootstrap has to happen before any data call means anything. And the internal webroutes endpoints expect a CSRF token the page mints, so a cold request without one is rejected.
Bootstrap once per pin, then reuse:
import re, json
def zomato_session(pin_id):
"""Render the homepage once to mint cookies, then pin the location."""
p = PINS[pin_id]
html = fetch("https://www.zomato.com/", render=True)
m = re.search(r'window\.__PRELOADED_STATE__\s*=\s*JSON\.parse\(\s*"(.+?)"\s*\)',
html, re.S)
state = json.loads(json.loads('"%s"' % m.group(1))) if m else {}
csrf = state.get("pageData", {}).get("csrf") or state.get("csrf")
# The location cookie is what makes every later response location aware.
locus = json.dumps({
"addressId": 0, "lat": p["lat"], "lon": p["lng"],
"cityId": 0, "ltv": 0, "lty": "", "fetchFromCookie": False,
}, separators=(",", ":"))
cookies = [
{"name": "locus", "value": locus, "domain": ".zomato.com"},
{"name": "zl", "value": "en", "domain": ".zomato.com"},
]
headers = {"x-zomato-csrft": csrf} if csrf else {}
return cookies, headers
Field names inside __PRELOADED_STATE__ and the exact cookie payload move between releases, so verify both in DevTools against a live session rather than trusting the snippet blind. The structural point survives the churn: Zomato encodes the delivery pin in a cookie, so nothing you fetch means anything until it is set, and the same URL without it returns a city default belonging to nobody's real pin.
With a session in hand the page payload comes back as JSON:
def zomato_page(path, cookies, headers):
url = "https://www.zomato.com/webroutes/getPage?page_url=" + path
return fetch_json(url, cookies=cookies, headers=headers)
def zomato_menu(res_path, pin_id):
cookies, headers = zomato_session(pin_id)
doc = zomato_page(res_path + "/order", cookies, headers)
order = doc.get("page_data", {}).get("order", {})
menus = order.get("menuList", {}).get("menus", [])
items = []
for menu in menus:
for grp in menu.get("menu", {}).get("categories", []):
cat = grp.get("category", {})
for it in cat.get("items", []):
info = it.get("item", {})
items.append({
"item_id": str(info.get("id")),
"item_name": info.get("name"),
"item_category": cat.get("name"),
# Zomato sends rupees, not paise.
"base_price_inr": float(info.get("display_price")
or info.get("price") or 0),
"in_stock": not info.get("is_disabled", 0),
})
return items
If a Zomato path resists a direct JSON call, render the page with render_js=true and pull __PRELOADED_STATE__ out of the HTML instead. It costs 25 credits instead of 10, but it survives a webroutes change because the browser fires whatever the current call happens to be. The general technique is in How to Scrape Hidden JSON API Endpoints, and the cookie mechanics in Handle Cookies and Sessions in Web Scraping.
Capture the fee stack separately from item prices
Here is the mistake that wrecks food delivery analysis. Someone scrapes item prices, sees a stable 249 rupee biryani for six weeks, and reports flat pricing. The real cost to the customer moved 40 rupees, because delivery fee, platform fee and a surge charge all shifted underneath. Item price is one line in a stack, and the stack is where the movement lives.
| Component | Where it lives | Moves with | Store as |
|---|---|---|---|
| Item base price | menu item object | restaurant repricing, infrequent | `base_price_inr` |
| Item discount | offer tags on the item | campaign windows | `effective_price_inr` |
| Base delivery fee | listing `feeDetails` / order context | pin distance | `delivery_fee_inr` |
| Long distance fee | separate line in the fee list | pin distance | fold into `delivery_fee_inr`, keep the raw name |
| Surge or rain fee | fee list, appears and vanishes | weather, peak hours | `surge_fee_inr` |
| Packaging or restaurant charge | cart or menu metadata | per restaurant | `packaging_fee_inr` |
| Platform fee | order context | platform policy, changes quarterly | `platform_fee_inr` |
| Taxes | cart summary | statutory rates | `tax_inr` |
| Cart level discount | offer banner, coupon | campaign windows | `discount_inr` and `offer_text` |
Swiggy puts a fee breakdown on the restaurant object inside the listing response, the cheapest place to get it, because one listing call covers every restaurant at that pin:
FEE_MAP = {
"BASE_DELIVERY": "delivery_fee_inr",
"BASE_TIME": "delivery_fee_inr",
"BASE_DISTANCE": "delivery_fee_inr",
"ANCILLARY_SURGE_FEE": "surge_fee_inr",
"SURGE_FEE": "surge_fee_inr",
"RAIN_FEE": "surge_fee_inr",
}
def parse_fee_stack(fee_details):
"""Swiggy feeDetails -> normalized rupee columns. Unknown names are kept."""
stack = {"delivery_fee_inr": 0.0, "surge_fee_inr": 0.0, "other_fees": {}}
if not fee_details:
return stack
for f in fee_details.get("fees", []):
rupees = round((f.get("fee") or 0) / 100, 2)
col = FEE_MAP.get(f.get("name"))
if col:
stack[col] += rupees
else:
stack["other_fees"][f.get("name")] = rupees
stack["total_fee_inr"] = round((fee_details.get("totalFee") or 0) / 100, 2)
known = stack["delivery_fee_inr"] + stack["surge_fee_inr"]
known += sum(stack["other_fees"].values())
stack["reconciles"] = abs(known - stack["total_fee_inr"]) < 0.51
return stack
Keeping unrecognised fee names in other_fees instead of dropping them is the whole trick, and the reconciles flag makes it actionable. Both platforms add line items without notice, and a parser that silently discards what it does not recognise reports a total that stops matching its parts. Alert on reconciles == False and you hear about a new fee the day it launches, not the quarter after.
Two India specific realities belong in your notes. Since 1 January 2022, tax on restaurant services supplied through these platforms is paid by the platform rather than the restaurant, under the e-commerce operator provisions administered by the CBIC, which is why the tax line behaves like a platform charge. And listed menu prices exclude the platform's fee stack, so the number on a card is never the number paid. Proxies for Grocery and Delivery Prices covers quick commerce, where the same fee logic applies to a different catalog.
Running a multi pin panel without getting blocked
The failure mode is rarely a CAPTCHA on request one. It is a panel that quietly degrades, returning empty lists for one pin while the others look fine, unnoticed for a fortnight because the job keeps exiting zero.
| Symptom | Likely cause | Fix |
|---|---|---|
| Empty `cards` array with HTTP 200 | non Indian exit IP, or unserviceable pin | set `country_code=IN`, verify the pin resolves |
| Identical fees at every pin | coordinates or location cookie not applied | re-bootstrap the session, assert pin variance |
| 403 or a challenge page | datacenter IP, request burst | `premium_proxy=true`, slow the sweep |
| `webroutes` returns 4xx | CSRF token stale or missing | re-run the bootstrap, forward the token |
| Prices 100x too high | paise treated as rupees | divide Swiggy values by 100 at ingest |
| Item count collapses overnight | daypart effect, not a delisting | compare same daypart, same pin only |
Practices that keep a panel honest:
- Assert pin variance in CI. After every sweep, check that at least two pins report different delivery fees for the same chain. If every pin agrees exactly, location control has stopped working and every row from that sweep is suspect. This one assertion catches more silent breakage than all your parser unit tests combined.
- Sample restaurants, do not crawl the universe. A tracked panel of 150 to 400 restaurants answers pricing questions at a fraction of the request volume of enumerating every listing.
- Stagger by pin. Fire all seven pins in the same second from the same subnet and you look exactly like what you are. Randomize pin order, leave 3 to 8 seconds between calls.
- Sticky exit per pin. Hold one exit IP per pin for a whole sweep so the cookie, the coordinates and the IP tell a consistent story. What Does Geo Targeting Mean in Proxies covers the mechanics.
- Archive a raw sample. Keep the unparsed JSON for one restaurant per pin per sweep. When a parser breaks three weeks later, that archive names the release that changed the shape.
- Fail loudly. A sweep returning under 60 percent of the expected restaurant count should raise, not log a warning nobody reads.
The discipline transfers. How to Scrape OpenTable Data covers the reservation availability equivalent, and How to Scrape Yelp Business Data the review aggregator side, where the personal data boundary is the one drawn above.
Frequently asked questions
FAQ
Collecting public restaurant, menu and price information is ordinary commercial data collection, but both platforms prohibit automated access in their terms, so a scrape can breach a contract even where nothing criminal happens. Collecting reviewer identities is a separate and far riskier activity under the Digital Personal Data Protection Act, 2023, so keep personal data out of the schema entirely.
Because the delivery pin, the capture time and any active promotion are all inputs to the displayed price, and the two platforms apply different commission structures, fee stacks and campaign calendars to the same outlet. Comparing them is only valid when the pin and the timestamp match on both sides.
You will get a response, but it is a city level default rather than a real customer's view, and its fees and serviceability belong to no actual pin. Swiggy takes coordinates as query parameters on its dapi endpoints and Zomato reads them from a session cookie, so set the location explicitly on both.
Not for the data itself. Swiggy's dapi menu and listing endpoints return JSON to a plain HTTP request, so render_js=false works and costs a fraction of a rendered call. Reserve rendering for Zomato's session bootstrap and for any payload that resists a direct call.
Menus once or twice a day per pin is usually enough, since item level repricing is infrequent. Fees, surge charges and item availability move hourly, so sample the order context every 60 to 120 minutes during service hours and always include a peak dinner window.
Aggregate ratings and rating counts are business attributes and are fine to store. Reviewer names, profile links and anything that builds a picture of an identifiable person are personal data under the DPDP Act, 2023, which carries no broad research exemption, so design the schema without a reviewer identity column instead of justifying one later.
Get 20% off your first month
Premium datacentre proxies with unlimited bandwidth. Use the code at checkout.
Save up to 15% more on quarterly, half-yearly and yearly plans
Related articles

How to Scrape Yandex Search Results in 2026
Scrape Yandex search results reliably: lr region codes, Cyrillic queries, SmartCaptcha detection, the official XML API, and working SparkProxy code.

How to Scrape Vinted Listings
Scrape Vinted listings across vinted.fr, .de and .co.uk: the internal JSON API, cookie bootstrapping, per-market catalogue IDs, and GDPR-safe resale analytics.

How to Scrape TikTok Public Data With Proxies
Scrape TikTok public data with proxies: read the hydration JSON blob, use the Research API and oEmbed, detect fake 200s, and cut credits per good page.
