ovidens
Analytical AppsPipeline

Validation results

When you configure validations on a dataset node - "this column is required", "these two columns must be unique together", "this code must exist in that other dataset" - the platform executes them on every pipeline run and publishes the results as two nodes in your pipeline (it also runs them whenever you look at the data, which is further down this page):

NodeWhat it holds
Validation rulesOne row per validation you have configured, across every dataset node in the app.
Validation errorsOne row per dataset row that fails a validation.

Both appear on the canvas, have a Data tab, can be read by a SQL node and charted by a Visualisation widget. What is different is that the platform writes them: they are their own kind of node, with their own icon, and they are not offered when you add a node. They open read-only and update themselves whenever you change a validation - there is nothing to maintain, and nothing to break. Opening one shows the query the platform wrote, and you can Run it to see the current results, exactly as you would in a SQL node.

They appear the moment your app has its first configured validation, and they stay after that. If you later remove every validation, both nodes remain and simply return no rows, because your own queries may already read them.

What the nodes are called

Normally they are called validation_rules and validation_errors, and the examples on this page use those names.

They are not guaranteed, though. If one of your own nodes already holds the name, the platform takes the next free one - validation_rules_2, validation_errors_2 and so on - rather than touching your node. Your existing node keeps its name and its contents; the generated pair simply sits beside it. If you are writing a query against these nodes, check the name on the canvas first and use the one your app actually has.

Validation errors

ColumnMeaning
dataset_technical_idThe dataset node the failing row belongs to.
row_idThat row's stable id.
column_technical_idThe column, for a per-column rule. Empty for a condition-tree rule.
rule_idJoins to validation_rules.rule_id.
check_typeThe check that failed.
validation_errorThe message describing the failure.

row_id is the point of the table. It is the same stable row id the data grid uses, and every dataset node's table carries it as _row_id (see SQL nodes), so you can join a finding back to the row it belongs to - substituting the name your app actually gave the node, if it is not the usual one:

orders_with_findings.sql
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_id

A row with no matching finding is a trusted row. A row with several failing rules appears once per rule, so a row that breaks two checks gives you two rows, told apart by rule_id.

Gating a SQL node on the findings

The most useful thing to build from this is a node that carries only the rows nothing reported - so everything downstream of it works on data you have already checked:

trusted_orders.sql
SELECT o.*
  FROM orders o
 WHERE NOT EXISTS (
   SELECT 1 FROM validation_errors e
    WHERE e.dataset_technical_id = 'orders'
      AND e.row_id = o._row_id
 )

Declare validation_errors as an input table, beside the dataset:

input_tables:
  - orders
  - validation_errors

The validation nodes always run first, before any node you write, so a query that reads them always sees the findings for the run it is part of - whether or not you declare them. What the declaration buys is the arrow on the canvas: leave it out and the node still gives the right answer, but nothing on the diagram shows that it depends on the validation results, and the next person to read your pipeline will not see the link.

To gate on a specific check rather than on any failure at all, add AND e.check_type = 'required' (or join validation_rules and filter there).

In the data grid

You do not have to query the node to see what failed. Open a dataset node's Data tab and the failing cells are marked there, with the reason on hover: a strong mark on the cell a per-column rule named, a lighter one across every cell of a row a condition tree rejected. Hovering a marked cell shows the reason and only the reason - the usual tooltip that repeats the cell's own content steps aside for it.

The marks arrive with the rows. The grid asks for a page of data and gets that page's findings back in the same answer, checked against the very rows it is about to show you. There is no run to name and no "checked at" date to read past, because the rows and the marks come from one and the same read. So:

  • Nothing is stale, and nothing is withdrawn. Save a change in the grid, or complete an import, and the rows you touched are re-checked the next time the grid reads them: a mark you fixed is gone, a rule you have just broken appears, and every other row keeps its own marks. You do not run the pipeline to see this.
  • A row someone else added or changed is checked the same way, on the next read.
  • Every failing row in front of you is marked, at any dataset size. A row's findings travel with the row, so a block of rows is marked the moment it appears - there is no fixed first slice, no partially marked screen and nothing that arrives late.
  • The line above the grid describes the whole dataset, not the page: either no findings, or how many rows fail something. That count does not move as you scroll, search or filter.

Beside the count is Show only failing rows, which narrows the grid to the rows that fail at least one check, and back again.

A dataset that has no validations of its own shows no line at all, even when other datasets in the same app have plenty.

A run's data view and a version's data view are marked too, each by its own answer. A run shows exactly what that run found. A version is checked by the validations as they were configured when it was published, over the data as it stood then - so a historical view is never re-judged by today's rules.

If the checks cannot be run at all - the platform could not resolve one of the tables a rule reads, say - the line says the validations could not be checked and nothing is marked. Your data is unaffected: every row is there, in order, with the correct total. A failing validation costs you the marks, never the data.

On a Visualisation page

The same marks appear on a table widget backed by a dataset, so the people who read the app - rather than build it - see what the validation found without opening the pipeline. The reason is on hover there too, and the marks are checked against the same rows the table is showing.

Three differences from the Data tab, all deliberate:

  • The line above the table appears only when there is something to warn about: that findings exist, or that the checks could not be run. A clean dataset is simply shown without a strip, so a page of tables does not turn into a page of notices.
  • A grouped or summarised table is not marked. Its rows are totals over many source rows, not the rows a rule named. Neither is a table over a SQL transform: a finding belongs to a dataset row, and a transform's output rows are not dataset rows.
  • There is no "show only failing rows" control. That is the author's tool, and it stays on the Data tab; a page is laid out by the person who built it.

While a draft is open, the published app is checked as it was published

An open draft makes every dataset table on the published app read the published snapshot of the data rather than the live rows, so that a half-finished edit of yours cannot leak to readers (see Draft and publish). The marks follow that exactly: the table is checked by the validations of the version it is showing, over the snapshot it is showing. Your team therefore sees what the published app says about the published data, while the Data tab in the editor goes on checking the live rows against the rules you are editing. Neither borrows the other's answer, and neither has to wait for a run.

Validation rules

ColumnMeaning
rule_idThe rule's identity within this run - col:<dataset>.<column>.<n> for a per-column rule, where n is its position in that column's list of checks, and tree:<dataset>.<n> for a condition tree, where n is its position in that dataset's list.
dataset_technical_idThe dataset node the rule is configured on.
column_technical_idThe column, for a per-column rule. Empty for a condition-tree rule.
rule_kindcolumn, complex, or overflow.
check_typeThe operator for a per-column rule, or the top-level group for a condition tree.
parameterThe value the check compares against, where the check takes one.
error_messageThe message the rule reports when it fires.
compilableWhether the rule actually runs.
skip_reasonEmpty when the rule runs, otherwise why it does not.

This table is not decoration: Validation errors reads check_type and error_message from it. So editing a condition-tree rule's message changes what your findings say, without anything else changing.

What each check means

Every check below shares one rule, which is worth reading before the table:

A blank value passes every check except Required.

A blank value is an empty cell, or one holding only spaces. Without this rule an optional column with a pattern check would report every empty cell as an error, which is not what "optional" means. If blanks matter for a column, add Required beside the other check.

CheckFails when
RequiredThe cell is empty. What "empty" means per column type is the table below. This is the only check a blank fails.
UniqueAnother row holds the same value. Every copy of a duplicate is reported, not just the later ones. Blank cells are never duplicates of each other.
EqualsThe value is not equal to the one you gave.
Greater thanThe value is not greater than the one you gave.
Less thanThe value is not less than the one you gave.
Greater or equalThe value is below the one you gave.
Less or equalThe value is above the one you gave.
Matches pattern (like)The text does not match the pattern. % stands for any run of characters, _ for one character.
Matches pattern (any case) (ilike)The same, comparing without regard to upper and lower case.
Starts withThe text does not start with the value you gave.
Ends withThe text does not end with the value you gave.
Matches expression (regex)The text does not match the regular expression.

Two consequences follow from these, and both are deliberate:

  • A value that cannot be compared fails a comparison. If a column holding abc carries "greater than 0", that row is reported. The value cannot be compared, and an uncomparable value is not greater than zero.
  • A check whose answer is unknown counts as a failure. The platform never reads "unknown" as "fine".

Which checks a column offers

The checks a column offers follow from what that column holds, so a date is compared against a date and a yes/no against a yes/no:

Column typeRequiredUniqueEqualsGreater / less thanThe five text patterns
Textyesyesnonoyes
Long textyesyesnonoyes
Phoneyesyesnonoyes
Emailyesyesnonoyes
Integeryesyesyesyesno
Decimalyesyesyesyesno
Percentyesyesyesyesno
Currencyyesyesyesyesno
Yearyesyesyesyesno
Dateyesyesyesyesno
Timeyesyesyesyesno
Timestampyesyesyesyesno
Booleanyesnoyesnono
Dropdownyesyesyesnono
Multi-selectyesnononono
Attachmentyesnononono

The rule behind the table: Required is offered everywhere; Unique wherever a cell holds one value that could identify a row (so not on a boolean, which has two values, nor on a multi-select or an attachment); Equals wherever there is one exact value to compare against; the ordered comparisons wherever values have an order; and the five text patterns on the four free-text types. On a text column, Matches pattern with no % already IS an exact match, which is why Equals is not offered there as a second way to spell the same check.

A check you configured earlier is never removed. If you change a column's type after configuring its checks, the checks stay, keep running, and keep showing in the editor - they are simply no longer offered as something to add.

What to type as the value

A comparison's value has to be a value the column itself could hold:

Column typeType thisExample
Integer, Decimal, Percent, Currencya number1500, -3.5
Yearfour digits2026
DateYYYY-MM-DD2026-01-01
TimeHH:mm or HH:mm:ss12:30
TimestampYYYY-MM-DDTHH:mm:ss2026-01-01T08:00:00
Booleanpicked from the two optionsTrue
Dropdownthe option's exact textEU
Text, Long text, Phone, Emailany text (the pattern)A%

A value outside that format leaves the rule skipped rather than guessed at: it appears in Validation rules with compilable false and skip_reason operand_type_mismatch, and every other rule keeps running. The editor marks the field as you type, so you normally see it before you save.

Two details worth knowing:

  • The value is stored normalised, and that is what Validation rules reports back in parameter: 12:30 becomes 12:30:00, 2026-01-01T08:00:00 becomes 2026-01-01 08:00:00, and YES becomes true.
  • Decimal, Percent and Currency compare as floating-point numbers. A value carrying more precision than that can represent may not compare exactly equal, so Equals on a money column is best kept for round amounts.

What "Required" means, per column type

Required is one check - "the cell is not empty" - and what empty means follows from what the column stores:

Column typeRequired fails when
Multi-selectNo option is selected. A multi-select cell stores its options as one text, so no selection stores nothing at all - "at least one option selected" is exactly what Required means here.
BooleanThe cell is empty. false is a VALUE and PASSES: to require the box be ticked, use Equals True.
AttachmentThe cell holds no attachment.
DropdownNo option is picked.
Everything elseThe cell is empty or holds only spaces.

A cell that holds the literal text [] in an attachment column is NOT treated as empty. The "blank" rule is one rule for every column type, and giving attachments their own would be a second rule to keep in step with it.

Condition trees

A complex validation combines checks into a tree, and the groups mean:

GroupFails when
ANDAny child check fails.
OREvery child check fails.
NOTThe child check passes.
UNIQUEAnother row holds the same combination of the listed columns. A row where ANY of those columns is blank is exempt - a partial key is not a key.
REFERENCEThe row's value does not exist in the target dataset's column. A blank source value is exempt. The comparison ignores surrounding spaces, so " 1" matches "1".

A check inside a tree behaves exactly like the per-column version, blank rule included - which has one consequence worth knowing:

NOT (column matches "x%") fires on a blank column, because the inner check passes on a blank and NOT inverts it.

If that is not what you want, put the check inside an AND beside Required.

When a rule cannot run

A rule the platform cannot turn into a correct check is never run as a broken one. It is skipped, and the skip is visible: the rule is still a row in Validation rules, with compilable false and a skip_reason. Every other rule keeps running, and the pipeline run itself is unaffected.

Find them with a SQL node, a Visualisation widget or an ad-hoc query:

skipped_rules.sql
SELECT rule_id, dataset_technical_id, column_technical_id, check_type, skip_reason
  FROM validation_rules
 WHERE NOT compilable
skip_reasonWhat to do
operand_missingThe check needs a value to compare against and none was given. Fill it in.
operand_not_numericA check on a number column was given something that is not a number. Correct the value.
operand_type_mismatchThe value is not one the column's type could hold - a date that is not YYYY-MM-DD, a year that is not four digits, a time outside 24 hours, a yes/no that is neither. See "What to type as the value" above.
column_not_foundThe rule names a column the dataset no longer has. Point it at an existing column, or remove it.
reference_target_not_foundThe dataset the reference points at no longer exists.
reference_target_not_datasetA reference must point at a dataset node, not at a SQL node or an import.
reference_column_not_foundThe reference names a column the target dataset does not have.
tree_incompleteA group has no children, a NOT has more than one, a UNIQUE lists no columns, or a REFERENCE maps no columns. Finish the rule.
rule_budget_exceededSee below.

The overflow row

There is a ceiling on how many validations one app's generated nodes can represent. An app that goes past it gets one extra row in Validation rules with rule_id overflow, whose parameter is how many configured validations are not represented and whose message says so. The validations that did not fit are simply absent; your dataset still saves normally.

If you see it, the fix is to have fewer, more meaningful validations - or to split the datasets across apps.

Things this does not do

  • A failing validation never blocks a write. Validation is observational: a run reports what is wrong, it never refuses your data. You decide what to do about it.
  • There is no warning level. Every configured validation is an error when it fires.
  • The stored messages are in English. They belong to a shared table that has no reader's language at the moment it is written. The structured columns beside them (check_type, parameter, column_technical_id) are what a translated view renders from - which is what the data grid does: its cell reasons are in your language for every per-column check, and are the message you typed yourself for a condition tree.
Was this page helpful?

On this page