# Results and charts

> Filter and copy DuckLocal query results, profile every column, draw bar, line, scatter, pie and map charts, and export results to CSV or Parquet.

Source: https://ducklocal.app/docs/results-and-charts

**[中文](zh/results-and-charts.md)**

The panel under the editor has three tabs: **Results**, **Chart** and
**Overview**. To the right of the tabs sit the result's size
(`N rows · M columns`) and the **Export CSV** and **Export Parquet** buttons.

The **Results** tab also shows what the editor's **Explain** and **Profile**
buttons produce — see [Explain and Profile output](#explain-and-profile).

## The results grid

The grid starts with a `#` row-number column, followed by the query result columns. Numeric columns are right-aligned. Initial widths are calculated from the headers and first 200 rows, counting Chinese or Japanese characters as two characters wide. Widths range from 80 to 360 pixels at the default [interface size](settings-and-data.md#interface-size) and scale proportionally at other sizes. Drag column borders to adjust the widths; the `#` column cannot be resized.
When the columns are wider than the panel, a horizontal scrollbar stays in
view under them.

Click the arrows in a column's header to sort the loaded rows by it:
descending, then ascending, then back to the query's order. Number columns sort
as numbers, everything else as text (which puts ISO dates and times in order),
`NULL` sorts last, and rows with equal values keep the query's order. A
[filter](#filtering) keeps the sort, and the next result starts unsorted. The
sort is client-side, like the filter: to sort more rows than were loaded, use
`ORDER BY`.

### Cell rendering

| Value | Shown as |
| --- | --- |
| `NULL` | the literal `NULL`, in a muted color |
| BLOB | `<blob N bytes>` |
| GEOMETRY | `<geometry N bytes>` |
| Float with an integer value | `3.0` |
| Timestamp | `2026-09-17 04:21:09.5` — **in UTC, not local time**; the fraction has as many digits as the value (nine for TIMESTAMP_NS) |
| Interval | DuckDB notation, such as `1 day 02:00:00` |
| List / struct / map | pretty-printed |

The UTC rendering is worth remembering if you are eyeballing timestamps rather
than comparing them in SQL.

### Filtering

The filter box does a case-insensitive substring match **across all cells** of
the loaded rows. It is client-side: it never queries the database, and it
only covers the rows that were loaded, not the true result size.

The filter applies when you press Enter or clear the box, not on every
keystroke. While a filter is applied, the grid reports `visible / total rows`
beside it.

## Row limits

Results are truncated at whichever of these binds first:

- **100,000 rows**
- **2,000,000 cells** — so a 400-column result produces only about 5,000
  rows

The cell budget exists because a row cap alone does not bound the work: a
`PIVOT` over a high-cardinality column returns one column per distinct value,
and formatting tens of millions of cells would look like a hang.

Truncation is reported in a banner that quotes the real number of rows that
were loaded. There is **no pagination and no "load more"** — narrow the
query instead.

## Copying

Hovering a cell reveals a copy button that puts that cell's text on the
clipboard. Right-click a cell for **Copy cell**, **Copy row** (tab-separated),
or **Copy column** (one value per line, in the displayed order). Right-click
a row number to copy its row. Copies follow the current filter and sort.
Dashboard tables offer the same three copy actions. There is no range
selection and no "copy as SQL"; for more than a row or a column, use
[Export](#export).

## Export

`Export CSV` and `Export Parquet` open a small dialog with a path field,
pre-filled with `~/Desktop/export.csv` or `~/Desktop/export.parquet`.

Two things to know:

- The export **re-runs the SQL that produced the current rows**, so the file
  holds the query's full result set — not the filtered view, and not the
  truncated grid.
- There is no file picker and no overwrite prompt. Type a path, and it is
  written.

## Charts

Until you pick columns, a chart is drawn from the rows already in the grid, in
the order the query returned them, with no aggregation. The [**X**, **Y** and
**Split** menus](#pick-columns) have DuckDB group the whole result instead.

The chart picker above the chart starts at **Auto**, which chooses from the
result's columns:

| The result has | Auto draws |
| --- | --- |
| A latitude and a longitude column | A map |
| A temporal first column (date / timestamp) | An area chart; x is column 0, one series per numeric column |
| Anything else | A bar chart; column 0 is the label, the first numeric column is the value |

"Numeric column" includes the first column: if column 0 is a number — an
hour, a year, an ID — it becomes the bar chart's value, not its label. Cast it
to text (`year::VARCHAR`, or `format('{:02d}:00', hour(ts))`) to make it the
label.

**Bar**, **Line**, **Area** and **Scatter** are a click away for any result;
**Pie** for a result whose first column is not a time, and **Map** for one with
coordinates. The pick is kept across runs; when a new result cannot draw it — a
map without coordinates — the chart falls back to Auto's choice without
forgetting yours.

### Pick columns {#pick-columns}

The **X**, **Y** and **Split** menus above the chart choose what it draws. **Y** is the row count, or the sum, average, minimum or maximum of a number. DuckDB then groups the whole result rather than the loaded rows, and keeps the 200 largest categories (or the first 50,000 ordered values) and the 12 largest series. A notice says what was left out.

- With a **Split** picked, **Stacked** and **Heatmap** join the chart picker, and **Pie** leaves it.
- **Histogram** is offered when there's a number to bin. DuckDB bins the whole result.
- **Copy as .dash** copies your pick as a `query` and a `plot` block that draw the same chart in a [dashboard](dashboards.md).

Without a pick, a chart of a cut-off result says **Drawn from the first N rows the grid loaded, not the whole result**. A cut-off time series is run again in the background and averaged over every row.

### Chart types and limits

A **pie** draws the first numeric column's value per label, largest first.
Zero, negative and missing values are not drawn as slices, and a notice counts
them; past 8 slices, the 7 largest keep their slices and the rest fold into
"other".

A **map** is picked by column names:

- **Latitude:** `lat`, `latitude`, or any name containing them (`geo_lat`,
  `start_lat`) — `纬度` works too.
- **Longitude:** `lng`, `lon`, `long`, `longitude`, likewise — `经度` works
  too.

Each row is a point on a Web Mercator projection fitted to the points, over a
latitude/longitude grid labelled in degrees; a text column with only a few
distinct values colours them. Hover a point for its row.

Under the points, an OpenStreetMap base map can be drawn: turn it on with
**View → Online base map (OpenStreetMap)**. It is off by default because loading tiles needs the network — the requests
reveal roughly the area being viewed, never your rows. Tiles are cached for a
week; offline, the map draws over the latitude/longitude grid alone.

Rows whose value cells are not numeric — `NULL`, for instance — are dropped
before plotting.

Limits, all announced in a notice line under the chart title:

| Limit | Behaviour |
| --- | --- |
| 12 series | Extra numeric columns are dropped |
| 1,500 points | The series is bucketed and **averaged** within each bucket |
| 200 bars | Surplus bars are truncated, not merged |
| 8 pie slices | The smallest fold into "other" |
| 20,000 map points | Surplus points are left out |

The title always reports how many points were plotted, so check it before
trusting the shape of a curve.

## Column overview {#overview}

The **Overview** tab describes every column of the current result at once:
how full it is, how many values it takes, its range, and what its values look
like.

The heading line reads `Current result · N rows · M columns · took …`. Then
each column gets one line:

| Part | What it shows |
| --- | --- |
| Name and type | The column name and its DuckDB type |
| Nulls | The share of rows that are `NULL`, with a small bar; highlighted when above 0% |
| Distinct | `~N distinct` — DuckDB's estimate of the number of distinct values |
| Range | Minimum – maximum, or the single value when they are equal |
| Median | For numbers, dates and timestamps |
| Distribution | A small chart, described below |

The distribution depends on the column's type:

- **Numbers, dates and timestamps** get a histogram of about 12 equal-width
  bars. A column with a single value lists that value instead.
- **Text and booleans** list their 5 most common values, each with its share
  of rows; hover a value for its exact count. Past about 10,000 distinct
  values the values are estimated and listed under *Common values
  (estimated)*, without shares.
- **Lists, structs, maps and unions** get no chart.

How it works:

- **The overview runs the query again**, whole, in two scans: `SUMMARIZE` for
  the statistics, then one `SELECT` for every column's distribution. So it
  covers the full result, not just the rows the grid kept under its
  [row limits](#row-limits) — and a slow query is slow again. A
  query that is not deterministic (`random()`, `USING SAMPLE`) is described as
  it came out on that second run.
- It is computed when you open the tab, and again for each new result while
  the tab is open.
- **Only a query that can sit inside `FROM ( … )` can be summarized**: one
  that starts with `SELECT`, `WITH`, `FROM`, `VALUES`, `TABLE`, `PIVOT` or
  `UNPIVOT` (leading `--` comments are skipped). Anything else — `SHOW`,
  `DESCRIBE`, `PRAGMA`, `CALL`, `SUMMARIZE` — gets *Only the result of a query
  (SELECT, WITH, FROM…) can be summarized: the overview runs it again.*

To summarize a whole table, view or registered file without writing a query,
right-click it in the sidebar and choose **Column overview**. That puts
`FROM <name>;` in the editor, runs it and opens this tab. See
[Right-click menu](schema-and-history.md#right-click-menu).

## Explain and Profile output {#explain-and-profile}

The [**Explain** and **Profile** buttons](sql-editor.md#explain-and-profile) in
the editor toolbar show their output on the **Results** tab, switching to it if
another tab was open. Either one replaces the grid; run the query again to get
its rows back.

**Explain** shows DuckDB's plan as plain monospace text under a
`EXPLAIN · took …` line.

**Profile** shows the plan that actually ran as a tree, one line per operator,
each indented under the operator it feeds:

- a bar for the operator's share of the time all operators spent;
- the operator's own time, its children's excluded;
- its name, such as `TABLE_SCAN`, `FILTER` or `HASH_GROUP_BY`;
- `N rows` — the rows it produced;
- `est. N` — the optimizer's estimate of those rows, where the plan has one.
  It is highlighted when it is off by more than 10× either way (hover it for
  *Estimate off by more than 10×*): a miss that size is a common reason a join
  is slow;
- below, what the operator worked with: filters, join keys, projections.

The heading line reads `Profile · took … · operators …`: the wall-clock time,
then the sum of the operators' own times. The two differ, because operators of
one pipeline run interleaved; the bars are shares of the second. Beside it,
`Hottest: NAME (time)` names the operator that cost the most on its own, and
that operator's line is drawn in red.
