Quickstart notes
Save Pinnacle odds API prices to a CSV file in Python
Extend the Python polling loop: write one CSV row per price change, dedupe against the last value, and stay inside the free key's daily allowance.
The Python quickstart on this site polls the Pinnacle odds API and prints the money line. The next question most readers ask is how to keep what they fetched. A CSV file is enough for a first store: one row per selection per change, appended on every poll, readable by Excel, pandas or a database import later.
What a row should hold
Store the observation, not just the price. A useful row names the event (id, league, teams), the market coordinates (period, market, side) and the observation itself: the decimal price and the time you saw it. Add the response's last cursor if you want to trace which poll wrote the row. Prices arrive as decimal odds, so keep them exactly as served and convert later if you need another format.
Write the timestamp in UTC and in a strict format. A price you cannot place in time is hard to compare against a closing price later, and mixed timezones will corrupt a week of careful logging in one afternoon.
Append on change, not on poll
The since cursor already limits each response to changed events, but a changed event still contains many unchanged selections. Keep a small dictionary of the last price you wrote per selection and write a row only when the value differs. Without that rule the file grows with your poll rate instead of the market's movement, and an all-day loop writes hundreds of identical rows.
One wrinkle: the dictionary starts empty after a restart. If you want clean deduplication across restarts, prime it at startup by reading the file and keeping the last row for each selection key.
The loop, extended
import csv, os, time, requests
BASE = os.environ["ODDS_BASE_URL"]
KEY = os.environ["ODDS_API_KEY"]
URL = BASE + "/kit/v1/markets"
s = requests.Session()
s.headers["x-portal-apikey"] = KEY
last_seen = {} # (event_id, side) -> price
since = None
def rows(ev, seen_at):
ml = ev.get("periods", {}).get("num_0", {}).get("money_line", {})
for side in ("home", "draw", "away"):
price = ml.get(side)
if price is None:
continue
key = (ev["id"], side)
if last_seen.get(key) != price:
last_seen[key] = price
yield [seen_at, ev["id"], ev.get("league_name", ""),
ev["home"], ev["away"], side, price]
with open("odds.csv", "a", newline="") as f:
w = csv.writer(f)
if f.tell() == 0:
w.writerow(["observed_at", "event_id", "league", "home",
"away", "side", "price"])
while True:
q = {"sport_id": 2}
if since is not None:
q["since"] = since
r = s.get(URL, params=q, timeout=10)
if r.status_code == 429:
time.sleep(int(r.headers.get("Retry-After", "5")))
continue
r.raise_for_status()
data = r.json()
since = data["last"]
seen_at = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
for ev in data["events"]:
w.writerows(rows(ev, seen_at))
f.flush()
time.sleep(900)
The 900-second sleep keeps the whole day inside the free key's 100 calls, the same budget the Python quickstart uses for an all-day test. The free tier is listed at 20 calls a minute and 100 a day on the pricing page, checked 2026-09-29. On a paid plan the same script runs unchanged with a shorter sleep.
Where this stops
CSV is a good first store and a poor long-term one. It has no transactions, no deduplication across restarts unless you reload the last prices from the file, and no compression. When the file passes a few hundred thousand rows, or when more than one process needs to read it, move the same change-only logic into a table. Until then, a flat file you can open in a spreadsheet is the fastest way to watch your own polling work.