One call from DataFrame to a file someone else can open — but only if you win the index argument.
The most-shared CSV in your company was probably written by this method — and it probably carries an extra column nobody asked for.
to_csv() writes a DataFrame to a comma-separated file with one call: df.to_csv('report.csv', index=False). Every knob a business report needs is a keyword — sep for other delimiters, decimal=',' for German Excel, columns to pick and order columns, na_rep for missing markers, date_format and float_format for clean presentation, mode='a' to append, and compression='infer' to write .gz directly. Call it with no path and it returns the CSV as a string instead — handy for previewing exactly what will hit the disk.
Every pandas workflow ends in an export: to Excel for the controller, to an SFTP drop for the ERP, to a .gz for the pipeline. to_csv() is that handoff, and its two silent failures are expensive. Forget index=False and your file sprouts an unnamed column that breaks the import downstream. Skip decimal=',' and a German Excel reads 1290.55 as text. The method is easy; using it deliberately is what separates a report people trust from one they re-do by hand.
import pandas as pd
sales = pd.DataFrame({
"product": ["Widget", "Sprocket", "Gasket", "Widget"],
"region": ["EMEA", "APAC", "EMEA", "AMER"],
"revenue": [120.5, 88.0, 42.3, 95.7],
"units": [40, 22, 17, 31],
})
sales.to_csv("sales.csv", index=False)
back = pd.read_csv("sales.csv")
print(back)product region revenue units 0 Widget EMEA 120.5 40 1 Sprocket APAC 88.0 22 2 Gasket EMEA 42.3 17 3 Widget AMER 95.7 31
index=False keeps the file clean — with the default index=True, read_csv returns an extra 'Unnamed: 0' column.
orders = pd.DataFrame({
"customer": ["Müller & Söhne", "Bäckerei Kern", "Nordwind GmbH"],
"amount_eur": [1290.55, 84.10, 3020.00],
})
orders.to_csv("orders_eu.csv", sep=";", decimal=",", index=False)
print(open("orders_eu.csv").read())
back = pd.read_csv("orders_eu.csv", sep=";", decimal=",")
print(back)customer;amount_eur
Müller & Söhne;1290,55
Bäckerei Kern;84,1
Nordwind GmbH;3020,0
customer amount_eur
0 Müller & Söhne 1290.55
1 Bäckerei Kern 84.10
2 Nordwind GmbH 3020.00
sep=';' + decimal=',' is the continental-European CSV dialect — what German Excel actually opens without an import wizard.
inventory = pd.DataFrame({
"sku": ["A-100", "B-220", "C-315", "D-402", "E-511"],
"warehouse": ["Yanbu", "Jubail", "Yanbu", "Jubail", "Yanbu"],
"stock": [12, 0, 8, 0, 45],
"reorder_level": [10, 5, 10, 5, 20],
})
low = inventory.query("stock <= reorder_level")[["sku", "warehouse", "stock"]]
low.to_csv("reorder_report.csv", index=False)
print(low)
print()
print(open("reorder_report.csv").read())sku warehouse stock 1 B-220 Jubail 0 2 C-315 Yanbu 8 3 D-402 Jubail 0 sku,warehouse,stock B-220,Jubail,0 C-315,Yanbu,8 D-402,Jubail,0
The daily-workhorse idiom: filter with query(), pick columns, export — a ready-to-send reorder report in three lines.
meter = pd.DataFrame({
"site": ["Yanbu", "Jubail"],
"reading_ts": pd.to_datetime(["2026-09-17 08:30:00", "2026-09-17 09:00:00"]),
"kwh": [1234.5678, 987.65432],
})
meter.to_csv("meter.csv", index=False, date_format="%Y-%m-%d %H:%M", float_format="%.2f")
print(open("meter.csv").read())site,reading_ts,kwh Yanbu,2026-09-17 08:30,1234.57 Jubail,2026-09-17 09:00,987.65
date_format and float_format shape timestamps and floats for the recipient — Excel-friendly at the moment of writing.
meter.to_csv("meter.csv.gz", index=False)
r = pd.read_csv("meter.csv.gz")
print(r.dtypes) # CSV is text: dates come back as strings unless parsedsite str reading_ts str kwh float64 dtype: object
compression='infer' zips because of the .gz suffix — but round-tripping dates through CSV always loses the dtype; re-parse with read_csv(parse_dates=...).
| Flag | Meaning |
|---|---|
index=False | skip the DataFrame index — omitting this is the #1 source of 'Unnamed: 0' columns downstream |
sep=';' / decimal=',' | European CSV dialect: semicolon delimiter, comma decimals — pairs with read_csv(sep=';', decimal=',') |
columns=[...] | write only selected columns, in exactly this order |
na_rep='NULL' | marker for missing values instead of the empty default — matters for SQL loaders |
mode='a' + header | append to an existing file; pass header=not os.path.exists(path) so the header row is written once |
date_format / float_format | printf-style formatting for datetimes and floats at write time |
compression='infer' | .gz and .bz2 suffixes compress automatically; 'infer' has been the default since 0.24.0 |
to_csv() shipped in the same early era as read_csv and has been the canonical pandas export ever since. The big modernization came in 0.24.0 (January 2019): compression='infer' became the default, so to_csv('file.csv.gz') zips without any extra argument.
pandas 1.5.0 (September 2022) deprecated line_terminator in favor of lineterminator — matching the stdlib csv module and read_csv — and pandas 2.0 (April 2023) removed the old name for good (GH 45302). Defaults to os.linesep: \n on Linux, \r\n on Windows.
to_csv() writes row by row through a C formatter (the same machinery as to_string): each column's values are converted with its formatting rules — float_format and date_format are applied per value here, not in Python — while quoting follows the stdlib csv module rules (QUOTE_MINIMAL by default, so separators inside 'Müller & Söhne' get quoted automatically). With chunksize=N, the frame is written in N-row blocks, which bounds memory on huge exports. Compression hooks in below the writer: the .gz stream is gzipped as it is written, never materialized uncompressed on disk.