Efficient CSV File Processing in Python: csv, pandas, and polars Compared

Compare Python's csv module, pandas, and polars with real benchmarks. Covers malformed CSV, encoding, and dtype-inference pitfalls, large file processing, and performance optimization.

What is a CSV File?

CSV (Comma-Separated Values) is a text-based file format that stores data separated by commas. It is widely used for data exchange and as input/output for data analysis. Python offers the built-in csv module as well as third-party libraries like pandas and polars, and choosing the right tool for your use case and data scale is important.

1. The Standard Library csv Module

1.1 csv.reader: Basic Reading

csv.reader reads a CSV file row by row as lists.

import csv

with open("data.csv", "r", encoding="utf-8") as f:
    reader = csv.reader(f)
    header = next(reader)  # Get the header row
    print(f"Columns: {header}")
    for row in reader:
        print(row)  # ['value1', 'value2', 'value3']

1.2 csv.writer: Basic Writing

import csv

data = [
    ["name", "age", "city"],
    ["Alice", 30, "New York"],
    ["Bob", 25, "London"],
    ["Charlie", 35, "Tokyo"],
]

with open("output.csv", "w", encoding="utf-8", newline="") as f:
    writer = csv.writer(f)
    writer.writerows(data)

Specifying newline="" is important; without it, extra blank lines will appear on Windows.

1.3 DictReader / DictWriter: Dictionary-Based Access

Working with dictionaries keyed by column names improves code readability.

import csv

# Reading as dictionaries
with open("data.csv", "r", encoding="utf-8") as f:
    reader = csv.DictReader(f)
    for row in reader:
        print(row["name"], row["age"])  # Access by column name

# Writing from dictionaries
fieldnames = ["name", "age", "city"]
rows = [
    {"name": "Alice", "age": 30, "city": "New York"},
    {"name": "Bob", "age": 25, "city": "London"},
]

with open("output.csv", "w", encoding="utf-8", newline="") as f:
    writer = csv.DictWriter(f, fieldnames=fieldnames)
    writer.writeheader()
    writer.writerows(rows)

1.4 Custom Delimiters and Quoting

The module supports TSV (tab-separated), semicolon-separated, and other formats.

import csv

# Reading a TSV file
with open("data.tsv", "r", encoding="utf-8") as f:
    reader = csv.reader(f, delimiter="\t")
    for row in reader:
        print(row)

# Custom quoting behavior
with open("data.csv", "w", encoding="utf-8", newline="") as f:
    writer = csv.writer(f, quoting=csv.QUOTE_ALL)  # Quote all fields
    writer.writerow(["name", "address, New York", "notes"])

2. CSV Operations with pandas

2.1 read_csv: Flexible Reading

pandas.read_csv comes with a wide range of options and is the most commonly used approach in practice.

import pandas as pd

# Basic reading
df = pd.read_csv("data.csv")

# Reading with commonly used options
df = pd.read_csv(
    "data.csv",
    encoding="utf-8",       # Encoding
    header=0,               # Header row index (None for no header)
    index_col=0,            # Column to use as index
    dtype={"age": int, "sales": float},   # Explicit type specification
    usecols=["name", "age", "sales"],     # Read only specific columns
    na_values=["N/A", "-", ""],           # Strings to treat as missing
    parse_dates=["date"],   # Columns to parse as dates
    nrows=1000,             # Read only first N rows
)

2.2 to_csv: Writing

import pandas as pd

df = pd.DataFrame({
    "name": ["Alice", "Bob", "Charlie"],
    "age": [30, 25, 35],
    "city": ["New York", "London", "Tokyo"],
})

# Basic writing
df.to_csv("output.csv", index=False, encoding="utf-8")

# Commonly used options
df.to_csv(
    "output.csv",
    index=False,            # Exclude index column
    encoding="utf-8-sig",   # BOM-prefixed UTF-8 for Excel compatibility
    columns=["name", "city"],  # Specify columns to output
    sep="\t",               # Tab-separated output
    na_rep="N/A",           # Representation for missing values
)

2.3 Filtering Rows and Aggregation

import pandas as pd

df = pd.read_csv("sales.csv")

# Filtering by condition
ny_sales = df[df["city"] == "New York"]
high_sales = df[df["sales"] > 100000]

# Compound conditions
result = df[(df["city"] == "New York") & (df["age"] >= 30)]

# Group-by aggregation
summary = df.groupby("city").agg(
    total_sales=("sales", "sum"),
    avg_sales=("sales", "mean"),
    count=("sales", "count"),
).reset_index()

print(summary)

2.4 Merging Multiple CSVs

import pandas as pd
import glob

# Concatenate CSVs with the same schema
files = glob.glob("data/sales_*.csv")
dfs = [pd.read_csv(f) for f in files]
combined = pd.concat(dfs, ignore_index=True)

# Join two CSVs on a key
customers = pd.read_csv("customers.csv")
orders = pd.read_csv("orders.csv")
merged = pd.merge(orders, customers, on="customer_id", how="left")

3. CSV Operations with polars

polars is a high-performance DataFrame library written in Rust. It provides an API similar to pandas while being significantly faster on large datasets.

import polars as pl

# Reading
df = pl.read_csv("data.csv")

# Reading with type specifications (polars >= 0.20.31 renamed dtypes to schema_overrides)
df = pl.read_csv(
    "data.csv",
    schema_overrides={"age": pl.Int32, "sales": pl.Float64},
    encoding="utf8",
)

# Filtering and aggregation
result = (
    df.filter(pl.col("city") == "New York")
    .group_by("category")
    .agg(
        pl.col("sales").sum().alias("total_sales"),
        pl.col("sales").mean().alias("avg_sales"),
        pl.col("sales").count().alias("count"),
    )
)

# Writing
result.write_csv("output.csv")

The dtypes argument is old-style polars syntax; it was renamed to schema_overrides a while ago, and using dtypes now triggers a DeprecationWarning (this article was verified against polars 1.42.1). Plenty of sample code online still uses dtypes, so watch out when copying snippets from books or blog posts.

Lazy Evaluation (Lazy API)

A key feature of polars is its Lazy API, which automatically optimizes queries.

import polars as pl

# Build query with lazy evaluation
result = (
    pl.scan_csv("large_data.csv")  # Read as LazyFrame
    .filter(pl.col("sales") > 10000)
    .group_by("city")
    .agg(pl.col("sales").sum())
    .sort("sales", descending=True)
    .collect()  # Execution happens here
)

scan_csv reads only the data that is needed, making it memory-efficient for large files.

4. Efficient Processing of Large CSV Files

When working with CSV files of several gigabytes, loading all data into memory may not be feasible.

4.1 pandas chunksize

import pandas as pd

# Read and process in chunks
chunk_size = 10000
results = []

for chunk in pd.read_csv("large_data.csv", chunksize=chunk_size):
    # Process each chunk independently
    filtered = chunk[chunk["sales"] > 10000]
    results.append(filtered)

# Combine results
final = pd.concat(results, ignore_index=True)

4.2 Memory-Efficient Processing with itertools and csv

For minimal memory usage, combine the csv module with generators.

import csv
from itertools import islice

def read_csv_chunks(filepath, chunk_size=10000):
    """Generator that reads a CSV file in chunks."""
    with open(filepath, "r", encoding="utf-8") as f:
        reader = csv.reader(f)
        header = next(reader)
        while True:
            chunk = list(islice(reader, chunk_size))
            if not chunk:
                break
            yield header, chunk

# Example: compute total sales with minimal memory
total_sales = 0
for header, chunk in read_csv_chunks("large_data.csv"):
    sales_idx = header.index("sales")
    total_sales += sum(float(row[sales_idx]) for row in chunk)

print(f"Total sales: {total_sales:,.0f}")

4.3 Streaming with polars

import polars as pl

# Process large files with streaming mode
result = (
    pl.scan_csv("very_large_data.csv")
    .filter(pl.col("status") == "completed")
    .group_by("city")
    .agg(pl.col("sales").sum())
    .collect(engine="streaming")  # Execute with the streaming engine
)

collect(streaming=True) was deprecated in polars 1.25.0 in favor of collect(engine="streaming") (the old form still works but emits a DeprecationWarning). Through 2025 the polars team rewrote the streaming engine around morsel-driven parallelism – splitting data into small chunks (“morsels”) processed in parallel via Rust async tasks – and it is being promoted to the default engine over time. Because this API keeps moving, always double-check the current syntax against the official docs when working with large CSVs.

5. Handling Encoding Issues

Encoding problems are common when working with CSV files, especially in multilingual environments.

5.1 Common Encodings

EncodingUse CasePython Specification
UTF-8Standard encodingencoding="utf-8"
UTF-8 BOMExcel outputencoding="utf-8-sig"
Shift_JISJapanese Windows environmentsencoding="shift_jis"
CP932Extended Shift_JISencoding="cp932"
Latin-1Western Europeanencoding="latin-1"
CP1252Windows Western Europeanencoding="cp1252"

5.2 A Real UnicodeDecodeError and Automatic Encoding Detection

Files saved from Excel as “CSV (Windows)” on a Japanese-locale machine are encoded as Shift_JIS (CP932). Opening one with the default utf-8 fails with a real, reproducible error:

import pandas as pd

# Trying to read a Shift_JIS file as utf-8
df = pd.read_csv("sjis_sample.csv")
UnicodeDecodeError: 'utf-8' codec can't decode byte 0x96 in position 0: invalid start byte

When you hit an error like this, the first step is to guess the encoding from the raw bytes. Let’s actually run both chardet and the newer charset-normalizer (the library requests uses internally, and more actively maintained) against the same file and compare.

import chardet
from charset_normalizer import from_path

with open("sjis_sample.csv", "rb") as f:
    raw = f.read()

# chardet
result = chardet.detect(raw)
print(result)
# {'encoding': 'cp932', 'confidence': 0.0387..., 'language': 'ja', 'mime_type': 'text/plain'}

# charset-normalizer
best = from_path("sjis_sample.csv").best()
print(best.encoding, best.language)
# cp932 Japanese

Both correctly land on cp932, but notice that chardet’s confidence is only 3.87%. On small files (a few hundred bytes), statistical detection is inherently uncertain, and CP932/Shift_JIS/EUC-JP occupy nearby regions of the encoding space, so even a correct guess can come back with low confidence. Rejecting a guess purely because “confidence is low” can discard a correct answer for Japanese-encoded CSVs. Confidence stabilizes somewhat with larger files, but in practice it’s more reliable to combine detection with domain knowledge about the data’s origin (CP932 for Windows exports, UTF-8 for Unix-originated pipelines) rather than trusting the confidence score alone.

import pandas as pd
from charset_normalizer import from_path

def read_csv_auto_detect(filepath):
    """Detect encoding with charset-normalizer and read the CSV."""
    best = from_path(filepath).best()
    if best is None:
        raise ValueError(f"Could not detect encoding: {filepath}")
    return pd.read_csv(filepath, encoding=best.encoding)

df = read_csv_auto_detect("sjis_sample.csv")

5.3 Handling Encoding Errors

import pandas as pd

# Ignore errors (may lose data)
df = pd.read_csv("data.csv", encoding="utf-8", encoding_errors="ignore")

# Replace errors with replacement character
df = pd.read_csv("data.csv", encoding="utf-8", encoding_errors="replace")

# Try multiple encodings in sequence
def read_csv_auto(filepath):
    """Read CSV by trying multiple encodings."""
    encodings = ["utf-8", "utf-8-sig", "cp932", "shift_jis", "latin-1", "cp1252"]
    for enc in encodings:
        try:
            return pd.read_csv(filepath, encoding=enc)
        except (UnicodeDecodeError, UnicodeError):
            continue
    raise ValueError(f"Failed to read file: {filepath}")

5.4 BOM-Prefixed UTF-8 for Excel Compatibility

Microsoft Excel requires a BOM (Byte Order Mark) to correctly display UTF-8 CSV files.

import pandas as pd

df = pd.DataFrame({"name": ["Alice", "Bob"], "sales": [100, 200]})

# Write with BOM-prefixed UTF-8 (no garbled text in Excel)
df.to_csv("for_excel.csv", index=False, encoding="utf-8-sig")

6. Practical Example: Data Preprocessing Pipeline

Here is a practical example combining multiple processing steps. For string pattern matching, regular expressions are invaluable.

import pandas as pd
import re

def process_sales_data(input_path, output_path):
    """Sales data preprocessing pipeline."""
    # 1. Read (with encoding handling)
    df = pd.read_csv(input_path, encoding="utf-8", parse_dates=["date"])

    # 2. Drop unnecessary columns
    df = df.drop(columns=["notes", "updated_at"], errors="ignore")

    # 3. Handle missing values
    df["sales"] = df["sales"].fillna(0)
    df["city"] = df["city"].fillna("Unknown")

    # 4. Type conversion
    df["sales"] = df["sales"].astype(int)

    # 5. Cleanse phone numbers with regex
    df["phone"] = df["phone"].apply(
        lambda x: re.sub(r"[^\d]", "", str(x)) if pd.notna(x) else ""
    )

    # 6. Filter (keep only rows with positive sales)
    df = df[df["sales"] > 0]

    # 7. Add aggregation columns
    df["month"] = df["date"].dt.to_period("M")

    # 8. Write output
    df.to_csv(output_path, index=False, encoding="utf-8")
    print(f"Processing complete: {len(df)} rows written")

process_sales_data("raw_sales.csv", "cleaned_sales.csv")

For repeated tasks, decorators can add logging or retry functionality to your processing functions.

7. Comparison: csv vs pandas vs polars

Featurecsv (built-in)pandaspolars
InstallationNone (built-in)pip install pandaspip install polars
SpeedSlowMediumFast
Memory EfficiencyGood (row-based)AverageGood (columnar)
Feature RichnessMinimalVery richRich
Type SafetyNoneWeakStrong
ParallelismNoneNoneAuto-parallelized
Lazy EvaluationNoneNoneYes (scan_csv)
Learning CurveLowMediumMedium
Best ForSmall/simpleGeneral analysisLarge-scale data

Both libraries have changed significantly in recent years. pandas introduced an Apache Arrow-based backend (dtype_backend="pyarrow") in the 2.0 series (2023), and the 3.0 series (released January 2026) made a dedicated str dtype (PyArrow-backed, falling back to NumPy object dtype if PyArrow isn’t installed) the default for string columns, substantially improving string-column memory usage and string-operation speed over the old object dtype. polars hit its “1.0” milestone in 2024, marking the in-memory engine and API as production-ready, and through 2025 it went through a full streaming-engine rewrite (morsel-driven parallelism, discussed below). Because APIs keep shifting between versions (dtypes -> schema_overrides, collect(streaming=True) -> collect(engine="streaming"), etc.), every code sample in this article was verified against the versions actually installed (pandas 3.0.3 / polars 1.42.1).

Performance Benchmark

Saying “polars is typically faster” without evidence isn’t a real comparison. Below is a benchmark that was actually executed, with the raw measured numbers reported as-is.

Test environment: Apple M1 (8 cores) / macOS 26.5.2 / Python 3.14.6 / pandas 3.0.3 / polars 1.42.1 / pyarrow 25.0.0. The CSV has 10 columns (id, name, age, city, sales, date, status, score, quantity, region), including non-ASCII string columns, generated with a fixed seed (numpy.random.default_rng(42)) at three sizes: 100,000 / 500,000 / 1,000,000 rows. Each read/write was run 3 times; wall-clock time is the median of time.perf_counter() measurements taken inside each script, and peak memory is the “maximum resident set size” reported by /usr/bin/time -l for the whole process (methodology detailed in the code below).

import time
import csv

filepath = "benchmark_data.csv"  # measured at 100,000 / 500,000 / 1,000,000 rows

# --- csv.reader ---
start = time.perf_counter()
with open(filepath, "r", encoding="utf-8", newline="") as f:
    reader = csv.reader(f)
    header = next(reader)
    rows = list(reader)
csv_time = time.perf_counter() - start
print(f"csv.reader: {csv_time:.4f}s ({len(rows)} rows)")
import time
import pandas as pd

start = time.perf_counter()
df_pd = pd.read_csv(filepath)
pandas_time = time.perf_counter() - start
print(f"pandas: {pandas_time:.4f}s ({len(df_pd)} rows)")
import time
import polars as pl

start = time.perf_counter()
df_pl = pl.read_csv(filepath)
polars_time = time.perf_counter() - start
print(f"polars: {polars_time:.4f}s ({len(df_pl)} rows)")

Running these against the 100,000 / 500,000 / 1,000,000-row files produced the following (median of 3 runs, seconds):

RowsFile Sizecsv readpandas readpolars readcsv writepandas writepolars write
100,0006.7MB0.142s0.363s0.087s0.100s0.210s0.010s
500,00034MB0.608s0.636s0.106s0.434s1.042s0.054s
1,000,00068MB1.329s1.028s0.115s0.868s1.964s0.077s

Read/write time comparison for csv, pandas, and polars at 100K/500K/1M rows (log scale, lower is faster)

On reads, polars was about 11.6x faster than csv and about 8.9x faster than pandas at 1,000,000 rows. What’s more interesting is writing: pandas’s to_csv is actually slower than the stdlib csv.writer (1.96s vs 0.87s at 1,000,000 rows). This has a methodological explanation. In this benchmark, the csv-module path reads rows with csv.reader and writes those already-string rows straight back out with csv.writer – no type conversion needed. pandas and polars, on the other hand, first parse numeric/date columns into typed data via read_csv, then have to re-serialize that typed data back into text during to_csv/write_csv. That formatting cost is what makes pandas’s write slower. polars stays faster than the csv module despite paying the same formatting cost because it parallelizes the column-wise write in Rust. The accurate takeaway isn’t “pandas is slow at writing” – it’s that once you hold typed data, the text-formatting cost is unavoidable, and polars parallelizes/vectorizes that cost far more effectively than pandas does.

Peak memory footprint of csv, pandas, and polars while reading CSV at 100K/500K/1M rows (whole-process peak memory footprint)

For memory, reading 1,000,000 rows peaked at 796MB for the csv module, 387MB for pandas, and 279MB for polars. It may be surprising that the csv module is heaviest, but that’s because list(reader) holds every row in memory as a Python list[list[str]]. Python string and list objects carry meaningful per-object header overhead, so the same data held this way uses several times more memory than the equivalent NumPy or Apache Arrow arrays. pandas is smaller because it stores columns as NumPy arrays (and, since pandas 3.0, Arrow-backed string arrays for some columns); polars is smallest because Apache Arrow’s columnar layout is even denser.

Why pandas and polars Outperform the csv Module

The read-speed gap ultimately comes down to where type conversion and looping happen.

  • The csv module: csv.reader produces a Python object (a list of strings) one row at a time, and any subsequent type conversion (calling int() or float(), etc.) also runs in a Python-interpreter loop. CPython’s for loop carries bytecode-interpretation overhead on every iteration, and that overhead accumulates linearly with row count.
  • pandas: read_csv uses a C-implemented parser by default, reading the file in blocks and performing numeric conversion column-by-column inside NumPy’s C-level loops. Because it never drops into a Python for loop, it’s substantially faster than the csv module.
  • polars: has a multi-threaded parser written in Rust that splits the file into chunks and processes columns and chunks in parallel. It also uses Apache Arrow’s memory layout (columnar, contiguous, close to zero-copy buffer sharing), which improves cache efficiency and makes SIMD vectorization more effective. Where pandas’s C parser is single-threaded, polars automatically tries to use every available CPU core – so the gap widens on machines with more cores (the M1 used here has 8).

The Lazy API (scan_csv + .collect()) can go even faster because it optimizes the whole query before executing it. For example, it can push column selection (like usecols) or filter conditions down into the parsing stage itself (predicate/projection pushdown), skipping unneeded columns or parsing work entirely. The eager pl.read_csv() API doesn’t get this optimization, so on large data the Lazy API can have a real edge.

8. Pitfalls You’ll Actually Hit in Practice

Everything above assumed “correctly formatted CSV, processed fast.” Real-world CSVs are often structurally broken or don’t have the types you expect. Let’s walk through two representative pitfalls, running real code against each.

8.1 Delimiters and Newlines Inside Quoted Fields

The CSV spec (RFC 4180) lets a field contain commas or newlines if it’s wrapped in double quotes. First, the properly quoted case:

import csv
import io

csv_text = (
    'name,address,notes\n'
    'Alice,"123 Main St\n(Suite 4B)",VIP\n'
    'Bob,"456 Oak Ave, Springfield",Regular\n'
)

reader = csv.reader(io.StringIO(csv_text))
for row in reader:
    print(row)
['name', 'address', 'notes']
['Alice', '123 Main St\n(Suite 4B)', 'VIP']
['Bob', '456 Oak Ave, Springfield', 'Regular']

The csv module, pandas, and polars all handle quoted commas and newlines correctly per RFC 4180. You’ll sometimes hear the claim that “the csv module mishandles embedded newlines” – but at least with the standard dialect, all three libraries handle this correctly, as verified above.

The real problem shows up when a field contains a comma but was never quoted – common with data pasted manually from Excel, or custom export scripts that skip quoting.

import csv
import io
import pandas as pd
import polars as pl

# Row 2's address contains a comma but isn't quoted
csv_text = (
    "name,address,notes\n"
    "Alice,123 Main St,VIP\n"
    "Bob,456 Oak Ave, Springfield,Regular\n"
)

print("--- csv.reader ---")
reader = csv.reader(io.StringIO(csv_text))
header = next(reader)
for row in reader:
    print(row, "len=", len(row))
--- csv.reader ---
['Alice', '123 Main St', 'VIP'] len= 3
['Bob', '456 Oak Ave', ' Springfield', 'Regular'] len= 4

csv.reader returns the ragged row without raising any error. Row 2 now has 4 fields, and if downstream code indexes into it positionally, it will silently use misaligned data without any warning. Reading the same data with pandas and polars fails explicitly instead.

print("--- pandas.read_csv ---")
try:
    df = pd.read_csv(io.StringIO(csv_text))
except Exception as e:
    print(f"{type(e).__name__}: {e}")

print("--- polars.read_csv ---")
try:
    df = pl.read_csv(csv_text.encode())
except Exception as e:
    print(f"{type(e).__name__}: {e}")
--- pandas.read_csv ---
ParserError: Error tokenizing data. C error: Expected 3 fields in line 3, saw 4


--- polars.read_csv ---
ComputeError: found more fields than defined in 'Schema'

Consider setting 'truncate_ragged_lines=True'.

pandas raises ParserError, polars raises ComputeError – both fail explicitly. “The csv module is safer because it’s lenient” has it backwards: silently returning corrupted data is the more dangerous failure mode, and pandas/polars’s “fail loudly” behavior is what actually prevents downstream incidents.

Reaching for truncate_ragged_lines=True, as polars’s error message suggests, trades that loud failure for a silent data corruption of its own.

df = pl.read_csv(csv_text.encode(), truncate_ragged_lines=True)
print(df)
shape: (2, 3)
┌───────┬─────────────┬──────────────┐
│ name  ┆ address     ┆ notes        │
│ ---   ┆ ---         ┆ ---          │
│ str   ┆ str         ┆ str          │
╞═══════╪═════════════╪══════════════╡
│ Alice ┆ 123 Main St ┆ VIP          │
│ Bob   ┆ 456 Oak Ave ┆  Springfield │
└───────┴─────────────┴──────────────┘

Row 2’s notes value (“Regular”) has vanished, replaced by the tail of the address ( Springfield). truncate_ragged_lines just silently drops the overflow field rather than realigning columns correctly. In practice, it’s safer to first surface which rows are broken with something like pd.read_csv(..., on_bad_lines="warn"), then fix the upstream export logic.

df = pd.read_csv(io.StringIO(csv_text), on_bad_lines="warn")
ParserWarning: Skipping line 3: expected 3 fields, saw 4

    name      address notes
0  Alice  123 Main St   VIP

8.2 dtype Inference Pitfalls: One Bad Value in a Numeric-Looking Column

When a single non-numeric value slips into an otherwise numeric-looking column, pandas gives up on type inference for the entire column. This isn’t just a typing inconvenience – it means aggregations fail silently with a wrong answer instead of raising an error.

import pandas as pd
import io

csv_text = "name,sales\nAlice,15000\nBob,23000\nCarol,TBD\nDave,18500\n"
df = pd.read_csv(io.StringIO(csv_text))
print(df.dtypes)
print(df)
print("Sum of sales:", df["sales"].sum())

Running this on pandas 3.0.3 gives:

name     str
sales    str
dtype: object

    name  sales
0  Alice  15000
1    Bob  23000
2  Carol    TBD
3   Dave  18500

Sum of sales: 1500023000TBD18500

That single “TBD” string forces the entire sales column – including 15000 and 23000 – to become string-typed, and .sum() performs string concatenation instead of raising an error. Instead of the correct total (56500), you silently get the corrupted string above, with no exception raised. This is a silent failure and arguably more dangerous than a loud TypeError would be.

Note that pandas 3.0’s default string-typed columns show up as the new str dtype (PyArrow-backed); pandas 2.x shows the same situation as object dtype. The underlying behavior is identical across both – a column that fails full numeric inference becomes entirely string-typed, and .sum() silently concatenates strings, in both the 2.x and 3.0 series. Testing the same data against pandas 2.3.3 locally, the column’s memory footprint was 346 bytes under object dtype versus 182 bytes under pandas 3.0’s str dtype – roughly half. String-column memory efficiency has genuinely improved in pandas 3.0, but the “silently corrupts on bad input” behavior itself hasn’t changed.

The fix is to specify types explicitly, or use pd.to_numeric with coercion to surface the bad values:

df["sales_fixed"] = pd.to_numeric(df["sales"], errors="coerce")
print(df)
print("Missing count:", df["sales_fixed"].isna().sum())
print("Correct sum:", df["sales_fixed"].sum())
    name  sales  sales_fixed
0  Alice  15000      15000.0
1    Bob  23000      23000.0
2  Carol    TBD          NaN
3   Dave  18500      18500.0

Missing count: 1
Correct sum: 56500.0

errors="coerce" turns unconvertible values into NaN, so isna().sum() immediately tells you how many bad values were present. On large files, running this kind of check on key numeric columns right after loading prevents aggregations from silently breaking downstream. The same discipline applies in polars: if pl.read_csv falls back to str for a numeric column, cast explicitly with pl.col(...).cast(pl.Float64, strict=False) and check null_count() to see how many values failed to convert.

9. Performance Tips

Key techniques for improving performance when working with large CSV files.

9.1 Read Only the Columns You Need

import pandas as pd

# Reading all columns (slow)
df = pd.read_csv("large.csv")

# Reading only needed columns (fast, less memory)
df = pd.read_csv("large.csv", usecols=["name", "sales"])

9.2 Specify Types Explicitly

pandas spends time on type inference, so specifying types explicitly speeds up reading large files.

import pandas as pd

dtypes = {
    "id": "int32",          # int64 -> int32 halves memory
    "name": "string",       # More efficient than object
    "sales": "float32",     # float64 -> float32 halves memory
    "category": "category", # Category type drastically reduces memory
}

df = pd.read_csv("large.csv", dtype=dtypes)

9.3 Convert to Parquet Format

If you repeatedly read the same CSV, converting it to Parquet format provides significantly faster reads.

import pandas as pd

# Convert CSV to Parquet (one-time)
df = pd.read_csv("large.csv")
df.to_parquet("large.parquet", engine="pyarrow")

# Subsequent reads from Parquet (several times to orders of magnitude faster)
df = pd.read_parquet("large.parquet")

9.4 Reading Multiple CSVs in Parallel

When processing many CSV files simultaneously, asynchronous processing or multiprocessing can help.

from concurrent.futures import ProcessPoolExecutor
import pandas as pd
import glob

def process_file(filepath):
    """Process an individual CSV file."""
    df = pd.read_csv(filepath)
    return df[df["sales"] > 10000]

# Parallel processing with multiprocessing
files = glob.glob("data/sales_*.csv")
with ProcessPoolExecutor(max_workers=4) as executor:
    results = list(executor.map(process_file, files))

combined = pd.concat(results, ignore_index=True)

Summary

Choosing the right tool for CSV processing depends on your use case:

  • Small-scale, simple taskscsv module (no dependencies, but it silently accepts malformed rows such as ones with the wrong field count, making it fragile against unexpectedly broken input)
  • Data analysis and transformationpandas (most widely used; measured at about 1.03s to read 1,000,000 rows. Failed type inference can silently break aggregations like .sum(), so make checking with pd.to_numeric(errors="coerce") a habit)
  • Large-scale data, high performancepolars (auto-parallelization, lazy evaluation; measured at about 0.12s to read the same 1,000,000 rows – roughly 8-9x faster than pandas and over 11x faster than the csv module)

Encoding issues are unavoidable in multilingual environments, so adopting practices like automatic detection with charset-normalizer (or chardet) and using utf-8-sig for Excel compatibility will save you time. That said, detection confidence can come back low on small files, so don’t rely on the confidence score alone – factor in what you know about the data’s origin (Windows vs. Unix-style pipelines). Additionally, converting frequently-read files to Parquet format can dramatically improve read speeds.

CSV looks simple on paper, but pitfalls like missing quotes, mismatched field counts, and failed type inference show up constantly in practice. A library that raises an error is still the safe case – the real danger is a library that fails silently and returns corrupted data. The more critical your pipeline, the more it’s worth adding a validation step right after loading to check row counts, types, and missing-value counts.

References