Howwise RSS
Trading Software7 min read

Best Database for Tick Data Backtesting: Parquet vs Servers

Short answer

The best database for tick data backtesting at 0.5 to 1TB is usually not a database: zstd-compressed Parquet files partitioned by date and symbol, queried with DuckDB or Polars. Use ClickHouse or QuestDB only for multi-user or live ingestion.

Key takeaways
  • Backtests do long, time-ordered scans per symbol, so columnar files sorted by (symbol, timestamp) beat row-oriented databases such as SQLite, MySQL and plain Postgres.
  • Partitioned Parquet with zstd, read through DuckDB, Polars or PyArrow, is the default stack for 0.5 to 1TB because it needs no server and is easy to share.
  • Aim for Parquet files of roughly 128MB to 1GB with row groups of 100k to 1M rows, and compact small daily files into larger ones after ingestion.
  • For options, store one row per contract per timestamp with a normalised integer contract ID instead of repeating the OCC symbol string.
  • On object storage, repeated full scans and egress can cost more than the storage itself, so cache hot slices on local NVMe.

What access pattern does tick data backtesting have?

Start from how the data is read, not from which product is popular. A backtest reads large, time-ordered ranges for one symbol or one underlying, usually a handful of columns, and almost never fetches a single row by key. Writes are append-only: today's data arrives, yesterday's never changes.

That profile favours columnar storage sorted by time. A columnar file lets the reader skip columns it does not need, and a time-sorted layout lets it skip whole blocks using min/max statistics. Row-oriented OLTP engines are built for the opposite: random lookups, updates and transactions.

Three access patterns decide the engine, and they often mix. Match yours before choosing.

Access patternTypical needBest fit
Sequential replay for backtestsFast scan of one symbol/date range, few columnsPartitioned Parquet + Polars/PyArrow/DuckDB
Ad-hoc analytics, multi-userAggregations, joins, concurrent queriesClickHouse (or DuckDB over shared Parquet)
Live ingestion plus queriesContinuous inserts, recent-data SQL, ASOF joinsQuestDB or ClickHouse
Pandas-native research on versioned framesRead/write DataFrames by symbol with historyArcticDB

Why is partitioned Parquet the default for 0.5 to 1TB?

At this size the data fits on one NVMe drive or one cheap bucket, and a single machine can scan it quickly. Running a database server adds operations work (upgrades, backups, memory tuning) that Parquet does not need. Files are also trivially shareable: copy a folder, or point a reader at a bucket.

Raw CSV tick data typically shrinks 5 to 10x in Parquet with zstd, so 1TB of CSV often becomes roughly 100 to 200GB. Gains come from column-wise compression, dictionary encoding of repeated symbols, and delta encoding of near-monotonic timestamps. Actual ratios depend on how noisy your prices and sizes are, so measure on one day before planning capacity.

Use a Hive-style layout so engines can prune whole directories from the path alone:

ticks/
  trade_date=2024-03-15/
    symbol_bucket=07/part-0000.parquet
    symbol_bucket=12/part-0000.parquet
  trade_date=2024-03-18/
    ...

# Each file is sorted by (symbol, ts_event_ns).
# Partition by date, plus a symbol hash bucket (or underlying for options)
# so no file is tiny and no directory holds thousands of files.
Tip: Partitioning by symbol alone creates thousands of small directories for a wide universe. Partition by date, then bucket symbols so each file lands in the 128MB to 1GB range.

How do I query Parquet tick data with DuckDB and Polars?

DuckDB reads Parquet in place, pushes filters down to row-group statistics, and only decodes the columns you select. Because files are sorted by (symbol, timestamp), the min/max statistics let it skip most row groups for a single-symbol query.

Polars lazy scans do the same from Python and hand you columnar data without a pandas copy. The second example streams a date range as Arrow batches, which suits an event-driven backtest that cannot hold everything in memory.

import duckdb, polars as pl, pyarrow.dataset as ds

# 1) Ad-hoc: 1-minute bars for one symbol, only 4 columns read
con = duckdb.connect()
bars = con.sql("""
    SELECT time_bucket(INTERVAL 1 MINUTE, to_timestamp(ts_event_ns / 1e9)) AS minute,
           first(price ORDER BY ts_event_ns) AS open,
           max(price) AS high, min(price) AS low,
           last(price ORDER BY ts_event_ns) AS close,
           sum(size) AS volume
    FROM read_parquet('ticks/trade_date=*/**/*.parquet', hive_partitioning=true)
    WHERE trade_date BETWEEN '2024-03-01' AND '2024-03-29'
      AND symbol = 'AAPL'
    GROUP BY 1 ORDER BY 1
""").pl()

# 2) Polars lazy scan: filters and projection pushed into the scan
lf = (pl.scan_parquet('ticks/**/*.parquet', hive_partitioning=True)
        .filter((pl.col('symbol') == 'AAPL') &
                (pl.col('trade_date') >= pl.date(2024, 3, 1)))
        .select(['ts_event_ns', 'price', 'size']))
df = lf.collect()

# 3) Streaming for event-driven replay: bounded memory
dataset = ds.dataset('ticks', format='parquet', partitioning='hive')
scanner = dataset.scanner(columns=['ts_event_ns', 'price', 'size'],
                          filter=(ds.field('symbol') == 'AAPL'),
                          batch_size=500_000)
for batch in scanner.to_batches():
    ts = batch.column('ts_event_ns').to_numpy(zero_copy_only=False)
    px = batch.column('price').to_numpy(zero_copy_only=False)
    # feed arrays to the engine; never iterate rows in Python
Warning: Loading everything with pd.read_csv or pandas row iteration is the most common cause of slow backtests. Select columns and date ranges at scan time, and process arrays in vectorised batches.

Which is the best database for tick data backtesting if I need a server?

Choose a server when you need concurrent users, continuous ingestion, or low-latency queries over recent data. Row stores such as SQLite, MySQL and plain Postgres struggle here: B-tree indexes on billions of rows bloat storage, row storage reads every column, and index maintenance slows bulk inserts. Indexing everything in Postgres is a classic trap at this scale.

TimescaleDB softens this with hypertables and columnar compression, but very large scans remain slower than ClickHouse or Parquet engines.

OptionStrengthWeaknessChoose when
ClickHouseVery fast scans and aggregation, many concurrent users, SQLServer to run; ASOF join support is more limited than QuestDBSmall team querying shared data, heavy aggregation
QuestDBFast ingestion, SQL, native ASOF joins, time-partitionedSmaller ecosystem, fewer analytic functionsLive feed capture and quote/trade alignment
TimescaleDBPostgres tooling, compression, familiar SQLSlower on huge scans, heavier storageYou already run Postgres and data is modest
kdb+/qIndustry standard for tick data, extremely fastLicensing cost, q learning curveFirm already invested, or low-latency needs
ArcticDBPandas-native, versioned DataFrames, S3 or LMDB backendNot SQL; library not a general query serverPython-only research on per-symbol frames
SQLite/MySQL/plain PostgresSimple, ubiquitousRow-oriented, index bloat, slow scans at this sizeMetadata and reference tables only
Tip: A common hybrid: capture live data into QuestDB or ClickHouse, then export each finished day to Parquet as the immutable backtest copy.

How should I store options chain historical data?

Options data is large mostly because of quotes: a single underlying can have thousands of live contracts, each updating constantly, and full OPRA feeds are enormous. If you do not need tick quotes, store 1-minute NBBO snapshots per contract instead, which cuts volume by orders of magnitude.

Use one row per contract per timestamp. Normalise contract identity into a dimension table rather than repeating the OCC symbol string on every row, and join through an integer ID. Partition by trade date and underlying, sort by (contract_id, ts_event_ns), and dictionary-encode low-cardinality columns.

import pyarrow as pa

# Dimension: one row per contract (small, rarely changes)
contracts = pa.schema([
    ('contract_id', pa.int32()),
    ('underlying', pa.dictionary(pa.int16(), pa.string())),
    ('expiry', pa.date32()),
    ('strike_x10000', pa.int32()),     # strike * 10000, scaled integer
    ('right', pa.dictionary(pa.int8(), pa.string())),  # C or P
    ('occ_symbol', pa.string()),       # stored once here
])

# Fact: one row per contract per timestamp
quotes = pa.schema([
    ('ts_event_ns', pa.int64()),       # exchange time, UTC ns
    ('ts_recv_ns', pa.int64()),        # receive time, UTC ns
    ('contract_id', pa.int32()),
    ('bid_x10000', pa.int64()), ('ask_x10000', pa.int64()),
    ('last_x10000', pa.int64()),
    ('bid_size', pa.int32()), ('ask_size', pa.int32()),
    ('volume', pa.int32()), ('open_interest', pa.int32()),
    ('iv', pa.float64()), ('delta', pa.float64()),
    ('gamma', pa.float64()), ('theta', pa.float64()), ('vega', pa.float64()),
])
Warning: Vendor-supplied greeks and IV depend on the vendor's model, rates and dividend inputs. Store them as received, label the source, and keep any greeks you compute yourself in separate columns.

What schema, file size and compression settings matter?

Store timestamps as int64 nanoseconds in UTC, and keep both exchange time and receive time. Mixing timezones is a leading source of silent look-ahead errors, so convert to local time only at presentation. Use sizes as int32.

For prices, pick one convention and keep it everywhere: scaled integers (price times 10,000) avoid rounding drift and compress well, while float64 is acceptable and simpler. Avoid float32 for prices because it cannot represent many tick values exactly.

Sort by (symbol, timestamp) inside each file so row-group min/max statistics work. Too many tiny files hurts S3 and metadata performance, while one giant file defeats parallel reads and makes appends painful.

SettingRule of thumbWhy
File sizeAbout 128MB to 1GBFew requests on S3, still parallelisable
Row groupAbout 100k to 1M rowsUseful statistics without huge metadata
Compressionzstd, level 3 to 9Good ratio with fast decode
Timestampsint64 ns, delta-encodedNear-monotonic values compress well
Symbols/expiryDictionary or categoricalAvoids repeating strings
After daily ingestCompact small files nightlyPrevents small-file buildup
  1. Land each day's vendor files unchanged in an immutable raw/ folder.
  2. Convert to Parquet sorted by (symbol, ts_event_ns) with zstd.
  3. Write into trade_date= partitions, targeting 128MB to 1GB per file.
  4. Run a compaction job to merge any small files.
  5. Record a dataset version (date, row counts, checksums) for reproducibility.

Where should I host it, and what are the cost traps?

Keep the working copy on local NVMe, where scan speed is highest, and use object storage for sharing and backup. S3, Cloudflare R2 (no egress fees) and Backblaze B2 all work with DuckDB httpfs or Athena. Managed options include ClickHouse Cloud and MotherDuck, which trade control for less operations work.

Compare options with a simple model: monthly cost equals storage per TB-month plus egress per GB times data read, plus compute. Repeated full scans over a remote bucket can cost more than the storage itself, since every backtest rerun re-reads the data. Filter with partitions and columns, and cache hot slices on NVMe.

If several people share the bucket, an open table format such as Iceberg or Delta Lake gives versioning, schema evolution and safe appends. Plain Parquet folders are enough when one writer appends by date and readers tolerate a daily refresh.

Keep adjusted and unadjusted prices in separate datasets, and store corporate actions and symbol changes as their own tables so results are reproducible.

Warning: Vendor data licences often forbid redistribution. Before putting vendor files in a shared or public bucket, confirm that your licence allows sharing with each person who will access it.

Common questions

Is DuckDB good enough for backtesting large tick datasets?

Yes for most single-machine workloads at 0.5 to 1TB, since it scans partitioned Parquet with filter and column pushdown. It is designed for one process at a time writing, so use it for reading shared files rather than as a multi-writer server.

Can I query tick data stored in S3 or R2 with DuckDB?

Yes, DuckDB's httpfs extension reads Parquet directly from S3-compatible storage including R2. Keep files large and partition-pruned, because many small requests and repeated full scans are what make remote queries slow and costly.

When is ClickHouse better than Parquet files?

ClickHouse wins when several people run concurrent aggregation queries against the same data or you need a long-running service. For a solo developer replaying data in backtests, files with DuckDB or Polars are simpler and usually just as fast.

Do I need Iceberg or Delta Lake for tick data?

Not if a single process appends whole days and readers can wait for a daily refresh. Adopt one when multiple writers, schema changes or time-travel to earlier dataset versions become real requirements.

Related topics:tick dataparquetduckdbbacktestingclickhouse