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
QuestDBfacade. Application code should obtain senders and query handles from the facade (db.borrowSender()/db.borrowQuery()), not by constructing them directly. Directly instantiating aSender(Sender.fromConfig(...),Sender.builder(...)), aLineUdpSender, or aQwpQueryClient/Querybypasses 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 onclose(). Reach for the low-levelSender/QwpQueryClientAPIs only when you are integrating them into your own pooling layer.
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.
docker run -p 9000:9000 questdb/questdbA 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 ...
}All examples assume an open QuestDB db handle (see Connect). Create the handle once and share it; the
snippets below borrow from its pools.
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.
}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
}
}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();
}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();
}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
Queryhandle 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 toquery_pool_max).
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 ...
}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();
}
}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();
}
}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 ...
}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();
}
}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 ...
}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 ...
}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
_ssowith the token as the password (requiresacl.oidc.pg.token.as.password.enabled=trueon 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.
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).
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);
}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.
| 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 |
| 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 |
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_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.
- Java 8 or later (the artifact ships as Java 8 bytecode)
- Maven 3+ (for building from source)
git clone https://github.com/questdb/java-questdb-client.git
cd java-questdb-client
mvn clean package -DskipTestsThe 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.
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.
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.)
| 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 |
# 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# 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# 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# 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 ReleaseThe 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
This project is licensed under the Apache License 2.0.