Check Sheet Migration
Create quality checks, and the computed tables or joins they depend on, from a single spreadsheet. The check sheet is a normalized tabular template (Excel or CSV, one row per check or computed container) designed to be filled from a client's own check catalog, so a migration that starts life in a tracking spreadsheet ends as checks in Qualytics without a hand-written script.
When to use which bulk path
Use migrate when the checks live in an external catalog or spreadsheet and do not yet exist in any Qualytics instance. If they already exist in another instance, use the Quality Checks export and import flow. If they are dbt tests, use dbt Import. For whole-datastore configuration trees in Git, see Config as Code.
Commands
| Command | Description |
|---|---|
migrate plan |
Validate and summarize a sheet (offline, no authentication) |
migrate validate |
Everything plan checks, plus read-only resolution and SQL validation against target datastores |
migrate apply |
Create the sheet's computed containers and checks on the target instance |
Workflow
graph LR
S[check sheet] -->|migrate plan| P[Offline summary and issues]
S -->|migrate validate| V[Names, references, and SQL resolved against the target]
S -->|migrate apply| Q[(Qualytics datastore)]
Q --> A[Run artifacts: run.log, results.csv, yaml/]
- Fill the sheet from the source catalog.
migrate plancatches structural problems without touching anything.migrate validate --datastore-id Nproves every name, reference, and SQL query resolves on the target. Still creates nothing.migrate apply --datastore-id Ncreates computed containers first, waits for each one's profile, then imports the checks.
Every row problem is reported with its row number and check_id; rows with errors are skipped, never half-applied.
The check sheet
One .xlsx or .csv file. Column headers are matched case-insensitively with spaces and punctuation normalized, so Check ID, check_id, and CHECK-ID are the same column. A workbook with several tabs is read from the first tab by default; --worksheet picks another by name or position. Unrecognized columns are ignored with a single warning naming them.
Identity columns
| Column | Meaning |
|---|---|
check_id |
Required. Your catalog's key for the row. Duplicates are errors. Stamped verbatim as additional_metadata.legacy_check_id, which is both the trace back to the source row and the identity re-applies update against. |
kind |
Blank or check (default) for a quality check; computed_table or computed_join for a computed container the checks depend on. |
datastore |
Optional per-row target (name or numeric ID). Rows without it go to every --datastore-id passed to apply; rows with it go only there. |
container |
For checks, the target table or view name (matched by name, casing corrected against the synced schema). For computed rows, the new container's name. |
Check columns
| Column | Meaning |
|---|---|
rule_type |
The Qualytics rule, for example notNull, unique, freshness, existsIn, aggregationComparison, equalTo, between, matchesPattern, satisfiesExpression. Rules outside this list pass through with a warning; supply their properties via properties_json. |
fields |
Comma or semicolon separated field names. Casing is corrected against the synced schema. |
description |
Check description. Generated from check_id and the rule when blank. |
filter |
Row-scope SQL predicate. Dropped with a warning on rules that reject filters, such as freshness. |
coverage |
0 to 1. Omitted on rules without coverage support. |
tags |
Comma or semicolon separated tag names. apply --tag X appends X to every row. Tags are created on the target if missing. |
status |
Active or Draft per row. The default is Draft, so everything is reviewed before it fires. |
anomaly_message_field |
Field whose value becomes the anomaly message. |
Rule properties
| Column | Used by | Notes |
|---|---|---|
value |
freshness (max age: raw milliseconds or shorthand like 45s, 90m, 36h, 7d, 2w), equalTo, greaterThan, lessThan (numeric, with inclusive) |
|
min, max, inclusive_min, inclusive_max |
between |
One-sided ranges narrow to greaterThan or lessThan automatically; inclusivity defaults to true |
inclusive |
equalTo, greaterThan, lessThan |
Defaults to true |
pattern |
matchesPattern |
Regular expression |
expression |
satisfiesExpression, aggregationComparison (left-side aggregate) |
|
comparison |
aggregationComparison |
lt, lte, eq, gte, gt; also accepts <, <=, =, >=, > and long forms |
ref_expression |
aggregationComparison |
Right-side aggregate, evaluated on the referenced container |
ref_container |
existsIn, notExistsIn, aggregationComparison |
Referenced container name, resolved on the target at apply time |
ref_field |
existsIn, notExistsIn |
The referenced field's name |
ref_datastore |
Cross-datastore references | Name or numeric ID. Omit when the referenced container lives in the same datastore |
ref_filter |
existsIn, aggregationComparison |
SQL filter on the referenced container |
properties_json |
Any rule | JSON object merged over the flat columns, the escape hatch for anything not listed above. A key set by both a flat column and the JSON with different values is an error |
Required columns are validated per rule before anything runs: freshness requires value; existsIn requires fields, ref_container, and ref_field; aggregationComparison requires expression, comparison, ref_container, and ref_expression.
Computed container columns
| Column | Meaning |
|---|---|
query |
The container's SQL. For computed_table this runs on the source database in its native dialect. For computed_join it is the join query written against the source aliases. |
sources |
computed_join only: the joined containers as orders=o; customers=c pairs (the alias defaults to the name), or a JSON list. Names resolve to containers on the target at apply time. |
description |
Container description. |
A check row that targets a computed container declared in the same sheet just names it in container; apply orders the phases. Row order never matters between checks and containers. It matters only among computed containers themselves: a computed_join may read a computed table from the same sheet only if that table's row comes first.
Qualify tables inside computed SQL
A computed table's query runs directly on the source database, where tables usually need a schema prefix even though synced container names do not: write tpch.ORDERS, not ORDERS. The datastore's schema is shown by qualytics datastores get --id N. migrate validate checks each query against the source and reports failures like Invalid object name before anything is created.
Custom metadata columns
A column headed metadata:<key> writes <key> into every produced check's additional_metadata, key taken verbatim, casing and punctuation preserved. An empty cell means the key does not apply to that row, so one sheet can carry metadata:X for some rows and metadata:Y for others:
check_id,rule_type,container,fields,metadata:Business Domain,metadata:SLA Tier
100,notNull,orders,order_id,Treasury,
200,unique,invoices,invoice_id,,gold
legacy_check_id is reserved (it is set from check_id); a metadata: column naming it is an error.
Plan
Validate and summarize the sheet offline. No authentication, no network.
qualytics migrate plan --sheet week1.xlsx
qualytics migrate plan --sheet week1.xlsx --worksheet "Week 1" --show-checks
| Option | Type | Default | Required | Description |
|---|---|---|---|---|
--sheet, -s |
TEXT | - | Yes | Path to the check sheet (.xlsx or .csv) |
--worksheet |
TEXT | first tab | No | Workbook tab to read: name or 1-based position |
--status |
TEXT | Draft |
No | Default landing status for rows without one |
--tag |
TEXT | - | No | Tag to attach to every check (repeat for multiple) |
--show-checks |
FLAG | false |
No | List every row and the check it produces |
--emit-yaml |
TEXT | - | No | Also write the converted checks to this directory |
--strict |
FLAG | false |
No | Exit non-zero when the sheet has error rows |
Problems are graded: errors (a missing required column, a duplicate check_id, an unknown kind) stop those rows from ever applying, while warnings (an unrecognized column, date logic with no explicit time zone conversion) apply but deserve a look.
Validate
Fail early. Everything plan checks, plus read-only resolution against target datastores: containers exist (or the sheet creates them), field names match the synced schema, every ref_container, ref_datastore, and ref_field resolves (each printed with the container it resolved to), join sources exist, and each computed container's SQL is checked against the source without creating anything. Exits non-zero on any problem.
Validating against datastore 42 (read-only)...
row 6 (770): SQL for 'orders_recon' validated against the source
row 4 (326): ref 'CUSTOMER' → container 169 in datastore 43
row 5 (211): ref 'REGION' → container 176 in datastore 43
datastore 42: all references resolve
Validation passed.
| Option | Type | Default | Required | Description |
|---|---|---|---|---|
--sheet, -s |
TEXT | - | Yes | Path to the check sheet (.xlsx or .csv) |
--worksheet |
TEXT | first tab | No | Workbook tab to read: name or 1-based position |
--datastore-id |
INTEGER | - | No | Target datastore to resolve against (repeat for multiple); omit for offline-only checks |
--validate-sql / --no-validate-sql |
FLAG | --validate-sql |
No | Check each computed container's SQL against the source |
If the instance itself cannot be reached (an expired sign-in, a connectivity problem), validate reports that once as an instance problem rather than flagging every computed row, so a real SQL mistake is never confused with an outage.
Apply
Create everything on the target. Two phases, in order:
- Computed containers. Every specification is validated first; a validation failure stops the phase before anything is created. Containers are then created in declaration order, and the CLI waits for each container's own profile operation to finish before moving on, so dependent joins and checks always see profiled fields.
- Checks. Container and field names are case-corrected against the synced schema, cross-references resolve to the right datastore, and checks are created or updated in place. Failures are reported per row and never stop the run.
# Preview: no changes, dependent checks count as creates
qualytics migrate apply --sheet week1.xlsx --datastore-id 42 --dry-run
# Apply
qualytics migrate apply --sheet week1.xlsx --datastore-id 42
# Re-apply after sheet edits, keeping checks a reviewer already activated
qualytics migrate apply --sheet week1.xlsx --datastore-id 42 --preserve-status
| Option | Type | Default | Required | Description |
|---|---|---|---|---|
--sheet, -s |
TEXT | - | Yes | Path to the check sheet (.xlsx or .csv) |
--worksheet |
TEXT | first tab | No | Workbook tab to read: name or 1-based position |
--datastore-id |
INTEGER | - | No | Target datastore for rows without a datastore column (repeat for multiple) |
--dry-run |
FLAG | false |
No | Preview what would be created or updated |
--status |
TEXT | Draft |
No | Default landing status for rows without one |
--preserve-status |
FLAG | false |
No | Omit status so re-applies keep what is set in the platform |
--tag |
TEXT | - | No | Tag to attach to every check (repeat for multiple) |
--validate-fields / --no-validate-fields |
FLAG | --validate-fields |
No | Check field names against the synced schema and correct their casing |
--skip-containers |
FLAG | false |
No | Skip the computed-container phase (containers already ensured) |
--on-existing |
TEXT | skip |
No | What to do when a computed container already exists: skip or update |
--force-drop-fields |
FLAG | false |
No | With --on-existing update: allow definition changes that drop fields carrying checks (the platform preserves those checks; they reactivate if the fields reappear) |
--profile-timeout |
INTEGER | 900 |
No | Seconds to wait for each created container's profile operation |
--emit-yaml |
TEXT | - | No | Also write the converted checks to this directory |
--run-dir |
TEXT | migrate-runs/<timestamp> |
No | Directory for this run's artifacts; empty string disables |
--results-csv |
TEXT | <run-dir>/results.csv |
No | Per-check results ledger path; empty string skips |
--failures-log |
TEXT | <run-dir>/failures.log |
No | Failure log path |
--strict |
FLAG | false |
No | Exit non-zero when the sheet has error rows or anything fails |
Updating existing computed containers
--on-existing update compares the sheet against the live definition and only sends real changes: an identical definition is reported unchanged, a description or metadata edit takes a light path that never re-profiles, and a genuine SQL change triggers validation, the update, and a fresh profile. A change that would drop fields carrying quality checks is refused by the platform; add --force-drop-fields to proceed (the affected checks are preserved and reactivate if the fields reappear), and update the sheet's dependent check rows to the new field names.
Run artifacts
Every real run leaves an audit folder (dry runs write nothing):
migrate-runs/20260917-150505/
├── run.log # the full terminal transcript, timestamped per line
├── results.csv # one row per check: check_id, created/updated/failed,
│ # container, rule, status, a link to the check, and the
│ # failure reason for failed rows
├── failures.log # failure detail
└── yaml/ # the definitions exactly as applied, per datastore
results.csv is the receipt that goes back to whoever owns the source catalog: their ID, what happened to it, and where it now lives.
Cross-datastore references
ref_container never resolves globally. With ref_datastore filled, the name is looked up only in that datastore; without it, only in the row's own target datastore. A name that does not resolve in the scoped datastore is an error, never a guess, and names whose casing is ambiguous on the target (say Orders and ORDERS both exist) are rejected with both variants named.
check_id,rule_type,container,fields,ref_datastore,ref_container,ref_field
900,existsIn,STG_POSITIONS,PORTFOLIO_ID,Analytics Warehouse,portfolio_status,PORTFOLIO_ID
This row's check lives on STG_POSITIONS in the apply target, and its reference resolves inside the Analytics Warehouse datastore. migrate validate prints the resolved container for every reference so a wrong scope is visible before anything is created.
Idempotency
Checks are matched by legacy_check_id, the sheet's own check_id. Re-running apply after sheet edits updates checks in place rather than duplicating them, and two same-shaped checks with different IDs stay separate. Because a re-apply sets status from the sheet by default, use --preserve-status so checks a reviewer activated in the platform stay active.
Where to look next
- Quality Checks: managing the checks the migration creates
- Containers: computed tables, files, and joins in general
- dbt Import: the same idea when the source catalog is a dbt project
- Config as Code: keeping exported checks in Git