Baza wiedzy / Python

pandas read_json: StringIO, orient i dane zagnieżdżone

Rozdzielam tekst JSON, ścieżkę pliku i strukturę rekordów. Pokazuję kompletne przykłady z typami i kontrolami, które wykrywają poprawny JSON o niewłaściwym kształcie.

Kajo RudzińskiOpublikowano Aktualizacja 11 min czytania

Czytnik wyjmuje wewnętrzną tackę rekordów z większej skrzyni i układa dane w płaską tabelę.

pandas.read_json zamienia dane zapisane w JSON na obiekt pandas, najczęściej DataFrame. W praktyce najwięcej problemów pojawia się przed pierwszym obliczeniem: czy przekazuję ścieżkę, czy tekst, gdzie w odpowiedzi leżą rekordy i jaki kształt tabeli chcę uzyskać. Ja zaczynam od tych pytań. Poprawny składniowo JSON potrafi zawierać zupełnie inną strukturę niż oczekiwany raport.

Poniższe dane są syntetyczne. Przykłady sprawdziłem w Pythonie 3.14.4, pandas 3.0.6 i NumPy 2.5.4. Każdy blok uruchomisz osobno. Używam małych danych w pamięci lub katalogu tymczasowym, więc do ćwiczenia nie potrzebujesz działającego API, tokenu ani pliku pobieranego z przypadkowej strony. Kod pokazuje odczyt i jego kontrolę; nie udaje kompletnego klienta dowolnej usługi.

Tekst JSON przekazuję przez StringIO

Jeżeli masz tekst zawierający JSON, opakuj go w StringIO. Otrzymasz obiekt zachowujący się dla czytnika jak plik tekstowy. Zwykły napis przekazany jako pierwszy argument oznacza ścieżkę lub obsługiwany adres. Bezpośrednie przekazywanie literałów JSON było przestarzałe od pandas 2.1, a usunięto je w pandas 3.0. Starszy przykład może więc próbować otworzyć plik o nazwie przypominającej całą tablicę danych.

# example: records
from io import StringIO
import pandas as pd

payload = '[{"id":"001","units":2},{"id":"002","units":null}]'
df = pd.read_json(
    StringIO(payload), orient="records",
    dtype={"id": "string", "units": "Int64"}, convert_dates=False,
)
print(df["id"].tolist())
print(df["units"].tolist())
print(df.shape, int(df["units"].isna().sum()))

Wyniki to ['001', '002'], [2, <NA>] i (2, 2) 1. Identyfikatory pozostały napisami, a brak liczby jest reprezentowany przez nullable integer Int64. Nie zamieniam null na zero, bo przykład nie mówi, że brak pomiaru oznacza pomiar zerowy. Zwróć uwagę również na convert_dates=False: tutaj żadne pole nie ma być automatycznie interpretowane jako data.

Dokumentacja read_json pokazuje rozróżnienie wejść i dostępne parametry, a zmiany pandas 3.0 opisują usunięte stare zachowanie. StringIO nie konwertuje struktury JSON i nie wybiera z niej rekordów. Rozwiązuje wyłącznie problem przekazania tekstu do interfejsu oczekującego źródła danych.

Plik i zawartość pliku mają inne role

Jeśli dane już leżą na dysku, podaj ścieżkę. Nie trzeba najpierw odczytywać całego pliku do napisu i opakowywać go ponownie. Dla krótkiej próbki takie rozwiązanie działa, ale powiela operacje i może zajmować dodatkową pamięć. Ścieżkę buduję przez Path; sam format JSON pozostaje taki sam jak w przykładzie tekstowym.

# example: file
from pathlib import Path
from tempfile import TemporaryDirectory
import pandas as pd

with TemporaryDirectory(prefix="json-reading-") as folder:
    path = Path(folder) / "records.json"
    path.write_text('[{"city":"Łódź","units":0}]', encoding="utf-8")
    df = pd.read_json(path, orient="records", encoding="utf-8")
    print(df.loc[0, "city"], int(df.loc[0, "units"]))

Wynik to Łódź 0. Próbka powstaje wyłącznie w katalogu tymczasowym, który zostanie usunięty po bloku. W rzeczywistym imporcie uzgodnij kodowanie i lokalizację pliku. Błąd odczytu ścieżki nie jest błędem struktury JSON, a błąd dekodowania nie oznacza automatycznie, że dokument zawiera zły zestaw kolumn.

Funkcja obsługuje również określone adresy URL i magazyny plików, ale to nie zwalnia z obsługi pobrania. API może zwrócić stronę błędu, kopertę z metadanymi albo tylko pierwszą stronę wyników. W procesie opartym na API oddzielam pozyskanie odpowiedzi od jej przekształcenia w tabelę. Pozwala to jasno sprawdzić status odpowiedzi, kompletność stronicowania i właściwe pole z rekordami.

Orient opisuje układ danych tabelarycznych

orient mówi, jak struktura JSON reprezentuje wiersze, kolumny i indeks. records oznacza listę obiektów, z których każdy odpowiada wierszowi. index oznacza obiekt, którego klucze są etykietami wierszy, a wartości obiektami pól. columns odwraca ten układ: zewnętrzne klucze oznaczają kolumny. Wybór parametru powinien odpowiadać temu, co naprawdę zapisano.

Orient Główny kształt dla DataFrame Informacja o indeksie
records lista obiektów wierszy brak etykiet indeksu
index etykieta wiersza, potem pola w kluczach zewnętrznych
columns nazwa kolumny, potem etykiety w kluczach wewnętrznych
split osobne index, columns, data osobna lista
values tablica tablic wartości bez nazw i etykiet
table schema oraz data zależnie od schematu
# example: orientations
from io import StringIO
import pandas as pd

by_index = '{"row_a":{"units":2},"row_b":{"units":3}}'
by_split = '{"index":["row_a","row_b"],"columns":["units"],"data":[[2],[3]]}'
a = pd.read_json(StringIO(by_index), orient="index")
b = pd.read_json(StringIO(by_split), orient="split")
print(a.equals(b), a.index.tolist(), a["units"].tolist())

Wynik True ['row_a', 'row_b'] [2, 3] potwierdza, że dwa różne zapisy odtworzyły tę samą tabelę. Domyślną orientacją dla DataFrame jest columns, ale w przykładach jawnie podaję format. Zmniejsza to zależność czytelnika od domyślnych zachowań i ułatwia porównanie importu z kodem, który wcześniej zapisał dane.

Nie wybieram orientacji metodą prób aż do zniknięcia błędu. Ważny jest wynik: nazwy kolumn, liczba wierszy i typy wartości w komórkach. Tabela z jedną kolumną pełną słowników może być technicznie poprawna, a jednocześnie bezużyteczna dla planowanego grupowania. Zapis wyniku przez to_json powinien używać uzgodnionego formatu po obu stronach wymiany.

Kopertę API otwieram przed tworzeniem tabeli

Częsty format odpowiedzi to obiekt z kluczem data, pod którym leży lista rekordów, oraz dodatkowymi polami opisującymi odpowiedź. To nie jest bezpośrednio lista records. Parametr orient nie pełni roli ścieżki do dowolnego miejsca w dokumencie. W starszym przykładzie ta różnica została pominięta i czytelnik dostawał inną tabelę niż obiecywał opis.

# example: envelope
import json
import pandas as pd

payload = '{"data":[{"id":"A","buyer":{"city":"Łódź"}},{"id":"B","buyer":{"city":"Gdańsk"}}],"page":1}'
document = json.loads(payload)
records = document["data"]
if not isinstance(records, list):
    raise ValueError("data must be a list")
df = pd.json_normalize(records)
print(df.columns.tolist())
print(df["buyer.city"].tolist())

Dostaniesz ['id', 'buyer.city'] oraz ['Łódź', 'Gdańsk']. json.loads zamienia tekst na zwykłe struktury Pythona. Następnie wybieram listę rekordów i spłaszczam zagnieżdżone pola za pomocą json_normalize. Dokumentacja tej funkcji opisuje również record_path i metadane. Dla już płaskiej listy słowników wystarczy często DataFrame(records).

Spłaszczenie słownika w jednym rekordzie nie jest tym samym co rozwinięcie listy elementów na wiele wierszy. Jeśli zgłoszenie ma kilka pozycji, po rozwinięciu wiersz może oznaczać pozycję zamiast zgłoszenia. Wtedy zwykłe policzenie wierszy zwiększy wynik. Zanim wybiorę record_path, określam jednostkę tabeli i sprawdzam, które dane nadrzędne muszą zostać powielone przy każdym elemencie.

Typy i braki sprawdzam po odczycie

JSON rozróżnia napisy, liczby, wartości logiczne i null, ale nie przechowuje pełnego modelu typów pandas. Nie ma osobnego typu „identyfikator tekstowy” ani ogólnej informacji o nullable integer. W przykładzie określam dtype, aby czytelnik i importer mieli tę samą interpretację. Następnie kontroluję wynik, zamiast zakładać, że sama konfiguracja zastąpi wszystkie reguły jakości.

Warto pamiętać o różnicy między polem pominiętym a polem z jawnym null. Po zamianie listy rekordów na tabelę obydwa przypadki mogą stać się brakiem w tej samej kolumnie. Jeśli rozróżnienie ma znaczenie, sprawdź obecność klucza na surowych rekordach przed utworzeniem DataFrame. Po utracie tej informacji zwykłe isna() nie odtworzy sposobu zapisu w źródle.

Przy niejednorodnych danych odczyt może utworzyć typ szerszy niż oczekujesz. Sprawdź dtypes, liczbę braków i przykładowe wartości, zwłaszcza identyfikatory przypominające liczby. Zera wiodące utracone przez konwersję liczbową nie wrócą po późniejszym astype("string"). Typ identyfikatora najlepiej ustalić przed odczytem albo zachować go przy wcześniejszym parsowaniu struktury.

Nie zastępuję wszystkich braków pustym napisem, żeby tabela wyglądała czyściej. Kolumna liczbowa może przez to zmienić typ albo przestać nadawać się do obliczeń. Dla konkretnego znaczenia braku wybierz osobną regułę i sprawdź, jak wpłynie na wynik. Syntaktycznie poprawna tabela nie dowodzi jeszcze, że pomiar zerowy i nieznany pomiar pozostały odróżnialne.

Daty wolę konwertować jawnie

read_json może konwertować określone kolumny dat, między innymi za pomocą convert_dates. Przy źródle o ścisłym kontrakcie często rozdzielam odczyt od konwersji. Widzę wtedy oryginalne napisy i mogę osobno określić format, strefę oraz zachowanie dla błędnych wartości. To ułatwia diagnozę, gdy producent danych nagle zmieni zapis daty.

# example: dates
from io import StringIO
import pandas as pd

payload = '[{"observed_at":"2026-10-24T10:00:00+02:00"},{"observed_at":"2026-10-24T08:00:00Z"}]'
df = pd.read_json(StringIO(payload), orient="records", convert_dates=False)
df["observed_at"] = pd.to_datetime(df["observed_at"], format="ISO8601", utc=True, errors="raise")
print(df["observed_at"].dt.strftime("%Y-%m-%d %H:%M %z").tolist())
print(df.loc[0, "observed_at"] == df.loc[1, "observed_at"])

Obydwa napisy dadzą 2026-10-24 08:00 +0000, a porównanie zwróci True. Opisują tę samą chwilę z różnymi przesunięciami strefowymi. Jawne utc=True sprowadza je do wspólnej reprezentacji. Dla dat pozbawionych informacji o strefie musisz najpierw wiedzieć, jakiej strefy dotyczą; nie dopisuj UTC do lokalnego czasu wyłącznie po to, żeby pozbyć się błędu.

Dokumentacja to_datetime opisuje format i obsługę błędów. errors="coerce" jest użyteczne, jeśli chcesz oznaczyć niepoprawne daty jako braki i je rozliczyć. W tym ćwiczeniu używam raise, bo dane mają ścisły format. Liczbowe timestampy wymagają dodatkowo zgodnej jednostki: sekundy i milisekundy opisują bardzo różne chwile.

Oddzielne karty przechodzą przez czytnik i trafiają do kilku małych tacek, pokazując przetwarzanie partiami.

JSON Lines czytam przez lines i chunksize

Zwykła tablica JSON zawiera jeden dokument, często rozpisany na wiele linii dla czytelności. JSON Lines zawiera osobny dokument w każdym wierszu. Sam fakt, że plik ma dużo linii, nie wystarcza do włączenia lines=True. Najpierw sprawdzam format. Ustawienie powinno odpowiadać sposobowi zapisu, a nie temu, jak duży wydaje się plik.

# example: chunks
from io import StringIO
import pandas as pd

payload = '{"id":"A","units":0}\n{"id":"B","units":null}\n{"id":"C","units":3}\n'
sizes = []
rows = missing = total = 0
with pd.read_json(StringIO(payload), lines=True, chunksize=2, dtype={"units": "Int64"}) as reader:
    for chunk in reader:
        sizes.append(len(chunk))
        rows += len(chunk)
        missing += int(chunk["units"].isna().sum())
        total += int(chunk["units"].sum())
print(sizes, rows, missing, total)

Wynik to [2, 1] 3 1 3: partie mają dwa i jeden rekord, łącznie są trzy rekordy, jeden brak i suma znanych wartości trzy. Nie odrzuciliśmy zera. chunksize w tym interfejsie wymaga lines=True; zwraca czytnik kolejnych partii. W przykładzie cały mały tekst już leży w pamięci, natomiast rzeczywisty plik możesz podać przez ścieżkę.

Suma znanych wartości nie jest oszacowaniem brakującego pomiaru. Raportujemy brak osobno. Przy pustym zbiorze lub samych brakach sama suma zero może być myląca, dlatego zachowuję również liczbę rekordów i braków. Jeśli obliczasz średnią między partiami, połącz sumy i liczby znanych obserwacji; nie uśredniaj bezpośrednio średnich z partii różnej wielkości.

Błędy parsera oddzielam od błędów danych

W raporcie kontroli zapisuję liczbę odebranych rekordów, liczbę rekordów przyjętych i powód każdego odrzucenia. Te liczby powinny się uzgadniać, aby pominięta partia nie wyglądała jak poprawny pusty wynik.

Niepoprawny dokument może wywołać ValueError w read_json. json.JSONDecodeError jest wyjątkiem parsera standardowego json, a nie obietnicą identycznego typu błędu dla każdego parsera pandas. Brak pliku to jeszcze inny problem. Przy obsłudze błędów zachowuję etap i kontekst, zamiast zamieniać wszystkie awarie na pustą tabelę.

# example: malformed
from io import StringIO
import pandas as pd

try:
    pd.read_json(StringIO('[{"id":}]'), orient="records")
except ValueError:
    print("invalid JSON")

Wynik to invalid JSON. Nie wpisuję do przykładu zależnego od wersji pełnego tekstu wyjątku, tylko sprawdzam jego rodzaj. Poprawna składnia też nie wystarczy: dokument może mieć brakujące pola, zdublowane identyfikatory albo ujemne pomiary. Te reguły należą do kontroli danych po parsowaniu lub do walidacji surowej struktury.

Nie używam eval do czytania JSON. To inny mechanizm o innym znaczeniu i niepotrzebnym ryzyku wykonania kodu. Standardowy moduł json służy do parsowania dokumentu na struktury Pythona. Zewnętrzne źródło warto również ograniczyć pod względem rozmiaru; sam poprawny format nie gwarantuje, że dowolny dokument zmieści się w pamięci procesu.

Sprawdzam kontrakt tabeli przed obliczeniem wyniku

Dla importu powtarzanego codziennie zapisuję oczekiwane kolumny, ich znaczenie i dopuszczalne braki. Kontroluję także liczbę rekordów oraz to, czy źródło zwraca pełen zakres. Jedna poprawna strona stronicowanego API nadal może być tylko fragmentem danych. W przykładzie lokalnym możemy policzyć całość ręcznie, więc wykorzystuję tę możliwość jako punkt odniesienia.

# example: contract
from io import StringIO
import json
import pandas as pd

payload = '[{"id":"001","units":0},{"id":"002","units":3}]'
records = json.loads(payload)
if not isinstance(records, list) or not records:
    raise ValueError("Expected a nonempty list")
if any(not isinstance(row, dict) or set(row) != {"id", "units"} for row in records):
    raise ValueError("Unexpected record fields")
if any(type(row["id"]) is not str or type(row["units"]) is not int for row in records):
    raise ValueError("Expected string identifiers and integer units")
df = pd.read_json(StringIO(payload), orient="records", dtype={"id":"string","units":"Int64"})
if set(df.columns) != {"id", "units"}:
    raise ValueError("Unexpected columns")
if df["id"].isna().any() or df["id"].eq("").any() or df["id"].duplicated().any():
    raise ValueError("Invalid identifiers")
if df["units"].isna().any() or df["units"].lt(0).any():
    raise ValueError("Invalid units")
print(len(df), int(df["units"].sum()))

Wynik 2 3 jest końcem małego, sprawdzalnego procesu. W tym kontrakcie nie dopuszczam braków liczby, choć pierwszy przykład pokazywał ich zachowanie. To świadomie inna reguła zadania. Nie kopiuj takiego warunku bez sprawdzenia, czy nie odrzuci poprawnych nieznanych pomiarów w Twoim źródle. Metoda odczytu i akceptowalność danych są osobnymi decyzjami.

W tym przykładzie sprawdzam też surowe typy przed konwersją. Samo dtype nie jest walidatorem: niezgodna wartość może pozostać w szerszym typie, a wartość logiczna może zostać przekształcona w liczbę. Warunek type(...) is int celowo odrzuca wartości logiczne, liczby ułamkowe i napisy. Ten kontrakt wymaga liczby całkowitej już w źródłowym JSON.

Zmiana schematu wymaga świadomej reakcji

Załóżmy, że producent jutro doda pole source. Ścisłe porównanie zestawu kolumn zatrzyma import, choć dotychczasowe pola nadal są poprawne. To może być właściwa reakcja przy uzgodnionym pliku wymiany. Przy rozwijanym API czasem rozsądniejszy jest warunek obecności wymaganych kolumn, zapis nowych nazw w raporcie kontroli i jawny wybór kolumn potrzebnych do dalszej analizy. Wybierz tę politykę przed awarią.

Inaczej traktuję usunięcie wymaganego pola albo zmianę jego znaczenia. Jeśli units nagle opisuje opakowania zamiast sztuk, identyczny typ liczbowy i identyczna nazwa nie wykryją problemu. Dlatego umowa dotycząca danych obejmuje również jednostkę pomiaru, zakres dat i poziom szczegółowości wiersza. Walidacja techniczna wspiera tę umowę, ale nie odgaduje intencji producenta na podstawie samego JSON.

Pusty wynik też wymaga interpretacji. Może oznaczać dzień bez zdarzeń, filtr wykluczający wszystkie rekordy albo niepełną odpowiedź źródła. Nie dodaję sztucznego wiersza z zerem, żeby raport wyglądał poprawnie. Ustalam, czy pusta odpowiedź jest dopuszczalna, i zapisuję liczbę rekordów przed przekształceniem. Jeżeli pusty zbiór ma przejść dalej, tworzę oczekiwany schemat jawnie i nadal przekazuję informację, że nie było obserwacji.

Przy przetwarzaniu partiami kontrola powinna działać dla każdej partii oraz dla całego importu. Duplikat identyfikatora może wystąpić dopiero pomiędzy dwiema partiami, więc samo duplicated() wewnątrz każdej z nich go nie zobaczy. Przechowywanie wszystkich widzianych identyfikatorów kosztuje pamięć. Dla dużych danych trzeba dobrać rozwiązanie do skali, na przykład ograniczenie unikalności w docelowym magazynie, zamiast obiecywać stałe zużycie pamięci całego procesu.

Zapisuję również informację o wersji biblioteki, oczekiwanej orientacji i wykonanych kontrolach. Dzięki temu kolejna osoba może odtworzyć decyzje importera bez analizowania przypadkowego zrzutu terminala. Nie wkładam do takiej notatki pełnej odpowiedzi z danymi klientów ani sekretów dostępu. Do odtworzenia błędu często wystarcza mała anonimowa próbka zachowująca problematyczny kształt oraz komunikat wskazujący etap, na którym kontrola się zatrzymała.

Przed aktualizacją pandas uruchamiam te same małe próbki i porównuję wynik, typy oraz reguły odrzucania. Właśnie tak można wykryć zmianę obsługi tekstowego argumentu, zanim dotknie cyklicznego importu. Sam fakt, że proces nadal kończy się bez wyjątku, jest słabym testem. Warto zachować przypadek z zerem, brakiem, tekstowym identyfikatorem i datą ze strefą, bo pokazują różne rodzaje niezamierzonej konwersji.

Ćwiczenie: zmień kształt, typ i kompletność wejścia

Zacznij od ostatniego przykładu. Powtórz identyfikator, usuń wymagane pole i wpisz ujemną liczbę. Każdy wariant uruchamiaj osobno i ustal, na którym etapie powinien się zatrzymać. Potem dodaj dodatkowe pole: nasz ścisły kontrakt je odrzuci. W innym procesie można je zaakceptować, ale warto wtedy zapisać zmianę schematu i sprawdzić, czy nie wymaga nowej interpretacji.

Na koniec opakuj rekordy pod kluczem data i spróbuj świadomie przejść ścieżką z json.loads oraz wyborem listy. Sprawdź też pustą listę. Bez rekordów nie da się odgadnąć wszystkich kolumn z samych danych, więc decyzja o pustym wyniku może wymagać jawnego schematu. Kiedy potrafisz przewidzieć te przypadki, read_json staje się kontrolowanym wejściem do analizy, a nie tylko sposobem na uzyskanie jakiejkolwiek tabeli.

Najważniejsze w pigułce

Dlaczego read_json traktuje mój tekst JSON jak nazwę pliku?

W pandas 3 tekst z danymi należy opakować w io.StringIO. Zwykły napis w path_or_buf reprezentuje ścieżkę albo adres, a bezpośrednie literały JSON zostały wycofane.

Czy orient='records' wybierze listę ukrytą pod kluczem data?

Nie. Orient opisuje strukturę tabeli, nie ścieżkę do zagnieżdżonych rekordów. Najpierw wybierz tę listę, na przykład przez json.loads, a potem użyj DataFrame lub json_normalize.

Kiedy mogę użyć chunksize?

Przy JSON Lines z lines=True. Otrzymujesz czytnik kolejnych partii; zwykła tablica JSON wymaga innego sposobu przetwarzania.

Przy pracy nad artykułem użyto w zautomatyzowany sposób sztucznej inteligencji. Jeśli też chcesz tak umieć, sprawdź ten projekt.

Kajo Rudziński

Newsletter KajoData

Zostańmy w kontakcie.

Piszę o analizie danych, uczeniu się i zmianie pracy. Dzielę się tym, co pomaga mi ogarniać technologię i zawodowe decyzje. Jeśli lubisz ten sposób myślenia, zapraszam do mojego newslettera.