Every Polymarket outcome trades on its own central limit order book: resting bids and asks for one token, priced in cents of probability. The L2 table records that book on every change the venue broadcasts, as a full snapshot of the best 25 price levels on each side, with the size at each level and nanosecond receive timestamps. Below is what the table holds, what one day of one book looks like, and how deep it actually was, measured on the free sample with a script you can run yourself.
Polymarket's CLOB API answers questions about now. /book returns the current resting bids and asks for one outcome token, with a hash so you can tell whether the book changed between two reads; it takes no time parameter. /prices-history returns timestamp and price pairs only, with no bid, ask, size or depth, and when we asked it for one-minute fidelity over a two-week-old market's whole life it returned points ten minutes apart. The market websocket does push every book change as it happens, but only to whoever is connected at that moment.
So a book's history exists only if someone was recording it. We have been, continuously, since 2026-05-11, and order-book history reaches back to February 2026. That is the data on this page.
One row per book update per outcome token. Each row is the book after the update, not a diff: the 25 best bids and the 25 best asks, price and size, padded with NaN past the last level present. On the sample day the venue resent a whole book 9,819 times and sent 630,916 incremental changes; both land as full snapshots, told apart by event_kind. Sizes are in shares (contracts), so a level's dollar depth is price times size.
l2.parquet — one row per book update per outcome tokents_recv_ns int64 our receive time, ns since epoch (authoritative order)ts_src_ms int64 the venue's own timestamp, mscondition_id str the markettoken_id str the outcome (YES or NO leg) — read as a stringevent_kind str "snapshot" (venue resent the whole book) | "update"source_event_type str upstream message: book | price_changemsg_seq int64 capture sequence numberbid_levels int16 price levels in the WHOLE bid book (the file keeps 25)ask_levels int16 price levels in the whole ask bookbest_bid, best_ask float64 top of book, rebuilt from the levelspayload_best_bid float64 top of book as the venue's message declared itpayload_best_ask float64bid_ask_reconciles bool rebuilt top of book == declared top of bookbid_px_1 … bid_px_25 float64 price per level, best first, NaN past the bookbid_sz_1 … bid_sz_25 float64 size per level, in shares (contracts)ask_px_1 … ask_px_25 float64ask_sz_1 … ask_sz_25 float64
Premium and Custom plans carry all 25 levels; Explorer and the free market·days carry the best 10. Bundles also carry L1 (top of book on each change) and, from 2026-04-13, the trade tape. Full reference in the schema docs.
The YES side of "Bitcoin Up or Down - August 19, 4:00PM-8:00PM ET" over the four hours its question covers. Each cell is one minute and one cent of price; its brightness is the dollar size that rested there, averaged second by second. Bids are green below the mid, asks red above it, and circles are fills.
Read the jump just before 21:00. At 20:49:44 UTC the mid was 68¢; sixty seconds later it was 93¢. In that minute the YES book changed 2,049 times and 15 fills printed on it, about $1,400 in all. A trade tape tells you that much. The book tells you the rest: when the minute began, $5,680 of asks rested between 69¢ and 93¢, and when it ended the best ask was 94¢. A price chart shows the jump. Only the book shows whether you could have traded through it, and at what size.
The same market and window, sampled once a second so that a burst of updates counts for the time it lasted, not for how many messages it produced. Spread is taken as best ask minus best bid.
| spread | ≤ 1¢ | 1–2¢ | 2–5¢ | > 5¢ | median |
|---|---|---|---|---|---|
| share of time | 63.9% | 20.6% | 15.5% | 0.0% | 1.0¢ |
14,274 two-sided seconds · 2026-08-19 20:00–24:00 UTC
| resting within | bids · median | bids · p10 | asks · median | asks · p10 |
|---|---|---|---|---|
| ±1¢ of mid | $256 | $0 | $141 | $0 |
| ±2¢ of mid | $944 | $102 | $294 | $76 |
| ±5¢ of mid | $2,260 | $719 | $2,918 | $419 |
USDC (price × size) · p10 = the thinnest tenth of seconds
| level | bid at level | bids through | ask at level | asks through | ask level exists |
|---|---|---|---|---|---|
| 1 | $110 | $110 | $77 | $77 | 100% |
| 2 | $130 | $305 | $95 | $196 | 93% |
| 3 | $99 | $568 | $88 | $304 | 93% |
| 5 | $98 | $936 | $98 | $606 | 85% |
| 10 | $66 | $2,428 | $74 | $2,378 | 77% |
| 25 | $40 | $4,736 | $53 | $10,480 | 25% |
median USDC at that level, and summed from the touch through it · the bid side had all 25 levels in 99.9% of seconds
Three things stand out. The touch is small: a median $110 on the best bid and $77 on the best ask, so anything bigger than a small order walks the ladder. Depth near the mid comes and goes: in at least one second in ten a given side had nothing resting within 1¢ of the mid. And the two sides were not alike: with the price above 90¢ for two-thirds of the window there was little room between the mid and 100¢, and the median book had 111 bid levels against 17 ask levels. None of this can be read off a price series.
The script behind all three tables, unchanged. Unzip the sample, run it in the bundle directory, and point it at any other market·day you download: it picks the busiest market in the file and its own window.
# pm_depth.py — how deep was a Polymarket order book, level by level and near the mid?# Runs on the free sample bundle: https://tickfoundry.com/samplesimport numpy as npimport pandas as pdl2 = pd.read_parquet("l2.parquet") # one full 25-level snapshot per book updatemarkets = pd.read_csv("reference/markets.csv")# The YES leg of the day's busiest market, over the four hours its question covers.busiest = l2.groupby("condition_id").size().idxmax()m = markets.set_index("condition_id").loc[busiest]book = l2[l2.token_id == str(m.yes_token_id)].sort_values("ts_recv_ns").reset_index(drop=True)end = pd.Timestamp(m.end_time)start = end - pd.Timedelta(hours=4)# Sample the book once a second so every statistic is time-weighted:# a burst of 50 updates inside one second counts as one second, not as 50.grid = np.arange(start.value, end.value, 1_000_000_000)at = np.searchsorted(book.ts_recv_ns.to_numpy(), grid, side="right") - 1snap = book.iloc[at[at >= 0]]snap = snap[snap.best_bid.notna() & snap.best_ask.notna()] # drop one-sided secondspx = {s: snap[[f"{s}_px_{i}" for i in range(1, 26)]].to_numpy() for s in ("bid", "ask")}usd = {s: px[s] * snap[[f"{s}_sz_{i}" for i in range(1, 26)]].to_numpy() for s in ("bid", "ask")}mid = ((snap.best_bid + snap.best_ask) / 2).to_numpy()[:, None]spread = (snap.best_ask - snap.best_bid).to_numpy() * 100 # cents; never the rounded spread columnprint(m.question, f"· {start:%Y-%m-%d %H:%M} to {end:%H:%M} UTC")print(f"{len(book):,} book updates for this token on the day, "f"{((book.ts_recv_ns >= start.value) & (book.ts_recv_ns < end.value)).sum():,} inside the window; "f"{len(snap):,} two-sided seconds sampled\n")print("spread (¢) median", round(np.median(spread), 2),"| ≤1¢", f"{np.mean(spread <= 1.0001):.1%}","| 1–2¢", f"{np.mean((spread > 1.0001) & (spread <= 2.0001)):.1%}","| 2–5¢", f"{np.mean((spread > 2.0001) & (spread <= 5.0001)):.1%}","| >5¢", f"{np.mean(spread > 5.0001):.1%}\n")rows = []for c in (1, 2, 5): # USDC resting within c cents of the midbid = np.nansum(np.where(px["bid"] >= mid - c / 100 - 1e-9, usd["bid"], 0), axis=1)ask = np.nansum(np.where(px["ask"] <= mid + c / 100 + 1e-9, usd["ask"], 0), axis=1)rows.append({"within": f"±{c}¢", "bid median $": np.median(bid), "bid p10 $": np.percentile(bid, 10),"ask median $": np.median(ask), "ask p10 $": np.percentile(ask, 10)})print(pd.DataFrame(rows).round(0).to_string(index=False), "\n")rows = []for lvl in (1, 2, 3, 5, 10, 25): # size at each level, and through itrows.append({"level": lvl,"bid $ at level": np.nanmedian(usd["bid"][:, lvl - 1]),"bid $ through": np.median(np.nansum(usd["bid"][:, :lvl], axis=1)),"ask $ at level": np.nanmedian(usd["ask"][:, lvl - 1]),"ask $ through": np.median(np.nansum(usd["ask"][:, :lvl], axis=1)),"ask levels present": f"{np.mean(~np.isnan(px['ask'][:, lvl - 1])):.0%}"})print(pd.DataFrame(rows).round(0).to_string(index=False), "\n")print("price levels in the whole book (the file keeps the best 25 a side): median bid",int(np.median(snap.bid_levels)), "· ask", int(np.median(snap.ask_levels)))
To price an order against this book instead of describing it, see how to backtest Polymarket, which walks the same ladder for $100, $500 and $2,000 market buys.
Two worked examples from the archive: the Spain–Belgium quarterfinal, where the spread opened to 31¢ while the price moved 64 points, and the World Cup final through the order book.
A free account claims 5 market·days from any day in July 2026, in the same schema, and pm_depth.py runs on each of them unchanged. No card.