Data-Cleaning Tool

Data-Cleaning Tool

Cleans messy CSV and Excel exports reproducibly from a YAML profile, with a rejects file and an HTML report. Version 0.1.0, shown here on an invented survey export of a fictional panel research company.

  • Reads CSV and Excel the same way every time: encoding and delimiter sniffing, header offsets, footer rows, several sheets, short and malformed records.
  • Normalises headers to snake_case and cleans text: Unicode form, invisible characters, whitespace, null tokens, case and value maps.
  • Converts types with German and English number and date formats; a value that does not parse is rejected or, where the profile says so, set to null; never guessed.
  • Removes exact and key duplicates (fuzzy matching as an option), fills missing values per column and checks ranges, patterns and allowed values.
  • Writes the cleaned file (CSV, Excel or Parquet), every removed row with its reason, and a JSON and HTML report with before and after statistics.

Install and run

Install the command from the release wheel with uv, extras included:

uv tool install "data-cleaning-tool[fuzzy,parquet] @ https://github.com/HARISPAARTAN/data-cleaning-tool/releases/download/v0.1.0/data_cleaning_tool-0.1.0-py3-none-any.whl"

Or work from a clone, which also has the fixtures and profiles used below:

git clone https://github.com/HARISPAARTAN/data-cleaning-tool.git && cd data-cleaning-tool && uv sync --all-extras

In a clone, put uv run in front of each clean-data command. The extras are optional: fuzzy (rapidfuzz) for fuzzy de-duplication, parquet (pyarrow) for Parquet output.

Demo run on the survey fixture

Four commands on fixtures/01_survey_export_de.csv, a German survey export with 1,026 records, recorded with version 0.1.0. Standard output carries results only; logs and the explain text go to standard error.

read1026
empty8
rejected18
duplicates18
out982
exit0

1 of 4Profile the file

clean-data profile fixtures/01_survey_export_de.csv
stdout
file       01_survey_export_de.csv · 107,237 bytes · sha256 6ca4696f292c4a49
encoding   utf-8-sig (bom)
delimiter  ';' (consistency 1.00)
header     row 1
records    1,026 · empty 8 · short 0 · malformed 0 · duplicate 8
locale     decimal ',', thousands '.'

  #  header                     normalised                 non-empty distinct  guess
  1  Respondent ID              respondent_id                   1017      999  string
  2  Welle                      welle                           1018        4  integer(decimal=',', thousands='.') 100.0 %
  3  Startdatum                 startdatum                      1018      755  date(%Y-%m-%d, %d.%m.%Y, %d/%m/%Y) 99.0 %
     note: 95 values parse day-first and month-first (%d/%m/%Y and %m/%d/%Y); the guess reads them day-first
  4  Abgeschlossen am           abgeschlossen_am                1018     1006  datetime(%d.%m.%Y %H:%M) 100.0 %
  5  Alter                      alter                           1018       73  integer(decimal=',', thousands='.') 100.0 %
     null-like 'k.A.' x3
  6  Geschlecht                 geschlecht                      1018        8  string
  7  Land                       land                            1018        6  string
  8  Haushaltseinkommen (€)     haushaltseinkommen_eur           998      374  integer(decimal=',', thousands='.') 100.0 %
     note: 189 values read differently in English and German style (e.g. "38.500" is 38.5 or 38500)
  9  Ausgaben letzter Monat (€) ausgaben_letzter_monat_eur      1018     1000  number(decimal=',', thousands='.') 100.0 %
 10  Zufriedenheit              zufriedenheit                   1003       10  integer(decimal=',', thousands='.') 100.0 %
 11  NPS                        nps                             1018       11  integer(decimal=',', thousands='.') 100.0 %
 12  Kommentar                  kommentar                        588       15  string
 13  Kommentar                  kommentar_2                      253        6  string
 14                             column_14                          0        0  empty

repeated headers: Kommentar
empty columns: column_14

2 of 4Write a profile skeleton

clean-data init fixtures/01_survey_export_de.csv

The first 30 of 89 lines.

stdout
# Profile skeleton from clean-data init 0.1.0 for 01_survey_export_de.csv.
# Review every column. Validation rules (required, min, max, regex, allowed) and missing-value
# strategies are not guessed: add them where the data needs them. Reference: docs/profile-reference.md
version: 1
name: "01_survey_export_de"
description: "Skeleton generated from 01_survey_export_de.csv"
input:
  encoding: "utf-8-sig"  # bom
  delimiter: ";"
  header_row: 1
  null_tokens: ["", "k.A."]
  locale:
    decimal: ","
    thousands: "."
    date_formats: ["%Y-%m-%d", "%d.%m.%Y", "%d/%m/%Y"]
    datetime_formats: ["%d.%m.%Y %H:%M"]
headers:
  normalise: snake_case
  # Normalised input names: respondent_id, welle, startdatum, abgeschlossen_am, alter, geschlecht, land, haushaltseinkommen_eur, ausgaben_letzter_monat_eur, zufriedenheit, nps, kommentar, kommentar_2, column_14
  # rename maps a normalised name to the output name, e.g. {old_name: new_name}
  rename: {}
  drop: ["column_14"]  # empty in the analysed records
columns:
  respondent_id:
    type: string
    # 1017 non-empty, 999 distinct
    # e.g. "NG-279327", "NG-959966", "NG-926477"
  welle:
    type: integer
    # 1018 non-empty, 4 distinct

3 of 4Dry run with --explain

clean-data clean fixtures/01_survey_export_de.csv --profile profiles/01_survey.yaml --dry-run --explain --report out/dry-run.json

An excerpt of stderr: the plan, then the blocks for one column, the age rules, de-duplication and the fills. The full text is in the report's Explain section when --explain is given.

stdout
read 1026 · empty 8 · rejected 18 · duplicates 18 · out 982 · exit 0
stderr
INFO: [plan]     in-memory run · profile survey-de
INFO: [read]     encoding utf-8-sig (given) · delimiter ';' · header row 1 · footer rows 0
…
INFO: [dedupe]   dedupe.exact · dedupe.key.1 [respondent_id] keep last by completed_at
INFO: [read]     1026 records · 0 malformed · 0 short · 0 footer rows skipped
…
INFO: [types]    household_income number(decimal=",", thousands=".")  parsed 998 · null 20 · invalid 0
INFO:            r2 "45.000,00" -> 45000.0 · r3 "38.500" -> 38500.0 · r4 "47.500" -> 47500.0 · r5 "40.500,00" -> 40500.0 · r6 "28.500,00" -> 28500.0
…
INFO: [validate] age.type (integer)  failed 0
INFO: [validate] age.min (>= 16)  failed 2 · r48 "15" · r76 "12"
INFO: [validate] age.max (<= 99)  failed 3 · r118 "130" · r544 "150" · r871 "101"
…
INFO: [dedupe]   dedupe.exact  removed 12 · r85 duplicate of r80 · r304 duplicate of r124 · r324 duplicate of r181 · r389 duplicate of r41 · r407 duplicate of r189
INFO: [dedupe]   dedupe.key.1 [respondent_id] keep last by completed_at  removed 6 · r40 superseded by r902 · r190 superseded by r288 · r220 superseded by r687 · r422 superseded by r610 · r528 superseded by r783
INFO: [missing]  household_income median 68000.0  filled 20
INFO: [missing]  satisfaction median_low 6  filled 15
…

4 of 4Clean, with rejects and both reports

clean-data clean fixtures/01_survey_export_de.csv --profile profiles/01_survey.yaml --out out/cleaned.csv --rejects out/rejects.csv --report out/report.html --report out/report.json
stdout
read 1026 · empty 8 · rejected 18 · duplicates 18 · out 982 · exit 0
stderr
INFO: read: 1026 records from 01_survey_export_de.csv
INFO: structure: 8 empty records dropped, 13 output columns
INFO: normalise: 2723 cells changed
INFO: types: 10 invalid cells, 0 set to null
INFO: validate: 18 rows rejected
INFO: dedupe: 18 rows removed (12 exact, 6 by key, 0 fuzzy)
INFO: missing: 35 cells filled, 0 rows dropped
INFO: write: 982 rows written to cleaned.csv

Before and after: the first 8 records

Values as read (after header normalisation) and as written. Invisible characters are shown as escapes such as \u00a0.

Tinted and underlined: the cell changed during cleaning.

Before: as read
rowrespondent_idwavestart_datecompleted_atagegendercountryhousehold_incomespend_last_month_eursatisfactionnpscommentinterviewer_note
r2NG-279327421.03.202522.03.2025 08:0155wDeutschland45.000,002.016,82\u00a0€81Preis und Leistung stimmen
r3NG-959966227.08.202628.08.2026 09:1060WDeutschland38.500832,58\u00a0€98
r4NG-926477129.05.202530.05.2025 21:5444keine AngabeDeutschland47.5001.482,09\u00a0€61Fragebogen war zu lang
r5NG-249014216/11/202516.11.2025 12:4247diversAT40.500,002.133,26\u00a0€610Preis und Leistung stimmenTelefon besetzt, zweiter Versuch
r6NG-252513308.06.202609.06.2026 16:3877weiblichÖsterreich28.500,00259,17\u00a0€34Preis und Leistung stimmen
r7NG-172475125.06.202627.06.2026 13:3380männlichde102.500,001.269,38\u00a0€21Gerne wieder
r8NG-504081407.04.202608.04.2026 17:4658WÖsterreich43.000,001.012,56\u00a0€72
r9NG-159651405.03.202505.03.2025 18:4839WSchweiz38.0001.758,26\u00a0€34
After: as written to cleaned.csv
rowstatusrespondent_idwavestart_datecompleted_atagegendercountryhousehold_incomespend_last_month_eursatisfactionnpscommentinterviewer_note
r2keptNG-2793274changed: 2025-03-21changed: 2025-03-22T08:01:0055changed: femalechanged: DEchanged: 45000.0changed: 2016.8281Preis und Leistung stimmen
r3keptNG-9599662changed: 2026-08-27changed: 2026-08-28T09:10:0060changed: femalechanged: DEchanged: 38500.0changed: 832.5898
r4keptNG-9264771changed: 2025-05-29changed: 2025-05-30T21:54:0044changed: not_statedchanged: DEchanged: 47500.0changed: 1482.0961Fragebogen war zu lang
r5keptNG-2490142changed: 2025-11-16changed: 2025-11-16T12:42:0047changed: diverseATchanged: 40500.0changed: 2133.26610Preis und Leistung stimmenTelefon besetzt, zweiter Versuch
r6keptNG-2525133changed: 2026-06-08changed: 2026-06-09T16:38:0077changed: femalechanged: ATchanged: 28500.0changed: 259.1734Preis und Leistung stimmen
r7keptNG-1724751changed: 2026-06-25changed: 2026-06-27T13:33:0080changed: malechanged: DEchanged: 102500.0changed: 1269.3821Gerne wieder
r8keptNG-5040814changed: 2026-04-07changed: 2026-04-08T17:46:0058changed: femalechanged: ATchanged: 43000.0changed: 1012.5672
r9keptNG-1596514changed: 2025-03-05changed: 2025-03-05T18:48:0039changed: femalechanged: CHchanged: 38000.0changed: 1758.2634

Files of the demo run

CI rebuilds these files and this page with tools/build_demo.py --check and fails on any byte difference.

Exit codes

exitmeaning
0success; a run without --strict may still have rejects
1internal error (a bug); run again with --log-level debug
2usage or profile error, a profile that cannot run chunked, a missing extra
3input error: missing or undecodable file, no matching sheet, a profile column absent
4quality gate: --strict or quality.max_reject_rate; rejects and reports written, output not
5output error: target not writable, Excel row limit exceeded
130interrupted; temporary files removed