A database is something you load your data into. csvql is a query you run on the data where it already lives.
SELECT only. No INSERT/UPDATE/DELETE/DROP exists in the parser. It cannot modify your data.
--root <dir> confines every file the process can open. Paths outside it, including ../ traversal, fail with PathOutsideAllowedRoot. Give an agent a directory, not a filesystem.
Ships as an MCP server, wired up by csvql install. Results are capped at about 100 rows / 12 KB, so a careless query can't flood the context or pull a table out one call at a time.
A GROUP BY on an 8 GB file: 74 MB for csvql, 367 for DuckDB, 1,754 for polars. Counting 5 million distinct values in 11 GB: 235 MB against 2,639 for DuckDB. Fits on the small VM next to your data.
Queries the raw CSV in place via mmap. No import step, no native store to build first. No cloud, no telemetry, air-gappable, with --audit logging.
Written in Zig. No runtime, no dependencies. macOS, Linux, Windows. Also on npm, PyPI and Homebrew.
Same GROUP BY department on raw CSV, one process per engine, Apple M2 Pro, 16 GB. Peak memory and wall time:
| File | csvql | DuckDB | polars 2.0 |
|---|---|---|---|
| 0.69 GB | 74 MB / 0.2s | 254 MB / 0.8s | 1151 MB / 0.6s |
| 1.89 GB | 74 MB / 0.4s | 253 MB / 1.2s | 2107 MB / 1.1s |
| 3.80 GB | 75 MB / 0.7s | 275 MB / 2.1s | 2666 MB / 9.8s |
| 8.14 GB | 74 MB / 2.4s | 367 MB / 5.5s | 1754 MB / 26.7s |
Memory is flat because an aggregate holds an accumulator, not a dataset. Speed is in the same band as DuckDB and polars: ahead on scans, filters, small files and joins against raw CSV, level with polars on COUNT(*) and top-N, behind DuckDB where it has to hold a lot of state.
Where DuckDB wins, on an 11.15 GB CSV with 75 million rows and a column of 5 million distinct values (DuckDB 1.4.2, best-of-3):
| Query | csvql | DuckDB | Memory |
|---|---|---|---|
COUNT(*) WHERE country='JP' | 1.95s | 4.34s | 53 MB vs 268 MB |
COUNT(DISTINCT user_id) (5M) | 4.8s | 5.7s | 235 MB vs 2,639 MB |
SELECT DISTINCT user_id (5M) | 9.0s | 7.0s | 262 MB vs 2,724 MB |
GROUP BY user_id (5M groups) | 16.2s | 8.3s | 1,502 MB vs 2,894 MB |
On high-cardinality grouping DuckDB is about 2× ahead, at roughly twice the memory. MEDIAN is a 5× loss. If memory is free, DuckDB is an excellent default. If you are running next to the data on a small box, or handing a directory-confined tool to an agent, the memory column is the reason csvql exists.
Every number has a reproduce script. Full tables, including joins, quoted CSV, wide files and the losses: BENCHMARKS.md. The run that produced the second table also found a real bug: Five ways to find distinct values in a 10 GB CSV.
--root confines every file the process can open, so an agent with shell access to csvql still cannot read outside the directory you named.
# allowed csvql --root /data "SELECT COUNT(*) FROM '/data/events.csv'" 2000000 # blocked, including ../ traversal csvql --root /data "SELECT COUNT(*) FROM '/etc/passwd'" execution error: error.PathOutsideAllowedRoot
Combined with SELECT-only execution, no network calls and --audit logging, that is the whole trust story: the tool cannot modify your data, cannot phone home, and cannot read outside its root.
Token cost: five questions about a taxi dataset cost about 640 tokens over MCP, tool schema included, and that does not change with file size. Pasting the 417 MB file would cost about 230 million tokens, so pasting was never an option. An agent that already shells out to duckdb -csv uses slightly fewer tokens than csvql over MCP, because MCP returns JSON. The case for csvql is the paragraph above, not the token count.
# Homebrew (macOS / Linux) brew install melihbirim/csvql/csvql # Query a CSV csvql "SELECT cab_type, COUNT(*) FROM 'trips.csv' GROUP BY cab_type" # Wire it into Claude (Code + Desktop), no manual config csvql install
Prebuilt binaries for macOS, Linux and Windows, and one-click .mcpb bundles for Claude Desktop, on the releases page. Also: npm i csvql-query, pip install csvql-query.