BlogBuilding with odds data

Export betting odds to CSV in Python

A betting odds CSV file takes one matched board from OddsRelay's API and Python's csv module: fetch the board, walk it down to each offer, and write one row per bookmaker back price and one per exchange lay price.

The script below does that for any matched board, with nothing beyond the standard library.

How do you export odds from an API to a CSV file?

You flatten the JSON, because a CSV file has no nesting. A matched board holds events, then markets, then outcomes, and each outcome holds a back list of bookmaker offers and a lay list of exchange offers, so every offer becomes a row that repeats the event, market and selection it belongs to.

We write one row per offer on purpose. One row per outcome, with a column for each bookmaker, looks tidy until a new venue prices the market and every column after it moves. One row per offer never changes shape: a new venue is just more rows, and a side column keeps the bookmaker backs and the exchange lays apart in the same file.

The lay rows carry one more number than the back rows: available, the money waiting at that lay price. With it in its own column, a sheet can filter out lays too thin for a given stake, or sort a selection's lays by depth.

Two files, run from one folder

The script needs Python 3.9 or later and installs nothing. The csv-export folder holds both files and a test that runs on the API's own example replies. Clone the repo or copy the two files below into one folder, get a key through the quickstart, set ODDSRELAY_KEY in your shell, and pass main.py the board and filters you want (with no --out, the file is board.csv):

Run it · example

git clone https://github.com/oddsrelay/examples.git
cd examples/csv-export
export ODDSRELAY_KEY="your API key"
python3 main.py --board standard --sports soccer --out football.csv
python3 main.py --board each-way --out each-way.csv
python3 main.py --sports soccer --markets h2h --bookmakers william_hill --exchanges smarkets --max-tokens 50

api.py is the same helper the Python oddsmatcher tutorial prints and explains. It sends the key in an Authorization: Bearer header, asks for gzip, waits out a 429 for as long as Retry-After says, and stops on any other error with the API's own error code and request id. afford() checks the quote=true price, so nothing is spent until that price is within your --max-tokens ceiling or you say yes at the prompt, and a price above the ceiling stops the run.

api.py · example

"""The small HTTP helper every example here shares. Each folder keeps an identical copy, so any
folder runs on its own with nothing but the Python standard library."""
from __future__ import annotations

import gzip
import json
import os
import sys
import time
from http.client import HTTPMessage
from typing import Any
from urllib.error import HTTPError, URLError
from urllib.parse import urlencode
from urllib.request import Request, urlopen

API = "https://api.oddsrelay.io"


def get(path: str, params: dict, headers: dict | None = None) -> tuple[int, HTTPMessage, Any]:
    """GET one route with gzip: (status, headers, decoded JSON or None). Waits out 429s, exits on errors."""
    key = os.environ.get("ODDSRELAY_KEY", "").strip()  # a stray newline would put the key in a ValueError
    if not key:
        sys.exit("set ODDSRELAY_KEY to your API key")
    query = urlencode({k: v for k, v in params.items() if v is not None})
    for attempt in range(3):
        req = Request(f"{API}{path}" + (f"?{query}" if query else ""), headers={
            "Authorization": f"Bearer {key}", "Accept-Encoding": "gzip", **(headers or {})})
        try:
            with urlopen(req, timeout=30) as res:
                status, hdrs, body = res.status, res.headers, res.read()
        except HTTPError as err:  # urllib raises for 304 and for every 4xx and 5xx
            status, hdrs, body = err.code, err.headers, err.read()
        except URLError as err:
            sys.exit(f"could not reach {API}: {err.reason}")
        if body and hdrs.get("Content-Encoding") == "gzip":
            body = gzip.decompress(body)
        if status == 429:  # rate-limited: wait as long as the reply says, unless that was the last try
            if attempt < 2:
                time.sleep(int(hdrs.get("Retry-After", "1")))
            continue
        if status >= 400:
            error = json.loads(body).get("error", {}) if body[:1] == b"{" else {}
            sys.exit(f"HTTP {status} {error.get('code', '')}: {error.get('message', '')} "
                     f"(request id {error.get('request_id', '-')})")
        return status, hdrs, json.loads(body) if body else None
    sys.exit("still rate-limited after 3 tries")


def afford(quote: dict, max_tokens: int | None) -> bool:
    """True when a quoted price is fine: free, within --max-tokens, or confirmed at the prompt."""
    left = "unknown" if quote["remaining"] is None else quote["remaining"]
    print(f"quote: {quote['cost']} tokens ({left} left)", file=sys.stderr)
    if quote["cost"] == 0 or (max_tokens is not None and quote["cost"] <= max_tokens):
        return True
    if max_tokens is None and sys.stdin.isatty():
        print("fetch it? [y/N] ", end="", file=sys.stderr, flush=True)  # stderr: stdout may be redirected
        return input().strip().lower() == "y"
    return False

Each offer becomes one dictionary

main.py does the flattening and the writing:

main.py · example

"""A matched board to CSV: one row per offer, back and lay, ready for a spreadsheet or pandas.

Run: ODDSRELAY_KEY=<your key> python3 main.py --board each-way --out each-way.csv
"""
from __future__ import annotations

import argparse
import csv
import sys
from typing import IO, Iterator

from api import afford, get

FILTERS = ("region", "sports", "bookmakers", "exchanges", "markets")
BOARDS = ["standard", "2up", "dutching", "each-way", "extra-place", "bog"]
OFFER = ["price", "available", "places", "place_fraction", "dutch_id", "link"]
FIELDS = ["event_id", "commence_time", "sport_key", "event", "market", "selection", "point", "side", "venue",
          *OFFER]


def rows(events: list[dict]) -> Iterator[dict]:
    """One dict per offer. side is back, lay, or (each-way, extra-place) lay_win and lay_place."""
    for event in events:
        name = (f"{event['home_team']} v {event.get('away_team')}" if event.get("home_team")
                else event.get("event_name") or event.get("venue"))
        for market in event["markets"]:
            for outcome in market["outcomes"]:
                lay = outcome.get("lay") or []  # a list, a {win, place} object, or absent on dutching
                lays = {f"lay_{k}": v for k, v in lay.items()} if isinstance(lay, dict) else {"lay": lay}
                sides = {"back": outcome["back"], **lays}
                for side, offers in sides.items():
                    for offer in offers:
                        yield {"event_id": event["event_id"], "commence_time": event["commence_time"],
                               "sport_key": event["sport_key"], "event": name, "market": market["key"],
                               "selection": outcome["name"], "point": outcome.get("point"), "side": side,
                               "venue": offer.get("bookmaker") or offer.get("exchange"),
                               **{field: offer.get(field) for field in OFFER}}


def write_csv(events: list[dict], out: IO[str]) -> int:
    """Write the board's offers to out as CSV; returns the number of rows."""
    writer = csv.DictWriter(out, fieldnames=FIELDS)
    writer.writeheader()
    count = 0
    for count, row in enumerate(rows(events), 1):
        writer.writerow(row)
    return count


def main() -> None:
    ap = argparse.ArgumentParser(description="Write a matched board to CSV, one row per offer.")
    ap.add_argument("--board", choices=BOARDS, default="standard")
    for name in FILTERS:
        ap.add_argument(f"--{name}", help="one region code" if name == "region" else "comma-separated keys")
    ap.add_argument("--out", default="board.csv", help="the CSV file to write")
    ap.add_argument("--max-tokens", type=int, help="spend up to this many tokens without asking")
    args = ap.parse_args()
    path, params = f"/v2/odds/{args.board}", {name: getattr(args, name) for name in FILTERS}
    if not afford(get(path, {**params, "quote": "true"})[2], args.max_tokens):
        sys.exit("not fetched: narrow the filters, or pass --max-tokens")
    board = get(path, params)[2]
    with open(args.out, "w", newline="", encoding="utf-8") as out:
        print(f"{write_csv(board['data'], out)} rows written to {args.out}")


if __name__ == "__main__":
    main()

rows() yields one dictionary per offer. The event column is a readable name. A race has no teams, so its name comes from the event's own venue field, the course (the file's venue column is the bookmaker or exchange), and commence_time tells that course's races apart. A fixture with no home and away teams uses event_name. A totals outcome carries a point, as do outcomes in a few other over/under markets such as 1st_half_totals. The point column lets you filter Over at 2.5 apart from Over at 3.5. Most other line markets, handicaps among them, put the line in the selection name (Away +0.5 Goals) and leave point empty.

The sides dictionary is where the boards differ. On the standard, Best odds guaranteed and 2Up boards, lay is a plain list, and its rows say lay. On the each-way and extra-place boards it splits into win and place lists, and those rows say lay_win and lay_place. Each back row there adds the bookmaker's places and place_fraction (0.2 for 1/5 odds), and each lay_place row gives the place market's places. The dutching board carries back offers only, so its file holds back rows alone, each with the dutch_id that groups its legs.

csv.DictWriter writes a blank cell for a field that holds None, which is why a back row's available column is empty rather than the word None. An offer with no venue link leaves link empty the same way. Python's csv documentation asks for newline="" when the file is opened. Without it, Windows gets an extra carriage return on every line, and a spreadsheet can show a blank row between each pair of rows.

One outcome becomes four rows

Here is Arsenal to win on the standard board, trimmed to a single event:

One outcome from GET /v2/odds/standard, trimmed · example

{
  "event_id": "or_evt_917dd44bce05",
  "sport_key": "soccer_premier_league",
  "commence_time": "2026-09-20T14:00:00Z",
  "home_team": "Arsenal",
  "away_team": "Chelsea",
  "markets": [{
    "key": "h2h",
    "outcomes": [{
      "name": "Arsenal",
      "back": [
        { "bookmaker": "william_hill", "price": 2.9, "link": null },
        { "bookmaker": "paddy_power", "price": 2.8, "link": null }
      ],
      "lay": [
        { "exchange": "smarkets", "price": 2.96, "available": 6, "link": null },
        { "exchange": "matchbook", "price": 3.0, "available": 175, "link": null }
      ]
    }]
  }]
}

The script keeps the board's order, backs best price first and lays lowest first, so the file comes out like this:

The file, from this sample · example

event_id,commence_time,sport_key,event,market,selection,point,side,venue,price,available,places,place_fraction,dutch_id,link
or_evt_917dd44bce05,2026-09-20T14:00:00Z,soccer_premier_league,Arsenal v Chelsea,h2h,Arsenal,,back,william_hill,2.9,,,,,
or_evt_917dd44bce05,2026-09-20T14:00:00Z,soccer_premier_league,Arsenal v Chelsea,h2h,Arsenal,,back,paddy_power,2.8,,,,,
or_evt_917dd44bce05,2026-09-20T14:00:00Z,soccer_premier_league,Arsenal v Chelsea,h2h,Arsenal,,lay,smarkets,2.96,6,,,,
or_evt_917dd44bce05,2026-09-20T14:00:00Z,soccer_premier_league,Arsenal v Chelsea,h2h,Arsenal,,lay,matchbook,3.0,175,,,,

In the lay rows, the lower price, 2.96, has only 6 waiting, while the 3.0 behind it has 175.

Nothing in the file scores a pair. The reply holds prices and the money at each lay, so any score you put on a back and lay pair, or a lay stake, is your own sum in the sheet or in pandas, at whatever commission rate you set. The post on ratings and the qualifying loss has the formulas, worked on this same outcome.

Opening the file in pandas, Excel or Google Sheets

In pandas, pd.read_csv("football.csv", parse_dates=["commence_time"]) loads the file with the start times as UTC timestamps, since the feed sends them in ISO 8601 with a Z. Excel and Google Sheets may leave that column as text, so convert it in the sheet before you filter by date or do sums on it. In Excel, open the file through Data, From Text/CSV and choose UTF-8, because a double-click can garble accented names.

Filter on side to split backs from lays. Pivot on selection and venue, within one event and market, to put every bookmaker side by side. To set each back row against the lowest lay for its outcome, join on event_id, market, selection and point. Keep the event_id column even if you never show it. A fixture has one id on every route, so that column joins this file to the next one.

Historical files, and four changes to the script

Historical betting odds already come as CSV: OddsRelay's historical odds product has gzip CSV files, one set a day from /v2/historical/files, holding the bookmakers' prices since 17 October 2026, closing lines and price changes included. Exchange prices are not in them. The historical odds guide explains the files.

The script itself writes the board as it stands when you call it. Four changes take it further:

  • Narrow it before you write it. --sports, --bookmakers, --exchanges and --markets go to the API as filters, so the file holds only what you asked for, and on the standard and dutching boards a narrower call is also a cheaper one. Filters and tokens sets out the rules.
  • Skip the write when nothing changed. Add format=bare to the params, because in the default envelope meta.processed_at moves the ETag with every new board, and drop the ["data"] index, since a bare reply is the event list. Send the last ETag back as If-None-Match through get()'s headers argument. An unchanged list comes back as an empty 304 that costs nothing, so skip the write on that status. main() overwrites its file on each run, so a log needs "a" mode and one header row. A changed list comes back whole, so offers that did not move repeat in that log. Polling an odds feed efficiently covers the loop.
  • Stamp the rows when you keep them. A file that grows over a day needs a column for when each row was read. Use the board's own time: meta.processed_at, or the X-Processed-At header on a bare reply. Storing and versioning odds data goes further, into keeping every version of a price.
  • Use a package instead of the helper. The oddsrelay package on PyPI is our Python client, standard library only like api.py. client.odds("standard") returns the reply in result.data, and result.data["data"] is the list rows() flattens.

Write your first file

Run the script on one sport first, open the file, and check the columns suit your sheet before you point it at a whole board. The API reference has the filters for each board. A sheet or file you share with other people, or one a business uses, runs on Enterprise.

Sources, checked 30 October 2026

  1. Python documentation: the csv module (opens in a new tab)

Related posts

18+ · OddsRelay supplies odds data only and takes no bets. Please gamble responsibly.