
In January we shipped the first Python SDK for Arc. Since then Arc has added clustering, tiered storage, governance, backups, edge sync and a lot more, and the SDK did not keep up. If you wanted any of that from Python, you were back to building HTTP requests by hand.
arc-tsdb-client 0.2.0 closes that gap. It is out today on PyPI.
The headlines:
- Results that tell you when they are incomplete. A query that failed part way, or one that a row cap stopped, no longer looks like a complete answer.
- Typed errors. A missing permission, a refused write and a partial write each raise their own exception, so you can handle them instead of parsing messages.
- Bulk import of CSV and Parquet files, and
tag_columnson columnar writes. - zstd compression for writes, now the default.
- The rest of Arc's API: databases, compaction, backups, the cluster, diagnostics, scheduler controls and edge sync, all sync and async.
Let's walk through it from a fresh install. Every example below was run against Arc 26.09.3 with 0.2.0 installed from PyPI.
Install or upgrade
pip install -U "arc-tsdb-client[all]"
# or with uv
uv add "arc-tsdb-client[all]"[all] brings pandas and polars. Without them, the core client still works and returns plain Python and PyArrow results.
Connect
import os
from arc_client import ArcClient
client = ArcClient(
host="localhost",
port=8000,
token=os.environ["ARC_TOKEN"],
database="iot",
)
ready = client.write_readiness()
print(ready.status, ready.reason)ready None
write_readiness() is new. It asks the node whether it accepts writes right now, which matters in a cluster where only some nodes take writes. client.readiness() asks whether it should receive traffic at all. A "not ready" answer comes back as a result with a reason, not as an exception.
Write data
Columnar MessagePack
This is still the fastest path. New in 0.2.0, tag_columns tells Arc which columns are tags:
import time
now = int(time.time() * 1_000_000) # microseconds
client.write.write_columnar(
measurement="temperature",
columns={
"time": [now - 2_000_000, now - 1_000_000, now],
"device": ["sensor_01", "sensor_02", "sensor_01"],
"location": ["warehouse_a", "warehouse_a", "warehouse_b"],
"value": [22.5, 23.1, 21.8],
},
tag_columns=["device", "location"],
)Line Protocol
Line Protocol now takes a precision, so you can send the timestamps your tools already produce instead of converting them to nanoseconds:
ts = int(time.time())
client.write.write_line_protocol(
[
f"temperature,device=sensor_03,location=warehouse_b value=20.9 {ts}",
f"temperature,device=sensor_04,location=warehouse_c value=24.3 {ts}",
],
precision="s",
)DataFrames
import pandas as pd
df = pd.DataFrame(
{
"time": pd.date_range(end=pd.Timestamp.now(tz="UTC"), periods=3, freq="s"),
"device": ["sensor_01", "sensor_02", "sensor_03"],
"humidity": [41.0, 43.5, 39.8],
}
)
client.write.write_dataframe(df, measurement="humidity", tag_columns=["device"])If you used this with 0.1.x, check your timestamps. pandas columns stored as datetime64[us], [ms] or [s] were read as nanoseconds and landed about 1,000 times too early. 0.2.0 reads each column's real resolution, and polars datetime columns are converted through polars' public API.
Buffered writes
When records arrive one at a time, let the buffer batch them:
import random
with client.write.buffered(batch_size=5_000, flush_interval=2.0) as buffer:
for i in range(10_000):
buffer.write(
{
"measurement": "events",
"tags": {"source": f"gateway_{i % 4}"},
"fields": {"latency_ms": random.uniform(1, 50)},
}
)The buffer now flushes on flush_interval from a background timer, so a quiet stream still gets written. In 0.1.x the timer only fired on the next write. Closing the buffer waits for writes in flight and reports a failed batch instead of silently dropping it.
Make writes queryable now
Arc buffers incoming rows and writes them to Parquet. With the default arc.toml that happens every 100 ms, so a query sent the instant a write returns can still miss its rows. In a test that writes and immediately asserts, flush instead of sleeping:
client.write.flush_server_buffers()It flushes every buffer on that node and needs an admin token.
Query
query() returns a QueryResponse:
result = client.query.query(
"SELECT device, avg(value) AS avg_temp FROM temperature GROUP BY device ORDER BY device"
)
print(result.columns)
print(result.data)
print(result.row_count, result.execution_time_ms)['device', 'avg_temp']
[['sensor_01', 22.15], ['sensor_02', 23.1], ['sensor_03', 20.9], ['sensor_04', 24.3]]
4 4.0
For analysis, go straight to a DataFrame:
df = client.query.query_pandas(
"SELECT source, count(*) AS events, avg(latency_ms) AS avg_latency_ms "
"FROM events GROUP BY source ORDER BY source"
)
print(df) source events avg_latency_ms
0 gateway_0 2500 25.814036
1 gateway_1 2500 25.289487
2 gateway_2 2500 25.677258
3 gateway_3 2500 25.608348
pl_df = client.query.query_polars("SELECT * FROM humidity ORDER BY time")
print(pl_df)shape: (3, 3)
┌────────────────────────────────┬───────────┬──────────┐
│ time ┆ device ┆ humidity │
│ --- ┆ --- ┆ --- │
│ datetime[μs, UTC] ┆ str ┆ f64 │
╞════════════════════════════════╪═══════════╪══════════╡
│ 2026-11-05 16:05:57.461698 UTC ┆ sensor_01 ┆ 41.0 │
│ 2026-11-05 16:05:58.461698 UTC ┆ sensor_02 ┆ 43.5 │
│ 2026-11-05 16:05:59.461698 UTC ┆ sensor_03 ┆ 39.8 │
└────────────────────────────────┴───────────┴──────────┘
query_arrow() returns a PyArrow table. Both DataFrame methods are built on it.
Know when a result is complete
This is the change we care most about in this release.
Arc streams query results. If something fails after the first rows are on their way, a partial or disk failure for example, Arc cannot take the response back. And with a governance row cap configured, a query stops at the cap. Both used to arrive looking like a complete answer. 0.2.0 tells them apart, and they mean opposite things:
- Truncated: the query failed part way. The rows you got are not the result. Do not use them.
- Row cap reached: a policy stopped the query. The rows are correct, there are just more of them than the cap allows.
query() gives you both on the response:
result = client.query.query("SELECT * FROM temperature")
if result.truncated:
raise RuntimeError(f"Arc could not finish this query: {result.truncation_reason}")
if result.rows_capped:
print(f"Stopped at the row cap ({result.row_cap} rows); narrow the query for the rest")query() returns a truncated result rather than raising, so it is up to you to check truncated. The Arrow path is stricter. query_arrow(), query_pandas() and query_polars() raise ArcQueryError on a truncated stream. query_arrow() puts the cap in the table's schema metadata:
table = client.query.query_arrow("SELECT * FROM temperature")
print(table.schema.metadata){b'arc.rows_capped': b'false', b'arc.execution_time_ms': b'1'}
A pandas or polars DataFrame does not carry that metadata, so if a cap may apply to your query, use query_arrow() and convert it yourself.
Import files
The bulk import API loads CSV and Parquet files in one call:
result = client.import_.import_csv("readings.csv", measurement="pump_current")
print(result)database='iot' measurement='pump_current' rows_imported=4 partitions_created=1 time_range_min='2026-11-05T10:00:00Z' time_range_max='2026-11-05T10:01:00Z' columns=['time', 'device', 'value'] duration_ms=0
import_parquet(), import_lp() and import_tle() work the same way, and accept a path, a file object or bytes.
Errors you can act on
Every Arc error now maps to its own exception. The one most likely to change your code is 403, which used to collapse into ArcAuthenticationError. Now there are three, depending on why Arc said no:
ArcPermissionError: the token lacks the permission the endpoint needs.ArcScopedAccessError: the token may read, but not this database. The database is on.database.ArcLicenseError: the feature needs an Enterprise license.
Here a read-only token asks for an admin operation:
from arc_client.exceptions import ArcPermissionError
try:
reader.write.flush_server_buffers()
except ArcPermissionError as e:
print(e)Permission denied: Permission denied: admin required
All three subclass ArcAuthenticationError, so except ArcAuthenticationError blocks you already have keep catching them.
Writes got the same treatment:
ArcPartialWriteError: Arc stored part of a batch and dropped the rest. It carries.droppedand.others_written. It is never retried, so a resend cannot duplicate the rows that were stored.ArcIngestRefusedError: a node refused the write, for example during a cluster failover. It carries.reasonand.retry_safe. The client resends it on its own when that is safe.
Both need Arc 27.01.1, the next Arc release. On 26.09.x a bad line rejects the whole batch with ArcIngestionError.
Retries follow the same rule throughout: read-only requests retry 429, 503 and transport failures with backoff, and honor Retry-After. Writes retry only when the connection was never established, and data DELETEs never retry, because the work may already have committed.
Operate Arc from Python
0.2.0 covers the rest of Arc's API:
for m in client.databases.list_measurements("iot").measurements:
print(m.name)
print(client.diagnostics.line_protocol_stats()["stats"])events
humidity
pump_current
temperature
{'total_bytes': 96, 'total_bytes_gzipped': 96, 'total_errors': 0, 'total_records': 2, 'total_requests': 1}
Alongside databases and diagnostics (metrics, logs, memory and write-protocol stats), there are clients for compaction, backup, cluster, tiering, governance, rbac, audit, queries (find and kill running queries), schedulers, reconciliation, mqtt and edge_sync. Anything that deletes data or disrupts a node requires confirm=True, and the actions that have a dry run do that by default.
Everything works the same with AsyncArcClient:
import asyncio
from arc_client import AsyncArcClient
async def main():
async with AsyncArcClient(host="localhost", port=8000, token=os.environ["ARC_TOKEN"], database="iot") as client:
df = await client.query.query_pandas(
"SELECT device, max(value) AS max_temp FROM temperature GROUP BY device ORDER BY device"
)
print(df)
asyncio.run(main()) device max_temp
0 sensor_01 22.5
1 sensor_02 23.1
2 sensor_03 20.9
3 sensor_04 24.3
Upgrading from 0.1.x
- Writes are compressed with zstd by default. Arc reads zstd on MessagePack writes from 26.01.1 and on Line Protocol from 26.05.1. On an older server, keep gzip:
ArcClient(..., compression="gzip"). - 403s raise more specific exceptions, as described above. Existing
except ArcAuthenticationErrorblocks still catch them. ready()no longer retries a "not ready" answer. One call, one answer.- Check data written with pandas
datetime64[us],[ms]or[s]columns. Those timestamps landed about 1,000 times too early before this release. - Some APIs follow the next Arc release, 27.01.1: partial-write and ingest-refusal errors, compaction cycles, backup restore modes,
allow_unreachabledeletes and the cluster policy controls. Each method's docstring names the Arc version it needs. - The SDK now sends an anonymous installation ID to the Arc server you connect to, the same way arcli does, so Arc's opt-out telemetry can count SDK installations. The SDK talks to nothing but your Arc server. Turn it off with
DO_NOT_TRACK=1,ARC_CLIENT_SEND_INSTALLATION_ID=0orArcClient(..., send_installation_id=False).
The changelog has the full list.
Thank you
Thank you to @jallegri and @lecodev-26 for the pull requests in this release, from the scheduler and write-protocol diagnostics clients to the README fixes and the wheel test in CI. Thank you also to @efegokdemir, whose work laid the groundwork for the result-completeness fields.
Resources:
Found a bug or missing an API? Open an issue or come say hi on Discord.