Swiggy Scraper in Python: Dish Prices and Menus by Location
Swiggy is India's biggest food delivery app, and its search answers the question every food business asks: what does this dish cost across the restaurants that deliver here? One search for "biryani" returns about 50 restaurants and every biryani on their menus, with price, veg flag, rating and delivery time. Getting that into a CSV takes one short Python script, but there are three things in the way: an AWS WAF challenge, a web page with no data in it, and results that change completely with the delivery address.
The block: an AWS WAF challenge
import requests chrome = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36" r = requests.get("https://www.swiggy.com/city/bangalore", headers={"User-Agent": chrome}) print(r.status_code, len(r.text), r.headers.get("x-amzn-waf-action")) # 202 0 challenge | Request | Result |
|---|---|
requests with a Chrome user agent | HTTP 202, empty body, x-amzn-waf-action: challenge |
requests with its default user agent | HTTP 403, an error page |
Through Scrape.do, geoCode=in | 200 in about 3 s, the full response, every try |
The 202 is the tricky one: a status check that only looks for errors lets it through, and the script parses an empty string. I route every request through Scrape.do from India (geoCode=in). A plain request is enough here, 1 credit each.
Trap 2: the page has no data
A restaurant page like /city/bangalore/starbucks-coffee-ashok-nagar-rest124178 comes back as 78 KB of HTML with no menu in it. The browser fills it in afterwards from JSON endpoints under /dapi, so those are what the script calls:
| Data | Endpoint |
|---|---|
| Dish search | /dapi/restaurants/search/v3?lat&lng&str=biryani&selectedPLTab=DISH |
| Restaurant search | the same, with selectedPLTab=RESTAURANT |
| Full menu | /dapi/menu/pl?page-type=REGULAR_MENU&complete-menu=true&lat&lng&restaurantId=… |
Every response is a list of widget cards with an @type. The dish search returns one DishGroup card per restaurant, each holding the restaurant and its matching dishes; the rest are filters and suggestions. Money is in paise: "price": 42000 is ₹420, and some dishes only carry defaultPrice.
import csv import json import requests TOKEN = "<your_token>" LAT, LNG = 19.0760, 72.8777 # delivery location: Mumbai def swiggy(path, **query): url = requests.Request("GET", "https://www.swiggy.com" + path, params=query).prepare().url response = requests.get("https://api.scrape.do/", params={"token": TOKEN, "url": url, "geoCode": "in"}, timeout=60) body = response.json() if response.status_code != 200 or body.get("statusCode") != 0: raise RuntimeError(f"HTTP {response.status_code}: {body.get('statusMessage')}") return body["data"] def rupees(paise): return int(paise) / 100 if paise is not None else None data = swiggy("/dapi/restaurants/search/v3", lat=LAT, lng=LNG, str="biryani", trackingId="undefined", submitAction="ENTER", selectedPLTab="DISH") cards = data["cards"][0]["groupedCard"]["cardGroupMap"]["DISH"]["cards"] rows = [] for card in cards: group = card["card"]["card"] if not group.get("@type", "").endswith("DishGroup"): continue # filters and query suggestions shop = group["restaurant"]["info"] for dish in group["dishes"]: item = dish["info"] rows.append({ "dish": item["name"], "price_inr": rupees(item.get("price", item.get("defaultPrice"))), "veg": bool(item.get("isVeg")), "rating": item.get("ratings", {}).get("aggregatedRating", {}).get("rating"), "restaurant": shop["name"], "restaurant_rating": shop.get("avgRating"), "delivery_min": shop["sla"].get("deliveryTime"), "ad": bool(shop.get("promoted")), }) with open("swiggy-biryani.csv", "w", newline="", encoding="utf-8") as f: writer = csv.DictWriter(f, fieldnames=list(rows[0].keys())) writer.writeheader() writer.writerows(rows) print(f"Saved {len(rows)} dishes from {len({r['restaurant'] for r in rows})} restaurants") One request, 304 dishes from 50 restaurants. 13 of those dishes came from restaurants Swiggy marks as promoted: paid placements mixed into the results, worth a column of their own.
Trap 3: the address decides the results
lat and lng decide which restaurants deliver, how far they are and how long delivery takes. The same search in two cities:
| "biryani" | Bengaluru | Mumbai |
|---|---|---|
| Restaurants in the dish search | 50 | 50 |
| Restaurants in the restaurant search | 102 | 100 |
| Cheapest dish with "biryani" in its name | ₹90 | ₹80 |
Not one restaurant appears in both lists. Change the coordinates and you get a different market, so record them with every row. A menu still loads for a far-away address; the restaurant comes back with serviceability: NON_SERVICEABLE instead of a delivery time.
The full menu: variants and FSSAI licence
The menu endpoint returns every section of a restaurant's menu, plus its FSSAI licence and address. One catch: the "Recommended" section repeats dishes from the other sections. Starbucks in Ashok Nagar has 168 dish cards and 148 dishes. Same script, continued:
import re def menu(restaurant_id): data = swiggy("/dapi/menu/pl", **{"page-type": "REGULAR_MENU", "complete-menu": "true"}, lat=LAT, lng=LNG, restaurantId=restaurant_id) sections = next(c for c in data["cards"] if "groupedCard" in c)["groupedCard"]["cardGroupMap"]["REGULAR"]["cards"] dishes, licence = {}, None for section in sections: card = section["card"]["card"] if card.get("@type", "").endswith("RestaurantLicenseInfo"): licence = re.search(r"\d{10,}", " ".join(card["text"])).group(0) nested = [card] + card.get("categories", []) for group in nested: for item_card in group.get("itemCards", []): item = item_card["card"]["info"] dishes.setdefault(item["id"], { # "Recommended" repeats dishes: keep each once "dish": item["name"], "category": item.get("category"), "price_inr": rupees(item.get("price", item.get("defaultPrice"))), "veg": bool(item.get("isVeg")), "in_stock": bool(item.get("inStock", 1)), }) return licence, list(dishes.values()) licence, dishes = menu("124178") # Starbucks, Ashok Nagar, Bengaluru print(f"FSSAI {licence}: {len(dishes)} dishes") print(dishes[0]) FSSAI 11218333002406: 148 dishes {'dish': 'Marigold Oat cold brew', 'category': 'Diwali Specials', 'price_inr': 420.0, 'veg': True, 'in_stock': True} Sizes and options sit in variants (a surcharge on the base price) or variantsV2.pricingModels (absolute prices); the package below turns both into rupees.
Skip the boilerplate
The same logic is packaged as an open-source library, swiggy-scraper on GitHub and PyPI, with city presets, variant prices, retries and CSV output:
pip install swiggy-scraper export SCRAPEDO_TOKEN=your_token swiggy-scraper dishes biryani --city mumbai -f csv -o swiggy-biryani.csv swiggy-scraper restaurants pizza --city bengaluru -f csv -o swiggy-pizza.csv swiggy-scraper menu https://www.swiggy.com/city/bangalore/starbucks-coffee-ashok-nagar-rest124178 -f csv -o swiggy-menu.csv There is also a no-code version on Apify (Swiggy Scraper) and a browser demo on Hugging Face.
Run it at scale with Scrape.do
Every request here is one Scrape.do call from India, 1 credit: no browser, no WAF tokens. Run the same dish search for every neighbourhood you care about and you have a price map of the market, or pull the menus of your competitors every morning. Get 1000 free credits and start scraping with Scrape.do.
The script calls the JSON endpoints Swiggy's own pages use for search and menus. Swiggy's robots.txt allows these paths and disallows account, cart, checkout, order tracking and payment pages, which it never touches. Restaurant and menu data is not personal data; read Swiggy's terms for your use case and keep request rates modest.