SQL-on-CSV Workbench (DuckDB)
Run real SQL on CSV and Parquet files in your browser with DuckDB — joins, group-bys and window functions, nothing uploaded.
Nothing downloads until you ask. The engine, its parquet and json extensions and your files all stay on this device — this page can't fetch anything else.
No files yet — drop a CSV, TSV or Parquet file here, or load the sample.
CSV, TSV and Parquet load here — read locally, never uploaded.
Run a query to see results here.
Press ? for shortcuts · Share links carry the SQL and view only — never your files.
Do it offline:$ duckdb -c "SELECT country, COUNT(*) FROM 'cities.csv' GROUP BY country ORDER BY 2 DESC"
How to use it
- Press Load DuckDB engine — a one-time ~8.8 MB compressed download, fetched only when you ask and cached by the browser afterwards.
- Drop CSV, TSV or Parquet files, up to 12 at 500 MB each. Every file becomes a view named after it, so cities.csv answers to FROM cities, and its card shows sniffed columns and a computed row count.
- Write SQL in the editor and press ⌘/Ctrl + Enter. Results land in a paged grid of exact values — big integers and decimals arrive as full-precision strings, and NULL is labeled, never blank.
- Copy the on-screen rows as CSV, or download the complete result as CSV or Parquet — both are produced by DuckDB's own COPY statement, not a reimplementation.
Load a CSV and interrogate it with real SQL — joins, GROUP BY, window functions — right in this tab. The engine is DuckDB compiled to WebAssembly, running in its own worker after an explicit download, with parquet and json support served from this site. Files register as views over in-memory readers; nothing is uploaded, and the page refuses URL-shaped sources outright.
How it works
The workbench runs DuckDB — a real analytical database — compiled to WebAssembly, inside a Web Worker the page spawns only after you press the gate button. Your files never become uploads: each one registers with the engine as a virtual file (CSV and TSV stream through the browser's file reader; Parquet is pinned as an in-memory buffer), and a view named after the file wraps read_csv_auto or read_parquet so plain FROM clauses work.
Parquet and JSON support are DuckDB extensions, and this page serves them from its own origin: at startup the engine is told to stop auto-installing anything, its extension repository is pointed at this site, and exactly two INSTALL/LOAD pairs run — parquet and json, signature-checked by DuckDB itself. There is no CDN in the path and no other repository the engine can reach.
Results come back as Arrow tables, and every cell is rendered from the exact accessor for its type: BIGINT and HUGEINT as full-precision integer strings, DECIMAL by placing the point over the unscaled integer, timestamps from the raw 64-bit value rather than a lossy float. The grid keeps the first 10,000 rows in pages of 100 and says so when it truncates; downloads bypass the cap by re-running your query through COPY. Before anything runs, a guard refuses URL-shaped sources and statements like INSTALL, LOAD, ATTACH or extension settings — measured in testing, a URL source otherwise makes the worker attempt a real network request, so the refusal is what keeps the zero-upload promise checkable.
Examples
SELECT country, COUNT(*) AS n, SUM(pop) AS pop FROM cities GROUP BY country ORDER BY pop DESC;country,n,pop
NL,4,2476203
BE,4,1188535
LU,2,169008
The sample file loaded as the view cities collapses to one row per country: four Dutch cities sum to 2,476,203 people. COUNT and SUM come back as BIGINT and print exactly — no scientific notation, no rounding.
SELECT c.country, COUNT(*) AS orders, SUM(o.amount) AS revenue FROM orders o JOIN customers c ON c.customer_id = o.customer_id GROUP BY c.country ORDER BY revenue DESC;country,orders,revenue
JP,4,367.75
NG,2,360.75
NL,3,305.75
IT,2,173.5
DK,1,74.5
Two files join like two tables, because to DuckDB that is what they are. The revenue column is a DOUBLE, so DuckDB prints its shortest exact form — 173.5, not the 173.50 the source file spelled.
SELECT order_date, customer_id, amount, SUM(amount) OVER (PARTITION BY customer_id ORDER BY order_date) AS running FROM orders ORDER BY customer_id, order_date LIMIT 8;order_date,customer_id,amount,running
2026-01-05,1,129.5,129.5
2026-01-12,1,89.75,219.25
2026-02-02,1,49.0,268.25
2026-02-25,1,99.5,367.75
2026-01-07,2,64.25,64.25
2026-01-20,2,31.5,95.75
2026-02-14,2,210.0,305.75
2026-01-15,3,240.0,240.0
A window function computes a running total that restarts at each customer_id partition — customer 2's first order starts again at 64.25. The order_date column was sniffed as a real DATE, which is why ORDER BY sorts it chronologically, not textually.
SELECT city, pop FROM read_csv_auto('cities.csv') WHERE pop > 500000 ORDER BY pop DESC;city,pop
Amsterdam,905234
Rotterdam,656050
The Hague,552995
Antwerp,536079
The generated view is a convenience, not a requirement: the registered file itself is addressable as 'cities.csv', quoted, through read_csv_auto — handy when you want sniffer options like delim or header set by hand.
SELECT 9223372036854775807 AS max_bigint, CAST('123456789.123456789' AS DECIMAL(38,9)) AS exact_decimal, 0.1::DOUBLE + 0.2::DOUBLE AS double_sum;max_bigint,exact_decimal,double_sum
9223372036854775807,123456789.123456789,0.30000000000000004
The precision contract in one row: the largest BIGINT and a 38-digit DECIMAL come back digit-for-digit, while the DOUBLE sum shows binary floating point exactly as it is. The grid renders what the engine computed — it never launders 0.30000000000000004 into 0.3.
Every example on this page is executed against the same engine that powers the tool, on every build.
Gotchas worth knowing
Big numbers stay exact
Grid cells are built from Arrow's exact accessors, so BIGINT, HUGEINT and DECIMAL values print every digit — 9223372036854775807 never degrades to 9223372036854776000, which is what forcing it through a JavaScript float would produce. Timestamps render from the raw 64-bit microsecond value for the same reason.
The sniffer is good, and overridable
Column types on each file card come from DuckDB's own sniff_csv — delimiter, header and types are detected, not assumed. When it guesses wrong, skip the view and call read_csv yourself with options like delim='|', header=false or types={'col': 'VARCHAR'}; the file name works anywhere a path does.
Parquet works because the extension ships with the page
DuckDB's parquet and json extensions are not compiled into the core wasm build — this site vendors both and installs them from its own origin at engine start, signatures verified. That is why read_parquet and COPY … (FORMAT PARQUET) work offline-grade, with no CDN fetch behind them.
Memory is the real file-size ceiling
Files over 500 MB are refused by name, and at most 12 can be registered at once — a Parquet file is held fully in tab memory, and CSV readers allocate while scanning, so the cap is an honesty line about what a browser tab can actually hold, not a licensing tier.
URLs are refused before the engine sees them
Point a query at 'https://…' and the page refuses it as a typed error. In testing, a URL-shaped source made the engine's worker attempt a real network request even with auto-loading disabled — so the guard refuses the shape outright, and the site's content security policy stands behind it.
Floating-point columns print like DuckDB, not like the file
A DOUBLE column renders each value's shortest exact form: 49.00 from the source file prints as 49.0 in CSV output and 173.50 as 173.5. When cents must stay cents, cast the column to DECIMAL(12,2) in your query and the text comes back fixed-width.
Common errors
Parser Error: syntax error at or near "SELEC"- The statement never parsed — here a mistyped keyword. DuckDB points a caret at the offending token in the lines that follow; the workbench shows the whole message untouched.
Catalog Error: Table with name missing_table does not exist!- Nothing registered under that name. Views are named after your files (cities.csv becomes cities), and each card prints its FROM name — DuckDB usually suggests the nearest match on the next line.
Binder Error: Referenced column "nope" not found in FROM clause!- The table exists but the column does not; the error lists candidate bindings from the actual sniffed schema. Check the column list on the file's card — sniffed names keep the file's exact spelling and case.
IO Error: No files found that match the pattern "missing.csv"- read_csv_auto was handed a name no registered file matches. Only files you loaded exist in the engine's world — there is no disk to scan and no URL fallback, by design.
FAQ
Is my data uploaded when I query it?
No. The engine executes inside a Web Worker in this tab, and files register as virtual files backed by browser memory — no request carrying your data exists to send. The page also refuses URL sources and extension installs inside SQL, so a query cannot be tricked into reaching the network; the devtools network panel will show nothing after the engine loads.
Why does the engine need a separate ~8.8 MB download?
That is DuckDB itself — a full analytical SQL engine compiled to WebAssembly — plus its worker and its parquet and json extensions, all served compressed from this site. It would be dishonest to hide that cost in page load, so nothing fetches until you press the button, and the browser caches it for next time.
Which DuckDB version runs here?
SELECT version() in the workbench answers v1.5.4, and the ready badge repeats whatever the engine reports rather than a number typed into copy. The wasm build is pinned exactly, so the same query keeps producing the same bytes until the page ships a deliberate upgrade.
How large a file can I load?
500 MB per file, twelve files at once — refused by name past either line, never silently truncated. The ceiling is browser-tab memory: Parquet files are held fully in memory and CSV scans allocate as they go, so a laptop with little free RAM may struggle before the cap does. For bigger data, the CLI line below runs the identical SQL on your machine.
Can I query Parquet files, and export to Parquet?
Both. read_parquet works on any Parquet file you load (each gets a view too), and Download Parquet re-runs your last query through COPY … (FORMAT PARQUET), producing a real Parquet file of the full result. The columnar format usually downloads far smaller than the equivalent CSV.
What SQL does the workbench understand?
DuckDB's SQL — joins of any shape, GROUP BY with real aggregate functions, window functions, CTEs, QUALIFY, list and struct types. The only statements this page locks are the ones that could reach outside the tab: INSTALL, LOAD, ATTACH, DETACH, EXPORT DATABASE and extension-repository settings.
Why does FROM cities work when my file is cities.csv?
Loading a file creates a view named from its stem — lowercased, punctuation collapsed to underscores, deduplicated with _2 when names collide — wrapping read_csv_auto or read_parquet on the registered file. The card shows the exact FROM name, and the quoted file name itself keeps working inside read_csv_auto.
Keyboard shortcuts
- ⌘/Ctrl + Enter
- Run the query against your loaded files.
- ⌘/Ctrl + S
- Download the full result as CSV.
- ⌘/Ctrl + C
- Copy the on-screen rows as CSV when nothing is selected.
- ⌘/Ctrl + Shift + C
- Copy the on-screen rows even mid-selection.
- t
- Toggle the light or dark theme.
- ? or ⌘/Ctrl + /
- List every keyboard shortcut in a dialog.
- Esc
- Close the open dialog.