DuckDB stock data Parquet guide

Run SQL directly against OHLCV Parquet files -- no database load step, no CSV parsing, and full-day cross-sectional queries in tens of milliseconds. Every example below was run against real MarketParquet files and shows its actual output.

File layout and schema

MarketParquet files are partitioned by trading date: by_date/{asset}_{timeframe}/YYYY/YYYY-MM-DD.parquet. Each file contains every symbol for one trading day -- a 2024 stock 1-minute file is ~22 MB, 1.28 million rows, 7,180 symbols.

  columns (intraday):
    timestamp    TIMESTAMP[us]  -- bar OPEN time, US/Eastern, tz-naive
    symbol       VARCHAR        -- 'AAPL'; delisted end in '-DELISTED'
    asset_type   VARCHAR
    open/high/low/close  DOUBLE -- split-adjusted
    volume       DOUBLE

  columns (daily): same, but a DATE column named 'date'
                   (files before 2026-03-27: legacy 'timestamp' instead)

Two things trip people up on day one: stocks and ETFs are separate datasets (SPY lives in etf_* files, not stock_* -- an empty result for SPY usually means you are querying the wrong asset class), and intraday timestamps cover the extended session, 04:00 premarket through 19:59 post-market bar opens.

Query one day

  import duckdb

  con = duckdb.connect()
  bars = con.execute("""
      SELECT timestamp, open, high, low, close, volume
      FROM 'stock_1min_2024-01-03.parquet'
      WHERE symbol = 'AAPL'
      ORDER BY timestamp
  """).fetchdf()

  # 741 rows in ~6 ms. First bars of the premarket:
  #            timestamp    open    high     low   close  volume
  # 0 2024-01-03 04:00:00  185.00  185.00  185.00  185.00   791.0
  # 1 2024-01-03 04:01:00  184.90  184.90  184.90  184.90  1278.0

Note the row count: 741 bars, not 960. Bars exist only for minutes the symbol actually traded -- nothing is forward-filled. If you need a dense grid (e.g. for matrix ops), generate the session minutes with generate_series and LEFT JOIN the bars onto it.

Regular session only

Because timestamps are bar opens in Eastern time, the regular session is a simple range filter -- here computing session VWAP for three symbols on 2024-01-03:

  SELECT symbol,
         ROUND(SUM(close * volume) / SUM(volume), 2) AS vwap,
         SUM(volume)::BIGINT AS shares
  FROM 'stock_1min_2024-01-03.parquet'
  WHERE timestamp >= '2024-01-03 09:30'
    AND timestamp <  '2024-01-03 16:00'
    AND symbol IN ('AAPL', 'TSLA', 'NVDA')
  GROUP BY symbol ORDER BY symbol;

  -- symbol    vwap      shares
  -- AAPL    184.36  37,331,553
  -- NVDA     47.69 219,800,480     <- 47.69, not 476.9: split-adjusted
  -- TSLA    239.77  97,936,006

Query many files with globs

DuckDB expands globs and pushes filters down into the Parquet scans, reading only the row groups and columns a query needs, so scanning a month or a year of files stays fast without any loading step:

  -- a month, cross-sectional
  SELECT symbol, AVG(close) AS avg_close, SUM(volume) AS total_volume
  FROM 'by_date/stock_daily/2024/2024-01-*.parquet'
  GROUP BY symbol ORDER BY total_volume DESC LIMIT 20;

  -- a whole year for one symbol
  SELECT * FROM 'by_date/stock_1min/2024/*.parquet'
  WHERE symbol = 'NVDA' ORDER BY timestamp;

Add filename = true inside read_parquet(...) if you want to know which file each row came from -- useful when auditing a suspicious bar.

Resample 1-minute to 5-minute

time_bucket plus ordered first/last aggregates gives correct OHLCV resampling in one query:

  SELECT time_bucket(INTERVAL 5 MINUTE, timestamp) AS bar_open,
         first(open ORDER BY timestamp)  AS open,
         MAX(high)                       AS high,
         MIN(low)                        AS low,
         last(close ORDER BY timestamp)  AS close,
         SUM(volume)::BIGINT             AS volume
  FROM 'stock_1min_2024-01-03.parquet'
  WHERE symbol = 'AAPL'
  GROUP BY bar_open ORDER BY bar_open;

  --    bar_open              open    high     low   close  volume
  -- 2024-01-03 04:00:00    185.00  185.00  184.90  184.90    3932
  -- 2024-01-03 04:05:00    184.85  184.88  184.73  184.88    1632

That said, MarketParquet already ships native 5-minute, 30-minute, and 1-hour files built by the vendor from the tape -- prefer those for research; resample yourself only for non-standard intervals.

Edge case: legacy daily files

Daily files written since 2026-03-27 have a typed date column; older archive files carry a midnight timestamp instead. When a query spans both eras, read with union_by_name and coalesce:

  SELECT COALESCE(date, CAST(timestamp AS DATE)) AS d, symbol, close
  FROM read_parquet('by_date/stock_daily/*/*.parquet', union_by_name = true)
  WHERE symbol = 'AAPL' ORDER BY d;

  -- d           symbol   close
  -- 2010-01-04  AAPL     7.6432    <- 214.01 as traded / 28 (two splits)
  -- ...
  -- 2026-07-24  AAPL   333.02

Survivorship bias in one WHERE clause

Delisted tickers stay in every file with a -DELISTED suffix (1,194 of them in the 2024-01-03 1-minute file alone). Include them in backtests; exclude them only when you explicitly want today's survivors:

  -- the universe as it actually existed that day (default: keep everything)
  SELECT COUNT(DISTINCT symbol) FROM 'stock_daily_2010-01-04.parquet';   -- 5,876

  -- survivors only (this is how survivorship bias sneaks in)
  SELECT COUNT(DISTINCT symbol) FROM 'stock_daily_2010-01-04.parquet'
  WHERE symbol NOT LIKE '%-DELISTED';                                    -- 2,360

How fast is it?

Measured on a 2-core / 2 GB VPS (not a workstation), against the 1.28M-row 2024-01-03 1-minute file:

  • COUNT(*) + distinct symbols over the file: 9 ms
  • Full-day GROUP BY symbol volume ranking (7,180 groups): 22 ms
  • Single-symbol extract, sorted: 6 ms

Filter and projection pushdown mean DuckDB decompresses only the columns and row groups it needs -- which is the entire point of shipping this data as Parquet instead of CSV.

FAQ

Why is SPY / QQQ missing from stock files? They are ETFs -- query etf_1min / etf_daily instead.

Are timestamps UTC? No -- US/Eastern, timezone-naive, bar open time. Convert with timezone('America/New_York', 'UTC', ts) only if your stack requires UTC.

Why do prices differ from Yahoo's "Adj Close"? MarketParquet prices are split-adjusted but not dividend-adjusted, so you see the levels that actually traded. See data quality & methodology.

Can DuckDB read the files over HTTP? Yes -- httpfs works with the presigned URLs the download API returns, but for repeated research downloading once to local disk is faster and kinder to your quota.

Related

stock data hub · browse stock daily · data quality & methodology · Polars guide · local data lake guide · pricing