CSV — это не стандарт
Если бы вас попросили в трёх словах описать, что такое CSV, нормальный ответ был бы «значения через запятую». Это верно ровно до того момента, как вы открываете первый файл от реального заказчика. Там оказывается точка с запятой вместо запятой, кодировка windows-1251 вместо UTF-8, в одной из ячеек — перенос строки в кавычках, а в другой — кавычка внутри кавычки, экранированная двойной кавычкой по правилам Excel. И всё это в одном файле.
CSV — это не стандарт, а конвенция. Есть документ
Поэтому ваша задача — не «прочитать CSV», а понять, как именно его сгенерировал источник, и настроить парсер соответственно. Хорошая новость: в stdlib для этого есть модуль csv, который делает 95% работы.
Базовый интерфейс
В модуле csv четыре главных класса:
import csv
with open("orders.csv", encoding="utf-8", newline="") as f:
reader = csv.reader(f) # выдаёт списки: ['1', 'RU', '100']
for row in reader:
print(row)
with open("orders.csv", encoding="utf-8", newline="") as f:
reader = csv.DictReader(f) # выдаёт словари: {'id': '1', 'country': 'RU', ...}
for row in reader:
print(row["country"])
with open("out.csv", "w", encoding="utf-8", newline="") as f:
writer = csv.writer(f) # принимает списки
writer.writerow(["id", "country", "amount"])
writer.writerow([1, "RU", 100])
with open("out.csv", "w", encoding="utf-8", newline="") as f:
writer = csv.DictWriter(f, fieldnames=["id", "country", "amount"])
writer.writeheader()
writer.writerow({"id": 1, "country": "RU", "amount": 100})
Запомните два правила, которые ловят почти всех junior’ов на первых же CSV.
Первое: всегда newline="" при открытии файла. Без него csv будет дважды интерпретировать переводы строк — один раз open, второй раз сам, и в Windows-CRLF-файлах вы получите лишние пустые строки в выводе или потерянные строки во входе. Это написано в самой первой строке документации модуля, но обнаруживается обычно только после первой ошибки. Пишите newline="" рефлекторно, каждый раз.
Второе: csv.reader отдаёт всё как строки. '100', а не 100. Если нужна типизация — это ваша работа: int(row["amount"]), Decimal(row["price"]), datetime.fromisoformat(row["created_at"]). В уроке про Pydantic (Module 04) мы увидим, как это автоматизировать через модель.
Диалекты: запятая, точка с запятой, табуляция
CSV-парсер ведёт себя по правилам, которые описывает
csv.reader все параметры можно передавать прямо в конструктор:
reader = csv.reader(
f,
delimiter=";", # разделитель полей
quotechar='"', # символ кавычек вокруг значения
quoting=csv.QUOTE_MINIMAL, # когда оборачивать в кавычки на записи
escapechar=None, # символ экранирования (если не двойные кавычки)
lineterminator="\r\n", # окончание строки на записи
)
Названия чаще всего встречающихся «диалектов» в реальности — не RFC-документы, а названия программ, которые их генерируют.
excel(по умолчанию) — запятая, двойные кавычки удваиваются, CRLF. Совпадает с RFC 4180.excel-tab— то же, но разделитель — табуляция. Часто называют TSV.- «немецкий Excel» — точка с запятой как разделитель. Происходит из локалей, где десятичный разделитель — запятая, и
1,5нельзя путать с разделителем. Многие выгрузки из 1С, банковских клиентов, госуслуг — именно такие. - Custom-разделители:
|(pipe-separated),\t(TSV), даже\x1f(Unit Separator) в банковских форматах.
Если разделитель неизвестен, есть csv.Sniffer:
with open("unknown.csv", encoding="utf-8", newline="") as f:
sample = f.read(2048)
dialect = csv.Sniffer().sniff(sample)
f.seek(0)
reader = csv.reader(f, dialect=dialect)
for row in reader:
...
Sniffer смотрит на первые несколько килобайт и пытается угадать разделитель и стиль кавычек. Не панацея — на маленьких файлах с одной колонкой он может ошибиться, — но для exploration быстрого незнакомого файла полезен.
Кодировки и BOM
Вторая большая категория проблем — кодировка. CSV хранит текст, а текст в файле — это байты, которые надо как-то расшифровать. По умолчанию open использует кодировку платформы, и это та причина, по которой ваш код работает на Linux и падает на Windows.
Правило простое: всегда указывайте encoding явно. Для большинства новых данных это utf-8. Для legacy — нужно узнать у источника:
# Современный источник — UTF-8
with open("api_export.csv", encoding="utf-8", newline="") as f:
...
# Выгрузка из старой 1С — Windows-1251
with open("1c_export.csv", encoding="cp1251", newline="") as f:
...
# Файл «открыли в Excel и сохранили» — иногда utf-8-sig
with open("excel_export.csv", encoding="utf-8-sig", newline="") as f:
...
Что такое utf-8-sig? Excel при сохранении CSV в UTF-8 кладёт в начало файла три байта EF BB BF — это
utf-8, первое поле первой строки получит в начало невидимый символ . И ваш row["id"] превращается в row["id"]. utf-8-sig — это вариант кодировки, который автоматически снимает BOM на чтении и не пишет его на записи.
Если совсем не знаете кодировку — открывайте в бинарном режиме первые 4 байта:
with open("mystery.csv", "rb") as f:
head = f.read(4)
if head.startswith(b"\xef\xbb\xbf"):
encoding = "utf-8-sig"
elif head.startswith(b"\xff\xfe") or head.startswith(b"\xfe\xff"):
encoding = "utf-16"
else:
encoding = "utf-8" # надеемся
Для серьёзного определения есть библиотека charset-normalizer (в составе requests), но в 90% случаев достаточно знать четыре варианта: utf-8, utf-8-sig, cp1251, utf-16.
Никогда не оборачивайте чтение CSV в errors="ignore" или errors="replace". Это глушит проблему, а не решает её — вы молча получите искажённые данные и узнаете об этом через неделю, когда отчёт не сойдётся с источником. Правильнее упасть и разобраться с кодировкой.
Запятые внутри значений и многострочные ячейки
«А что, если в значении сама запятая?» — самый частый и самый честный вопрос про CSV. Стандартный ответ: значение оборачивается в кавычки.
id,name,note
1,"Иванов, Иван","комментарий"
2,"Петров","без запятой"
csv.reader это понимает из коробки — он знает про quotechar и не считает запятую внутри кавычек разделителем. То же самое для переводов строк внутри значений: если ячейка с многострочным текстом обернута в кавычки, парсер прочитает её как одно значение, перешагнув через перенос строки.
id,description
1,"Первая строка
Вторая строка
Третья строка"
2,"Однострочное"
Это нормальный CSV, и стандартный парсер с ним справится — только если newline="" указан правильно. Без него Python и csv поссорятся на тему, что такое конец строки, и парсер не дойдёт до закрывающей кавычки.
Что делать, если в значении сама кавычка? По RFC её удваивают:
id,quote
1,"Он сказал ""привет"""
csv.reader снова справится. А если источник использует \-экранирование? Тогда передавайте escapechar="\\" и doublequote=False. Это редко, но в SQL-экспортах и Postgres-COPY встречается.
Стриминг: чтение CSV любого размера
В уроке про генераторы (Module 03) мы уже видели: гигабайтный CSV нельзя грузить в память целиком. csv.reader и csv.DictReader сами по себе — итераторы, поэтому правильный код выглядит так:
from collections.abc import Iterator
import csv
def read_rows(path: str) -> Iterator[dict[str, str]]:
with open(path, encoding="utf-8", newline="") as f:
reader = csv.DictReader(f)
for row in reader:
yield row
# использование — без list(...)
for row in read_rows("orders_12gb.csv"):
process(row)
В каждый момент времени в памяти лежит одна строка. Это та же модель, что в уроке 03/01 про генераторы — каждый кирпич независим, потребляет O(1) памяти, и из них можно собирать пайплайн.
DE-кейс: парсинг 1С-выгрузки
Соберём всё вместе на реальном кейсе. Бухгалтерия дала файл 1c_export.csv: cp1251, разделитель ;, кавычки RFC, в первой колонке — код контрагента с пробелами в начале.
import csv
from pathlib import Path
def parse_1c(path: Path) -> list[dict[str, str]]:
with path.open(encoding="cp1251", newline="") as f:
reader = csv.DictReader(f, delimiter=";", quotechar='"')
return [
{
"code": row["Код"].strip(),
"name": row["Наименование"].strip(),
"inn": row.get("ИНН", "").strip() or None,
}
for row in reader
]
Это маленький, но боевой кусок кода. Здесь четыре важные вещи. encoding="cp1251" — без этого русские буквы превратятся в кракозябры. delimiter=";" — потому что 1С генерирует немецкий вариант. quotechar='"' — на случай, если в названии будут запятые. .strip() — чистим невидимые пробелы, которыми любят грешить выгрузки.
И, на всякий случай, обязательная проверка first row — next(reader) или reader.fieldnames, чтобы убедиться, что заголовки именно такие, как ожидаешь, и упасть с понятной ошибкой, если источник поменял формат.
Запись CSV: контекст-менеджер и DictWriter
На запись симметричный набор:
import csv
from pathlib import Path
rows = [
{"id": 1, "country": "RU", "amount": 100},
{"id": 2, "country": "US", "amount": 50},
]
with Path("orders.csv").open("w", encoding="utf-8", newline="") as f:
writer = csv.DictWriter(f, fieldnames=["id", "country", "amount"])
writer.writeheader()
writer.writerows(rows)
writerows принимает любой итерируемый объект — включая генератор. Так что вы можете писать на диск результат пайплайна, не материализуя его в память:
def transform(source: Iterator[dict]) -> Iterator[dict]:
for row in source:
yield {"id": row["id"], "country": row["country"].upper(), "amount": int(row["amount"])}
with Path("clean.csv").open("w", encoding="utf-8", newline="") as f:
writer = csv.DictWriter(f, fieldnames=["id", "country", "amount"])
writer.writeheader()
writer.writerows(transform(read_rows("dirty.csv")))
Параметр quoting=csv.QUOTE_ALL оборачивает все значения в кавычки — это безопаснее для downstream-парсеров, особенно если значения могут содержать разделитель. QUOTE_MINIMAL (по умолчанию) кавычит только когда необходимо.
Когда CSV не подходит
CSV — лучший формат для обмена табличными данными между разными системами, потому что его умеют все. Но у него есть три ограничения, после которых пора думать про JSON или Parquet.
Вложенные структуры. Если в строке нужен список или объект — CSV это плохо переживает. Можно засунуть JSON в одно поле, но это компромисс: парсер тогда нужен в два этапа.
Типизация. В CSV всё — строки. Дата, число, булево, NULL — всё одинаково: текст. На каждом чтении нужно re-cast’ить, и можно ошибиться. Parquet хранит типы в схеме файла — типы известны без догадок.
Размер и компрессия. 100 ГБ CSV — это терабайт после расжатия Pandas. Для тех же данных Parquet занимает 5-20 ГБ и читает только нужные колонки.
Три формата для одних и тех же данных. CSV — обмен, JSONL — потоки, Parquet — аналитика.
К Parquet мы вернёмся в уроке 04, к JSONL — в следующем. Сейчас итог по CSV.
Что мы получили
- CSV — это не стандарт, а конвенция. Реальные файлы отличаются разделителем, кодировкой, экранированием.
- Главное правило кода:
with open(path, encoding="...", newline="") as fи парсер наcsv.reader/csv.DictReader. encoding="utf-8-sig"снимает BOM,cp1251— для legacy-1С.csv.Snifferпомогает угадать диалект незнакомого файла.csv.readerсам стримит — гигабайтный файл проходит через лаптоп без OOM, если не делатьlist(...).- Внутри значений могут быть запятые и переносы строк, это нормально, если они обёрнуты в кавычки.
- CSV не для вложенных структур и не для аналитики — это формат обмена; для типов и размера есть JSONL и Parquet.
В следующем уроке — JSON и JSONL: ещё один популярный формат, в котором, наконец-то, есть типы данных и вложенные структуры.