5.0 Migration Guide

Connect once, then stream, load, or query

The QWP/WebSocket API has one connection-owning root, the QuestDB handle returned by questdb.connect():

  • QuestDB.sender lends a pooled row-building sender — a lean lease exposing row(), dataframe(), flush(), wait(), the FSN delivery receipts (flush_and_get_fsn / await_acked_fsn), rejection polling (poll_error) and close(), not a Sender instance (isinstance checks and type annotations must not assume the Sender class — use the exported questdb.PooledSender instead);

  • QuestDB.dataframe bulk-loads a whole DataFrame through the direct columnar path;

  • QuestDB.query executes queries; and

  • QuestDB.reader lends a pooled questdb.PooledReader for running several queries on one connection.

Existing row code can move from a standalone Sender to a pooled one:

import questdb
from questdb import Sender, ServerTimestamp

# Before: a standalone multi-transport sender.
with Sender.from_conf('ws::addr=localhost:9000;') as sender:
    sender.row('weather', columns={'temperature': 21.5},
               at=ServerTimestamp)
    sender.flush()

# After: the handle owns the pool and lends a row builder.
with questdb.connect('ws::addr=localhost:9000;') as db:
    with db.sender() as sender:
        sender.row('weather', columns={'temperature': 21.5},
                   at=ServerTimestamp)
        sender.flush(wait=True)

The pooled sender publishes into the store-and-forward QWP path. A plain flush() returns after local acceptance; flush(wait=True) also waits for the server’s OK acknowledgement of everything published through the lease. The wait is a pure ack barrier — only a terminal connection failure raises. Server rejections are pushed to the pool’s error_handler (a questdb.connect() keyword; one questdb.SenderError per rejection, on a dedicated dispatcher thread). Without a handler every rejection is logged through the questdb logger — ERROR for terminal rejections, WARNING for retriable ones, which the queue replays — so rejections are never silent. To ingest concurrently, borrow one sender per thread.

DataFrame bulk loads over QWP/WebSocket

Over ws:: / wss::, DataFrame bulk loads use the direct columnar path — a database operation, not stream serialization. The recommended entry point is QuestDB.dataframe on the handle; the sender variants exist as conveniences and share the same path:

import pandas as pd
import questdb
from questdb import Sender, ServerTimestamp

frame = pd.DataFrame({
    't': [21.5, 20.9],
    'ts': pd.to_datetime(['2026-01-01T00:00:00Z', '2026-01-01T00:00:01Z'])})

with questdb.connect('ws::addr=localhost:9000;') as db:
    # Recommended — on the handle:
    db.dataframe(frame, table_name='weather', at='ts')

    # Convenience: on a pooled sender — same path, direct connection
    # from the pool; NOT part of the sender's row stream:
    with db.sender() as sender:
        sender.row('weather', columns={'t': 21.5}, at=ServerTimestamp)
        sender.dataframe(frame, table_name='weather', at='ts')
        sender.flush()

# Convenience: on a standalone sender — same path, poolless direct
# connection:
with Sender.from_conf('ws::addr=localhost:9000;') as sender:
    sender.dataframe(frame, table_name='weather', at='ts')

All commit the whole source before returning, are independent of sf_dir, and re-send from your DataFrame on transient failures (raising with in_doubt set when a blind re-send could duplicate rows); none converts the dataframe into row calls. dataframe() opens a direct connection just for that call (borrowed from the pool for a pooled sender, opened from the sender’s own configuration — carrying its auth/TLS — for a standalone one) and commits immediately — it has no ordering relationship with rows buffered on a sender via row() and does not flush them; publish those with flush(). This works for any standalone ws sender, whether built via Sender.from_conf / Sender.from_env or the Sender(...) constructor.

Over ILP/HTTP, ILP/TCP and QWP/UDP, sender.dataframe() is unchanged and fully supported (over UDP it serializes row by row into fire-and-forget datagrams, with the same delivery caveats as row()).

Update imports from questdb.ingress

The 4.x questdb.ingress module is now a deprecated compatibility shim. It keeps ILP/HTTP and ILP/TCP code running — including IngressError / IngressErrorCode aliases and explicit Buffer construction with sender.flush(buffer) — and emits one DeprecationWarning at import. New code imports from the top-level package:

# Before
from questdb.ingress import Sender, IngressError

# After
from questdb import Sender, QuestDBError

Buffer is not part of the top-level API: buffers are managed internally by senders. Where 4.x code built buffers on worker threads and flushed them through one sender, borrow one pooled sender per thread instead.

Behavioural changes to watch for

  • Broader except IngressError. IngressError is an alias of the unified QuestDBError, so handlers written for 4.x ingestion errors now also catch query, pool and egress failures raised through the same handle. Narrow by err.code where that matters.

  • BadDataFrame.value changed. The BadDataFrame error code’s numeric .value moved from 14 to 0x10000; raw value 14 now belongs to ServerRejection. Codes 0–13 are unchanged. Compare by member identity (err.code is QuestDBErrorCode.BadDataFrame), never by integer.

  • Stricter timestamp handling. Pre-epoch datetime scalars now floor toward negative infinity instead of truncating toward zero (4.x encoded fractional-second pre-epoch instants one second too high); TimestampNanos.from_datetime raises ValueError for datetimes outside the 64-bit nanosecond range instead of silently overflowing; and negative sub-second datetime.timedelta durations (e.g. timedelta(milliseconds=-500)) raise ValueError instead of being mis-read as positive.

  • Naive datetimes are UTC. Every path — DataFrame column cells, scalar at=, row() values, from_datetime, query binds — interprets naive (timezone-unaware) values as UTC. 4.x interpreted naive scalars in the process-local timezone; if your values were local wall-clock times, convert with dt.astimezone(); if they were already UTC, use dt.replace(tzinfo=timezone.utc) (or just silence the one-time UserWarning). datetime.now() callers should switch to TimestampNanos.now() or datetime.now(timezone.utc).