Victor Pasqualini
CV
All projects

The problem

Data quality tooling usually arrives as a platform: a service to deploy next to the cluster that is already holding the data. And its output stops at a verdict: "3,412 rows failed" says the table is bad, not which rule rejected which row, so the quarantine it writes cannot actually be worked.

Rules are data

One class, Cola, with a registry of rule types. A rule is a plain dict (a type plus that check’s parameters), so a validation block can be written by hand, generated, or stored next to the pipeline that runs it. Thresholds are one small DSL shared by every metric: > 0, < 5%, between 10 and 20, <= 2h.

A rule is a dict. A failed check is a result, never an exception.
from sparquet_cola import Cola

cola = Cola()

for r in cola.run(df, [
    {"type": "row_count", "min": 1},
    {"type": "not_null", "columns": ["id", "email"]},
    {"type": "missing_percent", "column": "cpf", "must_be": "< 5%", "warn": "= 0"},
    {"type": "sql", "failed_rows": "SELECT * FROM _validation_df WHERE amount < 0"},
]):
    print(r)   # [FAIL] check: missing_percent(cpf) = 8 violates must_be (< 5%)

Codes, so the quarantine is workable

Every row-level rule carries a code: declared, or rendered from the rule itself, always the same string for the same rule, because that string lands in your data. annotate writes it beside each rejected row, built from predicates the split already computes, so it costs no extra pass.

The quarantine table says which rule rejected each row
split = cola.split(df, [
    {"type": "not_null", "columns": ["id", "email"]},
    {"type": "range", "column": "age", "min": 1, "max": 99, "code": "AGE_RANGE"},
], annotate="dq_codes")

split.valid.write.format("delta").save(".../silver_ok")
split.invalid.write.format("delta").save(".../silver_quarantine")

split.invalid.select("id", "dq_codes").show(truncate=False)
# +----+-------------------+
# | id | dq_codes          |
# +----+-------------------+
# | 7  | [AGE_RANGE]       |
# | 8  | [not_null(email)] |
# +----+-------------------+

One rule, several targets

Each target expands into an independent rule with its own result, its own code and its own share of the quarantine: a single verdict over two columns would not say which of them broke. Ambiguous forms (an empty target list, a nested targets, a code on the parent) are refused at parse time instead of being silently degraded.

One rule, two targets: two independent results and two codes
{"type": "regex", "targets": [
    {"column": "document",  "pattern": "^[0-9]{11}$"},
    {"column": "document2", "pattern": "^[0-9]{12}$"}]}

# regex(document,^[0-9]{11}$)
# regex(document2,^[0-9]{12}$)

One Spark action instead of a dozen

Until v0.4.0 each check fired its own action, often more than one, so a block of a dozen rules scanned the data a dozen times. Now run collects the columns of every check that answered, issues a single df.agg(...), and hands each check back its slice. Checks registered from outside, which cannot know the mechanism exists, keep their own action and keep working unchanged.

tests/test_run_batch_spark.py runs each rule alone and again inside a block, comparing verdict, message, failed_count, severity and metric_value field by field, so the two paths cannot drift.

A check declares what it wants measured; run() issues one df.agg(...)
class NoFutureDateCheck(BaseCheck):
    def aggregations(self, df):
        return [F.count(F.when(F.col(self.params["column"]) > F.current_date(), F.lit(1)))]

    def from_aggregations(self, values):
        failed = int(values[0])
        if failed:
            return CheckResult("no_future_date", False, f"{failed} future dates", failed)
        return CheckResult("no_future_date", True)

Where it runs

  • PyPI: pip install sparquet-cola, imported as sparquet_cola
  • Drops into a Spark job, a notebook or an Airflow task
  • Documented in English, Portuguese and Spanish
  • The engine behind the validations block of Sparquet
Next projectPulse

A finance dashboard that reads the spreadsheet you already keep, and knows a contribution is not spending.