pandas to_csv() — write DataFrames to CSV exactly the way the recipient needs them

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.

What it does

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.

Why it matters

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.

Examples

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 parsed
site                  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=...).

Flags

FlagMeaning
index=Falseskip 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' + headerappend to an existing file; pass header=not os.path.exists(path) so the header row is written once
date_format / float_formatprintf-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

Born in 0.8.0 (June 2012), grew an API since

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.

line_terminator → lineterminator (1.5.0 → 2.0)

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.

Under the hood: the C writer, row by row

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.

Fun facts

Pros

Cons

Takeaways