Polars market data Parquet guide
Lazy-scan date-partitioned OHLCV files, read only the columns and rows a query needs, and resample intraday bars -- with the actual outputs and timings from running each example on real MarketParquet files (Polars 1.43, 2-core / 2 GB VPS).
Why Polars fits this layout
MarketParquet ships one Snappy-compressed Parquet file per trading day
(by_date/{asset}_{timeframe}/YYYY/YYYY-MM-DD.parquet),
every symbol included -- a 2024 stock 1-minute day is ~22 MB and 1.28
million typed rows. Polars' lazy engine pushes column selection and
filters down into the Parquet reader, so
scan_parquet over a year of files touches only the bytes
your query needs instead of loading 250 files into RAM.
One symbol, one day
import polars as pl
aapl = (
pl.scan_parquet("stock_1min_2024-01-03.parquet")
.filter(pl.col("symbol") == "AAPL")
.select(["timestamp", "close", "volume"])
.collect()
)
# 741 rows in ~12 ms
# ┌─────────────────────┬────────┬────────┐
# │ timestamp ┆ close ┆ volume │
# │ datetime[μs] ┆ f64 ┆ f64 │
# ╞═════════════════════╪════════╪════════╡
# │ 2024-01-03 04:00:00 ┆ 185.0 ┆ 791.0 │
# │ 2024-01-03 04:01:00 ┆ 184.9 ┆ 1278.0 │
741 rows, not 960: bars exist only for minutes with trades, and the
file covers the extended 04:00–19:59 Eastern session. Timestamps
are bar opens, US/Eastern, timezone-naive -- attach a
zone explicitly with
.dt.replace_time_zone("America/New_York") if the rest of
your stack is timezone-aware.
Cross-sectional screens
Full-day volume ranking across all 7,180 symbols runs in ~60 ms:
top = (
pl.scan_parquet("stock_1min_2024-01-03.parquet")
.group_by("symbol")
.agg(pl.col("volume").sum().alias("vol"))
.sort("vol", descending=True)
.head(5)
.collect()
)
# ┌───────────────┬──────────┐
# │ BETS-DELISTED ┆ 772.5M │ <- delisted tickers are in the file,
# │ NVDA ┆ 244.5M │ suffixed '-DELISTED'
# │ TSLA ┆ 102.7M │
# │ MARA ┆ 91.1M │
That first row is the survivorship-bias point in miniature: the
highest-volume stock that day no longer exists. Filter with
pl.col("symbol").str.ends_with("-DELISTED").not_() only
when you deliberately want today's survivors.
Resample 1-minute to 5-minute
bars5 = (
pl.scan_parquet("stock_1min_2024-01-03.parquet")
.filter(pl.col("symbol") == "AAPL")
.sort("timestamp")
.group_by_dynamic("timestamp", every="5m")
.agg(
pl.col("open").first(),
pl.col("high").max(),
pl.col("low").min(),
pl.col("close").last(),
pl.col("volume").sum(),
)
.collect()
)
# ~11 ms
# ┌─────────────────────┬────────┬────────┬────────┬────────┬────────┐
# │ 2024-01-03 04:00:00 ┆ 185.0 ┆ 185.0 ┆ 184.9 ┆ 184.9 ┆ 3932.0 │
# │ 2024-01-03 04:05:00 ┆ 184.85 ┆ 184.88 ┆ 184.73 ┆ 184.88 ┆ 1632.0 │
The .sort("timestamp") matters:
group_by_dynamic requires sorted input, and file order is
(timestamp, symbol) -- already sorted once you filter to one symbol,
but be explicit after joins or concats. Also note MarketParquet ships
native 5-minute, 30-minute, and 1-hour files -- resample yourself only
for non-standard intervals.
Scan a year (or the whole archive)
spy_2024 = (
pl.scan_parquet("by_date/etf_daily/2024/*.parquet")
.filter(pl.col("symbol") == "SPY")
.select(["date", "close", "volume"])
.sort("date")
.collect()
)
Note etf_daily: SPY and QQQ are ETFs, so
they live in the etf_* datasets -- an empty frame from a
stock_* scan is the most common first-day mistake. For
scans too big for RAM (e.g. touching every 1-minute file), finish with
.collect(engine="streaming") so Polars processes files in
batches.
Edge case: legacy daily files
Daily files written since 2026-03-27 carry a typed date
column; older archive files have a midnight timestamp
column instead. When one scan spans both eras, insert the missing
columns and coalesce:
lf = pl.scan_parquet(
"by_date/stock_daily/*/*.parquet",
missing_columns="insert", # tolerate both schema eras
).with_columns(
pl.coalesce(pl.col("date"), pl.col("timestamp").dt.date()).alias("d")
)
# AAPL close: 2010-01-04 -> 7.6432 (214.01 as traded / 28 across
# two later splits), 2026-07-24 -> 333.02. Split-adjusted, and
# deliberately NOT dividend-adjusted -- see /data-quality.
Join daily context onto intraday bars
Because every file holds the full cross-section for one date, joining
datasets is a plain join on symbol -- no per-ticker loops.
A gap scanner, for example, joins yesterday's daily closes onto
today's 1-minute opens:
prev = (
pl.scan_parquet("stock_daily_2024-01-02.parquet")
.select("symbol", pl.col("close").alias("prev_close"))
)
gaps = (
pl.scan_parquet("stock_1min_2024-01-03.parquet")
.filter(pl.col("timestamp") == pl.datetime(2024, 1, 3, 9, 30))
.join(prev, on="symbol")
.with_columns(
((pl.col("open") / pl.col("prev_close") - 1) * 100)
.round(2).alias("gap_pct")
)
.sort("gap_pct", descending=True)
.collect()
)
Both scans stay lazy until .collect(), so Polars reads
just three columns from one file and five from the other. The same
shape works for joining fundamentals, borrow lists, or your own
signals -- anything keyed by symbol and date.
Pitfalls checklist
- Empty result for SPY/QQQ -- they are in
etf_*, notstock_*. - Missing minutes are not an error -- no trades, no bar. Use
upsampleor join against a generated session grid if you need density. - Naive Eastern timestamps -- do not
.dt.convert_time_zone()before attaching the zone; replace first, convert second. - Volume is Float64 -- cast with
.cast(pl.Int64)if you need integer share counts. - Delisted suffix --
BETS-DELISTED-style symbols are the point of the dataset; excluding them re-introduces survivorship bias. - Prices differ from Yahoo "Adj Close" -- these are split-adjusted traded levels, not dividend-smoothed total-return prices.
Related
stock data hub · browse stock daily · intraday stock data · data quality & methodology · DuckDB guide · pricing