How to turn business requirements into SQL data quality rules, with evidence
The short answer
Write each requirement as one sentence an owner would sign. Add a SQL condition that is true for a bad row, the share of bad rows the owner tolerates, and good and bad example rows that test the SQL. Run every rule on each year's data. You get one evidence row per rule and year.
What you will have at the end
A Python script, requirements_to_rules.py, that holds six business requirements as a table, profiles every column year by year, tests each rule against its own example rows, runs the rules over the data and writes rule_evidence.csv: one row per rule and year with the requirement, a fingerprint of the exact SQL, the rows tested, the rows failed, the tolerance, the verdict and the numbers of the first three failing rows. It prints no name or address. Get the full guide (PDF) for the same steps with the complete code and a checklist.
We do not sell or reuse this data. Two closed cycles of a public FEC file stand in for a company’s own data. FEC contributor names and addresses may not be sold or used for solicitation or commercial purposes (FEC: sale or use of contributor information (read 2026-11-05)). On this file the script prints counts, shares, row numbers and code values such as a transaction type, and no name or address. If you add a code list for a column that holds personal data, a failed list check prints the unlisted values, so list only columns that hold codes.
Step 1. Download two cycles and profile every column
The file is the FEC’s file of committee contributions to candidates and other committees, called pas2: each contribution or independent expenditure a committee made to a candidate or another committee during a two-year election cycle (FEC: file description (read 2026-11-05)). We use the closed cycles 2018 and 2020 from the FEC bulk data page and print each file’s byte size, Last-Modified header and the first 16 characters of its SHA-256, so you can tell whether the source changed since our run. The script downloads with Python’s urllib, unzips into a folder you give it (it refuses a folder that is not empty), so that read_csv reads plain text, and reads every column as text (DuckDB: CSV overview (read 2026-11-05)).
Before writing any rule, the script prints the share of blank values in every column, cycle by cycle. This is where requirements come from. In this file it shows two columns blank on every row in both cycles (EMPLOYER and OCCUPATION), and two memo columns that are blank on most rows. A rule on an always-blank column would fail every row, so the owner decides first whether such a column matters.
Step 2. Write each requirement as a sentence, a condition, a tolerance and examples
Each entry in REQUIREMENTS has four parts. text is the sentence the business owner signs. bad_when is a SQL condition that is true for a bad row. tolerance is the most percent of bad rows the owner accepts in one year. good and bad are example rows, written as changes to one BASELINE row that breaks no requirement. The rule must flag every bad example and no good one. The tolerances are our choices: change them to your owner’s.
REQUIREMENTS = [
{"id": "R1", "text": "Every payment names the candidate or committee that received it.",
"bad_when": "cand_id IS NULL AND other_id IS NULL", "tolerance": 0.1,
"good": [{}, {"cand_id": "NULL"}, {"other_id": "NULL"}],
"bad": [{"cand_id": "NULL", "other_id": "NULL"}]},
{"id": "R2", "text": "Every payment has an amount that is a number.",
"bad_when": "amount IS NULL", "tolerance": 0.0,
"good": [{}, {"amount": "CAST(-5.00 AS DECIMAL(14,2))"}],
"bad": [{"amount": "NULL"}]},
{"id": "R3", "text": "Every payment is dated inside the two-year election cycle it is filed under.",
"bad_when": "txn_date IS NULL OR txn_date < MAKE_DATE(grp - 1, 1, 1) OR txn_date > MAKE_DATE(grp, 12, 31)",
"tolerance": 1.0,
"good": [{}, {"txn_date": "DATE '2017-01-01'"}, {"txn_date": "DATE '2018-12-31'"}],
"bad": [{"txn_date": "DATE '2016-12-31'"}, {"txn_date": "DATE '2019-01-01'"}, {"txn_date": "NULL"}]},
{"id": "R4", "text": "Every payment can be found by its own unique id.",
"bad_when": "sub_id IS NULL OR (grp, sub_id) IN (SELECT grp, sub_id FROM {t} GROUP BY grp, sub_id HAVING COUNT(*) > 1)",
"tolerance": 0.0,
"good": [{}, {}],
"bad": [{"sub_id": "'DUP'"}, {"sub_id": "'DUP'"}, {"sub_id": "NULL"}]},
{"id": "R5", "text": "Every payment has a transaction type the business recognizes.",
"bad_when": "txn_type IS NULL OR txn_type NOT IN (SELECT code FROM txn_types)", "tolerance": 0.0,
"good": [{}, {"txn_type": "'24A'"}],
"bad": [{"txn_type": "'99'"}, {"txn_type": "NULL"}]},
{"id": "R6", "text": "Every payment carries a valid US state or territory code.",
"bad_when": "state IS NULL OR state NOT IN (SELECT code FROM us_codes)", "tolerance": 2.0,
"good": [{}, {"state": "'PR'"}],
"bad": [{"state": "'ZZ'"}, {"state": "NULL"}, {"state": "'ny'"}]},
]
Two details matter. First, a condition that comes out NULL for a row counts that row as bad: an unknown is never a pass. A blank value is NULL, and a value that is not in a subquery result holding a NULL gives NULL with NOT IN, not true (DuckDB: IN operator (read 2026-11-05)). Without COALESCE(..., TRUE) around the condition, such rows would pass silently. Second, the code lists the rules use are tables written out in the script, so the reader can disagree with them. The nine transaction type codes are nine of the codes the FEC defines (FEC: transaction type codes (read 2026-11-05)); we listed only those that occur in 2018 and 2020; the state and territory codes are the USPS abbreviations (read 2026-11-05).
Step 3. Test every rule on its own examples, then run it
test_rule builds a small table from the example rows and checks that the rule flags exactly the bad ones. A rule that cannot fire, or fires on good rows, fails the self-check, and no evidence file is written. run_rule then counts, for each year, the rows tested and the rows failed, and lists the first three failing row numbers. It wraps the condition in COALESCE(..., TRUE), which is what turns an unknown into a failure.
def test_rule(con, req):
"""Run the rule on its own example rows. Returns a problem text, or '' if it flags exactly the bad ones."""
rows = [("good", o) for o in req["good"]] + [("bad", o) for o in req["bad"]]
fixture_table(con, req, rows)
expected = [i for i, (kind, _o) in enumerate(rows, start=1) if kind == "bad"]
cond = sql_of(req).replace("{t}", "fx")
got = [r[0] for r in con.sql(f"SELECT row_no FROM fx WHERE COALESCE(({cond}), TRUE) ORDER BY row_no").fetchall()]
if got != expected:
return f"{req['id']} flagged example rows {got}, expected exactly {expected}"
return ""
def run_rule(con, req):
cond = sql_of(req).replace("{t}", "data")
tested = dict(con.sql("SELECT grp, COUNT(*) FROM data GROUP BY grp").fetchall())
failed = dict(con.sql(f"SELECT grp, COUNT(*) FROM data WHERE COALESCE(({cond}), TRUE) GROUP BY grp").fetchall())
result = {}
for g in sorted(tested, key=str):
first = [r[0] for r in con.execute(f"SELECT row_no FROM data WHERE grp IS NOT DISTINCT FROM ? "
f"AND COALESCE(({cond}), TRUE) "
f"ORDER BY row_no LIMIT 3", [g]).fetchall()]
n, bad = tested[g], failed.get(g, 0)
pct = 100.0 * bad / n
result[str(g)] = {"tested": n, "failed": bad, "pct": pct, "tolerance": req["tolerance"],
"verdict": "Pass" if pct <= req["tolerance"] else "Fail", "first": first}
return result
The evidence row carries a 12-character fingerprint of the SQL text, so a later run can show that the SQL did not change between two evidence files. The fingerprint does not cover the code lists, the tolerance or the examples, so keep those with the evidence. The script also checks itself: the rows it loaded equal the rows in the source, every rule passes its example rows, every transaction type in the data is in the list, and every code in the list matches at least one row.
Step 4. Run 1: the clean data
We ran the script on 2026-11-05 with DuckDB 1.5.6. The self-check passed and every rule passed in both cycles. Two rules flagged rows that stayed inside the owner’s tolerance: rule R3 (dates outside the cycle or unusable) flagged 2,143 rows in 2018 and 5,699 in 2020, and rule R6 (state code) flagged 1,722 and 1,419. The evidence keeps those counts, with row numbers to start from, instead of a bare “Pass”.
Command: run 1python3 -I requirements_to_rules.py --data-dir <a new empty folder>
Output from our run 1
duckdb 1.5.6, python 3.12.15
source https://www.fec.gov/files/bulk-downloads/data_dictionaries/pas2_header_file.csv bytes=212 last-modified=Wed, 10 Jan 2018 18:30:12 GMT sha256=9ce58b0576635ddb
source https://www.fec.gov/files/bulk-downloads/2018/pas218.zip bytes=12556308 last-modified=Sun, 25 Dec 2022 21:49:49 GMT sha256=5fee204a918b4546
source https://www.fec.gov/files/bulk-downloads/2020/pas220.zip bytes=28978598 last-modified=Sun, 29 Dec 2024 22:08:51 GMT sha256=7a8355e2cdae6c4b
total downloaded: 41535118 bytes
column profile: percent of rows with a blank value, by group
column 2018 2020
CMTE_ID 0.00 0.00
AMNDT_IND 0.00 0.00
RPT_TP 0.00 0.00
TRANSACTION_PGI 0.48 0.21
IMAGE_NUM 0.00 0.00
TRANSACTION_TP 0.00 0.00
ENTITY_TP 1.62 0.58
NAME 0.40 0.15
CITY 0.47 0.15
STATE 0.49 0.16
ZIP_CODE 0.51 0.17
EMPLOYER 100.00 100.00
OCCUPATION 100.00 100.00
TRANSACTION_DT 0.58 0.63
TRANSACTION_AMT 0.00 0.00
OTHER_ID 0.00 0.02
CAND_ID 0.42 0.21
TRAN_ID 0.00 0.05
FILE_NUM 0.00 0.00
MEMO_CD 98.11 98.51
MEMO_TEXT 86.39 94.76
SUB_ID 0.00 0.00
rules (SQL fingerprint, then the SQL that is TRUE for a bad row)
R1 d05eab9843aa cand_id IS NULL AND other_id IS NULL
R2 807224c224f2 amount IS NULL
R3 f9ef81d911f8 txn_date IS NULL OR txn_date < MAKE_DATE(grp - 1, 1, 1) OR txn_date > MAKE_DATE(grp, 12, 31)
R4 dea6770623a4 sub_id IS NULL OR (grp, sub_id) IN (SELECT grp, sub_id FROM {t} GROUP BY grp, sub_id HAVING COUNT(*) > 1)
R5 6bd9f684edde txn_type IS NULL OR txn_type NOT IN (SELECT code FROM txn_types)
R6 fd2c55694c09 state IS NULL OR state NOT IN (SELECT code FROM us_codes)
rule group tested failed failed% tolerance% verdict first failing row numbers
R1 2018 349820 0 0.000 0.100 Pass -
R1 2020 887829 0 0.000 0.100 Pass -
R2 2018 349820 0 0.000 0.000 Pass -
R2 2020 887829 0 0.000 0.000 Pass -
R3 2018 349820 2143 0.613 1.000 Pass 24, 25, 26
R3 2020 887829 5699 0.642 1.000 Pass 2, 3, 5
R4 2018 349820 0 0.000 0.000 Pass -
R4 2020 887829 0 0.000 0.000 Pass -
R5 2018 349820 0 0.000 0.000 Pass -
R5 2020 887829 0 0.000 0.000 Pass -
R6 2018 349820 1722 0.492 2.000 Pass 3988, 5967, 6522
R6 2020 887829 1419 0.160 2.000 Pass 2649, 2652, 5138
SELF-CHECK PASSED: row counts equal the source, all 6 rules flag exactly their own bad examples, every value of a required lookup is listed, and every listed code is used
wrote rule_evidence.csv
| Rule | Requirement | 2018 failed of 349,820 | 2020 failed of 887,829 | Tolerance | Verdict |
|---|---|---|---|---|---|
| R1 | Names the candidate or committee that received it | 0 | 0 | 0.100% | Pass |
| R2 | Has an amount that is a number | 0 | 0 | 0.000% | Pass |
| R3 | Dated inside its two-year election cycle | 2,143 (0.613%) | 5,699 (0.642%) | 1.000% | Pass |
| R4 | Has its own unique id | 0 | 0 | 0.000% | Pass |
| R5 | Has a recognized transaction type | 0 | 0 | 0.000% | Pass |
| R6 | Carries a valid US state or territory code | 1,722 (0.492%) | 1,419 (0.160%) | 2.000% | Pass |
Download the evidence CSV (rule_evidence.csv)
Step 5. Run 2: plant faults and see what catches them
A rule set that has never failed has not been shown to work. We ran the same script with five faults planted: the recipient blanked on 2,000 rows of 2018, 500 rows of 2020 given the transaction type 99, the last 1,000 rows of 2018 loaded twice, rule R3’s SQL replaced with FALSE, and a code, 24X, that no row carries added to the transaction types. The script stops if the data already holds the code 99 or has fewer than 2,000 rows with a recipient to blank.
- The rules caught the data faults: R1 failed on 2,000 rows of 2018, R5 on 500 rows of 2020, and R4 on 2,000 rows of 2018 (the 1,000 duplicated rows and their originals).
- The self-checks caught the script faults: the row-count check tripped on the duplicated rows, the example-row check tripped on R3 (it flagged none of the three bad examples), the lookup check tripped on
99, and the unused-code check tripped on24X. - No evidence file was written. Note that R3 shows Pass in this run, with zero failures: only its example rows showed the rule was broken. A verdict alone cannot tell you that.
python3 -I requirements_to_rules.py --data-dir <another new empty folder> --out-dir planted --plant duplicate-rows,unknown-type,blank-recipient,broken-rule,unused-code
Output from our run 2: faults reported, no CSV written
PLANTED: the SQL of rule R3 replaced with FALSE, so it can never fire
PLANTED: the code '24X' added to the transaction types
duckdb 1.5.6, python 3.12.15
source https://www.fec.gov/files/bulk-downloads/data_dictionaries/pas2_header_file.csv bytes=212 last-modified=Wed, 10 Jan 2018 18:30:12 GMT sha256=9ce58b0576635ddb
source https://www.fec.gov/files/bulk-downloads/2018/pas218.zip bytes=12556308 last-modified=Sun, 25 Dec 2022 21:49:49 GMT sha256=5fee204a918b4546
source https://www.fec.gov/files/bulk-downloads/2020/pas220.zip bytes=28978598 last-modified=Sun, 29 Dec 2024 22:08:51 GMT sha256=7a8355e2cdae6c4b
total downloaded: 41535118 bytes
PLANTED: the recipient blanked on 2,000 rows of the 2018 file
PLANTED: 500 rows of the 2020 file given the transaction type '99'
PLANTED: the last 1,000 rows of the 2018 file loaded twice
column profile: percent of rows with a blank value, by group
column 2018 2020
CMTE_ID 0.00 0.00
AMNDT_IND 0.00 0.00
RPT_TP 0.00 0.00
TRANSACTION_PGI 0.48 0.21
IMAGE_NUM 0.00 0.00
TRANSACTION_TP 0.00 0.00
ENTITY_TP 1.61 0.58
NAME 0.40 0.15
CITY 0.47 0.15
STATE 0.49 0.16
ZIP_CODE 0.51 0.17
EMPLOYER 100.00 100.00
OCCUPATION 100.00 100.00
TRANSACTION_DT 0.59 0.63
TRANSACTION_AMT 0.00 0.00
OTHER_ID 0.57 0.02
CAND_ID 0.99 0.21
TRAN_ID 0.00 0.05
FILE_NUM 0.00 0.00
MEMO_CD 98.11 98.51
MEMO_TEXT 86.38 94.76
SUB_ID 0.00 0.00
rules (SQL fingerprint, then the SQL that is TRUE for a bad row)
R1 d05eab9843aa cand_id IS NULL AND other_id IS NULL
R2 807224c224f2 amount IS NULL
R3 4e523a5ae5b4 FALSE
R4 dea6770623a4 sub_id IS NULL OR (grp, sub_id) IN (SELECT grp, sub_id FROM {t} GROUP BY grp, sub_id HAVING COUNT(*) > 1)
R5 6bd9f684edde txn_type IS NULL OR txn_type NOT IN (SELECT code FROM txn_types)
R6 fd2c55694c09 state IS NULL OR state NOT IN (SELECT code FROM us_codes)
rule group tested failed failed% tolerance% verdict first failing row numbers
R1 2018 350820 2000 0.570 0.100 Fail 1, 2, 3
R1 2020 887829 0 0.000 0.100 Pass -
R2 2018 350820 0 0.000 0.000 Pass -
R2 2020 887829 0 0.000 0.000 Pass -
R3 2018 350820 0 0.000 1.000 Pass -
R3 2020 887829 0 0.000 1.000 Pass -
R4 2018 350820 2000 0.570 0.000 Fail 348821, 348821, 348822
R4 2020 887829 0 0.000 0.000 Pass -
R5 2018 350820 0 0.000 0.000 Pass -
R5 2020 887829 500 0.056 0.000 Fail 1, 2, 3
R6 2018 350820 1727 0.492 2.000 Pass 3988, 5967, 6522
R6 2020 887829 1419 0.160 2.000 Pass 2649, 2652, 5138
SELF-CHECK FAILED: no evidence CSV written
- group 2018: loaded 350820 rows but the source file has 349820 (+1000)
- example rows: R3 flagged example rows [], expected exactly [4, 5, 6]
- txn_types: '99' x 500 are not in the txn_types list
- txn_types: the codes ['24X'] match no row in the data
Run it on your own data
To run it on your own table, export the table to a CSV file with a header row. In the script, set DATA_CSV to that file's path and GROUP_COLUMN to the column that holds each row's year or period. Replace everything inside REQUIREMENTS with your own requirements: for each one, put the sentence in text, a SQL condition that is true for a bad row and uses your own column names in bad_when, the percent of bad rows you accept in tolerance, and example rows in good and bad. Replace the values in BASELINE with one row of your own data that breaks no requirement, written as SQL values for your own columns, and keep grp and row_no in it. Replace everything inside LOOKUPS with your own code lists, or with {} if you have none. Then run the script without --data-dir.
The script prints the type it read each column as. DuckDB reads the whole file before it picks a type, so a column with even one value that is not a number or a date is read as text. A rule that compares such a column with a number cannot run on the data. The other rules still run, and the run ends with SELF-CHECK FAILED, a line that names the rule’s id, and no evidence CSV. To count those values as bad rows instead, write the column, here one named amount, as TRY_CAST(amount AS DECIMAL(14,2)) each time it appears in bad_when: a value that is not a number becomes NULL, the condition then comes out NULL, and a condition that comes out NULL counts the row as bad. If a date column is read as text, read it with the file’s own date format, for example TRY_STRPTIME(ship_date, '%m/%d/%Y')::DATE for dates written like 01/15/2022, and write that column’s values in BASELINE and in the example rows as text in the same format, such as '01/15/2022'. TRY_CAST(… AS DATE) reads only dates written like 2022-01-15.
We ran exactly these changes on a made-up orders file with other column names, a status written in the wrong case, a blank status, a blank customer, a negative amount, a duplicated id and a ship date before its year. Every planted error was reported. A lookup list that held a blank did not hide the unlisted status.
Get the full guide as a PDF
We'll email you a link to it. Please use your work email.
Thanks, . We're sending the link to now. If it isn't there in a few minutes, check your spam or junk folder, or email info@datameaning.com.
Method
- Dataset: FEC “contributions from committees to candidates and other committees” (
pas2), cycles 2018 and 2020, fromhttps://www.fec.gov/files/bulk-downloads/<year>/pas2<yy>.zip, with column names fromhttps://www.fec.gov/files/bulk-downloads/data_dictionaries/pas2_header_file.csv. US government data, public. Size,Last-Modifiedand the first 16 characters of the SHA-256 of each file are in the run 1 output. - One script: every count is over all rows of the cycle. No sampling, no random seed.
- Requirements, tolerances, code lists and example rows are written out in step 2. They are our judgment.
- Self-checks: loaded rows equal source rows (trips on duplicated rows); every rule flags exactly its example rows (trips on a rule that cannot fire, or that cannot run on its example rows); every transaction type is listed (trips on an unknown code); every listed code appears in the data (trips on an added unused code); every rule runs on the data (trips on a column read as text). The evidence CSV is written only if all pass.
Limits
- This is not a client result and not a data set for sale. It tests a public government file. Your data has other fields, owners and rules.
- A passing rule is not proof of accuracy. A filled-in state can be the wrong state. The rules check form and presence, not truth.
- The tolerances are ours. We did not tune them against any owner’s needs, and R3 and R6 pass only because of them.
- Example rows show the SQL matches the sentence you wrote, not that the sentence is the right requirement.
- The source can change. The FEC republishes its bulk files. Our numbers are for the files as they stood on 2026-11-05.
- Row numbers are positions as the script loaded the file, counted without an explicit sort, so a different DuckDB version could number rows differently. They are not FEC record numbers.
Tested on
Test environment
- Platform
- DuckDB 1.5.6, Python 3.12, run from Python with the
duckdbmodule - Edition
- Open-source DuckDB, in-memory
- Date
- 2026-11-05
- Dataset
- FEC committee contributions to candidates and other committees (
pas2), cycles 2018 and 2020, 41,535,118 bytes downloaded with the header file (US government data, public; source) - Code
- requirements_to_rules.py, result rule_evidence.csv
When this is not enough
- You have hundreds of tables. Writing every sentence and example by hand does not scale; someone has to draft the rules and an owner has to approve them.
- Your owners do not yet know their requirements. The profile shows what is in the data, not what the business needs.
- You need the rules where people look for data. A script and a CSV are not a catalog.
Metadata Enrichment Engine delivers three things, data quality rules among them
This guide checks data quality rules you already have. Metadata Enrichment Engine delivers AI-driven contextualized business descriptions, sensitivity classifications and data quality rules for your own tables.
Your data owners review and approve the AI-driven contextualized business descriptions, sensitivity classifications and data quality rules for each table, instead of starting from a blank page.