C & C++ client for QuestDB
The C and C++ clients ingest and query over QWP, a columnar binary protocol carried over WebSocket. The pool is the entry point for both ingesting and querying data: borrow a sender to write rows, or a reader to execute SQL queries.
CMake setup
The client lives at
questdb/c-questdb-client: one
questdb_client library for both languages (the C++ API is header-only
wrappers over the C ABI). Requirements: C11 (C API) or C++17 (C++ API), and a
Rust toolchain to build the Rust core from source. The
fastest integration is CMake FetchContent; pin a release tag or commit SHA
for production builds:
include(FetchContent)
FetchContent_Declare(
c_questdb_client_proj
GIT_REPOSITORY https://github.com/questdb/c-questdb-client.git
GIT_TAG 7.0.0) # or a newer release tag, or a commit SHA
FetchContent_MakeAvailable(c_questdb_client_proj)
target_link_libraries(your_target questdb_client)
- The query reader and its
zstddependency are always compiled into the C and C++ library. - Arrow support (ingestion and
result batches) is opt-in: build with
-DQUESTDB_ENABLE_ARROW=ON. - The library links statically by default;
-DBUILD_SHARED_LIBS=ONbuilds a shared library instead. Outside CMake, add the repo'sinclude/directory to your include path and link-lquestdb_client. - Vendoring with
git submodule/git subtree, and building from source, are covered in the repo's DEPENDENCY.md.
Headers
Three QWP entry headers, in both a C (.h) and a C++ (.hpp) flavour:
| Header | Provides |
|---|---|
questdb/client | The pool: questdb_db / questdb::pool |
questdb/ingress/qwp_sender | Senders: questdb_db_borrow_sender / pool::borrow_sender() |
questdb/egress/qwp_reader | Readers: questdb_db_borrow_reader / pool::borrow_reader() |
Both borrow headers include the pool header, so a program that only ingests
includes only qwp_sender, and one that only queries includes only
qwp_reader — each gets the pool along with it. Include client directly to hold
or configure a pool in a translation unit that borrows nothing itself. Mixing
the C and C++ flavours in one translation unit is fine; the C++ API is a
header-only wrapper over the C ABI.
The shared error, table/column name and UTF-8 types (line_sender_error,
line_sender_buffer, the _utf8 literals) live in
questdb/ingress/line_sender.h, which all three entry headers include, so the
examples below use them without naming that header. The
Full API reference maps the whole surface.
Quick start
The code below opens a connection pool, borrows a sender to write a row, then borrows a reader to read it back. This exercises both APIs in one runnable program; in production the ingestion and query paths are usually separate services that share only the table.
The read-back pauses for a second first. Flush-and-wait returns once the server has accepted the row; the row becomes visible to queries a few milliseconds later, once QuestDB applies the WAL to the table (see Durability and backpressure). For bulk/columnar and Arrow ingestion, see The pool and Sending data: chunks.
- C++
- C
#include <questdb/ingress/qwp_sender.hpp> // pool::borrow_sender
#include <questdb/egress/qwp_reader.hpp> // pool::borrow_reader
#include <chrono>
#include <iostream>
#include <thread>
using namespace questdb::ingress::literals;
int main()
{
questdb::pool pool{"ws::addr=localhost:9000;"};
// Insert: borrow a sender, write a row, flush-and-wait.
{
auto sender = pool.borrow_sender();
auto buffer = sender.new_buffer();
buffer.table("trades"_tn)
.symbol("symbol"_cn, "ETH-USDT"_utf8)
.column("price"_cn, 2615.54)
.at(questdb::ingress::timestamp_nanos::now());
sender.flush_and_wait(buffer);
}
// Pause so the read-back sees the row: the flush-and-wait above means the
// server accepted it, and it becomes queryable a few milliseconds later.
std::this_thread::sleep_for(std::chrono::seconds(1));
// Query: borrow a reader, run SQL, print rows.
auto reader = pool.borrow_reader();
auto cur = reader.execute("SELECT symbol, price FROM trades LIMIT 5"_utf8);
while (auto batch = cur.next_batch())
{
for (size_t r = 0; r < batch->row_count(); ++r)
{
auto price = batch->column(1).get<double>(r);
std::cout << batch->column(0).symbol(r).value_or("NULL") << " "
<< (price ? std::to_string(*price) : "NULL") << "\n";
}
}
}
#include <questdb/ingress/qwp_sender.h> // questdb_db_borrow_sender + line_sender_buffer
#include <questdb/egress/qwp_reader.h> // questdb_db_borrow_reader
#include <stdio.h>
#include <string.h>
#include <unistd.h>
int main(void)
{
/* line_sender_error and questdb_error are the same type under two names,
so one variable would do; two keeps the error paths separate below. */
line_sender_error* err = NULL; /* pool + sender */
questdb_error* rerr = NULL; /* reader + cursor */
questdb_db* db = NULL;
qwp_sender* sender = NULL;
line_sender_buffer* buffer = NULL;
qwp_reader* rd = NULL;
qwp_reader_cursor* cursor = NULL;
const char* conf = "ws::addr=localhost:9000;";
db = questdb_db_connect(conf, strlen(conf), &err);
if (!db) goto on_error;
/* Insert: borrow a sender, write a row, flush-and-wait. */
sender = questdb_db_borrow_sender(db, &err);
if (!sender) goto on_error;
buffer = questdb_db_new_buffer(db, &err);
if (!buffer) goto on_error;
if (!line_sender_buffer_table(buffer, QDB_TABLE_NAME_LITERAL("trades"), &err)) goto on_error;
if (!line_sender_buffer_symbol(buffer, QDB_COLUMN_NAME_LITERAL("symbol"),
QDB_UTF8_LITERAL("ETH-USDT"), &err)) goto on_error;
if (!line_sender_buffer_column_f64(buffer, QDB_COLUMN_NAME_LITERAL("price"), 2615.54, &err)) goto on_error;
if (!line_sender_buffer_at_nanos(buffer, line_sender_now_nanos(), &err)) goto on_error;
if (!qwp_sender_flush_buffer_and_wait(sender, buffer, qwpws_ack_level_ok, &err)) goto on_error;
line_sender_buffer_free(buffer);
buffer = NULL;
questdb_db_return_sender(db, sender);
sender = NULL;
/* Pause so the read-back sees the row: the flush-and-wait above means the
server accepted it, and it becomes queryable a few milliseconds later. */
sleep(1);
/* Query: borrow a reader, run SQL, print rows. */
rd = questdb_db_borrow_reader(db, &rerr);
if (!rd) goto on_query_error;
cursor = qwp_reader_execute(rd,
QDB_UTF8_LITERAL("SELECT symbol, price FROM trades LIMIT 5"), &rerr);
if (!cursor) goto on_query_error;
const qwp_reader_batch* batch;
while ((batch = qwp_reader_cursor_next_batch(cursor, &rerr)) != NULL)
{
size_t rows = qwp_reader_batch_row_count(batch);
qwp_reader_column_data d_symbol, d_price;
qwp_reader_symbol_dict dict;
if (!qwp_reader_batch_column_data(batch, 0, &d_symbol, &rerr)) goto on_query_error;
if (!qwp_reader_batch_column_data(batch, 1, &d_price, &rerr)) goto on_query_error;
if (!qwp_reader_batch_symbol_dict(batch, &dict, &rerr)) goto on_query_error;
for (size_t r = 0; r < rows; ++r)
{
bool sym_null = false, price_null = false;
const char* sym = NULL;
size_t sym_len = 0;
if (!qwp_reader_column_data_get_symbol(&d_symbol, &dict, r, &sym, &sym_len, &sym_null))
goto on_query_error;
double price = qwp_reader_column_data_get_f64(&d_price, r, &price_null);
if (sym_null) printf("NULL ");
else printf("%.*s ", (int)sym_len, sym);
if (price_null) printf("NULL\n");
else printf("%g\n", price);
}
}
if (rerr) goto on_query_error;
qwp_reader_cursor_free(cursor);
qwp_reader_close(rd);
questdb_db_close(db);
return 0;
on_error:;
{
size_t len = 0;
const char* msg = line_sender_error_msg(err, &len);
fprintf(stderr, "error: %.*s\n", (int)len, msg);
line_sender_error_free(err);
}
line_sender_buffer_free(buffer);
if (sender) questdb_db_drop_sender(db, sender);
questdb_db_close(db);
return 1;
on_query_error:;
{
size_t len = 0;
const char* msg = questdb_error_msg(rerr, &len);
fprintf(stderr, "error: %.*s\n", (int)len, msg);
questdb_error_free(rerr);
}
qwp_reader_cursor_free(cursor);
qwp_reader_close(rd);
questdb_db_close(db);
return 1;
}
The pool
questdb_db (C) / questdb::pool (C++) is a thread-safe handle that owns a set
of reusable QWP/WebSocket connections. Open one per deployment, share it across
threads, close it at shutdown.
You don't open connections yourself: borrow a lease (a sender or reader),
use it on one thread, then return it (recycles the connection) or drop
it (retires it). In C++ the borrowed sender wrapper and pooled reader return on
destruction; drop_on_return() forces a drop. The pool connects lazily, and
reconnects and fails over on its own.
Which borrow?
Match the borrow to the shape of your data:
| You have | Borrow | Why |
|---|---|---|
| Columnar data already in arrays or Arrow batches (bulk loads, backfills, ETL) | Sender (borrow_sender) + chunk or Arrow method | Whole columns are encoded in one pass, with no per-row assembly. |
| Events arriving one at a time (tickers, order flow, telemetry) | Sender (borrow_sender) + buffer | Build rows field-by-field into a buffer, flush batches on your cadence. |
| SQL to run: verification read-backs, dashboards, downsampling | Reader (borrow_reader) | Streams typed columnar batches with flow control. |
Buffers, chunks, and Arrow batches may target the same tables; QuestDB unifies the schema server-side.
Connecting
The connect string uses a QWP/WebSocket scheme: ws / wss. For auth and TLS
keys, see the
connect string reference. The pool is
lazy: questdb_db_connect (C) and the questdb::pool constructor (C++)
parse and validate the string but perform no blocking network I/O, so auth /
TLS / connect errors surface from the first borrow, not at construction. The
one exception is the on-disk send queue (sf_dir, see
Durability and backpressure): at connect the
pool reopens any queue directories a previous run left holding unacknowledged
rows, then reconnects and resends them in the background.
- C++
- C
questdb::pool pool{"ws::addr=localhost:9000;"}; // alias of questdb::ingress::pool
line_sender_error* err = NULL;
const char* conf = "ws::addr=localhost:9000;";
questdb_db* db = questdb_db_connect(conf, strlen(conf), &err);
if (!db) { /* read err, handle */ }
Authentication and TLS
Authentication happens during the WebSocket upgrade, before any binary frames are exchanged. All keys go in the connect string:
wss::addr=db.example.com:9000;username=admin;password=quest; # HTTP basic
wss::addr=db.example.com:9000;token=your_bearer_token; # bearer token (Enterprise, recommended)
- TLS: use the
wssscheme.tls_capicks the root store; the default,webpki_roots, ships with the client and behaves the same in every environment (good for containers). Useos_rootswhen trust is managed at the OS level (corporate CAs pushed to the host),webpki_and_os_rootsfor both, ortls_roots=/path/ca.pemfor a private CA.tls_verify=unsafe_offdisables verification (testing only). A standard CMake build accepts this key; a library built with-DQUESTDB_ENABLE_INSECURE_SKIP_VERIFY=OFFrejects it. auth_timeout_ms(default 15000) bounds the WebSocket upgrade.
Because the pool connects lazily, a bad credential surfaces as
line_sender_error_auth_error from the first borrow on each connection.
Handle it there, not at connect (see
Which errors mean what).
Unsupported auth paths
The client supports only HTTP basic auth and static bearer-token auth. The following are not supported:
| Path | Status | Workaround |
|---|---|---|
| OIDC token acquisition or in-band refresh | Not supported. 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. Acquire an access token out-of-band from your IdP, pass it via token=..., and rebuild the pool 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. |
| Token rotation mid-session | Not supported. Credentials are presented once during the WebSocket upgrade and are not re-sent. | On token expiry, close the pool and build a fresh one with the new token. |
Headers
One header covers all writing, including the row-buffer API; querying adds the reader header.
- C++
- C
#include <questdb/ingress/qwp_sender.hpp> // pool::borrow_sender
#include <questdb/egress/qwp_reader.hpp> // pool::borrow_reader
#include <questdb/ingress/qwp_sender.h> // questdb_db_borrow_sender + line_sender_buffer
#include <questdb/egress/qwp_reader.h> // questdb_db_borrow_reader
Sending data: row buffers
Borrow a sender and flush a line_sender_buffer built field-by-field. Create
the buffer from the pool — questdb_db_new_buffer in C,
borrowed_sender::new_buffer() in C++ — so that it picks up the pool's
protocol settings. The buffer is caller-owned and is not tied to the sender
borrow.
The pool is the shared handle; a borrowed sender is single-thread, so borrow one per worker.
- C++
- C
#include <questdb/ingress/qwp_sender.hpp>
#include <iostream>
using namespace questdb::ingress::literals;
int main()
{
try
{
questdb::pool pool{"ws::addr=localhost:9000;"};
auto sender = pool.borrow_sender(); // borrowed_sender (RAII)
auto buffer = sender.new_buffer(); // buffer from the pool
buffer.table("trades"_tn)
.symbol("symbol"_cn, "ETH-USDT"_utf8)
.column("price"_cn, 2615.54)
.at(questdb::ingress::timestamp_nanos::now());
sender.flush(buffer); // borrowed_sender::flush(line_sender_buffer&)
return 0;
}
catch (const questdb::error& e)
{
std::cerr << "error: " << e.what() << "\n";
return 1;
}
}
#include <questdb/ingress/qwp_sender.h> // questdb_db_borrow_sender + line_sender_buffer
#include <stdio.h>
#include <string.h>
int main(void)
{
line_sender_error* err = NULL;
questdb_db* db = NULL;
qwp_sender* sender = NULL;
line_sender_buffer* buffer = NULL;
const char* conf = "ws::addr=localhost:9000;";
db = questdb_db_connect(conf, strlen(conf), &err);
if (!db) goto on_error;
sender = questdb_db_borrow_sender(db, &err);
if (!sender) goto on_error;
buffer = questdb_db_new_buffer(db, &err); // buffer from the pool
if (!buffer) goto on_error;
if (!line_sender_buffer_table(buffer, QDB_TABLE_NAME_LITERAL("trades"), &err)) goto on_error;
if (!line_sender_buffer_symbol(buffer, QDB_COLUMN_NAME_LITERAL("symbol"),
QDB_UTF8_LITERAL("ETH-USDT"), &err)) goto on_error;
if (!line_sender_buffer_column_f64(buffer, QDB_COLUMN_NAME_LITERAL("price"), 2615.54, &err)) goto on_error;
if (!line_sender_buffer_at_nanos(buffer, line_sender_now_nanos(), &err)) goto on_error;
// queues the rows locally and clears the buffer; does not wait for the server
if (!qwp_sender_flush_buffer(sender, buffer, &err)) goto on_error;
line_sender_buffer_free(buffer);
questdb_db_return_sender(db, sender);
questdb_db_close(db);
return 0;
on_error:;
size_t len = 0;
const char* msg = line_sender_error_msg(err, &len);
fprintf(stderr, "error: %.*s\n", (int)len, msg);
line_sender_error_free(err);
line_sender_buffer_free(buffer);
if (sender) questdb_db_drop_sender(db, sender);
questdb_db_close(db);
return 1;
}
There are no auto_flush_rows / auto_flush_bytes keys for the QWP/WebSocket
buffer path; flushing is always explicit. Accumulate rows in the buffer on
your own cadence (row count, byte size via line_sender_buffer_size, or a
timer) and call flush yourself. A good starting cadence is about 1,000 rows or
100 ms, whichever comes first: the auto-flush defaults of the other QWP
clients. Flushing every row collapses throughput, and a single flush must
stay under the sf_max_segment_bytes payload cap (default 4 MiB). Null columns are
written by omission: skip the column for that row. There is no set_null
call.
qwp_sender_flush_buffer (C++ sender.flush(buffer)) queues the rows locally
and returns without waiting for the server. qwp_sender_flush_buffer_and_wait
(C++ sender.flush_and_wait(buffer)) flushes and then blocks until the server
has acknowledged everything published so far. Its wait is bounded by the
pool-wide request_timeout (default 30000 ms), which fires only if
acknowledgements stop arriving — it is not a cap on total wait time.
To pick your own deadline, call flush and then qwp_sender_wait /
sender.wait() yourself. A C timeout_millis of 0 (and the C++ default
argument) waits indefinitely; a non-zero value likewise fires only when
acknowledgements stop arriving (see
Durability and backpressure). To track progress
without blocking, use a flush that returns a frame sequence number (FSN) and
check how far the acknowledged FSN has advanced; see
FSN progress.
Decimal columns
DECIMAL columns (QuestDB server 9.2.0 or later) accept either a string
value or a binary unscaled-integer value. Create the column ahead of time
with the precision and scale you need
(CREATE TABLE trades (..., price DECIMAL(18, 2))) so QuestDB stores it at the
intended width and scale.
Within one flush, the first non-null decimal value for a column fixes the scale
that every later value in that flush is encoded at. Later values may use the
same or a smaller scale; a larger one fails at flush, because encoding it would
lose precision. Encode every value at the table column's scale — "1.20"
rather than "1.2" for DECIMAL(..., 2) — and row order stops mattering.
- C++
- C
// `buffer` and `.table(...)` as in the buffer example above.
using namespace questdb::ingress::decimal; // "..."_decimal, decimal_view
// String form: use '.' between the whole and fractional parts. "+Infinity",
// "-Infinity" and "NaN" are accepted but decay to null once stored.
buffer.table("trades"_tn)
.column("price"_cn, "2615.54"_decimal)
.at(questdb::ingress::timestamp_nanos::now());
// Binary form: an unscaled value as two's-complement big-endian bytes plus a
// scale. 12345 with scale 2 is 123.45. Scale <= 76, mantissa <= 32 bytes.
const uint8_t unscaled[] = {0x30, 0x39}; // 12345
buffer.table("trades"_tn)
.column("price"_cn, decimal_view(2, unscaled))
.at(questdb::ingress::timestamp_nanos::now());
column uses DECIMAL256 on the wire. To use a narrower wire width, call
column_dec64 or column_dec128; they accept the same string or decimal_view
value and raise line_sender_error if the unscaled value does not fit the
selected width after conversion to the pending buffer's wire scale. You can
also ingest a custom decimal type directly by implementing a
to_decimal_view_state_impl customization point for it. See
examples/line_sender_cpp_example_decimal_custom.cpp in the client repository.
// `buffer` and `err` as in the buffer example above.
// String form (value + byte length). Format rules as described above.
if (!line_sender_buffer_table(
buffer, QDB_TABLE_NAME_LITERAL("trades"), &err))
goto on_error;
if (!line_sender_buffer_column_dec_str(
buffer, QDB_COLUMN_NAME_LITERAL("price"), "2615.54", 7, &err))
goto on_error;
if (!line_sender_buffer_at_nanos(buffer, line_sender_now_nanos(), &err))
goto on_error;
// Binary form: scale + unscaled value (two's-complement, big-endian).
const uint8_t unscaled[] = {0x30, 0x39}; // 12345, scale 2 -> 123.45
if (!line_sender_buffer_table(
buffer, QDB_TABLE_NAME_LITERAL("trades"), &err))
goto on_error;
if (!line_sender_buffer_column_dec(
buffer, QDB_COLUMN_NAME_LITERAL("price"),
2, unscaled, sizeof(unscaled), &err))
goto on_error;
if (!line_sender_buffer_at_nanos(buffer, line_sender_now_nanos(), &err))
goto on_error;
line_sender_buffer_column_dec64{,_str} and _dec128{,_str} pin the wire width
to DECIMAL64 / DECIMAL128.
The decimal setters above are available on row buffers. For chunk ingestion, use the Arrow appenders.
See the Decimal datatype reference for precision and storage widths, and the QWP decimal encoding for the binary wire layout.
Sending data: chunks
Borrow a sender, build a chunk of columns (each a contiguous array plus a row
count), set the designated timestamp, then flush. flush publishes the chunk
into the store-and-forward queue and returns; wait blocks until the server has
acknowledged everything published so far. flush_and_wait combines the two for
the simple synchronous path, shown below.
The questdb_db / questdb::pool is the only handle you share across threads;
a borrowed sender belongs to the thread that took it, so borrow one per worker.
- C++
- C
#include <questdb/ingress/qwp_sender.hpp>
#include <iostream>
int main()
{
try
{
questdb::pool pool{"ws::addr=localhost:9000;"};
auto sender = pool.borrow_sender(); // RAII: returns to pool on scope exit
double price[] = {2615.54, 2616.00, 2617.25};
double amount[] = {0.00044, 0.00050, 0.00021};
int64_t ts_ns[] = {1700000000000000000, 1700000000001000000, 1700000000002000000};
size_t n = 3;
questdb::ingress::column_chunk chunk{"trades"};
chunk.column_f64("price", price, n)
.column_f64("amount", amount, n)
.at_nanos(ts_ns, n);
sender.flush_and_wait(chunk); // publish, then block until acked
return 0;
}
catch (const questdb::error& e)
{
std::cerr << "error: " << e.what() << "\n";
return 1;
}
}
#include <questdb/ingress/qwp_sender.h>
#include <stdio.h>
#include <string.h>
int main(void)
{
line_sender_error* err = NULL;
questdb_db* db = NULL;
qwp_sender* sender = NULL;
qwp_chunk* chunk = NULL;
const char* conf = "ws::addr=localhost:9000;";
db = questdb_db_connect(conf, strlen(conf), &err);
if (!db) goto on_error;
sender = questdb_db_borrow_sender(db, &err);
if (!sender) goto on_error;
double price[] = {2615.54, 2616.00, 2617.25};
double amount[] = {0.00044, 0.00050, 0.00021};
int64_t ts_ns[] = {1700000000000000000, 1700000000001000000, 1700000000002000000};
size_t n = 3;
chunk = qwp_chunk_new("trades", 6, &err);
if (!chunk) goto on_error;
// (chunk, name, name_len, data, row_count, validity_or_NULL, err_out)
if (!qwp_chunk_column_f64(chunk, "price", 5, price, n, NULL, &err)) goto on_error;
if (!qwp_chunk_column_f64(chunk, "amount", 6, amount, n, NULL, &err)) goto on_error;
if (!qwp_chunk_at_nanos(chunk, ts_ns, n, &err)) goto on_error;
// publish, then block until acked
if (!qwp_sender_flush_chunk_and_wait(sender, chunk, qwpws_ack_level_ok, &err)) goto on_error;
qwp_chunk_free(chunk);
questdb_db_return_sender(db, sender); // return the borrow to the pool
questdb_db_close(db);
return 0;
on_error:;
size_t len = 0;
const char* msg = line_sender_error_msg(err, &len);
fprintf(stderr, "error: %.*s\n", (int)len, msg);
line_sender_error_free(err);
qwp_chunk_free(chunk);
if (sender) questdb_db_drop_sender(db, sender); // drop a possibly-in-doubt sender
questdb_db_close(db);
return 1;
}
Notes
- Reuse the chunk across flushes: a flush clears it once the frame is
accepted into the local queue, while keeping its capacity. To reset a chunk
you built but decided not to send, call
qwp_chunk_clear(C++chunk.clear()). - All columns (and the timestamp) must share the same
row_count. The chunk borrows your arrays; they must outlive the flush. - A chunk too large for one frame is split across several, publishing each
on its own. The client targets about 2 MiB per frame — half of
sf_max_segment_bytes(default 4 MiB) — so a chunk encoding to more than roughly 2 MiB splits. It halves the row range until each frame fits, stopping at 8 rows: validity bitmaps and boolean columns pack one bit per row, so a frame must start on a byte boundary. An 8-row frame is checked against the full 4 MiB rather than the 2 MiB target; if 8 rows still exceed 4 MiB — which takes very large string, binary, or array values — the flush fails instead of splitting. - Recovery depends on
in_doubt, not on the error code. Checkline_sender_error_in_doubt(C++:e.in_doubt()). False means the queue never took the frame and the chunk is intact: re-flush it. True means delivery is uncertain, sowaitfor what the queue already holds, and resend the chunk only where the table's dedup keys make duplicate rows harmless. A chunk that split across frames needs extra care; Durability and backpressure has the full rules. (To poll progress without blocking, see FSN progress.) - A successful
flushis local acceptance, not delivery.qwp_sender_flush_chunkreturning true means the local store-and-forward queue took the frame; a background thread then delivers it and collects the server's ack, whichqwp_sender_wait(C++sender.wait()) observes. For throughput, publish many chunks withflush, thenwaitonce (qwpws_ack_level_durablewaits for durable upload, Enterprise). See Durability and backpressure. - Return the borrow. C:
questdb_db_return_sender(recycle) orquestdb_db_drop_sender(retire a possibly in-doubt sender); C++ does it in theborrowed_senderdestructor, anddrop_on_return()forces a drop. Once a sender hits a terminal error, every later call on it fails the same way. The return path retires those senders, and any whose pool has been closed, so plain return/destruction is correct for healthy senders. Drop after an error when the next borrower must not inherit that connection.
Null values
Chunk nulls use an Arrow-shape validity bitmap: bit = 1 means valid,
LSB-first, one bit per row. Null rows' data slots are ignored (any value works).
Pass NULL (C) / omit the argument (C++) for a column with no nulls.
- C++
- C
double amount[] = {0.00044, 0.0, 0.00021}; // row 1 is null; slot ignored
uint8_t amount_valid[] = {0x05}; // 0b101: rows 0 and 2 valid
questdb::ingress::validity_view validity{amount_valid, 3};
chunk.column_f64("amount", amount, 3, &validity);
double amount[] = {0.00044, 0.0, 0.00021}; // row 1 is null; slot ignored
uint8_t amount_valid[] = {0x05}; // 0b101: rows 0 and 2 valid
qwp_validity validity = {amount_valid, 3};
if (!qwp_chunk_column_f64(chunk, "amount", 6, amount, 3, &validity, &err))
goto on_error;
The designated timestamp is always non-null per the QWP wire spec: the timestamp setters take no validity parameter.
Column setters
The fixed-width scalar and temporal setters take
(chunk, name, name_len, data, row_count, validity, err_out) in C, and the
same arguments minus chunk, name_len, and err_out, chained on
column_chunk, in C++ (name is a std::string_view; errors throw). Three
families vary from that shape:
column_tstakes one extra argument, aqwp_ts_unit(auint32_ton the ABI), sitting betweenrow_countandvalidity.column_str/column_binarytake(offsets, bytes, bytes_len)in Arrow Utf8 / Binary layout in place ofdata.symbol_i8/_i16/_i32take(codes, row_count, dict_offsets, dict_offsets_len, dict_bytes, dict_bytes_len)in place ofdata.
See qwp_sender.h for the exact signatures. Complete list:
| Setter | Input per row | QuestDB type |
|---|---|---|
column_i8 / column_i16 / column_i32 / column_i64 | signed int | BYTE / SHORT / INT / LONG |
column_f32 / column_f64 | float / double | FLOAT / DOUBLE |
column_bool | LSB-first packed bitmap | BOOLEAN |
column_ts + qwp_ts_unit (_micros / _nanos) | int64 since epoch | TIMESTAMP / TIMESTAMP_NS |
column_date | int64 millis since epoch | DATE |
column_uuid | 16 bytes | UUID |
column_long256 | 32 bytes (4 LE limbs) | LONG256 |
column_ipv4 | uint32 | IPV4 |
column_str | Arrow Utf8 offsets + bytes | VARCHAR |
column_binary | Arrow Binary offsets + bytes | BINARY |
symbol_i8 / _i16 / _i32 | dict codes + Utf8 dictionary | SYMBOL |
Designated timestamp (exactly once per chunk, before flush):
at_nanos / at_micros / at_millis / at_seconds (millis and
seconds are widened to micros on the wire). Decimals, geohash, arrays, and the
remaining Arrow types are reachable through the
Arrow appenders.
qwp_sender.h also declares qwp_chunk_append_numpy_column,
which appends a raw buffer tagged with a qwp_numpy_dtype. It is
there for hosts that already hold NumPy arrays. There is no C++ wrapper for it,
by design: without a NumPy host it is awkward to call, and the
Arrow appenders reach the same types through a safer
interface. If you do have NumPy buffers in a C++ program, call the C function
directly — and note it requires a C-contiguous buffer, since it walks data
forward at the dtype's native stride and cannot see NumPy's strides. Pass a
sliced, transposed, or reversed view and it reads out of bounds. Run
numpy.ascontiguousarray first.
timestamp server-sideWhatever your source column is called, QuestDB materializes the designated
timestamp as a column literally named timestamp. Query it back with
SELECT timestamp ... / ORDER BY timestamp, not the source name.
Arrow ingestion
If your data is already in Arrow (Arrow C++, PyArrow, polars, pandas, DuckDB,
nanoarrow), skip the per-column setters and flush a whole RecordBatch through
the Arrow C Data Interface
(ArrowArray + ArrowSchema). One call encodes the batch, picks the
designated timestamp from a named column, and publishes it as one logical
batch. Requires a build with QUESTDB_CLIENT_ENABLE_ARROW (CMake option
QUESTDB_ENABLE_ARROW). For the read direction, see
Arrow result batches.
- C++
- C
using namespace questdb::ingress::literals;
// Requires a build with QUESTDB_CLIENT_ENABLE_ARROW.
// `array` + `schema` from any Arrow C Data Interface producer, e.g.
// arrow::ExportRecordBatch(*batch, &array, &schema) in Arrow C++.
void ingest(questdb::pool& pool, ArrowArray& array, const ArrowSchema& schema)
{
auto sender = pool.borrow_sender(); // one per thread
// Publish, then block until acked; plain flush_arrow_batch() + wait()
// is the pipelined form.
sender.flush_arrow_batch_and_wait("trades"_tn, array, schema, "ts"_cn);
}
// `array` + `schema` from any Arrow C Data Interface producer.
bool ingest(questdb_db* db, struct ArrowArray* array,
const struct ArrowSchema* schema, line_sender_error** err)
{
qwp_sender* sender = questdb_db_borrow_sender(db, err);
if (!sender)
return false;
/* Publish, then block until acked; qwp_sender_flush_arrow_batch_at_column
followed by qwp_sender_wait is the pipelined form. */
bool ok = qwp_sender_flush_arrow_batch_at_column_and_wait(
sender, QDB_TABLE_NAME_LITERAL("trades"), array, schema,
QDB_COLUMN_NAME_LITERAL("ts"), NULL, 0, qwpws_ack_level_ok, err);
questdb_db_return_sender(db, sender);
return ok;
}
- The designated timestamp comes from the named
Timestamp(_)column ("ts"above).flush_arrow_batch_at_now(explicit opt-in) lets the server stamp arrival time instead; don't use it when the batch carries real event time. - Ownership: on success
array->releaseis consumed (set toNULL); the caller keepsschema. On failure checkarray->release != NULLbefore invoking it. - Per-column wire-type hints (
qwp_arrow_override: force SYMBOL/VARCHAR, IPv4, char, geohash precision) steer encoding without touching the Arrow schema. - To append Arrow columns into a chunk alongside hand-built ones, use
qwp_chunk_append_arrow_column, orqwp_arrow_import_new+..._append_arrow_importto import once and slice across many chunks. - Dictionary-encoded string columns map to
SYMBOLby default; plain Utf8 toVARCHAR.qwp_sender.hlists every Arrow type the client accepts, and the kinds it rejects (Struct,Map,Interval, ...); a rejected type fails withline_sender_error_arrow_unsupported_column_kind.
Querying data
Get a reader (QWP/WebSocket only), prepare/execute SQL, then stream batches and
read typed columns. In C, qwp_reader_query_execute consumes the query handle
(sets your pointer to NULL). A borrowed reader, like the senders, is
single-thread; the pool it came from is the shared, thread-safe handle.
Borrow the reader from the pool: C++ auto r = pool.borrow_reader(); returns
a questdb::egress::reader that returns itself to the pool on scope exit; in C,
close the borrowed handle with qwp_reader_close(r) to return it. Call
qwp_reader_drop_on_return(r) / C++ r.drop_on_return() first to retire it
instead. The reader pool is capped independently of the sender pool.
If the connection fails over while a result set is streaming, the query restarts on the new endpoint and rows you already consumed are delivered again. The client never discards replayed rows silently; you choose the recovery:
-
Default (no callback installed): the next
next_batch()fails withquestdb_error_failover_would_duplicate(C++ throwsquestdb::errorwith the samecode()) instead of double-delivering. Free the cursor and re-execute the query from scratch. -
Transparent replay: install
qwp_reader_query_on_failover_reset(C) /query::on_failover_reset(C++) on the prepared query, and discard any partial state you accumulated when it fires; the replayed stream then arrives as if the query had just started. Installing this callback is what enables replay —on_failover_progressonly reports the reconnect lifecycle, so a query carrying just that one still fails withquestdb_error_failover_would_duplicate.Both callbacks run on the internal thread that drives the cursor, so they must not call back into the reader, query, or cursor.
Failover before the first batch is always transparent: no rows were consumed yet, so there is nothing to duplicate.
- C++
- C
#include <questdb/egress/qwp_reader.hpp> // questdb::pool + reader
#include <iostream>
using namespace questdb::ingress::literals;
int main()
{
try
{
questdb::pool pool{"ws::addr=localhost:9000;"};
auto reader = pool.borrow_reader(); // RAII: returns to the pool on scope exit
auto cur = reader.execute("SELECT x AS n, x * 1.5 AS d FROM long_sequence(5)"_utf8);
while (auto bo = cur.next_batch())
{
auto& batch = *bo;
for (size_t r = 0; r < batch.row_count(); ++r)
{
for (size_t c = 0; c < batch.column_count(); ++c)
{
auto col = batch.column(c);
if (col.kind() == questdb::egress::column_kind::double_)
{
auto v = col.get<double>(r); // std::optional-like
std::cout << (v ? std::to_string(*v) : "NULL") << " ";
}
else if (col.kind() == questdb::egress::column_kind::long_)
{
auto v = col.get<int64_t>(r);
std::cout << (v ? std::to_string(*v) : "NULL") << " ";
}
}
std::cout << "\n";
}
}
return 0;
}
catch (const questdb::error& e)
{
std::cerr << "error: " << e.what() << "\n";
return 1;
}
}
This example uses the questdb_error spelling throughout, including for the
pool call — see One error type, two names.
#include <questdb/egress/qwp_reader.h> // questdb_db pool + reader
#include <stdio.h>
#include <string.h>
int main(void)
{
questdb_error* err = NULL;
questdb_db* db = NULL;
qwp_reader* rd = NULL;
qwp_reader_query* query = NULL;
qwp_reader_cursor* cursor = NULL;
const char* conf = "ws::addr=localhost:9000;";
db = questdb_db_connect(conf, strlen(conf), &err);
if (!db)
{
size_t len = 0;
const char* msg = questdb_error_msg(err, &len);
fprintf(stderr, "connect error: %.*s\n", (int)len, msg);
questdb_error_free(err);
return 1;
}
rd = questdb_db_borrow_reader(db, &err); // pooled borrow
if (!rd) goto on_error;
query = qwp_reader_prepare(rd,
QDB_UTF8_LITERAL("SELECT x AS n, x * 1.5 AS d FROM long_sequence(5)"), &err);
if (!query) goto on_error;
cursor = qwp_reader_query_execute(&query, &err); // consumes `query`
if (!cursor) goto on_error;
const qwp_reader_batch* batch;
while ((batch = qwp_reader_cursor_next_batch(cursor, &err)) != NULL)
{
size_t rows = qwp_reader_batch_row_count(batch);
size_t cols = qwp_reader_batch_column_count(batch);
for (size_t c = 0; c < cols; ++c)
{
qwp_reader_column_data col;
if (!qwp_reader_batch_column_data(batch, c, &col, &err)) goto on_error;
for (size_t r = 0; r < rows; ++r)
{
bool is_null = false;
if (col.kind == qwp_reader_column_kind_double)
{
double v = qwp_reader_column_data_get_f64(&col, r, &is_null);
printf(is_null ? "NULL " : "%g ", v);
}
else if (col.kind == qwp_reader_column_kind_long)
{
int64_t v = qwp_reader_column_data_get_i64(&col, r, &is_null);
printf(is_null ? "NULL " : "%lld ", (long long)v);
}
}
}
printf("\n");
}
if (err) goto on_error; // next_batch returns NULL at end-of-stream AND on error
qwp_reader_cursor_free(cursor);
qwp_reader_close(rd); // return the borrow to the pool
questdb_db_close(db);
return 0;
on_error:;
size_t len = 0;
const char* msg = questdb_error_msg(err, &len);
fprintf(stderr, "error: %.*s\n", (int)len, msg);
questdb_error_free(err);
qwp_reader_query_free(query);
qwp_reader_cursor_free(cursor);
qwp_reader_close(rd); // pool drops it if the transport tore down
questdb_db_close(db);
return 1;
}
Read values with the static inline qwp_reader_column_data_get_* accessors; each
takes (&col, row, &is_null). Dispatch on col.kind (qwp_reader_column_kind) to
pick the right accessor.
Decimal result widths
For a DECIMAL(p, s) result, precision p selects the QWP kind:
| Result precision | C kind | C++ kind |
|---|---|---|
| 1 through 18 | qwp_reader_column_kind_decimal64 | column_kind::decimal64 |
| 19 through 38 | qwp_reader_column_kind_decimal128 | column_kind::decimal128 |
| 39 through 76 | qwp_reader_column_kind_decimal256 | column_kind::decimal256 |
QWP sends scale s separately and reports the width bucket, not the exact
precision p.
For width-independent access, use column::visit and
decimal_view::mantissa_bytes(row) in C++, or
qwp_reader_column_data_get_bytes with value_stride in C. Both expose the
mantissa as little-endian two's-complement bytes. Check for null before
decoding it.
Parameterised queries
Prepare then bind: C qwp_reader_prepare + qwp_reader_query_bind_* +
qwp_reader_query_execute; C++ reader.prepare(sql) chained with bind_*, then
execute(). Plain execute(sql) is the no-bind shortcut. The reader must
outlive any cursor it produces. The complete bind surface (C
qwp_reader_query_bind_<name>, C++ bind_<name> on the prepared query):
| Bind | Input | QuestDB type |
|---|---|---|
bind_bool | bool | BOOLEAN |
bind_i8 / bind_i16 / bind_i32 / bind_i64 | signed int | BYTE / SHORT / INT / LONG |
bind_f32 / bind_f64 | float / double | FLOAT / DOUBLE |
bind_timestamp_micros / bind_timestamp_nanos | int64 since epoch | TIMESTAMP / TIMESTAMP_NS |
bind_date_millis | int64 millis since epoch | DATE |
bind_char | UTF-16 code unit | CHAR |
bind_decimal64 / bind_decimal128 / bind_decimal256 | unscaled value + scale | DECIMAL |
bind_geohash | bits + precision | GEOHASH |
bind_varchar | UTF-8 string | VARCHAR |
bind_uuid | 16 bytes | UUID |
bind_long256 | 32 bytes | LONG256 |
bind_binary | bytes + length | BINARY (not yet accepted server-side) |
bind_ipv4 | uint32, host order | IPV4 (not yet accepted server-side) |
To bind SQL NULL, use the typed NULL binds: bind_null(kind), plus the
parameterised forms bind_null_varchar, bind_null_binary,
bind_null_decimal64 / _128 / _256 (scale) and bind_null_geohash
(precision). Ingestion writes nulls by omission and has no set_null;
querying does have NULL binds. There are no array binds: double[] /
long[] appear only as result columns. bind_binary and bind_ipv4 (and
their NULL forms) compile but fail at execute with
questdb_error_invalid_bind on current servers.
Arrow result batches
Instead of reading cell-by-cell, export each result batch as an
Arrow C Data Interface
ArrowArray + ArrowSchema pair. This is the efficient path for large result
sets, and a zero-copy handoff to polars, pandas, or DuckDB. It uses the same
interface as Arrow ingestion and the same build flag
(QUESTDB_ENABLE_ARROW).
- C++
- C
// Requires a build with QUESTDB_CLIENT_ENABLE_ARROW.
auto cur = reader.execute("SELECT symbol, price FROM trades"_utf8);
while (auto batch = cur.next_arrow_batch()) // std::nullopt at end of stream
{
// batch->array / batch->schema are an owned Arrow pair, independent of the
// cursor. Hand them to a consumer; ImportRecordBatch zeroes the slots.
auto rb = arrow::ImportRecordBatch(&batch->array, &batch->schema).ValueOrDie();
// ... use rb ...
}
/* Requires a build with QUESTDB_CLIENT_ENABLE_ARROW. */
struct ArrowArray array;
struct ArrowSchema schema;
for (;;)
{
// out slots must be uninitialised (zeroed or already-released) on each call
qwp_reader_arrow_batch_result rc =
qwp_reader_cursor_next_arrow_batch(cursor, &array, &schema, &err);
if (rc == qwp_reader_arrow_batch_end) break;
if (rc == qwp_reader_arrow_batch_error) goto on_error;
/* array + schema are now an owned Arrow pair: pass to a consumer, then
release whatever it did not take. */
if (array.release) array.release(&array);
if (schema.release) schema.release(&schema);
}
- Ownership. On
_okthe caller owns the batch'sreleasecallbacks. The C++arrow_batchfrees them in its destructor unless you hand it off; passing it to an Arrow consumer such asarrow::ImportRecordBatchconsumes and zeroes the slots. Each C call needs fresh out slots (zeroed, or whose previousreleasealready ran), otherwise it leaks the prior batch. - Compact symbols (C).
qwp_reader_cursor_next_arrow_batch_compactemits eachSYMBOLcolumn with only the dictionary values that batch references, under batch-local codes: smaller batches when symbols are sparse. - Schema drift. If the table's schema changes mid-stream the call returns
questdb_error_schema_drift. Call again: the cursor picks up the new schema and re-delivers the batch that triggered the error under it, so no rows are dropped.
Durability and backpressure
QWP/WebSocket ingestion is asynchronous: every flush publishes into a local queue and returns before the server acks.
flush= local acceptance. Success means only that the frame is queued locally. A background thread does all the talking to QuestDB: it delivers queued frames, receives acks, and reconnects and replays after a connection failure. It keeps running after you return the sender to the pool, so queued frames still reach the server.wait= observation.qwp_sender_wait(C++wait()) blocks until everything published so far is acknowledged.- Ack levels.
qwpws_ack_level_okmeans the server accepted the frames.qwpws_ack_level_durableadditionally waits until they are uploaded to object storage, not just in the server's WAL (Enterprise with replication; see the protocol page's durable acknowledgement section). Durable acks must be requested at pool open withrequest_durable_ack=on; the connect fails withprotocol_version_errorwhen the server cannot provide them, and without the key any durable-levelwaitorflush_and_waitfails up front withinvalid_api_call, leaving the buffer or chunk untouched. - Ack is not visibility. Rows become visible to queries after WAL apply, typically within milliseconds of the ack, so a query issued right after the ack can miss the newest rows. An empty read-back is not data loss.
- The timeout is a no-progress deadline. It fires only if the server
stops acknowledging frames, not as a cap on total wait time. On timeout
the frames stay queued: call
waitagain, or poll progress without blocking with frame sequence numbers. Don't re-flush.waittakes its deadline per call (0= none);flush_and_waituses the pool-widerequest_timeout(default 30000 ms).
- Ack levels.
in_doubtdecides whether to retry. When a flush or wait fails, checkline_sender_error_in_doubt(C++:e.in_doubt()) rather than the error code or the state of your buffer or chunk. A delivery-unknown failure typically reportsfailover_retry, and that code alone does not mean the input is safe to resend; likewise an emptied buffer does not mean the rows arrived, and a full one does not mean they didn't.in_doubt == falsemeans the flush failed before the queue took the frame: the rows never entered the send path, so nothing can reach the server and the buffer or chunk is intact — re-flush it. True means the queue may already hold them: wait rather than re-flush, and replay the same rows only if the table's dedup keys make duplicates harmless.- A buffer is one frame, queued whole or not at all. So an in-doubt buffer failure can only be a failed ack wait, with every row already queued. Wait again and the queue delivers them.
- A chunk may be split into several frames (see Sending data: chunks). A failure partway through queues the earlier frames and never accepts the rest, so waiting delivers only part of the batch. Getting the remainder means resending the chunk, which is safe only where dedup keys absorb the duplicated rows.
sf_dir= crash survival. Without it the queue is in memory: a process crash loses unacked frames, and pool close drains best-effort withinclose_flush_timeout_millis(default 5000). Withsf_dir, each sender's queue is a directory on disk, and frames replay automatically when a pool restarts with the samesender_id: it finds the directories the previous run left behind, and resends their unacknowledged frames in the background without you borrowing anything. Keepsender_idstable across restarts so replay finds them; see Pool keys for the directory layout. Strongly recommended for multi-host deployments: during failover, flushes keep landing on disk instead of filling RAM.- Backpressure is bounded blocking. Two caps apply at once: 128 in-flight
unacked frames, and
sf_max_total_bytes(default 128 MiB memory mode, 10 GiB disk mode). On hitting either,flushblocks up tosf_append_deadline_millis(default 30000), then returnsserver_flush_error. Nothing is dropped or overwritten while blocked. A buffer whose single payload exceeds the frame cap derived fromsf_max_segment_bytes(default 4 MiB) is rejected immediately instead; an oversize chunk is split across frames rather than rejected.
Concurrency: one borrow per worker
The pool is the only thread-safe handle. Senders and readers belong to the
thread that borrowed them. Chunks and buffers are single-thread too: only one
thread may touch one at a time. The recommended pattern: share the pool, give
each worker its own borrow for its lifetime, and size sender_pool_max to the
worker count.
- C++
- C
#include <questdb/ingress/qwp_sender.hpp>
#include <thread>
#include <vector>
#include <iostream>
using namespace questdb::ingress::literals;
int main()
{
// sender_pool_max bounds concurrent sender borrows: size it to the worker
// count.
questdb::pool pool{"ws::addr=localhost:9000;sender_pool_max=4;"};
std::vector<std::thread> workers;
for (int w = 0; w < 4; ++w)
{
workers.emplace_back([&pool, w]() {
try
{
// One borrow per worker; lives for the worker's lifetime.
auto sender = pool.borrow_sender();
auto buffer = sender.new_buffer();
for (int i = 0; i < 100; ++i)
{
buffer.table("trades"_tn)
.symbol("symbol"_cn, "ETH-USDT"_utf8)
.column("price"_cn, 2615.54 + w)
.at(questdb::ingress::timestamp_nanos::now());
}
sender.flush(buffer);
sender.wait(); // block until acked, before the borrow returns
}
catch (const questdb::error& e)
{
std::cerr << "worker " << w << ": " << e.what() << "\n";
}
});
}
for (auto& t : workers) t.join();
return 0;
}
#include <questdb/ingress/qwp_sender.h> // questdb_db_borrow_sender + line_sender_buffer
#include <pthread.h>
#include <stdio.h>
#include <string.h>
static void* worker(void* arg)
{
questdb_db* db = (questdb_db*)arg;
line_sender_error* err = NULL;
qwp_sender* sender = NULL;
line_sender_buffer* buffer = NULL;
/* One borrow per worker; lives for the worker's lifetime. */
sender = questdb_db_borrow_sender(db, &err);
if (!sender) goto on_error;
buffer = questdb_db_new_buffer(db, &err);
if (!buffer) goto on_error;
for (int i = 0; i < 100; ++i)
{
if (!line_sender_buffer_table(buffer, QDB_TABLE_NAME_LITERAL("trades"), &err)) goto on_error;
if (!line_sender_buffer_symbol(buffer, QDB_COLUMN_NAME_LITERAL("symbol"),
QDB_UTF8_LITERAL("ETH-USDT"), &err)) goto on_error;
if (!line_sender_buffer_column_f64(buffer, QDB_COLUMN_NAME_LITERAL("price"), 2615.54, &err)) goto on_error;
if (!line_sender_buffer_at_nanos(buffer, line_sender_now_nanos(), &err)) goto on_error;
}
if (!qwp_sender_flush_buffer(sender, buffer, &err)) goto on_error;
if (!qwp_sender_wait(sender, qwpws_ack_level_ok, 0, &err)) goto on_error; /* block until acked; 0 = no deadline */
line_sender_buffer_free(buffer);
questdb_db_return_sender(db, sender);
return NULL;
on_error:;
size_t len = 0;
const char* msg = line_sender_error_msg(err, &len);
fprintf(stderr, "worker: %.*s\n", (int)len, msg);
line_sender_error_free(err);
line_sender_buffer_free(buffer);
if (sender) questdb_db_drop_sender(db, sender);
return NULL;
}
int main(void)
{
line_sender_error* err = NULL;
/* sender_pool_max bounds concurrent sender borrows: size it to the worker
count. */
const char* conf = "ws::addr=localhost:9000;sender_pool_max=4;";
questdb_db* db = questdb_db_connect(conf, strlen(conf), &err);
if (!db)
{
size_t len = 0;
const char* msg = line_sender_error_msg(err, &len);
fprintf(stderr, "connect: %.*s\n", (int)len, msg);
line_sender_error_free(err);
return 1;
}
pthread_t workers[4];
for (int w = 0; w < 4; ++w)
pthread_create(&workers[w], NULL, worker, db);
for (int w = 0; w < 4; ++w)
pthread_join(workers[w], NULL);
questdb_db_close(db);
return 0;
}
Notes:
- Do not pass a borrowed sender between threads mid-borrow, even with your own locking. Return it and borrow again on the other thread.
- Chunks and buffers are also single-thread, but you may build a chunk on one thread and flush it through a sender borrowed on another, as long as the handoff is ordered (e.g. via a queue) and only one thread touches it at a time.
- Each borrowed sender owns its own store-and-forward queue (its own directory
when
sf_diris set), so this pattern applies unchanged in store-and-forward mode.
Failover, retry, and pool lifecycle
The pool owns reconnection and connection health so borrowers don't have to:
- Multiple endpoints. Comma-separate hosts in one
addr=(or repeat the key):wss::addr=db-primary:9000,db-replica-1:9000;. The endpoints must be replicas of one logical deployment (Enterprise replication); listing unrelated instances splits your data across them. The pool rotates away from unhealthy endpoints on borrow and follows the writable primary on a role reject.target=filters by role (e.g.target=primary); when every reachable endpoint handshakes but none matches the filter, the borrow fails withrole_mismatchrather than a connect error. - Retrying borrow.
questdb_db_borrow_sender_with_retry(db, budget_ms, &err)(C++pool::borrow_sender_with_retry(budget_ms)) retries the connect withinbudget_msusing the pool's reconnect backoff. Authentication and protocol-version errors are terminal;budget_ms == 0makes a single attempt. - Failover budget.
questdb_db_reconnect_max_duration_ms(db)(C++pool::reconnect_max_duration_ms(), default 300000 ms) is the pool's overall reconnect budget. Pass the remaining budget to the_with_retrycalls when tracking a deadline. - Return vs. drop. Returning a healthy borrow recycles its connection;
dropping retires it and the next borrow opens a fresh one. The return path
drops any connection that has recorded a terminal error or whose pool has been
closed, so plain return/RAII destruction is the default. Use
questdb_db_drop_sender/drop_on_return()only after an error where the next borrower must not inherit the connection. - Closing idle connections. With
pool_reap=autoa background thread closes idle connections above the warm minimum afteridle_timeout_ms. Withpool_reap=manual, callquestdb_db_reap_idle(db)(C++pool::reap_idle()) yourself; it returns the number of connections it closed.
Which errors mean what
Dispatch on line_sender_error_get_code(err) (C++
e.code()). What to do with the borrow after each class:
| Error code | Meaning | Borrow state | What to do |
|---|---|---|---|
auth_error, tls_error, unsupported_server, protocol_version_error | Deployment/config problem | n/a (borrow failed) | Fix the connect string or server; retrying won't help. The retry helpers treat these as terminal. |
failover_retry | Transient transport failure; frames may be in doubt | Dead — every later call fails | Drop the borrow, then re-borrow with borrow_sender_with_retry(reconnect_max_duration_ms()). With sf_dir, unresolved frames replay automatically. |
server_rejection | Server refused the data (schema/type conflict, bad name) | Dead — every later call fails | Plain return is safe; the pool retires the connection. Fix the data before re-sending; blind retry re-fails. |
server_flush_error | Backpressure deadline hit: queue full for sf_append_deadline_millis | Usable | Retry later, shed load, or raise the deadline. Nothing was dropped. See backpressure. |
invalid_api_call | Borrow still at the pool cap after acquire_timeout_ms, pool closed, an operation on a borrow after close, or a durable-level wait without request_durable_ack=on | n/a | At-cap: treat as backpressure (see Sizing the pool). Closed pool: stop borrowing. |
If you are unsure which case you hit, return is always safe: the pool inspects the connection and retires it if unhealthy, so a broken connection never reaches the next borrower. Drop is the conservative choice when the next borrower must not inherit the connection.
Pool keys
The connect string accepts pool-tuning keys; the defaults suit most callers.
The ingestion and query pools size independently, so each has its own pair of keys.
| Key | Default | Meaning |
|---|---|---|
sender_pool_min | 1 | Warm/minimum ingestion connections. Once connections have been opened, this many stay open however long they sit idle. |
sender_pool_max | 4 | Hard cap on ingestion auto-grow. |
query_pool_min | 1 | Warm/minimum query connections. |
query_pool_max | 4 | Hard cap on query auto-grow. |
acquire_timeout_ms | 5000 | How long a borrow waits for a returned connection once its pool is at its cap, before returning an error. 0 fails fast. |
idle_timeout_ms | 60000 | Idle connections above the warm minimum are closed after this long. |
pool_reap | auto | auto closes idle connections on a background thread; manual leaves it to your questdb_db_reap_idle / pool::reap_idle calls. |
When to change them: raise sender_pool_min / query_pool_min when workers
borrow intermittently and a cold borrow would pay reconnect and auth latency;
that many connections stay warm. idle_timeout_ms trades idle sockets against
that same reconnect cost. Pick pool_reap=manual to control the shrink cadence
yourself and avoid the background thread; call reap_idle on your own schedule.
Every borrowed sender writes through a local store-and-forward queue.
Without sf_dir the queue is in memory and private to the borrow; setting
sf_dir moves it to a directory on disk that survives process restarts. The
pool gives each borrow its own directory, named
<sf_dir>/<sender_id>-ingest-<index>/, where index is the lowest free number
in [0, sender_pool_max). Because those names are stable, a restarted pool
re-adopts the directories and replays the unacked frames they hold.
sender_id (default default) is the name prefix for those directories,
not a directory itself. Keep it stable across restarts so replay finds them,
and give each pool sharing an sf_dir its own prefix, since a pool assumes
every <sender_id>-ingest-* directory is its own. If two processes (or two
pools) do share a prefix, the second one fails immediately with an in-use error
naming the pid that holds the directory. A bare <sf_dir>/<sender_id>/
directory, with no -ingest-<index> suffix, is not pool-managed: the pool
treats it as an orphan and drains it only with drain_orphans=on. The
sf_max_segment_bytes, sf_max_total_bytes, and sf_append_deadline_millis keys
tune the queue in both memory and disk mode; sender_id has no effect
without sf_dir. Full sf_* key semantics are in the
connect string reference.
Sizing the pool
A borrow at the cap waits for a return before it fails. When
sender_pool_max sender handles are already out, questdb_db_borrow_sender
waits up to acquire_timeout_ms for another thread to return one; if none does,
it returns NULL with line_sender_error_invalid_api_call (C++ throws). Set
acquire_timeout_ms=0 to fail fast instead. In disk-backed store-and-forward
mode, an at-cap borrow may also wait up to close_flush_timeout_millis for a
sender that is currently closing to release its directory. Two consequences:
- Size
sender_pool_maxto your worker count. The natural pattern is one borrow per worker thread held for the worker's lifetime (see Concurrency); then the cap is never hit. - If borrows are short-lived and demand can spike past the cap, treat the
at-cap error as backpressure: return a borrow elsewhere, or retry after a
delay in your own loop.
borrow_sender_with_retrywill not help here — it retries connection establishment, and at the cap the connection side is working fine.
The sender and reader pools are capped independently, at sender_pool_max and
query_pool_max, so heavy ingestion cannot starve queries; budget each active
path up to its own cap in live connections.
FSN progress (non-blocking)
Every QWP/WebSocket flush is asynchronous: it publishes into the local queue and
returns before the server ACKs. Beyond the blocking wait barrier, the sender
exposes frame sequence numbers (FSNs) for non-blocking progress tracking
while the borrow is still held:
- Publish with an FSN-returning flush: C
qwp_sender_flush_buffer_and_get_fsnorqwp_sender_flush_chunk_and_get_fsn; C++flush_and_get_fsn()(overloaded for buffers and chunks, returningstd::optional<uint64_t>). - Keep doing work, then compare the saved FSN against how far the server has
acknowledged: C
qwp_sender_acked_fsn; C++acked_fsn(). The server has acknowledged everything you published up to that flush onceacked_fsnreturns a value>=your saved FSN. - A successful flush of a non-empty buffer or chunk always yields an FSN. No
value (C:
has_value == falsein theline_sender_qwpws_fsnout-param, C++:std::nullopt) means the buffer or chunk was empty, so there was no frame to publish. Likewiseacked_fsnhas no value until the first frame on this borrow completes: keep polling, don't re-flush.
An FSN is a watermark on one borrowed sender's own stream, meaningful only while you hold that borrow. A later borrow from the same pool gets a fresh stream, and a number you saved from an earlier one means nothing against it.
Closing
Orderly shutdown is: wait, return, close.
- If you need delivery confirmation, call
waiton each sender first (or check FSN watermarks); pool close only drains best-effort withinclose_flush_timeout_millis(default 5000 ms;0disables the drain; see the connect string reference) on an in-memory queue. Withsf_dir, unacked frames survive on disk regardless and replay on the next run. - Return or drop every borrow. In C++, scope exit does this.
questdb_db_close(db)(C++:pooldestructor). AcceptsNULL. Unlike every other pool call, this one is not safe to run concurrently: no other thread may be borrowing, returning, or reaping on the same handle while it runs.
Outstanding borrows survive the pool's close: returning or dropping them
afterwards is safe (they close instead of recycling), but new operations on
them fail with invalid_api_call. Return and drop remain mutually
exclusive per handle (exactly one, exactly once); a second release call on
the same handle is undefined behavior.
You never have to inspect a connection's health yourself. The return path detects one that has failed permanently and retires it, so the only choice is between plain return and drop.
Production shape: TLS, token, multi-host, retry
A production-shaped configuration: TLS with OS roots, bearer token, two endpoints, a store-and-forward spool, durable acks, and a retry-bounded borrow:
- C++
- C
#include <questdb/ingress/qwp_sender.hpp>
#include <iostream>
using namespace questdb::ingress::literals;
int main()
{
try
{
questdb::pool pool{
"wss::addr=db-primary.example.com:9000,db-replica.example.com:9000;"
"token=YOUR_BEARER_TOKEN;"
"tls_ca=os_roots;" // trust the OS store (corporate CAs)
"sf_dir=/var/spool/questdb;"
"sender_id=ingest-01;"
// Enterprise; the connect fails against a server without replication.
"request_durable_ack=on;"};
// First borrow opens the connection: give it the failover budget.
auto sender =
pool.borrow_sender_with_retry(pool.reconnect_max_duration_ms());
auto buffer = sender.new_buffer();
buffer.table("trades"_tn)
.symbol("symbol"_cn, "ETH-USDT"_utf8)
.column("price"_cn, 2615.54)
.at(questdb::ingress::timestamp_nanos::now());
sender.flush(buffer); // lands in the sf_dir spool
// Durable barrier; needs request_durable_ack=on above.
sender.wait(questdb::ingress::qwpws_ack_level::durable);
return 0;
}
catch (const questdb::error& e)
{
std::cerr << "error: " << e.what() << "\n";
return 1;
}
}
#include <questdb/ingress/qwp_sender.h> // questdb_db_borrow_sender + line_sender_buffer
#include <stdio.h>
#include <string.h>
int main(void)
{
line_sender_error* err = NULL;
questdb_db* db = NULL;
qwp_sender* sender = NULL;
line_sender_buffer* buffer = NULL;
const char* conf =
"wss::addr=db-primary.example.com:9000,db-replica.example.com:9000;"
"token=YOUR_BEARER_TOKEN;"
"tls_ca=os_roots;" /* trust the OS store (corporate CAs) */
"sf_dir=/var/spool/questdb;"
"sender_id=ingest-01;"
/* Enterprise; the connect fails against a server without replication. */
"request_durable_ack=on;";
db = questdb_db_connect(conf, strlen(conf), &err);
if (!db) goto on_error;
/* First borrow opens the connection: give it the failover budget. */
sender = questdb_db_borrow_sender_with_retry(
db, questdb_db_reconnect_max_duration_ms(db), &err);
if (!sender) goto on_error;
buffer = questdb_db_new_buffer(db, &err);
if (!buffer) goto on_error;
if (!line_sender_buffer_table(buffer, QDB_TABLE_NAME_LITERAL("trades"), &err)) goto on_error;
if (!line_sender_buffer_symbol(buffer, QDB_COLUMN_NAME_LITERAL("symbol"),
QDB_UTF8_LITERAL("ETH-USDT"), &err)) goto on_error;
if (!line_sender_buffer_column_f64(buffer, QDB_COLUMN_NAME_LITERAL("price"), 2615.54, &err)) goto on_error;
if (!line_sender_buffer_at_nanos(buffer, line_sender_now_nanos(), &err)) goto on_error;
/* flush lands in the sf_dir spool; the durable barrier needs the
request_durable_ack=on key above. */
if (!qwp_sender_flush_buffer(sender, buffer, &err)) goto on_error;
if (!qwp_sender_wait(sender, qwpws_ack_level_durable, 0, &err)) goto on_error;
line_sender_buffer_free(buffer);
questdb_db_return_sender(db, sender);
questdb_db_close(db);
return 0;
on_error:;
size_t len = 0;
const char* msg = line_sender_error_msg(err, &len);
fprintf(stderr, "error: %.*s\n", (int)len, msg);
line_sender_error_free(err);
line_sender_buffer_free(buffer);
if (sender) questdb_db_drop_sender(db, sender);
questdb_db_close(db);
return 1;
}
API at a glance
The pooled surface in both languages:
| Concern | C | C++ |
|---|---|---|
| Connection pool | questdb_db* | questdb::pool |
| Borrow a sender | questdb_db_borrow_sender → qwp_sender* | pool::borrow_sender() → borrowed_sender |
| Borrow a reader | questdb_db_borrow_reader → qwp_reader* | pool::borrow_reader() → reader |
| Row buffer | line_sender_buffer* | questdb::ingress::line_sender_buffer |
| Column chunk | qwp_chunk* | questdb::ingress::column_chunk |
| Buffer flush | qwp_sender_flush_buffer | borrowed_sender::flush(line_sender_buffer&) |
| Chunk flush | qwp_sender_flush_chunk | borrowed_sender::flush(column_chunk&) |
| Arrow batch flush | qwp_sender_flush_arrow_batch_at_column | borrowed_sender::flush_arrow_batch() |
| Flush, then block until acked | qwp_sender_flush_buffer_and_wait / qwp_sender_flush_chunk_and_wait | borrowed_sender::flush_and_wait() overloads |
| Streaming result | qwp_reader_cursor* → qwp_reader_batch* | cursor → batch → column |
Conventions and lifecycle
- Error handling. C has one error type under two names: reader declarations
spell it
questdb_error, ingress declarationsline_sender_error, and either name works anywhere (see One error type, two names below). Fallible calls returnboolor a handle (NULLon failure); read and free errors withquestdb_error_msg/questdb_error_free, or the identicalline_sender_error_msg/line_sender_error_free. In C++, catchquestdb::erroracross pool, ingestion, and query operations. - Ownership. C handles are created by
*_connect/*_new/*_from_conf/questdb_db_borrow_*and released with*_close/*_free/questdb_db_return_*/questdb_db_drop_*. Return and drop are mutually exclusive on the same handle: call exactly one, exactly once. The C++ wrappers (pool,borrowed_sender,column_chunk,reader,cursor) are RAII and move-only. - Concurrency. The pool is shared across threads; a borrowed handle belongs to one thread at a time. See Concurrency: one borrow per worker.
One error type, two names (C)
The C library also ships the legacy Line Sender API for ILP ingestion, whose
types all carry a line_sender_ prefix. The QWP API shares some of them, the
error object among them: it reports failures through the same
line_sender_error rather than declaring an error type of its own. To keep the
naming neutral across ingest and query, the headers alias it:
typedef line_sender_error questdb_error;
typedef line_sender_error_code questdb_error_code;
Hence the two spellings in C code on this page. They are the same type: the
error codes are aliased too (questdb_error_auth_error is
line_sender_error_auth_error), and the accessors come in both spellings with
identical behavior (questdb_error_msg / line_sender_error_msg). Either name
compiles against any error this page returns, so pick one per program and stay
with it. The examples use whichever spelling matches the header in view.
Full API reference
The installed headers are the complete reference:
| Header | Covers |
|---|---|
questdb/client.h | The pool, its lifecycle, the connect-string keys, connection events, and pool diagnostics |
questdb/ingress/qwp_sender.h | Sender borrowing, buffers, chunks, and ingestion |
questdb/ingress/line_sender.h | Errors, table and column names, UTF-8 helpers |
questdb/egress/qwp_reader.h | Reader borrowing, queries, cursors, and Arrow result batches |
Each has a .hpp counterpart wrapping the same surface in RAII types that
throw questdb::error instead of returning false.
Next steps
- Connect string reference
- QWP protocol
- Rust pooled API (the same pool, in Rust)