Skip to content

About

The official Java client for QuestDB

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

10 stars

Watchers

0 watching

Forks

QuestDB Logo

 

Maven Central License

QuestDB Client Library for Java

This is the official Java client library for QuestDB, a high-performance time-series database.

The recommended entry point is the QuestDB facade: a single, thread-safe handle that pools connections for both ingest (writing rows) and queries (reading results) over QWP, the QuestDB WebSocket protocol. Construct one QuestDB per deployment, share it across threads, and close it at shutdown. Borrow a Sender to write, borrow a Query to read, and the facade manages the underlying connections, pooling, reconnection, and store-and-forward buffering for you.

import io.questdb.client.QuestDB;
import io.questdb.client.Sender;

try (QuestDB db = QuestDB.connect("ws::addr=localhost:9000;")) {
    try (Sender sender = db.borrowSender()) {
        sender.table("trades")
                .symbol("symbol", "ETH-USD")
                .symbol("side", "sell")
                .doubleColumn("price", 2615.54)
                .doubleColumn("amount", 0.00044)
                .atNow();
        // close() flushes pending rows and returns the Sender to the pool.
    }
}

Use the QuestDB facade. Application code should obtain senders and query handles from the facade (db.borrowSender() / db.borrowQuery()), not by constructing them directly. Directly instantiating a Sender (Sender.fromConfig(...), Sender.builder(...)), a LineUdpSender, or a QwpQueryClient/Query bypasses the pooling, shared lifecycle, reconnection, and store-and-forward guarantees the facade provides — and every borrowed handle is single-owner and returns to the pool on close(). Reach for the low-level Sender/QwpQueryClient APIs only when you are integrating them into your own pooling layer.

Quick Start

Add Dependency

Maven:

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

Gradle:

implementation 'org.questdb:questdb-client:1.0.0'

Replace 1.0.0 with the latest version from Maven Central.

Start QuestDB

docker run -p 9000:9000 questdb/questdb

Connect

A QuestDB cluster is one logical target reached over QWP for both ingest and queries, so the facade takes one ws/wss configuration string. List every node in a single addr server list and both pools connect across it.

import io.questdb.client.QuestDB;

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

// Whole cluster in one config string
try (QuestDB db = QuestDB.connect("ws::addr=node1:9000,node2:9000,node3:9000;")) {
    // ... use db ...
}

Examples

All examples assume an open QuestDB db handle (see Connect). Create the handle once and share it; the snippets below borrow from its pools.

Ingest Data

Borrow a Sender, write rows, and close it. close() flushes pending rows and returns the sender to the pool — the underlying connection is only torn down when the QuestDB handle itself is closed.

import io.questdb.client.Sender;

try (Sender sender = db.borrowSender()) {
    sender.table("trades")
            .symbol("symbol", "BTC-USD")
            .doubleColumn("price", 42_500.50)
            .longColumn("size", 100)
            .atNow();
    // No explicit flush() needed: close() flushes for you.
}

Sending Batches of Rows

The sender buffers rows and sends them in batches. For high throughput, write many rows on one borrowed sender and flush per batch rather than per row: each flush has a fixed cost, so batching raises throughput. close() flushes the final partial batch.

try (Sender sender = db.borrowSender()) {
    for (int i = 0; i < trades.size(); i++) {
        Trade t = trades.get(i);
        sender.table("trades")
                .symbol("symbol", t.symbol)
                .doubleColumn("price", t.price)
                .longColumn("size", t.size)
                .atNow();
        // Flush every 10k rows to bound buffer memory and latency.
        if ((i + 1) % 10_000 == 0) {
            sender.flush();
        }
    }
    // Remaining rows are flushed by close().
}

You can also let the client flush batches for you with the auto_flush_rows / auto_flush_interval config keys, e.g. ws::addr=localhost:9000;auto_flush_rows=10000;auto_flush_interval=1000;.

Confirm a batch is durably received. Over QWP each flush returns a frame sequence number (FSN); awaitAckedFsn blocks until the server has acknowledged it. With sf_dir, rows in the store-and-forward log replay after reconnect or a producer-process restart. For periodic host-power-loss checkpoints, also configure sf_durability=periodic;sf_sync_interval_millis=5000;.

try (Sender sender = db.borrowSender()) {
    for (Trade t : batch) {
        sender.table("trades").symbol("symbol", t.symbol).doubleColumn("price", t.price).atNow();
    }
    long fsn = sender.flushAndGetSequence();          // publish the batch, get its sequence number
    if (sender.awaitAckedFsn(fsn, 30_000)) {          // block up to 30s for the server ack
        // batch acknowledged by the server
    } else {
        // not yet acked within the timeout; it stays buffered and replays on reconnect
    }
}

Query Data

Borrow a Query, set the SQL and a result handler, submit, and await. The handler receives results a batch at a time; submit() returns a Completion you can await() synchronously, time out on, or cancel().

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

QwpColumnBatchHandler handler = new QwpColumnBatchHandler() {
    @Override
    public void onBatch(QwpColumnBatch batch) {
        for (int r = 0; r < batch.getRowCount(); r++) {
            System.out.println(batch.getDoubleValue(0, r));
        }
    }

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

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

try (Query q = db.borrowQuery()) {
    q.sql("SELECT price FROM trades WHERE symbol = 'BTC-USD' LIMIT 10")
            .handler(handler)
            .submit()
            .await();
}

Query with Bind Parameters

Reuse the same SQL text with different values via bind parameters. The same SQL text reuses the server's compiled-factory cache; interpolating values into the SQL string defeats that cache.

import java.util.concurrent.TimeUnit;

try (Query q = db.borrowQuery()) {
    q.sql("SELECT price FROM trades WHERE symbol = $1 LIMIT $2")
            .binds(binds -> {
                binds.setVarchar(0, "BTC-USD");
                binds.setLong(1, 10L);
            })
            .handler(handler)
            .submit()
            .await();
}

Cancel or Time Out a Query

submit() returns a Completion. await(timeout, unit) returns false if the query is still in flight; cancel() stops it.

import io.questdb.client.Completion;

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();
        c.await(); // wait for the terminal (cancelled) state
    }
}

A borrowed Query handle is single-flight: submit one query at a time on it, then submit again or close it. To run queries concurrently, borrow one handle per concurrent query (up to query_pool_max).

Custom Pool Sizing and Timeouts

Use the builder to override the defaults. senderPoolSize/queryPoolSize set a fixed pool size; *Min/*Max allow an elastic pool that grows under load and is reaped back when idle.

try (QuestDB db = QuestDB.builder()
        .fromConfig("ws::addr=node1:9000,node2:9000;")
        .senderPoolSize(8)
        .queryPoolSize(4)
        .acquireTimeoutMillis(10_000)
        .build()) {
    // ... use db ...
}

Multiple Servers and Failover

List every cluster node in one addr server list; the single string configures both the ingest and query pools across all of them. On the query side, target selects the node role to route to (any, primary, or replica) and failover=on enables failover across the list. The ingest side reconnects across the same node list on its own — a store-and-forward sender keeps buffering rows through a failover window and replays unacknowledged data. The default memory durability mode protects process restarts, not host power loss; use periodic durability for background disk checkpoints.

try (QuestDB db = QuestDB.connect(
        "ws::addr=node1:9000,node2:9000,node3:9000;target=primary;failover=on;")) {
    try (Sender s = db.borrowSender()) {
        s.table("trades").symbol("symbol", "BTC-USD").doubleColumn("price", 42_500.50).atNow();
    }
    try (Query q = db.borrowQuery()) {
        q.sql("SELECT count() FROM trades").handler(handler).submit().await();
    }
}

Zone-Aware Query Routing

In a multi-zone deployment, zone tells the query pool to prefer endpoints in the same zone, cutting cross-zone read latency. It is a query-routing hint (opaque, case-insensitive), matched against each server's advertised zone; it applies only to target=any / target=replica (a primary is followed across zones). Cross-zone hosts remain fallbacks, so a same-zone outage still fails over. client_id is an opaque identifier surfaced server-side for observability.

try (QuestDB db = QuestDB.connect(
        "ws::addr=node1:9000,node2:9000,node3:9000;"
                + "target=replica;zone=eu-west-1a;failover=on;client_id=dashboard/2.0;")) {
    try (Query q = db.borrowQuery()) {
        q.sql("SELECT price FROM trades WHERE symbol = 'BTC-USD' LIMIT 10")
                .handler(handler)
                .submit()
                .await();
    }
}

Observe Ingest Errors and Connection Events

Register callbacks on the builder to observe asynchronous ingest errors and connection transitions across the whole sender pool. Callbacks run on the senders' I/O threads, so they must be thread-safe and must not block.

try (QuestDB db = QuestDB.builder()
        .fromConfig("ws::addr=localhost:9000;")
        .errorHandler(error ->
                System.err.println("ingest error: " + error.getCategory() + " " + error.getServerMessage()))
        .connectionListener(event ->
                System.out.println("connection event: " + event.getKind()))
        .build()) {
    // ... use db ...
}

Tolerant Startup When the Server May Be Down

Set lazy_connect=true to start the handle even when the server is down. The ingest side buffers writes (store-and-forward) until the wire is up, and the read pool connects lazily on the first borrowQuery() once the server is available — reads stay enabled, they are just deferred.

try (QuestDB db = QuestDB.connect("ws::addr=localhost:9000;lazy_connect=true;")) {
    try (Sender s = db.borrowSender()) {
        s.table("trades").symbol("symbol", "ETH-USD").doubleColumn("price", 2615.54).atNow();
        // Buffers while the server is down; flushes once it comes up.
    }
    // Later, once the server is up, reads connect on first borrow:
    try (Query q = db.borrowQuery()) {
        q.sql("SELECT 1").handler(handler).submit().await();
    }
}

Bound the Connect Timeout

connect_timeout (milliseconds) bounds the TCP connect and TLS handshake so a black-holed or firewalled host fails fast instead of waiting out the OS-level connect timeout.

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

Authentication and TLS

Use the wss schema for TLS. Auth keys apply to both the ingest and query WebSocket upgrades.

Bearer token (wss):

try (QuestDB db = QuestDB.connect("wss::addr=db.questdb.cloud:9000;token=YOUR_TOKEN_HERE;")) {
    // ... use db ...
}

Username / password:

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

Custom PEM certificate authority:

try (QuestDB db = QuestDB.connect(
        "wss::addr=localhost:9000;tls_roots=/path/to/ca.pem;")) {
    // ... use db ...
}

tls_roots accepts a PEM certificate or bundle directly. For an existing JKS or PKCS#12 trust store, also set tls_roots_password; the password switches the file interpretation from PEM to a Java trust store.

Disable certificate validation (not for production):

try (QuestDB db = QuestDB.connect("wss::addr=localhost:9000;tls_verify=unsafe_off;")) {
    // ... use db ...
}

OIDC Sign-In (Device Flow)

For QuestDB Enterprise instances secured with OIDC, OidcDeviceAuth signs a user in interactively using the OAuth 2.0 Device Authorization Grant. It works from environments that have no local browser — a remote notebook kernel, a container, a headless job — because the user authorizes on any device (laptop or phone) while the process only makes outbound calls to the identity provider.

On first use it prints a verification URL and a short code, and opens the URL in your default browser when one is available; authorize there (or open the URL on any device, such as your phone), enter the code, and the token is cached in memory and refreshed silently on later calls.

import io.questdb.client.QuestDB;
import io.questdb.client.Sender;
import io.questdb.client.cutlass.auth.OidcDeviceAuth;

// Discover the client id, scope and endpoints from the QuestDB server's /settings:
try (OidcDeviceAuth auth = OidcDeviceAuth.fromQuestDB("https://questdb.example.com:9000")) {
    auth.signIn(); // sign in once: prompts on first use, then caches and refreshes

    // The provider is shared by the ingest and query pools. It is queried for
    // every initial WebSocket upgrade and reconnect, so both pools follow token
    // rotation without putting a credential in the configuration string.
    try (QuestDB db = QuestDB.connect(
            "wss::addr=questdb.example.com:9000;",
            auth::getToken)) {
        try (Sender sender = db.borrowSender()) {
            sender.table("trades")
                    .symbol("symbol", "ETH-USD")
                    .doubleColumn("price", 2615.54)
                    .atNow();
        }
        // db.borrowQuery() uses the same provider for query connections.
    }
}

For a standalone sender, use httpTokenProvider(auth::getToken) for the same rotating-token behavior. A fixed httpToken(token) or token= connect-string value captures the token once, so a client that reconnects after that token expires starts failing authentication. Hand rotating credentials to the provider API, not a Sender.fromConfig(...) string or the QDB_CLIENT_CONF environment variable, which are easily logged, persisted, or left in shell history.

getToken() sits on a hot path — it is called once per ILP flush and once per WebSocket upgrade or reconnect — so a credential failure is rate-limited rather than retried on every call. When a silent refresh fails, getToken() does not attempt another one for 5 seconds: calls inside that window fail immediately, asking for an interactive signIn(), instead of hitting the identity provider again. Without that guard a producer retrying its rows would drive one token-endpoint round trip per flush, blocking the producer thread for each one and hitting the provider hard enough to trip its rate limits and lengthen the very outage being retried. Only a real refresh attempt arms the guard, and an explicit signIn() or clearCache() clears it outright. It is deliberately short — a stampede guard, not a circuit breaker — so a credential that comes back within seconds is picked up on the first call after the window rather than on the first call after it recovers.

By default the prompt prints the verification URL and code to System.out and tries to open the URL in your default browser. The browser open is best-effort: it only opens an http(s) URL, is skipped on a headless host or a JVM without the java.desktop module, and never blocks sign-in (the client declares requires static java.desktop, so the module is optional at run time and its absence can never break module resolution; a modular application therefore gets the browser launch only when java.desktop is in its own module graph) — the URL and code are always printed too, so a remote or browserless process still works. To disable the browser launch for a whole process (a server, automation, CI), set the system property -Dquestdb.client.oidc.open.browser=false. To print only (no browser) for a single client, pass DeviceCodePrompt.SYSTEM_OUT; to render the challenge yourself (a clickable link or QR code in a notebook), pass any DeviceCodePrompt:

// print only, do not open a browser:
try (OidcDeviceAuth auth = OidcDeviceAuth.fromQuestDB(
        "https://questdb.example.com:9000",
        new OidcDeviceAuth.DiscoveryOptions().prompt(DeviceCodePrompt.SYSTEM_OUT))) {
    auth.signIn();
}

The same token can be presented to QuestDB over any auth path the server already validates:

  • REST API: send it as an Authorization: Bearer <token> header (auth.getAuthorizationHeaderValue() returns the full value).
  • PG-wire: connect as user _sso with the token as the password (requires acl.oidc.pg.token.as.password.enabled=true on the server).

To configure the identity provider explicitly instead of discovering it from the server:

OidcDeviceAuth auth = OidcDeviceAuth.builder()
        .clientId("questdb")
        .deviceAuthorizationEndpoint("https://idp.example.com/as/device_authz.oauth2")
        .tokenEndpoint("https://idp.example.com/as/token.oauth2")
        .scope("openid groups")
        .groupsInToken(true) // matches acl.oidc.groups.encoded.in.token on the server
        .build();

Discovery via fromQuestDB(...) reads the OIDC client id, scope, audience and endpoints from the server's /settings, and the identity provider's client must have the device authorization grant enabled. When the server does not advertise its device authorization endpoint (today's servers), pin the identity provider by its issuer so the client can discover the endpoint from the issuer's .well-known/openid-configuration document:

try (OidcDeviceAuth auth = OidcDeviceAuth.fromQuestDB(
        "https://questdb.example.com:9000",
        new OidcDeviceAuth.DiscoveryOptions().issuer("https://idp.example.com"))) {
    auth.signIn();
}

The identity provider's device authorization and token endpoints must use https — a loopback endpoint (localhost or 127.0.0.0/8) may use http, since the request never leaves the host — so the device code and refresh token are never sent in cleartext. That is a rule about the scheme: the trust anchor is tlsConfig, and one OidcDeviceAuth carries a single one, so ClientTlsConfiguration.INSECURE_NO_VALIDATION — reached for to talk to a QuestDB server with a self-signed certificate — also stops the client validating the identity provider's certificate on the legs that carry the device code and the refresh token. Point tlsConfig at a trust store rather than disabling validation whenever an identity provider is involved. allowInsecureTransport(true) relaxes only the QuestDB /settings link (for local development against an http QuestDB server), e.g. OidcDeviceAuth.fromQuestDB(url, new OidcDeviceAuth.DiscoveryOptions().allowInsecureTransport(true)); it never relaxes the identity provider endpoints, matching the Python client.

fromQuestDB(...) takes the identity provider endpoints from the server's unauthenticated /settings, so it trusts that server to designate where you sign in: a spoofed, compromised, or man-in-the-middled server could redirect the sign-in to an attacker-controlled identity provider. Only use it against a server you trust, reached over https. Passing an issuer hardens this: the token and device authorization endpoints are then pinned to the issuer's origin (and, when the issuer has a path, an endpoint advertised by /settings must also be under that path — so a tampered /settings cannot redirect to a different tenant on a path-based provider such as Keycloak …/realms/{realm}), and an endpoint outside it is rejected; the issuer itself comes from you out of band, so a tampered /settings cannot move it. When .well-known discovery is needed, the document must also return the exact issuer prefix used to retrieve it before any discovered endpoint is accepted. When the server is not trusted, configure the identity provider explicitly with OidcDeviceAuth.builder() (optionally with .issuer(...)) instead of discovering it.

Persisting the Token Across Restarts

By default the token lives in memory only, so a process that restarts has to run the device flow again. Pass a TokenStore to persist it; the restarted process then resumes from the saved refresh token — a silent call to the token endpoint — instead of prompting the user again:

import io.questdb.client.cutlass.auth.FileTokenStore;

try (OidcDeviceAuth auth = OidcDeviceAuth.fromQuestDB(
        "https://questdb.example.com:9000",
        new OidcDeviceAuth.DiscoveryOptions().tokenStore(FileTokenStore.atDefaultLocation()))) {
    auth.signIn(); // prompts the first time; after a restart it refreshes silently from the saved token
}

FileTokenStore.atDefaultLocation() writes one file per OIDC configuration under ${user.home}/.questdb/oidc-tokens/ (override the directory with the questdb.client.oidc.token.store.dir system property). The file name is a hash of the endpoints, client id, scope, audience and groups-in-token mode — the configuration, not the person who signed in through it, since none of those fields names a subject. Entries for different servers, providers or client configurations therefore stay separate, but two people signing in through the same configuration share one entry: whoever signs in last overwrites the previous token, so a store represents a single active login. If more than one application user has to be signed in at the same time, give each their own store — FileTokenStore.at(dir) on a per-user directory, or a per-user questdb.client.oidc.token.store.dir — rather than relying on the file name to separate them. The default location is already per OS user, so this only arises when one OS user (a shared service account, a multi-tenant process) signs in as several people. After a restart, getToken() also works as the first call — no explicit signIn() needed — which suits a long-lived Sender built with httpTokenProvider(auth::getToken). clearCache() removes the persisted entry and forces a fresh sign-in next time.

A store read that throws — an unreadable file after a chmod or a uid change in a container, EIO/ESTALE on an NFS home — is not fatal and does not disable persistence for the life of the process, but it is not retried on every call either, since getToken() would otherwise pay a blocking file open and a WARN line per ILP flush, forever. The first failure is retried immediately, so a one-shot fault (notably a carried interrupt flag, which makes the channel underneath FileTokenStore throw on a thread that merely carries it) recovers on the next call; each consecutive failure after that backs off 5 seconds, doubling to a 60 second cap. A store that simply has nothing to return is unaffected — load reports that by returning null rather than by throwing, and the client stops asking.

The token is stored as plaintext JSON protected by file permissions — 0600 file, 0700 directory on POSIX systems (Linux, macOS), the same approach gcloud, aws and gh take. On Windows these POSIX permissions cannot be enforced, so the file currently relies on the user-profile directory's default ACL (owner-only ACL hardening is a follow-up); the client logs a one-line warning through SLF4J at WARN the first time it cannot enforce them (the library ships slf4j-api only, so this - and every other client warning - is discarded unless your application supplies an SLF4J binding). Enabling persistence therefore writes a long-lived refresh token to disk: anyone who can read the file holds a credential until it expires or is revoked. To encrypt it at rest, supply your own TokenStore (backed by an OS keychain or a secrets manager) instead of FileTokenStore. A persisted file is treated as untrusted input on load, but it is not cryptographically authenticated — there is no MAC or signature over its contents. Anyone who can write the file can therefore substitute a well-formed entry of their own, and the client will adopt it and present those tokens: the file permissions, not the file format, are what protect it. What the load path rejects is corruption and mix-ups rather than forgery — an oversized, malformed or unparseable file; an entry whose recorded client id, endpoints, scope, audience or groups-in-token mode does not match the identity being loaded; an entry carrying no usable token; and a token with control or non-ASCII characters, which is never placed on the wire — with the recorded expiry and lifetime clamped rather than trusted. In each case the client falls back to a refresh or an interactive sign-in. On POSIX the container is checked too: if the directory was writable by other local users, it is tightened back to 0700 and every entry in it is discarded — not just the one being read, since the tightening is what destroys the evidence, so anything left behind would look protected to the next load. Each identity then signs in again. Inside those permissions, though, the client cannot tell a planted credential from its own.

FileTokenStore is safe to share between processes that sign in as the same identity: each update is written atomically (so a concurrent reader never sees a half-written credential), and when the identity provider rotates the refresh token on each refresh, the read-refresh-write is serialized across processes with a lock file so they do not race each other into an unnecessary re-prompt. The lock file's staleness is judged by its modification time, so this coordination assumes the processes share a clock — a single machine, or machines with synchronized clocks; under significant clock skew (for example a store directory on NFS shared across hosts) a live lock can be mis-judged stale or a dead one never expire. clearCache() removes the persisted entry under the same lock, but across processes it is best-effort: a peer that still holds a live in-memory token may legitimately re-persist afterwards (it always forces a fresh sign-in for the calling process).

Rotating Bearer Tokens (Microsoft Entra ID and Other Identity Platforms)

Service identities - an Azure managed identity, a service principal, an AKS workload identity - authenticate with bearer tokens that expire every hour or every day. Select a token provider and the client fetches tokens itself, keeps them in a shared cache, and refreshes them in the background well before they expire, so long-lived senders and query clients keep reconnecting with a valid token.

Microsoft Entra ID from a connect string. Add the optional questdb-client-azure artifact (same version as questdb-client); it brings Azure Identity and registers token_provider=azure:

<dependency>
    <groupId>org.questdb</groupId>
    <artifactId>questdb-client-azure</artifactId>
    <version>1.0.0</version>
</dependency>
try (QuestDB db = QuestDB.connect(
        "wss::addr=qdb1:9000,qdb2:9000;token_provider=azure;azure_resource=api://<questdb-app-id>;"
                + "azure_credential=managed_identity;")) {
    // ... use db ...
}
  • azure_resource (required) is the application ID URI (api://<app-id>) or the client ID of the QuestDB app registration; the client requests the scope <azure_resource>/.default.
  • azure_credential (optional) selects the credential. In production, name it: managed_identity, workload_identity or environment (a service principal from AZURE_TENANT_ID, AZURE_CLIENT_ID and AZURE_CLIENT_SECRET or a certificate). A managed identity selected this way retries an unreachable endpoint, and fails fast when the identity is not assigned to the host.
  • default, the default, is Azure Identity's DefaultAzureCredential, a discovery chain meant for development: an environment service principal, a workload identity, a managed identity, or developer tools. It checks the managed-identity endpoint once without retrying, so an outage of that endpoint fails a startup as if the host had no credential; the client logs a warning saying so.
  • azure_client_id (optional) selects a user-assigned managed identity or a workload identity. It cannot be combined with azure_credential=environment.
  • No secret ever goes into the connect string, so it stays safe to log and to put in QDB_CLIENT_CONF.
  • token_provider requires wss::, and cannot be combined with token, username/password or an application-supplied provider.
  • Every sender, query client and pooled connection built from equivalent connect strings shares one provider per process: one refresher thread, one call to Entra at a time.

On the server, validate tokens locally (acl.oidc.groups.encoded.in.token=true, acl.oidc.groups.claim=roles, acl.oidc.sub.claim=oid), set the QuestDB app registration to issue v2 tokens, and map its app roles to groups with CREATE GROUP ... WITH EXTERNAL ALIAS '<role>'.

Any other identity platform. Wrap a TokenSource in a RefreshingTokenProvider and pass the provider to QuestDB.connect(config, provider), httpTokenProvider(...) or withBearerTokenProvider(...). The application owns the provider: close it after the clients that use it.

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(() -> {
    MyToken t = myIdentityPlatform.requestToken(); // runs on the provider's own thread
    return new ExpiringToken(t.value(), t.expiresAtEpochMillis());
}).build();
try (QuestDB db = QuestDB.connect("wss::addr=qdb1:9000;", tokens)) {
    // ... use db ...
} finally {
    tokens.close();
}

A source reports a failure by throwing TokenUnavailableException.retryable(...) (network trouble, throttling, 5xx) or TokenUnavailableException.permanent(...) (missing or wrong configuration); anything else counts as retryable. Never put a token or a raw response body in the message. The provider keeps retrying with backoff and keeps serving the current token while it is still valid.

The provider hands a token out only while it has more than 60 s left (handoutFloorMillis, the hand-out floor). A source whose token arrives with 60 s or less left gets a failed fetch whose error names handout_floor. A token that arrives with less than 90 s left is handed out, but it cannot be refreshed before it enters the floor, because refreshes are at least 30 s apart (minRefreshIntervalMillis): callers wait for a fetch whenever such a token runs out, and the provider logs a warning once. If your platform issues tokens that short, lower the floor with handoutFloorMillis(...).

How failures are handled:

  • The provider refreshes a token proactively, usually at about half its lifetime, and starts earlier for shorter hand-out windows where the minimum refresh interval permits. Uninterrupted availability requires the refresh to finish before the current token enters the hand-out floor. Even a successful fetch can leave callers waiting: with defaults, a 100 s token starts refreshing at +30 s and stops being handed out at +40 s, leaving only 10 s for the refresh to complete.
  • When the server rejects a token with 401, the client asks the provider for a new one and retries the same server once, immediately.
  • At startup, initial_connect_retry=off (the default) fails if no token can be obtained; on keeps retrying a retryable provider failure within reconnect_max_duration_millis; async retries in the background.
  • Once connected, a store-and-forward sender rides out credential outages and 401/403 indefinitely, buffering rows and reporting each failure to the error handler and in its connection health. It never fails on its own: an application that prefers a hard failure watches the health or the error handler, and closes the sender itself.

Connection Health

A sender that rides out an outage keeps accepting rows, so check its connection health to see that it is not reaching the server. Reading health never blocks and never contains a credential, so it can back a health endpoint.

ConnectionHealth.Aggregate health = db.health(); // every pooled sender and query client
if (health.count(ConnectionHealth.State.RECONNECTING) > 0) {
    long since = health.getOldestOutageSinceEpochMillis();
    ConnectionHealth.Failure failure = health.getLastFailure(); // e.g. AUTH_REJECTED 401, CREDENTIAL_UNAVAILABLE
    // ...
}

Sender.health() and QwpQueryClient.health() return the same information for a single client: its state (CONNECTING, CONNECTED, RECONNECTING, FAILED, CLOSED), the last successful connect, the start of the current outage, the number of failed connect rounds since, and the last failure with its class.

Explicit Timestamps

import java.time.Instant;
import java.time.temporal.ChronoUnit;

try (Sender sender = db.borrowSender()) {
    // Using an Instant
    sender.table("trades")
            .symbol("symbol", "ETH-USD")
            .doubleColumn("price", 2615.54)
            .at(Instant.now());

    // Using a long value with a time unit
    sender.table("trades")
            .symbol("symbol", "BTC-USD")
            .doubleColumn("price", 39_269.98)
            .at(1_000_000_000L, ChronoUnit.NANOS);
}

Configuration Reference

The configuration string format is:

schema::key1=value1;key2=value2;

Schemas (facade): ws, wss (TLS). A single string configures the whole cluster for both ingest and queries.

Common keys

Key Default Description
addr (required) Server address(es) as host:port, comma-separated for a cluster
username / user Basic-auth username
password / pass Basic-auth password
token Bearer token (sent as an Authorization header on the WS upgrade)
tls_verify on TLS certificate validation (on or unsafe_off)
tls_roots Path to a PEM certificate/bundle, or a JKS/PKCS#12 trust store
tls_roots_password Optional JKS/PKCS#12 password; omit when tls_roots is PEM
connect_timeout (OS) TCP connect + TLS handshake timeout, in milliseconds
auth_timeout_ms 15000 Authentication/upgrade request timeout, in milliseconds
token_provider Refreshing bearer-token provider, wss only: azure (needs questdb-client-azure)
azure_resource token_provider=azure: application ID URI or client ID of the QuestDB app registration
azure_client_id token_provider=azure: client ID of a user-assigned managed identity or workload identity
azure_credential default token_provider=azure: default, managed_identity, workload_identity or environment

Pool keys (facade only)

Key Default Description
lazy_connect off Tolerant startup: async ingest + lazy reads so build() succeeds server-down
sender_pool_min 1 Minimum ingest connections kept warm (0 lets the pool drain when idle)
sender_pool_max 4 Maximum ingest connections
query_pool_min 1 Minimum query connections kept warm (0 under lazy_connect)
query_pool_max 4 Maximum query connections
acquire_timeout_ms 5000 How long borrowSender()/borrowQuery() waits for a free slot
idle_timeout_ms 60000 How long a pooled connection may stay idle before it is reaped
max_lifetime_ms 1800000 Maximum lifetime of a pooled connection before it is recycled

Query routing keys

Applied by the query pool to select and fail over between the nodes in the addr list.

Key Default Description
target any Node role to route reads to: any, primary, or replica
failover off Enable query-side failover across the addr list
zone Prefer same-zone endpoints for target=any/replica (opaque, case-insensitive)
client_id Opaque client identifier surfaced server-side for observability

The ingest side also accepts store-and-forward and reconnection tuning keys (auto_flush_*, initial_connect_retry, reconnect_*, request_durable_ack, sf_*, max_frame_rejections, poison_min_escalation_window_millis, …). sf_max_total_bytes caps the unacknowledged data all pooled senders buffer together — 128 MiB of memory by default, 10 GiB of disk with sf_dir — so a larger sender_pool_max adds connections, not buffer memory. The only overshoot is the minimum working set every live sender keeps: two segments (2 × sf_max_segment_bytes, 8 MiB by default). sf_durability=periodic checkpoints mmap-published data in the background; sf_sync_interval_millis defaults to 5000 in that mode. The interval is a target cadence: JVM scheduling and storage-sync latency add to the actual loss window. Use request_durable_ack=on when end-to-end server durability is also required. See the QuestDB documentation for the full reference.

Requirements

  • Java 8 or later (the artifact ships as Java 8 bytecode)
  • Maven 3+ (for building from source)

Building from Source

git clone https://github.com/questdb/java-questdb-client.git
cd java-questdb-client
mvn clean package -DskipTests

The native libraries are built from source as part of the build — they are no longer committed to the repository. mvn package compiles libquestdb for the host platform and bundles it into the client jar, so a fresh checkout needs the native toolchain (see Building Native Libraries) before mvn -pl core test can load it.

Releasing

Maven Central publishing is owned by the manually triggered Release to Maven Central GitHub Actions workflow, run from the Actions tab. Do not publish from a local machine and do not run mvn deploy in the normal release path.

The workflow builds each shipped platform's native library from source, runs the full test suite with those freshly built binaries bundled, and validates the signed bundle with the Central Portal before it pushes a git tag or publishes anything. The Central publish is the single irreversible step and runs last; the next-development version bump lands as a follow-up pull request, so main keeps its PR-only protection.

The publish step is gated by the maven-release GitHub environment; configure it with required reviewers so the workflow pauses for human approval before any credentials are used or anything is published.

The release tag push uses a dedicated Maven release GitHub App that must be allowed to bypass the org restrict-tag-pushing ruleset; the built-in GITHUB_TOKEN/github-actions[bot] cannot be added for that bypass.

Full release procedure, one-time setup, and failure handling: artifacts/release/README.md.

Building Native Libraries

The client includes native libraries (C/C++ and assembly) for performance-critical operations. These are not committed to the repository: CI and the release pipeline build them from source, and the local build produces one for your host platform. Rebuild manually only if you are changing the native code.

Shipped release platforms: darwin-aarch64, linux-x86-64, linux-aarch64, windows-x86-64. (darwin-x86-64 / Intel macOS is not a shipped release target; build it from source locally if you need it.)

Prerequisites

Tool Version Notes
CMake 3.5+ Build system generator
NASM 2.14+ Netwide Assembler for assembly code
C/C++ Compiler GCC, Clang, or MinGW C++17 support required
Make Any Build tool
JDK 8+ For JNI headers

macOS (ARM64 or x86-64)

# Install build tools
brew install cmake nasm

# Set deployment target
export MACOSX_DEPLOYMENT_TARGET=13.0

# Build native library
cd core
cmake -B cmake-build-release -DCMAKE_BUILD_TYPE=Release
cmake --build cmake-build-release --config Release

Linux x86-64

# Install build tools (Debian/Ubuntu)
sudo apt-get install cmake nasm build-essential

# Build native library
cd core
cmake -DCMAKE_BUILD_TYPE=Release -B cmake-build-release -S.
cmake --build cmake-build-release --config Release

Linux ARM64

# Install build tools (Debian/Ubuntu)
sudo apt-get install cmake nasm build-essential

# Build using ARM64 toolchain
cd core
cmake -DCMAKE_TOOLCHAIN_FILE=./src/main/c/toolchains/linux-arm64.cmake \
      -DCMAKE_BUILD_TYPE=Release -B cmake-build-release-arm64 -S.
cmake --build cmake-build-release-arm64 --config Release

Windows x86-64 (Cross-compilation from Linux)

# Install cross-compilation tools (Debian/Ubuntu)
sudo apt-get install cmake nasm gcc-mingw-w64 g++-mingw-w64

# Build using Windows toolchain
cd core
cmake -DCMAKE_TOOLCHAIN_FILE=./src/main/c/toolchains/windows-x86_64.cmake \
      -DCMAKE_CROSSCOMPILING=True -DCMAKE_BUILD_TYPE=Release \
      -B cmake-build-release-win64
cmake --build cmake-build-release-win64 --config Release

Native Library Output Location

The build writes the library where the client loads it first (the "dev CXX lib" path):

core/target/classes/io/questdb/client/bin-local/
├── libquestdb.dylib  # macOS
├── libquestdb.so     # Linux
└── libquestdb.dll    # Windows

Community

License

This project is licensed under the Apache License 2.0.

About

The official Java client for QuestDB

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

10 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages