QuestDB 9.4: Time-Series Partitions You Can Store as Parquet
QuestDB 9.4 added one line of DDL that is easy to skim past:
CREATE TABLE trades (ts TIMESTAMP, price DOUBLE, sym SYMBOL)
TIMESTAMP(ts) PARTITION BY DAY FORMAT PARQUET WAL;FORMAT PARQUET on the table, at creation time, on QuestDB 9.4.3 or later. Before this, getting Parquet out of QuestDB meant running ALTER TABLE ... CONVERT PARTITION ... TO PARQUET by hand on open source, or configuring a storage policy on Enterprise. Now the format is a property of the table, and every partition the table writes lands as Parquet without anyone scheduling a conversion job.
This post is mostly about why that matters, which means starting with what Parquet actually is. If you already know, skip to how the format behaves.
What Parquet is, concretely
Apache Parquet is a file format for tabular data. The design decision that defines it: values are stored by column, not by row.
A CSV file with three columns writes ts,price,sym then ts,price,sym again, interleaving types. Parquet takes the same table, splits it into chunks called row groups (a few tens of thousands to a few million rows each), and inside each row group writes all the timestamps together, then all the prices together, then all the symbols together.
Three things fall out of that layout, and all three are why analytics engines converged on it.
Compression gets much better. Compressors work on repetition, and a column of one type has far more of it than a row of mixed types. A sym column holding EURUSD a hundred thousand times in a row is dictionary-encoded down to a dictionary plus a list of small integers. A ts column that increments by a few milliseconds per row is delta-encoded, so you store the deltas, not the timestamps. Parquet picks encodings per column and then applies a general compressor on top.
You only read the columns you asked for. SELECT avg(price) FROM trades needs the price bytes and nothing else. In a row-oriented file the engine has to walk over every column to find the one it wants. In Parquet the price column is a contiguous byte range it can read directly.
The file knows things about itself. Parquet stores per-row-group statistics: the min and max of each column, null counts, the byte offset of every column chunk. A query filtering WHERE price > 1000 can look at a row group whose recorded max price is 42 and skip the entire group without decompressing a single value. That is called row-group pruning, and it is the difference between scanning a file and scanning a fraction of it.
The other reason Parquet matters is social rather than technical. It is the format that DuckDB, Polars, pandas, Spark, Trino, Snowflake, and roughly every data tool written in the last decade can read natively. A Parquet file is a lingua franca in a way that no engine's internal storage format ever is.
Why time-series partitions are the right shape for it
A QuestDB table with a designated timestamp and a time-based partition interval is already split by time. PARTITION BY DAY means today's rows live in one directory, yesterday's in another, and the query engine uses the designated timestamp to skip entire partitions before it reads anything. (PARTITION BY NONE exists and gets you none of this, which is also why it gets you no Parquet.)
That is almost exactly the granularity Parquet wants. A day of one table is a self-contained, immutable-once-finished, columnar chunk of data. It maps to a Parquet file with essentially no impedance mismatch, and the partition boundary gives you a natural moment to say "this range is done being written, write it in the dense format."
It also lines up with how time-series data actually gets used. Recent partitions get hammered with writes and point queries. Old partitions get scanned, aggregated, and exported. Those are different access patterns, and until 9.4 QuestDB gave you one storage format for both unless you wired up conversion yourself.
What FORMAT PARQUET actually does
The CREATE TABLE reference puts the clause after PARTITION BY:
CREATE TABLE trades (
ts TIMESTAMP,
sym SYMBOL,
price DOUBLE,
qty INT
) TIMESTAMP(ts) PARTITION BY DAY FORMAT PARQUET WAL;The constraints are worth reading before you reach for it:
- Partitioned WAL tables only.
FORMAT PARQUETis rejected on non-partitioned tables and onBYPASS WALtables. TheWALkeyword in the statement above is not optional decoration. NATIVEis still the default for every table that does not ask otherwise. Nothing about your existing tables changed on upgrade.ALTER TABLE ... SET FORMATdoes not convert what already exists. SettingFORMAT PARQUETon a live table applies to partitions written afterwards. Historical partitions stay in whatever format they were written in until you convert them explicitly.- Out-of-order writes cost more. A late row landing in a Parquet partition rewrites that partition, because you cannot splice a row into the middle of a compressed column chunk. If your ingest is genuinely out of order across old days, that is a real cost, not a rounding error.
UPDATEis not supported on Parquet partitions. Deduplicating upserts still work, because those are inserts.
Read those as a shape rather than a list of gotchas: Parquet is for partitions whose writing is basically finished. QuestDB's own guidance is that NATIVE stays the default for a reason.
The pruning sidecar, and why 9.4 was the release that could do this
QuestDB 9.4.0 shipped a piece of plumbing that makes table-level Parquet practical: a compact binary _pm metadata sidecar written next to each Parquet partition. It holds column descriptors, per-row-group byte ranges, encodings, and min/max statistics.
The point of the sidecar is that the planner can read pruning information without opening data.parquet at all. Parquet's own footer lives at the end of the file, so the naive path to "which row groups can I skip" is a seek and a decode of the file you were hoping to avoid touching. The sidecar hoists that into a small file the planner can slurp. Upgrading generates _pm files for existing Parquet partitions through a migration.
Then the patch releases on the 9.4 line filled in the query side:
- 9.4.1 hardened everything with a new SQL fuzzing engine that flushed out more than 60 latent bugs, and migrated
_pmfiles to a newer version at startup. - 9.4.2 fixed all-null Parquet column reads, crashes on malformed files, and a batch of latest-by, ASOF join, and count correctness bugs over Parquet partitions.
- 9.4.3 is the one that shipped
FORMAT PARQUETas a table-level clause, replaced the old fixed-slot Parquet decode cache with a memory budget (cairo.sql.parquet.cache.memory.size, default 256 MB per cursor), and rewrote the top-K path soORDER BY ... LIMIT Nover Parquet stops decoding row groups the limit was going to discard anyway.
That last one is the sort of optimization the columnar layout makes possible and row storage does not. Decoding a Parquet row group is real CPU work, so the win comes from proving which rows the LIMIT was going to throw away and never paying to decode them, rather than from reading less off disk.
Checking it on a running database
Here is the whole thing end to end against a QuestDB instance on Layerbase, over the Postgres wire protocol, which is how you talk SQL to QuestDB from psql, psycopg, pg, pgx, or a Grafana Postgres data source. QuestDB's own PGWire port is 8812; take the exact host, port, and credentials from the Quick Connect panel on your database rather than typing them from memory.
psql "postgresql://admin@<host>:<port>/qdb" # host and port from Quick ConnectConfirm what you are running:
SELECT build();Build Information: QuestDB 9.4.3, JDK 25, Commit Hash ...Create a Parquet-format table and put rows in it:
CREATE TABLE trades (
ts TIMESTAMP,
sym SYMBOL,
price DOUBLE,
qty INT
) TIMESTAMP(ts) PARTITION BY DAY FORMAT PARQUET WAL;
INSERT INTO trades VALUES
('2026-08-01T09:30:00.000000Z', 'EURUSD', 1.0842, 100000),
('2026-08-01T09:30:01.250000Z', 'EURUSD', 1.0843, 250000),
('2026-08-01T09:30:02.500000Z', 'GBPUSD', 1.2711, 50000),
('2026-08-02T09:30:00.000000Z', 'EURUSD', 1.0851, 175000);Ordinary inserts, ordinary SQL. Nothing about the client changes because the storage format did.
Now ask the table what its partitions look like. table_partitions() is the meta function that reports one row per partition:
SELECT name, numRows, isParquet, parquetFileSize
FROM table_partitions('trades');name | numRows | isParquet | parquetFileSize
-----------+---------+-----------+-----------------
2026-08-01 | 3 | true | 1712
2026-08-02 | 1 | true | 964isParquet is true and parquetFileSize is a real byte count rather than the -1 you get for a native partition. That is the confirmation you want: the rows you just inserted over a Postgres connection are sitting in Parquet files, and you queried them back with the same SQL you would use against any other QuestDB table.
Queries behave normally on top of that:
SELECT ts, sym, avg(price) AS px
FROM trades
WHERE sym = 'EURUSD'
SAMPLE BY 1d;Where the interop story starts and stops
The honest version, because this is where Parquet posts tend to overpromise.
What you get today is a storage format change. Your historical partitions are written in a format the entire analytics ecosystem understands, they can compress better than native depending on your column shapes and codec settings (measure on your own data before you budget for a number), and the query planner can prune at row-group granularity instead of partition granularity. Those are real wins and they are the ones QuestDB is claiming.
What table-level Parquet does not do by itself is hand you the files. On Layerbase the partition files live on the server, and the way you read them is QuestDB. We have not built a "download your Parquet partitions" path, and I would rather say that plainly than imply a DuckDB workflow that does not exist yet. QuestDB's own export routes (COPY ... TO with format parquet, and the REST export endpoint) are the supported way to get a Parquet file out of a QuestDB instance and into DuckDB or pandas, and 9.4.0 fixed encoding propagation on those paths so PARQUET_ENCODING(...) survives projected exports.
If your mental model is "the partitions are now a format I could hand to a data team without a conversion step," that is right. If it is "I can mount the volume from Spark," that is a different feature and it is not this one.
When to use it
Reach for FORMAT PARQUET when:
- Your table is append-mostly and roughly in timestamp order.
- The partitions you care about are historical: you scan and aggregate them far more than you write to them.
- Storage cost or scan cost on old data is something you actually notice.
Stay on NATIVE when:
- You run
UPDATEagainst the table. - Your ingest arrives meaningfully out of order across old partitions.
- The table is small enough that none of this is worth thinking about.
A reasonable middle path exists too, and it is the one most people will end up on: leave the table NATIVE, and convert partitions to Parquet once they age out of the write window.
Running 9.4 on Layerbase
QuestDB 9.4 is the default line for new databases on Layerbase Cloud as of now, and 9.2 stays supported for databases already running it. Nothing moves underneath an existing database: the version you created on is the version you keep until you decide otherwise.
QuestDB is one of our always-on Performance engines, so it does not sleep when idle and there is no JVM cold start in front of your first query of the morning. That puts it on the Pro plan: $15/month flat for the whole engine catalog, with no usage meters, so a heavy ingest month costs the same as a quiet one. First provision takes a minute or two because the binary is large, and after that it stays up.
If you want the wider context on where managed QuestDB stands in 2026, QuestDB Cloud is gone, here is where to run QuestDB now covers the options including the ones that are not us. For a hands-on ingest walkthrough, Getting started with QuestDB starts from an empty table. Engine details and connection specifics live on the QuestDB engine page.
The full 9.4.3 release notes and the Parquet concept docs have the rest, including the breaking changes to two-bound LIMIT semantics that landed in the same release.
Keep reading
- QuestDB Cloud Is Gone. Here's Where to Run QuestDB Now.QuestDB Cloud is discontinued and the official path is a sales-gated Enterprise/BYOC product. If you want managed QuestDB with a signup form instead of a sales call, here is the 2026 state of play.
- QuestDB Cloud is gone. Here is what to use instead.QuestDB retired its self-serve cloud for Enterprise BYOC. If you want a managed QuestDB instance without a sales call, here are the options that still exist.
- Getting Started with QuestDBBuild a time-series sensor pipeline with QuestDB and TypeScript, learn why SAMPLE BY beats verbose GROUP BY queries, and run it all in one script.
- Best Database for IoTA practical guide to picking the right database for IoT sensor data, covering QuestDB, InfluxDB, and PostgreSQL with SpinDB examples you can run locally.