Python з нуля · Блок 5 · Тема 21

Дані, які переживуть програму

Файл, який розуміє тільки твоя програма, — це не збережені дані, а особистий щоденник. Розберімо два формати, якими світ обмінюється даними насправді: CSV для таблиць і JSON для всього, що складніше за таблицю.

У попередній темі ми навчилися відкривати файл через with, писати в нього рядки й обовʼязково вказувати encoding="utf-8". Тепер файл на диску є — і одразу постає питання, від якого не втекти: що саме в ньому написати, щоб через півроку його прочитала не лише ця програма?

Памʼять програми зникає разом із програмою. Список товарів, який ти зібрав у словник, живе рівно доти, доки процес не завершився. Файл — це спосіб пережити завершення. Але файл із власною вигадкою всередині («тут ціна, потім два пробіли, потім назва») зрозуміє тільки його автор — і то поки памʼятає. Формат обміну — це домовленість, записана заздалегідь і однаково зрозуміла Python, Excel, JavaScript і колезі, який відкриє файл через рік.

01 / ІдеяНавіщо потрібні формати

Форматів вигадано сотні, але в повсякденній роботі ти впираєшся в два. Обидва — звичайний текст, який можна відкрити блокнотом і прочитати очима. Обидва вміє читати будь-яка мова програмування. Різниця між ними — у формі даних:

Наскрізний приклад теми — маленька кавʼярня. Її меню чудово лягає в таблицю: у кожного напою є назва, ціна, категорія й опис. А замовлення в таблицю вже не лягає: у ньому є список позицій, і в різних замовленнях цих позицій різна кількість. Меню поїде в CSV, замовлення — у JSON. На цій парі ми й розберемо обидва модулі.

02 / CSVТаблиця як текст

Ідея CSV настільки проста, що її можна пояснити одним прикладом. Один рядок файлу — один рядок таблиці. Поля всередині рядка розділені комою. Перший рядок зазвичай не дані, а шапка: назви стовпців.

# меню.csv
назва,ціна,категорія,опис
Еспресо,25,кава,міцна
Капучино,45,кава,"молоко, кориця"
Чай,30,чай,трав'яний

Це весь формат. Ані типів, ані вкладеності, ані коментарів — самі значення й коми. Саме через цю простоту CSV вміють геть усі: бухгалтерські програми, бази даних, банки у виписці по картці, наукові прилади. Якщо тобі колись дадуть «дані», з імовірністю відсотків шістдесят це буде CSV.

Але придивись до третього рядка. Опис капучино — молоко, кориця — сам містить кому. Щоб її не сплутали з роздільником, поле взяли в подвійні лапки. Ця дрібниця й породжує все, про що йдеться в наступному розділі.

Строгого стандарту немає. Є документ RFC 4180, який описує «правильний» CSV, але зʼявився він 2005 року — через десятиліття після того, як формат уже розповзся світом. Тому реальні файли його дотримуються приблизно: хтось ставить крапку з комою, хтось табуляцію, хтось не екранує лапки взагалі. Через це модуль csv має поняття діалекту — набору налаштувань під конкретного відправника.

03 / ПасткаЧому не можна split(",")

Перше, що спадає на думку новачкові (і, чесно кажучи, не лише новачкові): рядок є, кома є, у нас же є split() з теми про рядки. Написав рядок.split(",") — отримав список полів, готово.

рядок = 'Капучино,45,кава,"молоко, кориця"'
print(рядок.split(","))
['Капучино', '45', 'кава', '"молоко', ' кориця"']

Полів мало бути чотири, а вийшло пʼять. Кома всередині опису порізала поле навпіл: тепер у нас є огризок "молоко з приліпленою лапкою й огризок кориця" з пробілом попереду. Ціна тепер у стовпці 1, а категорія — у стовпці 2, і все ще нібито збігається. Але наступний рядок, де ком немає, дасть чотири поля, і твоя програма почне брати з різних рядків різні речі. Це найгірший тип помилки: не падіння, а тихо зіпсовані дані.

Модуль csv існує саме для цього. Він читає рядок не як «текст із комами», а як автомат зі станами: побачив лапку на початку поля — перейшов у режим «всередині лапок», де кома вже не роздільник, а звичайний символ; побачив закривальну лапку — вийшов назад. Порівняй обидва підходи на одних і тих самих рядках:

Інтерактив 1 · split(",") проти модуля csv

Один і той самий рядок, два способи розібрати. Дивись на кількість полів.

полів у split
полів у csv
опис — поле 3 у split
Що читати: угорі — сирий рядок із файлу, підсвічене рожевим — те, що всередині лапок. Нижче два розбори: бірюзовий ряд означає «усі поля на місці», рожевий — «розбір зіпсовано». Зверни увагу на третій режим: подвоєні лапки "" — це спосіб записати одну лапку всередині поля, і модуль повертає її назад однією.

Висновок простий і категоричний: рядок CSV не можна розібрати рядковими методами. Не тому, що це складно, а тому, що правила формату складніші, ніж здається, — і твоя саморобна версія працюватиме рівно доти, доки в даних не трапиться кома, лапка або перенос рядка всередині поля. У практиці ти напишеш обидві версії й порівняєш їх на однакових даних.

04 / ДіалектРоздільник, лапки, newline

Тепер про те, чому файл від колеги не читається твоїм кодом. Три налаштування, які вирішують майже все.

Роздільник

Кома — лише значення за замовчуванням. В українській (як і в німецькій чи французькій) локалі Excel десятковим роздільником є кома: 45,50 — це сорок пʼять із половиною. Якби такий Excel розділяв стовпці теж комою, файл був би нечитабельним, тому він бере крапку з комою. Тому у файлі з української бухгалтерії ти майже завжди побачиш ;. Модулю про це треба сказати:

csv.reader(файл, delimiter=";")   # файл з українського Excel
csv.reader(файл, delimiter="\t")  # TSV: розділено табуляцією

Що станеться, якщо не сказати? Не помилка. Модуль слухняно розріже рядок по комах, яких у файлі майже немає, — і поверне один-два величезні поля замість чотирьох. Помацай це руками:

Інтерактив 2 · Роздільник і лапки

Той самий запис, різні діалекти. Знизу — що буде, якщо про діалект не сказати.

полів: правильно
полів: за умовч.
Найцікавіше — крапка з комою без лапок: поле молоко, кориця доїжджає цілим навіть без лапок, бо для цього діалекту кома — звичайний символ. Лапки потрібні не «завжди», а рівно тоді, коли всередині поля трапляється власний роздільник файлу, лапка або перенос рядка.

Лапки

Правило екранування в CSV одне й дуже коротке: поле беруть у подвійні лапки, якщо всередині є роздільник, лапка або перенос рядка; сама лапка всередині подвоюється. Слово сироп "Карамель" у файлі виглядає як "сироп ""Карамель""" — виглядає дико, але читається однозначно. Модуль csv робить це при записі й розбирає при читанні; тобі не треба знати цю механіку напамʼять — треба не робити її руками.

newline="" — той самий рядок, який усі забувають

Відкриваючи файл для csv, пиши так:

with open("меню.csv", "w", newline="", encoding="utf-8") as файл:
    # ... тут працює csv.writer

Причина технічна, але наслідки видно одразу. Модуль csv сам вирішує, чим завершувати рядок, — за замовчуванням це послідовність \r\n. Якщо файл відкрито звичайно, текстовий режим на Windows додатково перетворює кожен \n на \r\n — і в файлі зʼявляється \r\r\n, тобто зайвий порожній рядок між кожними двома записами. Порожній newline="" означає «не чіпай переноси, модуль розбереться сам».

При читанні ця сама вказівка рятує від іншої халепи: якщо всередині поля в лапках є перенос рядка (а таке буває — багаторядковий коментар у клітинці), без newline="" файловий обʼєкт поріже його передчасно, і модуль отримає обірваний запис. Правило, яке варто запамʼятати як догму: файл для csv завжди відкривають із newline="" — і на запис, і на читання.

Ще одна дрібниця про Excel. Excel відкриває CSV у кодуванні системи, а не в UTF-8, тому українські назви перетворюються на кракозябри. Лікується кодуванням utf-8-sig при записі: воно ставить на початок файлу невидиму позначку BOM, за якою Excel упізнає UTF-8. Для Python обидва варіанти читаються однаково — utf-8-sig при читанні цю позначку зʼїдає.

05 / Читанняreader і DictReader

Читачів у модулі два, і вибір між ними — це вибір, як ти хочеш звертатися до полів: за номером чи за назвою.

import csv

with open("меню.csv", newline="", encoding="utf-8") as файл:
    for рядок in csv.reader(файл):
        print(рядок)
['назва', 'ціна', 'категорія', 'опис']
['Еспресо', '25', 'кава', 'міцна']
['Капучино', '45', 'кава', 'молоко, кориця']

Зверни увагу на перший надрукований рядок: reader віддав шапку як звичайні дані. Він про неї нічого не знає — для нього це просто перший рядок файлу. Хочеш пропустити шапку — зроби це явно: шапка = next(читач) перед циклом.

DictReader поводиться інакше: він зʼїдає перший рядок, запамʼятовує його як назви полів і далі віддає словники.

with open("меню.csv", newline="", encoding="utf-8") as файл:
    for рядок in csv.DictReader(файл):
        print(рядок["назва"], "—", рядок["ціна"])
Еспресо — 25
Капучино — 45

Різниця виглядає косметичною, а насправді вона про надійність. рядок[1] мовчки зламається, щойно відправник додасть стовпець на початок. рядок["ціна"] переживе будь-яку перестановку стовпців і читається без коментаря. Ціна — трохи повільніше й трохи більше памʼяті; на файлах, які поміщаються в памʼять, це не має значення.

Інтерактив 3 · reader проти DictReader

Один файл, два читачі. Веди повзунок по ітераціях циклу.

reader: тип
DictReader: тип
як дістати ціну
Головне на ітерації 1: reader віддає шапку як дані — і якщо не пропустити її вручну, у твоїх розрахунках зʼявиться «напій» на назву «назва» з ціною «ціна». DictReader шапку вже зʼїв, тому ітерацій у нього на одну менше — і на пʼятій цикл просто закінчується.

06 / Записwriter і DictWriter

Симетрично читанню: csv.writer приймає списки, csv.DictWriter — словники. Різниця лише в тому, що DictWriter треба заздалегідь сказати, які поля й у якому порядку писати.

import csv

меню = [
    {"назва": "Еспресо", "ціна": 25, "опис": "міцна"},
    {"назва": "Капучино", "ціна": 45, "опис": "молоко, кориця"},
]

with open("меню.csv", "w", newline="", encoding="utf-8") as файл:
    писар = csv.DictWriter(файл, fieldnames=["назва", "ціна", "опис"])
    писар.writeheader()        # рядок із назвами стовпців
    писар.writerows(меню)     # усі записи одним викликом

Лапки навколо молоко, кориця зʼявляться у файлі самі — писар бачить кому й екранує поле. Це і є головна причина користуватися модулем на запис, а не склеювати рядок через ",".join(...): склеювання поставить кому як роздільник і зіпсує файл, який ти сам же потім прочитаєш.

що робитьчитаннязапис
спискамиcsv.reader(файл)csv.writer(файл)
словникамиcsv.DictReader(файл)csv.DictWriter(файл, fieldnames=…)
один записnext(читач)писар.writerow(запис)
усі записиlist(читач)писар.writerows(записи)
шапкаDictReader бере самписар.writeheader()

07 / ТипиCSV не знає, що таке число

Ось риса, на якій спотикаються всі. У файлі немає типів — у ньому є символи. Тому усе, що прочитано з CSV, — рядки. Завжди. Ціна 45 повернеться як '45', а не 45.

рядок = {"назва": "Капучино", "ціна": "45"}  # саме так віддає DictReader
print(рядок["ціна"] + 5)
TypeError: can only concatenate str (not "int") to str
print(int(рядок["ціна"]) + 5)
50

Перетворення — твоя робота, і робити її треба свідомо, в одному місці, одразу після читання. Три речі, про які варто памʼятати:

Для тих, хто вже пробував. У модулі є quoting=csv.QUOTE_NONNUMERIC, який при читанні перетворює всі поля без лапок на float. Виглядає як розвʼязання проблеми типів, але насправді це пастка: назва без лапок теж піде на float() і впаде, а цілі числа стануть дробовими. Явне int(рядок["ціна"]) у своєму коді надійніше.

08 / JSONКоли таблиці замало

Повернімось до замовлення кавʼярні. У ньому є номер, імʼя клієнта, ознака оплати — і список позицій, у кожної з яких своя назва й кількість. У таблицю це не лягає: клітинка не вміщає список. JSON вміщає.

{
  "номер": 17,
  "клієнт": "Аня",
  "оплачено": true,
  "позиції": [
    {"назва": "Капучино", "кількість": 2},
    {"назва": "Чай", "кількість": 1}
  ]
}

Упізнаєш? Це майже словник Python, у якому всередині лежить список словників. Схожість не випадкова: JSON народився як спосіб записати обʼєкт JavaScript текстом, а структури там ті самі. Тому переклад із Python у JSON і назад майже дослівний — майже, і саме про це «майже» піде розділ 09.

Чотири функції, і всі про одне

Модуль json має рівно чотири функції, які легко переплутати. Правило: буква s означає «string», тобто робота з рядком у памʼяті; без s — робота з файлом.

функціянапрямокз чим працює
json.dumps(об)обʼєкт → текстповертає рядок
json.loads(текст)текст → обʼєктприймає рядок
json.dump(об, файл)обʼєкт → файлпише у відкритий файл
json.load(файл)файл → обʼєктчитає відкритий файл
import json

with open("замовлення.json", "w", encoding="utf-8") as файл:
    json.dump(замовлення, файл, ensure_ascii=False, indent=2)

with open("замовлення.json", encoding="utf-8") as файл:
    повернулось = json.load(файл)
print(повернулось["позиції"][0]["назва"])
Капучино

Ніякого newline="" тут не треба — це вимога модуля csv, а не загальне правило. А от encoding="utf-8" потрібне так само, і про сусіда ensure_ascii=False — окремий розділ нижче, бо без нього твої дані стануть нечитабельними.

Два корисні аргументи dump/dumps: indent=2 робить відступи й переноси, щоб файл читала людина; sort_keys=True впорядковує ключі за абеткою. Без indent усе пишеться одним рядком — компактніше на 10-20% і швидше читається машиною. Правило: файл, у який заглядатиме людина (налаштування, приклад, дані в репозиторії) — з indent; файл, який летить по мережі чи лежить мільйоном записів на диску — без.

Синтаксис JSON суворіший, ніж здається. Тільки подвійні лапки ('Аня' — помилка). Ніяких коментарів. Ніякої коми після останнього елемента. true, false, null — маленькими літерами, на відміну від пітонівських True, False, None. Файл, який Python друкує сам, завжди правильний; проблеми починаються, коли JSON редагують руками.

09 / ВідповідністьЩо з чим збігається

Перетворення Python → JSON не взаємно однозначне. Кілька різних типів Python відображаються в один тип JSON — і назад повертається вже щось одне.

PythonJSONщо повернеться з loads
dictobjectdict — те саме
listarraylist — те саме
tuplearraylistтип змінився
strstringstr
int / floatnumberint / float
True / Falsetrue / falsebool
NonenullNone
setTypeError при записі
дата, часTypeError при записі

Три рядки з кінця варті окремої уваги. Кортеж перетворюється на список і назад уже не повертається — у JSON просто немає такого поняття, як незмінна послідовність. Якщо код десь розраховував, що там кортеж (наприклад, використовував його ключем словника), після циклу «записали — прочитали» він зламається.

Множина й дата не серіалізуються взагалі. Python не вгадуватиме, у що їх перетворити, — він чесно кидає TypeError: Object of type set is not JSON serializable. Перетворюй сам: множину — у sorted(множина), дату — у рядок. Це не обмеження Python, а обмеження формату: у JSON типів усього шість.

І третє, найпідступніше: ключі словника завжди стають рядками. У JSON ключ обʼєкта може бути тільки рядком, тому {1: "перший"} запишеться як {"1": "перший"} — а прочитається як словник із ключем-рядком "1". Числовий ключ мовчки пережив перетворення й став іншим.

Інтерактив 4 · Обʼєкт → JSON → обʼєкт

Обери, що записуємо, — і подивись, що повернулось із зворотного шляху.

тип до
тип після
обʼєкт == прочитане
Що читати: три поверхи — обʼєкт у памʼяті, його текстове представлення й те, що повернув json.loads. Бірюзова рамка внизу означає, що обʼєкт повернувся тим самим; рожева — що змінився або взагалі не записався. Режим «ключі-числа» показує найтихішу з усіх втрат.

10 / Кирилицяensure_ascii і українські літери

Найпоширеніша практична пастка теми. Запиши словник з українським текстом без додаткових аргументів — і подивись на файл:

print(json.dumps({"напій": "Кава"}))
{"\u043d\u0430\u043f\u0456\u0439": "\u041a\u0430\u0432\u0430"}
print(json.dumps({"напій": "Кава"}, ensure_ascii=False))
{"напій": "Кава"}

Обидва рядки — коректний JSON, і json.loads поверне з них однаковий словник. Але перший неможливо прочитати очима, неможливо знайти пошуком по файлу й соромно показати замовникові. Кожна українська літера перетворилась на шість символів \uXXXX — це «екранована» форма запису символу за його номером у Юнікоді.

Чому це поведінка за замовчуванням? Історія: аргумент означає «гарантувати, що на виході буде чистий ASCII». Такий файл переживе будь-який древній канал звʼязку, будь-яку систему, яка не знає про UTF-8, і будь-який редактор із неправильним кодуванням. У 1999 році це було розумно. Сьогодні UTF-8 усюди, тому для української мови розумно інше:

json.dump(дані, файл, ensure_ascii=False, indent=2)

Це має стати автоматизмом — так само, як encoding="utf-8" у open(). Подивись, у що обходиться забудькуватість:

Інтерактив 5 · ensure_ascii: та сама інформація, різний файл

Один словник, один прапорець. Порівняй читабельність і розмір.

символів у тексті
байтів у файлі
прочитає людина
Зверни увагу на латиницю: там обидва варіанти однакові до символу — прапорець узагалі ні на що не впливає, поки текст англійський. Саме тому пастка живуча: автори бібліотек її не бачать, а ти бачиш одразу. А емодзі перетворюється аж на два екранування — це сурогатна пара, спосіб записати символ, номер якого не вміщається у чотири шістнадцяткові цифри.

11 / Криві даніКоли файл зіпсований

Файли, які ти отримуєш ззовні, бувають биті: обірвалась передача, хтось редагував JSON руками й забув лапку, вивантаження зробили з помилкою. Код, який це не переживає, не можна пускати в роботу. Знання з теми про винятки тут стають у пригоді буквально.

JSON падає з json.JSONDecodeError — це підвид ValueError, тому ловити можна будь-який із двох. Виняток корисний тим, що точно каже, де зламалось:

import json

try:
    дані = json.loads(текст)
except json.JSONDecodeError as помилка:
    print("рядок", помилка.lineno, "стовпець", помилка.colno)
    print("причина:", помилка.msg)
    дані = {}  # працюємо далі з порожнечею, а не падаємо

CSV поводиться інакше — і тим підступніший. Файл, у якому в рядку бракує стовпця, для модуля не помилка: DictReader просто поставить у відсутнє поле None. Зайві стовпці він теж не викине — складе їх у список під ключем None. Помилки немає, дані криві, програма працює далі. Перевіряти доводиться самому.

Інтерактив 6 · Що робить Python із кривими даними

Чотири типові поломки: дві в JSON, дві в CSV. Дивись, як саме воно ламається.

чи впаде
де саме
Мораль у різниці: JSON падає голосно й показує пальцем на символ — таку помилку неможливо не помітити. CSV мовчить і підсовує None замість значення, і помітиш ти це вже в підсумковому звіті. Тому після читання CSV перевіряй кількість полів самостійно — у практиці ти напишеш саме таку перевірку.

12 / ВибірТаблиця чи вкладеність

Запитання, яке варто ставити собі перед тим, як обрати формат: чи всі мої записи однакові за формою? Якщо так — CSV. Якщо всередині запису є список змінної довжини або ще один запис — JSON.

Спокуса «запхати все в CSV» велика, бо таблиця звична. Але вкладеність у клітинку не влазить, і починаються милиці: теги через вертикальну риску кава|знижка, JSON-рядок усередині CSV-поля, дублювання шапки замовлення в кожному рядку позиції. Усі три працюють — і всі три означають, що розбирати дані назад доведеться руками.

Інтерактив 7 · Три задачі, два формати

Той самий набір даних у CSV і в JSON. Дивись і на форму, і на розмір.

CSV, КБ
JSON, КБ
Чому JSON важчий: у CSV назви стовпців записані один раз у шапці, а в JSON ключі повторюються в кожному записі. На плоских даних це дає різницю в рази. Але щойно зʼявляється вкладеність, CSV перестає бути дешевшим — він просто перестає бути придатним без ручних милиць.

13 / ПрофіМежі JSON

Три речі, які знадобляться, коли дані виростуть.

JSON читається цілком

json.load() не вміє читати наполовину: формат такий, що перевірити коректність можна лише дочитавши до останньої дужки. Тому файл на 2 ГБ спершу цілком приїде в памʼять текстом, а потім перетвориться на дерево обʼєктів Python — яке важить ще більше, ніж текст, бо кожен рядок і словник несуть службові дані. На великих обсягах це закінчується MemoryError.

Розвʼязання називається JSON Lines (розширення .jsonl): один самостійний JSON-обʼєкт на рядок, без обгортки-масиву.

{"номер": 17, "клієнт": "Аня"}
{"номер": 18, "клієнт": "Богдан"}
# читається по одному запису, памʼять не росте:
with open("замовлення.jsonl", encoding="utf-8") as файл:
    for рядок in файл:
        запис = json.loads(рядок)

Це той самий трюк, який робить CSV масштабованим: файл читається порядково, у памʼяті одночасно живе один запис. Плюс дописувати в кінець можна без переписування всього файлу.

Числа з рухомою крапкою

Python записує float у JSON через repr(), а той із версії 3.1 дає найкоротший запис, який гарантовано читається назад у те саме число. Тому 0.1 + 0.2 поїде у файл як 0.30000000000000004 і повернеться бітово тим самим — усередині Python втрати немає.

Втрата зʼявляється на кордоні з іншими системами. Класика: JavaScript має один числовий тип і не тримає цілі, більші за 253. Ідентифікатор 9007199254740993, який Python запише чесно, у браузері прочитається як 9007199254740992 — на одиницю менше, і це вже інший запис у базі. Тому великі ідентифікатори кладуть у JSON рядками. Ще одна межа: Decimal, яким рахують гроші, серіалізується лише через float — тобто з втратою точності саме там, де вона неприпустима.

NaN, якого в JSON немає

У стандарті JSON немає ані NaN, ані нескінченності. Python усе одно їх записує — словами NaN і Infinity, — і Python же їх прочитає. А сторонній парсер (у тому числі JSON.parse у браузері) на такому файлі впаде. Якщо файл їде назовні, страхуйся: json.dumps(дані, allow_nan=False) кине ValueError у тебе, а не в замовника.

14 / ПідсумокЩо з цього забрати

І ширша думка, заради якої була ця тема. Обидва формати — компроміс між «зрозуміло людині» й «зрозуміло машині», і кожен зробив свій вибір. CSV віддав типи й вкладеність в обмін на компактність і порядкове читання. JSON віддав компактність в обмін на структуру. Немає кращого — є придатний до твоїх даних. Уміння відповісти на питання «а якої форми мої записи?» коштує більше, ніж памʼять на аргументи функцій.

Що далі. Ти вже помітив дірку: дата в JSON не серіалізується, а з CSV повертається рядком "2026-03-01". Перетворювати її туди й назад — робота наступної теми, де ми розберемо datetime, часові пояси й формат ISO 8601, який недарма став стандартом де-факто саме для обміну. А в practice.ipynb ти зараз запишеш і прочитаєш меню кавʼярні у двох форматах, перетвориш типи, зловиш дві поломки й переконаєшся на assert, що кортеж після подорожі в JSON справді став списком.

Далі в темі

Теорію прочитано. Тепер закріпи її на практиці.