Diagnose CSV Delimiters with Python: Sniffer, Explicit Formats, and Full Validation
On this page
A CSV export turns into one column, or a comma inside a note creates extra fields. The file may use semicolons or tabs rather than being damaged. This beginner-to-intermediate tutorial builds a read-only delimiter inspector with Python’s standard library: suggest a candidate, then check the header and field counts throughout the file.
1. Method: suggest first, then validate
- When the export format is known, pass
--delimiter semicolonortabrather than guessing again. - Otherwise, suggest a delimiter from comma, semicolon, tab, and pipe only.
- Read the entire file against an expected header order and require matching field counts. Print successful JSON only after every record passes.
Python’s official Sniffer documentation describes deriving a dialect from sample text and restricting candidate delimiters. A suggestion is not proof: a short sample, a cut-off quoted field, or irregular input can defeat inference or produce the wrong choice.
We do not infer header presence or adopt every inferred dialect setting; only the delimiter is taken. Our contract requires a header, double-quoted fields, doubled embedded quotes, and no custom escape character. This complements CSV header and structure validation: diagnose the delimiter, then fix the data contract.
2. Setup: create an isolated demo directory
Use an existing Python 3.9 or newer interpreter, with no third-party packages. Commands work in Linux bash/zsh and were tested with Python 3.11.4. Use a new directory name when repeating the experiment to avoid overwriting previous files.
- Run in one terminal:
python3 --version
mkdir csv-delimiter-demo
cd csv-delimiter-demo
python3 - <<'PY_DATA'
from pathlib import Path
Path("samples.csv").write_bytes(
b'\xef\xbb\xbfid;note\n001;"alpha,beta"\n002;"two\nlines"\n'
)
PY_DATA
The semicolon-separated file has a UTF-8 BOM, a comma in the first note, and a two-line second note. There are only two data records.
- Save the next section as
inspect_csv.pyin the current directory, or download the complete script.
3. Complete implementation: rewind and check the entire file
"""Inspect completed local CSV files; guess only the delimiter. Python 3.9+."""
import argparse
import csv
import json
from pathlib import Path
import sys
DELIMITERS = {"comma": ",", "semicolon": ";", "tab": "\t", "pipe": "|"}
def inspect_csv(path, mode, columns):
if len(columns) < 2 or len(set(columns)) != len(columns):
raise ValueError("expected columns must be unique and number at least two")
if any(not name.strip() for name in columns):
raise ValueError("expected column names must not be blank")
with path.open(encoding="utf-8-sig", newline="") as stream:
if mode == "auto":
sample = stream.read(8192)
try:
delimiter = csv.Sniffer().sniff(sample, delimiters=",;\t|").delimiter
except csv.Error as exc:
raise ValueError("cannot suggest delimiter; specify --delimiter") from exc
stream.seek(0)
else:
delimiter = DELIMITERS[mode]
reader = csv.reader(stream, delimiter=delimiter, quotechar='"',
doublequote=True, skipinitialspace=False, strict=True)
try:
header = next(reader, None)
if header != columns:
raise ValueError(f"header mismatch: expected {columns!r}, got {header!r}")
records = 0
for row in reader:
if len(row) != len(columns):
raise ValueError(
f"record {records + 1}, ending line {reader.line_num}: "
f"expected {len(columns)} fields, got {len(row)}"
)
records += 1
except csv.Error as exc:
raise ValueError(f"CSV error near physical line {reader.line_num}: {exc}") from exc
return {"delimiter": delimiter, "columns": columns, "records": records,
"physical_lines": reader.line_num, "selection": mode}
def main():
parser = argparse.ArgumentParser(description=__doc__, allow_abbrev=False)
parser.add_argument("file", type=Path)
parser.add_argument("--delimiter", choices=["auto", *DELIMITERS], default="auto")
parser.add_argument("--columns", nargs="+", required=True)
args = parser.parse_args()
try:
report = inspect_csv(args.file, args.delimiter, args.columns)
except (OSError, UnicodeError, ValueError) as exc:
print(f"ERROR: {exc}", file=sys.stderr)
return 1
print(json.dumps(report, ensure_ascii=True))
return 0
if __name__ == "__main__":
raise SystemExit(main())
Sampling reads at most 8192 characters, then seek(0) returns to the start; forgetting to rewind skips the header. utf-8-sig accepts UTF-8 with or without an initial BOM, not arbitrary encodings. For decoding failures, establish the source encoding with the CSV-to-UTF-8 tutorial.
The official csv.reader documentation specifies newline="" for file objects, while the line_num documentation distinguishes physical lines from records. Let the parser handle quoted newlines instead of splitting physical lines yourself.
Use this tool for small, completed local regular files that remain unchanged during inspection, not pipes, devices, or actively appended files. Full validation iterates over records without retaining them all, but an oversized individual field is still subject to parser limits. This is not resource isolation for huge untrusted inputs.
4. Usage: automatic suggestions and explicit formats
- Show help, then try automatic selection and an explicit semicolon:
python3 inspect_csv.py --help
python3 inspect_csv.py samples.csv --columns id note
python3 inspect_csv.py samples.csv --delimiter semicolon --columns id note
Expected automatic-mode output:
{"delimiter": ";", "columns": ["id", "note"], "records": 2, "physical_lines": 4, "selection": "auto"}
Explicit mode produces the same counts with selection set to semicolon. physical_lines is 4 but records is 2 because one note spans two lines. 001 remains a string rather than becoming a number.
- Deliberately use the wrong delimiter and inspect the exit status:
python3 inspect_csv.py samples.csv --delimiter comma --columns id note
printf 'exit=%s\n' "$?"
The error below goes to stderr, followed by the shell’s exit=1:
ERROR: header mismatch: expected ['id', 'note'], got ['id;note']
| Exit code | Meaning |
|---|---|
| 0 | All records match the expected header and field counts; stdout contains one JSON report. |
| 1 | File, decoding, inference, or structural failure; stderr contains an error and stdout is empty. |
| 2 | Invalid CLI arguments, such as an unsupported delimiter name. |
- Once the upstream format is confirmed, pin it in automation and use
&&to stop after failure:
python3 inspect_csv.py samples.csv --delimiter semicolon --columns id note \
&& printf 'Input structure accepted\n'
The tool neither rewrites input nor prints data records, but error messages may include the actual header. Do not send sensitive headers into public logs. Passing these checks does not validate field values, unique IDs, or business rules.
5. Validation: test success and failure cases
Save this as test_inspect.py, or download the test script, then run python3 test_inspect.py in the demo directory:
"""Run from the directory containing inspect_csv.py. No third-party packages."""
import csv
import hashlib
import io
from pathlib import Path
import subprocess
import sys
import tempfile
script = Path("inspect_csv.py").resolve()
cases = [
("semicolon", b'id;note\n001;"alpha,beta"\n', "auto", 0, ";"),
("comma", b'id,note\n001,"alpha,beta"\n', "auto", 0, ","),
("tab", b'id\tnote\n001\talpha\n', "tab", 0, "\t"),
("pipe", b'id|note\n001|alpha\n', "pipe", 0, "|"),
("multiline", b'\xef\xbb\xbfid;note\r\n001;"a\nb"\r\n', "semicolon", 0, ";"),
("wrong", b'id;note\n001;alpha\n', "comma", 1, None),
("ragged", b'id;note\n001;alpha;extra\n', "semicolon", 1, None),
("blank", b'id;note\n\n', "semicolon", 1, None),
("quotes", b'id;note\n001;"unfinished\n', "semicolon", 1, None),
("encoding", b'id;note\n001;\xff\n', "semicolon", 1, None),
("empty", b'', "auto", 1, None),
]
with tempfile.TemporaryDirectory() as directory:
for name, data, mode, expected, delimiter in cases:
path = Path(directory) / (name + ".csv")
path.write_bytes(data)
before = hashlib.sha256(data).hexdigest()
result = subprocess.run(
[sys.executable, str(script), str(path), "--delimiter", mode,
"--columns", "id", "note"], capture_output=True, text=True
)
assert result.returncode == expected, (name, result.stderr)
assert hashlib.sha256(path.read_bytes()).hexdigest() == before
if expected == 0:
import json
report = json.loads(result.stdout)
assert report["delimiter"] == delimiter
assert report["records"] == 1 and not result.stderr
else:
assert not result.stdout and result.stderr.startswith("ERROR:")
print(f"PASS {name}: exit={expected}")
# A naive split breaks the quoted comma; csv.reader preserves it.
assert '001,"alpha,beta"'.split(",") != ["001", "alpha,beta"]
assert next(csv.reader(io.StringIO('001,"alpha,beta"'))) == ["001", "alpha,beta"]
print("PASS quoted comma: field values preserved")
Expect 11 passing case messages followed by PASS quoted comma: field values preserved. Tests check exit codes, output streams, and unchanged input hashes, covering BOM, CRLF, embedded newlines, wrong delimiters, ragged rows, blank records, unclosed quotes, invalid UTF-8, and empty input.
6. Troubleshooting: do not repair CSV by replacing characters
| Symptom | Concrete action |
|---|---|
cannot suggest delimiter |
Confirm nonempty input, establish the export format, then use --delimiter. Do not silently fall back to comma. On Python 3.11.4, a CRLF file with a multiline quoted field also defeated inference in our test, but parsed correctly with an explicit delimiter. |
header mismatch |
Check delimiter, header order, case, and spaces. Column-name whitespace is not stripped. |
expected 2 fields, got ... |
Inspect the ending physical line for missing fields, extra delimiters, or blank records; fix the export. |
CSV error |
Check closing quotes and doubled embedded quotes. strict=True is not business validation. |
field larger than field limit |
First rule out a wrong delimiter collapsing a whole line into one field, then follow CSV field-size troubleshooting. |
Do not convert with replace(";", ",") or read with split(","): neither understands quoted delimiters. For a real conversion, confirm the input format first, then use csv.reader and csv.writer to write a new output path.
7. Summary
Use a sample to narrow the investigation, then validate the candidate against a known header and every record’s field count. After confirming the format, pin the delimiter and let exit codes gate downstream work instead of treating a guess as a permanent data contract.
- Author: yanzhi
- Original article: https://www.bobobk.com/en/python-csv-delimiter-sniffer.html
- License: This work is licensed under CC BY-NC-ND 4.0. See the license for attribution, noncommercial use and no-derivatives terms.
Related articles
- Fix Python CSV Field Limits: Set a Character Cap and Test Boundaries
- Read CSV Files Inside tar.gz with Python: Summaries Without Extraction
- Check Executable Paths with Python shutil.which Before Automation Runs
- Validate INI Settings with Python configparser: Percent Signs, Booleans, and Duplicates
- Wait for a File in Python: Timeouts, Atomic Handoffs, and Exit Codes