Java client for QuestDB

note

This is the reference for the QuestDB Java client when QuestDB is used as a server. For embedded QuestDB, see the Java embedded guide.

The QuestDB Java client connects to QuestDB over QWP — QuestDB Wire Protocol — a columnar binary protocol carried over WebSocket. It supports high-throughput data ingestion and streaming SQL queries on the same transport.

The recommended entry point is the QuestDB handle: a shared, thread-safe facade that owns connection pools for both ingress and egress and exposes a fluent query API on top of them.

Key capabilities:

  • One handle, both directions: QuestDB.connect(...) configures ingress and egress from a single ws/wss connect string; db.borrowSender() for ingestion, db.query() / db.executeSql(...) for SQL.
  • Pooled connections: elastic sender and query pools that close idle connections, thread-affine senders, and zero-allocation borrow/submit paths at steady state.
  • Ingestion: column-oriented batched writes with automatic table creation, schema evolution, and optional store-and-forward durability.
  • Querying: streaming SQL result sets, DDL/DML execution, bind parameters, async Completion handles, and byte-credit flow control.
  • Failover: multi-endpoint connections with automatic reconnect across rolling upgrades and primary migrations.
Legacy transports

The client also supports ILP ingestion over HTTP and TCP for backward compatibility. This page documents the recommended WebSocket (QWP) path. For ILP transport details, see the ILP overview.

Quick start

Add the dependency:

<dependency>
<groupId>org.questdb</groupId>
<artifactId>questdb-client</artifactId>
<version>1.3.4</version>
</dependency>

Construct one QuestDB per deployment, share it across threads, close it at shutdown:

import io.questdb.client.Completion;
import io.questdb.client.QuestDB;
import io.questdb.client.QueryException;
import io.questdb.client.Sender;
import io.questdb.client.cutlass.qwp.client.QwpColumnBatch;
import io.questdb.client.cutlass.qwp.client.QwpColumnBatchHandler;

try (QuestDB db = QuestDB.connect("ws::addr=localhost:9000;")) {

// Ingest -- borrow a Sender, write rows, close() flushes and returns
// the Sender to the pool. Real disconnect only happens at db.close().
try (Sender sender = db.borrowSender()) {
sender.table("trades")
.symbol("symbol", "ETH-USD")
.symbol("side", "sell")
.doubleColumn("price", 2615.54)
.doubleColumn("amount", 0.00044)
.atNow();
sender.table("trades")
.symbol("symbol", "BTC-USD")
.symbol("side", "sell")
.doubleColumn("price", 39269.98)
.doubleColumn("amount", 0.001)
.atNow();
}

// Query -- one-shot SELECT, blocking await on the Completion.
Completion c = db.executeSql(
"SELECT ts, symbol, price, amount FROM trades "
+ "WHERE symbol = 'ETH-USD' LIMIT 10",
new QwpColumnBatchHandler() {
@Override
public void onBatch(QwpColumnBatch batch) {
batch.forEachRow(row -> System.out.printf(
"ts=%d symbol=%s price=%.4f amount=%.5f%n",
row.getLongValue(0),
row.getSymbol(1),
row.getDoubleValue(2),
row.getDoubleValue(3)));
}

@Override
public void onEnd(long totalRows) {
System.out.println("done: " + totalRows + " rows");
}

@Override
public void onError(byte status, String message) {
System.err.printf("error: 0x%02X %s%n", status & 0xFF, message);
}
});
try {
c.await();
} catch (QueryException e) {
// Server reported an error (parse failure, schema mismatch, etc.).
System.err.printf("query failed: status=0x%02X %s%n",
e.getStatus() & 0xFF, e.getMessage());
}
}

The QuestDB handle is a facade over two distinct kinds of client: a Sender for ingestion (db.borrowSender() / db.sender()) and a query client for SQL (db.query() / db.executeSql(...)). You acquire both from the same handle, and both speak QWP, so a single ws or wss connect string passed to QuestDB.connect(...) configures both. Each client reads the connect-string keys it owns and ignores the rest. Use QuestDB.connect(ingestCfg, queryCfg) or the builder when ingestion and queries need different addresses or settings.

The QuestDB handle

QuestDB is a Closeable handle for a QuestDB deployment: you create one, share it across your application's threads, and close it at shutdown. It owns a pool of each client type — Senders for ingestion and query clients for SQL — plus a background housekeeper thread that closes idle and over-age connections.

MethodReturnsPurpose
connect(String)QuestDBStatic factory. Single connect string for both ingress and egress (schema must be ws/wss).
connect(String, String)QuestDBStatic factory. Explicit ingress + egress connect strings.
builder()QuestDBBuilderPool sizes, timeouts, separate configs.
borrowSender()SenderLease a sender from the pool; close() flushes and returns it.
sender()SenderThread-affine sender: pinned to the calling thread on first use, reused on subsequent calls.
releaseSender()voidRelease the thread-affine sender for the calling thread.
query()QueryPer-thread cached fluent query builder (allocation-free).
newQuery()QueryFresh Query instance — use when one thread holds multiple in-flight queries.
executeSql(sql, handler)CompletionOne-shot shortcut, equivalent to query().sql(sql).handler(handler).submit().
close()voidShut down pools and disconnect every underlying client. Idempotent.

A QuestDB instance is safe to share across threads. Borrows, query submissions, and the per-thread caches all serialize through the pool internally.

Single connect string

try (QuestDB db = QuestDB.connect("ws::addr=localhost:9000;")) {
// ...
}

The schema must be ws or wss. QuestDB ingests and queries over QWP, so the pooled facade is WebSocket-only and rejects http/https/tcp and the other legacy ILP schemas. (The low-level Sender speaks those transports; only the QuestDB facade is QWP-only.)

The string is handed to both the ingress sender and the egress query client verbatim. Each direction reads the keys it owns and ignores keys meant only for the other, so ingress-only and egress-only options coexist in one string:

try (QuestDB db = QuestDB.connect(
"wss::addr=db.example.com:9000;"
+ "token=YOUR_TOKEN;" // common: authenticates both directions
+ "auto_flush_rows=5000;" // ingress-only: the query client ignores it
+ "compression=zstd;")) { // egress-only: the sender ignores it
// ...
}

addr, the credentials (username/password or token), and the tls_* keys are common — they apply to both directions. token is sent as an Authorization: Bearer header on both the ingress and egress WebSocket upgrades, and username/password configure HTTP basic auth on both.

An unrecognized key fails fast at connect() with unknown configuration key: <key>; a legacy ILP key (such as init_buf_size or retry_timeout) is rejected with a hint pointing to the right place. For the full key list, see the connect string reference.

Separate ingress and egress configs

When ingress and egress endpoints differ — typical when reads target a read-replica or when ingest goes through a different load balancer — pass explicit strings:

try (QuestDB db = QuestDB.connect(
"ws::addr=ingest.cluster:9000;",
"wss::addr=read-replica.cluster:9000;token=YOUR_TOKEN;")) {
// ...
}

Both strings must use the ws/wss schema. The first follows Sender.fromConfig format; the second follows QwpQueryClient.fromConfig format. Because the two sides share one key vocabulary, each string also accepts keys owned by the other direction; the split just lets ingress and egress point at different hosts or carry different tuning.

Builder API

For pool tuning, separate configs, or any of the housekeeping knobs:

try (QuestDB db = QuestDB.builder()
.ingestConfig("ws::addr=ingest.cluster:9000;")
.queryConfig("ws::addr=read-replica.cluster:9000;")
.senderPoolSize(8) // fixed size, opened up front
.queryPoolMin(2).queryPoolMax(16) // elastic
.acquireTimeoutMillis(10_000)
.idleTimeoutMillis(60_000)
.maxLifetimeMillis(30 * 60_000L)
.build()) {
// ...
}

Builder methods:

MethodDefaultPurpose
fromConfig(String)One ws/wss string for both sides; honors pool keys in the string.
ingestConfig(String)Ingress-side ws/wss config.
queryConfig(String)Egress-side ws/wss config.
senderPoolMin(int)1Senders kept open even when idle. 0 lets the pool close them all.
senderPoolMax(int)4Maximum senders the pool opens.
senderPoolSize(int)Shortcut: fixed min = max = size, all opened up front.
queryPoolMin(int)1Query clients kept open even when idle. 0 lets the pool close them all.
queryPoolMax(int)4Maximum query clients the pool opens.
queryPoolSize(int)Shortcut: fixed min = max = size.
acquireTimeoutMillis(long)5000How long borrowSender() / Query.submit() wait for a free connection when the pool is at max, before throwing.
idleTimeoutMillis(long)60000How long an unused connection stays open before the housekeeper closes it (never below min). 0 ⇒ keep idle connections forever.
maxLifetimeMillis(long)1800000Maximum age of a connection; the housekeeper closes and reopens older ones once idle. 0 ⇒ no age limit.
housekeeperIntervalMillis(long)5000How often the housekeeper checks for idle and over-age connections. Minimum 100ms.

Supply the connection config with either fromConfig(...) or with both ingestConfig(...) and queryConfig(...); build() throws if either side is left unset. The config strings combine last-write-wins, so an ingestConfig(...) or queryConfig(...) call after fromConfig(...) overrides that one side.

Environment variable

Set QDB_CLIENT_CONF and use QuestDB.connect with the same string read from the environment:

export QDB_CLIENT_CONF="wss::addr=db.example.com:9000;token=YOUR_TOKEN;"
String cfg = System.getenv("QDB_CLIENT_CONF");
try (QuestDB db = QuestDB.connect(cfg)) {
// ...
}

Connect-string pool keys

The pool-tuning options above can also live in the connect string itself. They belong to the facade: the Sender and QwpQueryClient parsers accept and ignore them, while the facade reads them off the string.

KeyBuilder equivalent
sender_pool_minsenderPoolMin(int)
sender_pool_maxsenderPoolMax(int)
query_pool_minqueryPoolMin(int)
query_pool_maxqueryPoolMax(int)
acquire_timeout_msacquireTimeoutMillis(long)
idle_timeout_msidleTimeoutMillis(long)
max_lifetime_msmaxLifetimeMillis(long)
housekeeper_interval_mshousekeeperIntervalMillis(long)

An explicit builder setter always wins over the same key in the string, regardless of call order. When you pass separate ingress and egress strings that both carry the same pool key with different values, build() fails — set it on one side only, or use the builder setter.

Authentication and TLS

QWP runs over WebSocket, so authentication uses HTTP-style credentials sent on the WebSocket upgrade request — for both ingress and egress, before any data is exchanged. The credential and TLS keys (token, username/password, tls_*) are common to both directions, so a single QuestDB.connect(...) string authenticates both.

try (QuestDB db = QuestDB.connect(
"wss::addr=db.example.com:9000;token=YOUR_BEARER_TOKEN;")) {
// ...
}

The token is sent as an Authorization: Bearer YOUR_BEARER_TOKEN header on both the ingress and egress WebSocket upgrades. It is mutually exclusive with username/password.

HTTP basic auth

try (QuestDB db = QuestDB.connect(
"wss::addr=db.example.com:9000;username=admin;password=quest;")) {
// ...
}

username/password are common keys, so this authenticates both the ingress and egress upgrades. Both halves must be present together, and they are mutually exclusive with token.

TLS with custom trust store

try (QuestDB db = QuestDB.connect(
"wss::addr=db.example.com:9000;"
+ "tls_roots=/path/to/truststore.jks;"
+ "tls_roots_password=changeit;"
+ "token=YOUR_BEARER_TOKEN;")) {
// ...
}

For development, tls_verify=unsafe_off disables certificate validation. Never use this in production.

The connection pool

Both pools are elastic. Each holds live connections — pooled senders in one, query clients in the other. A pool keeps at least min connections open and ready, opens more on demand up to max, and a background housekeeper thread closes connections left idle too long, down to min.

Borrowed vs thread-affine senders

// Borrowed: lease per use. close() flushes and returns to the pool.
try (Sender sender = db.borrowSender()) {
sender.table("trades").doubleColumn("price", 42.0).atNow();
}

// Thread-affine: first call pins one Sender to this thread. Subsequent
// calls on the same thread return the same instance with zero overhead.
Sender sender = db.sender();
for (int i = 0; i < 1_000_000; i++) {
sender.table("trades").doubleColumn("price", 42.0 + i).atNow();
}
sender.flush();

Pick db.sender() for long-lived dedicated producer threads where borrow/return overhead would dominate. Pick db.borrowSender() for short-lived or event-loop callers.

If your producer thread is borrowed from a foreign pool (Netty event loop, servlet container, etc.) and may be recycled to handle unrelated work, call db.releaseSender() before handing it back, otherwise it stays pinned for the rest of the thread's life.

Pooled Sender close semantics

Sender.close() on a borrowed sender flushes pending rows and returns it to the pool for reuse — it does not disconnect the underlying WebSocket. A real disconnect only happens at QuestDB.close() (or when the housekeeper closes an idle connection).

Per-thread Query cache

db.query() returns the same Query instance on every call from the same thread, reset to empty if it was in a terminal state. The associated Completion is a field on that instance, so the steady-state submit path is allocation-free.

For multiple in-flight queries from one thread, call db.newQuery() — each call allocates a fresh Query. The query pool's max caps overall concurrency (one query client per in-flight query).

Acquire timeout

Both db.borrowSender() and Query.submit() take a connection from a pool — a sender from the sender pool, a query client from the query pool. When every connection is in use and the pool has already grown to max, the call blocks, waiting for another caller to return one. It proceeds the moment a connection frees up, or, if the timeout (default 5s) elapses first, gives up and throws: LineSenderException from borrowSender(), QueryException from Query.submit(). Raise acquireTimeoutMillis to ride out longer bursts, or raise max to allow more concurrency.

Data ingestion

Once you have a Sender from the pool, the row-building API is identical across all transports.

General usage pattern

Sender is not thread-safe. The pool's contract is that a borrowed sender is owned by the borrower until it's returned (via close() or releaseSender()).

  1. Borrow a sender (db.borrowSender() or db.sender()).

  2. Call table(name) to select a table.

  3. Call column methods to add values:

    • symbol(name, value)
    • stringColumn(name, value)
    • boolColumn(name, value)
    • byteColumn(name, byte), shortColumn(name, short), intColumn(name, int)
    • longColumn(name, long), floatColumn(name, float), doubleColumn(name, double)
    • charColumn(name, char)
    • timestampColumn(name, Instant) or timestampColumn(name, long, ChronoUnit)
    • uuidColumn(name, lo, hi) (two longs)
    • long256Column(name, l0, l1, l2, l3) (four longs, least significant first)
    • decimalColumn(name, Decimal256) or decimalColumn(name, CharSequence)
    • ipv4Column(name, int) (packed 32-bit address) or ipv4Column(name, CharSequence) (dotted-quad)
    • geoHashColumn(name, long bits, int precisionBits) or geoHashColumn(name, CharSequence base32)
    • binaryColumn(name, byte[]), binaryColumn(name, long ptr, long len), or binaryColumn(name, DirectByteSlice)
    • doubleArray(name, ...) and longArray(name, ...) (see Ingest arrays)

    DATE is accepted on ingress server-side but the Java client does not yet expose a dateColumn() setter. All types are readable on the egress side.

    To store a null for a column, omit that column's setter before calling at() or atNow(). The column set for the batch is the union of all columns seen across rows; a column first used on a later row is backfilled with null for earlier rows.

    IPv4 string input is strict

    ipv4Column(name, CharSequence) rejects the literal strings "null" (case-insensitive) and "0.0.0.0" with a LineSenderException. Passing a null reference is a no-op (the column is left unset, which surfaces as SQL NULL on read).

    GEOHASH precision is locked per column

    The first call to geoHashColumn for a column fixes its precision (number of bits). Subsequent rows must use the same precision or the call throws LineSenderException. For the string overload, precision is value.length() * 5 bits; for the bits overload, it is the explicit precisionBits argument (1..60).

  4. Call at(Instant), at(long, ChronoUnit), or atNow() to finalize the row.

  5. Repeat from step 2, or call flush() to send buffered data.

  6. Release the sender:

    • For db.borrowSender(), close the sender (try-with-resources). The pool flushes pending rows before the sender returns for reuse.
    • For db.sender(), leave it pinned across calls and flush() between batches; release only on shutdown or thread recycling.
try (Sender sender = db.borrowSender()) {
sender.table("trades")
.symbol("symbol", "EURUSD")
.symbol("side", "buy")
.doubleColumn("price", 1.0842)
.doubleColumn("amount", 100_000.0)
.at(Instant.now());
}

Tables and columns are created automatically if they do not exist.

Ingest arrays

For 1D and 2D arrays, pass a Java array directly:

double[] prices = {1.0842, 1.0843, 1.0841};
sender.table("book").doubleArray("levels", prices).atNow();

For higher-dimensional arrays, use the DoubleArray class to avoid GC overhead. Create the instance once and reuse it across rows by calling clear() before populating each row:

import io.questdb.client.cutlass.line.array.DoubleArray;

try (Sender sender = db.borrowSender();
DoubleArray ary = new DoubleArray(3, 3, 3)) {
for (int i = 0; i < ROW_COUNT; i++) {
ary.clear(); // reset write position, reuse native memory
for (int v = 0; v < 27; v++) {
ary.append(v);
}
sender.table("book")
.doubleArray("cube", ary)
.at(getTimestamp(), ChronoUnit.MICROS);
}
}

The constructor new DoubleArray(d1, d2, ...) defines the shape. Values are appended in row-major order: the last dimension varies fastest. For a 2D array with shape (3, 2), append() fills positions [0,0], [0,1], [1,0], [1,1], [2,0], [2,1]. You can also use set(value, i, j, ...) to write at specific coordinates. Call reshape(d1, d2, ...) to change the shape without reallocating.

LongArray works the same way for 64-bit integer arrays — pass a Java long[], long[][], or long[][][] directly, or use the reusable LongArray class for higher dimensions:

import io.questdb.client.cutlass.line.array.LongArray;

try (LongArray counts = new LongArray(3, 3, 3)) {
counts.clear();
for (int v = 0; v < 27; v++) {
counts.append(v);
}
sender.table("book").longArray("counts", counts).atNow();
}

Designated timestamp

The designated timestamp column controls time-based partitioning and ordering. There are two ways to set it:

User-assigned (recommended for deduplication and exactly-once delivery):

sender.table("trades")
.symbol("symbol", "EURUSD")
.doubleColumn("price", 1.0842)
.at(Instant.now());

// Explicit microseconds for high-throughput paths:
sender.table("trades")
.symbol("symbol", "EURUSD")
.doubleColumn("price", 1.0842)
.at(System.currentTimeMillis() * 1000, ChronoUnit.MICROS);

// Nanosecond precision (creates a timestamp_ns column):
sender.table("ticks")
.symbol("symbol", "EURUSD")
.doubleColumn("price", 1.0842)
.at(System.nanoTime(), ChronoUnit.NANOS);

Using ChronoUnit.NANOS with at() or timestampColumn() creates a timestamp_ns column. Using any other unit creates a standard TIMESTAMP column (microsecond precision).

Server-assigned (server uses its wall-clock time):

sender.table("trades")
.symbol("symbol", "EURUSD")
.doubleColumn("price", 1.0842)
.atNow();
note

QuestDB works best when data arrives in chronological order (sorted by timestamp).

Decimal columns

Create decimal columns ahead of time with DECIMAL(precision, scale) so QuestDB ingests values with the expected precision. See the decimal data type page for details.

Flushing

Rows you build accumulate in a buffer. flush() hands the batch to a background send engine: a buffer of unacknowledged rows plus a thread that delivers them to the server, waits for acknowledgements, and reconnects on failure. The engine holds every unacknowledged row until the server acks it, and replays the held rows after a reconnect.

flush() returns once the rows are handed to the engine. The server's acknowledgement arrives later, asynchronously; Awaiting acknowledgements shows how to wait for a specific batch to be acked. Where the engine holds unacknowledged rows depends on the mode (see Store-and-forward): in RAM by default, or in memory-mapped files on disk when sf_dir is set.

Auto-flush (on by default) calls flush() for you when the buffer first crosses one of these thresholds:

TriggerDefaultConnect-string key
Row count1,000auto_flush_rows
Time100 msauto_flush_interval
Buffer bytesdisabledauto_flush_bytes

Smaller thresholds lower latency at the cost of more, smaller batches:

ws::addr=localhost:9000;auto_flush_rows=500;auto_flush_interval=50;

The WebSocket transport always auto-flushes; control batch size through these thresholds.

Explicit flush. Call flush() at any point to hand the buffered rows to the engine immediately, regardless of the auto-flush thresholds:

try (Sender sender = db.borrowSender()) {
for (Trade trade : trades) {
sender.table("trades")
.symbol("symbol", trade.symbol())
.doubleColumn("price", trade.price())
.doubleColumn("amount", trade.amount())
.at(trade.timestamp());
}
sender.flush(); // hand everything to the engine now
}

Backpressure. The engine's buffer is bounded by sf_max_total_bytes (default 128 MiB in memory mode, 10 GiB in store-and-forward mode). If a slow or unreachable server lets it fill, flush() blocks until the server acks rows and the engine frees space, up to sf_append_deadline_millis (default 30 s), then throws, keeping the buffered rows.

Closing. On a sender you own, close() flushes buffered rows and then drains: it waits for the server to acknowledge everything published, up to close_flush_timeout_millis (default 60 s), before disconnecting. If the drain times out, close() throws LineSenderException with rows still unacknowledged — recoverable by reopening on the same sf_dir in store-and-forward mode, lost in memory mode. A pooled sender (from db.borrowSender()) behaves differently: its close() flushes and returns it to the pool without disconnecting or draining, and the drain runs only when QuestDB.close() closes the underlying connection. To confirm a batch is durable before moving on, wait for its ack with awaitAckedFsn rather than relying on close().

Server-advertised batch cap

The server advertises its maximum accepted batch size on the WebSocket upgrade (X-QWP-Max-Batch-Size), and the client clamps batches to it. A single row larger than the cap, or a batch that would exceed it at flush time, surfaces as a LineSenderException from the offending column call or from flush().

Store-and-forward

Ingestion is asynchronous in every mode: flush() hands rows to the background send engine, which delivers them and tracks the server's acknowledgements on its own (see Flushing). The sf_dir key changes one thing — where the engine keeps unacknowledged rows — and with it, what a crash can lose.

Memory mode is the default (no sf_dir). Unacknowledged rows live in RAM. The engine still reconnects across transient server outages — rolling upgrades, brief network loss — for up to reconnect_max_duration_millis (default 5 minutes) and replays the unacknowledged tail, so a server blip loses nothing. But if the sender process dies, the RAM buffer dies with it. The buffer is capped at 128 MiB (sf_max_total_bytes).

Store-and-forward mode turns on when you set sf_dir. The engine backs its buffer with memory-mapped files under sf_dir, so unacknowledged rows survive a sender process restart: on the next startup the engine recovers the unacknowledged tail from disk and replays it once the server is reachable. The on-disk buffer defaults to a 10 GiB cap.

ws::addr=localhost:9000;sf_dir=/var/lib/questdb/sf;

Choosing an sf_dir layout. An sf_dir is a recovery identity — a restart recovers a run's unacknowledged rows by re-opening the same path — so keep it stable for a given producer. Pick one of two layouts:

  • One sf_dir per instance is the simplest and the common case. The default sender_id is fine. When the process restarts against the same directory, the pool recovers its own unacknowledged rows and delivers them once the server is reachable. Use this whenever a producer restarts in place — a fixed host, a pod with a stable volume.
  • One shared sf_dir across instances suits ephemeral producers — autoscaled workers or rolling pods on a shared volume — where a crashed producer may never come back to flush its own rows. Give each instance a distinct sender_id so their directories don't collide, and set drain_orphans=on so a surviving instance picks up a crashed peer's leftover rows instead of leaving them stranded.
# shared root: a distinct sender_id per instance, peers recover each other
ws::addr=db:9000;sf_dir=/shared/qdb-sf;sender_id=ingest-a;drain_orphans=on;
ws::addr=db:9000;sf_dir=/shared/qdb-sf;sender_id=ingest-b;drain_orphans=on;

Directories and recovery. Within an sf_dir, each pooled sender owns <sender_id>-<index> (ingest-a-0, ingest-a-1, …), held by an exclusive OS lock for its lifetime — two running senders never share a directory, and one becomes adoptable only after its owner releases the lock by exiting or crashing, so a live sender's rows are never drained from under it. On restart a pool recovers its own directories automatically; drain_orphans=on extends that to abandoned siblings under the same root, replaying their rows over fresh connections (up to max_background_drainers at a time, default 4) and skipping any directory an earlier drain flagged .failed. Two instances that share an sf_dir with the same sender_id collide, and the second to start fails fast with sf slot already in use. Allowed sender_id characters: letters, digits, _, -.

Durability. Store-and-forward runs with sf_durability=memory, the only supported mode. The engine relies on the OS page cache and the memory-mapped files rather than an explicit fsync, so the on-disk buffer survives a JVM crash and a process restart; rows still in the page cache at an OS crash or power loss can be lost.

Replay is at-least-once — enable DEDUP

After a reconnect or a sender restart, the client replays frames the server may have accepted but not yet acknowledged. Without DEDUP on the target table, replay produces duplicate rows. Tables ingested over a reconnecting or multi-host connection must declare DEDUP UPSERT KEYS(...) covering row identity. See Delivery semantics for the full at-least-once / exactly-once model.

A batch the server rejects outright — a schema, parse, or security error — is terminal. It latches an error on the sender: the next call throws LineSenderServerException, and the pool replaces the failed sender on the next borrow (see Ingestion errors).

Durable acknowledgement

Enterprise

Durable acknowledgement requires QuestDB Enterprise with primary replication configured.

By default, the server confirms a batch when it is committed to the local WAL. To wait for the batch to be durably uploaded to object storage:

ws::addr=localhost:9000;sf_dir=/var/lib/questdb/sf;request_durable_ack=on;

Awaiting acknowledgements

flush() returns once the batch is handed to the send engine, not once the server has acknowledged it. ACKs arrive asynchronously on the I/O loop. To bridge between publish and acknowledgement, every published frame is assigned a frame sequence number (FSN). flushAndGetSequence() returns the highest FSN the call published, and awaitAckedFsn(targetFsn, timeoutMillis) blocks until the server has acknowledged up to that FSN:

try (Sender sender = db.borrowSender()) {
sender.table("trades").doubleColumn("price", 42.0).atNow();
long fsn = sender.flushAndGetSequence();
if (fsn >= 0 && !sender.awaitAckedFsn(fsn, 10_000)) {
// timed out waiting for the server ACK
}
}

Related accessors:

MethodReturns
flushAndGetSequence()Highest FSN published by this call. -1 if nothing was published.
getAckedFsn()Highest FSN the server has acknowledged. -1 if no batch has been published yet.
awaitAckedFsn(fsn, timeoutMillis)Block until getAckedFsn() reaches fsn, or the timeout elapses.

When request_durable_ack=on is set, getAckedFsn() advances after the durable upload to object storage, not on the ordinary commit ACK. The same FSN span is reported on SenderError.getFromFsn() / getToFsn() for rejected batches, so the value returned by flushAndGetSequence() is also the correlation key for async error reports.

These methods are no-ops on transports that do not track frame sequence numbers (HTTP, TCP, UDP): flushAndGetSequence() and getAckedFsn() return -1, and awaitAckedFsn() returns immediately.

Querying with Query and Completion

SQL queries are submitted through the fluent Query builder and observed through an async Completion handle:

import io.questdb.client.Completion;
import io.questdb.client.QueryException;
import io.questdb.client.Query;

Query q = db.query()
.sql("SELECT ts, symbol, price FROM trades WHERE symbol = $1 LIMIT $2")
.binds(binds -> {
binds.setVarchar(0, "EURUSD");
binds.setLong(1, 100L);
})
.handler(handler);

Completion c = q.submit();
try {
c.await(); // blocks until onEnd / onError / onExecDone resolves
} catch (QueryException e) {
System.err.printf("query failed: status=0x%02X %s%n",
e.getStatus() & 0xFF, e.getMessage());
}

Or, for queries without bind parameters, the one-shot shortcut:

Completion c = db.executeSql(
"SELECT count(*) FROM trades",
handler);
c.await();

db.executeSql(...) is equivalent to db.query().sql(sql).handler(handler).submit() and uses the same per-thread cached Query and Completion — both forms are allocation-free at steady state.

The Query builder

Build a query by setting its SQL and a result handler — plus bind parameters if the SQL has any — then call submit() to run it:

MethodRequiredSets
sql(CharSequence)yesThe SQL text. Copied internally, so you can reuse or change your source string after submit().
binds(QwpBindSetter)noA callback that fills in the query's $1, $2, … parameters (see Bind parameters).
handler(QwpColumnBatchHandler)yesA callback that receives the query's row batches and its outcome (see Result handler).
  • submit() sends the query and returns a Completion you use to wait for it. It throws if the SQL or handler is missing, or if this Query's previous query is still running.
  • abandon() clears the SQL, binds, and handler without sending anything — use it to back out of a query you started building.

db.query() returns a reusable Query bound to the calling thread, which runs one query at a time. To run several at once from a single thread, call db.newQuery() for each; the query pool runs up to queryPoolMax queries in parallel.

Completion

A Completion tracks one submitted query until it finishes — succeeds (all rows delivered, or a DDL/DML statement done), fails with a server error, or is cancelled.

MethodReturnsNotes
await()voidBlocks until the query finishes. Returns normally on success; throws QueryException if the server reported an error or the query was cancelled.
await(long, TimeUnit)booleanLike await(), but gives up after the timeout and returns false — the query keeps running.
cancel()voidAsks the server to stop the query; safe to call more than once. The handler then sees onError with a cancel status, and await() throws QueryException.
isDone()booleantrue once the query has finished — succeeded, failed, or been cancelled.

QueryException.getStatus() returns the QWP status code the server sent (see Query error status codes). 0 means the failure was client-side — for example, the connection dropped before the server replied.

await() returns (or throws) only after the handler's final callback — onEnd, onError, or onExecDone — has run, so whatever state that callback set is visible by the time await() returns.

Bind parameters

Parameterized queries use typed bind values, avoiding SQL injection and enabling server-side factory cache reuse across repeated calls:

String sql = "SELECT ts, symbol, price, amount FROM trades "
+ "WHERE symbol = $1 AND price >= $2 LIMIT 1000";

for (String symbol : List.of("EURUSD", "GBPUSD", "USDJPY")) {
db.query()
.sql(sql)
.binds(binds -> binds
.setVarchar(0, symbol)
.setDouble(1, 1.0))
.handler(handler)
.submit()
.await();
}

Bind indices are 0-based ($1 maps to index 0). Setters must be called in ascending order with no gaps. Available setters:

SetterBind type
setBoolean(index, value)BOOLEAN
setByte(index, value)BYTE
setChar(index, value)CHAR
setShort(index, value)SHORT
setInt(index, value)INT
setLong(index, value)LONG
setFloat(index, value)FLOAT
setDouble(index, value)DOUBLE
setDate(index, millis)DATE
setTimestampMicros(index, micros)TIMESTAMP
setTimestampNanos(index, nanos)timestamp_ns
setVarchar(index, value)VARCHAR, STRING, and SYMBOL columns
setUuid(index, lo, hi) or setUuid(index, UUID)UUID
setLong256(index, l0, l1, l2, l3)LONG256
setGeohash(index, precisionBits, value)GEOHASH
setDecimal64(index, scale, unscaled)DECIMAL64
setDecimal128(index, scale, lo, hi)DECIMAL128
setDecimal256(index, scale, ll, lh, hl, hh)DECIMAL256

To pass a NULL bind value, either pass null to setVarchar or use the typed setNull:

.binds(binds -> binds.setVarchar(0, null))           // null VARCHAR/SYMBOL
.binds(binds -> binds.setNull(0, TYPE_LONG)) // typed null (requires QWP type code)
.binds(binds -> binds.setNullGeohash(0, 20)) // null GEOHASH with precision
.binds(binds -> binds.setNullDecimal64(0, 4)) // null DECIMAL64 with scale

To keep submission allocation-free, hoist your QwpBindSetter lambda to a field rather than capturing per-call locals — captured locals allocate a new closure object each submit.

Cancellation and timeouts

Completion c = db.executeSql(
"SELECT * FROM big_table ORDER BY ts",
handler);

if (!c.await(5, TimeUnit.SECONDS)) {
c.cancel();
try {
c.await();
} catch (QueryException cancelled) {
// status carries the cancel byte; handler also saw onError
}
}

cancel() is a no-op if the query has already completed. If cancel() races to terminal, await() throws QueryException with the cancel status. If the query finished first, await() returns normally.

Result handler

The handler interface is unchanged from the lower-level QwpQueryClient:

import io.questdb.client.cutlass.qwp.client.QwpColumnBatch;
import io.questdb.client.cutlass.qwp.client.QwpColumnBatchHandler;

new QwpColumnBatchHandler() {
@Override
public void onBatch(QwpColumnBatch batch) {
for (int row = 0; row < batch.getRowCount(); row++) {
long ts = batch.getLongValue(0, row);
String symbol = batch.getSymbol(1, row);
double price = batch.getDoubleValue(2, row);
// process row...
}
}

@Override
public void onEnd(long totalRows) {}

@Override
public void onError(byte status, String message) {
System.err.printf("error: 0x%02X %s%n", status & 0xFF, message);
}
};

The QwpColumnBatch object is valid only during the onBatch callback. Copy values out if you need them after the callback returns.

Convenience accessors: batch.forEachRow(row -> ...) provides a RowView with single-argument accessors (row.getLongValue(col), row.getSymbol(col), etc.) for compact read paths.

Null checking: call batch.isNull(col, row) before reading a value.

When the handler is invoked through Query/Completion, the same instance can be reused across submissions — there is no per-call wrapping allocation.

Reading result batches

QwpColumnBatch provides typed accessors for all QuestDB column types:

AccessorColumn types
getBoolValue(col, row)BOOLEAN
getByteValue(col, row)BYTE
getShortValue(col, row)SHORT
getCharValue(col, row)CHAR
getIntValue(col, row)INT, IPv4
getLongValue(col, row)LONG, TIMESTAMP, timestamp_ns, DATE, DECIMAL64 (unscaled)
getFloatValue(col, row)FLOAT
getDoubleValue(col, row)DOUBLE
getSymbol(col, row)SYMBOL (returns cached String)
getStrA(col, row) / getStrB(col, row)VARCHAR (reusable CharSequence views)
getString(col, row)VARCHAR (heap-allocating String)
getString(col, row, CharSink)VARCHAR (copy into sink)
getBinaryA(col, row) / getBinaryB(col, row)BINARY (reusable native views)
getBinary(col, row)BINARY (heap-allocating byte[])
getUuid(col, row, Uuid)UUID (zero-allocation, into sink)
getUuidHi(col, row) / getUuidLo(col, row)UUID (individual 64-bit halves)
getLong256(col, row, Long256Sink)LONG256, DECIMAL256 (into sink)
getLong256Word(col, row, wordIndex)LONG256, DECIMAL256 (individual 64-bit word)
getGeohashValue(col, row)GEOHASH (raw long value)
getGeohashPrecisionBits(col)GEOHASH (precision metadata, per column)
getDecimal128High(col, row) / getDecimal128Low(col, row)DECIMAL128 (two longs)
getDecimalScale(col)DECIMAL64 / DECIMAL128 / DECIMAL256 (scale metadata, per column)
getDoubleArrayElements(col, row)DOUBLE_ARRAY (flattened double[], row-major)
getArrayNDims(col, row)DOUBLE_ARRAY (dimension count)
isNull(col, row)All types

Column metadata is available via batch.getColumnName(col), batch.getColumnWireType(col), and batch.getColumnCount().

Reading array columns:

getDoubleArrayElements(col, row) returns a flattened double[] in row-major order. Use getArrayNDims(col, row) to discover the dimensionality:

int nDims = batch.getArrayNDims(colIndex, row);  // e.g. 2
double[] flat = batch.getDoubleArrayElements(colIndex, row);
// flat contains all elements in row-major order

Alternatively, extract individual elements in SQL (e.g., SELECT bids[1][1] FROM market_data) and read them as scalar doubles.

DDL and DML statements

Non-SELECT statements (CREATE TABLE, INSERT, UPDATE, ALTER, DROP, TRUNCATE) go through the same submit() / executeSql(...) path. The server replies with EXEC_DONE instead of result batches; override the onExecDone(opType, rowsAffected) callback:

db.executeSql(
"CREATE TABLE trades ("
+ "ts TIMESTAMP, symbol SYMBOL, side SYMBOL, price DOUBLE, amount DOUBLE"
+ ") TIMESTAMP(ts) PARTITION BY DAY WAL",
new QwpColumnBatchHandler() {
@Override public void onBatch(QwpColumnBatch batch) {}
@Override public void onEnd(long totalRows) {}

@Override
public void onError(byte status, String message) {
System.err.println("failed: " + message);
}

@Override
public void onExecDone(short opType, long rowsAffected) {
System.out.printf("done: opType=%d rows=%d%n", opType, rowsAffected);
}
}
).await();

rowsAffected reports the count for INSERT/UPDATE/DELETE. Pure DDL (CREATE, DROP, ALTER, TRUNCATE) reports 0.

Because await() blocks until the terminal callback returns, you can safely sequence DDL → DML → SELECT:

db.executeSql("CREATE TABLE t (...) ...", ddlHandler).await();
db.executeSql("INSERT INTO t VALUES ...", dmlHandler).await();
db.executeSql("SELECT * FROM t", selectHandler).await();

Flow control

For large result sets, byte-credit flow control prevents the server from overwhelming the client. Set initial_credit on the query connect string (via the builder's queryConfig):

try (QuestDB db = QuestDB.builder()
.ingestConfig("ws::addr=localhost:9000;")
.queryConfig("ws::addr=localhost:9000;initial_credit=" + (256 * 1024) + ";")
.build()) {
// server pauses after streaming ~256 KiB, auto-replenishes per batch
}

A credit of 0 (the default) means unbounded: the server streams as fast as the network allows.

Compression

Negotiate zstd compression to reduce network bandwidth for large result sets:

try (QuestDB db = QuestDB.connect(
"ws::addr=localhost:9000;compression=zstd;compression_level=3;")) {
// batches are automatically decompressed
}

compression selects the codec the client requests: raw (the default, no compression), zstd (always compress), or auto (compress only when the server offers zstd). compression_level is the zstd level, default 1, accepted in the range 122 and clamped server-side to 19. Both keys apply to the query side only and are ignored by the ingest side.

Error handling

Ingestion errors

WebSocket ingestion uses an asynchronous error model. Batch rejections are delivered via the SenderErrorHandler callback, not thrown from flush(). The QuestDB connect-string path does not yet expose this callback — if your application needs structured SenderError reports, drop down to the low-level Sender.builder directly and skip the pool. The structured fields on SenderError are the same regardless of how the sender was created:

FieldAccessorDescription
CategorygetCategory()SCHEMA_MISMATCH, PARSE_ERROR, INTERNAL_ERROR, SECURITY_ERROR, WRITE_ERROR, PROTOCOL_VIOLATION, or UNKNOWN
PolicygetAppliedPolicy()DROP_AND_CONTINUE (batch dropped, sender continues) or HALT (next API call throws LineSenderServerException)
Server messagegetServerMessage()Human-readable error text from the server (may be null)
Table namegetTableName()The rejected table (null for multi-table batches)
FSN rangegetFromFsn() / getToFsn()Frame sequence number span identifying the rejected batch
Message sequencegetMessageSequence()Server's per-frame sequence number (-1 if not available)
Status bytegetServerStatusByte()Raw QWP status code (-1 if not available)

The error handler runs on a dedicated dispatcher thread, never on the I/O or producer thread.

When a sender owned by QuestDB enters a terminal HALT state, the next producer-thread call throws LineSenderServerException. The pool detects the failure on close/return and replaces the failed sender with a fresh one on the next borrow.

Query errors

Query errors surface in two places:

  1. The handler's onError(byte status, String message) callback fires exactly once with the QWP status and server text.
  2. Completion.await() rethrows them as QueryException:
try {
db.executeSql(sql, handler).await();
} catch (QueryException e) {
if (e.getStatus() == 0) {
// client-side failure: transport drop before any server response
} else {
System.err.printf("server reported 0x%02X: %s%n",
e.getStatus() & 0xFF, e.getMessage());
}
}

Status codes:

CodeNameDescription
0x03SCHEMA_MISMATCHBind parameter type incompatible with placeholder
0x05PARSE_ERRORSQL syntax error or malformed message
0x06INTERNAL_ERRORServer-side execution failure
0x08SECURITY_ERRORAuthorization failure
0x0ACANCELLEDQuery terminated by Completion.cancel()
0x0BLIMIT_EXCEEDEDProtocol limit hit

Errors can arrive before any data (parse failure) or mid-stream (storage failure, server shutdown). When onError is called, no further frames arrive for that query.

onError(long requestId, byte status, String message) is a correlation-aware overload — the request id matches QwpColumnBatch.requestId() from any prior onBatch. Similarly, onEnd(long requestId, long totalRows), onExecDone(long requestId, short opType, long rowsAffected), and onFailoverReset(long requestId, QwpServerInfo newNode) are default-method overloads of the corresponding completion callbacks; all default to the shorter signatures, so existing handlers compile unchanged.

Diagnostics surface

What is and isn't carried on onError:

DiagnosticSurfaced?
Request idYes — requestId argument on the 3-arg overload. Also available mid-stream on QwpColumnBatch.requestId(). -1 when the failure is raised before a request was assigned (closed client, bind-encode failure, latched terminal failure from a prior generation).
Failing SQLNot surfaced. Stash the string at the submit site if you need it on onError.
Bind index (for SCHEMA_MISMATCH)Not structurally surfaced. May be embedded in the free-form message; format is not part of the protocol contract.
Message stabilitymessage is server-supplied free-form text, not localized. Wording is not guaranteed across server releases — pattern-match on status instead.
PII / secret safetymessage may echo back SQL fragments, bind values, and table or column names. Treat it as untrusted user-derived text; redact before logging at INFO.

Connection-level errors

  • Authentication failure: 401/403 HTTP response before the WebSocket upgrade completes. Terminal across all endpoints. The borrow that triggered the connect rethrows LineSenderException.
  • Malformed frames: QwpDecodeException or WebSocket close with a terminal code.
  • Role mismatch: QwpRoleMismatchException when all endpoints report roles that do not match the target= filter.

Failover and high availability

Enterprise

Failing over across multiple QuestDB hosts requires QuestDB Enterprise — the replicas you fail over to come from Enterprise replication.

A QuestDB handle keeps working across server restarts and network blips: the ingestion sender and the query client each reconnect on their own. The two sides recover a little differently, so they are covered separately below.

Multiple endpoints

List several hosts in addr, comma-separated:

ws::addr=db-primary:9000,db-replica-1:9000,db-replica-2:9000;

The client tries them in order and, when a connection drops, moves on to the next reachable one. addr is shared by both sides, so ingestion and queries use the same host list.

Ingestion failover

Ingestion always writes to the primary — replicas are read-only and reject a write connection. When you list several hosts, the sender tries them in order and settles on whichever one is the current primary, while the replicas turn it away. So failing over to a different host only helps once that host becomes the primary — for example, after the old primary goes down and a replica is promoted in its place. (The query-side keys target and zone don't apply to ingestion; the sender always needs the primary.)

If the connection to the primary drops, the sender reconnects on its own, using exponential backoff between attempts, and resumes where it left off once a primary is reachable again. Whether unacknowledged rows survive a sender failure depends on the store-and-forward mode: with sf_dir set they are on disk and survive even a restart; without it they live in RAM and are lost only if the sender process itself dies.

Tune the reconnect loop from the connect string:

KeyDefaultWhat it does
reconnect_max_duration_millis300000 (5 min)How long to keep retrying one outage before giving up.
reconnect_initial_backoff_millis100Wait before the first retry.
reconnect_max_backoff_millis5000Longest wait between retries.
initial_connect_retryoffWhether to retry the first connect too (on, sync, async).

Query failover

If a query's connection fails partway through, the query client reconnects to another host (with exponential backoff between attempts) and re-runs the query from the start. Because the server resends the whole result, your handler's onFailoverReset(...) fires first, so you can throw away the partial results you had collected:

@Override
public void onFailoverReset(QwpServerInfo newNode) {
results.clear(); // the server will resend from the beginning
}

If you don't clear, you'll see the first part of the result twice. Failover is a mid-query event; between queries the pool simply hands you a healthy client, so onFailoverReset doesn't fire there.

Tune query failover from the connect string:

KeyDefaultWhat it does
failoveronTurn per-query failover on or off.
failover_max_attempts8Most reconnect attempts for one query.
failover_backoff_initial_ms50Wait before the first retry.
failover_backoff_max_ms1000Longest wait between retries.
failover_max_duration_ms30000 (30 s)Total time budget for one query's retries.
Failover needs at least two hosts

Failover works by trying a different host. With a single addr there is nowhere else to go, so the query fails after one attempt no matter how high failover_max_attempts is. List two or more hosts for failover to do anything.

When every host is unreachable and the time or attempt budget runs out, the query fails: your handler gets onError and Completion.await() throws QueryException. You don't need to rebuild anything — the pool drops the broken client and gives you a fresh one on your next query.

Which failures are retried

  • Authentication failures (a bad token or wrong credentials) stop immediately on every host — retrying cannot help.
  • Network and availability failures (connection refused, TLS errors, a 5xx from the server, a mid-query drop) are treated as temporary and fed into the reconnect and failover loops.
  • A host that turns you away because of its role (for example, you asked for the primary but reached a replica) is skipped, and the client tries the next host.

Connection events

The QuestDB facade does not expose connection callbacks. To watch connect, disconnect, reconnect, and failover events — CONNECTED, DISCONNECTED, RECONNECTED, FAILED_OVER, AUTH_FAILED, and the rest — use the low-level Sender.builder and register a SenderConnectionListener.

For the full list of connect-string keys, see the reconnect and multi-host failover sections of the connect string reference.

Concurrency

QuestDB is the only thread-safe handle in the library. Sender and Query (and the underlying QwpQueryClient) are single-threaded:

  • Senders: not thread-safe. Borrow one per concurrent producer, or pin one per thread via db.sender().
  • Queries: one in-flight query per Query instance. db.query() returns the per-thread cached instance — fine for one in-flight query per thread. For multiple concurrent in-flight queries on one thread, call db.newQuery() per submit.
  • Query pool: caps total in-flight queries at queryPoolMax.

To max out parallel query throughput, raise queryPoolMax and submit from as many threads as you have workers.

Phase 1 limitation

A single underlying query client serves one in-flight query at a time. The pool runs N clients in parallel, so concurrency = queryPoolMax. The wire protocol allows demultiplexed concurrent queries on one client; multi-query support per client is planned for a future release.

Low-level primitives

QuestDB is built on the same Sender and QwpQueryClient types you can construct directly. Use them when you need:

  • A SenderErrorHandler, SenderConnectionListener, or SenderProgressHandler registered on the sender (not yet exposed via the pool).
  • The Sender.builder API for explicit advanced-TLS / endpoint configuration that isn't expressible in a connect string.
  • A single-shot lifecycle without pooling overhead — for example, an ETL job that opens, ingests, and exits.

Sender (low-level primitive)

import io.questdb.client.Sender;

try (Sender sender = Sender.fromConfig("ws::addr=localhost:9000;")) {
sender.table("trades")
.symbol("symbol", "ETH-USD")
.doubleColumn("price", 2615.54)
.atNow();
}

Or with the builder, for callbacks the unified config can't express:

try (Sender sender = Sender.builder(Sender.Transport.WEBSOCKET)
.address("db-primary:9000")
.address("db-replica:9000")
.enableTls()
.httpToken("YOUR_BEARER_TOKEN")
.errorHandler(error -> {
System.err.printf("batch rejected: category=%s table=%s msg=%s%n",
error.getCategory(), error.getTableName(),
error.getServerMessage());
})
.connectionListener(event -> {
System.out.printf("connection: %s host=%s:%d%n",
event.getKind(), event.getHost(), event.getPort());
})
.progressHandler(ackedFsn -> {
// settled watermark; see SenderProgressHandler javadoc for the
// distinction from durable
})
.build()) {
// ...
}

QwpQueryClient (low-level primitive)

import io.questdb.client.cutlass.qwp.client.QwpQueryClient;

try (QwpQueryClient client = QwpQueryClient.newPlainText("localhost", 9000)) {
client.connect();
client.execute(sql, handler);
}

This is the path the QuestDB facade uses internally. Direct usage gives you explicit control over connect() timing, per-query withInitialCredit adjustment, and removal of the pool's Completion wrapper layer.

Configuration reference

For the full list of connect-string keys and their defaults, see the connect string reference.

Common WebSocket-specific options:

KeyDefaultDescription
auto_flush_rows1000Rows before auto-flush.
auto_flush_interval100Milliseconds before auto-flush.
auto_flush_bytesdisabledBytes before auto-flush.
sf_dirunsetStore-and-forward directory.
sender_iddefaultSender slot identity for SF.
request_durable_ackoffRequest durable upload ACK (Enterprise).
reconnect_max_duration_millis300000Ingress reconnect budget.
failoveronEgress per-query reconnect switch.
compressionrawEgress batch compression (raw, zstd, auto).
compression_level1Egress zstd level (122, clamped server-side to 19).
client_idunsetEgress client label, sent as the X-QWP-Client-Id header; set in production so support can correlate sessions.

Pool keys (read by the QuestDB facade; the two clients accept and ignore them):

KeyDefaultDescription
sender_pool_min1Senders kept open even when idle.
sender_pool_max4Maximum senders the pool opens.
query_pool_min1Query clients kept open even when idle.
query_pool_max4Maximum query clients the pool opens.
acquire_timeout_ms5000Wait this long for a free connection before throwing.
idle_timeout_ms60000Close a connection after it is idle this long.
max_lifetime_ms1800000Replace a connection once it reaches this age.
housekeeper_interval_ms5000How often the housekeeper checks for idle and over-age connections.

Compatible JDKs

The client relies on some JDK internal libraries, which certain specialised JDK offerings may not support.

Known incompatible JDKs:

  • Azul Zing 17 (use Azul Zulu 17 instead)

Migration from ILP (HTTP/TCP)

If you are migrating from the ILP-based client, the row-building API is unchanged. The main differences:

AspectHTTP (ILP)WebSocket (QWP)
Connect string schemahttp:: / https::ws:: / wss::
Auto-flush rows75,0001,000
Auto-flush interval1,000 ms100 ms
Error modelSynchronous (flush() throws)Async (SenderErrorHandler callback)
Buffer capacityConfigurableNot configurable (internal cursor)
Store-and-forwardNot availableAvailable (sf_dir)
Multi-endpoint failoverLimitedFull reconnect loop with backoff
QueryingNot availableQuestDB.executeSql / QwpQueryClient
Pooled facadeNot availableQuestDB.connect(...)

To migrate, either:

  1. Replace Sender.fromConfig(...) with QuestDB.connect(...) and use db.borrowSender() — the row-building chain is unchanged.
  2. Or keep Sender.fromConfig(...) if you need the builder-only callbacks (SenderErrorHandler, SenderConnectionListener, SenderProgressHandler) and add a separate QuestDB or QwpQueryClient for queries.

Either way, change your connect string from http:: to ws:: (or https:: to wss::) to opt into the QWP path, and adjust auto-flush settings if needed.

Full example: ingestion and querying with failover

import io.questdb.client.Completion;
import io.questdb.client.QuestDB;
import io.questdb.client.QueryException;
import io.questdb.client.Sender;
import io.questdb.client.cutlass.line.array.DoubleArray;
import io.questdb.client.cutlass.qwp.client.QwpColumnBatch;
import io.questdb.client.cutlass.qwp.client.QwpColumnBatchHandler;
import io.questdb.client.cutlass.qwp.client.QwpServerInfo;

import java.time.Instant;
import java.util.concurrent.ThreadLocalRandom;
import java.util.concurrent.TimeUnit;

// One QuestDB per deployment. Holds elastic pools for both ingress and
// egress. Use try-with-resources at the application boundary; everything
// inside is allocation-free at steady state.

try (QuestDB db = QuestDB.builder()
.fromConfig(
"wss::addr=db-primary:9000,db-replica:9000,db-replica2:9000;"
+ "token=YOUR_BEARER_TOKEN;"
+ "failover=on;"
+ "failover_max_attempts=8;"
+ "failover_max_duration_ms=30000;")
.senderPoolMin(2).senderPoolMax(16)
.queryPoolMin(2).queryPoolMax(8)
.acquireTimeoutMillis(10_000)
.build()) {

// ─── Ingestion ─────────────────────────────────────────────────────

// Borrow a sender per producer task. close() flushes and returns it
// to the pool. The pool transparently handles reconnects; the
// failover loop drives the underlying Sender's reconnect budget.

try (Sender sender = db.borrowSender();
DoubleArray bids = new DoubleArray(5, 2);
DoubleArray asks = new DoubleArray(5, 2)) {

for (int i = 0; i < 100; i++) {
bids.clear();
asks.clear();
for (int lvl = 0; lvl < 5; lvl++) {
bids.append(1.0842 - 0.0001 * (lvl + 1));
bids.append(100_000 + ThreadLocalRandom.current().nextInt(900_000));
asks.append(1.0842 + 0.0001 * (lvl + 1));
asks.append(100_000 + ThreadLocalRandom.current().nextInt(900_000));
}
sender.table("book")
.symbol("ticker", "EURUSD")
.doubleArray("bids", bids)
.doubleArray("asks", asks)
.at(Instant.now());
}
// close() (via try-with-resources) flushes pending rows.
}

// ─── Querying ──────────────────────────────────────────────────────

// executeSql() is the one-shot shortcut. The pool serves the request
// from a query client. Mid-query failover is transparent to the
// application; the handler's onFailoverReset() fires before replayed
// batches arrive.

Completion c = db.executeSql(
"SELECT ts, ticker, bids[1][1] AS best_bid, asks[1][1] AS best_ask "
+ "FROM book ORDER BY ts DESC LIMIT 10",
new QwpColumnBatchHandler() {
@Override
public void onBatch(QwpColumnBatch batch) {
batch.forEachRow(row -> System.out.printf(
"ts=%s ticker=%s bid=%.5f ask=%.5f%n",
Instant.ofEpochMilli(row.getLongValue(0) / 1000),
row.getSymbol(1),
row.getDoubleValue(2),
row.getDoubleValue(3)));
}

@Override
public void onEnd(long requestId, long totalRows) {
System.out.printf("req=%d (%d rows)%n", requestId, totalRows);
}

@Override
public void onError(long requestId, byte status, String message) {
System.err.printf("query error: req=%d 0x%02X %s%n",
requestId, status & 0xFF, message);
}

@Override
public void onFailoverReset(long requestId, QwpServerInfo newNode) {
System.out.printf("failover req=%d to node=%s role=%s%n",
requestId, newNode.getNodeId(),
QwpServerInfo.roleName(newNode.getRole()));
}
});

try {
if (!c.await(30, TimeUnit.SECONDS)) {
c.cancel();
c.await();
}
} catch (QueryException e) {
System.err.printf("query failed: status=0x%02X %s%n",
e.getStatus() & 0xFF, e.getMessage());
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}

// ─── Query with bind parameters ────────────────────────────────────

db.query()
.sql("SELECT ts, ticker FROM book WHERE ticker = $1 LIMIT $2")
.binds(binds -> {
binds.setVarchar(0, "EURUSD");
binds.setLong(1, 50L);
})
.handler(new QwpColumnBatchHandler() {
@Override public void onBatch(QwpColumnBatch batch) { /* ... */ }
@Override public void onEnd(long totalRows) { /* ... */ }
@Override public void onError(byte status, String message) { /* ... */ }
})
.submit()
.await();
}