Polars
Polars is a fast DataFrame library implemented in Rust with a Python API. It processes large datasets efficiently and is well suited to time-series work. Since QuestDB 10.0 the Python and Rust clients both speak Polars natively over QWP, in both directions.
QWP results arrive as Arrow record batches, which is Polars' own memory layout, so a query becomes a DataFrame with no row-by-row conversion. Ingestion sends the frame column by column over the same connection pool. No ConnectorX or PGWire driver is involved.
Prerequisites
- QuestDB 10.0 or later, running and accessible. See the quick start.
- For Python: version 3.10 or later, plus the client and Polars.
- For Rust:
questdb-rswith the Polars crate features enabled.
python3 -m pip install -U questdb polars pyarrow
Python
Query into a DataFrame
db.query() streams Arrow batches from the server. to_polars() materializes
the whole result:
import questdb
with questdb.connect("ws::addr=localhost:9000;") as db:
with db.query(
"SELECT timestamp, symbol, price, amount FROM trades "
"WHERE timestamp IN '$now-1h..$now'"
) as result:
df = result.to_polars()
print(df.head())
Bind values with $1..$N placeholders rather than interpolating them into
the SQL string:
df = db.query(
"SELECT * FROM trades WHERE symbol = $1 AND price > $2",
["ETH-USDT", 2615.0],
).to_polars()
Use to_polars() by default. It requires pyarrow. If you need a
pyarrow-free installation, pass the result to pl.DataFrame instead:
import polars as pl
with db.query("SELECT * FROM trades LIMIT 1000") as result:
df = pl.DataFrame(result)
This still materializes the complete result and can be slower for
SYMBOL-heavy queries.
Stream large results
For results that do not fit comfortably in memory, iterate batch by batch with
iter_polars():
import questdb
with questdb.connect("ws::addr=localhost:9000;") as db:
with db.query("SELECT price, amount FROM trades") as result:
notional = sum(
(chunk["price"] * chunk["amount"]).sum()
for chunk in result.iter_polars()
)
print(notional)
A result is single-use and must stay on the thread that created it. Use a
with block, or call close(), so the connection returns to the pool.
Ingest a DataFrame
db.dataframe() accepts Polars DataFrame and LazyFrame alongside pandas
and pyarrow inputs. Each call publishes the frame in batches and blocks until
the server acknowledges the last one:
import polars as pl
import questdb
df = pl.DataFrame({
"symbol": ["ETH-USDT", "BTC-USDT"],
"price": [2615.54, 65432.10],
"amount": [0.00044, 0.00120],
"timestamp": [1735689600000000000, 1735689601000000000],
}).with_columns(pl.col("timestamp").cast(pl.Datetime("ns", "UTC")))
with questdb.connect("ws::addr=localhost:9000;") as db:
db.dataframe(df, table_name="trades", symbols=["symbol"], at="timestamp")
symbols takes a list of column names to store as SYMBOL, and at names
the designated timestamp column. See
DataFrame ingestion for
batching, null handling, and the full parameter set.
Rust
Crate features
Polars support is gated behind optional crate features:
[dependencies]
questdb-rs = { version = "7", features = ["polars"] }
The polars feature enables both polars-ingress and polars-egress. Enable
only the one you need if your application goes in a single direction. See
crate features for the full
list.
Query into a DataFrame
Borrow a reader, prepare the SQL, bind values, and collect the cursor into a frame:
use questdb::QuestDb;
fn main() -> Result<(), Box<dyn std::error::Error>> {
let db = QuestDb::connect("ws::addr=localhost:9000;")?;
let mut reader = db.borrow_reader()?;
let dataframe = reader
.prepare("SELECT timestamp, symbol, price FROM trades WHERE price > $1")
.bind_f64(2615.0)
.execute()?
.fetch_all_polars()?;
println!("{} rows", dataframe.height());
Ok(())
}
fetch_all_polars() materializes the complete result. For large results,
prefer next_polars() or iter_polars(), which yield one frame per batch.
The Cursor returned by execute() borrows the reader, so keep the whole
chain in one statement as above. Splitting it across a block that also owns
the reader fails to compile.
The client depends on polars with default features off, and only within a
version range. To use the returned frame in your own code, add polars as a
direct dependency at a version inside that range, otherwise its DataFrame is
a different type. Check the range in the client's Cargo.toml. Formatting a
frame with {} also needs one of the polars fmt features, which the client
does not enable.
Ingest a DataFrame
flush_polars_dataframe() borrows a direct sender from the pool, publishes a
commit boundary, waits for the requested ACK, and returns the connection:
use questdb::ingress::{
column_sender::ArrowColumnOverride,
polars::PolarsIngestOptions,
AckLevel,
ColumnName,
};
let overrides: [ArrowColumnOverride<'_>; 0] = [];
let options = PolarsIngestOptions::new()
.max_rows(50_000)
.timestamp_column(ColumnName::new("timestamp")?)
.overrides(&overrides)
.ack_level(AckLevel::Ok);
db.flush_polars_dataframe("trades", &dataframe, &options)?;
Omitting timestamp_column asks the server to assign timestamps, and
max_rows(0) uses the default batch size. The call checkpoints the frame and
automatically retries the uncommitted tail after a transient failover. Replay
is at-least-once, so use deduplication when
duplicates would be harmful. See
Arrow and Polars ingestion
for the Arrow equivalents.
Legacy: ConnectorX over PGWire
Since QuestDB 10.0 the recommended way to move data between Polars and QuestDB is the native client support shown above. ConnectorX is documented here for legacy reasons only: it goes through PGWire, it reads but cannot ingest, and it needs a workaround to avoid PostgreSQL features QuestDB does not implement.
ConnectorX is a Rust
library for fast data transfer between Python and various databases. Its
PostgreSQL connector works against QuestDB's PGWire endpoint, which lets
pl.read_database_uri() read a query into a Polars DataFrame.
pip install polars pyarrow connectorx
import polars as pl
QUESTDB_URI = "redshift://admin:quest@localhost:8812/qdb"
QUERY = "SELECT * FROM tables() LIMIT 5;"
df = pl.read_database_uri(query=QUERY, uri=QUESTDB_URI)
print("Received DataFrame:")
print(df)
The URI uses the redshift scheme, not postgresql. By default the
PostgreSQL connector uses features QuestDB does not support; the Redshift
scheme makes ConnectorX avoid them.