VAST DB Python SDK 2.0.14.1 and VAST 5.4.x

Prev Next

This guide is for anyone writing Python against VAST DB with the vastdb package. It covers the settings that make the biggest difference to query speed and the mistakes we see most often.

This is based on SDK 2.0.14.1 (Aug 2026)

Summary

If you only read one section, read this one. Each point links to the section that explains it.

  • Connect to the data VIP pool's DNS name - Not VMS addess, not a single VIP. Everything the SDK sends goes to this endpoint unless you add data_endpoints. (Section 1, Connecting)
  • Always set a timeout - The default is none, so a CNode that stops responding hangs your query forever and the retry logic never kicks in. Setting a timeout=(5, 300) is a reasonable start. (Section 1, Timeouts; retry behavior in Section 1, Retries)
  • Fan out large scans with QueryConfig - Pass data_endpoints=[pool_name] * N and num_splits=N. You get min(len(data_endpoints), num_splits) worker threads, so set both. If you leave num_splits unset, a table under 8 million rows gets one worker no matter how many endpoints you list. (Section 2, Fanning a query out across CNodes go see "How many workers you actually get" and "Repeating the pool name vs. listing VIPs")
  • Don't fan out small or LIMIT queries - Every worker costs a connection and a request, and limit_rows is applied per stream, so a fanned-out LIMIT 10000 can fetch and throw away many times that. One endpoint is usually faster for these types of queries. (Section 3, "Don't fan out LIMIT queries")
  • Make the CNode do the work - Pass only the columns you need, put filters in select(predicate=...) so they run server-side, and iterate the returned reader batch by batch instead of calling read_all(). (Section 3, "Filter and pick columns on the server" and "Read in batches")
  • In repeated jobs, load table metadata once with TableMetadata and keep one session for the life of the job. Per-open metadata round trips add up. (Section 4, Repeated jobs)

1. Connecting

Point the SDK at the DNS name of the VIP pool that serves Database/S3 traffic. VAST serves S3 on port 80 (HTTP) and 443 (HTTPS), so you normally don't need to specify a port.

import vastdb
from vastdb.config import BackoffConfig

session = vastdb.connect(
    endpoint="https://db-pool.example.com",
    access=ACCESS_KEY,
    secret=SECRET_KEY,
    timeout=(5, 300),  # seconds: 5 to open the connection, 300 between bytes
    backoff_config=BackoffConfig(max_tries=3, max_time=60),  # up to 3 attempts within 60 s
)

If you leave endpoint, access, or secret out, the SDK reads AWS_S3_ENDPOINT_URL, AWS_ACCESS_KEY_ID, and AWS_SECRET_ACCESS_KEY from env variables.

Whatever endpoint you give here is the only one the SDK talks to unless you add data_endpoints (section 2), so make it the pool, not a single VIP and not VMS.

Timeouts

By default the SDK sets no timeout at all. If a CNode stops responding (a network failure or something), the request will simply waits, forever. The retry logic never gets involved because, as far as the client is concerned, nothing has failed yet. So set one.

timeout takes a (connect, read) pair in seconds and is passed straight through to requests.

The first isconnect. That sets how long to wait for the TCP connection.
The seconds is read. It is the longest gap allowed between bytes arriving.

Note that read is not a limit on the whole query. A large query that keeps streaming data can run for longer than 300 seconds with timeout=(5, 300) and never trip it. Start with something like (5, 300) and tighten the timeouts once you get a feel for your typical response times. Worker threads will inherit the same timeout.

Retries

BackoffConfig controls what happens after a failed request. The default is up to 10 attempts within 60 seconds, with exponential backoff. Two kinds of failure are retried: the server saying it's overloaded (HTTP 503 with the S3 error code SlowDown), and connection errors on read-only calls (GET/HEAD). A query whose connection drops mid-stream resumes from the last row each subsplit had returned rather than starting over.

The SDK doesn't count retries for you, but it logs each one. Every retry writes a line to the backoff logger at the level set by BackoffConfig.backoff_log_level, which defaults to DEBUG. Raise it to WARNING and you'll see one line per retry, and an ERROR line when the SDK gives up:

import logging
from vastdb.config import BackoffConfig

logging.basicConfig(level=logging.WARNING)

session = vastdb.connect(
    endpoint="https://db-pool.example.com",
    access=ACCESS_KEY,
    secret=SECRET_KEY,
    timeout=(5, 300),
    backoff_config=BackoffConfig(max_tries=3, max_time=60, backoff_log_level=logging.WARNING),
)
WARNING:backoff:Backing off _single_request(...) for 1.0s (vastdb.errors.Slowdown: {'code': 'SlowDown', ...})
ERROR:backoff:Giving up _single_request(...) after 3 tries (vastdb.errors.Slowdown: ...)

To turn that into a metric, attach a handler to the backoff logger and count the lines:

class RetryCounter(logging.Handler):
    def __init__(self):
        super().__init__()
        self.retries = 0
        self.giveups = 0

    def emit(self, record):
        msg = record.getMessage()
        if msg.startswith("Backing off"):
            self.retries += 1
        elif msg.startswith("Giving up"):
            self.giveups += 1

counter = RetryCounter()
logging.getLogger("backoff").addHandler(counter)
# ... run queries ...
print(counter.retries, counter.giveups)

Watch this number in production.

2. Fanning a query out across CNodes

With no extra configuration, select() sends the whole query through one HTTP connection to one CNode. To run parts of it in parallel on several CNodes, give QueryConfig a list of data_endpoints:

from vastdb.config import QueryConfig

endpoint = "https://db-pool.example.com"

config = QueryConfig(
    data_endpoints=[endpoint] * 4,  # this is what gives you 4 entries
    num_splits=4,
)

with session.transaction() as tx:
    table = tx.bucket("bucket").schema("schema").table("table")
    reader = table.select(
        columns=["account_id", "event_time", "amount"],
        predicate=(table["event_time"] >= START_TIME),
        config=config,
    )
    for batch in reader:
        process(batch)

How many workers you actually get

The example above lists four endpoints and sets num_splits=4, so it runs four workers. The same four endpoints with num_splits=2 would give you two workers. If you leave num_splits unset, the SDK estimates it as row_count // 4,000,000 (minimum 1), so a table under 8 million rows gets one split and therefore one worker no matter how many endpoints you list. Set num_splits explicitly when you fan out.

Repeating the pool name vs. listing VIPs

Repeating the pool's DNS name, as above, is the low-maintenance option and the one to use with HTTPS. Each worker opens its own HTTP session and connection, so each one resolves the name separately, and DNS should spread the requests across the pool. When VIPs move, or the pool changes, DNS picks that up. It isn't a guarantee of one worker per CNode, though.

How well this spreads depends on DNS. The SDK README assumes VAST DNS is set up per the load-balancing best practice, with TTL 0 or multiple answers per query. A caching resolver or corporate DNS forwarder between the client and the cluster can undo that and hand every worker the same address. If your workers keep hitting the same CNode, check the resolver path first.

If you need every worker on a specific CNode, list the VIPs directly. Do this over HTTP:

config = QueryConfig(
    data_endpoints=[
        "http://192.0.2.10",
        "http://192.0.2.11",
        "http://192.0.2.12",
        "http://192.0.2.13",
    ],
    num_splits=4,
)

vastdb.util.expand_ip_ranges(["http://192.0.2.10-13"]) produces the same list from a last-octet range. It only understands http:// IPv4 addresses and passes anything else through untouched.

Listing VIPs with https:// doesn't work out of the box. The client checks the cluster's certificate against the address it connected to, and a certificate issued for the pool's DNS name doesn't match an IP address (such as192.0.2.10), so every worker will fail the TLS handshake. Your choices are to have the cluster certificate include the VIP IP addresses, or to pass ssl_verify=False to vastdb.connect(), which turns off certificate checking for every request in that session, workers included. If neither is acceptable, repeat the pool name instead with HTTPS.

Splits and subsplits

num_splits is the client-side division of work. num_sub_splits (default 4) is how many parallel streams each CNode uses internally for each split. Leave num_row_groups_per_sub_split at 8; the SDK notes it must be 8 for semi-sorted projections to work. Change any of these based on measurements or with VAST.

3. Writing efficient queries

Four rules, in order of how much they usually matter.

Let the CNode filter

Everything you pass to select() as columns and predicate goes into the query request, so the CNode does the filtering and only the rows and columns that survive cross the network. Everything you do after select() returns runs on your client, on data that has already been transferred.

So:

  • Name the columns you need - columns=None means every column.
  • Put filters in predicate - Only the operators listed under Supported Pushdowns are accepted; anything else raises NotImplementedError. If a filter can't be expressed with those operators, push down what you can and apply the rest in Python.
reader = table.select(
    columns=["account_id", "event_time", "amount"],
    predicate=(table["event_time"] >= START_TIME) & (table["amount"] > 0),
    config=config,
)

Stream, don't collect

select() returns a pyarrow.RecordBatchReader. Iterating over that keeps only a few batches in memory at a time; the workers block until you consume what's queued.

with session.transaction() as tx:
    table = tx.bucket(BUCKET).schema(SCHEMA).table(TABLE)
    for batch in table.select(columns=COLUMNS, predicate=PREDICATE, config=config):
        process(batch)

reader.read_all() and reader.read_pandas() materialize the whole result. Use them only when you know it fits in memory.

Either way, the reader is only valid inside the transaction that created it. Finish reading before the with block ends, or it raises MissingTransaction.

Don't fan out LIMIT queries

limit_rows caps what select() returns, but the SDK also sends the same number to the server as a per-stream limit. Each subsplit on each worker may therefore produce up to limit_rows rows before the client notices it has enough and tells the workers to stop. By then every stream has done its share of the work, and some of those rows are already in flight to the client.

A limit_rows=10000 query fanned out across 8 workers with the default 4 subsplits can cost the cluster up to 320,000 rows of scanning to hand you 10,000.

For a quick look at a table, use one endpoint (no data_endpoints) and the default subsplits. Lab numbers for a 10,000-row read:

Configuration Time Notes
One endpoint, default subsplits 26 ms Fastest. Use this.
Eight workers 74 ms Extra connections and server work, no benefit
QueryConfig(num_splits=1, num_sub_splits=1) 118 ms Least server work, but serial. Use it to protect a busy cluster, not for speed.

Leave sorted projections on

use_semi_sorted_projections=True is the default. It lets the server skip data that can't match your predicate. How much it helps depends on how the projection is sorted and how selective the predicate is; there's no reason to turn it off outside benchmarking.

4. Repeated jobs: skip the metadata round trips

tx.bucket(...).schema(...).table(...) lists the table and then loads its schema, sort columns, and stats: several round trips. In an interactive session that's fine. In a job that opens the same table thousands of times, load the metadata once and reuse it:

# From SDK README example (vastdb_sdk/README.md:176-204).
from vastdb.table_metadata import TableMetadata, TableRef

metadata = TableMetadata(TableRef(BUCKET, SCHEMA, TABLE))
with session.transaction() as tx:
    metadata.load(tx)

for item in work:
    with session.transaction() as tx:
        table = tx.table_from_metadata(metadata)
        ...

Reload after a schema change. Keep one session object for the life of the job too; it owns the HTTP connection pool. Each transaction still costs a begin and a commit, so batch related work into one transaction where that makes sense.

5. Writing data

  • Insert PyArrow RecordBatch or Table objects, not one row at a time. The SDK slices them to stay under its 5 MiB request limit, and by_columns=True (the default) uses the faster column-batched path.
  • If the data is already in Parquet on the cluster, use import_files(). CNodes read the files directly; nothing passes through the client. Parallelism comes from ImportConfig.import_concurrency (default 2), not QueryConfig.
  • insert(), update(), and delete() always use the session endpoint. data_endpoints only applies to select().

Sources