Export CSV with a Custom Delimiter and Quoting
The bank's upload portal rejects the payment file with Line 42: unexpected number of fields, the German ERP shows every row in one column, and a partner's importer reads "ACME, Ltd" as two fields. The export is a one-liner and every recipient wants something different: semicolons because their Excel uses a comma as the decimal separator, pipes because their data contains both commas and semicolons, quotes around everything, quotes around nothing, CRLF line endings, Latin-1 encoding.
Root Cause
"CSV" is not one format. RFC 4180 describes comma separation with double-quote escaping, but real recipients define their own dialect: the field delimiter, the quote character, whether quoting is minimal or applied to every field, how a quote inside a quoted field is escaped (doubled, or preceded by a backslash), the line terminator, and the character encoding. Regional settings compound it — in locales where the decimal separator is a comma, Excel writes and expects semicolon-separated files, and a numeric column exported as 1240.50 is read as text. pandas' defaults implement one dialect (comma, minimal quoting, doubled quotes, \n, UTF-8), so any mismatch shows up as parse errors or misaligned columns at the recipient, not at export time.
Minimal Diagnostic
Inspect a sample file the recipient accepts — or one they sent you — and read its dialect off the bytes rather than guessing.
# stdlib only
import csv
from pathlib import Path
SAMPLE = Path("in/recipient-sample.csv")
def sniff(path: Path, sample_bytes: int = 64_000) -> dict:
raw = path.read_bytes()[:sample_bytes]
encoding = "utf-8-sig" if raw.startswith(b"\xef\xbb\xbf") else "utf-8"
try:
text = raw.decode(encoding)
except UnicodeDecodeError:
encoding = "cp1252"
text = raw.decode(encoding, errors="replace")
dialect = csv.Sniffer().sniff(text[:8000])
crlf = raw.count(b"\r\n")
lf_only = raw.count(b"\n") - crlf
quoted_fields = text.count('"')
return {
"encoding": encoding,
"delimiter": repr(dialect.delimiter),
"quotechar": repr(dialect.quotechar),
"doublequote": dialect.doublequote,
"escapechar": repr(dialect.escapechar),
"line_terminator": "CRLF" if crlf and not lf_only else "LF" if lf_only and not crlf else "mixed",
"quote_characters": quoted_fields,
"first_line": text.splitlines()[0][:80],
}
if __name__ == "__main__":
for key, value in sniff(SAMPLE).items():
print(f"{key:>17}: {value}")
encoding: cp1252
delimiter: ';'
quotechar: '"'
doublequote: True
escapechar: None
line_terminator: CRLF
quote_characters: 0
first_line: Kundennummer;Name;Betrag;Buchungsdatum
Semicolons, Windows line endings, Latin-1-family encoding and no quoting at all — a common European ERP dialect. The absence of quote characters is itself information: their importer may not handle quoted fields, so values containing a semicolon must be cleaned rather than quoted.
Fix: Declare the Dialect Once and Export Through It
Keep each recipient's dialect in one place, including number and date formatting, and write exports through a single function. Changed lines carry comments.
# pip install "pandas>=2.2"
import csv
from dataclasses import dataclass, field
from pathlib import Path
import pandas as pd
@dataclass(frozen=True)
class Dialect:
sep: str = ","
quoting: int = csv.QUOTE_MINIMAL
quotechar: str = '"'
doublequote: bool = True
escapechar: str | None = None
lineterminator: str = "\r\n"
encoding: str = "utf-8"
decimal: str = "."
date_format: str = "%Y-%m-%d"
na_rep: str = ""
strip_delimiters_in_values: bool = False # for importers that cannot read quoted fields
RECIPIENTS = { # changed: one entry per partner, versioned with the code
"bank-upload": Dialect(sep=",", quoting=csv.QUOTE_ALL, encoding="utf-8", date_format="%d/%m/%Y"),
"erp-de": Dialect(sep=";", quoting=csv.QUOTE_NONE, encoding="cp1252", decimal=",",
date_format="%d.%m.%Y", strip_delimiters_in_values=True),
"partner-pipe": Dialect(sep="|", quoting=csv.QUOTE_MINIMAL, escapechar="\\", doublequote=False),
}
def export_csv(frame: pd.DataFrame, dest: Path, recipient: str) -> Path:
d = RECIPIENTS[recipient]
out = frame.copy()
if d.strip_delimiters_in_values: # changed: unquotable importer
for col in out.select_dtypes(include=["object", "string"]).columns:
out[col] = out[col].astype("string").str.replace(d.sep, " ", regex=False) \
.str.replace(r"[\r\n]+", " ", regex=True)
dest.parent.mkdir(parents=True, exist_ok=True)
out.to_csv(
dest, index=False,
sep=d.sep, quoting=d.quoting, quotechar=d.quotechar, # changed: dialect, not defaults
doublequote=d.doublequote, escapechar=d.escapechar,
lineterminator=d.lineterminator, encoding=d.encoding,
decimal=d.decimal, date_format=d.date_format, na_rep=d.na_rep,
)
return dest
if __name__ == "__main__":
payments = pd.DataFrame({"customer": ["ACME, Ltd", "Müller GmbH"],
"amount": [1240.5, 98.0],
"booked": pd.to_datetime(["2026-09-01", "2026-09-02"])})
print(export_csv(payments, Path("out/erp-de.csv"), "erp-de").read_text(encoding="cp1252"))
customer;amount;booked
ACME Ltd;1240,5;01.09.2026
Müller GmbH;98,0;02.09.2026
QUOTE_NONE raises Error: need to escape, but no escapechar set if a value still contains the delimiter, which is why the dialect strips delimiters from values for that recipient — an explicit, visible loss instead of a corrupt file. decimal="," affects only float formatting, not the delimiter, so semicolon separation and comma decimals coexist correctly.
Variant Fix 1: Values That Contain the Delimiter, Quotes or Line Breaks
When the recipient does support quoting, the safe default is minimal quoting with doubled quotes — the RFC 4180 behaviour that Excel and most importers implement. Verify the round trip for the awkward values rather than assuming:
# pip install "pandas>=2.2"
import csv
import io
import pandas as pd
TRICKY = pd.DataFrame({
"text": ['ACME, Ltd', 'He said "hello"', "line one\nline two", "trailing space ", ";semicolon"],
"amount": [1.5, 2.0, 3.25, 4.0, 5.5],
})
def roundtrip(frame: pd.DataFrame, **kwargs) -> bool:
buf = io.StringIO()
frame.to_csv(buf, index=False, **kwargs)
read_kwargs = {k: v for k, v in kwargs.items() if k in
{"sep", "quotechar", "escapechar", "doublequote", "encoding", "decimal"}}
back = pd.read_csv(io.StringIO(buf.getvalue()), dtype={"text": "string"}, **read_kwargs)
return back["text"].tolist() == frame["text"].tolist()
for name, opts in {
"minimal": dict(quoting=csv.QUOTE_MINIMAL),
"all": dict(quoting=csv.QUOTE_ALL),
"backslash-escape": dict(quoting=csv.QUOTE_MINIMAL, doublequote=False, escapechar="\\"),
}.items():
print(f"{name:<18} round-trips: {roundtrip(TRICKY, **opts)}")
Embedded line breaks are the value type that most often breaks a recipient's importer even when quoting is correct, because many simple parsers split on newlines before handling quotes. If a partner's system fails on multi-line fields, replace line breaks with a marker on export and restore them on import — and record that decision in the dialect, where the next person will find it.
Variant Fix 2: Fixed-Width and Tab-Separated Variants
Bank and payroll systems sometimes require tab separation or fixed-width records rather than delimited ones:
# pip install "pandas>=2.2"
from pathlib import Path
import pandas as pd
def export_tsv(frame: pd.DataFrame, dest: Path) -> Path:
out = frame.copy()
for col in out.select_dtypes(include=["object", "string"]).columns:
out[col] = out[col].astype("string").str.replace(r"[\t\r\n]+", " ", regex=True) # tabs cannot be quoted
out.to_csv(dest, sep="\t", index=False, quoting=3, lineterminator="\r\n", encoding="utf-8")
return dest
FIELDS = [("customer_id", 10), ("name", 30), ("amount_cents", 12), ("booked", 8)]
def export_fixed_width(frame: pd.DataFrame, dest: Path) -> Path:
lines = []
for row in frame.itertuples(index=False):
parts = []
for (name, width) in FIELDS:
value = str(getattr(row, name) or "")
if len(value) > width:
raise ValueError(f"{name}={value!r} exceeds {width} characters") # never silently truncate
parts.append(value.rjust(width, "0") if name.endswith(("_id", "_cents")) else value.ljust(width))
lines.append("".join(parts))
dest.write_text("\r\n".join(lines) + "\r\n", encoding="ascii", errors="strict")
return dest
Fixed-width formats fail loudly here on both over-long values and non-ASCII characters, which is correct: a truncated account number or a silently replaced ü is worse than a failed export. Amounts in such formats are usually integer cents with implied decimals — convert with Decimal rather than floats to avoid rounding surprises.
Documenting the Dialect for the Recipient
Half of the friction in file exchanges is that neither side has written down what the file is supposed to look like. Generate a short specification from the same dialect object that produces the file, and ship it alongside the first delivery:
# pip install "pandas>=2.2"
import csv
from pathlib import Path
QUOTING_NAMES = {csv.QUOTE_MINIMAL: "only when needed", csv.QUOTE_ALL: "every field",
csv.QUOTE_NONNUMERIC: "non-numeric fields", csv.QUOTE_NONE: "never"}
def write_spec(recipient: str, dest: Path, columns: list[str]) -> Path:
d = RECIPIENTS[recipient]
lines = [
f"File specification for {recipient}",
"=" * 40,
f"Delimiter : {d.sep!r}",
f"Quote character : {d.quotechar!r} ({QUOTING_NAMES[d.quoting]})",
f"Escaping : {'doubled quotes' if d.doublequote else f'escape char {d.escapechar!r}'}",
f"Line terminator : {'CRLF' if d.lineterminator == chr(13) + chr(10) else 'LF'}",
f"Encoding : {d.encoding}",
f"Decimal separator: {d.decimal!r}",
f"Date format : {d.date_format}",
f"Missing values : {'empty field' if d.na_rep == '' else d.na_rep!r}",
f"Header row : yes, columns in this order: {', '.join(columns)}",
]
dest.write_text("\n".join(lines) + "\n", encoding="utf-8")
return dest
Because the text is derived from the dialect that generates the file, the specification cannot drift from reality — a change to the dialect changes the document on the next run. Send it once, keep it in the repository, and refer to it when a recipient reports a parsing problem; most such reports turn out to be a change on their side.
Verification
Read every export back with the same dialect and compare against the source frame, then check the bytes for the properties a recipient's parser cares about.
# pip install "pandas>=2.2"
import csv
from pathlib import Path
import pandas as pd
def verify_export(frame: pd.DataFrame, dest: Path, recipient: str) -> None:
d = RECIPIENTS[recipient]
back = pd.read_csv(dest, sep=d.sep, quotechar=d.quotechar, escapechar=d.escapechar,
doublequote=d.doublequote, encoding=d.encoding, decimal=d.decimal,
dtype="string", keep_default_na=False)
assert list(back.columns) == list(frame.columns), f"columns differ: {list(back.columns)}"
assert len(back) == len(frame), f"{len(back)} rows read, {len(frame)} written"
raw = dest.read_bytes()
if d.lineterminator == "\r\n":
assert raw.count(b"\r\n") == len(frame) + 1, "line terminators are not CRLF on every line"
assert b"\n\n" not in raw.replace(b"\r\n", b"\n"), "blank lines present"
if d.quoting == csv.QUOTE_NONE:
assert d.sep.encode() not in raw.split(b"\r\n")[1].replace(
d.sep.encode(), b"", len(frame.columns) - 1), "unquoted value contains the delimiter"
field_counts = {line.count(d.sep.encode()) for line in raw.split(b"\r\n") if line}
assert len(field_counts) == 1, f"inconsistent field counts per line: {sorted(field_counts)}"
print(f"{dest.name}: {len(back)} rows, dialect '{recipient}' verified")
The field-count check is the one that catches the failure recipients actually report — unexpected number of fields on line 42 — because it finds the one row whose value contained an unescaped delimiter. Keep a fixture file per recipient containing deliberately awkward values and run the check on every release.
FAQ
Why does Excel open my comma CSV in one column?
Excel uses the system list separator. In locales where that is a semicolon, a comma file lands in one column. Export with semicolons for those users, or add a sep=, line as the first line, which Excel honours and most other parsers do not.
Should I use QUOTE_ALL?
It is the safest option for recipients whose parser is unknown, at the cost of a slightly larger file. Some strict importers reject quoted numeric fields, so confirm with a sample.
What about lineterminator versus line_terminator?
Current pandas uses lineterminator; the old line_terminator spelling was removed in pandas 2.0.
Is a BOM required for Excel?
Not required, but encoding="utf-8-sig" makes Excel recognise UTF-8 on double-click, which prevents accented characters turning into mojibake — see fixing encoding errors in CSV files.
Related
- Exporting Data to CSV Formats — the wider export workflow
- Fix CSV Leading Zeros Lost in Excel — codes damaged on the recipient's side
- Fix UnicodeEncodeError Writing CSV — when the chosen encoding cannot represent a character
- Fix CSV Blank Rows on Windows — line-terminator problems in detail
Part of Exporting Data to CSV Formats.