A CSV file can start with a real header even when csv.Sniffer().has_header() returns False. That result is a guess based on the sample, not a declaration from the file producer. If your importer chooses whether to skip the first record from that guess alone, it can turn a header into data or silently discard a real data record. Decide header presence from the producer's format contract or an explicit import option. Use sniffing as a diagnostic hint when the contract is unknown, and keep the original input available for review.
Reproduce the disagreement before changing the importer
A public CPython report describes a filename/path header that the heuristic does not recognize. The following small input reproduces that kind of failure on the tested CPython 3.11.6, 3.13.2, and 3.14.3 runtimes. These are observations for this fixture; another sample or future implementation can produce a different guess.
import csv
import io
text = "filename,path\nc.txt,/tmp/c.txt\npython.txt,/tmp/python.txt\n"
guess = csv.Sniffer().has_header(text)
print("header guess:", guess)
assert guess is False
assert next(csv.reader(io.StringIO(text))) == ["filename", "path"]The first row is a header because this example's producer defines it that way. The heuristic considers characteristics such as numeric values and string lengths in a bounded set of following rows. It does not know what a path field means. The official documentation explicitly allows both false positive and false negative results. Adding more sample text can change a guess, but it cannot turn this heuristic into a proof of header presence.
Do not reverse the boolean or add a special case for every failed filename. Those changes merely move the error to another input. When an export format is under your control, document its encoding, delimiter, quoting, and header rules together. When it is not, offer an explicit choice and show the first parsed records before committing an import.
Read a sample without losing the beginning
Calling read() on a file advances its cursor. Running the sniffer on that returned string does not restore the file position. If you then create a reader from the same file, the reader starts after the sample; a short input may appear completely empty. Save the starting position and restore it before parsing. For a newly opened regular text file, that position is normally zero.
import csv
import io
text = "filename,path\nc.txt,/tmp/c.txt\npython.txt,/tmp/python.txt\n"
stream = io.StringIO(text, newline="")
start = stream.tell()
sample = stream.read(4096)
hint = csv.Sniffer().has_header(sample)
assert stream.read() == ""
stream.seek(start)
reader = csv.DictReader(stream)
rows = list(reader)
assert reader.fieldnames == ["filename", "path"]
assert len(rows) == 2
assert rows[0]["filename"] == "c.txt"
print(hint, rows)This uses a known header contract; the hint does not control the parser. For an actual UTF-8 file, open it with encoding="utf-8" and newline="". Choose another encoding only when the producer contract requires it. A failed decode is not evidence that the file lacks a header.
On a non-seekable input, do not call seek() and hope it works. If a stable file can be reopened safely, reopen it under the same decoding and newline rules. Otherwise retain the consumed text and the remaining input in a bounded buffer or temporary spool before parsing from the start. A size limit is essential for large or untrusted uploads. Concatenating physical lines after an arbitrary character sample can split a quoted CSV record, so replay the original text through a proper text stream rather than treating the sample as complete rows.
Choose header or no header explicitly
DictReader without fieldnames uses the first row as field names and does not return that row as data. Supplying fieldnames changes the contract: the first input row becomes data too. The example below makes the choice visible. Both branches preserve the first data record instead of relying on a guess.
import csv
import io
def read_export(text, *, has_header):
stream = io.StringIO(text, newline="")
if has_header:
reader = csv.DictReader(stream)
if reader.fieldnames != ["filename", "path"]:
raise ValueError("Unexpected header: inspect the export contract")
else:
reader = csv.DictReader(stream, fieldnames=["filename", "path"])
return list(reader)
with_header = "filename,path\nfoo.txt,/tmp/foo.txt\n"
without_header = "c.txt,/tmp/c.txt\npython.txt,/tmp/python.txt\n"
assert read_export(with_header, has_header=True) == [
{"filename": "foo.txt", "path": "/tmp/foo.txt"}
]
rows = read_export(without_header, has_header=False)
assert len(rows) == 2 and rows[0]["filename"] == "c.txt"
assert read_export("", has_header=False) == []
try:
read_export("", has_header=True)
except ValueError:
print("Empty header-required input rejected")
else:
raise AssertionError("Expected a missing-header error")The strict field-name comparison is an example for this export schema, not a universal schema detector. An empty header-required file is rejected; an empty headerless file yields no records. Decide those outcomes deliberately. Do not catch every exception and retry under the opposite header mode: that can hide malformed exports and change which records are retained.
Keep delimiter inference and header inference separate
Sniffer.sniff() proposes a dialect; has_header() proposes header presence. Neither replaces your format contract. If the delimiter is known to be a comma, use that known dialect. If delimiter discovery is needed, restrict candidate delimiters to the producer's supported choices and surface csv.Error when a sample is insufficient. An incomplete quoted field or a sample with only one record deserves review, not a silent assumption that the first record is expendable.
A successful header decision is only the first import check. Duplicate names, extra columns, missing values, and inconsistent record widths need their own validation before writing into a database. Keep those checks separate so a schema error cannot be misreported as a sniffer failure. Also preserve the source file and report record counts before any irreversible downstream write.
Check the import outcome, not only the guess
For a regression fixture, assert the expected field names, the first data record, the total number of records, and the empty-file policy. Include both header modes. If you retain sniffing for a preview, test that reading the preview does not consume the actual import stream. A correct-looking boolean does not demonstrate that the importer kept every record.
This guide verifies local CSV parsing only. It does not measure automatic detection accuracy across arbitrary exports, search demand volume, or the correctness of a downstream data migration. The safe result is an explicit input contract and a first-row preservation test, with uncertain files held for review.