Data Quality Checks skill for Codex

Stop bad data before the dashboard sees it. Codex profiles a dataset first, then adds a short list of checks tied to real failure modes: schema, volume, freshness, key uniqueness and reconciliation. Gates run before publish using write-audit-publish, failures block or warn by agreed severity, and bad rows land in a rejects table with a reason. A bundled stdlib profiler sets thresholds from data instead of guesses.

  • Data & Analytics
  • $data-quality-checks
  • 172 lines
  • 3 files
  • ~1,900 tokens
  • MIT
  • Updated

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- data-quality-checks

Installs to ~/.agents/skills/data-quality-checks/ for every project on this machine.

104-line POSIX script. No sudo, no telemetry. Read install.sh

Use it in Codex

Start a prompt with $data-quality-checks, or pick it from /skills. You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.

What Codex reads before loading it

Designs data validation gates for pipelines with Great Expectations (GX Core 1.x), Soda checks (SodaCL), dbt tests and plain SQL - schema, volume, freshness, completeness, uniqueness, validity, referential and reconciliation checks, blocking versus warning severity, quarantine tables and write-audit-publish. Use when the user wants to stop bad data reaching dashboards or downstream tables, add checks to an ingestion or ETL job, choose between Great Expectations, Soda and dbt tests, profile a new dataset to set thresholds, or investigate why a data incident slipped through.

Use it when

  • Bad or incomplete data keeps reaching downstream tables or dashboards
  • Adding validation to an ingestion or ETL job
  • Choosing between Great Expectations, Soda, dbt tests or plain SQL checks
  • A new dataset needs profiling to set thresholds

Use something else when

How to check the result

A seeded bad fixture trips every gate with a clear message, clean data passes, and a 7-day backtest shows few or no false blocks.

The mistake it prevents

Running checks after the table is already published, so the check reports the incident instead of preventing it.

~/.agents/skills/data-quality-checks/SKILL.md

--- name: data-quality-checks description: Designs data validation gates for pipelines with Great Expectations (GX Core 1.x), Soda checks (SodaCL), dbt tests and plain SQL - schema, volume,… license: MIT ---

Done well means: every published dataset passes a small set of checks tied to real failure modes before consumers can see it, failures block or warn according to an agreed severity, bad rows are quarantined with a reason instead of silently dropped, and each check’s threshold came from profiling, not guessing.

Before adding checks

  1. Read AGENTS.md and find what already exists: dbt tests (data_tests: in YAML), great_expectations/ or gx/ folders, checks*.yml (Soda), assertion helpers in jobs. Extend the existing tool instead of adding a second one.
  2. List the consumers and what hurts them: a finance dashboard needs reconciliation, an ML feature table needs distribution checks, an API export needs schema stability.
  3. Profile the data to set thresholds. For a CSV, run the bundled profiler:
python3 scripts/profile_csv.py data/orders.csv --key order_id --not-null order_id,amount \
    --max-null-rate email=0.05 --min-rows 1000

It streams the file, prints null rate, distinct count and inferred type per column, flags mixed types and ragged rows, and exits 1 when a rule fails (0 on pass, 2 on read errors), so it also works as a quick CI gate. For warehouse tables, run the equivalent SQL (count(*), count_if(col is null), count(distinct col), min, max) per column.

Check catalogue

CategoryExample ruleTypical severity
SchemaExpected columns and types present; no unexpected columns in contractsBlock
VolumeRow count > 0; within +/-30% of the trailing 7-day medianBlock on zero, warn on drift
Freshnessmax(loaded_at) within the SLA (e.g. < 6 h)Warn, block for SLA-bound marts
CompletenessKey and required fields not null; optional fields under a measured null rateBlock for keys
UniquenessPrimary or grain key uniqueBlock
ValidityEnum values, ranges (amount >= 0), regex for formatsWarn or quarantine rows
ReferentialEvery customer_id exists in customersWarn (late-arriving dims)
ReconciliationSum of amount equals source system total for the dayBlock for finance
DistributionMean or null rate within historical boundsWarn

Start with schema, volume, freshness, key uniqueness and not-null. Add the rest where an incident or a consumer requirement justifies it. Ten meaningful checks beat two hundred noisy ones.

Where gates go

source -> [ingest gate: schema, volume, parse errors]
       -> staging -> [transform gate: keys, validity, referential]
       -> publish candidate -> [publish gate: reconciliation, freshness] -> swap/expose

Write-audit-publish: build into a staging table or branch, run the gate, and only then swap or merge into the published table. On failure, the previous good version stays visible.

Tool choice

SituationUse
Transformations live in dbtdbt data tests and contracts (see $dbt-testing)
Python pipelines, DataFrames, or many file-based sourcesGreat Expectations (GX Core 1.x)
SQL warehouses, YAML-defined checks owned by analystsSoda (SodaCL)
Single job, few rules, no new dependency wantedPlain assertions or SQL in the job

Great Expectations (GX Core 1.x)

import great_expectations as gx
import great_expectations.expectations as gxe

context = gx.get_context()                        # file-backed if a gx/ project exists
source = context.data_sources.add_pandas("pandas")
asset = source.add_dataframe_asset(name="orders")
batch_def = asset.add_batch_definition_whole_dataframe("orders_batch")

suite = context.suites.add(gx.ExpectationSuite(name="orders_publish_gate"))
suite.add_expectation(gxe.ExpectColumnValuesToNotBeNull(column="order_id"))
suite.add_expectation(gxe.ExpectColumnValuesToBeUnique(column="order_id"))
suite.add_expectation(gxe.ExpectColumnValuesToBeBetween(column="amount", min_value=0))
suite.add_expectation(gxe.ExpectColumnValuesToBeInSet(
    column="status", value_set=["placed", "shipped", "delivered", "returned"], mostly=0.99))
suite.add_expectation(gxe.ExpectTableRowCountToBeBetween(min_value=1000))

batch = batch_def.get_batch(batch_parameters={"dataframe": df})
result = batch.validate(suite)
if not result.success:
    failed = [r.expectation_config.type for r in result.results if not r.success]
    raise RuntimeError(f"orders gate failed: {failed}")

The 0.x API (get_validator, context.sources, ge.from_pandas) is gone in 1.x; port old code instead of mixing styles. For scheduled runs, wrap suite and batch in a ValidationDefinition and a Checkpoint with notification actions.

Soda (SodaCL)

# checks/orders.yml
checks for orders:
  - row_count > 0
  - missing_count(order_id) = 0
  - duplicate_count(order_id) = 0
  - invalid_count(status) = 0:
      valid values: [placed, shipped, delivered, returned]
  - freshness(loaded_at) < 6h
  - min(amount) >= 0
  - schema:
      fail:
        when required column missing: [order_id, customer_id, amount]
soda scan -d warehouse -c soda/configuration.yml checks/orders.yml   # needs DB network access

This is Soda Core 3.x syntax. Soda 4 moved to a data-contract YAML format; check the installed version (soda --version) and match the project’s existing files.

Plain SQL gate

-- returns rows only when something is wrong; the job fails if any row comes back
select 'duplicate order_id' as problem, count(*) as n
from (select order_id from staging.orders group by 1 having count(*) > 1) d
having count(*) > 0
union all
select 'negative amount', count(*) from staging.orders where amount < 0 having count(*) > 0
union all
select 'no rows', 0 where not exists (select 1 from staging.orders);

Quarantine instead of dropping

Rows that fail row-level validity go to <table>_rejects with reason, check_name, run_id and the raw payload. The job reports the reject count and fails if it exceeds a threshold (for example 0.5% of rows). Never filter bad rows away without recording them.

Gotchas

  • Checks that always warn train people to ignore alerts. Fix or delete them.
  • Static volume thresholds break on weekends and holidays; compare to the same weekday or a trailing median.
  • unique checks on large tables are expensive; scope to the new partition.
  • Freshness on updated_at measures source activity, not pipeline health; use a load timestamp the pipeline writes.
  • A check that runs after the table is already published only tells you about the incident. Gate before publish.
  • New tools (GX, Soda) need package installs and network; get approval in the sandbox and add them to the project’s dependency file.

Verification

  • Seed a known-bad fixture (duplicate key, null in a required column, negative amount) and confirm each gate fails with a clear message, then passes on clean data.
  • Run the gate against the last 7 days of real data to measure false positive rate before making it blocking.

Report template

**Dataset:** analytics.orders (daily, consumers: finance dashboard, churn model)
**Gates:** ingest (schema, volume), publish (key unique, not-null x3, status set 99%, reconciliation vs billing)
**Tooling:** Soda 3.x checks in checks/orders.yml, run after dbt build in the orchestrator
**Severity:** block on schema/key/reconciliation; warn on freshness > 6 h and referential
**Backtest:** 7 days of history, 0 false blocks, 2 warns (late customer dimension)
**Quarantine:** orders_rejects with reason; job fails above 0.5% rejects

What the zip contains

Codex reads bundled files only when a step points to them. Every one is shown here, so you can review it before installing.

agents/openai.yamlgenerated from this page

Gives the Codex app a display name, a one-line description and a starter prompt for this skill.

interface:
  display_name: "Data Quality Checks"
  short_description: "Stop bad data before the dashboard sees it"
  default_prompt: "$data-quality-checks Add data quality checks so a broken load never reaches the finance dashboard"
scripts/profile_csv.py163 lines, 6.2 KB
#!/usr/bin/env python3
"""Profile a CSV file and optionally enforce basic data quality rules.

Standard library only. Streams the file, so it works on files larger than memory
(distinct counts are capped to keep memory bounded).

Usage:
  python3 profile_csv.py data.csv
  python3 profile_csv.py data.csv --key order_id --not-null order_id,amount \
      --max-null-rate email=0.05 --min-rows 1000 --json

Exit codes: 0 all rules pass (or no rules given), 1 a rule failed, 2 usage or read error.
"""
from __future__ import annotations

import argparse
import csv
import json
import re
import sys
from collections import Counter

DISTINCT_CAP = 100_000
NULL_TOKENS = {"", "null", "none", "nan", "n/a", "na"}
PATTERNS = [
    ("integer", re.compile(r"^[+-]?\d+$")),
    ("decimal", re.compile(r"^[+-]?(\d+\.\d*|\.\d+|\d+)([eE][+-]?\d+)?$")),
    ("date", re.compile(r"^\d{4}-\d{2}-\d{2}$")),
    ("timestamp", re.compile(r"^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$")),
    ("boolean", re.compile(r"^(true|false|t|f|yes|no|0|1)$", re.I)),
]


def classify(value: str) -> str:
    for name, pattern in PATTERNS:
        if pattern.match(value):
            return name
    return "text"


def parse_kv(items: list[str]) -> dict[str, float]:
    out = {}
    for item in items:
        col, _, rate = item.partition("=")
        if not col or not rate:
            raise SystemExit(f"bad --max-null-rate value {item!r}; expected column=0.05")
        out[col] = float(rate)
    return out


def main() -> int:
    ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    ap.add_argument("path")
    ap.add_argument("--delimiter", default=",")
    ap.add_argument("--encoding", default="utf-8-sig")
    ap.add_argument("--key", help="comma-separated columns that must be unique together")
    ap.add_argument("--not-null", default="", help="comma-separated columns that must have no nulls")
    ap.add_argument("--max-null-rate", action="append", default=[], help="column=rate, repeatable")
    ap.add_argument("--min-rows", type=int, default=0)
    ap.add_argument("--json", action="store_true", help="print the profile as JSON")
    args = ap.parse_args()

    try:
        fh = open(args.path, newline="", encoding=args.encoding)
    except OSError as exc:
        print(f"error: {exc}", file=sys.stderr)
        return 2

    with fh:
        reader = csv.DictReader(fh, delimiter=args.delimiter)
        columns = reader.fieldnames or []
        if not columns:
            print("error: no header row found", file=sys.stderr)
            return 2
        key_cols = [c.strip() for c in args.key.split(",")] if args.key else []
        not_null = [c.strip() for c in args.not_null.split(",") if c.strip()]
        max_null = parse_kv(args.max_null_rate)
        missing = [c for c in key_cols + not_null + list(max_null) if c not in columns]
        if missing:
            print(f"error: unknown columns {missing}; header is {columns}", file=sys.stderr)
            return 2

        nulls = Counter()
        kinds = {c: Counter() for c in columns}
        distinct = {c: set() for c in columns}
        capped = set()
        seen_keys: set[tuple] = set()
        dup_keys = 0
        dup_examples: list[tuple] = []
        ragged = 0
        rows = 0

        try:
            for row in reader:
                rows += 1
                if None in row or any(v is None for v in row.values()):
                    ragged += 1
                for c in columns:
                    v = (row.get(c) or "").strip()
                    if v.lower() in NULL_TOKENS:
                        nulls[c] += 1
                        continue
                    kinds[c][classify(v)] += 1
                    if c not in capped:
                        distinct[c].add(v)
                        if len(distinct[c]) > DISTINCT_CAP:
                            capped.add(c)
                            distinct[c].clear()
                if key_cols:
                    k = tuple((row.get(c) or "").strip() for c in key_cols)
                    if k in seen_keys:
                        dup_keys += 1
                        if len(dup_examples) < 5:
                            dup_examples.append(k)
                    else:
                        seen_keys.add(k)
        except (csv.Error, UnicodeDecodeError) as exc:
            print(f"error at data row {rows + 1}: {exc}", file=sys.stderr)
            return 2

    profile = {"file": args.path, "rows": rows, "ragged_rows": ragged, "columns": {}}
    for c in columns:
        rate = nulls[c] / rows if rows else 0.0
        top_kind = kinds[c].most_common(1)[0][0] if kinds[c] else "empty"
        profile["columns"][c] = {
            "null_rate": round(rate, 4),
            "distinct": f">{DISTINCT_CAP}" if c in capped else len(distinct[c]),
            "inferred": top_kind,
            "mixed_types": len(kinds[c]) > 1 and not set(kinds[c]) <= {"integer", "decimal"},
        }

    failures = []
    if rows < args.min_rows:
        failures.append(f"row count {rows} < min {args.min_rows}")
    for c in not_null:
        if nulls[c]:
            failures.append(f"{c}: {nulls[c]} nulls (expected none)")
    for c, limit in max_null.items():
        rate = profile["columns"][c]["null_rate"]
        if rate > limit:
            failures.append(f"{c}: null rate {rate:.2%} > {limit:.2%}")
    if dup_keys:
        failures.append(f"key ({', '.join(key_cols)}): {dup_keys} duplicate rows, e.g. {dup_examples}")
    if ragged:
        failures.append(f"{ragged} rows have a different field count than the header")
    profile["failures"] = failures

    if args.json:
        print(json.dumps(profile, indent=2))
    else:
        print(f"{args.path}: {rows} rows, {len(columns)} columns")
        width = max(len(c) for c in columns)
        print(f"{'column'.ljust(width)}  null_rate  distinct  inferred   mixed")
        for c, p in profile["columns"].items():
            print(f"{c.ljust(width)}  {p['null_rate']:>9.2%}  {str(p['distinct']):>8}  {p['inferred']:<9}  {'yes' if p['mixed_types'] else ''}")
        print("RESULT: " + ("PASS" if not failures else "FAIL"))
        for f in failures:
            print(f"  - {f}")
    return 1 if failures else 0


if __name__ == "__main__":
    sys.exit(main())

About this skill

Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.

esc
New

Introducing Codex Skills Pro

79 professional skills that take Codex through complete engagements, each with its own scripts, references and a report that verifies the result.

All-AccessOne-time payment, no subscription
$49
Get All-AccessOr browse individual skills from $2.99 →

Secure checkout by Creem