A terminal client for any Apache Arrow Flight / Flight SQL server. One static binary: browse the catalog, run SQL, stream Arrow onward. Human-friendly on a TTY, machine-friendly in a pipe, agent-friendly by design.
Most Flight servers ship SDKs. Sometimes you just want to inspect one from a terminal. And the pipe is first-class: a table when you're reading, raw Arrow IPC when you're piping — the same command does both.
Status ✔ works against five independent Flight SQL servers · ✔ binaries for Linux · macOS · Windows Validated against ✔ GizmoSQL (DuckDB) ✔ Sparrow Flight ✔ ROAPI (DataFusion) ✔ Dremio OSS ✔ InfluxDB 3 Core
# a live 136-million-row Flight SQL server, open for exactly this:
sparrow connect grpc+tls://flight.sparrowflight.io:443 --basic demo:demo
sparrow ls
sparrow info series_data
sparrow sql "SELECT series_id, COUNT(*) FROM series_data GROUP BY 1 LIMIT 5"| command | does | wire calls |
|---|---|---|
sparrow connect <uri> |
verify + save a profile | vendor probe via GetSqlInfo, SELECT 1 fallback |
sparrow orient |
one-shot markdown map: vendor, every table, every schema | GetSqlInfo + GetTables w/ schemas |
sparrow ls [pattern] |
list tables; the pattern is a server-side SQL LIKE (%, _, case-sensitive) |
GetTables — the one discovery RPC that works everywhere |
sparrow info <table> |
schema, catalog, row count | GetTables w/ schema; LIMIT 0 fallback |
sparrow sql "<query>" |
run a statement (- = stdin, -f query.sql = file; --stats / --ipc stream anatomy; --schema = columns+types only; --bigint-as-string for JS precision; --substrait plan.pb executes a Substrait plan) |
CommandStatementQuery → GetFlightInfo → DoGet |
sparrow query <table> |
build the one-liner SELECT for you: --cols --where --order --limit; everything else works like sql |
same as sql |
sparrow head <table> [n] |
preview the first n rows (default 10) — the SELECT * … LIMIT n you keep typing |
Execute → DoGet |
sparrow pull '<ticket>' |
Direct Pull (1-RTT): a ready ticket straight to the server — no GetFlightInfo, no SQL (doget is a hidden alias). Flight SQL reads are two round trips by design; servers that accept client-made tickets (Sparrow: JSON {"series": [...]} or {"sql": "…"}) serve known pulls in one — measured 143 vs 224 ms for the same 10k-row series over the public internet, the 81 ms gap being exactly the saved round trip (the win is one RTT, so it shrinks to nothing on a LAN). --accept-compression lz4 (the default) asks a negotiating server for a compressed wire — decoded transparently; doctor --server probes which kind a server is |
DoGet only |
sparrow profile <table> |
per-column nulls %, approx-distinct, min, max — one server-side pass | one aggregate query |
sparrow doctor |
layered connection diagnosis — names the layer that breaks (--server: Flight SQL conformance card — 10 surface probes incl. IPC compression) |
staged: DNS → TCP → TLS/ALPN → auth → GetTables → SELECT 1 |
sparrow check <table> |
data doctor: nulls, duplicate keys, staleness, frozen series, outliers. --strict fails on warnings · --show-violations emits offending keys+values · --approx = memory-safe (HLL) uniqueness · --explain echoes each stage's SQL · --baseline prior.json gates on regressions |
server-side SQL aggregates — the table is never downloaded |
sparrow diff <table> --against <b> |
drift gate: schema, COUNT(*), --time bounds, numeric fingerprint vs a second server — exit 1 on drift |
conservative aggregates on both sides; nothing downloaded |
sparrow audit |
security surface: what client SQL can reach beyond queries — file reads, dir listing, writes, SSRF, config tamper. Exit 1 if exposed | benign probes; run against a server you operate |
sparrow ping |
separate network latency from server latency, as percentiles | bare TCP connect vs a no-match GetTables on the warm channel |
sparrow feedback "msg" |
send feedback to the sparrow maintainers | HTTPS to sparrowflight.io — independent of whichever server you use |
sparrow completion bash|zsh|fish |
shell tab-completion script | — |
sparrow profiles |
list saved connections (use <name> / rm <name>) |
— |
Auth — the two adapters that cover the whole tested landscape:
--basic user:pass # GizmoSQL, Dremio, Sparrow (API key as user
# works; Bearer handoff adopted automatically)
--bearer TOKEN --header database=mydb # InfluxDB 3 style: token + per-call metadataTLS: grpc:// plain, grpc+tls:// verified, --tls-skip-verify for
self-signed. The CLI identifies the server by trying GetSqlInfo first, then
SELECT version() as a fallback — Dremio answers the second, InfluxDB the
first; between them, every server identifies itself.
A Flight SQL read is two round trips by design: GetFlightInfo to plan, then
DoGet to stream. A Direct Pull sends a client-made ticket straight to the
server and skips the plan — one round trip. The same WTI crude series, fetched
both ways — an ordinary query the server plans, and a Direct Pull:
# the ordinary way — a query the server plans (2 round trips)
sparrow sql "SELECT series_id, period, value FROM series_data WHERE series_id='PET.RWTC.D' ORDER BY period" -o table --max-rows 5
# a Direct Pull — a ready ticket straight to the server (1 round trip)
sparrow pull '{"series":["PET.RWTC.D"]}' -o table --max-rows 5Both print the identical rows:
series_id period value
PET.RWTC.D 19860102 25.56
PET.RWTC.D 19860103 26
PET.RWTC.D 19860106 26.53
PET.RWTC.D 19860107 25.85
PET.RWTC.D 19860108 25.87
A ticket comes in two dialects — {"series":[…]} by key, or {"sql":"…"} for
an arbitrary read-only query:
sparrow pull '{"sql":"SELECT series_id, COUNT(*) AS n FROM series_data GROUP BY 1 ORDER BY n DESC LIMIT 3"}' -o tableseries_id n
FRED.DFF 13340
PET.EER_EPD2F_PE3_Y35NY_DPG.D 11117
PET.EER_EPD2F_PE1_Y35NY_DPG.D 11113
Default output is raw Arrow IPC when piped — so it composes (| duckdb,
> series.arrows) — and an aligned table on a TTY; -o picks explicitly.
--stats shows the round trip you skipped (plan (skipped: 1-RTT)) and the
codec you negotiated (see Measure).
pull is the same operation as the Flight DoGet RPC — which is why doget
still works as a hidden alias. Opaque-handle vendors — most Flight SQL servers —
don't accept client tickets; use sql there, and sparrow doctor --server
probes which kind a server is.
sparrow sql "..." # TTY: aligned table · pipe: raw Arrow IPC
sparrow sql "..." -o md # markdown table
sparrow sql "..." -o csv # CSV (empty cell = NULL)
sparrow sql "..." -o jsonl # one JSON object per row
sparrow sql "..." -o json # JSON array
sparrow sql "..." -o data.parquet # file sink: .parquet .csv .json .jsonl .arrow .mdJSON note: 64-bit integers are emitted as JSON numbers at full precision.
JavaScript's JSON.parse silently loses precision above 2^53 — use a
bigint-aware parser, or cast to text in SQL.
In a pipe, the default is a raw Arrow IPC stream — columnar data stays columnar all the way:
sparrow sql "SELECT period, value FROM series_data WHERE series_id='PET.RWTC.D'" \
| duckdb -c "LOAD arrow; SELECT COUNT(*), MIN(value), MAX(value) FROM read_arrow('/dev/stdin')"
# → 10217 · -36.98 · 145.31 — forty years of WTI without leaving Arrow
# (one-time setup: duckdb -c "INSTALL arrow FROM community" — read_arrow is a
# community extension, so the explicit LOAD is required; it never autoloads)# private CA + mTLS — for Flight deployments that require client certificates
sparrow connect grpc+tls://flight.corp:443 \
--tls-ca ca.crt --tls-cert client.crt --tls-key client.key
# sealed exports — in-spec Parquet Modular Encryption (AES-GCM)
sparrow sql "SELECT ..." -o data.parquet --encrypt-key env:SPARROW_KEYThe encryption key is hex (16/24/32 bytes), env:VAR, or file:path.
DuckDB, Spark and pyarrow read the file back with the key — and refuse it
without. The exact DuckDB recipe (key handed over as base64 of the same
bytes):
PRAGMA add_parquet_key('k', '<base64 of the key bytes>');
SELECT * FROM read_parquet('data.parquet', encryption_config = {footer_key: 'k'});Prefer 32-byte keys (64 hex digits). DuckDB guesses whether the key string is raw bytes or base64 by its length — and base64 of a 16/24-byte key is exactly 24/32 characters, a valid raw-key length that DuckDB tries first, ending in a spurious "AES tag differs" error. A 32-byte key encodes to 44 characters and is unambiguous. (Found by an external tester driving this CLI — thanks.) mTLS verified against an Envoy that requires client certificates: no cert → refused (exit 2); cert → query runs.
Every connection failure looks the same from a client: "connection error."
sparrow doctor walks the stack — config → DNS → TCP → TLS → auth → Flight
SQL → round trip — and names the layer that breaks, with evidence:
$ sparrow doctor -s grpc+tls://fixture:31337 --basic user:pass
✓ config profile "(ad-hoc)" · auth basic · TLS system roots
✓ dns fixture → 192.168.132.91 (18 ms)
✓ tcp connected to 192.168.132.91:31337 (2 ms)
✗ tls tls: failed to verify certificate: x509: certificate signed by unknown authority
wire presented: subject "localhost" · issuer "Norton Web/Mail Shield Untrusted Root"
hint: if that issuer is not your server's CA, something between you and the
server is intercepting TLS (antivirus HTTPS scanning, corporate proxy)
· auth not reached
3 ok · 0 warn · 1 fail — first failure at tls
That capture is real — a self-signed fixture that "wouldn't verify" turned out
to be local antivirus re-signing the connection, and doctor shows the swapped
certificate straight off the wire. On a healthy endpoint it reports the TLS
version, ALPN (gRPC needs h2; doctor says so when a proxy won't negotiate
it), certificate issuer and expiry, the auth handshake, the vendor, and a
timed round trip. -o json for scripts; exit 2 if any layer fails.
sparrow ping runs two round trips per round — a bare TCP connect (pure
network) and a lightweight RPC on the warm, authenticated channel (network +
server) — and summarizes the percentiles. The gap between the two is the
server:
$ sparrow ping -n 5 -s grpc+tls://flight.sparrowflight.io:443 --basic demo:demo
round 1 tcp 75.9 ms rpc 86.1 ms
...
min p50 p95 max
tcp 61.3 63.4 75.9 75.9 ms
rpc 75.4 81.4 86.1 86.1 ms (5/5 ok)
≈ network 63.4 ms + server 18.0 ms (medians)
sparrow sql --stats breaks a query's wall clock into its anatomy — plan,
first byte, stream — plus rows, bytes that actually crossed the wire (counted
at the gRPC layer), throughput, pacing, and the per-column type/encoding
breakdown. Half a million rows over the public internet:
$ sparrow sql "SELECT * FROM series_data LIMIT 500000" --stats > /dev/null
── query stats ─────────────────────────
plan (GetFlightInfo) 79 ms
first byte 404 ms
stream (DoGet) 2304 ms
total 2384 ms
rows 500,000 in 245 batches · rows/batch p50 2,048 (min 288 · max 2,048)
wire 22.5 MB received · decodes to 22.4 MB (1.0×) · no body compression declared
speed 78 Mbit/s over the stream
pacing gaps p50 4.2 ms · p95 22.7 ms · max 164.7 ms — 82% of the stream is
waiting (paced upstream: sender or network stalls between batches)
column type encoding nulls decoded
series_id utf8 plain 0 13.2 MB (59%)
period utf8 plain 0 5.2 MB (23%)
value float64 plain 0 4.0 MB (18%)
That's the stream's full anatomy: the server's batch signature (2,048-row
chunks — DuckDB's vector size showing through), whether the wire is actually
compressed — read from the IPC message header's declared codec, so a
compressed stream prints codec lz4_frame (or zstd) with the ratio
corroborating it — what
every column arrives as (type, arrow-level encoding, nulls, share of the
bytes), and whether the stream is paced by the wire or by gaps upstream —
measured excluding the client's own write time, so a slow local sink doesn't
skew the verdict. Run the same pull against two servers and the differences
name themselves.
Prefer the raw view? sql --ipc prints the message-by-message IPC manifest
instead: type, rows, body bytes, declared codec, custom metadata.
The example above reads no body compression declared because a sql query
is a 2-RTT read — the opaque statement ticket can't negotiate a codec. To
request a compressed wire, use a Direct Pull (Sparrow serving nodes accept
JSON tickets): sparrow pull '{"series":["…"]}' --accept-compression lz4 (lz4
is on by default; --accept-compression "" opts out). A negotiating server
compresses only for a codec the client lists, arrow-go decodes it
transparently, and the same --stats/--ipc view then prints codec lz4_frame with the ratio. sparrow doctor --server probes whether a server
offers it at all.
$ sparrow pull '{"series":["PET.RWTC.D"]}' --stats > /dev/null
plan (skipped: 1-RTT) 0 ms
rows 10,217 in 5 batches · rows/batch p50 2,048
wire 172.5 KB received · decodes to 347.4 KB (2.0×) · codec lz4_frame
Same 10k-row series, half the bytes on the wire — the server compressed it
because the client (arrow-go here) advertised lz4, and the ratio comes back
in the same stats line. Compression trades CPU for bandwidth: a clear win over
a wide-area link, a wash or worse on a fast LAN where the wire was never the
bottleneck — so it is negotiated, never forced.
Stats go to stderr, so they compose with every output format and pipe.
Every benchmark number this project publishes is reproducible with this flag.
ping -o json for scripts; both work against any Flight SQL server.
sparrow check <table> runs a statistical health screen server-side —
every check is one conservative SQL aggregate, the table is never downloaded,
and anything a dialect rejects degrades to a skip instead of aborting:
$ sparrow check checkme --key k --max-age 7d
✓ table 3 columns · key k · time t (auto-detected)
✓ rows 60
✓ nulls v 8.3%
✗ keys 1 duplicated (k, t) groups
e.g. DUP
⚠ time t spans 2026-06-01 → 2026-06-30 · newest point 13.7 days old
hint: older than --max-age 7d — is the feed still running?
✓ coverage 5 entities · rows per entity: min 1 · avg 12 · max 30
⚠ frozen 1 entities have a constant v across ≥10 observations
e.g. FLAT
✓ numeric v: min 1 · max 900000 · avg 16432.25
5 ok · 2 warn · 1 fail — checked in 0.0 s (10 queries, server-side)
--key names the entity key (uniqueness is checked on (key, time) when
--time is set, and temporal columns are auto-detected). Duplicate keys and
NULLs in key columns are failures and exit 1 — sparrow check t --key id && deploy works as a data gate in CI (--strict widens the gate to warnings,
and a sub-check the server couldn't execute is an ! error, never a silent
pass). Staleness, frozen series (a constant
value across ≥10 observations — a stuck feed's signature), dead columns and
σ-outliers are warnings. -o json for pipelines and agents.
Download a binary from the releases page
(Linux, macOS and Windows; amd64 + arm64), unpack it and put sparrow on your
PATH. Checksums included. Or install with Go, or build from source:
go install github.com/balicat/sparrowcli@latest # installs as `sparrowcli` — rename if you like
go build -o sparrow . # Go ≥ 1.25; pure Go, no cgo — trivially cross-compiles
GOOS=windows go build -o sparrow.exe .AI agents don't need a Flight client library — they can just call the CLI. One command maps a Flight server — vendor, tables, schemas, as markdown:
sparrow orientThen query with results the agent reads natively:
sparrow info series_data # row count for one table
sparrow sql "SELECT ... LIMIT 20" -o md # arbitrary query, readable results
echo "SELECT ..." | sparrow sql - -o md # SQL via stdin — no shell-quoting battles
sparrow pull '{"series":["PET.RWTC.D"]}' -o md # a known series in ONE round tripConventions agents can rely on:
-
Two ways to read the same data.
sparrow sql "<query>"plans an arbitrary query — two round trips (GetFlightInfothenDoGet). When you already know exactly what you want,sparrow pull '<ticket>'sends a ready ticket straight to the server in one round trip: a{"series":[…]}key or a{"sql":"…"}string, lz4-compressed by default.sparrow help pullprints the ticket dialects; the server also advertises them in-band viaGetSqlInfo. Opaque-handle vendors reject client tickets — usesqlthere (sparrow doctor --serversays which kind a server is). Rule of thumb: exploring →sql; a known series or a fixed query you'll repeat →pull. -
-o md/-o jsonl/-o csvare stable, parseable stdout formats — no ANSI, no decoration; row-count and timing summaries go to stderr. -
Exit codes:
0ok ·1query error ·2connection/auth ·3usage — branch on "server down" vs "my SQL was wrong". -
On exit
2, runsparrow doctor -o json— a layer-by-layer diagnosis (DNS, TCP, TLS/ALPN, auth) as structured JSON, instead of guessing. -
sparrow ping -o json(latency percentiles, network-vs-server split) andsql --stats(timing/throughput anatomy on stderr) make measurements scriptable too. -
sparrow check <table> --key id -o jsonscreens a dataset's health without downloading it — exit1means findings, so it gates pipelines. -
sql --ipcreveals the stream's raw IPC manifest — every message's type (schema / dictionary / record batch), rows, body bytes, declared codec and custom-metadata count — wire-level introspection without a packet capture. -
Found a bug or have an idea?
sparrow feedback "..." --from your-namedelivers it to the sparrow maintainers directly — independent of whichever Flight server you're connected to, so it works even when the server is the problem. Agents are explicitly welcome to use it. -
-o mdto stdout caps at 1,000 rows by default so a carelessSELECT *can't flood a context window (the true total reports on stderr;--max-rowsoverrides). File sinks and data formats (csv/jsonl/json/arrow/parquet) always emit everything. -
Prefer
LIMITin SQL —--max-rowsstill downloads the full result. -
Profiles live in
~/.sparrow/config.json;sparrow profiles use <name>switches the default,-s grpc+tls://host:port --basic u:pworks ad-hoc.
Other CLIs can reach a Flight SQL server — none preserve Arrow end-to-end:
| scope | Arrow stays Arrow? | |
|---|---|---|
flight_sql_client (apache/arrow-rs) |
any Flight SQL server | ✗ — "basic" example binary, one-shot RPC commands, text out |
timvw/arrow-flight-sql-client |
any Flight SQL server | ✗ — small RPC-mirror CLI, text out |
usql |
40+ databases; Flight SQL as one driver | ✗ — excellent universal shell, but results flatten through database/sql to rows and text |
bendsql (né "Arrow CLI") |
Databend only | — what happens when an Arrow CLI grows up inside one vendor |
sparrow |
any Flight SQL server | ✓ — columnar from Flight server to downstream process: raw IPC pipe, parquet sinks, typed formats |
The gap sparrow fills isn't "a CLI exists" — it's Arrow-native ergonomics:
catalog discovery over the Flight RPCs, profiles, orient, output that follows
the consumer, and conventions agents can script against.
One transport, many clients: Sparrow (the Flight server) · sparrowJS (the browser) · sparrowXL (Excel) · sparrowMCP (AI agents) · sparrowCLI (the terminal).
Dialect note: your SQL passes through untouched — quirks are the server's business (e.g. Dremio rejects aliases on FROM-less SELECTs; the CLI reports the server's error verbatim and exits 1).