Java client for QuestDB
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 singlews/wssconnect 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
Completionhandles, and byte-credit flow control. - Failover: multi-endpoint connections with automatic reconnect across rolling upgrades and primary migrations.
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:
- Maven
- Gradle
<dependency>
<groupId>org.questdb</groupId>
<artifactId>questdb-client</artifactId>
<version>1.3.4</version>
</dependency>
implementation 'org.questdb:questdb-client:1.3.4'
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.
| Method | Returns | Purpose |
|---|---|---|
connect(String) | QuestDB | Static factory. Single connect string for both ingress and egress (schema must be ws/wss). |
connect(String, String) | QuestDB | Static factory. Explicit ingress + egress connect strings. |
builder() | QuestDBBuilder | Pool sizes, timeouts, separate configs. |
borrowSender() | Sender | Lease a sender from the pool; close() flushes and returns it. |
sender() | Sender | Thread-affine sender: pinned to the calling thread on first use, reused on subsequent calls. |
releaseSender() | void | Release the thread-affine sender for the calling thread. |
query() | Query | Per-thread cached fluent query builder (allocation-free). |
newQuery() | Query | Fresh Query instance — use when one thread holds multiple in-flight queries. |
executeSql(sql, handler) | Completion | One-shot shortcut, equivalent to query().sql(sql).handler(handler).submit(). |
close() | void | Shut 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:
| Method | Default | Purpose |
|---|---|---|
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) | 1 | Senders kept open even when idle. 0 lets the pool close them all. |
senderPoolMax(int) | 4 | Maximum senders the pool opens. |
senderPoolSize(int) | — | Shortcut: fixed min = max = size, all opened up front. |
queryPoolMin(int) | 1 | Query clients kept open even when idle. 0 lets the pool close them all. |
queryPoolMax(int) | 4 | Maximum query clients the pool opens. |
queryPoolSize(int) | — | Shortcut: fixed min = max = size. |
acquireTimeoutMillis(long) | 5000 | How long borrowSender() / Query.submit() wait for a free connection when the pool is at max, before throwing. |
idleTimeoutMillis(long) | 60000 | How long an unused connection stays open before the housekeeper closes it (never below min). 0 ⇒ keep idle connections forever. |
maxLifetimeMillis(long) | 1800000 | Maximum age of a connection; the housekeeper closes and reopens older ones once idle. 0 ⇒ no age limit. |
housekeeperIntervalMillis(long) | 5000 | How 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.
| Key | Builder equivalent |
|---|---|
sender_pool_min | senderPoolMin(int) |
sender_pool_max | senderPoolMax(int) |
query_pool_min | queryPoolMin(int) |
query_pool_max | queryPoolMax(int) |
acquire_timeout_ms | acquireTimeoutMillis(long) |
idle_timeout_ms | idleTimeoutMillis(long) |
max_lifetime_ms | maxLifetimeMillis(long) |
housekeeper_interval_ms | housekeeperIntervalMillis(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.
Token (Enterprise, recommended)
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.
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()).
-
Borrow a sender (
db.borrowSender()ordb.sender()). -
Call
table(name)to select a table. -
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)ortimestampColumn(name, long, ChronoUnit)uuidColumn(name, lo, hi)(two longs)long256Column(name, l0, l1, l2, l3)(four longs, least significant first)decimalColumn(name, Decimal256)ordecimalColumn(name, CharSequence)ipv4Column(name, int)(packed 32-bit address) oripv4Column(name, CharSequence)(dotted-quad)geoHashColumn(name, long bits, int precisionBits)orgeoHashColumn(name, CharSequence base32)binaryColumn(name, byte[]),binaryColumn(name, long ptr, long len), orbinaryColumn(name, DirectByteSlice)doubleArray(name, ...)andlongArray(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()oratNow(). 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 strictipv4Column(name, CharSequence)rejects the literal strings"null"(case-insensitive) and"0.0.0.0"with aLineSenderException. Passing anullreference is a no-op (the column is left unset, which surfaces as SQL NULL on read).GEOHASH precision is locked per columnThe first call to
geoHashColumnfor a column fixes its precision (number of bits). Subsequent rows must use the same precision or the call throwsLineSenderException. For the string overload, precision isvalue.length() * 5bits; for the bits overload, it is the explicitprecisionBitsargument (1..60). -
Call
at(Instant),at(long, ChronoUnit), oratNow()to finalize the row. -
Repeat from step 2, or call
flush()to send buffered data. -
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 andflush()between batches; release only on shutdown or thread recycling.
- For
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();
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:
| Trigger | Default | Connect-string key |
|---|---|---|
| Row count | 1,000 | auto_flush_rows |
| Time | 100 ms | auto_flush_interval |
| Buffer bytes | disabled | auto_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().
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_dirper instance is the simplest and the common case. The defaultsender_idis 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_diracross 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 distinctsender_idso their directories don't collide, and setdrain_orphans=onso 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.
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
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:
| Method | Returns |
|---|---|
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:
| Method | Required | Sets |
|---|---|---|
sql(CharSequence) | yes | The SQL text. Copied internally, so you can reuse or change your source string after submit(). |
binds(QwpBindSetter) | no | A callback that fills in the query's $1, $2, … parameters (see Bind parameters). |
handler(QwpColumnBatchHandler) | yes | A callback that receives the query's row batches and its outcome (see Result handler). |
submit()sends the query and returns aCompletionyou use to wait for it. It throws if the SQL or handler is missing, or if thisQuery'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.
| Method | Returns | Notes |
|---|---|---|
await() | void | Blocks until the query finishes. Returns normally on success; throws QueryException if the server reported an error or the query was cancelled. |
await(long, TimeUnit) | boolean | Like await(), but gives up after the timeout and returns false — the query keeps running. |
cancel() | void | Asks 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() | boolean | true 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:
| Setter | Bind 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:
| Accessor | Column 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 1–22 and clamped server-side to 1–9. 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:
| Field | Accessor | Description |
|---|---|---|
| Category | getCategory() | SCHEMA_MISMATCH, PARSE_ERROR, INTERNAL_ERROR, SECURITY_ERROR, WRITE_ERROR, PROTOCOL_VIOLATION, or UNKNOWN |
| Policy | getAppliedPolicy() | DROP_AND_CONTINUE (batch dropped, sender continues) or HALT (next API call throws LineSenderServerException) |
| Server message | getServerMessage() | Human-readable error text from the server (may be null) |
| Table name | getTableName() | The rejected table (null for multi-table batches) |
| FSN range | getFromFsn() / getToFsn() | Frame sequence number span identifying the rejected batch |
| Message sequence | getMessageSequence() | Server's per-frame sequence number (-1 if not available) |
| Status byte | getServerStatusByte() | 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:
- The handler's
onError(byte status, String message)callback fires exactly once with the QWP status and server text. Completion.await()rethrows them asQueryException:
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:
| Code | Name | Description |
|---|---|---|
0x03 | SCHEMA_MISMATCH | Bind parameter type incompatible with placeholder |
0x05 | PARSE_ERROR | SQL syntax error or malformed message |
0x06 | INTERNAL_ERROR | Server-side execution failure |
0x08 | SECURITY_ERROR | Authorization failure |
0x0A | CANCELLED | Query terminated by Completion.cancel() |
0x0B | LIMIT_EXCEEDED | Protocol 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:
| Diagnostic | Surfaced? |
|---|---|
| Request id | Yes — 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 SQL | Not 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 stability | message is server-supplied free-form text, not localized. Wording is not guaranteed across server releases — pattern-match on status instead. |
| PII / secret safety | message 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/403HTTP response before the WebSocket upgrade completes. Terminal across all endpoints. The borrow that triggered the connect rethrowsLineSenderException. - Malformed frames:
QwpDecodeExceptionor WebSocket close with a terminal code. - Role mismatch:
QwpRoleMismatchExceptionwhen all endpoints report roles that do not match thetarget=filter.
Failover and high availability
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:
| Key | Default | What it does |
|---|---|---|
reconnect_max_duration_millis | 300000 (5 min) | How long to keep retrying one outage before giving up. |
reconnect_initial_backoff_millis | 100 | Wait before the first retry. |
reconnect_max_backoff_millis | 5000 | Longest wait between retries. |
initial_connect_retry | off | Whether 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:
| Key | Default | What it does |
|---|---|---|
failover | on | Turn per-query failover on or off. |
failover_max_attempts | 8 | Most reconnect attempts for one query. |
failover_backoff_initial_ms | 50 | Wait before the first retry. |
failover_backoff_max_ms | 1000 | Longest wait between retries. |
failover_max_duration_ms | 30000 (30 s) | Total time budget for one query's retries. |
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
5xxfrom 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
Queryinstance.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, calldb.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.
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, orSenderProgressHandlerregistered on the sender (not yet exposed via the pool). - The
Sender.builderAPI 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:
| Key | Default | Description |
|---|---|---|
auto_flush_rows | 1000 | Rows before auto-flush. |
auto_flush_interval | 100 | Milliseconds before auto-flush. |
auto_flush_bytes | disabled | Bytes before auto-flush. |
sf_dir | unset | Store-and-forward directory. |
sender_id | default | Sender slot identity for SF. |
request_durable_ack | off | Request durable upload ACK (Enterprise). |
reconnect_max_duration_millis | 300000 | Ingress reconnect budget. |
failover | on | Egress per-query reconnect switch. |
compression | raw | Egress batch compression (raw, zstd, auto). |
compression_level | 1 | Egress zstd level (1–22, clamped server-side to 1–9). |
client_id | unset | Egress 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):
| Key | Default | Description |
|---|---|---|
sender_pool_min | 1 | Senders kept open even when idle. |
sender_pool_max | 4 | Maximum senders the pool opens. |
query_pool_min | 1 | Query clients kept open even when idle. |
query_pool_max | 4 | Maximum query clients the pool opens. |
acquire_timeout_ms | 5000 | Wait this long for a free connection before throwing. |
idle_timeout_ms | 60000 | Close a connection after it is idle this long. |
max_lifetime_ms | 1800000 | Replace a connection once it reaches this age. |
housekeeper_interval_ms | 5000 | How 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:
| Aspect | HTTP (ILP) | WebSocket (QWP) |
|---|---|---|
| Connect string schema | http:: / https:: | ws:: / wss:: |
| Auto-flush rows | 75,000 | 1,000 |
| Auto-flush interval | 1,000 ms | 100 ms |
| Error model | Synchronous (flush() throws) | Async (SenderErrorHandler callback) |
| Buffer capacity | Configurable | Not configurable (internal cursor) |
| Store-and-forward | Not available | Available (sf_dir) |
| Multi-endpoint failover | Limited | Full reconnect loop with backoff |
| Querying | Not available | QuestDB.executeSql / QwpQueryClient |
| Pooled facade | Not available | QuestDB.connect(...) |
To migrate, either:
- Replace
Sender.fromConfig(...)withQuestDB.connect(...)and usedb.borrowSender()— the row-building chain is unchanged. - Or keep
Sender.fromConfig(...)if you need the builder-only callbacks (SenderErrorHandler,SenderConnectionListener,SenderProgressHandler) and add a separateQuestDBorQwpQueryClientfor 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();
}