C & C++ client for QuestDB

The QuestDB C and C++ client connects to QuestDB over the QWP — QuestDB Wire Protocol — a columnar binary protocol carried over WebSocket. The library is implemented in Rust and exposes both a C11 ABI and a C++17 header-only wrapper from a single shared/static library.

Two complementary APIs live in the same library:

  • Ingestion (line_sender_* / questdb::ingress::line_sender): column-oriented batched writes with automatic table creation, schema evolution, multi-host failover, and optional store-and-forward durability.
  • Querying (line_reader_* / questdb::egress::reader): parameterised SQL over the QWP egress endpoint (/read/v1), with streaming batch results, per-query failover, and credit-based flow control. See Querying and SQL execution.
Transports

QWP/WebSocket (ws:: / wss::) is the current default ingest path and the focus of this page. The same library also supports the legacy ILP transports (http:: / https:: / tcp:: / tcps::) and QWP over UDP for trusted high-throughput networks. For ILP transport details, see the ILP overview.

info

The ingestion and query clients are independent — each opens its own WebSocket connection. You can also query QuestDB from C/C++ via the PGWire C++ client or the REST API when the QWP transport is not available.

Quick start

Prerequisites

  • A C11 or C++17 compiler (tested with GCC and Clang).
  • CMake 3.15 or newer.
  • Rust 1.61 or newer (only required when building the library from source).

Build the library

Clone and build from c-questdb-client:

git clone https://github.com/questdb/c-questdb-client.git
cd c-questdb-client
cmake -S . -B build -DCMAKE_BUILD_TYPE=Release
cmake --build build

The build produces both a static (libquestdb_client.a) and a shared (libquestdb_client.so / .dylib / .dll) library. Headers live in include/questdb/ingress/. The same build directory also contains runnable example binaries (line_sender_c_example* for C, line_sender_cpp_example* for C++) that are useful as reference workloads.

Hello world

#include <questdb/ingress/line_sender.h>
#include <stdio.h>
#include <string.h>

int main(void) {
line_sender_error* err = NULL;
line_sender* sender = NULL;
line_sender_buffer* buffer = NULL;

const char* conf = "ws::addr=localhost:9000;";
line_sender_utf8 conf_utf8;
if (!line_sender_utf8_init(&conf_utf8, strlen(conf), conf, &err)) goto on_error;

sender = line_sender_from_conf(conf_utf8, &err);
if (!sender) goto on_error;

buffer = line_sender_buffer_new_for_sender(sender);

line_sender_table_name tbl = QDB_TABLE_NAME_LITERAL("trades");
line_sender_column_name symbol_name = QDB_COLUMN_NAME_LITERAL("symbol");
line_sender_column_name side_name = QDB_COLUMN_NAME_LITERAL("side");
line_sender_column_name price_name = QDB_COLUMN_NAME_LITERAL("price");
line_sender_column_name amount_name = QDB_COLUMN_NAME_LITERAL("amount");
line_sender_utf8 symbol_val = QDB_UTF8_LITERAL("ETH-USD");
line_sender_utf8 side_val = QDB_UTF8_LITERAL("sell");

if (!line_sender_buffer_table(buffer, tbl, &err)) goto on_error;
if (!line_sender_buffer_symbol(buffer, symbol_name, symbol_val, &err)) goto on_error;
if (!line_sender_buffer_symbol(buffer, side_name, side_val, &err)) goto on_error;
if (!line_sender_buffer_column_f64(buffer, price_name, 2615.54, &err)) goto on_error;
if (!line_sender_buffer_column_f64(buffer, amount_name, 0.00044, &err)) goto on_error;
if (!line_sender_buffer_at_nanos(buffer, line_sender_now_nanos(), &err)) goto on_error;

if (!line_sender_flush(sender, buffer, &err)) goto on_error;
if (!line_sender_qwpws_close_drain(sender, &err)) goto on_error;

line_sender_buffer_free(buffer);
line_sender_close(sender);
return 0;

on_error:;
size_t err_len = 0;
const char* msg = line_sender_error_msg(err, &err_len);
fprintf(stderr, "error: %.*s\n", (int)err_len, msg);
line_sender_error_free(err);
if (buffer) line_sender_buffer_free(buffer);
if (sender) line_sender_close(sender);
return 1;
}
QDB_*_LITERAL is for string literals only

The QDB_*_LITERAL macros expand to sizeof(literal) - 1; passing a const char* variable compiles but silently encodes the pointer size (typically 7 bytes), not the string length. For runtime strings, use the _init form — see how conf above is initialized with line_sender_utf8_init. The matching _table_name_init and _column_name_init exist as well.

Compile with:

gcc -std=c11 hello.c \
-I /path/to/c-questdb-client/include \
-L /path/to/c-questdb-client/build -lquestdb_client \
-o hello

Linking against the shared libquestdb_client.so needs no extra libraries. The static libquestdb_client.a additionally requires -lpthread -ldl -lm (and TLS deps if the build was configured with rustls or OpenSSL).

If you linked against an in-tree build, the binary needs to find libquestdb_client.so at runtime — either set LD_LIBRARY_PATH=/path/to/c-questdb-client/build before running, or add -Wl,-rpath,/path/to/c-questdb-client/build to the link line.

For production builds, prefer CMake integration — see the upstream dependency guide.

The four steps are:

  1. Build a sender from a connect string.
  2. Append rows to a buffer.
  3. Call flush() to publish.
  4. Call close_drain() before destroying the sender so already-published frames complete on the wire.

C function names that include qwpws — for example line_sender_qwpws_close_drain in step 4, or line_sender_qwpws_poll_error in Asynchronous error handling — are QWP/WebSocket-specific. Unprefixed functions (line_sender_close, line_sender_flush, the buffer setters) work for any transport the library supports.

Authentication and TLS

Authentication happens at the HTTP level during the WebSocket upgrade, before any binary frames are exchanged.

HTTP basic auth

wss::addr=db.example.com:9000;username=admin;password=quest;
line_sender_utf8 conf = QDB_UTF8_LITERAL(
"wss::addr=db.example.com:9000;username=admin;password=quest;");
line_sender_error* err = NULL;
line_sender* sender = line_sender_from_conf(conf, &err);

Token authentication has lower overhead than basic auth and is the recommended path for Enterprise deployments.

wss::addr=db.example.com:9000;token=your_bearer_token;

TLS

Use the wss schema for TLS. Select where root certificates come from with tls_ca:

KeyDescription
tls_ca=webpki_rootsUse bundled webpki roots.
tls_ca=os_rootsUse the OS certificate store.
tls_ca=webpki_and_os_rootsCombine both.
tls_roots=/path/to/root-ca.pemLoad roots from a PEM file. Useful for self-signed certs during testing.
tls_verify=unsafe_offDisable verification. Never use in production. Requires the library to be built with the insecure-skip-verify Cargo feature; the default builds reject this value.

Example with a custom CA file:

wss::addr=db.example.com:9000;tls_roots=/etc/ssl/qdb-ca.pem;

Authentication timeout

auth_timeout_ms (default 15000) controls how long the client waits for the WebSocket upgrade to complete. auth_timeout is also accepted for compatibility with the HTTP transport's spelling.

Unsupported auth paths

The C/C++ client supports only HTTP basic auth and static bearer-token auth (the two methods documented above). The following are not supported:

PathStatusWorkaround
OIDC token acquisition or in-band refreshNot supported by this client. The client does not negotiate with an identity provider and has no callback to refresh a token mid-session.QuestDB itself supports OIDC — see OpenID Connect. To connect this client to an OIDC-protected QuestDB, acquire an access token out-of-band from your IdP, pass it via token=... above, and rebuild the sender when the token nears expiry.
Mutual TLS (client certificates)Not supported. The QuestDB server does not negotiate client certificates regardless of client.Use bearer-token auth over wss://. See the connect-string reference's TLS section for the canonical statement.
Token rotation mid-sessionNot supported. Credentials are presented once during the WebSocket upgrade and are not re-sent.On token expiry, call line_sender_qwpws_close_drain / sender.close_drain(), free the sender, and build a fresh one with the new token.

Creating the client

From a connect string

The connect string format is <schema>::<key>=<value>;<key>=<value>;...

Use ws (plain) or wss (TLS). The default port is 9000.

line_sender_utf8 conf = QDB_UTF8_LITERAL("ws::addr=localhost:9000;");
line_sender_error* err = NULL;
line_sender* sender = line_sender_from_conf(conf, &err);

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

From an environment variable

Set QDB_CLIENT_CONF to keep credentials out of source code:

export QDB_CLIENT_CONF="wss::addr=db.example.com:9000;username=admin;password=quest;"
line_sender_error* err = NULL;
line_sender* sender = line_sender_from_env(&err);

Using the options API

For callers that prefer typed setters over a connect string, build the sender through line_sender_opts:

line_sender_error* err = NULL;
line_sender_utf8 host = QDB_UTF8_LITERAL("localhost");
line_sender_opts* opts = line_sender_opts_new(
line_sender_protocol_qwpws, host, 9000);
if (!line_sender_opts_qwpws_progress(
opts, LINE_SENDER_QWPWS_PROGRESS_BACKGROUND, &err))
goto on_error;
line_sender* sender = line_sender_build(opts, &err);
line_sender_opts_free(opts);

Most QWP/WebSocket settings are best configured through the connect string. The options API mirrors the same keys with C/C++-typed setters; see the function prototypes under bool line_sender_opts_* in line_sender.h for the full set.

Data ingestion

Concurrency

line_sender is single-owner: only one thread may call publishing methods on a given sender at a time. For concurrent producers, create one sender per producer thread, or hand rows to a single owner over a queue.

Buffers (line_sender_buffer) are also single-owner but are not tied to a specific sender. Give each encoder thread its own buffer, fill it locally, and hand the buffer to the sender thread (or call flush() if the same thread owns both). This lets worker threads encode rows in parallel and serialises only the publish step.

When several sender instances share an sf_dir, give each a distinct sender_id, slots are exclusive (see Store-and-forward).

General usage pattern

  1. Call line_sender_buffer_table(buffer, name, &err) (C++ buffer.table(name)) to select a target table.
  2. Call typed column setters to add values (see Column setters below).
  3. Finalize the row with line_sender_buffer_at_nanos, line_sender_buffer_at_micros, or line_sender_buffer_at_now (C++ buffer.at(...) / buffer.at_now()).
  4. Repeat from step 1, or call line_sender_flush(sender, buffer, &err) (C++ sender.flush(buffer)) to publish.

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

A typical ingest loop reuses both the sender and the buffer; a successful line_sender_flush clears the buffer (a failed flush retains the rows so you can retry):

while (running) {
if (!line_sender_buffer_table(buffer, tbl, &err)) break;
if (!line_sender_buffer_symbol(buffer, sensor_name, sensor_val, &err)) break;
if (!line_sender_buffer_column_f64(buffer, temp_name, read_temp(), &err)) break;
if (!line_sender_buffer_at_nanos(buffer, line_sender_now_nanos(), &err)) break;
if (!line_sender_flush(sender, buffer, &err)) break;
sleep_one_second();
}

Column setters

QWP buffers accept every QuestDB column type. The C ABI exposes each type as a separate function; the C++ wrapper overloads column() for primitive scalars and provides dedicated methods for everything else.

QuestDB typeC functionC++ method
SYMBOLline_sender_buffer_symbolbuffer.symbol(name, value)
BOOLEANline_sender_buffer_column_boolbuffer.column(name, bool)
BYTE (i8)line_sender_buffer_column_i8buffer.column_i8(name, value)
SHORT (i16)line_sender_buffer_column_i16buffer.column_i16(name, value)
INT (i32)line_sender_buffer_column_i32buffer.column_i32(name, value)
LONG (i64)line_sender_buffer_column_i64buffer.column(name, int64_t)
FLOAT (f32)line_sender_buffer_column_f32buffer.column_f32(name, value)
DOUBLE (f64)line_sender_buffer_column_f64buffer.column(name, double)
CHARline_sender_buffer_column_charbuffer.column_char(name, code_unit)
VARCHARline_sender_buffer_column_strbuffer.column(name, std::string_view)
BINARYline_sender_buffer_column_binarybuffer.column_binary(name, bytes, len)
UUIDline_sender_buffer_column_uuidbuffer.column_uuid(name, lo, hi)
LONG256line_sender_buffer_column_long256buffer.column_long256(name, bytes)
IPv4line_sender_buffer_column_ipv4buffer.column_ipv4(name, value)
DATEline_sender_buffer_column_datebuffer.column_date(name, millis)
TIMESTAMP (non-designated)line_sender_buffer_column_ts_microsbuffer.column(name, timestamp_micros)
TIMESTAMP_NS (non-designated)line_sender_buffer_column_ts_nanosbuffer.column(name, timestamp_nanos)
GEOHASHline_sender_buffer_column_geohashbuffer.column_geohash(name, bits, precision)
DECIMAL (string form)line_sender_buffer_column_dec_strbuffer.column(name, decimal_str_view)
DECIMAL (binary, generic)line_sender_buffer_column_decbuffer.column(name, decimal_view)
DECIMAL64line_sender_buffer_column_dec64 / _dec64_strbuffer.column_dec64(name, ...)
DECIMAL128line_sender_buffer_column_dec128 / _dec128_strbuffer.column_dec128(name, ...)
DOUBLE[] (arrays)line_sender_buffer_column_f64_arr_c_major / _byte_strides / _elem_stridesbuffer.column(name, array_view)

Null values

The C ABI does not expose _opt variants for typed nulls (unlike the Rust client). To write a null for a column on a given row, omit the setter for that column — there is no explicit "set null" call.

// `amount` is omitted on this row, so it is stored as NULL.
if (!line_sender_buffer_table(buffer, tbl, &err)) goto on_error;
if (!line_sender_buffer_symbol(buffer, symbol_name, symbol_val, &err)) goto on_error;
if (!line_sender_buffer_column_f64(buffer, price_name, 2615.54, &err)) goto on_error;
if (!line_sender_buffer_at_nanos(buffer, line_sender_now_nanos(), &err)) goto on_error;

The designated timestamp cannot be null — every row requires one of at_nanos, at_micros, or at_now.

On a brand-new table, an omitted column is not inferred from that row. The server only adds the column when a later row supplies a non-null value for it, so first-row nulls leave the column absent until then.

Ingest arrays

The client encodes one-dimensional and multidimensional DOUBLE[] (and LONG[], when the server accepts it) columns. Three layouts are supported:

LayoutFunction (f64)When to use
Row-major (C-major)line_sender_buffer_column_f64_arr_c_majorContiguous, row-major (C-style) buffers.
Byte stridesline_sender_buffer_column_f64_arr_byte_stridesNon-contiguous data with strides expressed in bytes.
Element stridesline_sender_buffer_column_f64_arr_elem_stridesNon-contiguous data with strides expressed in elements.
// Row-major 3x2 array of f64.
uintptr_t shape[2] = {3, 2};
double values[6] = {1.0850, 600000.0, 1.0849, 300000.0, 1.0848, 150000.0};
if (!line_sender_buffer_column_f64_arr_c_major(
buffer,
QDB_COLUMN_NAME_LITERAL("bids"),
2, // rank
shape, // shape (length == rank)
values, // data (typed pointer)
sizeof(values) / sizeof(values[0]), // element count
&err))
goto on_error;

Array ingestion requires QuestDB 9.0.0 or later.

Decimal columns

caution

Decimal ingestion requires QuestDB 9.2.0 or later. Pre-create decimal columns with DECIMAL(precision, scale) so the server enforces the expected precision. See the decimal data type page.

The simplest path is the string form:

const char* price = "2615.54";
if (!line_sender_buffer_column_dec_str(
buffer,
QDB_COLUMN_NAME_LITERAL("price"),
price, strlen(price),
&err))
goto on_error;

For fixed-width binary forms, use line_sender_buffer_column_dec64 (one int64_t unscaled value) and line_sender_buffer_column_dec128 (a 16-byte unscaled little-endian integer).

Designated timestamp

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

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

// Microsecond precision creates a standard TIMESTAMP column.
if (!line_sender_buffer_at_micros(buffer, line_sender_now_micros(), &err))
goto on_error;

// Nanosecond precision creates a TIMESTAMP_NS column.
if (!line_sender_buffer_at_nanos(buffer, line_sender_now_nanos(), &err))
goto on_error;

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

if (!line_sender_buffer_at_now(buffer, &err))
goto on_error;

at_now removes the ability to deduplicate rows. Prefer explicit timestamps for production ingestion. See Delivery semantics for why server-assigned timestamps defeat exactly-once outcomes.

note

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

Flushing

The sender and buffer are decoupled. Buffered rows are not on the wire until you call line_sender_flush(sender, buffer, &err) (C++ sender.flush(buffer)).

No auto-flush

The QWP/WebSocket C/C++ client does not implement auto-flushing at all — you must call flush() explicitly. The connect-string keys auto_flush_rows and auto_flush_bytes are rejected; auto_flush=off is accepted only as a no-op for compatibility with HTTP/ILP connect strings.

A common pattern is to flush periodically on a timer and/or when the buffer exceeds a threshold — by encoded byte size (line_sender_buffer_size(buffer), C++ buffer.size()) or row count (line_sender_buffer_row_count(buffer), C++ buffer.row_count()).

flush() clears the buffer after publishing locally. Use line_sender_flush_and_keep (C++ sender.flush_and_keep(buffer)) to retain the contents, for example to fan the same buffer out to multiple senders.

On QWP/WebSocket, flush() returns once the buffer is accepted by the local sender queue, before the server acknowledges it. The queue is in process memory by default; setting sf_dir swaps it for the disk-backed store-and-forward queue. Either way, the call can block if the queue is full (see Backpressure on flush). Server errors observed later are reported asynchronously (see Asynchronous error handling).

Backpressure on flush

flush() is not unconditionally non-blocking. The publisher feeds a bounded queue with two stacked caps:

  1. In-flight window, max_in_flight (default 128) unacknowledged frames on the connection. Reached first under steady-state load when the server keeps up but you have many small flushes in flight.
  2. Queue cap, sf_max_total_bytes (default 128 MiB in memory mode, 10 GiB in disk mode). Reached when the server is unreachable long enough that the in-flight count stops being the active limit.

When either cap is hit, flush() blocks the caller and retries as the I/O loop releases capacity (ACK-driven trim). The wait is bounded by sf_append_deadline_millis (default 30000). If the deadline elapses, flush() returns an error with code line_sender_error_server_flush_error carrying a SubmitTimedOut diagnostic, the application can retry, fail closed, or shed load. No data is ever dropped or overwritten while the publisher is parked.

Column setters and line_sender_buffer_table(...) never block, they only mutate the in-process buffer. Backpressure surfaces only at flush().

Oversized payloads are rejected, not parked

A single flushed payload larger than sf_max_bytes (default 4 MiB) returns an error from flush() immediately, it does not enter the backpressure wait. Fixes: reduce the number of rows you accumulate per buffer before flushing, or raise sf_max_bytes to fit your largest single flushed payload.

FSN-based completion

Every published frame is assigned a frame sequence number (FSN). To wait until the server has acknowledged a specific frame:

line_sender_qwpws_fsn fsn;
if (!line_sender_qwpws_flush_and_get_fsn(sender, buffer, &fsn, &err))
goto on_error;

if (fsn.has_value) {
bool reached = false;
if (!line_sender_qwpws_await_acked_fsn(
sender, fsn.value, 10000, &reached, &err))
goto on_error;
if (!reached)
fprintf(stderr, "timed out waiting for server ACK\n");
}

Related accessors:

C functionC++ methodReturns
line_sender_qwpws_flush_and_get_fsnflush_and_get_fsn(buffer)Highest FSN published by this call. has_value == false / std::nullopt if the buffer was empty.
line_sender_qwpws_flush_and_keep_and_get_fsnflush_and_keep_and_get_fsn(buffer)Same, but keeps the buffer.
line_sender_qwpws_published_fsnpublished_fsn()Highest FSN published locally.
line_sender_qwpws_acked_fsnacked_fsn()Highest FSN completed (server ACK or drop-and-continue).
line_sender_qwpws_await_acked_fsnawait_acked_fsn(fsn, timeout)Block until acked_fsn() reaches fsn, or the timeout elapses.

In durable ACK mode, acked_fsn advances after durable upload, not on the ordinary OK frame.

Store-and-forward

With store-and-forward (SF) enabled, unacknowledged frames are persisted to disk and replayed after reconnection, surviving sender process restarts:

ws::addr=localhost:9000;sf_dir=/var/lib/questdb/sf;sender_id=ingest-1;

Without sf_dir, unacknowledged data lives in process memory and is lost if the sender process exits. The reconnect loop still spans transient server outages, but a RAM cap bounds how much data can accumulate.

Replay is at-least-once — enable DEDUP

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

SF tuning keys

KeyDefaultDescription
sf_dirunsetEnables disk-backed SF when set.
sender_iddefaultSlot identity. Allowed chars: A-Za-z0-9_-. Use distinct ids per sender process.
sf_max_bytes4 MiBPer-segment size cap.
sf_max_total_bytes128 MiB (memory) / 10 GiB (disk)Cap on total queued bytes.
sf_durabilitymemorymemory is the only shipping value. flush and append are reserved for future per-write fsync modes; setting them today fails sender construction.
sf_append_deadline_millis30000Maximum time flush() blocks waiting for queue capacity to free up. Applies in both memory and disk modes (the SFA queue is shared). On timeout, flush() surfaces an error; no data is dropped. See Backpressure on flush.
drain_orphansoffIf on, take over stale slots owned by a previous sender.
max_background_drainers4Concurrency cap when draining orphans.

Durable acknowledgement

Enterprise

Durable acknowledgement requires QuestDB Enterprise with primary replication configured.

By default, the server confirms a batch once 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;

durable_ack_keepalive_interval_millis (default 200) controls how often the client probes the server for durable ACK progress when no other traffic is in flight.

Asynchronous error handling

QWP/WebSocket ingestion is asynchronous: flush() returns as soon as the frame is accepted locally. Server-side rejections and protocol violations are reported separately.

There are two ways to observe them.

Polling

for (;;) {
line_sender_qwpws_error* qwp_err = NULL;
if (!line_sender_qwpws_poll_error(sender, &qwp_err, &err))
goto on_error;
if (!qwp_err)
break; // no more queued diagnostics

line_sender_qwpws_error_view view =
line_sender_qwpws_error_get_view(qwp_err);
fprintf(stderr,
"qwp error: category=%d policy=%d status=%d fsn=[%llu..=%llu] msg=%.*s\n",
(int)view.category,
(int)view.applied_policy,
view.has_status ? view.status : -1,
(unsigned long long)view.from_fsn,
(unsigned long long)view.to_fsn,
(int)view.message_len, view.message);
line_sender_qwpws_error_free(qwp_err);
}

Handler callback

Install a handler on the options object. It runs synchronously from sender API calls such as flush(). The handler must not call back into the same sender.

static void on_qwp_error(
void* user_data,
const line_sender_qwpws_error_view* ev)
{
(void)user_data;
fprintf(stderr, "qwp error: category=%d msg=%.*s\n",
(int)ev->category, (int)ev->message_len, ev->message);
}

line_sender_utf8 host = QDB_UTF8_LITERAL("localhost");
line_sender_opts* opts = line_sender_opts_new(
line_sender_protocol_qwpws, host, 9000);
line_sender_opts_qwpws_error_handler(opts, on_qwp_error, NULL, &err);
line_sender* sender = line_sender_build(opts, &err);
line_sender_opts_free(opts);

Error view fields

The line_sender_qwpws_error_view struct (and the C++ qwp_ws_error struct) carries:

FieldMeaning
categoryschema_mismatch, parse_error, internal_error, security_error, write_error, protocol_violation, unknown. Use for programmatic dispatch.
applied_policydrop_and_continue (batch dropped, sender continues) or halt (sender latched terminal).
status (has_status)Raw QWP status byte. Absent for WebSocket protocol violations.
message / message_lenHuman-readable error text from the server, or a client-synthesized close reason for WebSocket protocol violations. The pointer is not NUL-terminated; always read exactly message_len bytes. See Message stability and PII safety.
message_sequence (has_message_sequence)Server's per-frame QWP wire sequence for the error frame. Resets on reconnect, only meaningful within one connection.
from_fsn / to_fsnInclusive FSN span of the affected frame(s), client-side.

line_sender_qwpws_errors_dropped (C++ qwp_ws_errors_dropped()) reports how many diagnostics were lost because the bounded log overflowed (typically due to a lagging poll cursor).

Message stability

message is a human-readable diagnostic, not a stable contract. Its text varies across server versions and across provenance:

  • QWP error frames carry a server-supplied UTF-8 string capped at 1024 bytes by the wire spec.
  • WebSocket protocol violations are client-synthesized as "ws-close[<code>]: <reason>".
  • The server-supplied text mirrors QuestDB's normal SQL error formatting, which historically reworded across releases.
  • The field may be empty.

Use category and status for programmatic dispatch. Never pattern-match on message.

PII / secret safety

message may include fragments of the client's own payload, for example, an offending column value quoted back by a schema or parse rejection, or a server-supplied WebSocket close reason that the operator did not control. Treat message as potentially containing PII or secrets.

Log it at the same trust level as the data being sent, and sanitize before forwarding to external error trackers (Sentry, Datadog, end-user UIs). The other fields on the error view are safe to forward as-is - they carry only structural metadata.

Correlating with server-side logs

The protocol does not currently surface a server-issued request or connection identifier in the WebSocket upgrade response. The closest correlation tuple is (message_sequence, from_fsn, to_fsn):

  • message_sequence, per-connection QWP wire sequence the server attached to the error frame. Resets on reconnect.
  • from_fsn / to_fsn, client-side FSN span of the affected frames. Not generally indexed by server-side logs.

When opening a bug report, supply the connection start time (from your application logs) and the (message_sequence, from_fsn, to_fsn) triple.

After a halt policy fires, the sender is terminal. Drop it and create a new one. line_sender_must_close(sender) (C++ sender.must_close()) reports whether the sender has entered a terminal state.

drop_and_continue errors do not halt the sender. The affected batch is discarded; subsequent frames are unaffected and the I/O loop keeps running.

For terminal diagnostics, the next failing sender call also returns a line_sender_error* whose structured QWP/WebSocket diagnostic can be copied with line_sender_error_qwpws_get_view(err, &view). The view is borrowed from the ordinary error object and is valid until line_sender_error_free(). Copy exactly message_len bytes from view.message before freeing the error. In C++, the same diagnostic is available via line_sender_error::qwp_ws_diagnostic() on the thrown exception.

Progress modes

The client drives the WebSocket loop in one of two modes:

ModeBehaviour
LINE_SENDER_QWPWS_PROGRESS_BACKGROUND (default)A sender-owned thread sends frames, receives ACKs, reconnects, and replays. Right choice for most callers.
LINE_SENDER_QWPWS_PROGRESS_MANUALNo background thread. The caller drives progress with line_sender_qwpws_drive_once or line_sender_qwpws_await_acked_fsn.

Set it via the connect string (qwp_ws_progress=manual) or the options API:

#define _POSIX_C_SOURCE 199309L   /* nanosleep */
#include <time.h>

line_sender_utf8 conf = QDB_UTF8_LITERAL(
"ws::addr=localhost:9000;qwp_ws_progress=manual;");
line_sender* sender = line_sender_from_conf(conf, &err);

// ... append rows ...
line_sender_qwpws_fsn fsn;
if (!line_sender_qwpws_flush_and_get_fsn(sender, buffer, &fsn, &err))
goto on_error;

if (fsn.has_value) {
bool reached = false;
while (!reached) {
bool progressed = false;
if (!line_sender_qwpws_drive_once(sender, &progressed, &err))
goto on_error;
line_sender_qwpws_fsn acked;
if (!line_sender_qwpws_acked_fsn(sender, &acked, &err))
goto on_error;
if (acked.has_value && acked.value >= fsn.value) {
reached = true;
} else if (!progressed) {
/* Park briefly so the loop does not spin. Replace with
* whatever your scheduler/event loop uses. */
struct timespec park = {0, 1000 * 1000};
nanosleep(&park, NULL);
}
}
}

drive_once performs at most one unit of work per call (send one frame, drain ready responses, do one storage-maintenance step). For a simpler blocking wait in manual mode, call await_acked_fsn directly, it drives manual progress internally while waiting.

Failover and high availability

Enterprise

Multi-host failover with automatic reconnect is most useful with QuestDB Enterprise primary-replica replication.

Multiple endpoints

Specify a comma-separated address list (or repeat addr=):

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

The client picks an endpoint, connects, and walks the list to find the next healthy peer when the current connection breaks. Duplicate host:port entries are rejected at parse time.

Strongly recommend sf_dir for multi-host deployments

Without sf_dir, flush() blocks when the connection is down and the in-memory queue fills up. After sf_append_deadline_millis (default 30s), it returns an error. With sf_dir, flush() writes to disk and returns quickly while the reconnect loop replays to the new primary in the background. For any deployment where failover may take more than a few seconds, sf_dir is strongly recommended.

Reconnect knobs

KeyDefaultDescription
reconnect_max_duration_millis300000Total outage budget before giving up.
reconnect_initial_backoff_millis100First post-failure sleep.
reconnect_max_backoff_millis5000Cap on per-attempt sleep.
initial_connect_retryoff (auto-promoted to on when any explicit reconnect_* key is set)Retry on first connect. Values: off, on / true / sync (synchronous retry), async (background retry), false (alias for off). Explicit off preserves fail-fast startup.

By default the first connect fails fast, but setting any explicit reconnect_* key promotes initial_connect_retry to on so the reconnect policy also covers the initial connect. Set initial_connect_retry=off explicitly to keep fail-fast startup while tuning the reconnect loop.

Once reconnect_max_duration_millis elapses without a successful reconnect, the sender latches terminal: line_sender_must_close(sender) (C++ sender.must_close()) returns true and subsequent flush() calls fail with a "sender is terminal" error. Drop the sender and create a new one to continue.

Error classification

  • Authentication errors (401/403): terminal across all endpoints. The reconnect loop stops immediately.
  • Role reject (421 + X-QuestDB-Role): transient if the role is PRIMARY_CATCHUP, topology-level otherwise.
  • Version mismatch at upgrade: per-endpoint, not terminal. The client tries the next endpoint.
  • All other errors (TCP/TLS failures, 404, 503, mid-stream errors): transient, fed into the reconnect loop.

Connection lifecycle events are not surfaced as a structured C/C++ callback. The default error handler writes one structured line to stderr per server diagnostic; install your own handler to integrate with another logging system.

Closing the sender

Call line_sender_qwpws_close_drain (C++ sender.close_drain()) before freeing the sender for delivery-sensitive shutdown:

if (!line_sender_qwpws_close_drain(sender, &err))
goto on_error;
line_sender_close(sender);

close_drain stops accepting new publications and waits up to close_flush_timeout_millis (default 5000) for already-published frames to ACK. Plain line_sender_close (C++ destructor) is best-effort and does not report delivery failure, use close_drain whenever delivery matters. With sf_dir, anything still un-acked is persisted to disk so a later sender can replay it.

Querying and SQL execution

The query client sends SQL over the QWP egress endpoint (/read/v1). It is a separate object from the ingestion sender — it opens its own WebSocket and accepts the same connect-string schemas (ws:: / wss::).

A reader connects to one endpoint at a time, executes one query at a time, and streams the result as a sequence of column-oriented batches until the server emits a terminal frame. DDL, DML, and SELECT use the same execute() entry point and differ only in their terminal frame.

Quick start

#include <questdb/egress/line_reader.h>
#include <stdio.h>
#include <stdbool.h>

int main(void) {
line_reader_error* err = NULL;
line_reader* reader = NULL;
line_reader_cursor* cursor = NULL;

line_sender_utf8 conf = QDB_UTF8_LITERAL("ws::addr=localhost:9000;");
reader = line_reader_from_conf(conf, &err);
if (!reader) goto on_error;

line_sender_utf8 sql = QDB_UTF8_LITERAL(
"SELECT ts, symbol, price, amount FROM trades "
"WHERE symbol = 'ETH-USD' LIMIT 100");
cursor = line_reader_execute(reader, sql, &err);
if (!cursor) goto on_error;

const line_reader_batch* batch;
while ((batch = line_reader_cursor_next_batch(cursor, &err)) != NULL) {
const size_t rows = line_reader_batch_row_count(batch);

/* Project each column once per batch, then index by row. */
line_reader_column_data d_ts, d_sym, d_price;
line_reader_symbol_dict sym_dict;
if (!line_reader_batch_column_data(batch, 0, &d_ts, &err)) goto on_error;
if (!line_reader_batch_column_data(batch, 1, &d_sym, &err)) goto on_error;
if (!line_reader_batch_column_data(batch, 2, &d_price, &err)) goto on_error;
if (!line_reader_batch_symbol_dict(batch, &sym_dict, &err)) goto on_error;

for (size_t r = 0; r < rows; ++r) {
bool ts_null = false, sym_null = false, price_null = false;
int64_t ts = line_reader_column_data_get_i64(&d_ts, r, &ts_null);

const char* symbol = NULL;
size_t symbol_len = 0;
if (!line_reader_column_data_get_symbol(
&d_sym, &sym_dict, r, &symbol, &symbol_len, &sym_null))
goto on_error;

double price = line_reader_column_data_get_f64(&d_price, r, &price_null);

printf("ts=%lld symbol=%.*s price=%g\n",
(long long)ts, (int)symbol_len, symbol ? symbol : "", price);
}
}
if (err) goto on_error;

line_reader_cursor_free(cursor);
line_reader_close(reader);
return 0;

on_error:;
size_t err_len = 0;
const char* msg = line_reader_error_msg(err, &err_len);
fprintf(stderr, "error: %.*s\n", (int)err_len, msg);
line_reader_error_free(err);
if (cursor) line_reader_cursor_free(cursor);
if (reader) line_reader_close(reader);
return 1;
}

The four steps mirror the ingestion side:

  1. Build a reader from a connect string.
  2. Call execute(sql) (or prepare(sql) + binds + .execute()) to obtain a cursor.
  3. Loop next_batch() until it returns nullopt / NULL (terminal).
  4. For each batch, project columns once with batch.column(c) / C line_reader_batch_column_data(batch, c, &d, &err), then index by row with col.get<T>(r) (C++) or the line_reader_column_data_get_* inline helpers (C). The C ABI is bulk-only at the symbol level — one FFI call per column, then pointer arithmetic per row.

For the full list of connect-string keys accepted by the reader (including target, zone, failover_*, compression, and the shared TLS / auth keys), see the connect string reference.

Creating a reader

The reader uses the same connect-string format as the sender. Build it from a literal, from QDB_CLIENT_CONF, or — in C — through the C ABI directly.

/* From a literal. */
line_sender_utf8 conf = QDB_UTF8_LITERAL("ws::addr=localhost:9000;");
line_reader_error* err = NULL;
line_reader* reader = line_reader_from_conf(conf, &err);

/* From QDB_CLIENT_CONF (credentials out of source code). */
line_reader* reader2 = line_reader_from_env(&err);

Use the same QDB_CLIENT_CONF environment variable as the sender — both clients parse the same connect-string grammar, so a single shared variable works for callers that open one of each in the same process.

Concurrency

The reader API has three handle types, each with its own thread-mobility rule:

HandleConcurrent accessMobility
line_reader / qdb::readerSingle-threaded.Movable between threads with an explicit happens-before edge (mutex hand-off, thread spawn/join, release/acquire on the pointer).
line_reader_query / qdb::querySingle-threaded.Pinned to the thread that created it.
line_reader_cursor / qdb::cursorSingle-threaded.Pinned to the thread that created it.
line_reader_error / qdb::line_reader_errorNot concurrent.Free to move.

Concurrent operations on the same handle from two threads are always undefined behaviour. To query in parallel, create one reader per thread. Each opens its own WebSocket connection and runs one query at a time. A reader can execute many queries sequentially; only one cursor may be live on a reader at any given moment.

A narrow set of reader stats are exempt from the one-thread rule because they read atomic counters: line_reader_bytes_received, line_reader_credit_granted_total, line_reader_read_ns, line_reader_decode_ns, line_reader_reset_timing. Use these (not the _cursor_credit_granted_total variant) when a monitoring thread polls a reader that another thread is actively driving.

DDL, DML, and SELECT

execute() is blocking: it sends the query, drives the receive loop on the calling thread, and returns a cursor that streams the response. DDL, DML, and SELECT use the same entry point; they differ only in the terminal frame the cursor delivers.

Statement classTerminalcursor.terminal_kind()What to read
SELECTRESULT_ENDterminal_kind::endterminal_end(){final_seq, total_rows}
INSERT / UPDATE / DELETEEXEC_DONEterminal_kind::exec_doneterminal_exec_done(){op_type, rows_affected}
CREATE / ALTER / DROP / TRUNCATEEXEC_DONEterminal_kind::exec_doneterminal_exec_done(){op_type, rows_affected == 0}

Concrete DDL / DML example:

/* CREATE TABLE. The cursor delivers no batches; only the terminal frame. */
line_sender_utf8 ddl = QDB_UTF8_LITERAL(
"CREATE TABLE IF NOT EXISTS trades ("
" ts TIMESTAMP, symbol SYMBOL, side SYMBOL,"
" price DOUBLE, amount DOUBLE"
") TIMESTAMP(ts) PARTITION BY DAY WAL");
line_reader_cursor* cursor = line_reader_execute(reader, ddl, &err);
if (!cursor) goto on_error;

/* Drain (DDL streams zero batches; the loop body never runs). */
const line_reader_batch* batch;
while ((batch = line_reader_cursor_next_batch(cursor, &err)) != NULL) {
(void)batch; /* unused for DDL */
}
if (err) goto on_error;

uint8_t op_type = 0;
uint64_t rows_affected = 0;
if (line_reader_cursor_terminal_exec_done(cursor, &op_type, &rows_affected))
printf("DDL ok: op_type=%u rows_affected=%llu\n",
(unsigned)op_type, (unsigned long long)rows_affected);

line_reader_cursor_free(cursor);

rows_affected is the count for INSERT / UPDATE / DELETE. Pure DDL reports 0.

EXEC_DONE confirms the statement has been applied to the local write-ahead log (WAL) and is visible to subsequent SELECTs on this reader. The request_durable_ack connect-string key is sender-only — it is not accepted on the reader, and reader-driven INSERTs acknowledge on local-WAL commit only. If you need durable upload to object storage for reader-side INSERTs, drive the inserts through the ingestion sender with request_durable_ack=on instead.

Sequencing operations across one reader is safe because execute() is synchronous: the next statement does not start until the previous cursor terminates and is freed.

Parameterised queries

Use prepare() for SQL with $N placeholders. Append binds in positional order, then call execute() to obtain a cursor.

line_sender_utf8 sql = QDB_UTF8_LITERAL(
"SELECT ts, symbol, price, amount FROM trades "
"WHERE symbol = $1 AND price >= $2 LIMIT 1000");

line_reader_query* query = line_reader_prepare(reader, sql, &err);
if (!query) goto on_error;

line_reader_query_bind_varchar(query, QDB_UTF8_LITERAL("ETH-USD"));
line_reader_query_bind_f64(query, 2500.0);

line_reader_cursor* cursor = line_reader_query_execute(&query, &err);
/* `query` is now NULL — line_reader_query_execute consumes it. */
if (!cursor) goto on_error;

Binds are positional: the first bind_* call fills $1, the second fills $2, and so on. The number of binds must match the number of placeholders; mismatches surface from execute() as line_reader_error_invalid_bind.

Most binds are infallible at the call site — they only mutate the in-process query buffer. The single exception is bind_varchar, which re-validates UTF-8 and silently freezes the builder on invalid bytes; the deferred error surfaces from execute() as line_reader_error_invalid_utf8. To recover, drop the query and rebuild.

Bind parameter types

The C ABI exposes a separate function per QuestDB type; the C++ wrapper exposes the same surface as query::bind_* methods returning query& for chaining. Every QuestDB column type that can appear in a $N placeholder has a setter:

QuestDB typeC functionC++ method
BOOLEANline_reader_query_bind_boolquery.bind_bool(value)
BYTE (i8)line_reader_query_bind_i8query.bind_i8(value)
SHORT (i16)line_reader_query_bind_i16query.bind_i16(value)
INT (i32)line_reader_query_bind_i32query.bind_i32(value)
LONG (i64)line_reader_query_bind_i64query.bind_i64(value)
FLOAT (f32)line_reader_query_bind_f32query.bind_f32(value)
DOUBLE (f64)line_reader_query_bind_f64query.bind_f64(value)
CHARline_reader_query_bind_charquery.bind_char(code_unit)
VARCHARline_reader_query_bind_varcharquery.bind_varchar(utf8_view)
BINARYline_reader_query_bind_binaryquery.bind_binary(buf, len)
UUIDline_reader_query_bind_uuidquery.bind_uuid(std::array<uint8_t, 16>)
LONG256line_reader_query_bind_long256query.bind_long256(std::array<uint8_t, 32>)
IPv4line_reader_query_bind_ipv4query.bind_ipv4(host_order_u32)
TIMESTAMP (μs)line_reader_query_bind_timestamp_microsquery.bind_timestamp_micros(micros)
TIMESTAMP_NS (ns)line_reader_query_bind_timestamp_nanosquery.bind_timestamp_nanos(nanos)
DATE (ms)line_reader_query_bind_date_millisquery.bind_date_millis(millis)
DECIMAL64line_reader_query_bind_decimal64query.bind_decimal64(mantissa, scale)
DECIMAL128line_reader_query_bind_decimal128query.bind_decimal128(lo, hi, scale)
DECIMAL256line_reader_query_bind_decimal256query.bind_decimal256(std::array<uint8_t, 32>, scale)
GEOHASHline_reader_query_bind_geohashquery.bind_geohash(value, precision_bits)
SYMBOLuse bind_varchar (the server narrows VARCHAR → SYMBOL on schema match)use bind_varchar

SYMBOL reuses bind_varchar because the QWP bind framing carries UTF-8 text; the server resolves to a symbol value on the receiving side.

DECIMAL128 mantissa sign

bind_decimal128 splits the two's-complement i128 mantissa into a uint64_t mantissa_lo (low 64 bits) and an int64_t mantissa_hi (upper 64 bits). The high limb must be passed as int64_t so the sign extends correctly into the full i128 — passing uint64_t zero- extends and silently corrupts every negative value. For example, i128 -1 is (mantissa_lo = UINT64_MAX, mantissa_hi = -1). The C++ overload takes int64_t directly, so calling it with a literal works without further care.

The following column types have no bind_* variant — they can appear in result columns but not in $N placeholders:

TypeWhy no bindWorkaround
DOUBLE[] / LONG[] (arrays)The QWP ARGS frame carries scalar binds only; shape and stride metadata have no wire encoding.Emit array literals directly in SQL, or filter by an extracted element (bids[1][1] >= $1).
INTERVALNot yet exposed as a bind type.Bind the boundary as a TIMESTAMP / TIMESTAMP_NS instead.

Binding NULL

Bind a typed NULL with bind_null for the simple kinds, or with the dedicated bind_null_* variants for kinds that carry metadata (column scale on decimals, precision on geohash). Index drift is the failure mode to avoid: NULL still consumes one positional slot.

line_reader_query_bind_null(query, line_reader_column_kind_long);     /* $1 = NULL LONG */
line_reader_query_bind_null_varchar(query); /* $2 = NULL VARCHAR */
line_reader_query_bind_null_decimal64(query, /*scale=*/4); /* $3 = NULL DECIMAL64 */
line_reader_query_bind_null_geohash(query, /*precision_bits=*/20); /* $4 = NULL GEOHASH */

bind_null_* variants exist for varchar, binary, decimal64, decimal128, decimal256, and geohash. Every other kind goes through the generic bind_null(kind).

Reading result batches

next_batch() returns a borrowed batch handle when a batch is available, nullopt when the stream has terminated, and throws (C++) / returns NULL with *err_out set (C) on error. The batch handle is the entry point for all data access — there are no per-cell cursor getters.

Pointers and views returned by the batch (column names, descriptor buffers, validity bitmaps, varlen / symbol strings, array shapes and elements) borrow from the currently-loaded batch and are invalidated by next_batch(), cancel(), add_credit(), or freeing the cursor. Copy out any values you need beyond the current batch.

Batches are decoded column-major: each column's values live in one contiguous buffer. The C ABI exposes this as flat line_reader_column_data / line_reader_array_data descriptors — fill one with line_reader_batch_column_data(batch, col, &d, &err) (or _array_column_data for DOUBLE[]), then index by row. This is the zero-copy path for Cython / numpy / pandas bindings. When iterating, run the outer loop over columns and the inner loop over rows; sequential access through each column buffer is cache-friendly. Row-major iteration (outer over rows) is correct but jumps between per-column buffers on every cell.

Per-batch metadata

Accessor (C)Accessor (C++)Returns
line_reader_batch_row_count(batch)batch.row_count()size_t
line_reader_batch_column_count(batch)batch.column_count()size_t
line_reader_batch_column_kind(batch, c, &k, &err)batch.column_kind(c)line_reader_column_kind
line_reader_batch_column_name(batch, c, &buf, &len, &err)batch.column_name(c)std::string_view
line_reader_batch_request_id(batch)batch.request_id()int64_t
line_reader_batch_seq(batch)batch.seq()uint64_t
line_reader_batch_flags(batch)batch.flags()uint8_t

Column descriptor (C)

line_reader_batch_column_data fills a line_reader_column_data struct with:

FieldUse
kindColumn kind discriminant. Disambiguate units for 64-bit temporals (LONG / TIMESTAMP μs / TIMESTAMP_NS ns / DATE ms).
row_countSame as line_reader_batch_row_count(batch).
validityLSB-first null bitmap, bit 1 = null. NULL when the column has no nulls.
values / value_strideDense little-endian buffer for fixed-width kinds (stride = 1 / 2 / 4 / 8 / 16 / 32 bytes per row, kind-dependent).
var_offsets / var_data / var_data_lenVARCHAR / BINARY ragged buffer: var_offsets[r..r+1] is a byte slice into var_data.
symbol_codesSYMBOL per-row dictionary codes (uint32_t). Resolve via line_reader_batch_symbol_dict(batch, &dict, &err) then dict.heap + dict.entries[code].
decimal_scaleDECIMAL64 / 128 / 256 column-wide scale.
geohash_precision_bitsGEOHASH precision (1..60).

For DOUBLE[] columns call line_reader_batch_array_column_data instead; that fills line_reader_array_data with data + data_offsets (byte offsets, row_count + 1) + shapes + shape_offsets (rank-prefixed).

Casual single-cell reads (C)

<questdb/egress/line_reader.h> ships header-only static inline helpers that package the row index + validity probe + typed little-endian load over a filled descriptor — no extra FFI crossing, no new exported symbols. Typed loads go through memcpy so they stay alignment-safe on every target (densified column slices may borrow from the wire payload at offsets that don't satisfy alignof(T); the compiler lowers each call to a single unaligned MOV).

QuestDB typeHelper
BOOLEANline_reader_column_data_get_bool
BYTE (i8)line_reader_column_data_get_i8
SHORT (i16)line_reader_column_data_get_i16
INT (i32)line_reader_column_data_get_i32
IPv4line_reader_column_data_get_ipv4
LONG, TIMESTAMP (μs / ns), DATE (ms)line_reader_column_data_get_i64
FLOAT (f32)line_reader_column_data_get_f32
DOUBLE (f64)line_reader_column_data_get_f64
CHAR (UTF-16 code unit)line_reader_column_data_get_char
VARCHAR / BINARYline_reader_column_data_get_varlen
SYMBOLline_reader_column_data_get_symbol (takes a line_reader_symbol_dict* from _batch_symbol_dict)
UUID / LONG256 (raw bytes)line_reader_column_data_get_bytes
DECIMAL64 (mantissa)line_reader_column_data_get_decimal64_mantissa
DECIMAL128 (low / high limbs)line_reader_column_data_get_decimal128
GEOHASHline_reader_column_data_get_geohash

For DECIMAL64 / 128 / 256 the column-wide scale is on d->decimal_scale; for GEOHASH the column-wide precision is on d->geohash_precision_bits.

The helpers do not bounds-check row — caller's responsibility (use d->row_count). Tight loops should still inline-index the descriptor buffers directly; the helpers exist for ergonomics, not performance.

Column accessors (C++)

The C++ wrapper exposes batch.column(c) returning a polymorphic egress::column object covering every kind. Per-cell accessors are methods on the column, not on the cursor:

QuestDB typeC++ methodReturn shape
BOOLEAN, BYTE, SHORT, CHAR, INT, IPv4, LONG / TIMESTAMP / TIMESTAMP_NS / DATE, FLOAT, DOUBLEcol.get<T>(row)nullable<T>
VARCHARcol.varchar(row)nullable<std::string_view>
BINARYcol.binary(row)nullable<binary_view>
SYMBOLcol.symbol(row)nullable<std::string_view> (resolved through the batch's symbol dict)
UUIDcol.get_uuid(row)nullable<std::array<uint8_t, 16>>
LONG256col.get_long256(row)nullable<std::array<uint8_t, 32>>
GEOHASHcol.get_geohash(row)nullable<geohash> (value + precision_bits)
DECIMAL64 / DECIMAL128 / DECIMAL256col.get_decimal64(row) / _128 / _256nullable<decimal64> / _128 / _256 (mantissa or limbs + scale)
DOUBLE[] shapecol.shape(row, &rank)const uint32_t* (dimension lengths)
DOUBLE[] elementscol.elements<double>(row, &count)const double*
Validity bitmapcol.validity() / col.validity_bytes() / col.has_nulls() / col.is_null(row)raw LSB-first bytes

column::get<T>(row) uses a kind whitelist (e.g. int32_t accepts only INT, not IPv4; int64_t accepts LONG / TIMESTAMP / DATE / TIMESTAMP_NS but not DECIMAL64). For deliberate reinterpretation use the strict overload col.get<T>(row, kind).

For column-oriented hot loops, get the dense pointer once and index it:

auto col = batch.column(0);
const int64_t* ts = col.values<int64_t>(); // throws on kind mismatch
const uint8_t* validity = col.validity(); // null when no nulls
for (size_t r = 0; r < col.row_count(); ++r) {
if (validity && ((validity[r >> 3] >> (r & 7)) & 1)) continue;
process(ts[r]);
}

LONG[] columns are reserved on the wire but not supported in this revision — batch.column(c) throws invalid_api_call for them.

Visitor dispatch (C++)

When the schema is unknown at compile time — generic row formatters, CSV / JSON converters, arrow-record builders — col.visit(visitor) is the ergonomic alternative to a hand-written switch (col.kind()). The column runs the kind discriminant once and hands the visitor a typed view; the visitor's overloads cover the kinds the caller cares about.

The seven view types are:

ViewKindsKey members
fixed_view<T>BOOLEAN, BYTE, SHORT, CHAR, INT, IPv4, LONG / TIMESTAMP / TIMESTAMP_NS / DATE, FLOAT, DOUBLEkind, values (typed pointer), row_count, validity, value(row) → nullable<T>
decimal_viewDECIMAL64, DECIMAL128, DECIMAL256kind, values (raw LE mantissa bytes), value_stride (8 / 16 / 32), scale, row_count, validity
bytes_viewUUID, LONG256kind, values (raw LE bytes), value_stride (16 / 32), row_count, validity
geohash_viewGEOHASHvalues (raw LE bytes), value_stride (1..8), precision_bits, row_count, validity
varlen_viewVARCHAR, BINARYkind, offsets (row_count + 1), data, data_len, as_string_view(row), as_binary(row), validity
symbol_viewSYMBOLcodes (per-row dict codes), dict (snapshot), resolve(row) → nullable<string_view>, validity
array_view<T>DOUBLE[] (T = double)kind, data, data_offsets, shapes, shape_offsets, shape(row), elements(row), validity

Every view also has is_null(row) → bool. LONG[] columns are not supported in this revision — visit throws invalid_api_call for them.

Two contracts to know:

  • All visitor overloads must return the same type. decltype(auto) deduction across the switch arms requires a common type. Return void for side-effect-only visitors. Mismatches are caught at compile time.
  • Unhandled kinds throw invalid_api_call. If the visitor has no overload for the kind the column actually carries, visit throws rather than calling an unrelated overload — there is no implicit conversion between view types.

The idiomatic visitor uses the eg::overload helper that ships with the library — a C++17 CTAD wrapper that combines a list of lambdas into a single callable whose operator() resolves by argument type:

namespace eg = questdb::egress;

void print_cell(const eg::column& col, size_t row)
{
col.visit(eg::overload{
// Fixed-width primitives — one lambda per T.
[&](eg::fixed_view<int64_t> v) {
// Covers LONG / TIMESTAMP / DATE / TIMESTAMP_NS;
// disambiguate via v.kind if the unit matters.
if (auto x = v.value(row)) std::cout << *x;
else std::cout << "NULL";
},
[&](eg::fixed_view<double> v) {
if (auto x = v.value(row)) std::cout << *x;
else std::cout << "NULL";
},
// Variable-width.
[&](eg::varlen_view v) {
if (v.kind == eg::column_kind::binary) {
if (auto x = v.as_binary(row)) {
/* x->data, x->size */
} else { std::cout << "NULL"; }
} else {
std::cout << v.as_string_view(row).value_or("NULL");
}
},
// SYMBOL — resolved through the batch's dict snapshot.
[&](eg::symbol_view v) {
std::cout << v.resolve(row).value_or("NULL");
},
// Arrays.
[&](eg::array_view<double> v) {
if (v.is_null(row)) { std::cout << "NULL"; return; }
auto el = v.elements(row); // pair<const double*, size_t>
std::cout << "[";
for (size_t i = 0; i < el->second; ++i)
std::cout << (i ? " " : "") << el->first[i];
std::cout << "]";
},
// Catch-all for the kinds this caller doesn't need.
[&](auto v) { (void)v; std::cout << "(unhandled)"; },
});
}

The [](auto v){ ... } generic lambda at the end is optional but lets you avoid listing every view type when the caller only needs a subset. Without it, the visitor must provide an overload for every kind the column might be — visit will throw if dispatch lands on an unmatched view.

For a complete worked example covering all 7 view types — including hex / IPv4 / decimal scale / geohash precision rendering — see examples/line_reader_cpp_example_columns.cpp in the client repo.

When to use which
PatternUse
Caller knows T at compile time (e.g. LONG accumulator).col.get<T>(row) for one cell, col.values<T>() for a contiguous loop.
Caller scans a known-mixed schema (a few kinds, fixed).Inline switch (col.kind()) with col.varchar/col.symbol/col.get<T> per arm. Smallest code.
Caller scans an unknown / wide / kind-agnostic schema.col.visit(eg::overload{...}). Kind discriminant runs once per column; the compiler picks the right lambda.
Caller needs the raw dense buffer (zero-copy interop).col.values<T>() (scalar) or col.elements<T>(row, &count) / col.shape(row, &rank) (array). The view types' values / data fields work the same way inside a visitor.

Reading NULLs

C ABI: the inline helpers write *out_is_null separately from the typed return value. Always branch on *out_is_null before consuming the value — a default-zero int64_t or empty string_view is a valid value, not a NULL marker. The underlying contract is that the column's validity bitmap (LSB-first; bit 1 = null) is NULL when the column has no nulls.

line_reader_column_data d;
if (!line_reader_batch_column_data(batch, 0, &d, &err)) goto on_error;
bool is_null = false;
int64_t price_micros = line_reader_column_data_get_i64(&d, r, &is_null);
if (is_null) {
/* SQL NULL. Skip, sentinel, error — your call. */
} else {
/* Real value. */
}

C++ wrapper: every per-cell accessor returns std::optional<T> (nullable<T>), empty for NULL cells.

auto col = batch.column(col_idx);
if (auto price = col.get<double>(r))
process(*price);
else
handle_null();

Reading arrays

DOUBLE[] columns use a separate descriptor — line_reader_batch_array_column_data(batch, c, &d, &err) fills a line_reader_array_data struct with four borrowed buffers:

FieldLayout
dataConcatenated little-endian double bytes for every row, all rows back-to-back.
data_offsetsrow_count + 1 entries; row r's slice is data[data_offsets[r] .. data_offsets[r+1]] (byte offsets).
shapesConcatenated uint32_t dimension lengths (row-major; innermost dimension last).
shape_offsetsrow_count + 1 entries indexing shapes; row r's rank is shape_offsets[r+1] - shape_offsets[r].
validityLSB-first null bitmap. NULL when the column has no nulls.

In C++, batch.column(c).shape(row, &rank) and col.elements<double>(row, &count) decode each row into typed pointers, or use col.visit(...) with an array_view<double> overload for full shape/element access (see Visitor dispatch).

A NULL array row is flagged via the column's validity bitmap (or col.is_null(row)). A non-null row whose shape produces zero elements (e.g. [2, 0, 3]) has shape != nullptr but count == 0 from elements<double>.

LONG[] is reserved on the wire but not supported in this revision — line_reader_batch_column_data / _array_column_data reject it with invalid_api_call, and batch.column(c) throws in C++.

Arrays require QuestDB 9.0.0 or later — older servers reject the QWP encoding outright with unsupported_server.

Flow control: credit

By default the server streams as fast as the network allows. Set initial_credit(bytes) on the query to apply byte-credit flow control: the server pauses after the configured byte budget is exhausted and the client tops it up by calling add_credit(more_bytes) after each batch is processed. 0 is the sentinel for "unbounded".

line_reader_query* query = line_reader_prepare(reader, sql, &err);
if (!query) goto on_error;
line_reader_query_initial_credit(query, 256 * 1024); /* 256 KiB */
line_reader_cursor* cursor = line_reader_query_execute(&query, &err);

const line_reader_batch* batch;
while ((batch = line_reader_cursor_next_batch(cursor, &err)) != NULL) {
/* ... read the batch (project columns, index by row) ... */
if (!line_reader_cursor_add_credit(cursor, 256 * 1024, &err))
goto on_error;
}
if (err) goto on_error;

Inspect line_reader_credit_granted_total(reader) (or reader.credit_granted_total()) from a monitoring thread to track cumulative credit issued on a connection.

Cancelling an in-flight query

There are two ways to stop a stream early:

  • cancel() (C: line_reader_cursor_cancel) — sends CANCEL, drains pending frames until the server's terminal reply, and surfaces any transport errors through err_out / an exception. Use this when you need to know whether the cancellation completed cleanly — for example before reusing the reader from a critical path, or when the cancel is itself observable to the application.
  • Free the cursor (C: line_reader_cursor_free; C++ destructor) — best-effort: sends CANCEL, then tears down the WebSocket bounded by a short internal timeout. Transport errors during teardown are swallowed. Use this when you don't care about clean closure (e.g. the process is shutting down anyway).

Either way, after the cursor is gone the reader is ready for the next execute().

Server info and connection state

Once the reader (or cursor) is connected, server_info() exposes the last-seen QWP SERVER_INFO frame:

if (auto info = reader.server_info()) {
std::cout << "role=" << int(info.role())
<< " epoch=" << info.epoch()
<< " cluster=" << info.cluster_id()
<< " node=" << info.node_id() << "\n";
}

role is one of standalone, primary, replica, primary_catchup, or other (use role_byte() for unknown values). epoch is monotonic across failover/role transitions. current_host() / current_port() return the endpoint currently in use.

The reader's getters reject while a cursor is live — they read non-atomic state the cursor thread may mutate. Use the cursor-scoped equivalents (cursor.server_info(), cursor.current_host(), cursor.current_port(), cursor.server_version()) instead, or release the cursor first.

Failover and high availability

Enterprise

Multi-host failover is most useful with QuestDB Enterprise primary-replica replication. A single-node deployment can configure the connect string the same way, but failover only kicks in when there is a second healthy endpoint to try.

Multiple endpoints and routing

Pass a comma-separated address list:

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

target filters by SERVER_INFO.role:

ValueEndpoints accepted
any (default)Any role.
primaryPRIMARY, PRIMARY_CATCHUP, or STANDALONE.
replicaREPLICA only.

zone=<id> biases endpoint selection toward same-zone hosts (Enterprise).

Per-query failover loop

When the connection breaks mid-stream, the cursor reconnects to another endpoint and replays the query from the start. The loop is bounded:

KeyDefaultDescription
failoveronMaster switch. failover=off disables per-query reconnect.
failover_max_attempts8Cap on reconnect attempts per execute().
failover_backoff_initial_ms50First post-failure sleep.
failover_backoff_max_ms1000Cap on per-attempt sleep.
failover_max_duration_ms30000Total wall-clock budget per execute().
auth_timeout_ms15000Per-host HTTP upgrade timeout.

When the budget is exhausted, next_batch() (or execute()) surfaces a terminal error — typically socket_error, handshake_error, or role_mismatch. Free the cursor; the reader is then available for the next execute(). There is no continuous reconnect spanning idle periods between queries — each execute() starts its own failover budget.

Failover requires multiple endpoints

The failover loop rotates across the addr= list. With a single address there is no other host to try, and the budget collapses after one attempt regardless of failover_max_attempts. Provide at least two addresses for failover to be useful.

Mid-stream failover hazard: duplicate rows

Read this before deploying a long-running cursor against a failover- enabled connect string.

When mid-stream failover fires, the server replays the query from the beginning. Any rows the application has already consumed will be delivered again on the new connection. Without explicit recovery, an aggregation, fan-out, or downstream writer sees duplicates.

The library does not silently discard the replayed rows. Instead, it gives the application a choice via either the on_failover_reset or the on_failover_progress callback — installing either one clears the silent-duplicate guard:

  • Wire a callback. The trampoline fires on the cursor's thread, before any replayed batch arrives. Discard the partial state you've accumulated. Replay then proceeds transparently. Use on_failover_reset for the reset-only signal, or on_failover_progress (see Failover progress phases) for the full lifecycle — disconnect, per-retry, reset, gave-up.
  • Don't wire a callback, and don't want replay. The cursor surfaces the next next_batch() (or execute()) call as line_reader_error_failover_would_duplicate and terminates instead of double-delivering. The application can then re-execute the query from scratch.

Initial-connect failover (before any batch has been yielded) is always transparent and ignores the callback — no rows are consumed yet, so there is nothing to duplicate.

static void on_failover_reset(
const line_reader_failover_event* ev,
void* user_data)
{
/* `user_data` is your accumulator; clear it before replay. */
struct row_buf* buf = (struct row_buf*)user_data;
row_buf_clear(buf);

/* Diagnostics only — must NOT call back into reader / query / cursor. */
const char* new_host = NULL;
size_t new_host_len = 0;
line_reader_failover_event_new_host(ev, &new_host, &new_host_len);
fprintf(stderr,
"failover -> %.*s:%u after %u attempts\n",
(int)new_host_len, new_host,
(unsigned)line_reader_failover_event_new_port(ev),
(unsigned)line_reader_failover_event_attempts(ev));
}

line_reader_query* query = line_reader_prepare(reader, sql, &err);
line_reader_query_on_failover_reset(query, on_failover_reset, &my_buf);
line_reader_cursor* cursor = line_reader_query_execute(&query, &err);

The callback runs synchronously on the cursor's drive thread. It must not call back into the originating reader, query, or cursor (including read-only stat getters — they read state the trampoline is mid-mutation on); must not throw or longjmp across the C boundary (an escaping unwind aborts the C++ trampoline); and must not block, because while it runs no batch is being read and no credit is being granted to the server.

Failover event fields

The failover_event passed to the callback carries:

C++ accessorC functionMeaning
failed_host() / failed_port()line_reader_failover_event_failed_host / _portThe previously-connected endpoint that failed.
new_host() / new_port()line_reader_failover_event_new_host / _portThe endpoint the cursor is now connected to.
new_request_id()line_reader_failover_event_new_request_idServer-assigned request ID on the new connection.
attempts()line_reader_failover_event_attemptsNumber of reconnect attempts that preceded this success (1 = first retry).
elapsed_ns()line_reader_failover_event_elapsed_nsWall-clock nanoseconds spent reconnecting (sleep + dial + handshake + SERVER_INFO).
trigger_code()line_reader_failover_event_trigger_codeline_reader_error_code that triggered the failover.
trigger_msg()line_reader_failover_event_trigger_msgHuman-readable message for the trigger.
server_info()line_reader_failover_event_server_infoSERVER_INFO of the new endpoint (empty on v1 servers).

Outside the callback, cursor.failover_resets() reports the cumulative number of successful resets observed by this cursor since execute().

Failover progress phases

on_failover_reset only fires on a successful reconnect. For full connection-lifecycle visibility — outage observed, per-retry telemetry, reset, retry budget exhausted — install an on_failover_progress callback instead (or in addition). The same callback fires for every phase; route on the phase discriminant:

Phase (C / C++)When it firesFields populated beyond the always-set ones
line_reader_failover_phase_disconnected / failover_phase::disconnectedOnce, immediately after the cursor's connection drops — before any retry. Lets observers alert on "QuestDB unreachable now" instead of retroactively when reconnect lands.None. attempt is 0.
line_reader_failover_phase_retrying / failover_phase::retryingOnce per outer-loop iteration, after the inter-attempt backoff sleep and immediately before the dial.attempt is 1-based for the about-to-be-tried dial. elapsed_ns already includes backoff cost.
line_reader_failover_phase_reset / failover_phase::resetA reconnect succeeded; replayed batches will start arriving next. Fires immediately before the on_failover_reset callback (when both are installed) so a single sink sees the lifecycle in order.new_host / new_port, new_request_id, server_info (empty on v1 servers). attempt is the dial that landed.
line_reader_failover_phase_gave_up / failover_phase::gave_upThe retry budget is exhausted; the cursor is terminal. The same error will surface on the next next_batch() / add_credit() call.final_error_code / final_error_msg. attempt is the total number of dials burned (may be 0 if the wall-clock deadline was already exhausted).

Always-set fields (every phase): failed_host / failed_port (the endpoint that died), trigger_code / trigger_msg (the original cause-of-death, preserved across phases), elapsed_ns (wall-clock since the disconnect was observed, monotonically non-decreasing across phases of the same lifecycle), and attempt (per-phase semantics above).

Accessor surface:

C++ accessorC functionNotes
phase()line_reader_failover_progress_event_phaseDiscriminant.
failed_host() / failed_port()line_reader_failover_progress_event_failed_host / _failed_portAlways set.
new_host() / new_port()line_reader_failover_progress_event_new_host / _new_portEmpty / 0 outside Reset.
new_request_id()std::optional<int64_t>line_reader_failover_progress_event_new_request_id (returns bool)nullopt / false outside Reset.
attempt()line_reader_failover_progress_event_attemptPer-phase semantics, see table.
trigger_code() / trigger_msg()line_reader_failover_progress_event_trigger_code / _trigger_msgAlways set; preserved across phases.
elapsed_ns()line_reader_failover_progress_event_elapsed_nsWall-clock since disconnect. Saturating, monotonic.
server_info()line_reader_failover_progress_event_server_infoNon-NULL only on Reset, v2+ servers.
final_error_code()std::optional<error_code>line_reader_failover_progress_event_final_error_code (returns bool)Populated only on GaveUp.
final_error_msg()line_reader_failover_progress_event_final_error_msg (returns bool)Populated only on GaveUp.

Installing on_failover_progress also clears the silent-duplicate guard documented under Mid-stream failover hazard — same as on_failover_reset. If you only want telemetry and not replay semantics, set failover=off in the connect string instead.

The reentrancy contract is identical to on_failover_reset: the callback runs synchronously on the cursor's drive thread, must not call back into the originating reader / query / cursor (including read-only stat getters), must not throw or longjmp across the C boundary (an escaping unwind aborts the process), and must not block — while it runs, no batch is being read and the failover loop cannot make progress.

static void on_failover_progress(
const line_reader_failover_progress_event* ev,
void* user_data)
{
const char* failed_host = NULL;
size_t failed_host_len = 0;
line_reader_failover_progress_event_failed_host(
ev, &failed_host, &failed_host_len);
const uint16_t failed_port =
line_reader_failover_progress_event_failed_port(ev);
const uint64_t elapsed_ns =
line_reader_failover_progress_event_elapsed_ns(ev);

switch (line_reader_failover_progress_event_phase(ev)) {
case line_reader_failover_phase_disconnected:
fprintf(stderr, "failover: disconnected from %.*s:%u\n",
(int)failed_host_len, failed_host, (unsigned)failed_port);
break;
case line_reader_failover_phase_retrying:
fprintf(stderr, "failover: retry #%u after %llu ns\n",
(unsigned)line_reader_failover_progress_event_attempt(ev),
(unsigned long long)elapsed_ns);
break;
case line_reader_failover_phase_reset: {
const char* new_host = NULL;
size_t new_host_len = 0;
line_reader_failover_progress_event_new_host(
ev, &new_host, &new_host_len);
fprintf(stderr,
"failover: reset -> %.*s:%u after %u attempts (%llu ns)\n",
(int)new_host_len, new_host,
(unsigned)line_reader_failover_progress_event_new_port(ev),
(unsigned)line_reader_failover_progress_event_attempt(ev),
(unsigned long long)elapsed_ns);
break;
}
case line_reader_failover_phase_gave_up: {
const char* msg = NULL;
size_t msg_len = 0;
line_reader_failover_progress_event_final_error_msg(
ev, &msg, &msg_len);
fprintf(stderr,
"failover: gave up after %u attempts: %.*s\n",
(unsigned)line_reader_failover_progress_event_attempt(ev),
(int)msg_len, msg);
break;
}
}
}

line_reader_query* query = line_reader_prepare(reader, sql, &err);
line_reader_query_on_failover_progress(query, on_failover_progress, NULL);
line_reader_cursor* cursor = line_reader_query_execute(&query, &err);

on_failover_progress and on_failover_reset coexist: when both are installed, the Reset phase fires immediately before the reset callback so a single observer sees the whole lifecycle in order. Pick whichever shape fits your sink — phased event stream vs. one-shot reset hook — or install both.

Connection-state observability

For mid-query connection lifecycle, install on_failover_progress (above): it surfaces Disconnected (on outage), Retrying (per dial attempt), Reset (reconnect succeeded), and GaveUp (budget exhausted) as structured events on the cursor's drive thread.

What remains polling-based:

  • Initial-connect failures (before execute() returns): no progress callback fires — line_reader_from_conf or line_reader_execute itself fails. Inspect the host / port the reader settled on with current_host() / current_port() once execute() returns a cursor.
  • Between queries, while no cursor is live, the reader holds no connection in the foreground, so there is nothing to observe. Poll reader.current_host() / reader.current_port() (or reader.server_info().epoch()) between successive execute() calls for "endpoint changed" or "topology rotated" signals.

Error handling

Every reader / query / cursor entry point that can fail returns a NULL/false / -1 on error and writes an opaque line_reader_error* through its err_out parameter. The C++ wrapper throws questdb::egress::line_reader_error (derived from std::runtime_error) with the same code and message.

line_reader_error_get_code(err) returns one of:

CodeMeaningTypical recovery
could_not_resolve_addrBad URL, host, or interface in the connect string.Fix the connect string.
config_errorConnect-string syntax or unknown key.Fix the connect string.
invalid_api_callWrong-order or wrong-state call (e.g. execute while a cursor is live, kind-mismatched col.get<T> / _column_data_get_*).Bug in the application; fix the call site.
socket_errorTCP / WS read / write / close failure.Per-query failover handles it transparently if failover=on and multiple addr= entries are configured; otherwise rebuild the reader.
tls_errorTLS handshake failure.Inspect cert chain; verify tls_roots / tls_ca.
handshake_errorHTTP upgrade or WebSocket handshake failed.Often a version or compression mismatch; check the server release.
auth_error401/403 from the upgrade response.Re-mint the credential; auth failures are terminal across all endpoints — failover does not retry them.
unsupported_serverServer advertises a QWP version, encoding, or capability the client cannot use.Align client and server versions; disable optional encodings.
role_mismatchAll endpoints connected but none matched target=.Loosen target or fix the topology.
protocol_errorWire-format violation: bad magic, truncated frame, bad varint.Rebuild the reader; report a bug if it recurs against an in-sync server.
invalid_utf8Connect string, SQL, or bind_varchar value contained invalid UTF-8.Re-encode the input.
invalid_bindBind count, index, or value rejected client-side (including timestamp / decimal / geohash range failures).Fix the bind.
server_schema_mismatchServer-reported SCHEMA_MISMATCH (0x03).Fix bind types or SQL.
server_parse_errorServer-reported PARSE_ERROR (0x05).Fix SQL.
server_internal_errorServer-reported INTERNAL_ERROR (0x06).Inspect server logs; retry with backoff.
server_security_errorServer-reported SECURITY_ERROR (0x08).Permission denied; check role / grants.
server_limit_exceededServer-reported LIMIT_EXCEEDED (0x0B).Reduce result size or raise the server limit.
limit_exceededClient-side limit hit (e.g. array row exceeds per-row element cap).Reduce row size.
cancelledLocal cancel() or server CANCELLED (0x0A).Expected after cancel().
failover_would_duplicateMid-query failover would replay rows the application has already consumed, and no on_failover_reset callback was installed.See Mid-stream failover hazard. Either wire a callback and discard partial state, or re-execute the query from scratch.

Once any of the server_* codes surface, the cursor is terminated; free it and (typically) reuse the reader for the next execute(). For auth_error, unsupported_server, tls_error, and role_mismatch, rebuild the reader from scratch — these are not transient.

Error object fields

A production error handler usually wants more than the code and the text. The following fields are everything you need to log a structured event, decide what to retry, and assemble a bug report:

FieldC++ accessorC functionNotes — stability / PII / scope
Codee.code()line_reader_error_get_code(err)line_reader_error_code discriminant. Stable across releases — the right field to dispatch on. For server-originated codes the discriminant embeds the QWP status byte (e.g. server_parse_error = 0x05). Safe to forward as-is.
Messagee.what()line_reader_error_msg(err, &len)UTF-8 diagnostic. Not null-terminated in C — read exactly len bytes; pointer is owned by the error and stays valid until line_reader_error_free. Server messages mirror QuestDB's normal SQL error formatting (capped at the QWP error-message limit, currently 1 KiB); client-synthesised messages cover transport / handshake / bind validation. Not stable across server versions — never pattern-match. May contain PII / secrets: it can echo offending bind values or server-supplied close reasons — log at the input's trust level and sanitise before forwarding to external trackers.
Cursor request IDcursor.request_id()line_reader_cursor_request_idServer-assigned request ID for the current connection. Refreshes on failover. Safe to forward.
Batch request IDbatch.request_id()line_reader_batch_request_id(batch)Request ID stamped on the most recently decoded batch (may differ from cursor.request_id() for already-buffered frames mid-failover). Read from the borrowed batch handle returned by next_batch(). Safe to forward.
Failover resetscursor.failover_resets()line_reader_cursor_failover_resetsCumulative successful mid-stream resets since execute(). A non-zero value next to a duplicate-row complaint tells you replay happened. Safe to forward.
Current endpointcursor.current_host() / cursor.current_port()line_reader_cursor_current_addr_host / _portEndpoint the cursor was attached to when the error fired. Safe to forward.
Client identifier(connect-string client_id= value)(same)Opaque label echoed by QuestDB as X-QWP-Client-Id. Set this in production so support can correlate sessions. Safe to forward.

The protocol does not currently surface a server-issued request or connection identifier in the WebSocket upgrade response. When opening a bug report, supply the connection start time (from your application logs), the client_id= value, and the cursor tuple (cursor.request_id, batch.request_id, failover_resets, current_host, current_port) — that is the closest you can get to a server-side correlation handle today.

Reader authentication and TLS

The reader and the sender share the same authentication and TLS configuration grammar — see Authentication and TLS above for the full table of tls_ca / tls_roots / tls_verify values. In brief:

  • HTTP basic auth: username=...;password=...;.
  • Bearer token (Enterprise): token=...;. Sent as Authorization: Bearer <token> on the WebSocket upgrade.
  • TLS: switch to wss::. Select root certificates with tls_ca (webpki_roots, os_roots, webpki_and_os_roots) or tls_roots=/path/to/ca.pem. tls_roots_password unlocks JKS / PKCS#12 keystores.

The reader applies the same restrictions as the sender:

PathStatusWorkaround
OIDC token acquisition or in-band refreshNot supported by this client. There is no IdP integration and no callback to refresh a token mid-session.QuestDB itself supports OIDC — see OpenID Connect. Acquire an access token out-of-band from your IdP, pass it via token=..., and rebuild the reader when the token nears expiry.
Mutual TLS (client certificates)Not supported. The QuestDB server does not negotiate client certificates.Use bearer-token auth over wss://. See the connect-string reference's TLS section.
Token rotation mid-sessionNot supported. The token is presented once during the WebSocket upgrade and is not re-sent.On token expiry, free the reader and build a fresh one with the new token. The cursor (and any open query) must be freed first.

Enterprise example: TLS, token auth, multi-host failover

A production read path typically combines all three. The reader uses the same connect-string grammar as the sender — drop sf_* keys (they are sender-only) and configure failover instead:

wss::addr=db-primary:9000,db-replica-1:9000,db-replica-2:9000;
token=eyJhbGciOi...;
target=primary;
failover_max_attempts=10;
failover_max_duration_ms=60000;
compression=auto;
tls_ca=os_roots;
client_id=quote-server;
line_sender_utf8 conf = QDB_UTF8_LITERAL(
"wss::addr=db-primary:9000,db-replica-1:9000,db-replica-2:9000;"
"token=eyJhbGciOi...;"
"target=primary;"
"failover_max_attempts=10;"
"failover_max_duration_ms=60000;"
"compression=auto;"
"tls_ca=os_roots;"
"client_id=quote-server;");
line_reader_error* err = NULL;
line_reader* reader = line_reader_from_conf(conf, &err);

client_id is opaque to the server but appears in QuestDB's connection logs as X-QWP-Client-Id — include it in bug reports to help support correlate sessions.

Configuration reference

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

Common WebSocket-specific options accepted by the ingestion sender:

KeyDefaultDescription
addrrequiredOne or more host:port entries, comma-separated or repeated.
username / passwordunsetHTTP basic auth.
tokenunsetBearer token auth (Enterprise).
auth_timeout_ms15000WebSocket upgrade timeout (milliseconds). auth_timeout is also accepted.
tls_ca / tls_roots / tls_verifywebpki_rootsTLS configuration (wss only).
auto_flushrequired off if setAuto-flush is not supported. auto_flush_rows and auto_flush_bytes are rejected.
sf_dirunsetEnable disk-backed store-and-forward.
sender_iddefaultSF slot identity.
sf_durabilitymemoryOnly memory is currently accepted.
request_durable_ackoffWait for durable upload before ACK (Enterprise).
reconnect_max_duration_millis300000Per-outage reconnect budget.
initial_connect_retryoff (auto-promoted to on when any explicit reconnect_* key is set)Apply reconnect policy to the first connect; explicit off preserves fail-fast startup.
close_flush_timeout_millis5000Bound on close_drain wait.
qwp_ws_progressbackgroundbackground or manual.
max_in_flight128Max unacknowledged frames in flight on a connection. Acts as the backpressure window: publishers block locally once the window is full.

Common WebSocket-specific options accepted by the query reader:

KeyDefaultDescription
addrrequiredOne or more host:port entries, comma-separated or repeated.
username / passwordunsetHTTP basic auth.
tokenunsetBearer token auth (Enterprise).
auth_timeout_ms15000Per-host HTTP upgrade timeout (milliseconds).
tls_ca / tls_roots / tls_verify / tls_roots_passwordwebpki_rootsTLS configuration (wss only).
targetanyEndpoint role filter: any, primary, replica.
zoneunsetZone-aware routing hint (Enterprise).
failoveronMaster switch for per-query reconnect.
failover_max_attempts8Cap on reconnect attempts per execute().
failover_backoff_initial_ms50First post-failure sleep.
failover_backoff_max_ms1000Cap on per-attempt sleep.
failover_max_duration_ms30000Total wall-clock budget per execute().
compressionrawraw / zstd / auto. zstd and auto require the compression-zstd build feature.
compression_level1Advertised zstd level. Server clamps to [1, 9].
max_batch_rowsserver defaultHint passed in X-QWP-Max-Batch-Rows. 0 (or unset) defers to the server.
client_idunsetOpaque identifier echoed in server logs as X-QWP-Client-Id.
path/read/v1Endpoint path. Rarely changed.
max_version2Highest QWP version the reader advertises.

Migration from ILP (HTTP/TCP)

The buffer API is unchanged. To switch a sender to QWP/WebSocket:

AspectHTTP (ILP)WebSocket (QWP)
Connect string schemahttp:: / https::ws:: / wss::
Batch triggerRow/time-based auto-flush (defaults: 75000 rows, 1000 ms)Explicit flush() only
Error modelSynchronous on flush()Async via line_sender_qwpws_poll_error / handler
Completion trackingImplicit per requestExplicit FSN watermarks
Store-and-forwardNot availableAvailable (sf_dir)
Multi-endpoint failoverNot availableBuilt in (comma-separated addr)
Shutdownline_sender_closeline_sender_qwpws_close_drain, then line_sender_close
Querying SQL from the same libraryNot availableline_reader_* (C) / questdb::egress::reader (C++) — see Querying and SQL execution

To migrate, change the connect string from http:: to ws:: (or https:: to wss::), drop any auto_flush_* keys, install a line_sender_opts_qwpws_error_handler (C) / qwp_ws_error_handler (C++) callback or poll line_sender_qwpws_poll_error, and call line_sender_qwpws_close_drain before closing the sender.

Full example: multi-host ingestion with failover

This example shows a production ingestion loop with store-and-forward, multi-host failover, and proper error handling including the retry pattern around flush().

#include <questdb/ingress/line_sender.h>
#include <stdio.h>
#include <string.h>

static void on_qwp_error(
void* user_data,
const line_sender_qwpws_error_view* ev)
{
(void)user_data;
fprintf(stderr,
"qwp error: category=%d policy=%d msg=%.*s\n",
(int)ev->category, (int)ev->applied_policy,
(int)ev->message_len, ev->message);
}

int main(void) {
line_sender_error* err = NULL;
line_sender* sender = NULL;
line_sender_buffer* buffer = NULL;

/* Multi-host with store-and-forward for failover durability.
* Without sf_dir, flush() blocks during an outage and times out
* after sf_append_deadline_millis (default 30s). With sf_dir,
* flush() writes to disk and returns quickly while the reconnect
* loop replays to the new primary in the background. */
line_sender_utf8 conf = QDB_UTF8_LITERAL(
"wss::addr=db-primary:9000,db-replica:9000;"
"token=your_bearer_token;"
"sf_dir=/var/lib/myapp/qdb-sf;"
"sender_id=ingest-1;"
"reconnect_max_duration_millis=300000;");
sender = line_sender_from_conf(conf, &err);
if (!sender) goto on_error;

buffer = line_sender_buffer_new_for_sender(sender);

line_sender_table_name tbl = QDB_TABLE_NAME_LITERAL("book");
line_sender_column_name ticker_name = QDB_COLUMN_NAME_LITERAL("ticker");
line_sender_column_name price_name = QDB_COLUMN_NAME_LITERAL("price");
line_sender_column_name size_name = QDB_COLUMN_NAME_LITERAL("size");
line_sender_utf8 ticker_val = QDB_UTF8_LITERAL("EURUSD");

for (;;) {
/* Only encode a fresh row when the previous batch has been flushed.
* On flush() failure, the rows stay buffered for the next attempt. */
if (line_sender_buffer_row_count(buffer) == 0) {
if (!line_sender_buffer_table(buffer, tbl, &err)) goto on_error;
if (!line_sender_buffer_symbol(buffer, ticker_name, ticker_val, &err))
goto on_error;
if (!line_sender_buffer_column_f64(buffer, price_name, 1.0842, &err))
goto on_error;
if (!line_sender_buffer_column_f64(buffer, size_name, 100000.0, &err))
goto on_error;
if (!line_sender_buffer_at_nanos(buffer, line_sender_now_nanos(), &err))
goto on_error;
}

/* flush() can still return an error if the SF queue fills to
* sf_max_total_bytes during a prolonged outage. On success the
* buffer is cleared; on failure it is retained so the next
* iteration retries the same payload. */
if (!line_sender_flush(sender, buffer, &err)) {
size_t err_len = 0;
const char* msg = line_sender_error_msg(err, &err_len);
fprintf(stderr, "flush error: %.*s\n", (int)err_len, msg);
line_sender_error_free(err);
err = NULL;

/* Check if the sender is terminal (auth failure, reconnect
* budget exhausted). If so, recreate it. */
if (line_sender_must_close(sender)) {
fprintf(stderr, "sender is terminal, exiting\n");
break;
}
}

/* Pace the loop as appropriate for your workload. */
}

if (!line_sender_qwpws_close_drain(sender, &err)) goto on_error;
line_sender_buffer_free(buffer);
line_sender_close(sender);
return 0;

on_error:;
size_t err_len = 0;
const char* msg = line_sender_error_msg(err, &err_len);
fprintf(stderr, "error: %.*s\n", (int)err_len, msg);
line_sender_error_free(err);
if (buffer) line_sender_buffer_free(buffer);
if (sender) line_sender_close(sender);
return 1;
}

Next steps

The header files are extensively commented and serve as the canonical API reference. Browse them on GitHub or in your local checkout:

For SQL execution from C/C++, the Querying and SQL execution section covers the QWP reader. Alternatives outside this library are the PGWire C++ client and the REST API.

With data flowing into QuestDB, the next step is querying. See the Query overview to learn QuestDB SQL.

Need help? Visit the Community Forum.