SQL nodes
A SQL node is a step that transforms your data. It reads the output of earlier nodes and reshapes it into something new, for example combining two tables, filtering rows, or calculating a total. SQL is the standard language for working with data, and a SQL node uses a query that reads from the nodes feeding into it.
You do not need to know SQL
This is the important part: you do not have to write SQL yourself. Describe the transformation you want in plain language, for example "keep only this year's orders and add up revenue per customer", and Vera writes the query for you. Vera is the no-code path; this page explains the node for when you want to read or adjust what it produced. See Build with Vera.
Working in a SQL node
If you do want to work with the query directly:
- In the editor, open the SQL node.
- Write or adjust the query. It reads from the nodes connected into this one.
- Run the query to see its output and the columns it produces.
A SQL node's results feed any nodes connected after it, and your Visualisation widgets. Because downstream steps depend on this node's output, they only have fresh data once the pipeline has run successfully. If the query has not been run yet, nodes that rely on it will not have data to work with until you click Run.
Running a query in a SQL node is a permission of its own, separate from reading the app. It comes with the Contributor, Analyst, App builder and Admin roles on the whole workspace - a role on a single space or app does not give it - and you always hold it in your own personal space. Without it, the node shows its query with no Run button and a short note saying why. If the app could not check whether you hold it, the note says so instead; reload the page to try again.
What the Run button can read
Running a query in the editor reads only this app's own nodes, named directly by
their id - the same rule the SQL editor and the SQL widget follow. A query may name a
node, or a WITH clause it defines itself, and nothing else.
An indicator data or analytic app data node can be read once the pipeline has run: the query reads the copy of its rows that run took. Before the app has a run there is no such copy, and the name is refused like any other name with nothing behind it. A local development stack never offers these two node types to a query and refuses their names; read one there through a SQL node that selects from it.
These are refused before anything runs, with a message saying what to change:
- a name with a prefix, such as
other_app.ordersormain.orders- writeorders; - a name that is not a node of this app, such as another app's table, a system table
like
duckdb_tables, or a file path; - a node whose id is also the name of one of the database's own catalog tables, such as
duckdb_tables,pg_classorsqlite_master- rename the node to read it; - a table function in a
FROMclause, such asread_parquet(...),range(...)orunnest(...)-unnest(...)in theSELECTlist is fine; DESCRIBE,SHOWorSUMMARIZEused as a subquery,PIVOTandUNPIVOT, reading an earlier version withAT (VERSION => ...), aWITH ... AS MATERIALIZEDclause, and an aggregate's internal state withEXPORT_STATE;- a function that reads settings, session details or the database catalog, runs SQL
handed to it as text, or changes something - for example
current_setting(...),current_database(),json_serialize_plan(...)ornextval(...). Ordinary functions for text, numbers, dates, lists and JSON, aggregates and window functions all work. A function written after a value, as inname.upper(), is refused too - writeupper(name); - a type that belongs to a schema, such as
CAST(x AS other_app.mood), or that is not one of DuckDB's built-in types; - a number too large for the database to hold, such as
1e400- the query cannot be checked, so it is not run.
The SQL keywords written without parentheses - current_user, current_schema,
current_date and the like - still work, including as column names. They report only
this query's own connection, never another app.
A pipeline run and the live preview of a node hold its saved query to the same rule.
Saving a query that uses one of these forms is not refused, but when the pipeline runs
that node fails with the same message, and the nodes that read from it are skipped.
Rewrite it without them - a conditional sum(CASE WHEN ...) in place of a PIVOT, or
SELECT unnest(range(10)) AS n in place of FROM range(10), for example. To use another
app's data, add an analytic app data node and read that node by
its id.
How much a page of results can hold
A query's results are shown one page at a time, with a count of every row the query produced. One page may hold at most 4 MB of data and at most 262,144 values. A value is measured as it would be shown as text, so a long text, a large list or a value nested inside another counts in full; and every element of a list, every field of a struct and every key and value of a map counts as a value of its own.
A query whose page is over either limit is refused with a message saying so. Nothing is cut short: you never see part of a value, or some of the rows, presented as if it were the whole result. To bring the page under the limits:
- select fewer columns, or shorter ones - for a long text,
left(text, 1000)returns its first 1,000 characters andlength(text)its length, andlen(list)the length of a long list; - filter to fewer rows with a
WHEREclause.
A few column types cannot be shown as a query result at all: a UNION, a VARIANT and a
fixed-size ARRAY, on their own or inside a list, struct or map. A query returning one is
refused with a message naming the column. Convert it in the query - CAST(... AS VARCHAR)
shows its value as text, and CAST(... AS INTEGER[]) turns a fixed-size array into a list.
The limits apply wherever a query's results are shown straight away: the Run button, the SQL editor, a SQL widget, the AI tools that query an app, and the preview of a SQL node described below. They limit what one page holds, not how many rows a query may produce.
Seeing what a query produces before you run
You do not have to run the pipeline to see whether a query is right. Open a SQL node's Data tab when there is no run to show and it previews the node's CURRENT query over the CURRENT contents of the app's datasets, right then. A strip above the rows says so, because these are not run results: nobody else sees them, your Visualisation does not use them, and they change the moment you edit the query or the data.
That makes the ordinary loop tight. Edit the query, look at the rows, fix it, look again - and only click Run when it is right. Type a value into a dataset's grid and the next preview already reflects it.
A few things to know about it:
- It is for people who can edit the app. A viewer sees the run results, as before.
- It shows the first page, in the query's own order. Sorting, filtering and the search box are not offered here - a preview that quietly ignored a sort would be worse than one that does not offer it. Run the pipeline for the full grid.
- The columns come from what the query actually returns, not from the Output columns list, so a query you have just rewritten shows its new columns immediately.
- If the query cannot run, it says why. The message names the relation that did not
resolve, which is usually a typo or a missing entry in
manifest.yml(see below).
What a preview can read
A preview binds every dataset node in the app, exactly as a run does - so a query
reading a dataset works whether or not manifest.yml mentions it.
The SQL nodes it binds are the ones your manifest.yml declares, and their inputs in
turn. So a query that reads ANOTHER SQL node's output without listing that node in
manifest.yml will not preview, and the message names it. Add it to input_tables and the
preview works - which is worth doing anyway, since that entry is also what draws the arrow
on the canvas and what tells the pipeline to run the two steps in the right order.
Two kinds of input cannot be previewed at all, and the message says so rather than looking like a broken query: an analytic-data import and an indicator-data bridge. Their rows are fetched when the pipeline runs, so there is nothing live to read. Run the pipeline to see a node that depends on one.
manifest.yml has to match the query
manifest.yml is where a SQL node declares which other nodes it reads. That list is not a
convenience: it is the only thing that draws the arrows on the canvas and the only thing that
tells a run which step has to finish before this one starts. A query that reads a node the
list does not mention still runs, but the app's picture of itself is then wrong, and two steps
that must run in order are free to run in the wrong one.
So saving a SQL node checks the two against each other, and refuses the save with a message naming the node and the table when they disagree:
- The query reads a node
input_tablesdoes not list. Add it to the list. input_tableslists a node the query never reads. Remove it, or read it in the query.input_tablesnames something that is not a node of this app. Usually a typo in the id.
A node that reads no other node needs no list, and an empty manifest.yml is right for it.
If you build apps through Vera or through an AI connector, this is handled for you: the tools require the list and are told the same rule, so the arrows are there from the first version rather than after you ask for them.
Reading a dataset's row id
Every dataset node's table carries three extra columns after your own:
| Column | Meaning |
|---|---|
_row_id | The row's stable id - the same one the data grid uses. |
_created_at | When the row was first created. |
_updated_at | When the row was last changed. |
They sit AFTER every column the dataset declares, in the order you declared them, and they can never clash with one of your own columns: a column name you write always starts with a letter.
SELECT * FROM orders returns them alongside your columns. They do not become the node's
output columns, though - a SQL node's Output columns list ignores them, so a query
that selects _row_id saves and runs exactly like one that does not.
The row id is what lets you join a row back to something said ABOUT that row - most usefully
the validation results. The validation node is normally called
validation_errors, but an app where one of your own nodes already holds that name gets
a numbered variant instead, so use the name your app actually shows:
SELECT o.*, e.validation_error
FROM orders o
LEFT JOIN validation_errors e
ON e.dataset_technical_id = 'orders'
AND e.row_id = o._row_idIt is available wherever you write SQL against the app - in a SQL node's script, in the node's preview, and in the ad-hoc query box - so the same query works in all three.
A dataset with no rows yet still has the columns, so a query reading them returns no rows rather than failing. Rows uploaded before row ids existed read as empty: a made-up id that looked stable would be worse than an absent one.
Output columns and how they are shown
The Output columns list shows one entry per column the query produces. You cannot add, remove or reorder them here - that is what the query decides. What you can set is how each column READS:
- Display name and Description, shown in the Data tab and in any picker that offers this column.
- Type, which decides how the column's cells are rendered and filtered. A
Currencycolumn shows a currency symbol and grouped digits, aYes / Nocolumn shows a check or cross instead of the words true and false, a date renders in your language's format, and an email column offers a mail link. Numeric types filter with the number filter, dates with the date filter, and the rest with a text filter that suggests the values actually present in the results. - Currency code and Decimals, shown for a
CurrencyorPercentcolumn.
Every column the query returns needs a usable name
A column's identifier comes straight from the query, so the query has to give each one a
name that can be used as an identifier: lowercase letters, digits and underscores, starting
with a letter, up to 32 characters. order_id, revenue and region_2024 are all fine.
Most queries already satisfy this, because selecting a column keeps its name. What does not
is an expression you have not named. SELECT count(*) returns a column literally called
count_star(), and SELECT a + b returns one called (a + b). Give those an alias:
SELECT count(*) AS order_countSaving a node whose query returns a name that cannot be used is refused, and the message says which column it means. You will usually see it sooner than that: as soon as you run the query, a line above the results names the columns that need an alias.
The alias is worth having for its own sake, not only to satisfy the rule. Later nodes and
your Visualisation widgets refer to this column BY NAME, so order_count is something you
can read and reference, while count_star() is not. Adding one costs nothing - you are
naming the column you already meant.
The type list is narrowed to what the query returns
Under the type picker, a line states what the query returns for that column - text, a whole number, a decimal number, yes/no, a date, a time, or a date and time. The picker offers only the display types that fit it:
| The query returns | You can display it as |
|---|---|
| Text | Text, Long text, Email, Phone number, Dropdown, Multi-select |
| Whole number | Whole number, Decimal, Percent, Currency, Year |
| Decimal number | Decimal, Percent, Currency |
| Yes / No | Yes / No |
| Date | Date |
| Time | Time |
| Date and time | Date and time |
The restriction is not cosmetic. A column's filter is applied by the database, so displaying a number as text would send text comparisons to a numeric column and the filter would stop working. A node that has never been run yet has no detected type, so it offers every display type until the first run tells it what the column really is.
You do not have to set anything. Leave a column's type alone and it follows what the query returns, which is already better than an unformatted result: whole numbers are grouped, dates use your language's format, and yes/no values show a glyph.
When the query changes under a display type
The display type is yours; the detected type belongs to the query. Rewriting the SELECT can therefore change what a column returns while its display type stays as you set it - a money column that becomes text, for instance.
That never fails a run. The data is correct and only its label is stale, so refusing to show you your own data would be the wrong answer. Instead the run stays successful and says so in four places: the node's card on the canvas turns amber with a tooltip, the column shows a Type mismatch chip in the Output columns list, its detail pane shows an amber note, and a strip appears above the results grid. Picking a display type that fits - or changing the query back - clears all four immediately, with no re-run needed.
Dataset nodes
A dataset node is an input table you fill with your own data. Learn how to define columns and types, import a CSV or Excel file, and edit data by hand.
Indicator nodes
An indicator-data node pulls metrics from one of your indicator apps into this pipeline. Choose the source app, indicators, period range, and dimension filters.