Shanraq.org Shanraq.org
Чтение и запись: CSV, JSON и SQL одной строкой
IT

Python: от данных до своей сводки Урок 27 из 56

Чтение и запись: CSV, JSON и SQL одной строкой

Двадцать шестой урок курса по Python. Чужой файл — это разделитель, запятая в дробях, BOM и своя метка пропуска; в цикле про каждую мелочь надо помнить самому, в `read_csv` это аргументы. Разбираем `read_csv`, `to_csv`, `read_sql` и `to_sql`, а заодно то, чего файл про таблицу не помнит.

Зачем это нужно

Двенадцатый урок научил читать CSV модулем csv: строка за строкой, каждое число из текста руками. Это правильный инструмент, когда файл один и небольшой.

Но чужие файлы приходят разными. Из Excel — с точкой с запятой вместо запятой и с запятой внутри дробей. Из бухгалтерии — с пробелом в тысячах и с «н/д» вместо пустоты. Из старой системы — с BOM в начале. Каждая из этих мелочей в цикле превращается в отдельную строчку кода, которую надо помнить, а забытая — в тихо неверный ответ.

В pandas всё это — аргументы одной функции. Урок про то, какие именно.

Сразу целиком

Файл formaty.py. Одна таблица уезжает в три формата и возвращается обратно.

"""Урок 26: чтение и запись — CSV, JSON, SQL.

Одна и та же таблица уезжает в три формата и возвращается обратно. Смотрим, что
переживает дорогу, а что теряется по пути.
"""

import sqlite3
from pathlib import Path

import pandas as pd

HERE = Path(__file__).resolve().parent

# Инфляция, % за год. Данные Всемирного банка; 2025 год ещё не посчитан.
df = pd.DataFrame(
    {"KZ": [8.0, 15.0, 14.5, 8.7, None], "UZ": [10.8, 11.4, 10.0, 9.6, None]},
    index=[2021, 2022, 2023, 2024, 2025],
)
df.index.name = "year"
print(df)

print()
print("== CSV: одна строка вместо цикла")
csv_path = HERE / "inflation.csv"
df.to_csv(csv_path)
print(csv_path.read_text(encoding="utf-8"), end="")

print()
print("== и обратно")
plain = pd.read_csv(csv_path)
print("столбцы:", list(plain.columns), "| подписи:", plain.index.tolist())
back = pd.read_csv(csv_path, index_col="year")
print("столбцы:", list(back.columns), "| подписи:", back.index.tolist())
print("совпало с исходной:", back.equals(df))

print()
print("== пропуск остался пропуском")
print("пустых ячеек:", int(back.isna().sum().sum()), "| в файле это:", repr(csv_path.read_text(encoding="utf-8").splitlines()[-1]))

print()
print("== чего файл не помнит: тип")
rates = pd.DataFrame({"day": pd.to_datetime(["2026-01-15", "2026-02-15"]), "rate": [512.3, 519.8]})
rates_path = HERE / "rates.csv"
rates.to_csv(rates_path, index=False)
print(rates_path.read_text(encoding="utf-8"), end="")
print("просто прочитали:", dict(pd.read_csv(rates_path).dtypes.astype(str)))
print("с parse_dates:   ", dict(pd.read_csv(rates_path, parse_dates=["day"]).dtypes.astype(str)))

print()
print("== JSON: тот же ряд, другой конверт")
json_path = HERE / "inflation.json"
df.to_json(json_path, orient="index")
text = json_path.read_text(encoding="utf-8")
print("длина:", len(text), "знаков, начало:")
print(text[:88], "…")

print()
print("== SQL: таблица уходит в базу и возвращается запросом")
db = sqlite3.connect(HERE / "digest.db")
df.reset_index().to_sql("inflation", db, if_exists="replace", index=False)
high = pd.read_sql("SELECT year, KZ FROM inflation WHERE KZ > 10 ORDER BY year", db)
print(high)
db.close()

Выводит:

        KZ    UZ
year            
2021   8.0  10.8
2022  15.0  11.4
2023  14.5  10.0
2024   8.7   9.6
2025   NaN   NaN

== CSV: одна строка вместо цикла
year,KZ,UZ
2021,8.0,10.8
2022,15.0,11.4
2023,14.5,10.0
2024,8.7,9.6
2025,,

== и обратно
столбцы: ['year', 'KZ', 'UZ'] | подписи: [0, 1, 2, 3, 4]
столбцы: ['KZ', 'UZ'] | подписи: [2021, 2022, 2023, 2024, 2025]
совпало с исходной: True

== пропуск остался пропуском
пустых ячеек: 2 | в файле это: '2025,,'

== чего файл не помнит: тип
day,rate
2026-01-15,512.3
2026-02-15,519.8
просто прочитали: {'day': 'str', 'rate': 'float64'}
с parse_dates:    {'day': 'datetime64[us]', 'rate': 'float64'}

== JSON: тот же ряд, другой конверт
длина: 143 знаков, начало:
{"2021":{"KZ":8.0,"UZ":10.8},"2022":{"KZ":15.0,"UZ":11.4},"2023":{"KZ":14.5,"UZ":10.0}," …

== SQL: таблица уходит в базу и возвращается запросом
   year    KZ
0  2022  15.0
1  2023  14.5

Разбор

Одна строка туда, одна обратно

df.to_csv(путь) пишет таблицу целиком: заголовок из имён столбцов, подписи строк первым столбцом, пропуски — пустыми ячейками. pd.read_csv(путь) читает обратно. Никакого writer.writerow в цикле и никакого int(row["year"]) — типы pandas определяет сам по содержимому.

Сравните с двенадцатым уроком: там на то же самое уходило пятнадцать строк, и каждая была местом, где можно ошибиться.

Что файл не помнит

Файл — это текст. Он не помнит две вещи, и обе стоят ошибок.

Индекс. read_csv без подсказки считает первый столбец обычным столбцом: подписи строк стали столбцом year, а вместо них появились номера [0, 1, 2, 3, 4]. Скажите index_col="year" — и год снова подпись. Проверка back.equals(df) в примере печатает True именно после этого.

Тип. 2026-01-15 в файле — строка. pandas прочитает её строкой, и day.dt.month не сработает: у строки нет месяца. parse_dates=["day"] превращает её в дату, и в выводе видно, как str становится datetime64[us]. То же с кодами: dtype={"kod": str} спасает ведущий ноль, который иначе исчезнет вместе с превращением в число.

Пропуск в этом примере дорогу пережил: пустая ячейка прочиталась как NaN, а NaN записался пустой ячейкой — потому и пустых ячеек ровно две, те же самые. Но это свойство не столбца, а везения: в текстовом столбце пустая ячейка и пустая строка неразличимы, а слова NA, N/A, nan и ещё десяток read_csv по умолчанию считает пропусками — включая настоящее сокращение страны NA, Намибии. Чем считать пропуском, задают явно: na_values=[...] и keep_default_na=False.

Аргументы, которые закрывают почти любой чужой файл

Аргумент Когда нужен
sep=";" Excel в наших краях пишет через точку с запятой
decimal="," 512,3 — это число, а не строка
thousands=" " 1 200,50 — тоже число
encoding="utf-8-sig" BOM в начале файла из Excel
na_values=["н/д", "-"] у отправителя своя метка пропуска
usecols=[...] не тащить двадцать столбцов ради двух
index_col="year" подпись вместо лишнего столбца
parse_dates=["day"] текст превратить в дату
dtype={"kod": str} сохранить ведущий ноль
nrows=5 заглянуть в гигабайтный файл, не читая его целиком

Последний стоит привычки: прежде чем читать чужой файл целиком, прочитайте пять строк и посмотрите на dtypes. Это тридцать секунд и половина будущих недоразумений.

При записи важны три:

  • index=False — если подписи строк это просто номера, им нечего делать в файле;
  • float_format="%.2f" — чтобы в отчёте не было 8.041471000000001;
  • na_rep="н/д" — если получателю нужна метка, а не пустота.

JSON: когда read_json, а когда json.load

to_json и read_json работают, когда JSON — плоская таблица, и главный вопрос там — orient: как именно разложить строки и столбцы. В примере orient="index" дал словарь «год → строка».

Но ответ банка из восемнадцатого урока — не таблица: там вложенные объекты, служебная обёртка и метаданные. Такое проще разобрать json.load, как мы и делали, а таблицу построить из готового списка словарей. Для вложенных структур есть pd.json_normalize, который раскладывает их по столбцам.

Правило по сути, а не по владельцу: плоский JSON, который и так таблица, читайте read_json; вложенный — разбирайте json.load или json_normalize. Чужой API чаще отдаёт второе, свой to_json — первое, но решает структура, а не то, кто прислал файл.

SQL: to_sql и read_sql

Соединение берётся оттуда же, откуда в двадцать первом уроке, — из sqlite3. to_sql кладёт таблицу в базу, read_sql возвращает результат запроса уже таблицей.

Это не замена двадцать второму уроку, а его продолжение. Группировку, соединение и фильтр по-прежнему лучше отдавать базе: она умеет это на данных, которые в память не влезут, и вернёт вам маленький результат вместо большого. read_sql — это дверь между двумя мирами, а не повод разлюбить GROUP BY.

Два предупреждения. Значения в запрос подставляют через params=, а не форматированием строки — почему, показано там же, в уроке про SQLite. И про соединение: sqlite3 pandas принимает как есть, для PostgreSQL и прочих подойдёт SQLAlchemy или ADBC — вызов при этом выглядит одинаково, меняется только то, что вы передали в con.

Карта урока

Карта урока: файл, таблица и запрос

Скажите своими словами

Не подглядывая, ответьте вслух или на бумаге. Ответы — в конце урока.

  1. Что теряется при записи таблицы в CSV и как это вернуть при чтении?
  2. Когда JSON лучше читать json.load, а не read_json?
  3. Зачем оставлять GROUP BY базе, если pandas тоже умеет группировать?

Разминка

Три коротких шага перед заданием: предсказать, дописать, починить. Ответы — в конце урока, но сначала ответьте сами.

1. Предскажите. Файл пришёл из Excel, а прочитали его без аргументов. Что напечатает программа?

import io

import pandas as pd

TEXT = "year;rate\n2025;512,3\n2026;519,8\n"
df = pd.read_csv(io.StringIO(TEXT))
print(df)
print("форма:", df.shape, "| столбцы:", list(df.columns))

2. Заполните пропуск. Вместо ... поставьте аргументы, чтобы получилось два столбца и настоящие числа.

# точка с запятой — разделитель, запятая — дробная часть
import io

import pandas as pd

TEXT = "year;rate\n2025;512,3\n2026;519,8\n"
df = pd.read_csv(io.StringIO(TEXT), ...)
print("форма:", df.shape, "| столбцы:", list(df.columns))
print(dict(df.dtypes.astype(str)))
print(df["rate"].sum())

3. Почините. После круга «записали — прочитали» в таблице появился лишний столбец. Уберите его одним аргументом.

# откуда взялся Unnamed: 0
import io

import pandas as pd

df = pd.DataFrame({"kind": ["еда", "связь"], "amount": [1200, 4000]})
buffer = io.StringIO()
df.to_csv(buffer)
print("прочитали:", pd.read_csv(io.StringIO(buffer.getvalue())).columns.tolist())

Задание

Обязательное. Программа сама пишет «выгрузку от бухгалтера» — файл в кодировке utf-8-sig, с точкой с запятой, пробелом в тысячах, запятой в дробях, меткой н/д и лишним столбцом примечание:

год;категория;сумма;примечание
2024;еда;1 200,50;
2024;связь;4 000,00;тариф сменился
2025;еда;1 380,75;
2025;связь;н/д;счёт не пришёл

Прочитайте её одним вызовом read_csv так, чтобы: сумма стала числом, н/д — пропуском, примечание не читалось вовсе. Напечатайте таблицу, типы столбцов и число пропусков. Затем запишите чистый CSV без индекса, положите таблицу в SQLite и получите запросом сумму по годам.

Ожидаемый вывод:

прочитано:
    год категория    сумма
0  2024       еда  1200.50
1  2024     связь  4000.00
2  2025       еда  1380.75
3  2025     связь      NaN
типы: {'год': 'int64', 'категория': 'str', 'сумма': 'float64'}
пропусков: 1

чисто:
год,категория,сумма
2024,еда,1200.5
2024,связь,4000.0
2025,еда,1380.75
2025,связь,

по годам:
 год   сумма
2024 5200.50
2025 1380.75

Готово, когда: вывод совпадает построчно; всё разобрано аргументами read_csv, а не заменами в строках; сумма имеет тип float64, а не str; в чистом файле нет столбца с номерами строк; сумма по годам получена запросом, а не groupby.

На своих данных. Возьмите любой файл, который вам присылали, — выписку, выгрузку, отчёт. Прочитайте пять строк (nrows=5), посмотрите dtypes и подберите аргументы так, чтобы числа стали числами, а даты датами. Запишите результат чистым CSV.

По желанию.

  • Сохраните ту же таблицу в JSON тремя разными orient и сравните файлы.
  • Прочитайте свой файл с dtype={"kod": str} и убедитесь, что ведущий ноль на месте.
  • Положите таблицу в SQLite и задайте ей запрос с WHERE и GROUP BY — результат придёт таблицей.

Куда это встанет в проекте

Восьмой шаг: диск начинает говорить таблицами. saqtau.save — это to_csv с float_format и пустой ячейкой вместо пропуска, saqtau.loadread_csv с index_col="year", и с диска приходит сразу таблица, а не словарь, из которого её надо собирать. Отчёт тоже пишется to_csv(index=False).

Столбец note исчез: он существовал, чтобы словами объяснить метку n/a, а пустая ячейка говорит то же самое, и read_csv сама превращает её в NaN.

Сеть не тронута: банк отвечает одним небольшим JSON, и json.load для него по-прежнему правильный инструмент.

Долги. База у сводки пока не заведена: to_sql в проекте не используется, хотя данные для этого уже готовы. Дойдём до неё там же, где до сервера.

Ответы

Показать ответы

На вопросы

  1. Теряются индекс и типы. Индекс возвращается аргументом index_col, типы — parse_dates для дат и dtype для тех столбцов, где важно, чтобы текст остался текстом. Пропуски, в отличие от них, переживают дорогу сами.
  2. Когда JSON — не таблица: вложенные объекты, служебная обёртка, метаданные вокруг данных. Тогда его разбирает json.load, а таблица строится из готового списка словарей или через pd.json_normalize.
  3. Потому что база работает с данными, которые не обязаны помещаться в память, и вернёт маленький результат вместо большого. Тащить миллион строк в pandas, чтобы получить пять, — это оплатить дорогу тому, что можно было оставить дома.

К разминке

  1. Разделителем по умолчанию считается запятая, поэтому строка 2025;512,3 разрезана по запятой на 2025;512 и 3. Первый кусок стал подписью строки, второй — значением, и все числа в таблице неверные. Программа не упала: она «успешно» прочитала чужой файл неправильно.
          year;rate
2025;512          3
2026;519          8
форма: (2, 1) | столбцы: ['year;rate']
  1. sep=";", decimal=",". Первый аргумент разрезает строку по правильному знаку, второй объясняет, что запятая внутри числа — дробная часть.
import io

import pandas as pd

TEXT = "year;rate\n2025;512,3\n2026;519,8\n"
df = pd.read_csv(io.StringIO(TEXT), sep=";", decimal=",")
print("форма:", df.shape, "| столбцы:", list(df.columns))
print(dict(df.dtypes.astype(str)))
print(df["rate"].sum())
форма: (2, 2) | столбцы: ['year', 'rate']
{'year': 'int64', 'rate': 'float64'}
1032.1
  1. to_csv(buffer, index=False). Подписи строк здесь — просто номера, и в файле им делать нечего; попав туда, они читаются как столбец без имени и получают его: Unnamed: 0.
import io

import pandas as pd

df = pd.DataFrame({"kind": ["еда", "связь"], "amount": [1200, 4000]})
buffer = io.StringIO()
df.to_csv(buffer)
print("с индексом:", pd.read_csv(io.StringIO(buffer.getvalue())).columns.tolist())
clean = io.StringIO()
df.to_csv(clean, index=False)
print("без индекса:", pd.read_csv(io.StringIO(clean.getvalue())).columns.tolist())
с индексом: ['Unnamed: 0', 'kind', 'amount']
без индекса: ['kind', 'amount']

К заданию

Все четыре особенности файла закрываются аргументами одного вызова: sep=";", decimal=",", thousands=" ", encoding="utf-8-sig", na_values=["н/д"] и usecols для трёх нужных столбцов. Соблазн вычистить файл заменами в тексте — ошибка: замена , на . испортит любую запятую внутри примечания, а метка пропуска у следующего отправителя будет другой.

Сумма по годам считается запросом SELECT год, SUM(сумма) ... GROUP BY год, потому что данные уже в базе, а база это умеет.

Источники

Если вы нашли ошибку или опечатку в тексте статьи, то сообщите нам об этом

Проверить задание

Сначала решите и запустите в VS Code — редактор покажет ошибку на месте. Готовое решение вставьте сюда. Проверяет модель: она укажет на ошибку, но не даст готовый ответ.

Чтобы проверить, нужно войти. Войти

Комментарии (0)

Пока нет комментариев. Будьте первым.