For AI agents: the complete documentation index is at llms.txt. Every page is also available as markdown by appending .md to its URL, or by sending an Accept: text/markdown request header.

Pandas

Pandas is a fast, powerful, flexible, and easy-to-use open-source data analysis and manipulation tool built on top of Python. Since QuestDB 10.0 the QuestDB Python client covers both directions natively over QWP: db.dataframe() ingests a whole frame column by column, and query results stream back as Arrow batches that materialize into a DataFrame with to_pandas().

Both paths are columnar end to end, so there is no SQLAlchemy, ODBC, or ConnectorX layer to install and no row-by-row conversion in the middle.

Prerequisites​

  • QuestDB 10.0 or later, running and accessible. See the quick start.
  • Python 3.10 or later.
  • The client and pandas:
python3 -m pip install -U questdb pandas

pyarrow is optional. to_pandas() and iter_pandas() work without it, and it is only needed if you also want to_arrow() or pyarrow-backed dtypes.

Query into a DataFrame​

db.query() returns a result that streams Arrow record batches from the server. Call to_pandas() to materialize 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_pandas()

print(df.head())

Bind values with $1..$N placeholders instead of interpolating them into the SQL string:

df = db.query(
"SELECT * FROM trades WHERE symbol = $1 AND price > $2",
["ETH-USDT", 2615.0],
).to_pandas()

Stream large results​

to_pandas() holds the complete result in memory. For results that do not fit comfortably, iterate batch by batch with iter_pandas() and reduce as you go:

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_pandas()
)

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.

Types and nulls​

SYMBOL columns arrive as a Categorical sharing one dictionary across batches, which keeps them compact. INT and LONG become plain int32 / int64 when the column has no nulls and nullable Int32 / Int64 with pd.NA when it does. QuestDB's sentinel values, such as NaN doubles and INT64_MIN longs, are decoded as nulls rather than leaking as magic numbers.

to_pandas(dtype_backend="pyarrow") selects pyarrow-backed dtypes instead, matching the pd.read_sql convention. See result types and nulls for the full mapping.

Ingest a DataFrame​

db.dataframe() publishes the frame in batches and blocks until the server acknowledges the last one:

import pandas as pd
import questdb

df = pd.DataFrame({
"symbol": pd.Categorical(["ETH-USDT", "BTC-USDT"]),
"price": [2615.54, 65432.10],
"amount": [0.00044, 0.00120],
"timestamp": pd.to_datetime([
"2025-01-01T00:00:00Z",
"2025-01-01T00:00:01Z",
]),
})

with questdb.connect("ws::addr=localhost:9000;") as db:
db.dataframe(df, table_name="trades", symbols=["symbol"], at="timestamp")

symbols defaults to "auto", which maps categorical columns to SYMBOL; pass a list of column names to be explicit. at names the designated timestamp column, or takes a fixed timestamp shared by every row, or questdb.ServerTimestamp to let the server assign one.

note

Naive timestamps, both DataFrame columns and a scalar at, are interpreted as UTC, matching the numpy datetime64 convention. Prefer timezone-aware values throughout.

Columns of float64 numpy arrays become DOUBLE[], and None, NaN, and pd.NA are stored as SQL nulls. A frame the columnar path cannot express raises UnsupportedDataFrameShapeError listing the offending columns. See DataFrame ingestion for batching, retries, and the full parameter set.

Legacy: ILP DataFrame ingestion​

warning

Since QuestDB 10.0 the recommended way to move data between pandas and QuestDB is the native client shown above. The API below is documented for legacy reasons: it encodes the frame as InfluxDB Line Protocol text and only covers ingestion. Use it for existing code, or when talking to a server older than 10.0.

The 4.x-style standalone Sender ships in the same questdb package and implements dataframe() on top of ILP:

import sys

import pandas as pd
from questdb import Sender, QuestDBError


def example(conf: str = "http::addr=localhost:9000;"):
df = pd.DataFrame({
"symbol": ["ETH-USDT", "BTC-USDT"],
"price": [2615.54, 65432.10],
"amount": [0.00044, 0.00120],
"timestamp": [
pd.Timestamp("2025-01-01 00:00:00", tz="UTC"),
pd.Timestamp("2025-01-01 00:00:01", tz="UTC"),
],
})
try:
with Sender.from_conf(conf) as sender:
sender.dataframe(
df,
table_name="trades", # Table name to insert into.
symbols=["symbol"], # Columns to insert as SYMBOL.
at="timestamp") # Designated timestamp column.

except QuestDBError as e:
sys.stderr.write(f"Got error: {e}\n")


if __name__ == "__main__":
example()

The questdb.ingress import path still works as a deprecated alias module, where IngressError remains an alias of QuestDBError. Importing from it raises a DeprecationWarning, so prefer questdb directly as shown above. The legacy API is documented on ReadTheDocs, and the 5.0 migration guide maps each 4.x call to its pooled equivalent.

See also​