CSV: «простой» формат, в котором всё непросто
CSV выглядит как примитивный формат: строки разделены \n, поля — запятыми. На самом деле это самый «грязный» формат в data engineering — нет одного стандарта (RFC 4180 — best-effort, не обязательный), нет встроенного encoding, дата в одном файле может быть 2026-05-15, в другом 15.05.2026, в третьем 15/05/26. Никаких типов: всё — строки. Junior работающий с CSV впервые столкнётся со всеми возможными граблями.
В этом уроке разберём RFC 4180, диалекты, экранирование, парсинг через csv module, использование pandas, и подводные камни encoding/BOM/дат.
RFC 4180: формальный (но не обязательный) стандарт
RFC 4180 — это «common format» для CSV, опубликован в 2005 году. Он не нормативный (informational), а описательный — фиксирует то, как Excel и большинство инструментов работают.
Основные правила:
- Поля разделены запятыми.
- Записи разделены CRLF (
\r\n). - Поле может быть в двойных кавычках. Внутри кавычек — любые символы, включая запятые и newlines.
- Двойная кавычка внутри quoted-поля экранируется удвоением:
"He said ""hello""". - Первая строка может быть header (опционально).
name,age,city
Alice,30,Moscow
"Smith, John",25,"New York"
"He said ""hi""",40,SPb
В реальности у вас будет 1000 файлов, и почти ни один не следует RFC 4180 полностью:
- Разделители: запятая, точка с запятой (Excel в Европе, где запятая — десятичный разделитель), tab (TSV), pipe
|. - Newlines:
\r\n(RFC),\n(Unix),\r(старый Mac). - Кавычки: иногда single, иногда отсутствуют, иногда обязательны для всех полей.
- Header: иногда есть, иногда нет.
- Encoding: чаще всего UTF-8, но cp1251 (Windows-1251) для русских файлов из старых систем.
Грабли, которые возникают при наивном парсинге через split(',')
Поэтому работа с CSV — это не «прочитать строки». Это «распознать формат, выбрать парсер с правильными настройками, обработать ошибки, attendant to encoding».
Python csv module: базовый инструмент
Stdlib csv — это правильный выбор для простой работы с CSV. Он понимает quoting, escape, разные диалекты.
import csv
# Запись
with open("users.csv", "w", encoding="utf-8", newline="") as f:
writer = csv.writer(f)
writer.writerow(["name", "age", "city"])
writer.writerow(["Alice", 30, "Moscow"])
writer.writerow(["Smith, John", 25, "New York"]) # запятая в имени
# Файл:
# name,age,city
# Alice,30,Moscow
# "Smith, John",25,New York
# Чтение
with open("users.csv", encoding="utf-8") as f:
reader = csv.reader(f)
header = next(reader)
for row in reader:
print(dict(zip(header, row)))
# {'name': 'Alice', 'age': '30', 'city': 'Moscow'}
# Заметьте: age -- string, не int!
# DictReader/DictWriter -- удобнее, header автоматически
with open("users.csv", encoding="utf-8") as f:
reader = csv.DictReader(f)
for row in reader:
print(row['name'], row['city'])
newline="" критичен. По спецификации csv-модуль сам управляет newlines. Если открыть с дефолтным newline= (преобразование linebreaks по platform), при чтении CSV с многострочными полями получите битые данные.
Диалекты: предустановки для разных форматов
Csv-module поддерживает «диалекты» — наборы параметров для разных форматов.
import csv
# Список встроенных диалектов
print(csv.list_dialects()) # ['excel', 'excel-tab', 'unix']
# excel -- default. Comma-separated, double-quote quoting
# excel-tab -- TSV. Tab-separated, double-quote quoting
# unix -- comma-separated, all fields quoted, LF newlines
# Использовать
with open("data.tsv", encoding="utf-8") as f:
reader = csv.reader(f, dialect='excel-tab')
# Эквивалентно: csv.reader(f, delimiter='\t')
# Кастомный диалект -- для европейского CSV (semicolon-separated)
csv.register_dialect('eu', delimiter=';', quotechar='"', quoting=csv.QUOTE_MINIMAL)
with open("eu_data.csv") as f:
reader = csv.reader(f, dialect='eu')
# Или через параметры напрямую
reader = csv.reader(f, delimiter=';', quotechar='"')
Важные параметры:
- delimiter: разделитель полей (
,;\t|). - quotechar: символ quoting (
"или'). - quoting: когда quoting’ать.
QUOTE_MINIMAL(только если нужно),QUOTE_ALL(все поля),QUOTE_NONE(никогда — ломается на запятых в значениях). - escapechar: альтернатива удвоению кавычек. Если задан, кавычка внутри значения экранируется через этот символ (например
\).
Sniffer: угадывание диалекта
Если вы не знаете формат заранее, csv.Sniffer анализирует первые байты и угадывает.
import csv
with open("unknown.csv", encoding="utf-8") as f:
sample = f.read(8192) # первые 8 КБ для анализа
f.seek(0)
sniffer = csv.Sniffer()
dialect = sniffer.sniff(sample)
print(f"Delimiter: '{dialect.delimiter}'")
print(f"Quote char: '{dialect.quotechar}'")
print(f"Has header: {sniffer.has_header(sample)}")
reader = csv.reader(f, dialect)
for row in reader:
print(row)
Sniffer не безошибочен (может перепутать tab и пробел, не справиться с экзотическими форматами), но для типичных CSV работает.
Pandas read_csv: для tabular данных
Для аналитики и больших файлов лучше pandas. read_csv — мощный, но и сложный (десятки параметров).
import pandas as pd
# Базовое чтение
df = pd.read_csv("data.csv")
# С контролем типов
df = pd.read_csv(
"data.csv",
encoding="utf-8", # Encoding файла. По умолчанию utf-8
sep=",", # Разделитель. ',' default. Для TSV: '\t'
header=0, # Какая строка -- header. 0 (default) = первая. None = нет header
names=["a", "b", "c"], # Кастомные имена колонок (если header=None)
dtype={"id": "int64", "name": "string"}, # Типы колонок
parse_dates=["created_at"], # Парсить колонку как datetime
na_values=["", "N/A", "null", "-"], # Что считать NaN
encoding_errors="replace", # Что делать с битыми символами: replace, ignore, strict
chunksize=10_000, # Читать по 10k rows за раз -- для больших файлов
)
# Запись
df.to_csv(
"out.csv",
index=False, # Не писать row index как колонку
encoding="utf-8",
date_format="%Y-%m-%d", # Формат дат при выводе
quoting=csv.QUOTE_MINIMAL,
)
Главные параметры, которые junior часто упускает:
dtype: без него pandas угадывает типы по первым строкам, что может быть медленно и неправильно (например, ID12345будет int, а ID12345-Xприведёт к dtype object для всей колонки). Указаниеdtype={"id": "string"}экономит время.parse_dates: дата как строка занимает больше места и медленнее в операциях. Парсинг сразу в datetime — быстрее и удобнее.na_values: что считать missing. Дефолт —'','NA','NaN','null'и ещё ~20 значений. Если в данных есть'-'или'unknown'— добавить в na_values.chunksize: для файлов больше памяти. Возвращает iterator чанков, не df.
BOM: невидимая засада
UTF-8 BOM (Byte Order Mark) — три байта 0xEF 0xBB 0xBF в начале файла. UTF-8 не нуждается в BOM (порядок байт в UTF-8 фиксирован), но Excel при сохранении CSV в UTF-8 ставит BOM, чтобы при открытии распознать кодировку.
Python по умолчанию не обрабатывает BOM. При чтении первой строки она будет начинаться с — невидимый символ, который ломает имя первой колонки.
# Открыть файл с BOM как обычный utf-8
with open("excel_export.csv", encoding="utf-8") as f:
header = next(csv.reader(f))
print(repr(header))
# ['name', 'age', 'city'] -- первая колонка испорчена!
# df['name'] -- KeyError, потому что колонка называется 'name'
# Решение: использовать utf-8-sig -- automatic BOM stripping
with open("excel_export.csv", encoding="utf-8-sig") as f:
header = next(csv.reader(f))
print(repr(header))
# ['name', 'age', 'city'] -- OK
# В pandas
df = pd.read_csv("excel_export.csv", encoding="utf-8-sig")
utf-8-sig — это «utf-8 with stripping BOM if present, no BOM written by default». Безопасно использовать всегда — лишний BOM не появится, существующий уберётся.
Encoding: главная боль с русскими файлами
Когда вы получаете CSV из неизвестного источника — encoding неизвестен. Если ошибётесь — получите mojibake (нечитаемый текст).
Обнаружение encoding — два пути:
1. Детектор chardet/charset_normalizer.
# pip install charset-normalizer (или chardet -- оба работают)
from charset_normalizer import detect
with open("unknown.csv", "rb") as f:
raw = f.read(10_000) # первые 10 КБ
result = detect(raw)
print(result)
# {'encoding': 'cp1251', 'confidence': 0.95, 'language': 'Russian'}
# Использовать
df = pd.read_csv("unknown.csv", encoding=result['encoding'])
2. Попробовать по очереди.
encodings = ['utf-8-sig', 'utf-8', 'cp1251', 'koi8-r', 'latin-1']
for enc in encodings:
try:
df = pd.read_csv("unknown.csv", encoding=enc)
# Проверка: если decoded успешно и данные выглядят osmysleno
print(f"OK with {enc}: {df.head()}")
break
except UnicodeDecodeError:
continue
latin-1 (= ISO-8859-1) никогда не даёт UnicodeDecodeError — каждый байт мапится в один символ. Это удобно как fallback, но текст будет читаться как мусор. Используйте только если ничего другое не работает.
Если конвертируете cp1251 -> utf-8 для дальнейшей обработки — делайте это на raw bytes перед csv-парсингом, а не побайтово при чтении csv. Open с правильным encoding и потом всё хранить в utf-8 (внутренний формат Python).
Даты: всё — строки, пока вы не сказали обратное
В CSV типов нет. Дата 2026-05-15 — это строка '2026-05-15', и её надо явно парсить.
import pandas as pd
# Без parse_dates -- всё string
df = pd.read_csv("data.csv")
print(df['created_at'].dtype) # object (string)
# С parse_dates -- datetime64
df = pd.read_csv("data.csv", parse_dates=['created_at'])
print(df['created_at'].dtype) # datetime64[ns]
print(df['created_at'].dt.year.head()) # можно использовать .dt accessor
# Кастомный формат -- date_format
df = pd.read_csv(
"data.csv",
parse_dates=['date'],
date_format='%d.%m.%Y', # для русского формата 15.05.2026
)
# Для разных колонок с разным форматом -- парсить вручную после read_csv
df['date1'] = pd.to_datetime(df['date1'], format='%Y-%m-%d')
df['date2'] = pd.to_datetime(df['date2'], format='%d/%m/%Y')
Excel хранит даты как float (число дней с 1900-01-01). При экспорте в CSV может вывести 45657 вместо 2026-01-15. Pandas с parse_dates это не поймёт — нужно конвертировать вручную:
# Excel serial date -> datetime
df['date'] = pd.to_datetime(df['date_excel_serial'], unit='D', origin='1899-12-30')
# (origin 1899-12-30 -- потому что Excel неправильно считает 1900-02-29 как валидную дату)
Подводные камни
CSV: дьявол в деталях CSV: csv.reader, csv.DictReader, dialects, quotingЧисла с десятичным разделителем-запятой. В Европе 19,99 — это число «девятнадцать целых девяноста девять». Если CSV использует ; как разделитель и , как десятичный, pandas нужно сказать:
df = pd.read_csv("eu.csv", sep=';', decimal=',')
Leading zeros у ID. ID типа 00123 парсер читает как int 123 — потерян ведущий ноль. Решение: dtype={'id': 'string'}.
Колонка с inconsistent типами. Первые 1000 rows — int, дальше встречаются 'unknown'. Pandas угадал int, потом упал. Решение: dtype={'col': 'string'} или low_memory=False (читает весь файл, чтобы определить тип).
Большие файлы. CSV 5 ГБ не лезет в память. Используйте chunksize:
for chunk in pd.read_csv("big.csv", chunksize=100_000):
process(chunk) # обрабатываем по 100k rows
Попробуй сам
import csv
import pandas as pd
# 1. Создать CSV с проблемными значениями
data = [
["name", "age", "comment"],
["Alice", "30", "Hello"],
["Smith, John", "25", 'He said "hi"'],
["Multi\nLine", "40", "Has newline"],
]
with open("test.csv", "w", encoding="utf-8", newline="") as f:
writer = csv.writer(f, quoting=csv.QUOTE_MINIMAL)
writer.writerows(data)
# 2. Посмотреть raw содержимое
with open("test.csv", "rb") as f:
print(f.read())
# Должны увидеть quoting вокруг проблемных полей и удвоенные кавычки
# 3. Sniffer -- угадаем диалект
with open("test.csv", encoding="utf-8") as f:
sample = f.read(1024)
f.seek(0)
dialect = csv.Sniffer().sniff(sample)
print(f"Delimiter: '{dialect.delimiter}', Quote: '{dialect.quotechar}'")
# 4. Encoding mystery
# Создаём файл в cp1251
with open("ru.csv", "w", encoding="cp1251") as f:
f.write("name,city\nАлиса,Москва\nБоб,Питер\n")
# Читаем как utf-8 -- mojibake
try:
df = pd.read_csv("ru.csv", encoding="utf-8")
print("UTF-8:", df)
except UnicodeDecodeError as e:
print("UnicodeDecodeError on utf-8:", e)
# Читаем правильно
df = pd.read_csv("ru.csv", encoding="cp1251")
print("CP1251:", df)
# 5. Excel BOM
with open("excel_like.csv", "w", encoding="utf-8-sig") as f:
f.write("name,age\nAlice,30\n")
# Открываем как чистый utf-8 -- first column corrupted
with open("excel_like.csv", encoding="utf-8") as f:
print(repr(next(csv.reader(f))))
# ['name', 'age']
# С utf-8-sig -- нормально
with open("excel_like.csv", encoding="utf-8-sig") as f:
print(repr(next(csv.reader(f))))
# ['name', 'age']