AI CLI and official skill
For the task-oriented overview, see SQL for AI agents on local data. This page is the complete command and output reference.
Run without a window
ducklocal --help lists nine subcommands:
| Command | Does |
|---|---|
ducklocal query | Run one SQL statement and print the result as JSON |
ducklocal schema | List what there is to query, with columns; --stats for one relation's column statistics |
ducklocal open | Show SQL, a dashboard or data in the running window; --state reads back what it shows |
ducklocal check | Validate a .dash dashboard |
ducklocal comments | Read and answer the review comments people left on a dashboard |
ducklocal propose | Suggest a dashboard change for the person to review and apply |
ducklocal project | Check a portable analysis folder and what it needs; relink fixes moved paths |
ducklocal session | Read back how an analysis got where it did |
ducklocal presence | Show who is working on an open dashboard and where an edit will land |
ducklocal lsp, the language server that editors spawn for .dash files, and ducklocal profile, the legacy alias for schema --stats, still work but are omitted from --help.
propose, project and session, and the --restricted, --wait and --if-revision options, arrived in 0.4.0 and are covered in Agent workflows.
ducklocal query executes SQL without initializing the GUI, history, registered files, language settings, or the GUI global connection. Each invocation creates a dedicated connection. Invoking DuckLocal without commands—or only with file paths, directories, and glob patterns—opens the regular GUI. To open a GUI path whose name matches a command (query, schema, open, check, comments, propose, project, session, presence, profile, or lsp), prefix it with ./, such as ./query.
Get the command
DuckLocal supports macOS 12+ on Apple silicon, Windows (x86_64), and Linux (x86_64). The CLI runs directly from the application executable, so you do not need to install a separate DuckDB CLI binary.
The quickest way onto your PATH is from the app (0.1.1 and later): open Command line & AI… — the robot button in the title bar, or the DuckLocal menu — and click Install command. The same dialog installs the skill for AI agents; see Install the official skill. If you skip the install, call the executable where it is:
# macOS
/Applications/DuckLocal.app/Contents/MacOS/ducklocal --help
# Linux: wherever you extracted the tarball
./ducklocal-linux-x86_64/ducklocal --help
# Windows (PowerShell): wherever you extracted the zip
.\ducklocal-windows-x86_64\ducklocal.exe --helpFrom a source checkout, cargo build --locked builds ./target/debug/ducklocal. The examples below assume ducklocal is on your PATH. Older releases may not have query; check --help first. JSON and Parquet support are bundled, not downloaded at query time.
Query options
ducklocal --help
ducklocal --version
ducklocal query --help
ducklocal query --sql "SELECT 1 AS n"
ducklocal query --sql "SELECT 1 AS n" --format md
ducklocal query --sql-file analysis.sql --limit 100
ducklocal query --sql-file - < analysis.sql
ducklocal query --database warehouse.duckdb --sql "SHOW TABLES"- Exactly one of
--sql SQLand--sql-file FILEis required. Files must be UTF-8;-reads stdin. SQL must contain exactly one statement. DuckDB's parser validates this before opening the target database or executing anything; comments, quoted semicolons, and trailing semicolons work. Scripts are rejected, not partially executed. --database PATHopens an existing file, read-only by default. Omit it for a fresh in-memory database.--read-writerequires--databaseand explicitly permits writes and creating a database (not parent directories). Database errors never fall back to memory. When DuckDB refuses a file, the error message starts with the reason DuckLocal reads from the file's header — written by a newer DuckDB, a v0.9-era file, a SQLite file, or not a database at all — and what to run instead (for a newer file, theATTACH … (STORAGE_VERSION)/COPY FROM DATABASEcopy to make in that release; for SQLite, theATTACHthat reads it), ahead of DuckDB's own message.--limit Nis a positive integer, defaults to 1000, and caps returned rows. A 2,000,000-cell budget also applies. After the cap, the CLI keeps counting up to 100,000 more rows without encoding their values, reportingtotal_rowsandtotal_rows_exact. This limits output, not computation or memory: sorting and aggregates can still process the whole input. An execution error encountered after the output cap still fails the command.--format json(the default) is the contract below.--format mdrenders the same result as a Markdown table, for a document or an agent reading it. Nothing else changes: the same SQL, the same limits, the same errors.- Repeated/unknown flags, missing values, empty SQL, and invalid limits are errors. Options use separate values, not
--flag=value. Relative paths resolve against the process working directory, not the SQL file directory. Quote shell paths; inside SQL, double apostrophes ('O''Brien.csv'). SQL identifiers use double quotes.
Read-only is not a filesystem or network sandbox. SQL such as COPY can write files even when the database is read-only. Authorize output paths, overwrites, database mutations, extension installation, and external access before execution. DuckLocal does not automatically install extensions or retry operations. Explicit INSTALL/LOAD SQL remains possible with authorization; installed extensions may autoload. A failed command can already have performed side effects (for example before an output failure); inspect the destination before retrying.
Markdown output
--format md is a rendering, not a second contract: the values are the ones the JSON holds, written the way the results grid shows them.
| Value | Rendered as |
|---|---|
NULL | NULL |
| integer, decimal, float | the digits the value carries, exactly — a DECIMAL(38,10) keeps its scale, NaN/inf/-inf are spelled out |
DATE | YYYY-MM-DD |
TIMESTAMP | YYYY-MM-DD HH:MM:SS[.fffffffff], with the fraction only when the value has one, and as many digits as it has. No timezone is appended; --format json keeps the raw count |
TIME | HH:MM:SS[.ffffff] |
INTERVAL | as DuckDB writes it: 1 month 2 days 00:00:00.000003 |
BLOB, GEOMETRY | 0x00ff, elided with … when long |
LIST, STRUCT, MAP | [1, 2], {x: 1}, elided with … past 120 characters |
A | in a cell is escaped and a newline becomes <br>, so a value cannot break the table it is in. A result with no rows is followed by _0 rows._, and a result that was cut short by --limit or the cell budget by _Truncated at 2 of 5000 rows, so this is not the whole answer._ (of at least … past the 100,000-row count) — a preview should not read like a complete answer. Errors are unchanged: JSON on stderr, empty stdout.
Map what can be queried
ducklocal schema answers "what is there, and what are its columns?" in one call, where DESCRIBE answers it one relation at a time. It is the first thing an agent should run.
ducklocal schema ./data/ # files, folders, globs
ducklocal schema --database warehouse.duckdb # tables and views
ducklocal schema warehouse.duckdb --table orders # one relation, whole
ducklocal schema ./logs/ --full # everything, however largePATHs expand the way a drop on the window does: a folder's data files recursively (hidden ones skipped, at most 256), a glob's matches, and a database file as if given with --database. One JSON object comes back:
{"detail":"full","relations":[{"name":"data/orders.csv","kind":"file","from":"'data/orders.csv'","rows":null,"column_count":2,"path":"data/orders.csv","columns":[{"name":"channel","type":"VARCHAR"},{"name":"amount","type":"BIGINT"}]}],"without_columns":0}| Field | Meaning |
|---|---|
name | A file's path as given (relative to the working directory when under it), or a table's name as SQL on this connection reaches it: bare in the default schema, schema.table elsewhere |
kind | file, glob, table, view or workbook |
from | The SQL that reads it, ready to follow FROM: a quoted path, or a quoted identifier where one is needed |
rows | The catalog's estimate for a table, exact for Parquet (from its footer), null where knowing would take a scan — CSV, JSON, views |
column_count, columns | The columns as DuckDB describes them |
files | For a glob: how many files it matches |
A glob whose matches are all one format is one relation, read the way a .dash source reads it (ducklocal schema 'logs/*.csv'); --stats profiles it as one. A glob over mixed formats, and a folder, list their files one by one.
A small catalog comes back whole ("detail": "full"). Past 12 relations or 400 columns it is a summary ("detail": "summary") instead. The 5 relations with the most rows keep their columns; the rest list name, kind, rows and column count, and without_columns counts them. To get more, hint names the two ways: --table NAME for one relation (matched by its name, its bare table name, or a file's name) and --full for all. A workbook is listed with its sheets and no columns, since it has no table function; --stats reads its first sheet. A PATH that does not exist, or a --table that names nothing, is exit 2.
Explore and convert files
After confirming the actual file exists:
ducklocal query --sql "DESCRIBE SELECT * FROM 'sales.csv'"
ducklocal query --sql "SELECT * FROM 'sales.csv'" --limit 20
ducklocal query --sql "SELECT sum(amount) AS total FROM 'sales.csv'"
# Only after authorizing this output path:
ducklocal query --sql "COPY (SELECT * FROM 'sales.csv') TO 'sales.parquet' (FORMAT PARQUET)"
ducklocal query --sql "SELECT count(*), sum(amount) FROM 'sales.parquet'"
ducklocal query --sql "DESCRIBE SELECT * FROM 'events.json'"Do not guess amount exists: run ducklocal schema or DESCRIBE first. Compute full aggregates in SQL rather than summing a truncated preview. CLI calls do not share the GUI's registered views or each other's in-memory tables. Persist explicitly when needed:
# These are separate statements and separate invocations, with explicit write permission.
ducklocal query --database warehouse.duckdb --read-write --sql "CREATE TABLE sales AS SELECT * FROM 'sales.csv'"
ducklocal query --database warehouse.duckdb --sql "SELECT count(*) FROM sales"A database lock conflict is an error. Close the other writer (including the GUI), or use an authorized copy; never delete lock files or silently create a different database.
JSONL and nested JSON
read_ndjson_objects is the reader for newline-delimited JSON (one object per line, .jsonl/.ndjson); read_json/read_json_auto read JSON documents (an array or a single object). Name the reader explicitly for a JSONL file rather than relying on auto-detection. json_extract_string(col, '$.a.b[0].c') walks a nested path — object keys and array indexes — and answers VARCHAR:
ducklocal query --sql "SELECT json_extract_string(event, '$.payload.items[0].sku') AS sku, count(*) AS n FROM read_ndjson_objects('events.jsonl') GROUP BY 1 ORDER BY n DESC"Use json_extract instead when the leaf is not a string and you want it as JSON.
Column statistics
ducklocal schema --stats answers the questions that decide a chart, a scale and a number format, before anything is built on them. DESCRIBE says a column is a DOUBLE; its statistics say it uses two decimals, that its largest value is a thousand times its median, and that its dates skip eleven days in the middle.
ducklocal schema sales.csv --stats
ducklocal schema ./exports/ --table orders.parquet --stats
ducklocal schema --database warehouse.duckdb --table "my orders" --stats--stats profiles one relation: the only one the arguments name, or the one --table picks — two or more without --table is exit 2, with a hint saying so. It cannot be combined with --full. ducklocal profile TARGET [--database PATH], from earlier releases, is the same report and still works.
The relation is a data file — csv, tsv, txt, parquet, json, ndjson, jsonl — or a workbook (xlsx, xls, xlsb, ods; its first sheet is imported as a TEMP table and profiled), or, with --database, a table or view name. A dotted name is read as schema.table and each part is quoted, so a name with a space or a capital letter works as written. A relation that names nothing is an argument error (exit 2), not a SQL one.
One JSON object comes back: target, relation (the SQL the statistics ran against), row_count, elapsed_ms, and columns in the relation's own order. Per column:
| Field | Meaning |
|---|---|
name, type | as DESCRIBE reports them |
nulls | rows where the column is NULL |
distinct | exact, not estimated |
unique | present and true when distinct equals row_count |
min, max | as text, so a DECIMAL keeps its digits |
decimals | digits after the point the column actually uses, which is not its declared scale |
median | a value the column holds, not an interpolation between two of them |
max_over_median | how many times the largest value is bigger than the median — the number that decides a linear axis from a logarithmic one |
covered_days, span_days, missing_days | days the column names, days between its ends, and the difference: 0 is a continuous series, anything else is holes a line would draw over |
decimals, median and max_over_median appear on numeric columns; the day fields on dates and timestamps. LIST, STRUCT, MAP and UNION columns report nulls only — min and max are not defined on them.
A text column that holds numbers and one short word, such as R's NA, also reports null_marker and null_marker_rows. The report then adds a hint naming the read_csv call that reads the column as numbers.
Statistics are exact and read the whole relation, so they cost a full scan. --limit does not apply: a profile of a sample is not a profile.
Show work in the running window
ducklocal open hands something to the DuckLocal window that is already running — starting one if none is — and brings it forward. It is how an agent puts its work in front of a person, where they can carry on from it:
ducklocal open --title "Revenue by channel" --run --sql "SELECT channel, sum(amount) AS total FROM 'orders.csv' GROUP BY 1"
ducklocal open sales.dash # a dashboard tab
ducklocal open ./data/ --sql-file analysis.sql
echo "SELECT 42" | ducklocal open --sql-file - --no-launch- PATHs open the way a drop on the window opens them: a
.dashfile as a tab, a DuckDB database file as the window's database, data files, folders and globs attached as views. Relative PATHs resolve against the command's directory. A request that arrives while the window is still starting waits until its startup open has finished. --sqlor--sql-file(-reads stdin) opens a new query tab, titled--title.--runruns it once the PATHs have attached.--no-launchfails with kindnot_runninginstead of starting a window — for a machine where a window appearing unasked would be unwelcome.
Success prints {"delivered": true, "launched": BOOL}: delivery, not a result. What opens runs in the window's own session — its connection, its attached views, its history — and any error shows there, not on stdout. Exit 2 for arguments; 1 with kind not_running (no window reachable), refused (the window rejected the request) or launch.
The window listens on a loopback port. It writes the port and a random token to a file only your account can read (see Settings and app data); another user on the machine, or a web page aiming at localhost, cannot reach it.
Read what the window shows
ducklocal open --state goes the other way: it opens nothing, never starts a window, and prints what the running window shows — so an agent can pick up where a person left off, instead of asking them to paste their query.
ducklocal open --state
ducklocal open --state sales.dash
ducklocal open --state --active{"database":":memory:","attached":[{"path":"/Users/me/data/orders.csv","name":"orders","rows":224}],"active_tab":1,"tabs":[{"kind":"query","title":"Query 1","active":false,"sql":"SELECT …","result":{"status":"rows","columns":[…],"row_count":5,"truncated":false,"preview":[["Web","132771.24"],…],"sql":"SELECT …","elapsed_ms":4}},{"kind":"dashboard","title":"sales","active":true,"path":"/Users/me/data/sales.dash","unsaved":false,"dashboard":{"running":false,"error":null,"stale":null,"plots":[…],"filters":[{"name":"channel","plot":"revenue_by_channel","column":"channel","picked":true,"value":"Web"}]}}]}databaseis the open database file, or":memory:";attachedlists the files the window registered as views, with the viewname. A CLI query cannot read those views — use thepath.- A query tab has
sql, the editor's text whether or not it ran, andresult:statusisempty,running,rows,affected,failed,explainorprofile. For rows:columns,row_count,truncated,preview(the first 20 rows as the grid shows them, as strings),elapsed_msand thesqlthat produced them; foraffected,countandelapsed_ms; for a failure,error. - Once the person has picked columns in the Chart tab, a result also has
chart:type,x,y,split, anddash, aqueryand aplotblock that draw the same chart in a dashboard. explainis what Explain shows:plan, DuckDB'sEXPLAINoutput as an array of text lines, andelapsed_ms.profileis what Profile shows (see SQL editor):plan, the operator treeEXPLAIN ANALYZEmeasured, as an array of roots, each{operator, seconds, rows, estimated, children}—secondsis the operator's own time with its children excluded,rowswhat it produced,estimatedthe optimizer's guess atrows(nullwhere the plan states none),childrenthe same shape again.operator_secondsis the sum of every operator's own time, which is what the shares add up to, not the wall clock;elapsed_msis the whole run.- A dashboard tab has
path,unsaved, anddashboard: itscomments_filepath,plots(name, type, title, query, rows,open_comments, andfailurewhen one cannot draw) and itsfilterswith what each has picked.value: nullwithpicked: truemeans the NULL value was picked.
Use --state sales.dash to keep only tabs showing that dashboard, or --state --active for only the active tab. A path that is not open returns exit 1, kind not_open, with the open dashboards in the hint. active_tab is the active tab's index within the returned tabs, or null if it is not among them; database and attached remain included.
--state accepts either one .dash PATH or --active, not both. --no-launch is accepted and changes nothing. Other options, including --sql and --run, are invalid (exit 2). Exit 1 with kind not_running when no window is up, and timeout when it does not answer within 5 seconds.
Check a dashboard spec
The format itself — blocks, attributes, plot types, SQL rules and common messages — is described in Dashboards (.dash); this section covers the command.
A .dash file declares a dashboard the way Terraform declares infrastructure — queries and plots as blocks, references between them — rather than scripting one. It is easy for a person or an agent to write and diff.
query "latency" {
sql = <<SQL
SELECT timestamp, service, avg(latency) AS latency
FROM logs
GROUP BY timestamp, service
SQL
}
plot "latency" {
type = "line"
query = query.latency
x = timestamp
y = latency
series = service
}ducklocal check dashboard.dash
ducklocal check dashboard.dash --database warehouse.duckdb
ducklocal check dashboard.dash --summaryA source block names data files, relative to the .dash file, for the queries to read. A query block holds one sql attribute — one read-only statement, as a heredoc or a string. SELECT, WITH, FROM-first, VALUES, SHOW, DESCRIBE, SUMMARIZE and PIVOT are accepted; anything that could write — DDL, DML, COPY, ATTACH, INSTALL — is a diagnostic, and the GUI refuses to run it, because a dashboard's queries run whenever the file is opened. A plot block holds type (one of line, bar, stacked_bar, area, scatter, pie, heatmap, histogram, map, table, card, text), query (a reference like query.latency to a query block in the same file), its columns, and optional title and width. A filter block makes a bar chart or a table clickable, and a query reads the pick as $name. # and // comment to end of line. Dashboards (.dash) has every attribute.
A text plot is the exception: it holds title, text, or both and takes no query. Cards can also use note and tone; see Cards and text panels.
First the static checks: each query's SQL through the real DuckDB parser on a throwaway connection, the way query validates before running, and every $name against the filter blocks — the SQL is parsed, and run, with each $name as TRUE, which is what the dashboard runs before anything is picked.
Then every query runs, exactly as opening the dashboard would run it — read-only, with its sources, through the same code path and row budget — and each plot's columns are checked against the columns the query actually returns: a y that is not numeric is an error for every chart type, so are a map's non-numeric coordinates or size, and so is a filter column its plot's query does not return.
- Without
--databasethe queries run in memory, which is all a dashboard over its ownsourcefiles needs. A query that reads a table no source defines may be meant for a database the check was not given: it is listed underunresolved(query, line, reason) rather than failed, and its plots' columns go unchecked. - With
--database PATH(existing file, read-only) they run on that database. Pass it whenever the queries read a database's tables: anything underunresolvedwent unchecked.
Every query that reads a filter is also bound with a picked value, without running that variant. This catches a filter column missing from a consuming query, even when the initial $name = TRUE version works. A filter must reference a column returned by its picking plot too.
Running rather than only planning matters: some failures, such as a cast the build cannot perform or a value that will not convert, appear only once rows are read, and a check that passes them would hand over a dashboard that cannot draw. Queries run in full, so a check against a large database takes as long as opening the dashboard does.
Success is one JSON object listing the file's sources (with each resolved path), queries (with their result columns and rows, how many each returned with nothing picked, plus truncated: true when the dashboard's row budget cut it off), filters (with the queries that read each), plots, unresolved, and empty — the queries that ran and returned no rows. An empty query passes every other check, but it is usually a regex, filter or join that matched nothing, so look at each one. With --summary, success is one compact JSON line:
{"file":"sales.dash","ok":true,"sources":1,"queries":3,"filters":1,"plots":6,"unresolved":[],"empty":[]}The four block fields are counts; unresolved lists the queries that could not be checked, and empty those that returned no rows.
A faulty block is left out while valid blocks continue through validation. Spec errors and query failures are gathered in the same run, one file:line: message each. If any spec diagnostic exists, exit is 2 with kind spec; if only SQL execution or binding fails, exit is 1 with kind sql. Database and I/O failures also exit 1. --summary does not hide diagnostics.
The same file opens as a dashboard tab in the GUI — ducklocal dashboard.dash, ducklocal open dashboard.dash, or drag it onto the window — with plots on a 12-column grid and a plot that cannot draw showing the reason inline. See Dashboards (.dash).
Review comments on a dashboard
People review a dashboard by commenting on its plots in the window — hover a plot, click the speech bubble (see Review with comments). ducklocal comments is the agent's side: it reads the threads and answers them, without a window.
ducklocal comments sales.dash [--all]
ducklocal comments sales.dash --reply c1 --text "Sorted by revenue, descending" [--resolve]
ducklocal comments sales.dash --resolve c1
ducklocal comments sales.dash --reopen c1
ducklocal comments sales.dash --add revenue_by_channel --text "Is this net of refunds?"- With no action, prints the open threads:
{file, comments_file, open, threads}.--allincludes resolved ones. - Each thread has
id,plot, and what the agent needs to find it in the spec:plot_title,plot_query, andplot_lines, the first and last line of theplotblock (nullwhen the spec does not parse).plot_missingistruewhen no plot of that name is left. Thenresolved, andcomments, each withauthor(useroragent), on an agent's comment itsname(the namepresenceshows),textandat(RFC 3339, local time). --reply ID --text TEXTadds the agent's answer. A reply to a resolved thread reopens it, unless--resolveis given too.--resolve IDand--reopen IDchange a thread's state;--add PLOT --text TEXTstarts a thread, signed as the agent.- A thread's number works as its ID:
--reply 1meansc1.--addrefuses a plot the spec doesn't have (exit 2), and the hint lists its plots. - An action prints
{thread}, as changed.
The threads are kept beside the spec, in FILE.comments.json. A running window notices a change within a second and shows it on the plot. An unknown thread id is exit 2, with a hint to list the threads; a spec file that does not exist is exit 1.
A review, start to finish: read the open threads, edit the spec, run ducklocal check, then answer each thread with what changed and --resolve it. Use --reply without --resolve to ask the person something back.
Show agent activity
check and comments automatically tell a running window which dashboard the agent is working on. presence can announce an edit before it happens:
ducklocal presence sales.dash --doing editing --plot revenue_by_channel,daily_revenue
ducklocal presence sales.dash --doing reading --agent "Analyst"
ducklocal presence sales.dash --doing idle--doingacceptsreading,editing, oridle(doneis an alias foridle).--plottakes comma-separated plot names. Withediting, new names are allowed and appear as placeholders. Withreadingoridle, an unknown name is exit 2.- The displayed name comes from
--agent, thenDUCKLOCAL_AGENT, then the detected agent environment (Claude, Codex, Cursor or Gemini), falling back toAgent. - Output is
{delivered}. No running window produces{"delivered":false}without an error or launching a window.
The name stays in the dashboard's corner while commands keep the session active. Click it for the activity log. Thirty seconds of silence or --doing idle ends the session; changed and added plots remain marked until viewed, and removed-plot notices until dismissed. See See an agent at work.
Edit a dashboard spec with LSP
ducklocal lsp is a Language Server Protocol server for .dash files, over stdio, for editors to spawn. It gives any LSP-capable editor what the GUI's own spec editor has:
- Diagnostics: the ones
checkreports, published on every open and change (full-document sync, all of them severity Error — they are mistakes, not style). - Completion: block types, attribute names, plot types and the names after
query.andplot., each with a text edit that replaces the word being typed. - Hover: documentation for blocks, attributes and references.
- Go to definition: from a
query.nameorplot.namereference to its block's name.
With --database PATH each query's SQL is additionally validated by the real DuckDB parser, the way check --database does.
Point your editor's generic LSP support at ducklocal lsp for *.dash files — the command takes no document arguments; the editor speaks to it on stdio. In Neovim (0.10+):
vim.api.nvim_create_autocmd("FileType", {
pattern = "dash",
callback = function(event)
vim.lsp.start({
name = "ducklocal",
cmd = { "ducklocal", "lsp" },
root_dir = vim.fs.dirname(vim.api.nvim_buf_get_name(event.buf)),
})
end,
})In VS Code, any generic-LSP extension does the same with command ducklocal, arguments ["lsp"], document selector dash. Exit codes: 0 after a clean shutdown/exit, 1 when the client goes away without one, 2 for a bad command line or a failed protocol handshake.
JSON contract
Successful queries write exactly one JSON object to stdout, followed by a newline:
{"columns":[{"name":"n","type":"Int32"}],"rows":[[1]],"row_count":1,"truncated":false,"total_rows":1,"total_rows_exact":true,"elapsed_ms":1}columns and each row preserve column order and duplicate column names. type is the Arrow debug representation, not the original DuckDB SQL type name (for example HUGEINT and DECIMAL can share an Arrow carrier). row_count counts returned rows. total_rows counts the rows produced, including those counted after the output cap; total_rows_exact: false means counting stopped after up to 100,000 additional rows, so that total is a lower bound. Neither count is an affected-row count. elapsed_ms measures preparation, execution, and row encoding, not file loading/connection setup. Zero-row results retain metadata. DDL/DML expose the actual DuckDB result, including Count columns: INSERT/COPY commonly return [[N]]; CREATE TABLE commonly returns no rows. They are not classified from the first SQL keyword.
Cell encodings:
| SQL value | JSON encoding |
|---|---|
| NULL | null (never the string "NULL") |
| Boolean, finite float | JSON boolean/number |
| Integer within ±9,007,199,254,740,991 | JSON number |
| Larger integer, including HUGEINT/UHUGEINT | {"encoding":"integer","value":"9007199254740992"} |
| Decimal | {"encoding":"decimal","value":"123.450"} with exact decimal text |
| Text, enum | JSON string |
| Blob, geometry | {"encoding":"hex","value":"00ff"}; lowercase bytes, geometry uses WKB |
| Date | {"encoding":"date","unit":"Day","value":"1","iso":"1970-01-02"}; days since 1970-01-01 |
| Timestamp | {"encoding":"timestamp","unit":"Nanosecond","value":"123456789","iso":"1970-01-01T00:00:00.123456789"}; signed count since Unix epoch; unit may also be Second/Millisecond/Microsecond |
| Time | {"encoding":"time","unit":"Microsecond","value":"123456","iso":"00:00:00.123456"}; count since midnight |
| Interval | {"encoding":"interval","months":1,"days":2,"nanos":"3000"} |
| NaN, positive/negative infinity | {"encoding":"float","value":"NaN"} / "inf" / "-inf" |
| List, array | JSON array, recursively encoded (including nested nulls) |
| Struct | {"encoding":"struct","fields":[["name",VALUE],...]} |
| Map | {"encoding":"map","entries":[[KEY,VALUE],...]}; not a JSON object, so keys retain their types |
| Union | {"encoding":"union-value","value":VALUE}; active value only, member tag is not retained |
Temporal values are exact counts, so no fractional precision is lost; date/timestamp infinity sentinels remain raw counts. Beside each, iso is the same value as ISO 8601 text for reading, with as many fraction digits as the unit holds. A timestamp from a column with a time zone (Arrow metadata) is a UTC instant, and its iso ends in Z. --format md renders dates and timestamps readably too. Because the pinned DuckDB Arrow wrapper cannot distinguish nested HUGEINT, UHUGEINT, and DECIMAL(38,0), non-null occurrences produce a clear error instead of silently changing sign or precision. Explicitly cast such fields to VARCHAR in SQL. Unknown unsupported types also produce an error; do not mistake an error for an empty result. Union encoding is an active-value representation, not a lossless union round trip.
Errors write one JSON object to stderr, leaving stdout empty:
{"error":{"kind":"argument","message":"Unknown query option: --limt","hint":"Did you mean --limit?"}}hint is there when the next step is known, in this CLI's own terms: the flag a misspelled one was meant to be, --read-write for a write refused on a read-only database, ducklocal schema for a table that does not exist, TRY_CAST for a value that will not convert, splitting a script that has more than one statement, or AS for a keyword used as a bare column alias (SELECT count(*) AS minutes, or quote it). An agent should act on it before rewriting the SQL. It is absent when there is nothing to add to DuckDB's own message, such as a syntax error.
Exit codes: 0 success, 2 invalid arguments/statement count, 1 SQL, database, I/O, or output failure. Error kinds are argument, sql, database, io, output, spec for check, and not_running, not_open, refused, timeout or launch for open. Check exit status before parsing stdout and inspect truncated before reporting completeness. Help/version are plain text exceptions. Transport failures such as a closed stdout pipe cannot guarantee an empty/complete output stream.
Install the official skill
The skill teaches an agent the habits that keep its answers honest: inspect the schema, preview a bounded sample, compute aggregates in SQL, check truncated, and answer from returned values. It covers schema inspection, column statistics, conversions, database queries, .dash dashboards and their review comments, and handing work to the window and reading it back; it does not provide S3 browsing, spatial-specific commands, session memory, or an MCP server.
From the app. Open Command line & AI… (the robot button in the title bar, or the DuckLocal menu):
- Install command puts
ducklocalon yourPATH, so an agent's shell can run it. - Add to Claude Code writes the skill to
~/.claude/skills/ducklocal, where Claude Code finds it in every project. Only the skill's own two files are written; anything else in that folder is kept. When a DuckLocal update ships a newer skill, the dialog offers Update skill. - Copy brief copies a short explanation for any other agent — Codex, Cursor, a chat window — naming the command and how to use it.
By hand. The skill is skills/ducklocal. To add it to a single project instead (example for Claude Code):
# Run from the DuckLocal checkout; replace the destination with your project.
mkdir -p /path/to/your-project/.claude/skills
cp -R skills/ducklocal /path/to/your-project/.claude/skills/Inspect an existing destination first rather than overwriting local changes. Other agents can use the same SKILL.md plus references/cli.md in their supported skills directory.
