{"id":2909,"date":"2026-05-20T10:00:00","date_gmt":"2026-05-20T10:00:00","guid":{"rendered":"https:\/\/oddspapi.io\/blog\/?p=2909"},"modified":"2026-10-22T13:16:38","modified_gmt":"2026-10-22T13:16:38","slug":"pandas-odds-api-dataframe-tutorial","status":"publish","type":"post","link":"https:\/\/oddspapi.io\/blog\/pandas-odds-api-dataframe-tutorial\/","title":{"rendered":"Pandas + Odds API: Turn a 59 MB Board into 79,503 Rows"},"content":{"rendered":"<p><strong>One OddsPapi <code>\/v4\/odds<\/code> call on Chelsea v Tottenham returned 59 MB of JSON nested four levels deep: bookmaker, market, outcome, player. This post turns that board into a flat pandas DataFrame of 79,503 rows across 275 bookmakers, then runs five recipes on it: best price per outcome, a stale-feed filter, margin per book with duplicate feeds grouped, price spread per outcome, and an arb scanner with guards.<\/strong><\/p>\n<p>Every output block below comes from one run at 13:13 UTC on 22 October 2026, about 51 hours before kick-off at Stamford Bridge. To follow along, grab an <a href=\"https:\/\/oddspapi.io\/en\/sign-up\">OddsPapi API key<\/a> and pick any soccer fixture ID. The structure is the same across sports. If you have never called the API before, start with <a href=\"https:\/\/oddspapi.io\/blog\/first-odds-api-call-python\/\">your first odds API call in Python<\/a>.<\/p>\n<h2>Why You Want a DataFrame, Not a Dict<\/h2>\n<p>Most odds-API tutorials hand you a Python dict and stop. That works when you check one bookmaker on one market. When you compare prices, compute fair odds or scan for arbs, you write the same nested loops again for every question.<\/p>\n<p>Flatten once into a long table, one row per bookmaker, market and outcome, and each question becomes a <code>groupby<\/code>. The work is getting from the raw payload to that table fast, then leaving the dict alone.<\/p>\n<figure class=\"wp-block-table\">\n<table>\n<thead>\n<tr>\n<th>Question<\/th>\n<th>With nested dicts<\/th>\n<th>With pandas<\/th>\n<\/tr>\n<\/thead>\n<tbody>\n<tr>\n<td>Best price per outcome<\/td>\n<td>Loop over books, compare by hand, track the max<\/td>\n<td><code>df.groupby(\"outcome\")[\"price\"].idxmax()<\/code><\/td>\n<\/tr>\n<tr>\n<td>Margin per bookmaker<\/td>\n<td>Re-shape per book, sum inverse prices by hand<\/td>\n<td><code>pivot_table<\/code> plus one vector sum<\/td>\n<\/tr>\n<tr>\n<td>Which books share one feed<\/td>\n<td>Hash every book&#8217;s prices yourself<\/td>\n<td><code>groupby<\/code> on the price columns<\/td>\n<\/tr>\n<tr>\n<td>Arbs across 275 books<\/td>\n<td>Three nested loops<\/td>\n<td><code>groupby(\"market_id\")<\/code> and a filter<\/td>\n<\/tr>\n<\/tbody>\n<\/table>\n<\/figure>\n<h2>Step 1: The JSON-to-DataFrame Snippet<\/h2>\n<p>Pull the board, flatten it with one comprehension, parse the timestamps. Keep <code>changedAt<\/code>: Step 3 needs it.<\/p>\n<pre class=\"wp-block-code\"><code>import requests, pandas as pd\n\nAPI_KEY = \"YOUR_API_KEY\"\nodds = requests.get(\"https:\/\/api.oddspapi.io\/v4\/odds\", params={\n    \"apiKey\": API_KEY,\n    \"fixtureId\": \"id1000001772221336\",  # Chelsea v Tottenham, 24 Oct\n}).json()\n\ndf = pd.DataFrame([\n    {\"book\": book, \"market_id\": int(mid), \"outcome_id\": int(oid),\n     \"price\": p[\"price\"], \"changed\": p[\"changedAt\"]}\n    for book, b in odds[\"bookmakerOdds\"].items()\n    for mid, m in b[\"markets\"].items()\n    for oid, o in m[\"outcomes\"].items()\n    for p in [o[\"players\"].get(\"0\")] if p and p.get(\"active\") and p[\"price\"] &gt; 0\n])\ndf[\"changed\"] = pd.to_datetime(df[\"changed\"], utc=True)\n<\/code><\/pre>\n<p>Output:<\/p>\n<pre class=\"wp-block-code\"><code>&gt;&gt;&gt; df.shape\n(79503, 5)\n&gt;&gt;&gt; df.book.nunique()\n275\n&gt;&gt;&gt; df.market_id.nunique()\n593\n&gt;&gt;&gt; df.head()\n     book  market_id  outcome_id  price                          changed\n0  198bet      10264       10264  19.00 2026-10-15 17:18:16.133000+00:00\n1  198bet      10172       10173   2.31        2026-10-22 11:16:11+00:00\n2  198bet      10172       10172   1.60        2026-10-22 11:16:11+00:00\n3  198bet      10602       10602   1.83 2026-10-22 11:04:07.687000+00:00\n4  198bet      10602       10603   1.99 2026-10-22 11:04:07.687000+00:00\n<\/code><\/pre>\n<p>That is 79,503 active match-market prices from 275 books on 593 markets, one row each. The download took 1.5 seconds.<\/p>\n<h3>Three filters inside the comprehension<\/h3>\n<ul>\n<li><strong><code>players.get(\"0\")<\/code> keeps match markets and skips player props.<\/strong> The 1X2, totals, handicaps, corners and bookings ship one price under the key <code>\"0\"<\/code>. Props key each price by player ID instead, and on this board they are a large share of the payload: Pitfall 4 builds them into their own DataFrame.<\/li>\n<li><strong><code>active<\/code> drops suspended prices.<\/strong> The raw board held 176,962 price rows, and 24,817 of them were inactive.<\/li>\n<li><strong><code>price &gt; 0<\/code> guards your <code>min()<\/code> and division.<\/strong> No active row on this board had a zero price. The filter costs nothing, so keep it.<\/li>\n<\/ul>\n<h2>Step 2: Add Human-Readable Names<\/h2>\n<p>Market and outcome IDs are fine for maths and hard to read. The <code>\/v4\/markets<\/code> catalogue gives you the lookup tables, plus <code>marketLength<\/code>, the number of outcomes a market should have. The arb scanner in Step 6 uses it.<\/p>\n<pre class=\"wp-block-code\"><code>cat = requests.get(\"https:\/\/api.oddspapi.io\/v4\/markets\",\n    params={\"apiKey\": API_KEY, \"sportId\": 10}).json()\n\nmkt_name = {m[\"marketId\"]: m[\"marketName\"] for m in cat}\nmkt_len = {m[\"marketId\"]: m[\"marketLength\"] for m in cat}\nout_name = {(m[\"marketId\"], o[\"outcomeId\"]): o[\"outcomeName\"]\n            for m in cat for o in m[\"outcomes\"]}\n\ndf[\"market\"] = df.market_id.map(mkt_name)\ndf[\"outcome\"] = [out_name.get(k) for k in zip(df.market_id, df.outcome_id)]\n<\/code><\/pre>\n<p>With <code>sportId=10<\/code> the catalogue returned 1,122 soccer market definitions, and every one of the 593 market IDs on the board found a name. Without <code>sportId<\/code> the same call returns 46,901 definitions across all sports, so pass it.<\/p>\n<h2>Step 3: Best Price Per Outcome, Then Cut the Stale Feeds<\/h2>\n<p>The first question a line shopper asks: which book pays the most on each outcome?<\/p>\n<pre class=\"wp-block-code\"><code>m101 = df[df.market_id == 101]  # Full Time Result (1X2)\nbest = m101.loc[m101.groupby(\"outcome\")[\"price\"].idxmax()]\nprint(best[[\"outcome\", \"book\", \"price\", \"changed\"]])\n<\/code><\/pre>\n<pre class=\"wp-block-code\"><code>      outcome           book  price                          changed\n10313       1  bet365.bet.br   1.76 2026-10-16 20:53:55.273000+00:00\n8622        2        bet3000   5.50 2026-10-22 07:45:41.184000+00:00\n8623        X        bet3000   4.50 2026-10-22 07:45:41.184000+00:00\n<\/code><\/pre>\n<p>Read the <code>changed<\/code> column before you act on that Chelsea price. The 1.76 last moved on 16 October, six days before the pull, and that book had changed no price on the fixture since then. A price can stay <code>active: true<\/code> after the book stops updating it, so give every best-price query two clocks:<\/p>\n<ul>\n<li><strong>Book age:<\/strong> how long since the book&#8217;s newest price on this fixture changed. If the whole feed is quiet, drop the book.<\/li>\n<li><strong>Price lag:<\/strong> how far one price trails that same book&#8217;s newest price. A market the book has stopped trading falls behind the rest of its board.<\/li>\n<\/ul>\n<pre class=\"wp-block-code\"><code>now = pd.Timestamp.now(tz=\"UTC\")\n\ndef drop_stale(d, book_max_age=\"24h\", price_lag_max=\"24h\"):\n    newest = d.groupby(\"book\")[\"changed\"].transform(\"max\")\n    fresh_book = (now - newest) &lt;= pd.Timedelta(book_max_age)\n    fresh_price = (newest - d[\"changed\"]) &lt;= pd.Timedelta(price_lag_max)\n    return d[fresh_book &amp; fresh_price]\n\nlive = drop_stale(df)\nprint(f\"{len(df):,} rows -&gt; {len(live):,} rows, \"\n      f\"{df.book.nunique()} books -&gt; {live.book.nunique()} books\")\n\nm101 = live[live.market_id == 101]\nbest = m101.loc[m101.groupby(\"outcome\")[\"price\"].idxmax()]\nprint(best[[\"outcome\", \"book\", \"price\"]])\n<\/code><\/pre>\n<pre class=\"wp-block-code\"><code>79,503 rows -&gt; 62,040 rows, 275 books -&gt; 270 books\n      outcome     book  price\n44225       1   kalshi  1.754\n8622        2  bet3000  5.500\n8623        X  bet3000  4.500\n<\/code><\/pre>\n<p>Five books fail the book-age test. The rest of the cut comes from the price-lag test. Set both windows from the horizon: two days out, many soft books leave the 1X2 alone for hours while they trade props, so 24 hours is a fair window. On match day, tighten both to an hour. The Chelsea leg now goes to Kalshi at 1.754. This is the core of <a href=\"https:\/\/oddspapi.io\/blog\/line-shopping-python-best-odds\/\">line shopping<\/a>, and pandas does it in three lines.<\/p>\n<h2>Step 4: Margin Per Book, With Shared Feeds Grouped<\/h2>\n<p>The margin (vig) is the bookmaker&#8217;s built-in edge: sum the inverse decimal prices across a market&#8217;s outcomes, and the excess over 1.0 is the margin. Pivot the 1X2 so each book is one row, compute the margin, then group books that quote the exact same three prices.<\/p>\n<pre class=\"wp-block-code\"><code>wide = m101.pivot_table(index=\"book\", columns=\"outcome\", values=\"price\").dropna()\nwide[\"margin_pct\"] = ((1 \/ wide[[\"1\", \"X\", \"2\"]]).sum(axis=1) - 1) * 100\n\nfeeds = (wide.reset_index()\n             .groupby([\"1\", \"X\", \"2\", \"margin_pct\"])[\"book\"]\n             .agg([\"count\", \"first\"])\n             .reset_index()\n             .sort_values(\"margin_pct\"))\nprint(f\"{len(wide)} books quote the 1X2, {len(feeds)} distinct price tuples\")\nprint(feeds.head(8).to_string(index=False))\nprint(\"...\")\nprint(feeds.tail(4).to_string(index=False))\n<\/code><\/pre>\n<pre class=\"wp-block-code\"><code>201 books quote the 1X2, 88 distinct price tuples\n    1     X     2  margin_pct  count      first\n1.750 4.500 4.700    0.641675      5 betfair-ex\n1.754 4.348 4.762    1.011203      1     kalshi\n1.743 4.444 4.706    1.124066      1     sx.bet\n1.746 4.400 4.700    1.277637      1   prophetx\n1.748 4.455 4.575    1.512851      1      1xbet\n1.741 4.440 4.550    1.938798      1   4casters\n1.724 4.348 4.762    2.003300      1 polymarket\n1.739 4.348 4.651    2.004145      1   novig.us\n...\n   1    X    2  margin_pct  count       first\n1.60 4.00 4.25   11.029412      2  pokerstars\n1.63 3.95 4.10   11.056393      1         pmu\n1.62 3.90 4.10   11.759665      1 leovegas.it\n1.61 3.75 3.80   15.094257      1     bwin.fr\n<\/code><\/pre>\n<p>Two findings sit in that table. First, the 201 fresh books quote only 88 distinct 1X2 prices. Five slugs (<code>betfair-ex<\/code>, <code>fair999-ex<\/code>, <code>orbitx<\/code>, <code>piwi247-ex<\/code>, <code>sharpxch<\/code>) carry the Betfair Exchange price to the third decimal. If you average across slugs to build a fair price, those five count five times. Group on the price tuple first, the way the <a href=\"https:\/\/oddspapi.io\/blog\/consensus-odds-fair-odds-calculator-python\/\">consensus odds calculator<\/a> does.<\/p>\n<p>Second, the margin ladder runs from 0.64% to 15.09% on one match. The exchanges and prediction markets (Betfair, Kalshi, SX Bet, ProphetX, Polymarket, Novig) sit at the top because they take their cut as commission or trading fees, which the price does not show. For a sportsbook-only ranking, filter them out by slug. Pinnacle quoted the same market at 3.67%.<\/p>\n<h2>Step 5: Price Spread Per Outcome<\/h2>\n<p>Where do the books disagree most? Group on market ID and outcome, never on the market name: every handicap and total line shares a name, so a name group mixes a -1 line with a +2 line. Use the 10th and 90th percentiles rather than min and max. One wild row moves the max, and it cannot move the 90th percentile of 150 quotes.<\/p>\n<pre class=\"wp-block-code\"><code>key = [\"market_id\", \"market\", \"outcome\"]\nspread = live.groupby(key)[\"price\"].quantile([0.1, 0.9]).unstack()\nspread.columns = [\"p10\", \"p90\"]\nspread[\"count\"] = live.groupby(key).size()\nspread[\"spread_pct\"] = (spread[\"p90\"] \/ spread[\"p10\"] - 1) * 100\n\ndeep = spread[spread[\"count\"] &gt;= 150].reset_index()\ndeep = deep[~deep.market.str.contains(\"Score\")]  # long-shot scorelines swamp the list\nprint(deep.sort_values(\"spread_pct\", ascending=False).head(8).to_string(index=False))\n<\/code><\/pre>\n<pre class=\"wp-block-code\"><code> market_id                          market outcome   p10    p90  count  spread_pct\n    101919           Half Time \/ Full Time     1\/2 29.00 43.190    161   48.931034\n    101919           Half Time \/ Full Time     2\/1 16.00 22.000    175   37.500000\n    101919           Half Time \/ Full Time     2\/2  6.09  7.705    160   26.518883\n     10137               European Handicap       2  8.50 10.320    174   21.411765\n    102222    Over Under Team 2 First Half    Over  6.70  8.000    152   19.402985\n     10930 Bookings - Over Under Full Time   Under  2.10  2.500    155   19.047619\n      1016            Over Under Full Time    Over  6.40  7.500    196   17.187500\n     10137               European Handicap       X  5.75  6.725    166   16.956522\n<\/code><\/pre>\n<p>Correct-score rows top the unfiltered list because a 0:4 scoreline is a long shot that every book prices its own way. With them gone, Half Time \/ Full Time leads: 161 books quote Chelsea-at-half, Tottenham-at-full-time, and the 90th percentile pays 49% more than the 10th. For comparison, the Tottenham win in the 1X2 spread 11.0% across 239 fresh quotes. Wide spreads on deep markets are where a <a href=\"https:\/\/oddspapi.io\/blog\/value-betting-scanner-python\/\">value betting scanner<\/a> earns its keep.<\/p>\n<h2>Step 6: Arb Scanner With Guards<\/h2>\n<p>An arb exists when the inverse sum of the best prices across every outcome is under 1.0. The scanner below checks that each market carries all its outcomes (<code>marketLength<\/code> from Step 2) and that the best legs come from at least two books. A single book never pays you to back both sides, so a one-book &#8220;arb&#8221; means two of its prices are out of step.<\/p>\n<pre class=\"wp-block-code\"><code>def guard(d, band=1.2, min_books=5):\n    key = [\"market_id\", \"outcome_id\"]\n    median = d.groupby(key)[\"price\"].transform(\"median\")\n    books = d.groupby(key)[\"book\"].transform(\"nunique\")\n    return d[(books &gt;= min_books) &amp; (d[\"price\"] &lt;= median * band)]\n\ndef scan(d):\n    hits = []\n    for mid, sub in d.groupby(\"market_id\"):\n        if sub.outcome_id.nunique() != mkt_len.get(mid):\n            continue  # a leg is missing, the sum means nothing\n        legs = sub.loc[sub.groupby(\"outcome_id\")[\"price\"].idxmax()]\n        if legs.book.nunique() &lt; 2:\n            continue  # one book never pays you to bet both sides\n        invsum = (1 \/ legs[\"price\"]).sum()\n        if invsum &lt; 1:\n            hits.append({\"market\": mkt_name[mid], \"margin_pct\": round((1 - invsum) * 100, 2),\n                         \"legs\": \" \/ \".join(f\"{b} {p}\" for b, p in zip(legs.book, legs.price))})\n    return pd.DataFrame(hits).sort_values(\"margin_pct\", ascending=False)\n\nraw = scan(df)\nsafe = scan(guard(live))\nsafe = safe[safe.margin_pct &lt; 5]\nprint(f\"raw scan: {len(raw)} hits, guarded scan: {len(safe)} hits\")\nprint(safe.head(6).to_string(index=False))\n<\/code><\/pre>\n<pre class=\"wp-block-code\"><code>raw scan: 47 hits, guarded scan: 15 hits\n                        market  margin_pct                                              legs\n   Corners - Over Under Team 2        1.88                       pokerstars 2.1 \/ 20bet 1.98\n   Corners - Over Under Team 2        1.64                          atg.se 1.53 \/ 20bet 3.03\nCorners - Over Under Full Time        1.00                    fanduel 2.0 \/ polymarket 2.041\nCorners - Over Under Full Time        0.82                   fanduel 2.62 \/ polymarket 1.639\n             First Half Result        0.78 polymarket 2.326 \/ polymarket 2.703 \/ bet3000 5.2\n          Over Under Full Time        0.59                    polymarket 2.439 \/ 1xbet 1.712\n<\/code><\/pre>\n<h3>What each guard removes<\/h3>\n<ul>\n<li><strong><code>drop_stale<\/code> (Step 3)<\/strong> removes legs whose book stopped trading the market. On this board, several of the widest raw hits had a leg that trailed its own book&#8217;s newest price by 40 to 66 hours.<\/li>\n<li><strong>The median band<\/strong> drops any price more than 20% above the median for that outcome. A fresh price far off the market usually means the book mapped an outcome to the wrong ID, and the band catches it without a per-book whitelist.<\/li>\n<li><strong>The five-book floor<\/strong> keeps thin markets out. Two books on a corners line give you no median to check against.<\/li>\n<li><strong>The 5% ceiling<\/strong> is a last net. On this board every guarded hit sat under 2%. Treat a bigger number as a data question first.<\/li>\n<\/ul>\n<p>Treat the survivors as candidates. Re-poll the fixture before you stake, check each leg&#8217;s <code>changed<\/code> stamp again, and confirm that the book takes your stake size. The <a href=\"https:\/\/oddspapi.io\/blog\/arbitrage-betting-bot-python\/\">arbitrage betting bot tutorial<\/a> adds stake splitting and alerting on top of this scan.<\/p>\n<h2>From One Fixture to a Full Round<\/h2>\n<p>To run the same recipes over a round, pull the fixture list, then build one frame per fixture. Filter <code>\/v4\/fixtures<\/code> on <code>tournamentId<\/code>: a bare <code>sportId=10<\/code> window returns every soccer fixture in the world, and each <code>\/odds<\/code> board on a top tournament runs to tens of megabytes.<\/p>\n<pre class=\"wp-block-code\"><code>from datetime import datetime, timedelta, timezone\nimport time\n\nnow = datetime.now(timezone.utc)\nend = now + timedelta(days=4)\n\nfixtures = requests.get(\"https:\/\/api.oddspapi.io\/v4\/fixtures\", params={\n    \"apiKey\": API_KEY,\n    \"sportId\": 10,\n    \"tournamentId\": 17,  # English Premier League\n    \"from\": now.strftime(\"%Y-%m-%dT%H:%M:%S\"),\n    \"to\": end.strftime(\"%Y-%m-%dT%H:%M:%S\"),\n}).json()\n\nframes = []\nfor f in fixtures:\n    if not f.get(\"hasOdds\"):\n        continue\n    r = requests.get(\"https:\/\/api.oddspapi.io\/v4\/odds\", params={\n        \"apiKey\": API_KEY, \"fixtureId\": f[\"fixtureId\"]\n    })\n    if r.status_code != 200:\n        print(f[\"fixtureId\"], r.status_code, r.text[:200])\n        time.sleep(1.0)\n        continue\n    o = r.json()\n    rows = [\n        {\n            \"fixture_id\": f[\"fixtureId\"],\n            \"match\": f\"{f['participant1Name']} v {f['participant2Name']}\",\n            \"book\": book, \"market_id\": int(mid),\n            \"outcome_id\": int(oid), \"price\": p[\"price\"],\n        }\n        for book, b in o.get(\"bookmakerOdds\", {}).items()\n        for mid, m in b[\"markets\"].items()\n        for oid, oc in m[\"outcomes\"].items()\n        for p in [oc[\"players\"].get(\"0\")]\n        if p and p.get(\"active\") and p[\"price\"] &gt; 0\n    ]\n    frames.append(pd.DataFrame(rows))\n    time.sleep(1.0)  # one call per second to the same endpoint\n\nslate = pd.concat(frames, ignore_index=True)\nprint(f\"Slate: {len(slate):,} rows across {slate.fixture_id.nunique()} fixtures\")\nprint(f\"{slate.memory_usage(deep=True).sum() \/ 1e6:.0f} MB in memory\")\nprint(slate.groupby(\"match\").book.nunique().sort_values())\n<\/code><\/pre>\n<pre class=\"wp-block-code\"><code>Slate: 766,644 rows across 10 fixtures\n174 MB in memory\nmatch\nHull City v Brentford FC                 272\nLiverpool FC v Brighton &amp; Hove Albion    273\nArsenal FC v Everton FC                  274\nAston Villa v Manchester City            274\nCrystal Palace v Newcastle United        275\nCoventry City v Fulham FC                275\nSunderland AFC v Leeds United            275\nChelsea FC v Tottenham Hotspur           276\nManchester United v AFC Bournemouth      276\nIpswich Town v Nottingham Forest         277\n<\/code><\/pre>\n<p>The full Premier League round took 30 seconds and fits in 174 MB of RAM, with 272 to 277 books on every fixture. Keep the one-second sleep between <code>\/odds<\/code> calls, and never fire them in parallel: concurrent calls to the same endpoint come back 429 (rate limited). Check <code>status_code<\/code> before you read a body, because a 429 body is valid JSON too.<\/p>\n<h2>Why OddsPapi Pairs Well With Pandas<\/h2>\n<figure class=\"wp-block-table\">\n<table>\n<thead>\n<tr>\n<th>Capability<\/th>\n<th>What it means for a pandas workflow<\/th>\n<\/tr>\n<\/thead>\n<tbody>\n<tr>\n<td><strong>350+ bookmakers in one response<\/strong><\/td>\n<td>No feed merging. One <code>\/v4\/odds<\/code> call put 275 books on this fixture into one nested dict, ready to flatten.<\/td>\n<\/tr>\n<tr>\n<td><strong>Free historical data<\/strong><\/td>\n<td>The same flatten works on <code>\/v4\/historical-odds<\/code>, with a list of snapshots per outcome in place of one price. Backtest without buying snapshots.<\/td>\n<\/tr>\n<tr>\n<td><strong>One ID scheme across books<\/strong><\/td>\n<td>Every book&#8217;s Full Time Result is market 101, so a groupby across 275 books needs no name matching.<\/td>\n<\/tr>\n<tr>\n<td><strong>Exchanges and prediction markets in the same payload<\/strong><\/td>\n<td>Betfair Exchange, Kalshi, Polymarket, ProphetX, Novig and SX Bet flatten into the same DataFrame as the sportsbooks. Exchange depth lives under <code>exchangeMeta<\/code>.<\/td>\n<\/tr>\n<tr>\n<td><strong>Native player props<\/strong><\/td>\n<td>Props ship on the same endpoint, keyed by player ID. Pitfall 4 flattens them.<\/td>\n<\/tr>\n<\/tbody>\n<\/table>\n<\/figure>\n<h2>Common Pitfalls<\/h2>\n<h3>1. Do not loop with <code>iterrows()<\/code><\/h3>\n<p>Row-by-row Python is the slow path in pandas. Before you write <code>for row in df.iterrows()<\/code>, reach for <code>groupby<\/code>, <code>transform<\/code>, <code>pivot_table<\/code> or <code>merge<\/code>.<\/p>\n<h3>2. Do not trust the widest arb<\/h3>\n<p>The raw scan in Step 6 flagged 47 markets and the guarded scan kept 15. The guards removed the widest raw hits first, so run them on every scan.<\/p>\n<h3>3. Parse timestamps as UTC<\/h3>\n<p>Both <code>changedAt<\/code> on <code>\/odds<\/code> and <code>createdAt<\/code> on <code>\/historical-odds<\/code> are ISO strings in UTC. Parse with <code>pd.to_datetime(..., utc=True)<\/code> and compare against <code>pd.Timestamp.now(tz=\"UTC\")<\/code>, never a naive local time.<\/p>\n<h3>4. Player props need their own DataFrame<\/h3>\n<p>The Step 1 snippet keeps only <code>players[\"0\"]<\/code>. Props key each price by player ID and carry a <code>playerName<\/code>. Build a parallel frame:<\/p>\n<pre class=\"wp-block-code\"><code>props = pd.DataFrame([\n    {\"book\": book, \"market_id\": int(mid), \"outcome_id\": int(oid),\n     \"player_id\": pid, \"player_name\": p.get(\"playerName\"), \"price\": p[\"price\"]}\n    for book, b in odds[\"bookmakerOdds\"].items()\n    for mid, m in b[\"markets\"].items()\n    for oid, o in m[\"outcomes\"].items()\n    for pid, p in o[\"players\"].items()\n    if pid != \"0\" and p.get(\"active\") and p[\"price\"] &gt; 0\n])\n<\/code><\/pre>\n<p>On the same Chelsea v Tottenham board that frame held 72,635 rows: 178 books pricing 73 players across 52 prop markets. Names come as &#8220;Last, First&#8221;, for example &#8220;Colwill, Levi&#8221;. The <a href=\"https:\/\/oddspapi.io\/blog\/player-props-api-nfl-nba-mlb-odds-python\/\">player props API tutorial<\/a> goes deeper.<\/p>\n<h3>5. Historical odds need a list-aware flattener<\/h3>\n<p><code>\/v4\/historical-odds<\/code> takes up to three bookmakers per call, names its top-level key <code>bookmakers<\/code> (not <code>bookmakerOdds<\/code>), and ships <code>players[\"0\"]<\/code> as a list of snapshots. Use <code>.get(\"0\", [])<\/code>: prop outcomes have no <code>\"0\"<\/code> key, and a hard <code>[\"0\"]<\/code> raises <code>KeyError<\/code> on the first one.<\/p>\n<pre class=\"wp-block-code\"><code>hist = requests.get(\"https:\/\/api.oddspapi.io\/v4\/historical-odds\", params={\n    \"apiKey\": API_KEY,\n    \"fixtureId\": \"id1000001772221318\",  # Everton v Chelsea, played 17 Oct\n    \"bookmakers\": \"pinnacle,bet365,singbet\",  # max 3 per call\n}).json()\n\nhist_df = pd.DataFrame([\n    {\"book\": book, \"market_id\": int(mid), \"outcome_id\": int(oid),\n     \"price\": snap[\"price\"], \"ts\": snap[\"createdAt\"]}\n    for book, b in hist[\"bookmakers\"].items()\n    for mid, m in b[\"markets\"].items()\n    for oid, o in m[\"outcomes\"].items()\n    for snap in o[\"players\"].get(\"0\", [])  # props are keyed by player ID\n    if snap.get(\"active\") and snap[\"price\"] &gt; 0\n])\nhist_df[\"ts\"] = pd.to_datetime(hist_df[\"ts\"], utc=True)\n\n# Pinnacle's 1X2 close: the last active snapshot before kick-off\nko = pd.Timestamp(\"2026-10-17T11:30:00Z\")\nclose = (hist_df[(hist_df.book == \"pinnacle\") &amp; (hist_df.market_id == 101) &amp; (hist_df.ts &lt; ko)]\n         .sort_values(\"ts\").groupby(\"outcome_id\").last())\nprint(close)\n<\/code><\/pre>\n<pre class=\"wp-block-code\"><code>                book  market_id  price                               ts\noutcome_id\n101         pinnacle        101   3.00 2026-10-17 11:29:16.863000+00:00\n102         pinnacle        101   3.67 2026-10-17 11:29:16.863000+00:00\n103         pinnacle        101   2.37 2026-10-17 11:29:16.863000+00:00\n<\/code><\/pre>\n<p>That call returned 110,358 snapshot rows for the three books, back to 19 September. Pinnacle alone logged 1,220 snapshots on the 1X2, with 356 to 396 actual price changes per outcome. Count changes, not snapshots. For CSV export and a backtest loop, see <a href=\"https:\/\/oddspapi.io\/blog\/historical-odds-csv-excel-backtesting\/\">Historical Odds CSV Export<\/a>.<\/p>\n<h2>What to Build With This DataFrame<\/h2>\n<ul>\n<li><strong><a href=\"https:\/\/oddspapi.io\/blog\/value-betting-scanner-python\/\">Value betting scanner<\/a>:<\/strong> compare each book&#8217;s price against a fair-odds benchmark and flag positive-EV prices.<\/li>\n<li><strong><a href=\"https:\/\/oddspapi.io\/blog\/consensus-odds-fair-odds-calculator-python\/\">Consensus odds calculator<\/a>:<\/strong> group duplicate feeds as in Step 4, then de-vig the consensus into a fair probability per outcome.<\/li>\n<li><strong><a href=\"https:\/\/oddspapi.io\/blog\/kelly-criterion-staking-calculator-python\/\">Kelly criterion stake sizing<\/a>:<\/strong> size the stake on each +EV price your scanner finds.<\/li>\n<li><strong><a href=\"https:\/\/oddspapi.io\/blog\/odds-comparison-dashboard-python-streamlit\/\">Live odds dashboard<\/a>:<\/strong> the same DataFrame in Streamlit with auto-refresh.<\/li>\n<li><strong><a href=\"https:\/\/oddspapi.io\/blog\/steam-move-detector-python\/\">Steam move detector<\/a>:<\/strong> use <code>changedAt<\/code> and the price history to catch sharp moves. For sub-second updates, swap polling for the WebSocket feed.<\/li>\n<\/ul>\n<h2>FAQ<\/h2>\n<h3>Why use pandas instead of dicts and loops?<\/h3>\n<p>A long-format DataFrame turns each question (best price, margin, spread, arbs) into a <code>groupby<\/code> of a line or two. The dict version needs its own nested loops for every question.<\/p>\n<h3>How big does the odds DataFrame get?<\/h3>\n<p>One Premier League fixture flattened to about 79,500 match-market rows. A ten-fixture Premier League round came to 766,644 rows and 174 MB in memory, which a laptop handles. For many rounds, write each one to Parquet and load only what you need.<\/p>\n<h3>Why is <code>players[\"0\"]<\/code> the convention?<\/h3>\n<p>Match markets (1X2, totals, handicaps, corners) are about the fixture, not one player, so OddsPapi files their price under the key <code>\"0\"<\/code>. Player props key each price by the player&#8217;s ID. Filter on <code>\"0\"<\/code> to split match markets from props.<\/p>\n<h3>How do I keep stale prices out of the DataFrame?<\/h3>\n<p>Keep <code>changedAt<\/code> as a column. Drop a book when its newest price on the fixture is older than your window, and drop a price when it trails that book&#8217;s newest price by more than the same window. Two days out, 24 hours works; on match day, use an hour.<\/p>\n<h3>Can I store the DataFrame between runs?<\/h3>\n<p>Yes. Parquet keeps dtypes and loads faster than CSV: <code>df.to_parquet(\"odds.parquet\")<\/code>. For long-running capture, write one file per fixture and poll: <code>df.to_parquet(f\"odds\/{fixture_id}\/{ts}.parquet\")<\/code>.<\/p>\n<h3>Does this work with Polars?<\/h3>\n<p>Yes. The flatten is plain Python, so pass the same list of dicts to <code>pl.DataFrame(...)<\/code>. The groupby and aggregation calls change name but keep the same shape.<\/p>\n<h2>Get Your API Key<\/h2>\n<p>Every recipe above ran on one call per fixture: 350+ bookmakers, exchanges and prediction markets in one payload, with free historical odds on the same key. <a href=\"https:\/\/oddspapi.io\/en\/sign-up\">Get your OddsPapi API key<\/a> and flatten your first board.<\/p>\n<p><script type=\"application\/ld+json\">\n{\n  \"@context\": \"https:\/\/schema.org\",\n  \"@type\": \"FAQPage\",\n  \"mainEntity\": [\n    {\n      \"@type\": \"Question\",\n      \"name\": \"Why use pandas instead of dicts and loops for odds data?\",\n      \"acceptedAnswer\": {\n        \"@type\": \"Answer\",\n        \"text\": \"A long-format DataFrame turns each question (best price, margin, spread, arbs) into a groupby of a line or two. The dict version needs its own nested loops for every question.\"\n      }\n    },\n    {\n      \"@type\": \"Question\",\n      \"name\": \"How big does an odds DataFrame get?\",\n      \"acceptedAnswer\": {\n        \"@type\": \"Answer\",\n        \"text\": \"One Premier League fixture flattened to about 79,500 match-market rows. A ten-fixture Premier League round came to 766,644 rows and 174 MB in memory, which a laptop handles. For many rounds, write each one to Parquet and load only what you need.\"\n      }\n    },\n    {\n      \"@type\": \"Question\",\n      \"name\": \"Why is players[\\\"0\\\"] the convention in OddsPapi responses?\",\n      \"acceptedAnswer\": {\n        \"@type\": \"Answer\",\n        \"text\": \"Match markets (1X2, totals, handicaps, corners) are about the fixture, not one player, so OddsPapi files their price under the key \\\"0\\\". Player props key each price by the player's ID. Filter on \\\"0\\\" to split match markets from props.\"\n      }\n    },\n    {\n      \"@type\": \"Question\",\n      \"name\": \"How do I keep stale prices out of an odds DataFrame?\",\n      \"acceptedAnswer\": {\n        \"@type\": \"Answer\",\n        \"text\": \"Keep changedAt as a column. Drop a book when its newest price on the fixture is older than your window, and drop a price when it trails that book's newest price by more than the same window. Two days out, 24 hours works; on match day, use an hour.\"\n      }\n    },\n    {\n      \"@type\": \"Question\",\n      \"name\": \"Can I store the odds DataFrame between runs?\",\n      \"acceptedAnswer\": {\n        \"@type\": \"Answer\",\n        \"text\": \"Yes. Parquet keeps dtypes and loads faster than CSV: df.to_parquet('odds.parquet'). For long-running capture, write one file per fixture and poll: df.to_parquet(f'odds\/{fixture_id}\/{ts}.parquet').\"\n      }\n    },\n    {\n      \"@type\": \"Question\",\n      \"name\": \"Does the pandas odds workflow work with Polars?\",\n      \"acceptedAnswer\": {\n        \"@type\": \"Answer\",\n        \"text\": \"Yes. The flatten is plain Python, so pass the same list of dicts to pl.DataFrame(...). The groupby and aggregation calls change name but keep the same shape.\"\n      }\n    }\n  ]\n}\n<\/script><\/p>\n<p><!--\nFocus Keyphrase: pandas odds api\nSEO Title: Pandas + Odds API: Turn a 59 MB Board into 79,503 Rows\nMeta Description: Pandas groups Chelsea v Tottenham's 275-book board into 88 distinct 1X2 prices, then ranks margins from 0.64% at Betfair to 15.09% at bwin.fr.\nSlug: pandas-odds-api-dataframe-tutorial\nRefreshed: 2026-10-22 (board pulled 13:13 UTC)\n--><\/p>\n","protected":false},"excerpt":{"rendered":"<p>Pandas groups Chelsea v Tottenham&#8217;s 275-book board into 88 distinct 1X2 prices, then ranks margins from 0.64% at Betfair to 15.09% at bwin.fr.<\/p>\n","protected":false},"author":2,"featured_media":2911,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[7],"tags":[8,9,62,11,10],"class_list":["post-2909","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-how-to-guides","tag-free-api","tag-odds-api","tag-pandas","tag-python","tag-sports-betting-api"],"yoast_head":"<!-- This site is optimized with the Yoast SEO plugin v26.4 - https:\/\/yoast.com\/wordpress\/plugins\/seo\/ -->\n<title>Pandas + Odds API: Turn a 59 MB Board into 79,503 Rows | OddsPapi Blog<\/title>\n<meta name=\"robots\" content=\"index, follow, max-snippet:-1, max-image-preview:large, max-video-preview:-1\" \/>\n<link rel=\"canonical\" href=\"https:\/\/oddspapi.io\/blog\/pandas-odds-api-dataframe-tutorial\/\" \/>\n<meta property=\"og:locale\" content=\"en_US\" \/>\n<meta property=\"og:type\" content=\"article\" \/>\n<meta property=\"og:title\" content=\"Pandas + Odds API: Turn a 59 MB Board into 79,503 Rows | OddsPapi Blog\" \/>\n<meta property=\"og:description\" content=\"Pandas groups Chelsea v Tottenham&#039;s 275-book board into 88 distinct 1X2 prices, then ranks margins from 0.64% at Betfair to 15.09% at bwin.fr.\" \/>\n<meta property=\"og:url\" content=\"https:\/\/oddspapi.io\/blog\/pandas-odds-api-dataframe-tutorial\/\" \/>\n<meta property=\"og:site_name\" content=\"OddsPapi Blog\" \/>\n<meta property=\"article:published_time\" content=\"2026-05-20T10:00:00+00:00\" \/>\n<meta property=\"article:modified_time\" content=\"2026-10-22T13:16:38+00:00\" \/>\n<meta property=\"og:image\" content=\"https:\/\/oddspapi.io\/blog\/wp-content\/uploads\/2026\/05\/pandas-odds-api-dataframe-tutorial-scaled.webp\" \/>\n\t<meta property=\"og:image:width\" content=\"2560\" \/>\n\t<meta property=\"og:image:height\" content=\"1429\" \/>\n\t<meta property=\"og:image:type\" content=\"image\/webp\" \/>\n<meta name=\"author\" content=\"Odds API Writer\" \/>\n<meta name=\"twitter:card\" content=\"summary_large_image\" \/>\n<meta name=\"twitter:image\" content=\"https:\/\/oddspapi.io\/logo-v2.webp\" \/>\n<meta name=\"twitter:creator\" content=\"@oddspapiapi\" \/>\n<meta name=\"twitter:site\" content=\"@oddspapiapi\" \/>\n<meta name=\"twitter:label1\" content=\"Written by\" \/>\n\t<meta name=\"twitter:data1\" content=\"Odds API Writer\" \/>\n\t<meta name=\"twitter:label2\" content=\"Est. reading time\" \/>\n\t<meta name=\"twitter:data2\" content=\"17 minutes\" \/>\n<script type=\"application\/ld+json\" class=\"yoast-schema-graph\">{\"@context\":\"https:\/\/schema.org\",\"@graph\":[{\"@type\":\"Article\",\"@id\":\"https:\/\/oddspapi.io\/blog\/pandas-odds-api-dataframe-tutorial\/#article\",\"isPartOf\":{\"@id\":\"https:\/\/oddspapi.io\/blog\/pandas-odds-api-dataframe-tutorial\/\"},\"author\":{\"name\":\"Odds API Writer\",\"@id\":\"https:\/\/oddspapi.io\/blog\/#\/schema\/person\/b6f21e649c4f556f0a95c23a0f1efa13\"},\"headline\":\"Pandas + Odds API: Turn a 59 MB Board into 79,503 Rows\",\"datePublished\":\"2026-05-20T10:00:00+00:00\",\"dateModified\":\"2026-10-22T13:16:38+00:00\",\"mainEntityOfPage\":{\"@id\":\"https:\/\/oddspapi.io\/blog\/pandas-odds-api-dataframe-tutorial\/\"},\"wordCount\":2003,\"commentCount\":0,\"publisher\":{\"@id\":\"https:\/\/oddspapi.io\/blog\/#organization\"},\"image\":{\"@id\":\"https:\/\/oddspapi.io\/blog\/pandas-odds-api-dataframe-tutorial\/#primaryimage\"},\"thumbnailUrl\":\"https:\/\/oddspapi.io\/blog\/wp-content\/uploads\/2026\/05\/pandas-odds-api-dataframe-tutorial-scaled.webp\",\"keywords\":[\"Free API\",\"Odds API\",\"Pandas\",\"Python\",\"Sports Betting API\"],\"articleSection\":[\"How To Guides\"],\"inLanguage\":\"en-US\",\"potentialAction\":[{\"@type\":\"CommentAction\",\"name\":\"Comment\",\"target\":[\"https:\/\/oddspapi.io\/blog\/pandas-odds-api-dataframe-tutorial\/#respond\"]}]},{\"@type\":\"WebPage\",\"@id\":\"https:\/\/oddspapi.io\/blog\/pandas-odds-api-dataframe-tutorial\/\",\"url\":\"https:\/\/oddspapi.io\/blog\/pandas-odds-api-dataframe-tutorial\/\",\"name\":\"Pandas + Odds API: Turn a 59 MB Board into 79,503 Rows | OddsPapi Blog\",\"isPartOf\":{\"@id\":\"https:\/\/oddspapi.io\/blog\/#website\"},\"primaryImageOfPage\":{\"@id\":\"https:\/\/oddspapi.io\/blog\/pandas-odds-api-dataframe-tutorial\/#primaryimage\"},\"image\":{\"@id\":\"https:\/\/oddspapi.io\/blog\/pandas-odds-api-dataframe-tutorial\/#primaryimage\"},\"thumbnailUrl\":\"https:\/\/oddspapi.io\/blog\/wp-content\/uploads\/2026\/05\/pandas-odds-api-dataframe-tutorial-scaled.webp\",\"datePublished\":\"2026-05-20T10:00:00+00:00\",\"dateModified\":\"2026-10-22T13:16:38+00:00\",\"breadcrumb\":{\"@id\":\"https:\/\/oddspapi.io\/blog\/pandas-odds-api-dataframe-tutorial\/#breadcrumb\"},\"inLanguage\":\"en-US\",\"potentialAction\":[{\"@type\":\"ReadAction\",\"target\":[\"https:\/\/oddspapi.io\/blog\/pandas-odds-api-dataframe-tutorial\/\"]}]},{\"@type\":\"ImageObject\",\"inLanguage\":\"en-US\",\"@id\":\"https:\/\/oddspapi.io\/blog\/pandas-odds-api-dataframe-tutorial\/#primaryimage\",\"url\":\"https:\/\/oddspapi.io\/blog\/wp-content\/uploads\/2026\/05\/pandas-odds-api-dataframe-tutorial-scaled.webp\",\"contentUrl\":\"https:\/\/oddspapi.io\/blog\/wp-content\/uploads\/2026\/05\/pandas-odds-api-dataframe-tutorial-scaled.webp\",\"width\":2560,\"height\":1429,\"caption\":\"Pandas + Odds API - OddsPapi API Blog\"},{\"@type\":\"BreadcrumbList\",\"@id\":\"https:\/\/oddspapi.io\/blog\/pandas-odds-api-dataframe-tutorial\/#breadcrumb\",\"itemListElement\":[{\"@type\":\"ListItem\",\"position\":1,\"name\":\"Home\",\"item\":\"https:\/\/oddspapi.io\/blog\/\"},{\"@type\":\"ListItem\",\"position\":2,\"name\":\"Pandas + Odds API: Turn a 59 MB Board into 79,503 Rows\"}]},{\"@type\":\"WebSite\",\"@id\":\"https:\/\/oddspapi.io\/blog\/#website\",\"url\":\"https:\/\/oddspapi.io\/blog\/\",\"name\":\"OddsPapi\",\"description\":\"Sports Odds API Tutorials &amp; Guides\",\"publisher\":{\"@id\":\"https:\/\/oddspapi.io\/blog\/#organization\"},\"alternateName\":\"Odds Papi\",\"potentialAction\":[{\"@type\":\"SearchAction\",\"target\":{\"@type\":\"EntryPoint\",\"urlTemplate\":\"https:\/\/oddspapi.io\/blog\/?s={search_term_string}\"},\"query-input\":{\"@type\":\"PropertyValueSpecification\",\"valueRequired\":true,\"valueName\":\"search_term_string\"}}],\"inLanguage\":\"en-US\"},{\"@type\":\"Organization\",\"@id\":\"https:\/\/oddspapi.io\/blog\/#organization\",\"name\":\"OddsPapi\",\"url\":\"https:\/\/oddspapi.io\/blog\/\",\"logo\":{\"@type\":\"ImageObject\",\"inLanguage\":\"en-US\",\"@id\":\"https:\/\/oddspapi.io\/blog\/#\/schema\/logo\/image\/\",\"url\":\"https:\/\/oddspapi.io\/blog\/wp-content\/uploads\/2025\/11\/oddspapi.png\",\"contentUrl\":\"https:\/\/oddspapi.io\/blog\/wp-content\/uploads\/2025\/11\/oddspapi.png\",\"width\":135,\"height\":135,\"caption\":\"OddsPapi\"},\"image\":{\"@id\":\"https:\/\/oddspapi.io\/blog\/#\/schema\/logo\/image\/\"},\"sameAs\":[\"https:\/\/x.com\/oddspapiapi\"]},{\"@type\":\"Person\",\"@id\":\"https:\/\/oddspapi.io\/blog\/#\/schema\/person\/b6f21e649c4f556f0a95c23a0f1efa13\",\"name\":\"Odds API Writer\",\"image\":{\"@type\":\"ImageObject\",\"inLanguage\":\"en-US\",\"@id\":\"https:\/\/oddspapi.io\/blog\/#\/schema\/person\/image\/\",\"url\":\"https:\/\/secure.gravatar.com\/avatar\/33b204f24af3d02e35b25ae730c0536121ca6a783fdb196e7611c9e49fcd13eb?s=96&d=mm&r=g\",\"contentUrl\":\"https:\/\/secure.gravatar.com\/avatar\/33b204f24af3d02e35b25ae730c0536121ca6a783fdb196e7611c9e49fcd13eb?s=96&d=mm&r=g\",\"caption\":\"Odds API Writer\"},\"url\":\"https:\/\/oddspapi.io\/blog\/author\/andy-lavelle\/\"}]}<\/script>\n<!-- \/ Yoast SEO plugin. -->","yoast_head_json":{"title":"Pandas + Odds API: Turn a 59 MB Board into 79,503 Rows | OddsPapi Blog","robots":{"index":"index","follow":"follow","max-snippet":"max-snippet:-1","max-image-preview":"max-image-preview:large","max-video-preview":"max-video-preview:-1"},"canonical":"https:\/\/oddspapi.io\/blog\/pandas-odds-api-dataframe-tutorial\/","og_locale":"en_US","og_type":"article","og_title":"Pandas + Odds API: Turn a 59 MB Board into 79,503 Rows | OddsPapi Blog","og_description":"Pandas groups Chelsea v Tottenham's 275-book board into 88 distinct 1X2 prices, then ranks margins from 0.64% at Betfair to 15.09% at bwin.fr.","og_url":"https:\/\/oddspapi.io\/blog\/pandas-odds-api-dataframe-tutorial\/","og_site_name":"OddsPapi Blog","article_published_time":"2026-05-20T10:00:00+00:00","article_modified_time":"2026-10-22T13:16:38+00:00","og_image":[{"width":2560,"height":1429,"url":"https:\/\/oddspapi.io\/blog\/wp-content\/uploads\/2026\/05\/pandas-odds-api-dataframe-tutorial-scaled.webp","type":"image\/webp"}],"author":"Odds API Writer","twitter_card":"summary_large_image","twitter_image":"https:\/\/oddspapi.io\/logo-v2.webp","twitter_creator":"@oddspapiapi","twitter_site":"@oddspapiapi","twitter_misc":{"Written by":"Odds API Writer","Est. reading time":"17 minutes"},"schema":{"@context":"https:\/\/schema.org","@graph":[{"@type":"Article","@id":"https:\/\/oddspapi.io\/blog\/pandas-odds-api-dataframe-tutorial\/#article","isPartOf":{"@id":"https:\/\/oddspapi.io\/blog\/pandas-odds-api-dataframe-tutorial\/"},"author":{"name":"Odds API Writer","@id":"https:\/\/oddspapi.io\/blog\/#\/schema\/person\/b6f21e649c4f556f0a95c23a0f1efa13"},"headline":"Pandas + Odds API: Turn a 59 MB Board into 79,503 Rows","datePublished":"2026-05-20T10:00:00+00:00","dateModified":"2026-10-22T13:16:38+00:00","mainEntityOfPage":{"@id":"https:\/\/oddspapi.io\/blog\/pandas-odds-api-dataframe-tutorial\/"},"wordCount":2003,"commentCount":0,"publisher":{"@id":"https:\/\/oddspapi.io\/blog\/#organization"},"image":{"@id":"https:\/\/oddspapi.io\/blog\/pandas-odds-api-dataframe-tutorial\/#primaryimage"},"thumbnailUrl":"https:\/\/oddspapi.io\/blog\/wp-content\/uploads\/2026\/05\/pandas-odds-api-dataframe-tutorial-scaled.webp","keywords":["Free API","Odds API","Pandas","Python","Sports Betting API"],"articleSection":["How To Guides"],"inLanguage":"en-US","potentialAction":[{"@type":"CommentAction","name":"Comment","target":["https:\/\/oddspapi.io\/blog\/pandas-odds-api-dataframe-tutorial\/#respond"]}]},{"@type":"WebPage","@id":"https:\/\/oddspapi.io\/blog\/pandas-odds-api-dataframe-tutorial\/","url":"https:\/\/oddspapi.io\/blog\/pandas-odds-api-dataframe-tutorial\/","name":"Pandas + Odds API: Turn a 59 MB Board into 79,503 Rows | OddsPapi Blog","isPartOf":{"@id":"https:\/\/oddspapi.io\/blog\/#website"},"primaryImageOfPage":{"@id":"https:\/\/oddspapi.io\/blog\/pandas-odds-api-dataframe-tutorial\/#primaryimage"},"image":{"@id":"https:\/\/oddspapi.io\/blog\/pandas-odds-api-dataframe-tutorial\/#primaryimage"},"thumbnailUrl":"https:\/\/oddspapi.io\/blog\/wp-content\/uploads\/2026\/05\/pandas-odds-api-dataframe-tutorial-scaled.webp","datePublished":"2026-05-20T10:00:00+00:00","dateModified":"2026-10-22T13:16:38+00:00","breadcrumb":{"@id":"https:\/\/oddspapi.io\/blog\/pandas-odds-api-dataframe-tutorial\/#breadcrumb"},"inLanguage":"en-US","potentialAction":[{"@type":"ReadAction","target":["https:\/\/oddspapi.io\/blog\/pandas-odds-api-dataframe-tutorial\/"]}]},{"@type":"ImageObject","inLanguage":"en-US","@id":"https:\/\/oddspapi.io\/blog\/pandas-odds-api-dataframe-tutorial\/#primaryimage","url":"https:\/\/oddspapi.io\/blog\/wp-content\/uploads\/2026\/05\/pandas-odds-api-dataframe-tutorial-scaled.webp","contentUrl":"https:\/\/oddspapi.io\/blog\/wp-content\/uploads\/2026\/05\/pandas-odds-api-dataframe-tutorial-scaled.webp","width":2560,"height":1429,"caption":"Pandas + Odds API - OddsPapi API Blog"},{"@type":"BreadcrumbList","@id":"https:\/\/oddspapi.io\/blog\/pandas-odds-api-dataframe-tutorial\/#breadcrumb","itemListElement":[{"@type":"ListItem","position":1,"name":"Home","item":"https:\/\/oddspapi.io\/blog\/"},{"@type":"ListItem","position":2,"name":"Pandas + Odds API: Turn a 59 MB Board into 79,503 Rows"}]},{"@type":"WebSite","@id":"https:\/\/oddspapi.io\/blog\/#website","url":"https:\/\/oddspapi.io\/blog\/","name":"OddsPapi","description":"Sports Odds API Tutorials &amp; Guides","publisher":{"@id":"https:\/\/oddspapi.io\/blog\/#organization"},"alternateName":"Odds Papi","potentialAction":[{"@type":"SearchAction","target":{"@type":"EntryPoint","urlTemplate":"https:\/\/oddspapi.io\/blog\/?s={search_term_string}"},"query-input":{"@type":"PropertyValueSpecification","valueRequired":true,"valueName":"search_term_string"}}],"inLanguage":"en-US"},{"@type":"Organization","@id":"https:\/\/oddspapi.io\/blog\/#organization","name":"OddsPapi","url":"https:\/\/oddspapi.io\/blog\/","logo":{"@type":"ImageObject","inLanguage":"en-US","@id":"https:\/\/oddspapi.io\/blog\/#\/schema\/logo\/image\/","url":"https:\/\/oddspapi.io\/blog\/wp-content\/uploads\/2025\/11\/oddspapi.png","contentUrl":"https:\/\/oddspapi.io\/blog\/wp-content\/uploads\/2025\/11\/oddspapi.png","width":135,"height":135,"caption":"OddsPapi"},"image":{"@id":"https:\/\/oddspapi.io\/blog\/#\/schema\/logo\/image\/"},"sameAs":["https:\/\/x.com\/oddspapiapi"]},{"@type":"Person","@id":"https:\/\/oddspapi.io\/blog\/#\/schema\/person\/b6f21e649c4f556f0a95c23a0f1efa13","name":"Odds API Writer","image":{"@type":"ImageObject","inLanguage":"en-US","@id":"https:\/\/oddspapi.io\/blog\/#\/schema\/person\/image\/","url":"https:\/\/secure.gravatar.com\/avatar\/33b204f24af3d02e35b25ae730c0536121ca6a783fdb196e7611c9e49fcd13eb?s=96&d=mm&r=g","contentUrl":"https:\/\/secure.gravatar.com\/avatar\/33b204f24af3d02e35b25ae730c0536121ca6a783fdb196e7611c9e49fcd13eb?s=96&d=mm&r=g","caption":"Odds API Writer"},"url":"https:\/\/oddspapi.io\/blog\/author\/andy-lavelle\/"}]}},"_links":{"self":[{"href":"https:\/\/oddspapi.io\/blog\/wp-json\/wp\/v2\/posts\/2909","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/oddspapi.io\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/oddspapi.io\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/oddspapi.io\/blog\/wp-json\/wp\/v2\/users\/2"}],"replies":[{"embeddable":true,"href":"https:\/\/oddspapi.io\/blog\/wp-json\/wp\/v2\/comments?post=2909"}],"version-history":[{"count":3,"href":"https:\/\/oddspapi.io\/blog\/wp-json\/wp\/v2\/posts\/2909\/revisions"}],"predecessor-version":[{"id":5300,"href":"https:\/\/oddspapi.io\/blog\/wp-json\/wp\/v2\/posts\/2909\/revisions\/5300"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/oddspapi.io\/blog\/wp-json\/wp\/v2\/media\/2911"}],"wp:attachment":[{"href":"https:\/\/oddspapi.io\/blog\/wp-json\/wp\/v2\/media?parent=2909"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/oddspapi.io\/blog\/wp-json\/wp\/v2\/categories?post=2909"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/oddspapi.io\/blog\/wp-json\/wp\/v2\/tags?post=2909"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}