CSV-формат: что это и как работать с ним в Python

CSV-формат: что это и как работать с ним в Python Полезное

CSV (Comma-Separated Values, «значения через запятую») — это текстовый формат для таблиц: одна строка файла — одна запись, поля внутри записи разделены запятой. Если в поле есть запятая, двойная кавычка или перевод строки, поле берут в двойные кавычки, а кавычку внутри удваивают. Описание формата закреплено в RFC 4180, но на практике у CSV много «диалектов».

Разберем, как устроен файл, как читать и записывать его модулем csv из стандартной библиотеки Python, какие кодировки выбирать и как не выгрузить пользователям опасную формулу. Примеры проверены на Python 3.14.

Три понятия, которые путают

  • Разделитель (delimiter) — символ между полями: запятая, точка с запятой, табуляция (TSV).
  • Кавычки (quotechar) — символ, внутри которого разделитель и перевод строки считаются частью значения.
  • Кодировка — как текст превращается в байты (UTF-8, UTF-8 с BOM, cp1251). Сам CSV ее не хранит, ее нужно знать или угадывать.

Набор из разделителя, правил кавычек и конца строки в модуле csv называется диалектом.

Как устроен CSV по RFC 4180

Файл из трех записей с заголовком:

id,name,comment
1,Анна,"любит Python, SQL"
2,Борис,"сказал ""привет"""
3,Вера,"две
строки"

Основные правила RFC 4180:

  1. Записи разделены переводом строки CRLF (\r\n).
  2. Строка заголовка необязательна.
  3. Во всех записях одинаковое число полей.
  4. Поле с запятой, кавычкой или переводом строки берется в двойные кавычки; кавычка внутри удваивается ("").

RFC 4180 (2005 год) имеет статус информационного документа, а не обязательного стандарта. Поэтому реальные файлы отступают от него:

Что По RFC 4180 Что встречается на практике
Разделитель запятая ; (Excel в русской локали, где запятая — десятичный знак), табуляция
Конец строки \r\n \n в выгрузках из Linux
Кодировка не задана жестко: параметр charset, распространенный вариант — US-ASCII UTF-8, UTF-8 с BOM, cp1251
Заголовок необязателен обычно есть, но может отсутствовать

Отсюда главное правило: CSV нельзя разбирать через split(","), нужен парсер, который знает про кавычки.

Почему не split(«,»)

Неверный подход — резать строки файла по запятой:

# Наивный разбор: split по запятой
with open("people.csv", encoding="utf-8") as f:
    for line in f:
        print(line.rstrip("\n").split(","))

Результат на файле выше — поля разъехались, кавычки остались, запись с переносом разорвана на две:

['id', 'name', 'comment']
['1', 'Анна', '"любит Python', ' SQL"']
['2', 'Борис', '"сказал ""привет"""']
['3', 'Вера', '"две']
['строки"']

Исправление — модуль csv, он входит в стандартную библиотеку и ставить его не нужно.

Чтение и запись: минимальный рабочий пример

Полный пример: записываем таблицу и читаем ее обратно.

import csv

rows = [
    ["id", "name", "comment"],
    [1, "Анна", "любит Python, SQL"],
    [2, "Борис", 'сказал "привет"'],
    [3, "Вера", "две\nстроки"],
]

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

with open("people.csv", newline="", encoding="utf-8") as f:
    for row in csv.reader(f):
        print(row)

Вывод:

['id', 'name', 'comment']
['1', 'Анна', 'любит Python, SQL']
['2', 'Борис', 'сказал "привет"']
['3', 'Вера', 'две\nстроки']

Что здесь важно:

  • csv.writer сам поставил кавычки только там, где нужно (режим QUOTE_MINIMAL по умолчанию), удвоил кавычки и завершил строки \r\n, как в RFC.
  • csv.reader вернул каждую запись списком строк. Числа тоже стали строками: '1', а не 1. Типы CSV не хранит, приводить их нужно самому.
  • Файл открыт с newline="" и явной кодировкой — оба параметра разберем ниже.

DictReader и DictWriter: доступ по именам столбцов

Если в файле есть заголовок, удобнее csv.DictReader: каждая запись — словарь, ключи берутся из первой строки.

import csv

with open("people.csv", newline="", encoding="utf-8") as f:
    reader = csv.DictReader(f)
    print(reader.fieldnames)
    for row in reader:
        print(int(row["id"]) * 10, row["name"])
['id', 'name', 'comment']
10 Анна
20 Борис
30 Вера

Если заголовка нет, имена передают явно: csv.DictReader(f, fieldnames=["id", "name", "comment"]). Для записи словарей есть csv.DictWriter: ему обязательно передают fieldnames, а заголовок пишет метод writeheader().

Зачем newline=»»

При открытии в текстовом режиме Python переводит концы строк. На Windows при записи каждый \n превращается в \r\n, а writer и так пишет \r\n. Итог — \r\r\n и пустые строки между записями. Поведение Windows можно воспроизвести на любой ОС параметром newline="\r\n":

import csv

rows = [
    ["id", "name"],
    [1, "Анна"],
]

with open("bad.csv", "w", newline="\r\n", encoding="utf-8") as f:
    csv.writer(f).writerows(rows)
print(open("bad.csv", "rb").read())

with open("bad.csv", newline="", encoding="utf-8") as f:
    for row in csv.reader(f):
        print(row)
b'id,name\r\r\n1,\xd0\x90\xd0\xbd\xd0\xbd\xd0\xb0\r\r\n'
['id', 'name']
[]
['1', 'Анна']
[]

Появились пустые записи — пустые списки после каждой строки. С newline="" в файле будет ровно \r\n. При чтении тот же параметр нужен, чтобы перевод строки внутри кавычек (как у Веры) не потерялся. Правило из документации модуля: файл для csv всегда открывать с newline="".

Разделитель и диалект

Файл с точкой с запятой читают так: csv.reader(f, delimiter=";"). Если разделитель заранее неизвестен, можно попросить csv.Sniffer угадать его по фрагменту:

import csv

sample = "name;price\nКофе;3,50\nЧай;2,00\n"
dialect = csv.Sniffer().sniff(sample, delimiters=";,\t")
print(repr(dialect.delimiter))
';'

Sniffer работает эвристически: на коротком или однородном фрагменте он может ошибиться или выбросить csv.Error. Для регулярного обмена лучше договориться о формате и указывать delimiter явно.

Кодировки: UTF-8, BOM и cp1251

CSV не сообщает свою кодировку. Если не указать encoding, Python возьмет кодировку по умолчанию для системы, и на разных машинах результат будет разным. Поэтому кодировку указывают всегда.

Excel на Windows без подсказки часто открывает UTF-8 как «кракозябры». Подсказка — метка порядка байтов (BOM) в начале файла, ее пишет кодировка utf-8-sig. Но при чтении такого файла как обычного utf-8 BOM прилипает к первому столбцу:

import csv

with open("report.csv", "w", newline="", encoding="utf-8-sig") as f:
    w = csv.DictWriter(f, fieldnames=["name", "price"], delimiter=";")
    w.writeheader()
    w.writerow({"name": "Кофе", "price": "3,50"})

# Ошибка: читаем файл с BOM как обычный utf-8
with open("report.csv", newline="", encoding="utf-8") as f:
    row = next(csv.DictReader(f, delimiter=";"))
print(list(row))
try:
    print(row["name"])
except KeyError as e:
    print("KeyError:", e)

# Исправление: utf-8-sig снимает BOM при чтении
with open("report.csv", newline="", encoding="utf-8-sig") as f:
    row = next(csv.DictReader(f, delimiter=";"))
print(row["name"], row["price"])
['\ufeffname', 'price']
KeyError: 'name'
Кофе 3,50

Ключ называется не name, а \ufeffname. Для чтения неизвестных UTF-8 файлов utf-8-sig безопасен: он снимает BOM, если он есть, и работает как utf-8, если его нет.

Старые выгрузки из 1С и Excel часто в cp1251. Попытка прочитать их как UTF-8 дает ошибку:

UnicodeDecodeError: 'utf-8' codec can't decode byte 0xe8 in position 0: invalid continuation byte

Решение — encoding="cp1251". Угадывать кодировку по одному байту ненадежно: если источник известен, берите его кодировку, если нет — уточните у того, кто выгружал файл.

pandas.read_csv коротко

Когда CSV нужен для анализа, а не для построчной обработки, обычно берут pandas. Те же параметры там называются похоже:

import pandas as pd

df = pd.read_csv("report.csv", sep=";", decimal=",", encoding="utf-8-sig")
print(df)
print(df["price"].dtype)
   name  price
0  Кофе    3.5
float64

В отличие от модуля csv, pandas сам приводит типы: decimal="," превратил «3,50» в число. Проверено на pandas 2.3; в pandas 3.0 текстовые столбцы по умолчанию получают строковый тип str вместо object, это видно в df.dtypes. Работа с самими DataFrame — отдельная тема.

Безопасность: CSV-инъекция при экспорте

Если в CSV попадают данные пользователей (комментарии, имена), а файл потом открывают в Excel или LibreOffice, ячейка вида =HYPERLINK(...) станет формулой. Это CSV-инъекция (formula injection): табличный процессор выполнит формулу, покажет фишинговую ссылку или, в старых настройках, запустит внешнюю команду. Модуль csv тут не помогает: для него это обычная строка.

Базовая защита — экранировать апострофом текстовые ячейки, которые начинаются с =, +, -, @, табуляции, \r или \n (список OWASP; в некоторых локалях опасны и полноширинные =, +, -, @):

import csv

DANGEROUS = ("=", "+", "-", "@", "\t", "\r", "\n", "=", "+", "-", "@")

def safe_cell(value):
    """Экранирует текстовую ячейку от интерпретации как формулы."""
    if isinstance(value, str) and value.startswith(DANGEROUS):
        return "'" + value
    return value

rows = [
    ["user", "comment", "balance"],
    ["mallory", '=HYPERLINK("http://example.com","жми")', -150],
    ["bob", "+7 900 000-00-00", 20],
]

with open("export.csv", "w", newline="", encoding="utf-8-sig") as f:
    w = csv.writer(f, quoting=csv.QUOTE_MINIMAL)
    for row in rows:
        w.writerow([safe_cell(v) for v in row])

print(open("export.csv", encoding="utf-8-sig").read())
user,comment,balance
mallory,"'=HYPERLINK(""http://example.com"",""жми"")",-150
bob,'+7 900 000-00-00,20

Границы этого приема стоит понимать. Числовой баланс -150 остался числом, потому что функция трогает только строки: если число пришло строкой, его нужно привести к типу до экспорта. Телефон +7 ... тоже получил апостроф — для выгрузки в таблицу это приемлемо, но если файл читает программа, а не человек, апостроф станет частью данных. Поэтому экранирование делают на этапе экспорта для людей, а в данных, которые хранятся и передаются между системами, его не применяют. И это не гарантия: после открытия и повторного сохранения файла в Excel апостроф может пропасть, и формула снова станет активной.

Второй риск — при чтении чужих файлов. По умолчанию модуль ограничивает размер одного поля 131072 символами (csv.field_size_limit()), и длинное поле вызывает csv.Error. Поднимать лимит до максимума «чтобы не падало» для недоверенных файлов не стоит: лучше ограничить размер файла и обработать ошибку.

Частые ошибки: симптом -> причина -> что делать

Симптом Причина Что делать
Пустые строки между записями файл открыт без newline="" (Windows) открывать с newline=""
Ключ \ufeffname, KeyError BOM прочитан как часть заголовка encoding="utf-8-sig"
UnicodeDecodeError файл в cp1251, читается как UTF-8 указать encoding="cp1251"
Вся строка в одном поле разделитель ; или табуляция delimiter=";" или "\t"
csv.Error: field larger than field limit очень длинное поле проверить файл, осознанно поднять лимит

Выводы

  • CSV — текстовый табличный формат: записи по строкам, поля через разделитель, спецсимволы внутри двойных кавычек с удвоением кавычки (RFC 4180).
  • Разбирать CSV нужно модулем csv или pandas, а не split(","): кавычки и переводы строк внутри полей ломают наивный разбор.
  • Файл для модуля csv открывают с newline="" и явной кодировкой; utf-8-sig — для обмена с Excel, cp1251 — для старых выгрузок.
  • Все значения из csv.reader и DictReader — строки, типы приводят вручную.
  • При экспорте пользовательских данных в таблицы экранируйте ячейки-формулы, это защита от CSV-инъекции.

Где применяется / связь с практикой

Освойте тему на практике

CSV — самый частый формат обмена данными между системами: выгрузки из CRM и 1С, отчеты рекламных кабинетов, открытые датасеты. Аналитик читает их в pandas, чистит кодировки и типы и строит отчет. Этот путь от сырого CSV до выводов системно разбирают на курсе «Python для аналитики». Попробовать формат занятий можно на открытых уроках Otus.

Как открыть и сохранить CSV в Windows и Excel без поломки кодировки — в статье «CSV в Windows от А до Я».

FAQ

Чем CSV отличается от XLSX?
CSV — простой текст без форматирования, формул, типов и нескольких листов. XLSX — zip-архив с XML, который хранит все это; для обмена между программами CSV проще, для отчетов с оформлением — XLSX.

Как прочитать огромный CSV, который не помещается в память?
csv.reader и так читает построчно и не держит файл целиком. В pandas для этого есть параметр chunksize, который отдает файл частями.

Можно ли хранить в CSV вложенные данные?
Формат плоский: вложенную структуру придется сериализовать в строку (например, в JSON внутри поля). Если вложенность постоянная, удобнее JSON Lines или Parquet.

OTUS Журнал
Бесплатные открытые уроки (поп-ап)