| description | Known issues and workarounds for vowl, including MSSQL regex limitations and backend-specific behaviours. |
|---|
Database backends handle NULL differently in aggregate checks like minimum, maximum, and mean. Most backends silently skip nulls, which means a column full of nulls can still pass a minimum check (because there are no non-null values to violate the constraint).
If you need to catch nulls, add an explicit nullValues check rather than relying on aggregate checks to find them:
properties:
- name: my_column
quality:
- id: my_column_no_nulls
metric: nullValues
mustBe: 0
description: "There must be no null values in the column."This catches nulls directly, regardless of which database backend runs the validation.
SQL Server does not support regex (REGEXP_LIKE). Any check that uses pattern matching will return ERROR when run against MSSQL.
Affected checks:
logicalTypechecks that validate string formats (e.g.date,timestamp,time)logicalTypeOptions.patterncheckslogicalTypeOptions.formatchecks for string, date, timestamp, and time logical typeslibrarymetricinvalidValueswitharguments.pattern
Workaround: Route queries through DuckDB instead, which has full regex support:
import ibis
from vowl import validate_data
from vowl.adapters import IbisAdapter
con = ibis.duckdb.connect()
con.raw_sql("ATTACH 'mssql://user:pass@host:1433/mydb' AS mssql_db (TYPE sqlserver, READ_ONLY)")
con.raw_sql("USE mssql_db")
result = validate_data("contract.yaml", adapter=IbisAdapter(con))Oracle's SQL dialect differs from standard SQL in ways that can cause some checks to ERROR:
- No
LIMITclause: Ibis rewrites this asFETCH FIRST N ROWS ONLY, but edge cases may arise. - No
!~regex operator: vowl rewrites regex checks to useREGEXP_LIKE, but complex patterns may not translate cleanly. - Case-sensitive identifiers: Oracle uppercases unquoted identifiers. If your tables were created with quoted lowercase names (e.g.
CREATE TABLE "my_table"), checks may fail because Oracle looks forMY_TABLEinstead. vowl applies quoting transforms, but mismatches can still occur. TEXT/CLOBcolumns can't useREGEXP_LIKE: vowl auto-casts these toVARCHAR(4000), which means values longer than 4000 characters get truncated before the regex runs.
SQLite has no built-in regex support. vowl works around this by using a Python-side regex function (_IBIS_REGEX_SEARCH) that Ibis registers automatically. This works in most cases, but may behave slightly differently from server-side regex (e.g. subtle Unicode or flag differences).
PooledAdapter runs checks across a thread pool, handing each pooled connection to one worker thread at a time. Python's sqlite3 connections default to check_same_thread=True, which forbids using a connection on any thread other than the one that created it. So a pooled SQLite connection that gets handed to a different worker raises a thread error, which surfaces as an intermittent ERROR status (it depends on thread scheduling — sometimes every checkout happens to land back on its creating thread, sometimes not).
The underlying SQLite library is thread-safe (CPython compiles it serialized, sqlite3.threadsafety == 3), so this is a Python-level guard, not a real engine limitation. To run pooled/parallel checks against SQLite, build the connection with the guard disabled and wrap it for Ibis:
import sqlite3
import ibis
raw_con = sqlite3.connect("my.db", check_same_thread=False)
con = ibis.sqlite.from_connection(raw_con)This is safe with PooledAdapter because it hands each connection to only one thread at a time. vowl's built-in connection-string path (ibis.connect("sqlite://...")) does not set this flag, so a SQLite adapter created that way and then pooled will hit the limitation. Pass a pre-built thread-safe connection as shown above if you need parallel SQLite.
When using MultiSourceAdapter (passing adapters={} to validate_data), vowl downloads each table into a local DuckDB instance before running checks. This means:
- Memory usage grows with table size, so large tables may cause out-of-memory errors.
- Network transfer: the full table (or filtered subset) is pulled to the client.
For large datasets, prefer the DuckDB ATTACH approach which queries data in-place without downloading it. See Usage Patterns for details.
vowl materialises tables via Arrow instead of using DuckDB ATTACH for these reasons:
-
Table names don't line up. DuckDB ATTACH puts tables under a qualified path (e.g.
pg_db.public.my_table), but contract queries use bare names likemy_table. For cross-database joins (the main multi-source use case), every table reference would need rewriting, which is fragile. -
No access to connection credentials. DuckDB ATTACH needs a connection string with host/port/password, but vowl only receives a live Ibis connection object. There's no reliable way to extract credentials from it.
-
Limited backend support. DuckDB ATTACH only works with PostgreSQL, MySQL, and SQLite. vowl supports any Ibis backend, so materialisation is needed anyway for most of them.
-
Filters can't be pushed down. With materialisation, vowl applies filter conditions at the source before downloading. With ATTACH, the remote table is exposed raw and pushing per-adapter filters into cross-database joins would require complex query rewriting.
-
ATTACH opens a separate connection. This bypasses any session state on the user's Ibis connection (transactions, temp tables, session variables,
search_path).
get_annotated_output() (and save(output_mode="annotated")) returns your full table with an extra check_info column showing which check(s) each row failed. However, not every check can be merged into this table; some checks simply don't produce results that map back to individual rows.
output = result.get_annotated_output()
output["annotated"] # {schema: full table + check_info} <- mergeable checks
output["residues"] # {"<schema>::<check>": failed rows + check_info + tables_in_query} <- non-mergeable checks that still have offending rowsresidues only holds non-mergeable checks that still produce offending rows. There are two such cases:
- column-subset checks, and
- cross-table checks whose failed rows carry columns from more than the anchor table (see case 1 below).
A non-mergeable check that produces no rows at all still appears in neither dict; its verdict is recorded only in summary.json. This covers scalar aggregations (AVG/SUM/MIN/MAX), rowCount, and errored checks.
The check_info column holds a JSON array of objects, one per failing check. Its shape follows the check_info preset ("names" default, "summary", or "full").
Residues are per-check. Each entry:
- covers exactly one non-mergeable check, keyed
"<schema>::<check_name>"; - carries that check's own failed rows; and
- uses the same
check_infocolumn as the annotated tables (here a single-element JSON array), plustables_in_query.
Two non-mergeable checks are never combined into one entry, and a check that was merged into a full table never also appears as a residue. So annotated tables and residues are read exactly the same way. (The standalone failed_rows/both CSVs come from a separate, unchanged path and keep their legacy comma-joined check_ids column.)
For example, suppose your full table hdb_resale_prices looks like this:
| month | town | block | street_name | flat_type | storey_range | floor_area_sqm | lease_commence_date | remaining_lease | resale_price |
|---|---|---|---|---|---|---|---|---|---|
| 2024-01 | ANG MO KIO | 123 | ANG MO KIO AVE | 3 ROOM | 04 TO 06 | 68 | 1980 | 55 years | 350000 |
| 2024-01 | BEDOK | 456 | BEDOK NORTH | 4 ROOM | 07 TO 09 | 92 | 1995 | 70 years | 480000 |
| 2024-02 | TAMPINES | 789 | TAMPINES ST | 5 ROOM | 10 TO 12 | 110 | 2000 | 75 years | 620000 |
A mergeable check (e.g. a row-level check like "resale_price must be > 0") can tag individual rows directly, producing an annotated table like:
| month | town | block | ... | resale_price | check_info |
|---|---|---|---|---|---|
| 2024-01 | ANG MO KIO | 123 | ... | 350000 | null |
| 2024-01 | BEDOK | 456 | ... | 480000 | null |
| 2024-02 | TAMPINES | 789 | ... | 620000 | [{"check_name": "resale_price_positive"}] |
This split is by design. A check can only be merged into the annotated table when all of the following are true:
- The check didn't error. An errored check has no usable failed rows.
- It produces row-level results (aggregation type is
countornone). Checks that return a single number (likemeanormaximum) can't point to specific rows. - Its failed rows have the same columns as the full table. If a check only selects a few columns, we can't match its results back to full rows. This condition also decides cross-table checks (see below): the merge depends only on the failed-rows column set, not on how many tables the query touches.
When a condition fails, the check is not merged onto the annotated table. What happens next depends on why it failed to merge:
- The check still has offending rows (fails condition 3: column-subset checks, or a cross-table check whose rows carry both tables' columns). Those rows become a residue, returned separately and keyed
"<schema>::<check_name>". - The check has no offending rows to emit (fails condition 2: a scalar aggregation like
AVG/SUM/MIN/MAX, or an errored check). There is nothing to put in a residue, so the failure appears only insummary.json(status,actual_value,expected_value) and is never written to a CSV.
Heads-up: a failed scalar aggregation has no CSV footprint in
annotatedmode. It tags no rows in the annotated table (itscheck_infostaysnull) and produces no residue file, so the only record of the failure issummary.json. Always consult the summary for the authoritative pass/fail verdict; the annotated CSVs alone do not surface scalar-aggregation or errored-check failures.
The common cases:
A cross-table check (one that JOINs a table against a reference table) can annotate onto its home schema's table. You just have to shape its failed-rows query so it projects only that schema's columns. As with any check, condition 3 alone decides whether it merges: what matters is the failed-rows column set, not how many tables the query touches.
To see why, note that every SQL check derives two queries from the single query you write:
- a scalar query: the
SELECT COUNT(*)that decides pass/fail; and - a lazy failed-rows query: a
SELECT *over the sameFROM, run only when the check fails and rows are requested.
The COUNT(*) → SELECT * rewrite only touches the outer select list. So if a subquery already projects just one table's columns, that projection still governs the shape of the failed rows.
Mergeable (project only the anchor table's columns via a wrapping subquery):
# Anchored to demo_employee_payroll; failed rows are payroll rows only.
quality:
- type: sql
name: employee_id_exists_in_master_list
query: >-
SELECT COUNT(*)
FROM (
SELECT payroll.*
FROM demo_employee_payroll payroll
LEFT JOIN demo_employee_list ref
ON payroll.employee_id = ref.employee_id
WHERE ref.employee_id IS NULL
) AS orphaned_payroll
mustBe: 0The failed-rows query rewrites to SELECT * FROM (SELECT payroll.* …), which returns only demo_employee_payroll's columns. Those rows match the anchor table exactly, so the orphan payroll rows are annotated directly into demo_employee_payroll's check_info column, with no residue and no downstream mapping.
Non-mergeable (a bare JOIN returns both tables' columns):
# Failed rows carry columns from BOTH tables (ref.* all NULL) -> residue.
quality:
- type: sql
name: employee_id_exists_in_master_list
query: >-
SELECT COUNT(*) FROM demo_employee_payroll p
LEFT JOIN demo_employee_list e ON p.employee_id = e.employee_id
WHERE e.employee_id IS NULL
mustBe: 0Here the failed-rows query rewrites to a top-level SELECT * over the JOIN, which returns both tables' columns. That column set doesn't match demo_employee_payroll, so the check stays a residue keyed "demo_employee_payroll::employee_id_exists_in_master_list", the same backward-compatible behaviour existing bare-JOIN checks already have.
The merge is decided by column structure, not intent. A misshaped query that happens to return anchor-shaped rows will merge. This is the same class of risk single-table custom SQL checks already carry. It is mitigated by two guards: the check is only ever considered against its own declared schema (a payroll-anchored check can never merge onto an unrelated table with a coincidentally-matching shape), and the failed-rows column set must match that schema's columns exactly.
Checks that produce a single number (e.g. AVG, MAX, SUM) can't point to specific rows.
properties:
- name: resale_price
quality:
- type: sql
name: avg_resale_price_in_range
query: "SELECT AVG(resale_price) FROM hdb_resale_prices"
mustBeBetween:
- 100000
- 2000000The query result is just one number:
| avg |
|---|
| 483333.33 |
A single scalar has no individual rows to flag, so it can't be annotated onto the full table. It also can't become a residue: a residue holds offending rows, and a scalar verdict has none. So a failed scalar aggregation produces neither an annotated tag nor a residue file. Unlike the cross-table and column-subset cases, its failure lives only in summary.json.
rowCount behaves the same way. Its query is a bare SELECT COUNT(*) FROM t with no failure predicate, so the count measures table size, not a number of failing rows. There is no per-row failure to annotate, so (like AVG/MAX/SUM) it fails condition 2, produces no residue, and reports its verdict in the summary only.
A check that only returns some columns can't be matched back to full rows. This happens with custom SQL query: checks that SELECT (or GROUP BY) a subset of columns rather than whole rows:
properties:
- name: resale_price
quality:
- type: sql
name: distinct_towns_with_outliers
query: >-
SELECT town
FROM hdb_resale_prices
GROUP BY town
HAVING MAX(resale_price) > 2000000
mustBe: 0The query result might look like:
| town |
|---|
| ANG MO KIO |
This tells us a town has an outlier, but the result only has 1 column. The full table has 10+ columns, so we can't match this partial result back to specific full rows, and it becomes a residue.
Auto-generated
unique,primaryKey, andduplicateValueschecks are mergeable. Although these are implemented withGROUP BY … HAVING COUNT(*) > 1internally, vowl rewrites their failed-rows query to return the full participating rows (every row whose value belongs to a duplicate group, plus NULL primary keys) via anIN/EXISTSpredicate against the base table. They therefore annotate directly onto the table rather than becoming residues. Their reportedfailed_rows_countcounts participating rows (not duplicate groups), so it matches the number of annotated rows. Thepercent-unit variant ofduplicateValuesstays non-mergeable (its result is a ratio, not a row count).
get_consolidated_output_dfs() (used by output_mode="failed_rows"/"both") groups failed rows by (tables_in_query, column_set). Within each group it deduplicates identical rows and comma-joins the names of every check that flagged them. Cross-table failures are included too, keyed by a composite table name (e.g. "table_a, table_b"). This method is deprecated in favour of get_annotated_output() and will be removed in a future release.
By contrast, get_annotated_output()'s residues emit one entry per non-mergeable check, keyed "<schema>::<check_name>" and never grouped across checks. So the same non-mergeable failure looks different in each:
- failed-rows CSVs: grouped (possibly multi-check) rows with a comma-joined
check_idscolumn. - annotated residues: a single-check entry with a
check_infoJSON-array column.
If you rely solely on annotated output, always check residues for non-mergeable failures.
-
A table can have both. If a table has mergeable and non-mergeable failing checks, you'll get both an annotated table and residue entries for that schema. Mergeable checks are never duplicated into
residues. -
Annotated entries exist even when nothing failed. Every schema with an available adapter gets an annotated table; the
check_infocolumn is just all null. -
Missing adapter? If a schema's adapter is unavailable, that schema is skipped (with a warning) and its failures appear only as residues.
-
max_failed_rowsraises an error for annotated output. If you cap failed rows (max_failed_rows >= 0) and a mergeable check gets truncated,get_annotated_output()raisesValueErrorrather than silently treating un-fetched failures as passing. Usemax_failed_rows=-1(the default) or switch tooutput_mode="failed_rows". -
Identical rows are all flagged. Rows are matched by their values, so if two rows are identical and one fails a check, the other fails it too. This is correct behaviour, but it means the annotated table can show more flagged rows than the summary's failure count, which only counts unique failing rows.
SQL checks can reference any table the connection can reach, not just those declared in your contract's schema. For example:
quality:
- type: sql
name: "cross_reference_check"
query: "SELECT COUNT(*) FROM hdb_resale_prices h JOIN audit_log a ON h.id = a.record_id WHERE a.flagged = 1"
mustBe: 0Here, audit_log isn't declared in the contract, but the check runs fine. vowl reports the tables involved via tables_in_query but does not block undeclared table access.
Why this matters:
- The contract is no longer the single source of truth for what's being validated.
- Hidden dependencies on undeclared tables aren't obvious to contract reviewers.
- It may unintentionally expose data the contract author didn't intend to include.
Backend differences:
| Adapter | Behaviour |
|---|---|
IbisAdapter (native Ibis connection) |
Works: the query runs against whatever the connection can reach. |
MultiSourceAdapter |
Works: all materialised tables are available in the local DuckDB instance. |
| DuckDB ATTACH | May fail: only explicitly attached tables are visible. References to undeclared tables give a missing table error. |
!!! warning
Treat SQL checks that reference undeclared tables as a code smell. Declare all referenced tables in your contract's schema, even if they're not the primary validation target.