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.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- data-quality-checks Installs to ~/.agents/skills/data-quality-checks/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install data-quality-checks The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project data-quality-checks Installs to .agents/skills/data-quality-checks/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/data-quality-checks.zipunzip -o data-quality-checks.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) data-quality-checks PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
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
- All transformations live in dbt and you only need dbt tests: use dbt-testing
- You need unit and integration tests for pipeline code: use data-pipeline-testing
- The task is fixing the messy values themselves: use data-cleaning
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.
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
- Read
AGENTS.mdand find what already exists: dbt tests (data_tests:in YAML),great_expectations/orgx/folders,checks*.yml(Soda), assertion helpers in jobs. Extend the existing tool instead of adding a second one. - 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.
- 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
| Category | Example rule | Typical severity |
|---|---|---|
| Schema | Expected columns and types present; no unexpected columns in contracts | Block |
| Volume | Row count > 0; within +/-30% of the trailing 7-day median | Block on zero, warn on drift |
| Freshness | max(loaded_at) within the SLA (e.g. < 6 h) | Warn, block for SLA-bound marts |
| Completeness | Key and required fields not null; optional fields under a measured null rate | Block for keys |
| Uniqueness | Primary or grain key unique | Block |
| Validity | Enum values, ranges (amount >= 0), regex for formats | Warn or quarantine rows |
| Referential | Every customer_id exists in customers | Warn (late-arriving dims) |
| Reconciliation | Sum of amount equals source system total for the day | Block for finance |
| Distribution | Mean or null rate within historical bounds | Warn |
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
| Situation | Use |
|---|---|
| Transformations live in dbt | dbt data tests and contracts (see $dbt-testing) |
| Python pipelines, DataFrames, or many file-based sources | Great Expectations (GX Core 1.x) |
| SQL warehouses, YAML-defined checks owned by analysts | Soda (SodaCL) |
| Single job, few rules, no new dependency wanted | Plain 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.
uniquechecks on large tables are expensive; scope to the new partition.- Freshness on
updated_atmeasures 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.yaml
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.py
#!/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.