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.borrowQuery()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.11</version>
</dependency>
implementation 'org.questdb:questdb-client:1.3.11'
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.Query;
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 -- borrow a Query handle, submit, block on the Completion.
// The table was auto-created above, so its designated timestamp column
// is named `timestamp`.
//
// Ingestion is asynchronous: flushing does not make rows queryable.
// QuestDB auto-creates the table and applies the WAL in the background,
// so on a first run this query can fail with "table does not exist" or
// return no rows. Production code polls until the rows appear, bounded
// by a deadline -- see "Read-after-write" below.
try (Query q = db.borrowQuery()) {
Completion c = q.sql(
"SELECT timestamp, symbol, price, amount FROM trades "
+ "WHERE symbol = 'ETH-USD' LIMIT 10")
.handler(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);
}
})
.submit();
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());
}
}
}
Completion.await() declares throws InterruptedException, so the enclosing
method must declare or handle it. QueryException extends RuntimeException,
so catching it as above is optional and does not satisfy the checked exception.
Read-after-write
A flush is not a commit, and a commit is not visibility. Sender.close()
on a pooled sender flushes buffered rows, but the server applies them to the
table asynchronously. Reading immediately after writing can therefore fail with
table does not exist on a first run — the table is auto-created in the
background — or succeed but return no rows.
For read-after-write, poll until the rows appear, bounded by a deadline:
long deadline = System.currentTimeMillis() + 10_000;
AtomicLong seen = new AtomicLong();
while (true) {
seen.set(0);
try (Query q = db.borrowQuery()) {
q.sql("SELECT timestamp, symbol, price FROM trades "
+ "WHERE symbol = 'ETH-USD' LIMIT 10")
.handler(new QwpColumnBatchHandler() {
@Override
public void onBatch(QwpColumnBatch batch) {
batch.forEachRow(row -> seen.incrementAndGet());
}
@Override public void onEnd(long totalRows) { }
@Override public void onError(byte status, String message) { }
})
.submit()
.await();
} catch (QueryException e) {
// The table may not exist yet -- keep polling until the deadline.
if (System.currentTimeMillis() >= deadline) {
throw e;
}
}
if (seen.get() > 0) {
break;
}
if (System.currentTimeMillis() >= deadline) {
throw new IllegalStateException("timed out waiting for query visibility");
}
Thread.sleep(100);
}
Two details that matter: the QueryException catch is what tolerates the table
not existing yet, and the row counter must be reset on each attempt because the
handler is invoked per batch. Do not substitute a fixed Thread.sleep(...) —
apply latency varies with load.
This applies to every client, not just Java. See the equivalent poll in the Python, Rust, Go, and Node.js quick starts.
The QuestDB handle is a facade over two distinct kinds of client: a
Sender for ingestion (db.borrowSender()) and a
query client for SQL
(db.borrowQuery()). 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. Pool sizes and timeouts are set through the
builder.
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.
The interface has these members:
| Method | Returns | Purpose |
|---|---|---|
connect(String) | QuestDB | Static factory. One connect string drives both ingress and egress (schema must be ws/wss). |
connect(String, HttpTokenProvider) | QuestDB | Static factory that takes the bearer token of every connection from a provider, such as a RefreshingTokenProvider. |
builder() | QuestDBBuilder | Pool sizes and timeouts. |
borrowSender() | Sender | Lease a sender from the pool; close() flushes and returns it. |
borrowQuery() | Query | Lease a query handle from the pool; close() returns it. |
health() | ConnectionHealth.Aggregate | The connection health of every pooled sender and query client. |
close() | void | Shut down pools and disconnect every underlying client. Idempotent. |
A QuestDB instance is safe to share across threads. Borrows and query
submissions all serialize through the pool internally. An individual borrowed
Sender or Query is not thread-safe: borrow one per thread.
The facade does not support separate ingress and egress endpoints. There is no
two-argument connect(ingestCfg, queryCfg) and no ingestConfig(...) /
queryConfig(...) on the builder. The single config string is passed to both
pools. To read from a replica while writing to a primary, construct two
QuestDB handles.
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.
Builder API
For pool tuning, callbacks, or any of the housekeeping knobs:
try (QuestDB db = QuestDB.builder()
.fromConfig("ws::addr=localhost:9000;")
.senderPoolSize(8) // fixed size, opened up front
.queryPoolMin(2).queryPoolMax(16) // elastic
.acquireTimeoutMillis(10_000)
.idleTimeoutMillis(60_000)
.maxLifetimeMillis(30 * 60_000L)
.errorHandler(e -> { /* async ingest rejections, whole pool */ })
.connectionListener(e -> { /* connect + disconnect events */ })
.build()) {
// ...
}
Builder methods:
| Method | Default | Purpose |
|---|---|---|
fromConfig(String) | — | One ws/wss string for both sides; honors pool keys in the string. Required. |
httpTokenProvider(HttpTokenProvider) | — | Supplies the bearer token of every pooled connection, such as from a RefreshingTokenProvider. Cannot be combined with token, username/password, or token_provider in the configuration. |
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. |
queryCloseTimeoutMillis(long) | — | How long Query.close() waits for an in-flight query to settle. |
errorHandler(SenderErrorHandler) | — | Async ingest rejections from across the whole sender pool, delivered on the senders' I/O threads. |
connectionListener(SenderConnectionListener) | — | Connect and disconnect events across the sender pool. |
drainerListener(BackgroundDrainerListener) | — | Store-and-forward background drainer events. |
fromConfig(...) is required; build() throws
IllegalStateException("configuration is required; call fromConfig()") without
it. That one string is passed to both the sender pool and the query pool.
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.
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.
The client sends the same token on every connection that it opens, including reconnects and failovers. A connection that is already open is not authenticated again, but once QuestDB stops accepting the token, for example because it has expired, every new connection fails. For tokens that expire, such as the tokens of an OpenID Connect provider, use a token provider instead.
Token providers
A token provider requests bearer tokens from an identity provider, caches them, and renews them in the background before they expire, so every connection, reconnect, and failover presents a valid token. Use one when a client runs longer than its tokens live, such as a service that authenticates as an Azure managed identity. Token providers require Java client 1.3.11 or later.
Microsoft Entra ID
Add the questdb-client-azure artifact, at the same version as
questdb-client. It brings the Azure Identity library, and registers the
azure token provider:
- Maven
- Gradle
<dependency>
<groupId>org.questdb</groupId>
<artifactId>questdb-client-azure</artifactId>
<version>1.3.11</version>
</dependency>
implementation 'org.questdb:questdb-client-azure:1.3.11'
Then select the provider in the connect string:
try (QuestDB db = QuestDB.connect(
"wss::addr=db.example.com:9000;"
+ "token_provider=azure;"
+ "azure_resource=api://8de84b90-1ea5-4e41-9e84-dba860aa01a6;"
+ "azure_credential=managed_identity;")) {
// ...
}
| Key | Description |
|---|---|
token_provider | azure. Requires the wss schema. Cannot be combined with token, username/password, or a provider passed in code. |
azure_resource | Required. The Application ID URI of the QuestDB application in Entra ID, such as api://<application ID>, or its application ID. The client requests tokens for the scope <azure_resource>/.default. |
azure_credential | The Azure credential that requests the tokens: default (the default), managed_identity, workload_identity, or environment. |
azure_client_id | The client ID of a user-assigned managed identity, or of a workload identity. Cannot be combined with azure_credential=environment. |
In production, name the credential with azure_credential:
managed_identityrequests tokens as the managed identity of the Azure resource that the client runs on: its system-assigned identity, or the user-assigned identity thatazure_client_idselects.workload_identityrequests tokens as an Azure Kubernetes Service workload identity.environmentrequests tokens as a service principal configured with theAZURE_TENANT_ID,AZURE_CLIENT_ID, andAZURE_CLIENT_SECRETenvironment variables, or with a certificate.
The default, default, uses DefaultAzureCredential, a chain meant for
development, which tries a service principal configured in the environment, a
workload identity, a managed identity, and then developer tools, such as the
Azure CLI. The chain checks the managed identity endpoint once, without
retrying, so at startup, an outage of that endpoint looks like a host without a
credential. The client logs a warning about this when the provider starts. A
managed identity selected with managed_identity retries an unreachable
endpoint instead.
These keys contain no secret, so they are safe to log and to keep in
QDB_CLIENT_CONF. Secrets, such as a client secret or a certificate, come from
the configuration of the Azure platform. Every sender, query client, and pooled
connection built from equivalent connect strings shares one provider in a
process: one thread renews the token, and one request goes to Entra ID at a
time. The provider closes 60 seconds after the last client that uses it.
To configure an Azure credential in code, such as a certificate credential,
wrap it in an AzureTokenSource, and pass that to a RefreshingTokenProvider,
as described in Other identity providers:
import com.azure.core.credential.TokenCredential;
import com.azure.identity.ClientCertificateCredentialBuilder;
import io.questdb.client.azure.AzureTokenSource;
import io.questdb.client.cutlass.auth.RefreshingTokenProvider;
TokenCredential credential = new ClientCertificateCredentialBuilder()
.tenantId("<tenant ID>")
.clientId("<client ID of the service principal>")
.pemCertificate("/etc/fx-ingest/client.pem")
.build();
RefreshingTokenProvider tokens = RefreshingTokenProvider.builder(
new AzureTokenSource(credential, "api://<application ID>")).build();
Whichever credential it uses, the Azure provider classifies the failures of Azure Identity:
- Permanent: a missing credential, such as a managed identity that is not
assigned to the host, a missing credential file, or a certificate that
cannot be parsed; a
400or401from Entra ID; or a configuration error that Entra ID reports, such asAADSTS700016(application not found),AADSTS7000215(invalid client secret),AADSTS7000222(expired client secret), orAADSTS500011(resource not found, usually a wrongazure_resource). - Retryable: anything else, such as network errors, timeouts, throttling,
and
5xxresponses. The provider honors theRetry-Afterheader of a throttled response.
The classification decides whether a startup fails at once, as described in
How the client handles token failures.
Each token request times out after 30 seconds. Error messages and log lines
name the failure type, the HTTP status, a known AADSTS code, and the
socket-level cause of a network failure, such as a refused connection. They
leave out the messages of the Azure SDK, which can contain the response of the
token endpoint.
The client finds the provider as a service, so a modular application on Java
11 or later needs only requires io.questdb.client;, with the artifact and
the Azure Identity library on the module path.
For the setup in Entra ID and QuestDB, see Managed identities and service principals.
Other identity providers
For any other identity provider, implement a TokenSource that requests a
token, and wrap it in a RefreshingTokenProvider. Pass the provider to
QuestDB.connect(String, HttpTokenProvider),
QuestDB.builder().httpTokenProvider(...),
Sender.builder(...).httpTokenProvider(...), or
QwpQueryClient.withBearerTokenProvider(...). The application owns the
provider: close it after the clients that use it.
import java.io.IOException;
import io.questdb.client.QuestDB;
import io.questdb.client.cutlass.auth.ExpiringToken;
import io.questdb.client.cutlass.auth.RefreshingTokenProvider;
import io.questdb.client.cutlass.auth.TokenUnavailableException;
RefreshingTokenProvider tokens = RefreshingTokenProvider.builder(() -> {
// runs on the provider's own thread, never on a connection thread
try {
IdpToken token = idp.requestToken(); // such as a client credentials grant
return new ExpiringToken(token.value(), token.expiresAtEpochMillis());
} catch (IOException e) {
throw TokenUnavailableException.retryable("token endpoint unreachable");
}
}).build();
try (QuestDB db = QuestDB.connect("wss::addr=db.example.com:9000;", tokens)) {
// ...
} finally {
tokens.close();
}
idp and IdpToken stand for your own code. ExpiringToken takes the token,
without the Bearer prefix, and the time when it expires, in milliseconds
since the epoch. The source reports a failure with an unchecked
TokenUnavailableException:
TokenUnavailableException.retryable(message)for a network failure, a timeout, throttling, or a5xxresponse. Pass aRetry-Afterwithretryable(message, retryAfterMillis).TokenUnavailableException.permanent(message)for a missing or wrong configuration, such as an invalid client secret.
Any other exception counts as retryable. Never put a token or a raw response body into the message. The provider keeps retrying with exponential backoff whatever the classification, so an operator can fix a permanent failure, such as a missing role assignment, without a restart.
The provider requests its first token as soon as it is built, and renews the token in the background at about half of its lifetime. It hands out a token only while more than 60 seconds of its lifetime are left, and a connection that needs a token while the provider holds no usable one waits up to 30 seconds for it. To tune this, use the builder:
| Builder method | Default | Description |
|---|---|---|
handoutFloorMillis(long) | 60000 | The provider hands out a token only while more than this is left of its lifetime. A token that arrives with less counts as a failed request, whose error names handout_floor. Lower it if your identity provider issues tokens that live less than about two minutes. |
coldWaitMillis(long) | 30000 | How long a connection waits for a token when the provider holds no usable one. |
forcedWaitMillis(long) | 5000 | How long the client waits for a new token after QuestDB rejects one with 401. |
minRefreshIntervalMillis(long) | 30000 | The least time between a request and the next scheduled renewal. |
backoffInitialMillis(long) | 500 | The delay after the first failed request. It grows exponentially, with jitter, with each further failure. |
backoffMaxMillis(long) | 60000 | The longest delay between failed requests. |
To fail fast at startup, for example on an invalid client secret, wait for the
first token with awaitReady(timeoutMillis), which returns false when the
timeout passes:
if (!tokens.awaitReady(30_000)) {
RefreshingTokenProvider.FetchFailure failure = tokens.getLastFailure();
if (failure != null && !failure.isRetryable()) {
tokens.close();
throw new IllegalStateException("cannot get a token: " + failure.getMessage());
}
// a retryable failure: start anyway, and let the provider keep trying
}
getConsecutiveFailures(), getLastSuccessEpochMillis(), and
getTokenExpiresAtEpochMillis() report the state of the provider, such as for
a health check. None of them, nor any log line or error, contains the token.
An application can also pass its own HttpTokenProvider. Its getToken()
returns the current token without the Bearer prefix. Pooled connections can
call it at the same time, so it must be thread-safe, and it must return
promptly, without prompting the user. After QuestDB rejects a token with
401, the client calls onTokenRejected(token, httpStatus), a default method
that does nothing, and then calls getToken() again. To report a transient
failure, throw TokenUnavailableException.retryable(...): the client treats
any other exception from getToken() as permanent.
How the client handles token failures
- A
401from QuestDB. When QuestDB rejects a token with401, and the response carries no Bearererrororerror="invalid_token", the client asks the provider for a new token. If it gets a different one, it retries the same server once, immediately. ARefreshingTokenProviderrequests a new token for a rejected one at most once every 30 seconds, and the client waits up toforcedWaitMillisfor it. A403never triggers this retry: it means that the token is valid, but lacks a permission. - Startup. A sender connects when it is built, unless
initial_connect_retry=async. Withinitial_connect_retry=off, the default, it fails when it cannot get a token. Withon, it retries a retryable failure of the provider withinreconnect_max_duration_millis, and fails at once on a permanent one. Withasync, it keeps retrying in the background. A401or403from QuestDB fails the startup in every mode: withasync, it reaches the error handler. - The
QuestDBhandle. The handle also connectsquery_pool_minquery clients, one by default, when it is built, and a query client does not retry a token failure. SoQuestDB.connect()andbuild()fail while the identity provider is unreachable. To start anyway, setlazy_connect=on: senders then connect in the background, and query clients on the first query. - A sender that has connected. It keeps retrying when it cannot get a
token, or when QuestDB rejects the token, with any kind of credential. It
buffers rows meanwhile, in memory or in
store-and-forward, and reports each failed attempt to
the error handler as a
SECURITY_ERRORwith theRETRIABLEpolicy, and in its connection health. The message of the error starts withcredential-unavailable:when the provider cannot supply a token, or withws-upgrade-failed:when QuestDB rejects the connection. The sender never fails on its own: an application that prefers a hard failure watches the error handler or the connection health, and closes the sender itself. - A query. A query whose client has to connect, and cannot get a token or
has its token rejected, fails with
QueryException. The next query connects again. - An orphan drainer. With
drain_orphans=on, a drainer that replays the slot of another sender gives up when QuestDB keeps rejecting its credential: at once with a fixed credential, and after the rejections persist for a while with a token provider. It then sets the slot aside, with its rows still on disk, and reportsDATA_LOSS.
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.
Borrowing a sender
// Lease per use. close() flushes and returns the sender to the pool.
try (Sender sender = db.borrowSender()) {
sender.table("trades").doubleColumn("price", 42.0).atNow();
}
A long-lived producer thread can hold its borrow across many rows rather than borrowing per row, flushing as it goes and returning the sender only when the thread winds down:
try (Sender sender = db.borrowSender()) {
for (int i = 0; i < 1_000_000; i++) {
sender.table("trades").doubleColumn("price", 42.0 + i).atNow();
}
sender.flush();
}
Size senderPoolMax to the number of producer threads that hold a borrow
concurrently, otherwise the last of them blocks until
acquireTimeoutMillis expires.
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).
Query handles are single-flight
db.borrowQuery() leases a Query from the query pool. A handle runs one
query at a time, so concurrency comes from borrowing several, up to
queryPoolMax:
try (Query a = db.borrowQuery(); Query b = db.borrowQuery()) {
Completion ca = a.sql("SELECT 1").handler(handler).submit();
Completion cb = b.sql("SELECT 2").handler(handler).submit();
ca.await();
cb.await();
}
Always borrow in try-with-resources. A handle that is never closed keeps its
pooled query client leased for the life of the QuestDB instance.
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 close() returns it.
-
Borrow a sender with
db.borrowSender(). -
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. -
Close the sender (try-with-resources). The pool flushes pending rows before the sender returns for reuse. A long-lived producer thread can keep its borrow and
flush()between batches, closing only at shutdown.
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.
A running sender reconnects across transient server outages — rolling upgrades,
brief network loss — retrying indefinitely with capped backoff, then replays the
unacknowledged tail, so a server blip loses nothing. What bounds your outage
tolerance is buffer capacity, not a timer: the RAM buffer is capped at 128 MiB
(sf_max_total_bytes), and once it fills, append() applies backpressure to
the producer. If the sender process dies, the RAM buffer dies with it.
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 defaults to sf_durability=memory, which
relies on the OS page cache and the memory-mapped files rather than an explicit
fsync. The on-disk buffer survives a JVM crash and a process restart, but rows
still in the page cache at an OS crash or power loss can be lost.
Set sf_durability=periodic to survive host loss. It requires sf_dir and
checkpoints published frames in the background every
sf_sync_interval_millis (default 5000). flush and append are reserved:
they parse but are rejected at build().
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.
Try it with a free 30-day trial.
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;
try (Query q = db.borrowQuery()) {
Completion c = q
.sql("SELECT ts, symbol, price FROM trades WHERE symbol = $1 LIMIT $2")
.binds(binds -> {
binds.setVarchar(0, "EURUSD");
binds.setLong(1, 100L);
})
.handler(handler)
.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());
}
}
For a query without bind parameters, drop the binds(...) call:
try (Query q = db.borrowQuery()) {
q.sql("SELECT count(*) FROM trades").handler(handler).submit().await();
}
Always borrow in try-with-resources: Query.close() returns the underlying
pooled query client, and a handle that is never closed leases it for the life
of the QuestDB instance.
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. -
close()returns the handle's pooled query client. Use try-with-resources.
A borrowed Query runs one query at a time. To run several at once, borrow one
handle per query; the query pool runs up to
queryPoolMax 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";
try (Query q = db.borrowQuery()) {
for (String symbol : List.of("EURUSD", "GBPUSD", "USDJPY")) {
q.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
try (Query q = db.borrowQuery()) {
Completion c = q.sql("SELECT * FROM big_table ORDER BY ts")
.handler(handler)
.submit();
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() path. The server replies with EXEC_DONE
instead of result batches; override the onExecDone(opType, rowsAffected)
callback:
try (Query q = db.borrowQuery()) {
q.sql("CREATE TABLE trades ("
+ "ts TIMESTAMP, symbol SYMBOL, side SYMBOL, price DOUBLE, amount DOUBLE"
+ ") TIMESTAMP(ts) PARTITION BY DAY")
.handler(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);
}
})
.submit()
.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:
try (Query q = db.borrowQuery()) {
q.sql("CREATE TABLE t (...) ...").handler(ddlHandler).submit().await();
q.sql("INSERT INTO t VALUES ...").handler(dmlHandler).submit().await();
q.sql("SELECT * FROM t").handler(selectHandler).submit().await();
}
Flow control
For large result sets, byte-credit flow control prevents the server from
overwhelming the client. Set initial_credit on the connect string; the
sender ignores the key, so a single string still drives both directions:
try (QuestDB db = QuestDB.connect(
"ws::addr=localhost:9000;initial_credit=" + (256 * 1024) + ";")) {
// 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().
Register the handler with QuestDB.builder().errorHandler(...), for every
sender of the pool, or with errorHandler(...) on the builder of a
low-level Sender. 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, NOT_WRITABLE, DICTIONARY_GAP, PROTOCOL_VIOLATION, DATA_LOSS, or UNKNOWN |
| Policy | getAppliedPolicy() | RETRIABLE (the client reconnects and replays the batch), RETRIABLE_OTHER (the same, on the next endpoint), TERMINAL (the next API call throws LineSenderServerException), or ABANDONED (with DATA_LOSS only: the rows can never be sent, and stay on disk) |
| 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 the TERMINAL 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 (Query q = db.borrowQuery()) {
q.sql(sql).handler(handler).submit().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. The client does not try the remaining endpoints. A connect at startup throwsQwpAuthFailedExceptionfromQuestDB.connect(),build(), or thedb.borrowSender()that creates the sender. It is anHttpClientException, not aLineSenderException. Its message starts withauth-rejected:,getStatusCode()returns401or403, andgetBearerError()returns the Bearererrorof the response, ornull. A sender that has connected before keeps retrying instead, and a query fails, as described in How the client handles token failures. - Credential unavailable: the token provider cannot
supply a token, for example because the identity provider is unreachable.
The client contacts no endpoint. A sender connect at startup throws the
exception of the provider, such as a
TokenUnavailableException. A query client throwsQwpCredentialUnavailableException, whose message starts withcredential-unavailable:, anddb.borrowQuery()wraps it in aQueryException. - 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.
Try it with a free 30-day trial.
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:
wss::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) | Sync initial-connect budget. The running loop retries indefinitely. |
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 the
walk over the hosts, because every host would reject the same credential.
With a token provider, a
401first earns one retry of the same host with a new token. Startup and queries fail, while a sender that has connected before keeps retrying, as described in How the client handles token failures. - 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
Register a SenderConnectionListener to watch connect, disconnect, reconnect
and failover events. It is available on both the facade builder
(QuestDB.builder().connectionListener(...), where it applies across the whole
sender pool) and the low-level
Sender.builder.
SenderConnectionEvent.Kind has seven values:
| Kind | Meaning |
|---|---|
CONNECTED | First successful connect. |
DISCONNECTED | The active connection dropped. |
RECONNECTED | A reconnect succeeded against the same endpoint. |
FAILED_OVER | A reconnect succeeded against a different endpoint. |
ENDPOINT_ATTEMPT_FAILED | One endpoint failed during a connect walk; the client tries the next. |
ALL_ENDPOINTS_UNREACHABLE | Every endpoint failed this round. The sender keeps retrying — this is not terminal. |
AUTH_FAILED | QuestDB rejected the credential with 401 or 403, which ends the round. Terminal before the first connect. A sender that has connected before keeps retrying. |
RECONNECTED and FAILED_OVER are mutually exclusiveFor any one successful reconnect you get either RECONNECTED (same node)
or FAILED_OVER (different node) — never both, and never a second
CONNECTED. Code that tracks "which node am I on?" by listening for
CONNECTED alone will silently miss every failover and keep reporting the old
endpoint.
Likewise, ALL_ENDPOINTS_UNREACHABLE is informational, not fatal: the sender
buffers into store-and-forward and keeps retrying. Treating it as a failure
signal will produce false alarms during ordinary rolling restarts.
For the full list of connect-string keys, see the reconnect and multi-host failover sections of the connect string reference.
Connection health
A sender that rides out an outage, such as an unreachable cluster or a token
that QuestDB rejects, keeps accepting rows, so a successful flush() does not
mean that the sender reaches the server. Its connection health does. Reading
the health never waits for network I/O, and never contains a credential, so it
can back a health endpoint. Connection health requires Java client 1.3.11 or
later.
import io.questdb.client.ConnectionHealth;
// every pooled sender and query client of the handle
ConnectionHealth.Aggregate health = db.health();
long since = health.getOldestOutageSinceEpochMillis();
if (since != ConnectionHealth.NONE) {
// a client has not connected yet, is reconnecting, or has failed
ConnectionHealth.Failure failure = health.getLastFailure();
System.err.printf("QuestDB unreachable since %d: %s%n", since, failure);
}
Sender.health() and QwpQueryClient.health() return the ConnectionHealth
of one client. Only the WebSocket (QWP) sender tracks it: senders of the other
transports throw UnsupportedOperationException.
| Accessor | Description |
|---|---|
getState() | CONNECTING (not connected yet), CONNECTED, RECONNECTING (the connection was lost, and the client is connecting again; for a query client, the last connect failed, and the next query tries again), FAILED (the client will not connect again), or CLOSED. |
getLastConnectedAtEpochMillis() | When the client last connected, or ConnectionHealth.NONE (-1). |
getOutageSinceEpochMillis() | When the current outage started, or NONE while connected. A client that has not connected yet has been in an outage since it was created. |
getFailedRounds() | How many connect rounds, walks over the endpoints in addr, have failed since the outage started. |
getLastFailure() | The last failed connect round, or null. It is kept after the client connects again. |
A ConnectionHealth.Failure names its class with getFailureClass():
CREDENTIAL_UNAVAILABLE (the token provider could not supply a token),
AUTH_REJECTED (QuestDB answered 401 or 403, which getStatusCode()
returns), ROLE_REJECTED (no endpoint had the required role), TRANSPORT
(no endpoint could be reached), or OTHER. getMessage() and
getEpochMillis() return its description and its time.
ConnectionHealth.Aggregate returns the number of clients in a state with
count(State), the number of clients with total(), the oldest outage with
getOldestOutageSinceEpochMillis(), and the most recent failure with
getLastFailure().
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 with
db.borrowSender(), and hold the borrow for as long as that producer runs. - Queries: one in-flight query per
Queryhandle. For multiple concurrent in-flight queries, calldb.borrowQuery()once per query. - 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
SenderProgressHandler, which is only reachable as a post-construction setter on a concreteQwpWebSocketSender.SenderErrorHandlerandSenderConnectionListenerare also available on the facade builder. - 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());
})
.build()) {
// ...
}
A SenderProgressHandler is not a builder option. Register it after
construction, on the concrete WebSocket sender:
SenderProgressHandler h = ackedFsn -> {
// settled watermark; see SenderProgressHandler javadoc for the
// distinction from durable
};
try (Sender sender = Sender.builder(Sender.Transport.WEBSOCKET)
.address("localhost:9000")
.build()) {
if (sender instanceof QwpWebSocketSender) {
((QwpWebSocketSender) sender).setProgressHandler(h);
}
}
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 | Sync initial-connect budget only. |
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. |
token_provider | unset | Token provider that requests and renews the bearer token, wss only: azure, configured with azure_resource, azure_credential, and azure_client_id. See Token providers. |
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.borrowQuery / 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 needSenderProgressHandler, which is only reachable on a concreteQwpWebSocketSender, and add a separateQuestDBorQwpQueryClientfor queries.SenderErrorHandlerandSenderConnectionListenerare available on the facade builder too.
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.Query;
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 ──────────────────────────────────────────────────────
// Borrow a Query handle. 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.
// `book` was auto-created by the ingest above, so its designated
// timestamp column is named `timestamp`. Create the table explicitly
// beforehand if you want to control that name. The rows may also not be
// visible yet -- see "Read-after-write" above for the polling pattern.
try (Query q = db.borrowQuery()) {
Completion c = q.sql(
"SELECT timestamp, ticker, bids[1][1] AS best_bid, "
+ "asks[1][1] AS best_ask "
+ "FROM book ORDER BY timestamp DESC LIMIT 10")
.handler(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)));
}
// `onEnd(long)` and `onError(byte, String)` are the
// interface's abstract methods and must be implemented even
// when you only use the requestId overloads below, which are
// `default` and fire when queries are multiplexed.
@Override
public void onEnd(long totalRows) { }
@Override
public void onError(byte status, String message) { }
@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()));
}
})
.submit();
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 ────────────────────────────────────
try (Query q = db.borrowQuery()) {
q.sql("SELECT timestamp, 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();
}
}