[KEP-10] Dataset Validation #5602
Replies: 3 comments
|
+1 Much improved from KEP-7. None of the comments below are blockers from my end.
Nit: Not super important, but do you think this would be at odds with the idea that you can extend to whichever data validation framework? I.e. if TBH I'm fine with being opinionated and choosing Pandera as the primary path for this, for now.
This is a clear distinction; I quite like it.
I wouldn't over-index on coexistance per se (don't think you have, but I'm not sure migration or anything needs to be called out even). kedro-pandera is essentially unmaintained; that said, I think it would be great to get the insights from their experience + call it out as inspiration in the launch announcement/whatever form it takes!
Interesting, nice call out. I was wondering what the pros and cons of just implementing this as a hook would have been. Guess Kedro also doesn't ship any in-built hooks as part of core, so the pattern is like: when something is a hook, it's an extension mechanism; this way, it's part of core. Makes sense to me, as I think about it.
Sounds reasonable in theory. I'm just curious whether this pattern is already leveraged somewhere, and, if so, if it's consistent with that? I haven't personally double-checked/thought through whether it is truly near-zero-cost—would be important to be certain of, but I believe it.
TIL it was deprecated (since I never really used it to begin with), good catch!
I'd deprioritize PySpark-specific handling, if the PySpark backend for Pandera backend is inconsistent. The truth is that the backend isn't as aligned/complete as some of the others; perhaps some of the work around the Narwhals-backed PySpark backend for Pandera will help that. Definitely something worth confirming before launch (or especially advertising PySpark support).
Is the
Nice!
Nit: Is
Nit: So users wouldn't ever write this? Shouldn't be exposed as
I think this section is unnecessary.
No, you shouldn't include the pandas backend by default... You could do something like the Ibis datasets in Kedro-Datasets, and add extras like
Not very interesting IMO
Please include me in any final review. :) |
|
+1, this is a great proposal and I agree with the general functionality here! 🙂 (Minor thought: do we need to enforce the location of schemas? e.g. does it have to be under |
|
+1 on the proposal. Few thoughts -
The write up is pretty good. Great work @SajidAlamQB Thank you |
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Context: Explore a Kedro-First Approach to Data Validation (#5390) · Spike: Prototype Dataset Validation Using Pandera (#5391)
Authors: @SajidAlamQB
KEP shepherd: @SajidAlamQB
Supersedes: KEP-7 (type-hint-driven dataset validation)
Demo: https://github.com/SajidAlamQB/spaceflights-validation-demo
Prototype Implementation: https://github.com/kedro-org/kedro/tree/feat/kep10-dataset-validation
What changed since v1 (summary for previous readers)
validator:key)TypeExtractorwalks pipeline node signaturesDataCatalogparses catalog config_ValidatingDatasetproxy wrapperload()/save()path — no wrapperValidatorprotocol; Pandera is the reference implementationvalidate_catalog_dataset()API +kedro catalog validateCLIDATASET_VALIDATIONsetting +KEDRO_DATASET_VALIDATIONenv varpd.DataFrame[Schema]still valid, no longer load-bearing)The pivot is a direct response to the review on KEP-7: @deepyaman on schema-on-dataset, save-side validation, catalog inspectability, and why-not-hooks; @noklam on a global disable plus a programmatic API for IDE diagnostics; and @Adeikalam on how schemas-next-to-nodes create cross-pipeline coupling.
What are we trying to do?
Add native dataset validation to Kedro, declared in the catalog:
Whenever something calls
catalog.load("companies")— a pipeline run, a notebook, the IDE extension, CI — the loaded DataFrame is checked againstCompaniesSchemabefore it comes back. On save, the data is checked before it's written, so invalid data never reaches disk. A failure raisesDataValidationErrorand reports every failed check at once (Pandera'slazy=True), naming the dataset, the validator, and whether it failed on load or save.The catalog entry is the single source of truth: one dataset, one validator. The KEP-7 bug where two pipelines annotate the same dataset and the last one silently wins simply can't occur here, because the binding is a key in a YAML mapping rather than something we discover by walking every pipeline.
Who is this for?
validate_catalog_dataset(catalog, name)and render the structured failures as editor diagnostics — no run, nothing raised.kedro catalog validate --resolve-onlyto catch typo'd validator paths and missing dependencies on every PR, and optionally run full data validation on a schedule.catalog.to_config()andcatalog.validators, without executing anything.validator:key.What this is not
validator:is a new reserved key; everything else is untouched.pip install "kedro[validation]".@check_typeson the function is still the right tool and works alongside this. Using both on the same dataset just validates twice, which we document.How is it done today, and where does it fall short?
There are roughly four ways people validate data in Kedro projects now:
after_dataset_loaded/before_dataset_saved) pointing at schemas in another module. The control flow is hidden, it's hard to discover, every new dataset means editing the hook, and — this is the important one — dataset I/O hooks only fire inside a pipeline run, so a notebook or an ad-hoccatalog.load()isn't covered at all.kedro-panderaplugin, which declares Pandera schemas under the catalogmetadata:key and validates via hooks. It's essentially unmaintained now, but it proved there's real demand for declarative, catalog-adjacent schemas, and it's the direct inspiration for this proposal. We should pull on its design experience and credit it properly in the launch writeup. This KEP brings the idea into core with a first-class key, an enforcement point that also covers non-run access, and a backend-agnostic protocol.@check_typesdecorator on node functions. It ties validation to the function, so it runs in every test and notebook call, and says nothing about data loaded outside that function.We'll still rewrite Kedro's own
docs/integrations-and-plugins/pandera.mdto lead withvalidator:and move the hand-rolled hook recipe down into an "advanced / conditional validation" section, with a note not to double-validate the same dataset. I don't think we owe a formal migration path: kedro-pandera is effectively unmaintained and the v1 prototype never shipped.What's new here, and why I think it'll work
Architecture overview
Save mirrors this:
catalog.save(name, data)validates beforedataset.save(), so invalid data is never persisted.Design decision 1: validation lives in the catalog's load/save path, not a wrapper
Heads up — this is a change from what I said in the pivot announcement, where I expected to keep the
_ValidatingDatasetwrapper. Once I started actually building it, the wrapper caused more trouble than it was worth, and dropping it cleared up several review comments at once.The problems with the wrapper were structural, not cosmetic. A proxy around the dataset breaks
isinstance(ds, AbstractVersionedDataset)in the catalog's own version handling, has to fake_EPHEMERAL(which runners check) and_SINGLE_PROCESS, has to delegateexists()/release(), and has to surviveForkingPicklerforParallelRunner. Putting the check incatalog.load/saveinstead means none of that applies — the dataset stays exactly what the YAML says it is.A few things fall out of that:
catalog.validatorsproperty, they show up inkedro catalogoutput, and they round-trip throughcatalog.to_config()(which re-injects thevalidator:key, which is what lets Kedro-Viz show schemas statically).Taskcallscatalog.load/catalog.save(task.py:152, 186), and so do notebooks,kedro ipython, plugins, and CI scripts. One enforcement point covers all of them. (What it doesn't cover is in the Risks section — I'd rather be upfront about that.)before_dataset_loaded→catalog.load(validates) →after_dataset_loadedalways sees validated data. If validation were itself a hook, that ordering would depend on pluggy registration order.The cost is honest to state: about 25 lines in
kedro/io, which is one of the most stable modules we have, and on a hot path. The mitigations are that the disabled/no-spec path is a dictionary miss,kedro.validationis imported lazily inside_maybe_validate, and I'll ship a micro-benchmark of the no-op path with the PR (see Performance).Two contracts the path defines, both pinned by tests:
validate()counts as a validation failure. A custom validator can raiseValueError,pydantic.ValidationError, whatever — the catalog normalises it toDataValidationErrorwith the dataset name and mode attached. The separateerroredstatus (see the API) is only for failures beforevalidate()runs at all: an import error, or the dataset failing to load.coerce=Truehands back a coerced frame, and that coerced frame is what the node gets on load and what gets written on save. This is a real behaviour and we document it prominently — see Risks for the hook-observability wrinkle it creates.Design decision 2: the
validator:keyvalidator:becomes a reserved top-level dataset-config key, sitting alongsidetype:/versioned:/credentials:/metadata:. Two forms:Parsing.
DataCatalog._add_from_config()is the one place both explicit entries and lazily-resolved factory patterns pass through. We capture the key intocatalog._validator_specs[name]and hand a cleaned copy of the config (withoutvalidator:) on to dataset instantiation. The resolver's own config store isn't mutated, sokedro catalog resolveand Kedro-Viz still see consistent, validator-bearing configs.parse_dataset_definition()also strips the key defensively (the same way it already popsmetadata, core.py:250) so a directAbstractDataset.from_config()call doesn't blow up with aTypeError. Avalidator:nested inside aCachedDatasetdataset:block is a configuration error with a fix-it message ("declare it on the top-level entry") rather than a silent strip — if you asked for validation, you shouldn't quietly lose it. I'll also audit kedro-datasets for any dataset whose__init__takes avalidatorkwarg and document the key as reserved next toversioned/credentials.Spec validation.
_ValidatorSpec.from_configonly accepts astror adict. It rejects unknown long-form keys (allowed:class,on,severity,enabled,skip_load_after_save,options), checkson ⊆ {load, save}and non-empty, and rejects a YAML list with a "reserved for future use" message — so we can add a per-direction list form later (validator: [{class: A, on: [load]}, {class: B, on: [save]}]) without breaking anyone. There's one YAML 1.1 wrinkle worth calling out: an unquotedonkey parses as booleanTrue(the "Norway problem", and it survives the OmegaConf round-trip — I checked).from_confignormalises theTruekey back to"on"so the examples above work unquoted, as written.Lifecycle. The spec follows the catalog entry, not the name forever. If you replace a dataset explicitly (
catalog["companies"] = dfin a notebook) the spec is cleared, so no stale validator fires against the replacement. Factory materialisation does not clear it. Both are pinned by tests.Design decision 3: schemas live in
src/<package>/schemas/, referenced by import pathSchemas are code, kept in one conventional place — the same idea as
conf/<env>/parameters.ymlfor parameters:The catalog points at them with a full dotted import path, resolved with the existing
kedro.utils.load_obj. It's a convention rather than anything magic: no implicit module prefixes, so the paths are grep-able (@Adeikalam's discoverability point) and unambiguous. The one rule is that a validator path has to be a top-level attribute of an importable module, which is exactly what theschemas/convention gives you; nested classes and notebook-defined__main__schemas are rejected with a hint. The spaceflights starters get the folder scaffolded with one example schema and a commented-outvalidator:line; other starters are left alone.Resolution is lazy, with a cheap pre-flight. At catalog build time we only parse the spec — strings, no imports, no pandera, no startup cost, and factory entries work naturally. The actual import happens on first load/save or first API call. To stop a typo from surfacing three hours into a run, catalog build runs a near-zero-cost pre-flight:
importlib.util.find_spec()on each spec's top-level package, no module execution. A miss is a warning by default and a hard error underDATASET_VALIDATION = "strict". The complete fail-fast gate iskedro catalog validate --resolve-onlyin CI.Design decision 4: a small pluggable
ValidatorprotocolThe resolution order matters, and it's deliberate:
kedro.validatorsentry-point group).optionsand protocol-check the instance. We never duck-type the class object directly, becauseisinstance(SomeClass, runtime_checkable_protocol)is true for any class that merely defines avalidatemethod — which would quietly return the uninstantiated class and dropoptions. (This one bit me in the prototype, so there's a test for it.)pa.DataFrameSchema.from_yaml(...)result), we protocol-check it; passingoptionsto an instance is a config error, since they can't be applied.ValidationConfigurationErrorthat names the dataset, the path, and what it actually found.The Pandera reference adapter handles real-world Pandera, not just the legacy import style:
pandera.pandas,pandera.polars, andpandera.pysparkDataFrameModel/DataFrameSchema. The top-levelpandera.DataFrameModelis deprecated as of pandera 0.30 and on its way out, so detecting only that (like the prototype did at first) would miss every modern schema.pandera.pysparkdoesn't raise on failure, it accumulates errors ondf.pandera.errors. The adapter branches per backend and raisesDataValidationErroroff the accessor when it's non-empty, otherwise invalid Spark data would sail through as a false pass.LazyFrame, Pandera validates at schema-only depth by default, so data-level checks are silently skipped. The adapter logs a one-time warning per dataset saying what depth actually ran, and offersoptions: {collect: true}to validatedata.collect()and return the original LazyFrame. The docs recommend validating eagerly on save of the producing dataset instead.head/tail/sample/random_stateare forwarded toschema.validate()as the cost knob for large frames. An unknown option is a configuration error naming the option and the adapter version, not a silently-ignored no-op.SchemaErrors.failure_casesis mapped into structured, grouped failures (theCheckFailuretype) with capped row examples, so error messages stay bounded even on million-row frames. The full Pandera report stays available onerr.__cause__.A worked Great Expectations adapter and a non-tabular metrics validator are in Appendix A — I checked the protocol against both, plus a per-row Pydantic validator, to make sure
validate(data) -> datais actually enough.Design decision 5: save-side behaviour
dataset.save(), so invalid data is never written.skip_load_after_save: trueflag. It's a per-catalog-instance set (never pickled, cleared onrelease()) that records save-validated datasets, and load validation is skipped only when this process wrote the data. Unlikeon: [save], this stays safe under partial runs —kedro run --from-nodes Bloads data this run never wrote, so it validates normally. I'd recommend it for round-trip-faithful formats like Parquet; a later extension could key onsave_versionfor cross-process caching.unique, frame-level checks) aren't enforced across the whole output — I'd rather say that plainly than imply a guarantee we don't have. For chunk-appended outputs, useon: [load]so the whole persisted dataset is validated on the next read. The per-chunk behaviour is pinned by a test so it's intentional rather than accidental.Design decision 6: opt-out and the kill switch
True— validation on, lazy resolution, pre-flight warns on missing packages.False— validation off everywhere; the path short-circuits before any import, so disabled really is zero cost. Explicit API calls still validate (below)."warn"— likeTrue, but a configuration error (an unresolvable validator, e.g. a deliberately slimmed serving image) downgrades to a warning and skips. Data failures still raise. Per-dataset failure tolerance is theseverity: warnkey, not this."strict"— likeTrue, plus every declared validator (including pattern-level ones) is resolved eagerly at catalog build, and any resolution failure fails the session immediately.The setting is validated at load (
is_in (True, False, "warn", "strict")), so a typo is a settings error rather than something that silently evaluates as truthy-and-on. I went withDATASET_VALIDATIONrather than theVALIDATION_ENABLEDname I floated earlier, to keep it clearly separate from KEP-1 parameter validation.The env var
KEDRO_DATASET_VALIDATION=0|false|1|true|warn|strictis read directly in_maybe_validate(same idea asKEDRO_MP_CONTEXTbeing read insidekedro/runner) and wins over the setting in both directions. Reading it inkedro.io, which never imports framework settings, means the emergency kill switch covers every catalog instance — including catalogs a plugin rebuilds from config inside a hook (the kedro-telemetry pattern) and a bareDataCatalog.from_config()in a notebook.For standalone catalogs,
DataCatalogitself defaultsvalidation_enabled=True, so declared config is honoured everywhere unless something turns it off;KedroContextoverrides it from settings/env. A catalog rebuilt inside a hook therefore validates on its own and idempotently — validation is per-load(), there's no wrapping, so there's nothing to double-apply. There's a kedro-telemetry-shaped regression test for that.Custom
DATA_CATALOG_CLASSis the one rough edge. The path members are deliberately not added toCatalogProtocol, because adding members to aruntime_checkableprotocol would instantly fail settings-load for every existing custom catalog, which would be a breaking change. Instead, at context catalog-creation, if validation is on and specs exist but the catalog class doesn't have the path (ahasattrcheck), Kedro logs a clear warning that declared validators will be ignored — so it's at least diagnosable rather than silent.SharedMemoryDataCataloginherits the path, and there's a regression test that validators fire insideParallelRunnerworkers.Design decision 7: the programmatic API (IDEs, notebooks, CI)
Full signatures and dataclasses are in Appendix A. The behaviour that matters:
enabled: false(one rule, no special cases), and the result carries the enabled-state so an IDE can grey it out rather than flag it red.ValidationResultwithstatus ∈ {passed, failed, skipped, errored}plus a machine-readableerror_type(missing_dependency/unresolvable_validator/dataset_error), so the VSCode extension can collapse "pandera not installed" into one workspace notice instead of painting every dataset red.datais optional and isn't something a user writes by hand: omit it and the dataset is loaded viacatalog.get(name).load()(the raw path, which bypasses the catalog check, so the API never double-validates). Internally a sentinel distinguishes "not passed" from an explicitNone, becauseNoneis itself validatable data (the JSON metrics example) — users never see or type the sentinel.version=passes through to versioned datasets.on="save"needs explicitdata, since validating loaded data against save rules wouldn't mean anything.raise_if_failed()flips it back to raising semantics in one call, and the validated/coerced data is on the result.validate_catalog(catalog)validates in bulk, and it includes factory-pattern entries by resolving the patterns against the project pipelines (the same machinerykedro catalog resolveuses). Without that, the CI gate would silently skip exactly the lazily-resolved entries that most need checking.The CLI:
kedro catalog validate--resolve-onlyimports and resolves every declared validator (explicit and pattern-level) without loading data — the cheap CI gate for typos and missing dependencies that lazy resolution otherwise gives up. Non-zero exit on anyerrored.--pipelineor explicit names. Validating all data across a big catalog is unbounded cost, so the docs recommend resolve-only as the default CI gate.skipped: no validatorfor pipeline datasets that don't have one, which also surfaces the namespace gap: a validator oncompaniesdoesn't match a namespacedns.companies, and the recipe for that is a"{namespace}.companies"pattern entry.What a failure actually looks like
Key features and benefits
kedro ipython, plugins, the programmatic API — not just runner-mediated loads.isinstancebehaviour. Catalog inspection stays honest.kedro.validatorsentry-point group in v1.x.DATASET_VALIDATION=Falseor the env var is a zero-cost short-circuit.to_config()round-trip pluscatalog.validatorsalready unlock Kedro-Viz schema display and schema-driven catalog docs without further core changes.One schema across many datasets (@deepyaman's training-node example)
Within a single catalog file, declare it once and reference it per dataset with a YAML anchor (the leading-underscore key is dropped by
OmegaConfigLoaderafter merge — verified atomegaconf_config.py:384):To be honest about the limit: anchors are per-file. Kedro's multi-file catalog convention means cross-file sharing is either "repeat the one-line dotted path" (which is grep-able, so not the worst thing) or a dataset factory pattern where the naming allows it, e.g.
"{name}_model_input":. The cost versus v1's single node annotation is N one-line references instead of one — and that's the trade I'm deliberately making, because the per-dataset declaration is exactly what kills the silent conflicts and the cross-pipeline coupling. For genuinely function-shaped contracts,@check_typeson the node is still the better fit.YAML-defined Pandera schemas already work
Because the adapter accepts
DataFrameSchemainstances, a module-level schema loaded from YAML:referenced as
validator: spaceflights.schemas.companies_yaml.schema, works with no new code. The caveats are documented: Pandera YAML is pandas-only and only serialises registered checks. APanderaYamlValidator(schema_file=...)convenience with package-relative path resolution is v1.x sugar, not something v1 depends on.Risks
A consolidated list, each with how I'd handle it:
DataCatalog.load/save. Mitigated by the dict-miss fast path, lazy imports, and a published no-op-path micro-benchmark (see Performance). Any bug in this path touches every load/save, which is why the test matrix below is large.catalog.get(name).load(),catalog[name].load(), and direct dataset access all skip validation — that's the raw path the API itself needs. Worth flagging specifically: Kedro-Viz previews calldataset.preview()directly and are not validated; Viz's benefit here is static schema surfacing, not validated previews. We document this clearly.coerce=True, the node and the disk get the coerced frame, which is a behaviour change if you add a validator to an existing pipeline. Needs a prominent docs and release-notes callout.after_dataset_savedgets the runner-local pre-coercion object (task.py:183-188), so what was actually persisted may differ. The contract we document: dataset I/O hooks see pre-validation data on save and post-validation data on load; a lineage/profiling plugin that needs the persisted truth has to read it back. Pinned by a test.save_argsblind spot. The schema validates what the node produced, not whatever dataset-side save processing actually wrote. A documented limitation.conf/localoverride of an entry that omitsvalidator:drops the base validator under destructive merge — the same family as last-wins, but at config level. Mitigations:kedro catalog validatereports which validator resolved and from where; a resolve-time warning when an explicit entry shadows a pattern that carries a validator; and a docs recommendation to declare validators inbaseand override withenabled: falserather than redeclaring the whole entry.hasattrwarning at context creation, and keeping the path members out ofCatalogProtocol.find_speccatches missing packages, not a bad attribute name.kedro catalog validate --resolve-onlyin CI and"strict"mode close that gap for teams that want build-time failure.catalog.load(), which changes job shape and can be expensive on a cluster. The docs will ship a per-backend eager/lazy table; the guidance is to preferon: [save]or sampled options for Spark/Dask and schema-only depth for polars-lazy. Streaming datasets aren't supported with load validation.__getstate__drops the resolved cache), so schema modules have to be importable in workers — which is already true for node functions.validator:imports and runs module code on first use, the same trust surface astype:. No new surface, just stating it.ThreadRunner, first-use resolution can race (harmless:setdefaultkeeps one instance), andvalidate()can be called concurrently — the protocol docstring asks for thread-safety, and the GE adapter shows the lock-guarded lazy-state pattern.Performance
Three micro-benchmarks ship with the implementation PR (numbers in the PR description, methodology in
benchmarks/):_maybe_validateoverhead on the disabled/dict-miss path over NMemoryDatasetloads — to defend adding code to the hot path.sampleoption) — so the cost guidance in the docs is evidence, not vibes.The v1 cost knobs are: narrowing
on:,severity: warnduring adoption,options: {head/sample: …},skip_load_after_save, per-datasetenabled: false, and the global flag.Migration
_ValidatingDatasetgo away;DataValidationErrormoves tokedro.validationwith an import shim kept for one release. v1 never shipped, so there's no real deprecation cycle owed — but for one release, if pandera is importable and a node input is annotated with aDataFrameModelwhose dataset has novalidator:, Kedro logs a warning linking to migration docs (reusing the discovery code that's otherwise being deleted), so nobody's validation silently evaporates. The deletion order keeps_is_pandera_modelavailable to KEP-1 code that imports it.validator:andmetadata: pandera:, which probably means double validation.Appendix A: Proposed API changes
User-facing (YAML)
See Design decision 2 —
validator:shorthand and long form. The reserved key is documented next toversioned/credentials.kedro.validation(new public package)Behaviour matrix
DATASET_VALIDATIONvalidator:declaredkedro run/catalog.load()validate_catalog_dataset()True(default)severity)Trueskipped, reason "no validator declared"TrueValidationConfigurationErrorat first useerrored,error_type="missing_dependency"False"warn"errored"strict"enabled: falseenabled=False)KEDRO_DATASET_VALIDATIONsetDataCatalog(framework-facing)to_config()re-injectsvalidator:. Explicitly-replaced entries drop their spec; in-memory (MemoryDataset) validators are runtime-only and surfaced viacatalog.validators(to_config()skips memory datasets today). WhetherDataCatalog.__eq__should consider validator specs is a small open call for PR review (currently it doesn't, which we'd document).Settings / CLI
Packaging
Note: modern pandera ships a core package with no dataframe backend, so a plain
pandera>=…pin would make the tutorial's first CSV validation fail with an import error.pandera[pandas]is the headline extra; non-pandas backends are user-installed (pip install "pandera[polars]"etc., since the validation path is backend-agnostic). The exact version floor is an open question below.Worked adapter sketches (showing the protocol holds up)
Open questions
>=0.24(the first release with thepandera.pandasnamespace and per-backend extras). Worth a quick check that theSchemaErrors.failure_casesshape theCheckFailuremapping relies on is stable from there to current (0.31.x).kedro catalog validatefull-data default. Should it require an explicit--all-data(resolve-only by default) so nobody accidentally runs unbounded validation in CI?severity: warnand CLI exit code. Should warn-level failures affect the exit code (report-only, vs a--strict-warnings)?ContextAwareValidator. An optionalvalidate(data, *, context)form that hands validators the dataset name/mode for richer messages — reserve the name in v1, or actually ship it?kedro.validatorsfor third-party adapters a v1 thing or v1.x?DataFrameModel,validate(lazy=),SchemaErrors.failure_cases).Reference
prototype/kep7-v2-catalog-validation(this design — the funnel/spec mechanism)All reactions