Use a recipe API from Python in ten minutes
Search recipes with requests, read the JSON, and have some fun with pandas.
You've got a notebook open and you want recipe data in it. Maybe for a class project, maybe for a side project, maybe just because you're curious how long Italian food takes to cook. Let's get there in about ten minutes with requests and pandas.
First, grab a free key from the sign-up page. No credit card. You get 50 requests a day and 300 a week, which is plenty for this. Put the key in an environment variable so it doesn't end up in your notebook or on GitHub:
export TINYPLATES_API_KEY=rd_your_api_key
pip install requests pandas
Now a tiny helper. Every request goes to https://api.tinyplates.dev with the key as a bearer token. When something goes wrong, the API sends back a JSON error with a message written for humans, so we just pass that along:
import os
import requests
API = "https://api.tinyplates.dev"
session = requests.Session()
session.headers["Authorization"] = f"Bearer {os.environ['TINYPLATES_API_KEY']}"
def get(path, **params):
response = session.get(f"{API}{path}", params=params, timeout=30)
if not response.ok:
try:
message = response.json()["error"]["message"]
except (ValueError, KeyError):
message = response.text
raise RuntimeError(f"Tiny Plates said {response.status_code}: {message}")
return response.json()
Let's search for something:
data = get("/search", query="creamy pasta", limit=5)
for result in data["results"]:
print(result["id"], result["title"])
Search works on meaning as well as words, so "creamy pasta" finds carbonara too. You get up to 50 results, best first. If you only need a few fields, ask for them with fields. The responses get much smaller:
data = get("/search", query="creamy pasta", fields="title,times,cuisines", limit=5)
What's in a recipe
Pick an id from the search and fetch the whole thing:
recipe = get(f"/recipes/{data['results'][0]['id']}")["recipe"]
print(recipe["title"])
print(recipe["times"]) # {'prep': {'seconds': 600}, 'total': {'seconds': 1800}, ...}
for line in recipe["ingredients"]:
print(line["display"]) # "2 cloves garlic, roughly chopped"
for step in recipe["instructions"]:
print(step["step"], step["text"])
A few things worth knowing. Times are in seconds, under prep, cook, inactive and total, and any of them can be missing. Each ingredient has a ready-to-print display line, plus the pieces split out: name, quantity (with value and unit), preparation and optional. The line the source site wrote is kept in original. If you just want text, recipe["text"]["ingredients"] and recipe["text"]["instructions"] are flat lists of strings. And url points back to the page the recipe came from. Please link to it when you show a recipe.
You can also scale and convert on the way in. This asks for two servings in metric:
small = get(f"/recipes/{recipe['id']}", servings=2, units="metric")
The full list of fields is in the recipe object docs.
Now filters. Allergens are done on the server. Add without and recipes with those allergens never show up:
data = get("/search", query="weeknight curry", without="milk,peanuts")
The names come from /vocabularies. We wrote more about how that works in Allergen data for every recipe.
Diets are a bit different. Search doesn't take a diet filter, but every recipe carries dietary flags like vegetarian and glutenFree. So filter in Python. One catch: a missing flag means "we don't know", and False means a known no. Check for True, not just "not False":
veggie = [r for r in data["results"] if r.get("dietary", {}).get("vegetarian") is True]
You might spot that the /recipes list endpoint has filters like diet and cuisine. It's meant for a business plan that doesn't exist yet, so on a free key it answers with a 403. Stick to /search, /popular and /recipes/{id}.
Next, more than one page. Search gives you the best matches and stops, so it has no paging. /popular does. It takes limit (up to 100) and offset, and each response has a pagination object with the total. Here's the thing to watch: every page is one request, and you have 50 a day. So always put a cap on the loop:
def popular(pages=3, language="en"):
recipes = []
for page in range(pages):
data = get("/popular", limit=100, offset=page * 100, language=language)
recipes += data["recipes"]
if len(recipes) >= data["pagination"]["total"]:
break
return recipes
Three pages is 300 recipes for 3 requests. language can be en, es or sv. If you go over the daily allowance you get a 429 that says so, and it resets at midnight UTC. More on that in the pagination docs.
Notebooks re-run cells a lot, and each re-run spends requests. Save what you fetched to a file and read it from there next time. The full script below does that.
Now the fun part. Turn the recipes into a DataFrame and ask it something:
import pandas as pd
rows = []
for r in recipes:
total = r["times"].get("total", {}).get("seconds")
rows.append({
"title": r["title"],
"cuisine": r["cuisines"],
"minutes": total / 60 if total else None,
"ingredients": len(r["ingredients"]),
"vegetarian": r.get("dietary", {}).get("vegetarian"),
})
df = pd.DataFrame(rows)
by_cuisine = (
df.explode("cuisine")
.dropna(subset=["cuisine", "minutes"])
.groupby("cuisine")["minutes"]
.agg(["count", "mean"])
.query("count >= 5")
.sort_values("mean")
)
print(by_cuisine.round(1))
A recipe can have more than one cuisine, so explode gives each one its own row. The count >= 5 keeps a single slow-roasted outlier from crowning a cuisine. Try the same with ingredients instead of minutes, or check what share of each cuisine is vegetarian with df["vegetarian"].eq(True). Keep in mind these are popular recipes, not every recipe, so it's a fun look and not a study.
The full script
import json
import os
from pathlib import Path
import pandas as pd
import requests
API = "https://api.tinyplates.dev"
CACHE = Path("popular.json")
session = requests.Session()
session.headers["Authorization"] = f"Bearer {os.environ['TINYPLATES_API_KEY']}"
def get(path, **params):
response = session.get(f"{API}{path}", params=params, timeout=30)
if not response.ok:
try:
message = response.json()["error"]["message"]
except (ValueError, KeyError):
message = response.text
raise RuntimeError(f"Tiny Plates said {response.status_code}: {message}")
return response.json()
def popular(pages=3, language="en"):
if CACHE.exists():
return json.loads(CACHE.read_text())
recipes = []
for page in range(pages):
data = get("/popular", limit=100, offset=page * 100, language=language)
recipes += data["recipes"]
if len(recipes) >= data["pagination"]["total"]:
break
CACHE.write_text(json.dumps(recipes))
return recipes
rows = []
for r in popular():
total = r["times"].get("total", {}).get("seconds")
rows.append({
"title": r["title"],
"cuisine": r["cuisines"],
"minutes": total / 60 if total else None,
"ingredients": len(r["ingredients"]),
"vegetarian": r.get("dietary", {}).get("vegetarian"),
})
df = pd.DataFrame(rows)
by_cuisine = (
df.explode("cuisine")
.dropna(subset=["cuisine", "minutes"])
.groupby("cuisine")["minutes"]
.agg(["count", "mean"])
.query("count >= 5")
.sort_values("mean")
)
print(by_cuisine.round(1))
print(f"{df['vegetarian'].eq(True).mean():.0%} of these are vegetarian")
The first run costs three requests. Every run after that is free, until you delete popular.json.
If you landed here looking for Spoonacular in Python, that's a fine choice too. It covers much more ground, like grocery products and restaurant menu items. We wrote an honest comparison and a roundup of Spoonacular alternatives if you're weighing them up.
From here you could build a "what can I cook" script with /pantry, or merge a week of recipes into one list with /shopping-list. If you work with an AI assistant, the MCP server lets it search recipes for you. Grab a free key and have a play.