Clean a dataset with soup data clean

soup data clean rewrites a fine-tuning dataset into a new file with a short list of deterministic hygiene rules applied, and it never touches the input. Control characters, zero-width spaces and byte-order marks come out by default. The repairs that rely on heuristics are opt-in. It is new in v0.76.0, so there is no earlier behaviour to compare against.

This page is for the step between "I have a file" and soup data validate, soup data dedup and training. The other data commands are on Data tools.

Quick start

bash
soup data clean raw.jsonl --dry-run
soup data clean raw.jsonl
soup data clean raw.jsonl -o clean.jsonl --force
soup data clean raw.jsonl --strip-boilerplate --repair-code --repair-json --prune-echo
soup data clean raw.jsonl --dry-run --json

--dry-run reads and cleans in memory, prints the statistics and writes nothing. Run it first. On a seven-row ChatML fixture with a zero-width space and a NUL byte in one answer and a whitespace-only answer in another, it prints:

text
Cleaning 7 rows in chatml format (DRY RUN)...
┌─────────────────── soup data clean ───────────────────┐
│ Scanned: 7 rows | Modified: 1 | Dropped: 1 | Clean: 5 │
└───────────────────────────────────────────────────────┘
         Dataset Cleaning Breakdown
┌───────────────────────────┬───────────────┐
│ Rule / Issue              │ Affected Rows │
├───────────────────────────┼───────────────┤
│ Invisible & Control Chars │             1 │
├───────────────────────────┼───────────────┤
│ Empty / Whitespace Turns  │             1 │
└───────────────────────────┴───────────────┘

Dry-run mode: No files were written.

Without --dry-run the same run ends with Cleaned dataset saved to: raw_cleaned.jsonl (6 rows): the six rows that survived, in their original order.

What it always does

These rules run on every call, with no flag:

  • Strips invisible characters from the text fields of the row's format (message content; alpaca instruction, input and output; ShareGPT value; prompt, chosen, rejected and completion on preference rows), and from every string field on a shape it has no rules for: the C0 control characters (U+0000 to U+0008, U+000B, U+000C, U+000E to U+001F), DEL (U+007F), the zero-width characters U+200B, U+200C and U+200D, and the byte-order mark U+FEFF. Tab and newline stay.
  • Normalises line endings: CRLF and CR become LF. A file with Windows line endings therefore reports every row that contains one as modified.
  • Drops a row whose assistant turn is empty. The test is the length of the turn after stripping whitespace, in characters, against --min-tokens (default 1; the name says tokens and the code counts characters). One short turn drops the whole row, so a two-turn chat with a good first answer and an empty second answer disappears entirely, and so does a row with no assistant turn at all. A turn that carries tool calls is exempt, because "content": "" is what the tool-calling format writes on a call-only turn.

It keeps every key it does not know and every custom role as it found them. Characters it does not remove: tab, newline, U+0085, U+00A0, U+00AD, U+2028, U+2060, U+200E and U+200F. A UTF-8 byte-order mark at the very start of the file is dropped by the loader, which reads JSONL as utf-8-sig.

Opt-in repairs

FlagWhat it doesDefault
--strip-boilerplate / --no-strip-boilerplateStrips canned English preambles ("Sure", "Certainly", "Of course", "Happy to help", "Absolutely", "As an AI ...", "I am an AI ...") over up to five passes, and sign-offs ("I hope this helps", "let me know if you have ... questions"). An answer that is only boilerplate becomes empty, and the row is then dropped by the empty-turn rule. A sign-off whose free-text tail is longer than 200 characters is left aloneoff
--repair-code / --no-repair-codeAppends a closing fence to an assistant answer with an odd number of triple backticksoff
--repair-json / --no-repair-jsonIn tool-call arguments: unwraps a markdown fence and removes trailing commas. Nothing else is repairedoff
--drop-invalid-jsonDrops a row whose tool-call arguments do not parse. It runs after --repair-json when both are set; on its own it also drops arguments that were only repairable. Without it an unparseable argument is left as it wasoff
--prune-echo / --no-prune-echoDrops a row whose assistant turn equals the user prompt, ignoring case and surrounding whitespaceoff

Other options:

OptionMeaning
PATHThe dataset. It must sit under the working directory. .jsonl, .json, .csv, .parquet and .txt load; anything else exits 1
-o, --outputWhere to write. Default <input stem>_cleaned<input suffix>. It must sit under the working directory and must not be the input
-f, --forceOverwrite an existing output. Without it an existing output is refused
--dry-runReport, write nothing
--min-tokens NDefault 1; a negative value exits 1
--jsonPrint the summary as JSON on stdout (below)

--repair-json and --drop-invalid-json read every call the tool-calling loader reads: a row's top-level tool_calls and each assistant turn's tool_calls, in the {"function": {"name": ..., "arguments": ...}} shape or the flat one. Arguments that are already a JSON object are left alone.

Which rules reach which format

The command detects the file's format and prints it in the first line. It covers chatml, alpaca, sharegpt, dpo, kto and tool-calling. A row that has a messages list (this includes audio files) is cleaned as ChatML and a row that has a conversations list (this includes llava and sharegpt4v files) as ShareGPT; a row of any other shape gets the control-character rule on its string fields and nothing else. Each cell below was run on a fixture.

FormatControl charactersEmpty-turn drop--strip-boilerplate--repair-code--prune-echoJSON flags
chatmlyesyes (a turn with tool_calls is exempt)yesyesyesyes, on assistant turns' tool_calls
alpacayesyes, on outputyesyesyes (instruction plus input against output)no
sharegptyesyesyesyesyesno
dpoyesyes, on chosen; an empty rejected is keptno effectyes, on chosen and rejectedno effectno
ktoyesyes, on completionno effectyes, on completionno effectno
tool-callingyesnonononoyes

Two rows need reading twice.

  • dpo and kto rows silently ignore --strip-boilerplate and --prune-echo. The flags are accepted, the run exits 0, and nothing changes: a dpo row whose chosen starts with Certainly! keeps it, and so does a row whose chosen equals its prompt. If you need either on preference data, clean the text before it becomes a pair.
  • tool-calling means a file that Soup detects as tool-calling, which needs a tools key, and any row anywhere with a top-level tool_calls key is cleaned this way too. In that case only the control-character rule and the two JSON flags run: an empty assistant turn stays and Certainly! stays. The same conversations without tools or a top-level tool_calls are detected as chatml, where the other rules do apply.

Reading the summary

  • Scanned is the number of rows loaded. A malformed JSONL line is skipped with a Warning: invalid JSON on line N and is not counted anywhere.
  • Modified is rows that changed and were kept. Dropped is rows removed. Clean is rows that came out identical.
  • The breakdown counts rows per rule. A row that several rules touched is counted under each, and a dropped row is counted under the rule that dropped it. The rule names are Invisible & Control Chars, Empty / Whitespace Turns, Boilerplate Disclaimers, Markdown Code Fence Repair, Target Leakage / Echo, Malformed JSON in Tools and Invalid JSON in Tool Calls, Empty / malformed messages and Empty / malformed conversations (a row whose messages or conversations list is empty), plus Format Normalization for a row that changed with no named rule.

--json prints exactly one object and suppresses the banner and tables. Loader warnings move to stderr so stdout still parses:

json
{
  "total_scanned": 7,
  "total_clean": 2,
  "total_modified": 3,
  "total_dropped": 2,
  "output_rows": 5,
  "rule_counts": {
    "Invisible & Control Chars": 1,
    "Empty / Whitespace Turns": 1,
    "Boilerplate Disclaimers": 2,
    "Markdown Code Fence Repair": 1,
    "Target Leakage / Echo": 1
  },
  "dry_run": true
}

That is the same seven-row fixture with --strip-boilerplate --repair-code --prune-echo. The object has no output path.

The input is never modified

The command refuses to run when the output path is the input path, when the output already exists and --force is absent, and when either path is outside the working directory. The write is atomic. The output is always JSON Lines text: a .json array loads, but the file written as <stem>_cleaned.json holds one row per line, and a .csv input likewise produces JSON lines under a .csv name. Non-ASCII text is written as it is, not escaped.

Exit codes

ExitMeaning
0The run completed. That includes a run that dropped rows and a --dry-run
1A missing or empty input, an unsupported extension, an input or output outside the working directory, an output equal to the input, an existing output without --force, a negative --min-tokens, or a write failure
2A mistyped option (Click's usage error). This command is not one of the gate commands whose usage errors moved to 3; see Exit codes

Dropping rows is not a failure, so a CI step cannot use the exit code to find out that the data was dirty. Read total_dropped and total_modified from --json instead.

Safety

  • No dataset text is echoed. The command prints counts, rule names, and error and warning messages. The text it prints that can carry user data, a load or write error and the rule names, goes through upstream's terminal-safe path, which strips C0 (except tab, newline and carriage return), DEL and C1 control characters and escapes Rich markup. A control sequence stored in a dataset cannot reach your terminal through this command.
  • A long crafted row cannot stall it. Before v0.76.0 shipped, a sign-off pattern behind --strip-boilerplate could take a very long time on a crafted row (upstream's source comment records about 20 seconds for 20,000 characters and about 64 seconds for 40,000). The pattern no longer begins with a whitespace run, and the free-text tail of a sign-off is capped at 200 characters; results on ordinary rows are unchanged. The command is new in this release, so no earlier release carried the slow pattern. A fixture of 40,000 consecutive spaces and one of 54,000 characters of repeated sign-offs both finished in under a second here.

Limits

  • The preamble and sign-off patterns are English regexes. They do nothing for other languages and will miss an unusual wording.
  • Stripping U+200C and U+200D is on by default. Some scripts (Persian, several Indic scripts) and emoji sequences use those characters on purpose, and the rule removes them there too.
  • It is not a PII scrubber and it does not deduplicate. Use soup expect with expect_no_pii to find personal data and soup data dedup for near-duplicates.
  • A row of an unknown shape gets a vague message (tracked upstream as #1818).
  • Cleaning the data is not the same as validating it. Run soup data validate on the output, which tells you whether the loader will keep every row.

See also: Data tools, Data formats, Tool-calling fine-tuning, Fine-tune Doctor.

Soup is free and Apache-2.0. If it saved you a training run, starring the repo costs nothing and helps most. You can also fund the GPU time behind the work a 4 GB laptop cannot reach.