Rules reference¶
dlt-ops pipeline validate runs a resolved set of rules against the discovered sources and .dlt/config.toml. This page lists every rule ID shipped by the package and its first-party plugins, with what each checks, why, and which tier enforces it. For the tier model itself, see validation.
How rules resolve¶
Rules resolve from provider defaults, overlaid by the [dlt_ops.rules] on/off knob and per-source exemptions; an unknown rule ID is a typo-guard error in both tiers.
- Rules arrive from providers registered in the
dlt_ops.validatorsentry-point group. The package's own rules ship through the same mechanism (core); installing a plugin distribution with a validator provider auto-activates its rules. A provider that fails to load is reported (its rules are unavailable that run), never silently skipped. - Every shipped rule defaults to on except
incremental_cursor_required, which ships off.[dlt_ops.rules]flips individual rules per project (rule_id = false, orrule_id = trueto adopt an opt-in one); a missing entry means the registered default. [sources.<X>.dlt_ops.rule_exemptions]suppresses one rule's findings for one source, with a mandatory non-empty reason string. The rule still runs for every other source. One exception:import_safetycannot be exempted per source, and naming it there is a config error —validatefails with arule_exemptions.import_safetyfinding rather than honouring it. The project-wide[dlt_ops.rules] import_safety = falseswitch is the only opt-out. The refusal is deliberate: every other exemption filters findings out of a rule that only ever reported, whereas this rule also decides whether the module is imported into the calling process at all — a decision made per module (one module may declare several sources) in a discovery pass every consumer runs,runand the DAG factory included. "Execute project code in this process" is a property of the process, not of one source being loaded into it. The refusal itself rides the always-on import-health path rather than the rule framework, so the very exemption being refused cannot suppress the refusal.- Rule IDs are stable within a major version — the knob and exemption tables key on them.
- Unknown rule IDs in either table are errors:
validatefails, and so does the Tier-2 runtime preflight on everyrun/backfill(the typo guard).
Inspect the resolution for your project — the command prints every known rule with its on/off state and owning plugin:
$ dlt-ops pipeline validate --show-resolved-rules
Resolved rules (23):
bigquery_partitioning on bigquery
bigquery_partition_hints on bigquery
import_safety on core
config_section_required on core
schedule_required on core
explicit_source_name on core
module_name_matches_section on core
orphan_config_sections on core
no_resource_overlap on core
json_hints_for_dict_fields on core
pydantic_columns_required on core
pydantic_model_forbids_extra on core
schema_contract_declared on core
explicit_resource_name_multi_source on core
cursor_not_load_timestamp on core
incremental_cursor_required off core
secret_backend_registered on core
alert_sink_registered on core
destination_capability on core
stale_sources on core
assertion_config_valid on core
assertion_columns_exist on core
assertion_predicate_resolvable on core
A bare environment resolves 23 rules: 21 from core and 2 from bigquery. The airflow provider adds one more (airflow_var_required) only when the [airflow] extra is installed — it loads either way, so plugins doctor stays green on a bare install, but contributes no rules until airflow is importable, and so does not appear above.
Every core rule but one defaults to on. incremental_cursor_required ships off, and this listing is where you discover it — the section below explains why it is a project's decision rather than a default.
To switch a rule off, set it to false under [dlt_ops.rules]; to exempt one source, name the rule under that source's [dlt_ops.rule_exemptions] with a reason. Both key on the rule ID:
[dlt_ops.rules]
stale_sources = false # off project-wide
[sources.legacy_api.dlt_ops.rule_exemptions]
pydantic_columns_required = "third-party generator yields untyped rows" # off for one source
Findings are errors unless tagged warning below. Every run renders both kinds; the difference is the exit code. A plain validate fails only on errors, so a project whose sole findings are warnings prints them and still exits 0. validate --strict promotes warnings to errors — same messages, rendered as errors — and exits 1. Reach for --strict when a warning should block CI.
Rule catalog¶
Every rule this page documents, at a glance — owning plugin, enforcement, and what it checks. Rule IDs are the exact strings --show-resolved-rules emits; the detailed sections below carry the rationale and edge cases, and airflow_var_required is active only with the [airflow] extra.
| Rule ID | Plugin | Enforcement | What it checks |
|---|---|---|---|
import_safety |
core | Tier 1 | Source modules do no network I/O, disk writes, pipeline runs, or process spawns at import (disk reads OK); enforced in the Phase-2 sandbox. |
config_section_required |
core | Tier 1 | Every discovered source has a [sources.<X>] section in .dlt/config.toml. |
schedule_required |
core | Tier 1 | Every source declares a valid schedule (@hourly … @manual). |
explicit_source_name |
core | Tier 1 | @dlt.source(name="<X>") names its section explicitly. |
module_name_matches_section |
core | Tier 1 | Source module filename equals its config section (source/<X>.py ↔ [sources.<X>]). |
orphan_config_sections |
core | Tier 1 · warning | A [sources.<X>] section with no matching discovered source is flagged. |
no_resource_overlap |
core | Tier 1 | No two sources in a pipeline directory declare a resource with the same name. |
json_hints_for_dict_fields |
core | Tier 1 | Every Pydantic dict / list[dict] field carries a data_type="json" column hint. |
pydantic_columns_required |
core | Tier 1 | Every @dlt.resource declares columns= resolving to a Pydantic model. |
pydantic_model_forbids_extra |
core | Tier 1 | No columns= model resolves to a discard_value contract — unknown columns must fail, not vanish. |
schema_contract_declared |
core | Tier 1 | A declared schema_contract is exactly the canonical freeze literal (or the opted-in evolve literal). |
explicit_resource_name_multi_source |
core | Tier 1 | In multi-source directories, every @dlt.resource passes an explicit name=. |
cursor_not_load_timestamp |
core | Tier 1 | No incremental cursor uses the configured load_timestamp_column. |
incremental_cursor_required |
core | Tier 1 · off by default | Every resource of a recurring-schedule source declares an incremental cursor. |
secret_backend_registered |
core | Tier 1 + Tier-2 twin | Every engaged secret backend resolves to a registered, healthy secret_backend plugin. |
alert_sink_registered |
core | Tier 1 + Tier-2 twin | Every configured alert_sinks name is a registered, constructible alert_sink plugin. |
destination_capability |
core | Tier 1 + Tier-2 twin | The resolved destination supports each adapter-gated feature the source engages (else error, or a core-tier warning). |
stale_sources |
core | Tier 1 · warning | A source with run history whose last run exceeds staleness_days (default 7) is flagged. |
assertion_config_valid |
core | Tier 1 + Tier-2 twin | Every assertions.<resource> table is structurally valid and references real resources and types. |
assertion_columns_exist |
core | Tier 1 | Columns named in assertion params exist on the resource's Pydantic model. |
assertion_predicate_resolvable |
core | Tier 1 | Every custom assertion predicate imports and resolves to a callable (sandboxed). |
bigquery_partitioning |
bigquery | Tier 1 | Every bigquery_adapter(...) call passes partition= and cluster= (per-call # no-partition: / # no-cluster: escape). |
bigquery_partition_hints |
bigquery | Tier 1 | Every resource resolving to BigQuery carries a real partition column hint at runtime (_dlt_load_id doesn't count). |
airflow_var_required |
airflow | Tier 1 · [airflow] extra |
A source using dlt.secrets.value configures airflow_var (only when [airflow] is installed). |
Tiers¶
Every rule on this page is Tier 1: statically checkable, run by validate (pre-deploy, CI). The runtime does not trust that validate ever ran — every run / backfill additionally executes a narrow Tier-2 preflight that hard-fails on critical preconditions: a referenced plugin not registered (secret backend, alert sink, assertion type), a destination that dlt cannot resolve or that engages an adapter-gated feature it cannot provide (a registered-but-broken or capability-incomplete adapter fails here too), a plugin that soft-failed at load, an unknown rule ID in [dlt_ops.rules], and backfill bounds supplied for resources without an incremental cursor. The overlap with Tier 1 (secret_backend_registered, alert_sink_registered, destination_capability, the assertion_* rules, the typo guard) is deliberate redundancy: orchestrator-triggered runs must fail fast rather than degrade silently. Tier 2 is not configurable and has no rule IDs — the per-rule tags below mark which rules have a Tier-2 twin.
Three findings sit outside the rule framework entirely — always on, no rule ID, no knob, and no exemption:
import— a source module that cannot be imported cannot run, sovalidatealways reports it.validation_coverage— one error per source the Phase-2 sandbox excluded, naming the rule coverage that exclusion cost. Every rule that iterates sources skipped it, so its findings are unknown rather than clean, and saying so is what keeps "no findings" from reading like "checked and fine".rule_exemptions.import_safety— the refusal described above, when a source tries to exemptimport_safetyper source.
All three ride this path rather than the rule framework for the same reason: a rule's findings are filtered by that source's exemptions, so routing any of them through a rule would let the thing being reported suppress its own report.
Core rules (plugin: core)¶
The core rules ship with the base distribution's core provider. All are on by default except incremental_cursor_required.
import_safety¶
Tier 1 (validate).
Source modules must be import-safe: no network I/O, no disk writes, no pipeline runs, no process spawns at module load (disk reads are fine). Findings come from the Phase-2 sandbox, which imports each module under CPython audit hooks. This catches the orchestrator foot-gun where a module-level requests.get(...) fires on every scheduler heartbeat that parses the file. Disabling the rule (import_safety = false) also skips the sandbox entirely; per-module import errors are still isolated so a broken module never breaks sibling discovery.
This is the one rule with no per-source exemption. Listing it under [sources.<X>.dlt_ops.rule_exemptions] fails validate as a config error; the project-wide switch is the only way off.
config_section_required¶
Tier 1 (validate).
Every discovered source has a [sources.<X>] section in .dlt/config.toml. A source without config cannot resolve a schedule, destination, or secrets.
schedule_required¶
Tier 1 (validate).
Every source declares schedule under [sources.<X>.dlt_ops], and its value is one of @hourly, @2hourly, @daily, @weekly, @monthly, @manual. The schedule is what orchestrator adapters build DAGs from.
explicit_source_name¶
Tier 1 (validate).
The @dlt.source decorator names its section explicitly: @dlt.source(name="<X>"). Discovery and config resolution key on that name; relying on dlt's function-name fallback makes renames silently break the config link.
module_name_matches_section¶
Tier 1 (validate).
The source module's filename equals its config section (source/<X>.py ↔ [sources.<X>]). This is load-bearing for dlt itself: dlt.secrets.value resolution uses the module name to find the right config section.
orphan_config_sections¶
Tier 1 (validate) · warning.
A [sources.<X>] section with no matching discovered source is flagged — usually a leftover from a deleted source or a typo'd section name. Known dlt-native sections (data_writer, normalize, load, extract) are excluded. Being a warning, it prints in every run but fails the command only under validate --strict.
no_resource_overlap¶
Tier 1 (validate).
No two sources within the same pipeline directory declare a resource with the same name. Overlapping names make table ownership and cleanup ambiguous.
json_hints_for_dict_fields¶
Tier 1 (validate).
Every Pydantic model field typed dict / list[dict] carries a data_type="json" column hint. Without it, dlt normalizes such fields into nested child tables instead of a single JSON column — on any destination.
pydantic_columns_required¶
Tier 1 (validate).
Every @dlt.resource declares columns= resolving to a Pydantic model. Without a declared schema, dlt infers types at load time — and a column whose values are all NULL in the first load is silently dropped, then can never be added under a frozen-columns contract. Attribute references (columns=cfg.model, the factory pattern) are accepted.
pydantic_model_forbids_extra¶
Tier 1 (validate).
No resource runs under a columns: "discard_value" contract. dlt derives a resource's schema_contract from its columns= model: extra="forbid" derives columns: "freeze", extra="allow" derives "evolve", and leaving extra unset — Pydantic's default — derives "discard_value". Under discard_value a field the model does not declare is stripped from every row with no error, no warning, and no trace in the load package, so a source that starts sending a new column looks identical to one that never did.
The fix is one line on the model:
The rule reads the contract dlt derived rather than re-deriving it, so it cannot drift from dlt's own mapping, and it passes a resource that reaches a non-discarding contract by any route — an explicit canonical schema_contract= literal overrides the model's extra inside dlt, and a strict base class propagates through Pydantic's model_config merge. extra="allow" passes here; whether the source may evolve at all is schema_contract_declared's business.
schema_contract_declared¶
Tier 1 (validate).
A resource that declares no schema_contract passes. For a Pydantic columns= model — which pydantic_columns_required makes mandatory — dlt has already derived the contract from the model's extra, and pydantic_model_forbids_extra is what keeps that derivation canonical. Only a dict columns= or a resource with no columns= at all reaches the runner without a contract, and those are the ones it applies the canonical literal ({"tables": "evolve", "columns": "freeze", "data_type": "freeze"}) to.
A declared contract must be exactly the canonical literal, or the evolve literal (columns: "evolve") on a source that opted in with a non-empty schema_contract_evolve_reason in config. Anything else is an error: contracts are a project policy, not a per-resource preference.
The two routes are not interchangeable, and the difference shows up on the first run. The runtime-applied literal is enforced at normalize time, where dlt gives a brand-new table one free pass — the first run defines the schema, and every later unknown column hard-fails. A model's extra="forbid" is enforced earlier, in the extract step, so it rejects an unknown column even on the very first run.
explicit_resource_name_multi_source¶
Tier 1 (validate).
In a pipeline directory hosting more than one source, every @dlt.resource must pass an explicit name= kwarg (any expression form) so resource-to-source ownership can be attributed for the schema-contract check. Single-source directories are unaffected.
cursor_not_load_timestamp¶
Tier 1 (validate).
No dlt.sources.incremental(...) uses the configured [dlt_ops] load_timestamp_column as its cursor. That column advances on every run, so cursoring on it silently skips in-window source updates — use the provider's business timestamp instead. Inert when load_timestamp_column is unset. It catches a wrong cursor; a missing one is the next rule's question.
incremental_cursor_required¶
Tier 1 (validate) · off by default.
Every resource of a source declaring a recurring schedule declares an incremental cursor. Its sibling above catches a cursor pointed at the wrong column, and only when load_timestamp_column is configured; a resource with no cursor at all re-extracts everything on every run while validate exits 0. This rule closes that gap.
It is the one core rule that ships off, and the reason is that it states a policy rather than a defect. A full refresh is a legitimate choice — a small dimension table is the obvious case — and nothing visible to the package distinguishes "chose to full-refresh" from "forgot the cursor". Turning it on is therefore a project's decision, not an upgrade surprise:
Three details govern what it flags:
- Scoped to recurring schedules. The harm is a full refresh repeating on a cadence, so
@manualsources and sources with no parsed config are out of scope. - It reads the live resource, not the source text.
apply_hints(incremental=...)and factory-built cursors are invisible to an AST scan, and a false "no cursor" would be worse than the gap it closes. That means the rule needs the source to import — a source excluded from Phase 2 is reported byvalidation_coverageinstead. - Error, not warning, when it is on. A warning never fails a run outside
--strict, so a project that deliberately switched this rule on would buy visibility and no gate.
Exemptions are per source, so a source that deliberately mixes incremental and full-refresh resources exempts all of them together, with the reason recorded:
[sources.dim_countries.dlt_ops.rule_exemptions]
incremental_cursor_required = "small dimension table; a full refresh is intended"
secret_backend_registered¶
Tier 1 (validate) · Tier-2 twin.
Every source that engages a secret backend (or uses dlt.secrets.value in its signature) resolves to a registered, healthy backend plugin on the secret_backend axis. The runtime preflight repeats the check.
alert_sink_registered¶
Tier 1 (validate) · Tier-2 twin.
Every name in [dlt_ops] alert_sinks is a registered alert_sink plugin that loads and constructs with its [dlt_ops.alert_sink.<name>] options. Only explicitly configured names are enforced (an unset key means the built-in logging default). The runtime preflight repeats the check.
destination_capability¶
Tier 1 (validate) · Tier-2 twin.
Every source's resolved destination must support each adapter-gated feature the source engages. The destination resolves through the config chain (per-source override, then default_destination); the rule then runs the same capability check as the Tier-2 preflight, so the tiers can't drift. It is an error when the destination is unresolvable through the config chain, is not a destination dlt can resolve (typo guard), or has a registered DestinationAdapter that fails to load or is capability-incomplete. It is also an error when the destination has no adapter and the source engages an adapter-gated feature that acts as a gate — assertion quarantine on a selected resource, @with_checkpoints, or [dlt_ops] require_destination_adapter = true. Otherwise an adapter-less destination is a warning naming the darkened adapter-gated features, because the source runs at core tier, which is allowed by design. The destinations reference has the full tier model. The runtime preflight repeats the capability check on every run / backfill and, unlike this rule, is not configurable.
Checkpoint engagement is detected by the Phase-1 AST scan: @with_checkpoints is seen when its terminal name appears as a decorator (bare or attribute form) in the source module or a shared resource/*.py sibling. An aliased import (... import with_checkpoints as wc) escapes the name match — such a source passes this rule as a core-tier warning, and the runtime CheckpointManager raises a typed error mid-run as the backstop.
stale_sources¶
Tier 1 (validate) · warning.
A source with run history in the _dlt_ops_runs ledger whose last run started more than staleness_days ago (default 7) is flagged as ingested-then-orphaned. Sources with zero history are skipped — they have nothing to be stale relative to. Degrades gracefully: without destination access (unresolved destination, unreachable ledger) the rule stays quiet, so validate never requires credentials. Being a warning, its findings print in every run without failing it — schedule validate --strict if you want staleness to block instead.
assertion_config_valid¶
Tier 1 (validate) · Tier-2 twin.
Every [sources.<X>.dlt_ops.assertions.<resource>] table is structurally sound and references real things. The checks:
- the assertions value and every resource entry are tables;
- every resource key names a resource of the source (the live Phase-2 list);
- every non-reserved key names a registered
assertionplugin (the error lists registered names); on_failureis one offail/quarantine/warnat every level;quarantineis not set on a batch-scoped type;- params pass the type's own
check_config(shape/domain — e.g.min_rows_per_load = -1); customentries are tables with apredicateinmodule:attr(or dotted) form;- no plugin registers a reserved name (
on_failure,custom).
Always-on in bare validate — there is no --include-assertions flag and no dry-run: facts that require extracting data are run's job, not validate's. The runtime preflight has a twin for unregistered types.
assertion_columns_exist¶
Tier 1 (validate).
Every column referenced by assertion params (required_columns, unique_columns, third-party types checking columns) exists on the resource's declared Pydantic columns= model. Skipped (not failed) for a resource whose model is unresolvable — pydantic_columns_required already polices that separately. A separate rule ID from assertion_config_valid on purpose: a source with intentionally dynamic columns can exempt this check without disabling structural config validation.
assertion_predicate_resolvable¶
Tier 1 (validate).
Every [[...custom]] assertion predicate imports and resolves to a callable: module importable, attribute present, attribute callable. The probe runs in the same audit-hook sandbox child as import_safety, calling the runtime's own predicate resolver — so a failing predicate fails validate with exactly the message run would produce, import side effects never run inside the validate process, and import-time network I/O, disk writes, pipeline construction, or process spawns in a predicate module are reported as import-safety findings under this rule.
BigQuery rules (plugin: bigquery)¶
These two rules ship with the core distribution's bigquery validator provider and are active by default; both no-op for projects that never touch BigQuery.
bigquery_partitioning¶
Tier 1 (validate).
The AST half: every bigquery_adapter(...) call in a pipeline directory passes partition= and cluster=. Unpartitioned BigQuery tables scan-charge the full table on every query. Escape hatch per call site: a # no-partition: <reason> / # no-cluster: <reason> comment on the line above the call.
bigquery_partition_hints¶
Tier 1 (validate).
The runtime half: every resource whose resolved destination is BigQuery carries a real partition column hint at runtime — catching resources that never went through a bigquery_adapter() call and are invisible to the AST check. _dlt_load_id does not count (STRING; BigQuery silently ignores STRING partition keys). Sources resolving to any other destination are skipped.
Airflow rules (plugin: airflow)¶
Registered only when the [airflow] extra is installed. The provider loads either way, so plugins doctor stays green on a bare install, but it contributes no rules — and airflow_var_required does not appear in --show-resolved-rules — unless airflow is importable.
airflow_var_required¶
Tier 1 (validate) · requires the [airflow] extra.
A source whose signature uses dlt.secrets.value must configure airflow_var in [sources.<X>.dlt_ops] — otherwise the Airflow secret backend has nothing to fetch from and the DAG fails at runtime instead of at review time. Only meaningful for Airflow-orchestrated projects, which is exactly why it lives in the Airflow plugin and not core.
Third-party rules¶
Any installed distribution can register a validator provider (a zero-argument callable returning rule specs) under the dlt_ops.validators entry-point group. Its rules join this resolution with the same knob and exemption mechanics, and appear in --show-resolved-rules with their plugin name. See Write plugins.