Rust SDK

Query ScopeDB and write events from async Rust applications. This guide uses scopedb-client 0.4.2.

Install and configure

Use Rust 1.91.0 or later. In your application's Cargo project, install the SDK, JSON support, and Tokio:

cargo add scopedb-client@0.4.2 serde_json
cargo add tokio --features macros,rt-multi-thread

The examples run inside Tokio's async runtime. Follow the application connection guide to obtain an endpoint and API key, then set them in the shell where you run the examples:

export SCOPEDB_ENDPOINT="https://<endpoint>"
export SCOPEDB_API_KEY="<api-key>"

Keep the key on your server. SDKs do not reuse the CLI's interactive login.

Run a query

Save this complete program as src/main.rs and run cargo run. It verifies the connection without creating a table or requiring sample data.

use std::error::Error;

use scopedb_client::Client;

#[tokio::main(flavor = "current_thread")]
async fn main() -> Result<(), Box<dyn Error>> {
    let endpoint = std::env::var("SCOPEDB_ENDPOINT")?;
    let api_key = std::env::var("SCOPEDB_API_KEY")?;
    let client = Client::builder(endpoint)
        .api_key(api_key)
        .build()?;

    let result = client.query("SELECT 1 AS ready").await?;
    println!("ready: {}", result.into_objects()?[0]["ready"]);
    Ok(())
}

Expected output:

ready: 1

query() waits for execution to finish; into_objects() returns rows keyed by column name. Reuse the client, and add the following examples inside main before Ok(()).

Write rows

Create the events table used by the quickstart. This statement leaves an existing table unchanged; if you already have events, check that its columns match before continuing.

client.query("
    CREATE TABLE IF NOT EXISTS events (
        time timestamp,
        service string,
        name string,
        message string,
        var object
    )
").await?;

Append one event and wait for it to commit:

let table = client.table("events");
let row = serde_json::json!({
    "time": "2026-10-23T09:00:00Z",
    "service": "checkout",
    "name": "payment_failed",
    "message": "Credit card gateway timed out",
    "var": {"region": "apac", "gateway": "credit_card"},
});
let stream = table.append_stream().build()?;
stream.send(&row).await?;
let report = stream.shutdown().await?;
println!("Written rows: {}", report.committed_rows);

send() accepts a row into the SDK's buffer. shutdown() sends remaining rows, waits for delivery, and closes the stream. With this default failure policy, a failed batch returns an error. A successful run prints Written rows: 1.

Read the event back after shutdown:

let written = client.query("
    FROM events
    WHERE time = '2026-10-23T09:00:00Z'::timestamp
      AND service = 'checkout'
      AND name = 'payment_failed'
    SELECT service, name, var['gateway']::string AS gateway
    LIMIT 10
").await?;
println!("{:?}", written.to_objects()?);

The result includes checkout, payment_failed, and credit_card. Each run appends another event. The examples use the default scopedb database and public schema; use with_database(...) or with_schema(...) on the table for another table location.

Work with results

Use to_objects() for named columns or to_values() for positional rows. An empty result produces an empty vector. first() returns an optional row:

if let Some(first) = written.first()? {
    println!("{}", first["name"]);
}

Object conversion rejects duplicate column names; use unique aliases or positional results. The consuming into_objects() and into_values() methods move the result instead of borrowing it.

Cells use the SDK's Value enum, including signed and unsigned 64-bit integers, Jiff timestamps and intervals, and Value::Null. Arrays, objects, and any values retain their textual representation; match the variant before parsing. raw_rows() exposes the original result text.

Manage table definitions

Read the table's complete definition and ETag:

let catalog = table.catalog();
let snapshot = catalog.get().await?;
println!("{:?}", snapshot.definition.columns);
println!("{}", snapshot.etag);

To change its comment, preserve the definition and preview the replacement:

use scopedb_client::CatalogOptions;

let mut definition = snapshot.definition;
definition.comment = Some("Checkout events".to_string());
let preview = catalog.apply(&definition, CatalogOptions {
    dry_run: true,
    if_match: Some(snapshot.etag),
    ..Default::default()
}).await?;
println!("{:?}", preview);

Review before, after, and destructive. Apply the same definition with the preview's ETag:

let applied = catalog.apply(&definition, CatalogOptions {
    if_match: Some(preview.etag),
    ..Default::default()
}).await?;
if let Some(after) = applied.after {
    println!("{}", after.comment.unwrap_or_default());
}

apply() replaces the complete definition; omitted fields are cleared. Typed reads reject unknown fields; use raw() to retain them during exports or edits. An ETag conflict requires a fresh read and preview. Potentially destructive changes also require allow_destructive: true; this comment edit does not. See the Catalog contract for resource selection and interrupted writes.

Configure queries and writes

Query execution

Use a statement builder to set execution limits or retain a handle for progress and cancellation:

use std::time::Duration;

let mut handle = client.statement("SELECT 1 AS ready")
    .with_max_elapsed(Duration::from_secs(60))
    .submit().await?;
println!("{:?}", handle.snapshot());
let query_result = handle.wait().await?;
println!("{:?}", query_result.into_objects()?);

snapshot() borrows cached state; refresh().await fetches an active statement's state. client.statement_handle(id) resumes tracking a known ID. with_max_elapsed includes queueing; with_max_estimated separately limits estimated duration.

Dropping or timing out a query future does not request server-side cancellation. Call handle.cancel().await and inspect its returned snapshot: execution may have finished already or may still be active. Confirmed cancellation makes wait() return ErrorKind::StatementCancelled.

Batching and delivery

Use flush() to wait for buffered writes while keeping a stream open. Streams target 4 MiB batches and allow four concurrent requests by default. Each SDK request is capped at 8 MiB and 200,000 rows.

For a long-running producer that must continue after a failed batch:

use scopedb_client::AppendFailurePolicy;

let telemetry = table.append_stream()
    .failure_policy(AppendFailurePolicy::Continue)
    .max_concurrent_batches(1)
    .build()?;
if let Err(error) = telemetry.try_send(&row) {
    eprintln!("Not queued: {error}");
}

// Stop producers before shutting down the stream.
let delivery = telemetry.shutdown().await?;
println!("{:?}", delivery);

try_send() returns an error when a row cannot enter the local buffer. Continue mode reports failures without retaining failed payloads for replay; use durable storage when loss is unacceptable. Inspect outcome, failed_rows, and unknown_rows as well as committed rows. See AppendStreamBuilder for buffering, circuit breaking, and retry controls.

Streams retry confirmed temporary rejections by default. retry_unknown(true) explicitly allows replay of uncertain writes and can duplicate rows. max_retries(0) disables SDK retries; client timeouts are never replayed.

Row encoding

Streams accept serializable structs or serde_json::Value objects through Serde. For existing NDJSON, use Table::append, which makes one attempt. Shape values in the producer; Append does not run a ScopeQL transformation. See the shared JSON input types for accepted field values and schema rules.

Timeouts

Queries and Catalog reads have no SDK deadline by default. Bound local waiting in the application and use the statement's execution limit to bound server work.

Append attempts and Catalog mutations default to 30 seconds, including response-body reads. Configure ClientBuilder::write_timeout or a stream's attempt_timeout with std::time::Duration. Rust requires a finite, positive duration; this attempt deadline cannot be disabled. See ClientBuilder when supplying an HTTP client with its own timeouts.

Handle errors

Inspect the SDK error's stable code and diagnostics before returning it:

if let Err(error) = client.query("SELECT 1 AS ready").await {
    eprintln!("{:?} {:?} {:?}", error.code(), error.http_status(), error.request_id());
    eprintln!("{:?} {:?}", error.statement_details(), error.write_details());
    return Err(error.into());
}

For an Append failure, inspect append_details() and its status and row diagnostics. write_details() identifies a timeout after sending, with an unknown outcome. Reconcile an uncertain write before replaying it; is_temporary() alone does not establish that replay is safe.

Catalog writes never retry automatically. catalog_commit_outcome_unknown requires reading the current definition before another mutation. If a query fails after submission, use error.statement_id() with client.statement_handle(id) to resume tracking instead of resubmitting it. Follow the HTTP error and retry contract for operation-specific decisions.