Skip to content

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:

CommandDoes
ducklocal queryRun one SQL statement and print the result as JSON
ducklocal schemaList what there is to query, with columns; --stats for one relation's column statistics
ducklocal openShow SQL, a dashboard or data in the running window; --state reads back what it shows
ducklocal checkValidate a .dash dashboard
ducklocal commentsRead and answer the review comments people left on a dashboard
ducklocal proposeSuggest a dashboard change for the person to review and apply
ducklocal projectCheck a portable analysis folder and what it needs; relink fixes moved paths
ducklocal sessionRead back how an analysis got where it did
ducklocal presenceShow 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:

bash
# 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 --help

From 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 ​

bash
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 SQL and --sql-file FILE is 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 PATH opens an existing file, read-only by default. Omit it for a fresh in-memory database. --read-write requires --database and 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, the ATTACH … (STORAGE_VERSION) / COPY FROM DATABASE copy to make in that release; for SQLite, the ATTACH that reads it), ahead of DuckDB's own message.
  • --limit N is 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, reporting total_rows and total_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 md renders 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.

ValueRendered as
NULLNULL
integer, decimal, floatthe digits the value carries, exactly — a DECIMAL(38,10) keeps its scale, NaN/inf/-inf are spelled out
DATEYYYY-MM-DD
TIMESTAMPYYYY-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
TIMEHH:MM:SS[.ffffff]
INTERVALas DuckDB writes it: 1 month 2 days 00:00:00.000003
BLOB, GEOMETRY0x00ff, 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.

bash
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 large

PATHs 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:

json
{"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}
FieldMeaning
nameA 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
kindfile, glob, table, view or workbook
fromThe SQL that reads it, ready to follow FROM: a quoted path, or a quoted identifier where one is needed
rowsThe catalog's estimate for a table, exact for Parquet (from its footer), null where knowing would take a scan — CSV, JSON, views
column_count, columnsThe columns as DuckDB describes them
filesFor 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:

bash
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:

bash
# 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:

bash
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.

bash
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:

FieldMeaning
name, typeas DESCRIBE reports them
nullsrows where the column is NULL
distinctexact, not estimated
uniquepresent and true when distinct equals row_count
min, maxas text, so a DECIMAL keeps its digits
decimalsdigits after the point the column actually uses, which is not its declared scale
mediana value the column holds, not an interpolation between two of them
max_over_medianhow 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_daysdays 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:

bash
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 .dash file 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.
  • --sql or --sql-file (- reads stdin) opens a new query tab, titled --title. --run runs it once the PATHs have attached.
  • --no-launch fails with kind not_running instead 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.

bash
ducklocal open --state
ducklocal open --state sales.dash
ducklocal open --state --active
json
{"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"}]}}]}
  • database is the open database file, or ":memory:"; attached lists the files the window registered as views, with the view name. A CLI query cannot read those views — use the path.
  • A query tab has sql, the editor's text whether or not it ran, and result: status is empty, running, rows, affected, failed, explain or profile. For rows: columns, row_count, truncated, preview (the first 20 rows as the grid shows them, as strings), elapsed_ms and the sql that produced them; for affected, count and elapsed_ms; for a failure, error.
  • Once the person has picked columns in the Chart tab, a result also has chart: type, x, y, split, and dash, a query and a plot block that draw the same chart in a dashboard.
  • explain is what Explain shows: plan, DuckDB's EXPLAIN output as an array of text lines, and elapsed_ms.
  • profile is what Profile shows (see SQL editor): plan, the operator tree EXPLAIN ANALYZE measured, as an array of roots, each {operator, seconds, rows, estimated, children} — seconds is the operator's own time with its children excluded, rows what it produced, estimated the optimizer's guess at rows (null where the plan states none), children the same shape again. operator_seconds is the sum of every operator's own time, which is what the shares add up to, not the wall clock; elapsed_ms is the whole run.
  • A dashboard tab has path, unsaved, and dashboard: its comments_file path, plots (name, type, title, query, rows, open_comments, and failure when one cannot draw) and its filters with what each has picked. value: null with picked: true means 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.

hcl
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
}
bash
ducklocal check dashboard.dash
ducklocal check dashboard.dash --database warehouse.duckdb
ducklocal check dashboard.dash --summary

A 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 --database the queries run in memory, which is all a dashboard over its own source files needs. A query that reads a table no source defines may be meant for a database the check was not given: it is listed under unresolved (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 under unresolved went 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:

json
{"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.

bash
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}. --all includes resolved ones.
  • Each thread has id, plot, and what the agent needs to find it in the spec: plot_title, plot_query, and plot_lines, the first and last line of the plot block (null when the spec does not parse). plot_missing is true when no plot of that name is left. Then resolved, and comments, each with author (user or agent), on an agent's comment its name (the name presence shows), text and at (RFC 3339, local time).
  • --reply ID --text TEXT adds the agent's answer. A reply to a resolved thread reopens it, unless --resolve is given too.
  • --resolve ID and --reopen ID change a thread's state; --add PLOT --text TEXT starts a thread, signed as the agent.
  • A thread's number works as its ID: --reply 1 means c1. --add refuses 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:

bash
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
  • --doing accepts reading, editing, or idle (done is an alias for idle).
  • --plot takes comma-separated plot names. With editing, new names are allowed and appear as placeholders. With reading or idle, an unknown name is exit 2.
  • The displayed name comes from --agent, then DUCKLOCAL_AGENT, then the detected agent environment (Claude, Codex, Cursor or Gemini), falling back to Agent.
  • 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 check reports, 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. and plot., each with a text edit that replaces the word being typed.
  • Hover: documentation for blocks, attributes and references.
  • Go to definition: from a query.name or plot.name reference 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+):

lua
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:

json
{"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 valueJSON encoding
NULLnull (never the string "NULL")
Boolean, finite floatJSON boolean/number
Integer within ±9,007,199,254,740,991JSON number
Larger integer, including HUGEINT/UHUGEINT{"encoding":"integer","value":"9007199254740992"}
Decimal{"encoding":"decimal","value":"123.450"} with exact decimal text
Text, enumJSON 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, arrayJSON 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:

json
{"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 ducklocal on your PATH, 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):

bash
# 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.

Released under the Apache-2.0 License.