Learning Platform
Урок 07.01 · 22 мин
Начальный
CSVcsv moduleDialectsEncodingBOMStreaming
CSV/TSV: диалекты, encoding и парсинг через API Batch processing: какие структуры для миллионов строк CSV

CSV — это не стандарт

Если бы вас попросили в трёх словах описать, что такое CSV, нормальный ответ был бы «значения через запятую». Это верно ровно до того момента, как вы открываете первый файл от реального заказчика. Там оказывается точка с запятой вместо запятой, кодировка windows-1251 вместо UTF-8, в одной из ячеек — перенос строки в кавычках, а в другой — кавычка внутри кавычки, экранированная двойной кавычкой по правилам Excel. И всё это в одном файле.

CSV — это не стандарт, а конвенция. Есть документ

RFC 4180
, который пытается формализовать «правильный» CSV (запятая-разделитель, двойные кавычки, CRLF в конце строк, кавычка внутри значения удваивается). Но фактический мир делится на тех, кто читал RFC, и тех, кто пишет CSV «как Excel сохранит». Junior DE сталкивается со вторыми гораздо чаще.

Поэтому ваша задача — не «прочитать 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 — это

BOM, Byte Order Mark
. Если открыть такой файл как обычный 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.

WARNING

Никогда не оборачивайте чтение 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 rownext(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 vs JSONL vs Parquet: когда что

Три формата для одних и тех же данных. CSV — обмен, JSONL — потоки, Parquet — аналитика.

CSVтекст, разделители
типывсё — строкаЦифры, даты, NULL — всё текст. Парсер дотипизирует на чтении.
схеманетТолько заголовки. Что в колонке — узнаёшь читая.
размерraw, без сжатия
плюсыпонимают все
DE-рольобмен с не-DE системами
JSONLодин JSON на строку
типыJSON: string/number/null/boolЛучше чем CSV: число останется числом, NULL — null.
схемаопциональнаяКаждая строка независима — можно делать union schema.
размерчуть больше CSV
плюсывложенность, streaming
DE-рольAPI responses, логи
Parquetбинарный, columnar
типыстрогая schema внутри файлаint64, float32, timestamp, decimal — типы определены и валидируются.
схемаобязательная, в файлеФайл носит схему с собой, не нужны догадки.
размер5-10x меньше CSVColumnar storage + snappy/zstd compression.
плюсыаналитика, column-pruning
DE-рольdata lake, warehouse

К 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: ещё один популярный формат, в котором, наконец-то, есть типы данных и вложенные структуры.

Закончили урок?

Отметьте его как пройденный, чтобы отслеживать свой прогресс

Войдите чтобы оценить урок

Прогресс модуля
0 из 5