Python Client Guide¶
The Python client is the main ApexBase interface for embedded applications, scripts, notebooks, and agents. It opens a directory (or an in-memory database), runs SQL, writes rows and DataFrames, and reads results into Arrow, Pandas, or Polars.
Client Lifecycle¶
Use the client as a context manager so file handles and temporary tables are released reliably:
from apexbase import ApexClient
with ApexClient("./data", durability="safe") as client:
client.create_table("events")
client.store({"kind": "signup", "user_id": 1})
create_clean() drops and recreates the storage under a path, which is handy for
tests; :memory: is an isolated in-memory database with no sidecar files:
with ApexClient.create_clean("./tmp_data") as client:
client.create_table("scratch")
with ApexClient(":memory:") as client:
client.execute("CREATE TABLE scratch (value INT)")
Cross-process features such as hybrid_search() need a file-backed database, not
:memory:. For a shared process-local session, the module-level helpers
(apexbase.execute(...)) behave the same way with one implicit client.
Database And Table Selection¶
client.use_database("analytics")
client.create_table("events")
client.use_table("events")
client.use(database="analytics", table="events")
use(...) switches context and creates the table if it is missing; named
databases are subdirectories under the client root.
Writing Data¶
client.create_table("users", {"name": "string", "age": "int64"})
client.store({"name": "Alice", "age": 30})
client.store([{"name": "Bob", "age": 25}, {"name": "Charlie", "age": 35}])
client.store({"name": ["Diana", "Eve"], "age": [29, 31]})
The columnar dict is the fastest pure-Python ingest, and the explicit schema skips type inference on the first write.
DataFrame importers select or create the target table:
import pandas as pd
import polars as pl
import pyarrow as pa
client.from_pandas(pd.DataFrame({"name": ["Alice"], "age": [30]}), table_name="users")
client.from_polars(pl.DataFrame({"name": ["Bob"], "age": [25]}), table_name="users")
client.from_pyarrow(pa.table({"name": ["Charlie"], "age": [35]}), table_name="users")
Blob Payloads¶
Declare large byte columns as blob or large_binary for descriptor-backed
storage:
client.create_table("files", {"name": "string", "payload": "blob"})
client.store({
"name": ["small.bin", "video.bin"],
"payload": [b"small", b"\0" * (5 * 1024 * 1024)],
})
info = client.read_blob_info("payload", 2)
chunk = client.read_blob_range("payload", 2, offset=0, length=65536)
data = client.read_blob("payload", 2)
Payload bytes load only when you project the blob column or use a helper.
Querying¶
Use execute() for SQL:
client.use_table("users")
result = client.execute("""
SELECT age, COUNT(*) AS users
FROM users
GROUP BY age
ORDER BY users DESC
""")
Use query() for table-scoped filtering and record helpers for known ids:
result = client.query(where_clause="age >= 30", limit=100)
row = client.retrieve(1)
rows = client.retrieve_many([1, 2, 3])
all_rows = client.retrieve_all()
Deterministic analytical SELECT results may be cached per client; writes and
schema changes invalidate entries, and volatile queries are not reused.
Working With Results¶
ResultView is a lightweight handle; convert it to the format you need:
client.use_table("users")
result = client.execute("SELECT * FROM users")
print(result.shape) # (rows, columns)
print(result.columns) # ['name', 'age']; show_internal_id=True adds '_id'
rows = result.to_dict()
df = result.to_pandas()
pl_df = result.to_polars()
arrow_table = result.to_arrow()
Arrow conversion is the cheapest path for columnar downstream code.
Typed Projection¶
describe_table() reports declared storage types, including parameterised
logical types:
import decimal
client.create_table("ledger", {"id": "int64", "amount": "decimal(18,2)"})
client.store({"id": [1], "amount": [decimal.Decimal("19.99")]})
client.use_table("ledger")
print(client.describe_table()) # {'id': 'int64', 'amount': 'decimal(18,2)'}
result = client.execute("SELECT * FROM ledger")
arrow_table = result.to_arrow(declared_types=client.describe_table())
polars_df = result.to_polars(declared_types=client.describe_table())
Without declared_types, decimal and UUID payloads stay as 16-byte values; with
it, Arrow gets decimal128(18, 2) and Polars gets Decimal(18, 2), and the same
holds for time-family and list<T> columns.
Batch Execution¶
execute_batch() runs ordered SQL with read-after-write visibility and coalesces
consecutive UPDATE ... WHERE _id = N statements:
client.use_table("users")
results = client.execute_batch([
"INSERT INTO users (name, age) VALUES ('Alice', 30)",
"UPDATE users SET age = 31 WHERE _id = 1",
"SELECT COUNT(*) AS users FROM users",
])
execute_batch_parallel() runs independent read-only queries concurrently:
stats = client.execute_batch_parallel([
"SELECT city, COUNT(*) FROM users GROUP BY city",
"SELECT AVG(age) FROM users",
])
Only SELECT / WITH / EXPLAIN are accepted.
Transactions¶
A statement string with several statements is one transaction; control statements in the same call apply to the whole batch:
client.use_table("users")
client.execute("""
BEGIN;
UPDATE users SET age = 31 WHERE _id = 1;
ROLLBACK;
""")
A client-side BEGIN covers later batches until you commit or roll back:
client.execute("BEGIN")
client.execute("INSERT INTO users (name, age) VALUES ('Dana', 41)")
client.execute("COMMIT")
An error inside a transaction discards buffered DML and returns the connection
to auto-commit; a failed COMMIT reports a machine-readable outcome and
transaction id. See the SQL Guide for recovery rules.
Streaming Results¶
execute_stream() returns an iterator of pyarrow.RecordBatch objects instead
of materializing the whole result:
for batch in client.execute_stream("SELECT name, age FROM users"):
print(batch.num_rows, "rows") # one row group at a time
Plain projections keep one batch resident. ORDER BY, GROUP BY, and joins are
materialized by their spill operators as a single batch. Errors surface as
RuntimeError; close() stops early.
Vector Search And Hybrid Retrieval¶
import numpy as np
client.create_table("docs", {"body": "text", "embedding": "float32_vector"})
client.use_table("docs")
client.store({"body": ["a", "b"], "embedding": np.random.rand(2, 8).astype(np.float32)})
query = np.random.rand(8).astype(np.float32)
hits = client.topk_distance("embedding", query, k=5, metric="cosine")
rows = client.retrieve_many(hits.get_ids().tolist())
Run many queries in one call with batch_topk_distance():
queries = np.random.rand(200, 8).astype(np.float32)
pairs = client.batch_topk_distance("embedding", queries, k=10)
# shape (200, 10, 2); pairs[i, :, 0] are ids, pairs[i, :, 1] distances
Fuse vector and BM25 ranking on a persistent database:
client.init_fts() # or CREATE FTS INDEX ON docs(body) on first use
fused = client.hybrid_search(
query, "deployment rollback",
k=10, vector_col="embedding", metric="cosine", fusion="rrf",
)
Build a persisted IVF index for large tables and query with nprobe=:
client.create_vector_index("embedding", nlist=64, iterations=10, metric="cosine")
info = client.vector_index_info() # dim/nlist/rows/metric/stale
approx = client.topk_distance("embedding", query, k=10, metric="cosine", nprobe=16)
client.drop_vector_index()
Rebuild when info["stale"] is true. See Agent Memory & RAG
for the full hybrid recipe.
Quantized Vector Retrieval¶
client.create_quantized_column("embedding", "embedding_tq4", "turboquant4")
hits = client.topk_distance(
"embedding", query, k=20,
accelerator="embedding_tq4", candidate_k=160, rescore=True,
)
client.drop_quantized_column("embedding_tq4")
Inserts update the accelerator automatically; direct accelerator writes are rejected. See the Vector Quantization Guide.
Updating And Deleting¶
client.use_table("users")
client.replace(1, {"name": "Alice", "age": 31})
client.delete(where="age < 18")
client.execute("UPDATE users SET age = 31 WHERE name = 'Alice'")
client.execute("DELETE FROM users WHERE age < 18")
Prefer replace() or delete() on tables with a vector column: in 2.0.0 a SQL
UPDATE on such a table disables the direct vector scan used by
hybrid_search(). See Agent Memory & RAG.
Schema Changes¶
client.use_table("users")
client.add_column("email", "string")
client.rename_column("email", "contact_email")
client.drop_column("contact_email")
Schema changes invalidate cached results and index postings for the table; rebuild secondary or FTS indexes when needed.
Durability And Flushing¶
| Level | Behaviour | Use for |
|---|---|---|
fast |
Highest throughput, no per-write fsync | Scratch data, reproducible imports |
safe |
Balanced persistence | Application data and agent memory |
max |
Fsync on every write | Data you cannot replay |
Memory Budgets And Admission¶
| Variable | Default | Effect |
|---|---|---|
APEX_QUERY_MEMORY_MB |
1024 |
Per-query operator budget; ORDER BY/GROUP BY/DISTINCT/JOIN spill when exceeded |
APEX_PROCESS_BUDGET_MB |
disabled | Process-wide reservation budget with a bounded queue |
APEX_SPILL_DIR |
temp apex_spill/ |
Root directory for out-of-core run files |
Set them before the client opens. The per-query budget is always in effect;
process-wide admission is off until APEX_PROCESS_BUDGET_MB is set. Statements
that do not fit wait in a bounded queue; a full queue or timeout is rejected
with [APEX_ADMISSION_QUEUE_FULL] or [APEX_ADMISSION_TIMEOUT] instead of OOM.
client._storage._admission_stats()
# (used_bytes, waiting, admitted, rejected, timed_out, budget_bytes)
Where To Go Next¶
- SQL Guide and Data Import for SQL and bulk loads.
- Agent Memory & RAG for hybrid retrieval.
- Python API Reference for every method signature.