Pandas 3.0: повний посібник з міграції та нових можливостей

Повний посібник з міграції на pandas 3.0 — новий рядковий тип, обов'язковий Copy-on-Write, мікросекундна роздільна здатність, pd.col(), інтеграція з Apache Arrow та покрокова стратегія оновлення.

21 січня 2026 року команда pandas офіційно випустила pandas 3.0 — і це, чесно кажучи, найбільше мажорне оновлення за останні роки. Але це не просто «ще одна версія з новими функціями». Pandas 3.0 фундаментально змінює внутрішню поведінку бібліотеки, на яку покладаються мільйони дата-інженерів, аналітиків та науковців з даних по всьому світу.

Якщо ви працюєте з Python-екосистемою обробки даних — ця версія безпосередньо вплине на ваш код, ваші пайплайни та, можливо, навіть ваші звички.

У цьому посібнику ми детально розглянемо всі ключові зміни: новий рядковий тип даних за замовчуванням, обов'язковий режим Copy-on-Write, зміну роздільної здатності datetime, новий синтаксис виразів, інтеграцію з Apache Arrow та покрокову стратегію міграції. Кожен розділ містить практичні приклади коду, пояснення причин змін і конкретні поради. Що ж, давайте розбиратися.

Новий рядковий тип даних за замовчуванням

Одна з найпомітніших змін у pandas 3.0 — те, як бібліотека тепер обробляє текстові дані. Раніше рядки зберігалися з типом object, що було, м'яко кажучи, неефективно з точки зору пам'яті та продуктивності. Тепер pandas 3.0 використовує спеціалізований тип str (він же StringDtype) як тип за замовчуванням для текстових стовпців.

Ця зміна — прямий наслідок глибшої інтеграції з бекендом PyArrow, який забезпечує набагато ефективніше зберігання рядків у пам'яті.

Як було раніше (pandas 2.x)

У попередніх версіях створення DataFrame з текстовими даними автоматично призначало тип object. Кожен рядок зберігався як окремий Python-об'єкт, що спричиняло значне споживання пам'яті та уповільнювало операції:

# pandas 2.x поведінка
import pandas as pd

df = pd.DataFrame({"name": ["Олена", "Максим", "Ірина"],
                    "city": ["Київ", "Львів", "Одеса"]})
print(df.dtypes)
# name    object
# city    object
# dtype: object

Як стало тепер (pandas 3.0)

У pandas 3.0 ті самі дані автоматично отримують тип str, побудований на базі PyArrow:

# pandas 3.0 поведінка
import pandas as pd

df = pd.DataFrame({"name": ["Олена", "Максим", "Ірина"],
                    "city": ["Київ", "Львів", "Одеса"]})
print(df.dtypes)
# name    str
# city    str
# dtype: object

# Перевірка внутрішнього типу
print(type(df["name"].dtype))
# <class 'pandas.core.arrays.string_.StringDtype'>

Вплив на існуючий код

Ця зміна може зламати код, який явно перевіряє тип object. І повірте, таких місць у типовому проекті буває більше, ніж здається. Ось що потрібно оновити:

# Старий код (pandas 2.x) — ЗЛАМАЄТЬСЯ
if df["name"].dtype == "object":
    print("Це текстовий стовпець")

# Новий код (pandas 3.0) — ПРАВИЛЬНО
if pd.api.types.is_string_dtype(df["name"]):
    print("Це текстовий стовпець")

# Або альтернативний варіант
if df["name"].dtype == "str" or df["name"].dtype == pd.StringDtype():
    print("Це текстовий стовпець")

Поведінка з пропущеними значеннями

Важлива перевага нового типу str — коректна обробка пропущених значень. Замість NaN (який технічно є числом з плаваючою комою — і це завжди виглядало дивно для текстових даних) тепер використовується pd.NA:

import pandas as pd
import numpy as np

df = pd.DataFrame({"name": ["Олена", None, "Ірина"]})

# pandas 2.x: значення None перетворювалось на NaN (float)
# pandas 3.0: значення None стає pd.NA
print(df["name"].isna())
# 0    False
# 1     True
# 2    False

print(df["name"][1])
# <NA>   (замість NaN)

Поради з міграції рядкових типів

  • Замініть усі перевірки dtype == "object" на pd.api.types.is_string_dtype().
  • Якщо ваш код залежить від NaN у рядкових стовпцях, оновіть його для роботи з pd.NA.
  • Використовуйте df.convert_dtypes() для автоматичного перетворення типів під час міграції.
  • Зверніть увагу на серіалізацію: Parquet та Arrow працюватимуть без змін, але деякі CSV-пайплайни можуть потребувати оновлення.

Copy-on-Write (CoW) як обов'язковий режим

Copy-on-Write (CoW) — механізм управління пам'яттю, який кардинально змінює спосіб роботи pandas з даними. У pandas 3.0 він став єдиним доступним режимом. Що це означає на практиці? Будь-яка операція індексування тепер повертає представлення (view) даних, а фактичне копіювання відбувається лише тоді, коли ви намагаєтесь ці дані модифікувати.

Цей підхід значно зменшує споживання пам'яті та підвищує продуктивність. Але є нюанси.

Що таке Copy-on-Write і чому це важливо

У попередніх версіях pandas поведінка копіювання була, скажімо чесно, непередбачуваною. Іноді операція повертала копію, іноді — представлення, і це залежало від внутрішньої реалізації (яку мало хто розумів до кінця). Результат — численні помилки з ненавмисною модифікацією оригінальних даних та горезвісне попередження SettingWithCopyWarning.

CoW усуває цю невизначеність раз і назавжди: тепер кожна операція індексування повертає представлення, і лише при спробі запису створюється копія.

Ланцюгове присвоювання більше не працює

Це, мабуть, найважливіший наслідок CoW — ланцюгове присвоювання (chained assignment) більше не модифікує оригінальний DataFrame. Якщо ви хоч раз натрапляли на баг через ланцюгове присвоювання, то зрозумієте, чому це хороша новина:

import pandas as pd

df = pd.DataFrame({"A": [1, 2, 3], "B": [4, 5, 6]})

# СТАРИЙ КОД — працював у pandas 2.x (з попередженням)
# Тепер НЕ МОДИФІКУЄ оригінальний df!
df["A"][0] = 100
print(df["A"][0])
# 1   (значення НЕ змінилось!)

# Також не працює:
df[df["A"] > 1]["B"] = 99
print(df)
#    A  B
# 0  1  4
# 1  2  5
# 2  3  6
# (нічого не змінилось)

Використання .loc замість ланцюгового присвоювання

Правильний спосіб модифікації даних у pandas 3.0 — використання .loc, .iloc або інших явних методів:

import pandas as pd

df = pd.DataFrame({"A": [1, 2, 3], "B": [4, 5, 6]})

# ПРАВИЛЬНО — використовуємо .loc
df.loc[0, "A"] = 100
print(df["A"][0])
# 100   (значення змінилось!)

# ПРАВИЛЬНО — умовне присвоювання через .loc
df.loc[df["A"] > 1, "B"] = 99
print(df)
#      A   B
# 0  100   4
# 1    2  99
# 2    3  99

Переваги продуктивності

CoW приносить відчутні переваги у продуктивності, бо зменшує кількість непотрібних копій. Ось наочний приклад:

import pandas as pd
import numpy as np

# Створюємо великий DataFrame
df = pd.DataFrame(np.random.randn(1_000_000, 50))

# Зрізи тепер завжди повертають представлення (view)
# Копія створюється лише при модифікації
subset = df[["A", "B", "C"]]  # Без копіювання!
filtered = df[df[0] > 0]       # Без копіювання!

# Копія створюється лише коли ви змінюєте subset
subset.loc[0, "A"] = 999  # Тут відбувається копіювання

SettingWithCopyWarning видалено

Нарешті! Оскільки CoW усуває невизначеність у поведінці копіювання, попередження SettingWithCopyWarning було повністю видалено з pandas 3.0. Якщо ваш код придушував це попередження (через pd.options.mode.chained_assignment = None), можете сміливо видалити ці рядки.

# Цей код більше не потрібен у pandas 3.0
# pd.options.mode.chained_assignment = None   # видаліть це
# pd.set_option("mode.chained_assignment", None)  # і це також

# Також видалено опцію copy_on_write, оскільки CoW тепер єдиний режим
# pd.options.mode.copy_on_write = True  # більше не існує

Поради з міграції CoW

  • Знайдіть усі випадки ланцюгового присвоювання та замініть на .loc / .iloc.
  • Видаліть усі налаштування pd.options.mode.chained_assignment.
  • Видаліть явні виклики .copy(), які раніше використовувались для уникнення попереджень — тепер вони зайві.
  • Протестуйте код на pandas 2.3 з pd.options.mode.copy_on_write = True перед переходом на 3.0.

Нова роздільна здатність дати та часу

Ще одна фундаментальна зміна стосується обробки часових міток. Pandas 3.0 змінює роздільну здатність за замовчуванням з наносекунд на мікросекунди.

Чому? Причина досить прагматична: наносекундна точність обмежувала діапазон дат приблизно від 1677 до 2262 року. Для більшості задач це не проблема, але якщо ви коли-небудь працювали з історичними даними або фінансовим моделюванням на довгий горизонт — ви точно натрапляли на це обмеження.

Зміна з наносекунд на мікросекунди

Мікросекундна роздільна здатність розширює діапазон дат до приблизно 290 000 років в обидва боки від нашої ери — цього вистачить навіть найамбітнішим проектам:

import pandas as pd

# pandas 2.x
# ts = pd.Timestamp("2026-01-21")
# ts.unit  →  'ns'

# pandas 3.0
ts = pd.Timestamp("2026-01-21")
print(ts.unit)
# 'us'   (мікросекунди)

# DatetimeIndex також за замовчуванням у мікросекундах
dti = pd.to_datetime(["2026-01-21", "2026-06-15", "2026-12-31"])
print(dti.dtype)
# datetime64[us]   (замість datetime64[ns])

Вплив на операції з часовими мітками

Ця зміна впливає на арифметику з часовими мітками та порівняння, особливо якщо ваш код явно працював з наносекундами:

import pandas as pd
import numpy as np

# Створення DatetimeIndex
dates = pd.date_range("2026-01-01", periods=5, freq="D")
print(dates.dtype)
# datetime64[us]

# Timedelta також у мікросекундах
td = pd.Timedelta("1 day")
print(td.unit)
# 'us'

# Перетворення з numpy — зверніть увагу на роздільну здатність
np_date = np.datetime64("2026-01-21", "ns")  # наносекунди
pd_ts = pd.Timestamp(np_date)
print(pd_ts.unit)
# 'ns'   (зберігає оригінальну роздільну здатність при явному вказанні)

Використання as_unit() для контролю роздільної здатності

Метод as_unit() дозволяє явно конвертувати між різними роздільними здатностями. Це особливо корисно під час міграції:

import pandas as pd

ts = pd.Timestamp("2026-01-21 14:30:00.123456789")

# Конвертація до різних роздільних здатностей
print(ts.as_unit("ns"))   # 2026-01-21 14:30:00.123456789
print(ts.as_unit("us"))   # 2026-01-21 14:30:00.123456
print(ts.as_unit("ms"))   # 2026-01-21 14:30:00.123
print(ts.as_unit("s"))    # 2026-01-21 14:30:00

# Для Series та DataFrame
df = pd.DataFrame({
    "timestamp": pd.to_datetime(["2026-01-21", "2026-06-15"])
})
# Конвертація стовпця до наносекунд (якщо потрібно для сумісності)
df["timestamp_ns"] = df["timestamp"].dt.as_unit("ns")
print(df.dtypes)
# timestamp       datetime64[us]
# timestamp_ns    datetime64[ns]

Поради з міграції часових міток

  • Перевірте код, який покладається на наносекундну точність — у більшості випадків мікросекунди цілком достатні.
  • Якщо ваш код обчислює значення у наносекундах (наприклад, через ts.value), оновіть його для врахування нової роздільної здатності.
  • Використовуйте as_unit("ns") там, де наносекундна точність є критичною.
  • Оновіть тести, що перевіряють dtype == "datetime64[ns]" на datetime64[us].

Новий синтаксис pd.col()

А ось це, на мою думку, одна з найприємніших новинок. Pandas 3.0 вводить функцію pd.col()відкладений вираз (deferred expression), який дозволяє створювати лаконічні ланцюжки операцій без необхідності повторювати ім'я DataFrame або писати лямбда-функції.

Якщо ви працювали з Polars, цей підхід буде вам знайомий. І це чудово, бо синтаксис дійсно стає набагато чистішим.

Спрощене посилання на стовпці

Функція pd.col() створює об'єкт-вираз, який обчислюється лише у контексті конкретного DataFrame:

import pandas as pd

df = pd.DataFrame({
    "name": ["Олена", "Максим", "Ірина"],
    "salary": [45000, 52000, 48000],
    "bonus_pct": [0.10, 0.15, 0.12]
})

# Старий спосіб (pandas 2.x) — з лямбда-функцією
df_old = df.assign(total=lambda x: x["salary"] * (1 + x["bonus_pct"]))

# Новий спосіб (pandas 3.0) — з pd.col()
df_new = df.assign(total=pd.col("salary") * (1 + pd.col("bonus_pct")))

print(df_new)
#     name  salary  bonus_pct    total
# 0  Олена   45000       0.10  49500.0
# 1 Максим   52000       0.15  59800.0
# 2  Ірина   48000       0.12  53760.0

Приклади використання з assign()

Функція pd.col() особливо зручна при множинних трансформаціях через assign(). Порівняйте з тим, як це виглядало раніше з лямбдами — різниця разюча:

import pandas as pd

df = pd.DataFrame({
    "product": ["Ноутбук", "Телефон", "Планшет"],
    "price": [25000, 15000, 12000],
    "quantity": [10, 25, 15],
    "discount": [0.05, 0.10, 0.08]
})

# Ланцюжок трансформацій з pd.col()
result = df.assign(
    revenue=pd.col("price") * pd.col("quantity"),
    discounted_price=pd.col("price") * (1 - pd.col("discount")),
    total_after_discount=(
        pd.col("price") * (1 - pd.col("discount")) * pd.col("quantity")
    )
)

print(result)
#    product  price  quantity  discount  revenue  discounted_price  total_after_discount
# 0  Ноутбук  25000        10      0.05   250000           23750.0              237500.0
# 1  Телефон  15000        25      0.10   375000           13500.0              337500.0
# 2  Планшет  12000        15      0.08   180000           11040.0              165600.0

Ланцюжкові операції

Вирази pd.col() підтримують широкий набір операцій — рядкові, числові, агрегатні:

import pandas as pd

df = pd.DataFrame({
    "city": ["київ", "львів", "одеса", "харків"],
    "population": [2962000, 717000, 993000, 1419000],
    "area_km2": [847, 182, 236, 350]
})

# Рядкові операції
result = df.assign(
    city_upper=pd.col("city").str.upper(),
    city_len=pd.col("city").str.len(),
    density=pd.col("population") / pd.col("area_km2")
)

print(result[["city", "city_upper", "density"]])
#     city city_upper      density
# 0   київ       КИЇВ  3497.639905
# 1   львів      ЛЬВІВ  3939.560440
# 2   одеса      ОДЕСА  4207.627119
# 3  харків     ХАРКІВ  4054.285714

Інтеграція з Apache Arrow (Arrow PyCapsule Interface)

Pandas 3.0 значно покращує інтеграцію з екосистемою Apache Arrow через підтримку Arrow PyCapsule Interface. Це стандартизований протокол обміну даними між бібліотеками без копіювання пам'яті (так званий zero-copy). Нові методи DataFrame.from_arrow() та Series.from_arrow() дозволяють безшовно конвертувати дані між pandas та будь-якою бібліотекою, що підтримує цей інтерфейс.

Методи from_arrow()

Нові методи приймають будь-який об'єкт, що реалізує Arrow PyCapsule Interface — таблиці PyArrow, DuckDB, Polars тощо:

import pandas as pd
import pyarrow as pa

# Створення таблиці PyArrow
arrow_table = pa.table({
    "id": [1, 2, 3, 4],
    "name": ["Олена", "Максим", "Ірина", "Дмитро"],
    "score": [95.5, 87.3, 92.1, 88.7]
})

# Конвертація з Arrow до pandas (zero-copy де можливо)
df = pd.DataFrame.from_arrow(arrow_table)
print(df)
#    id    name  score
# 0   1   Олена   95.5
# 1   2  Максим   87.3
# 2   3   Ірина   92.1
# 3   4  Дмитро   88.7

print(df.dtypes)
# id        int64
# name        str
# score   float64

# Конвертація Series з Arrow
arrow_array = pa.array([10, 20, 30, 40])
series = pd.Series.from_arrow(arrow_array, name="values")
print(series)
# 0    10
# 1    20
# 2    30
# 3    40
# Name: values, dtype: int64

Zero-copy інтероперабельність

Головна перевага PyCapsule Interface — обмін даними без копіювання пам'яті. Для великих датасетів це може бути справжнім порятунком:

import pandas as pd
import pyarrow as pa

# Великий набір даних — 10 мільйонів рядків
large_arrow_table = pa.table({
    "id": pa.array(range(10_000_000), type=pa.int64()),
    "value": pa.array(range(10_000_000), type=pa.float64())
})

# Zero-copy конвертація — миттєво, без додаткової пам'яті
df = pd.DataFrame.from_arrow(large_arrow_table)

# Зворотна конвертація — pandas до Arrow
arrow_back = pa.RecordBatch.from_pandas(df)

# Інтеграція з DuckDB через Arrow
import duckdb

conn = duckdb.connect()
result = conn.execute("SELECT * FROM df WHERE value > 5000000").arrow()
df_filtered = pd.DataFrame.from_arrow(result)
print(f"Відфільтровано рядків: {len(df_filtered)}")
# Відфільтровано рядків: 4999999

Переваги нової інтеграції

  • Уніфікований інтерфейс — один метод для конвертації з будь-якої Arrow-сумісної бібліотеки.
  • Zero-copy — мінімальне споживання додаткової пам'яті.
  • Стандартизація — PyCapsule Interface є офіційним стандартом Apache Arrow.
  • Продуктивність — значне прискорення порівняно з попередніми методами конвертації.

Нові можливості merge() — анти-з'єднання

Якщо ви коли-небудь писали конструкцію з merge(), indicator=True та фільтрацією _merge == "left_only" — то ви точно оціните цю зміну. Pandas 3.0 додає довгоочікувані анти-з'єднання (anti-joins) прямо у функцію merge().

Тепер замість п'яти рядків коду потрібен лише один.

left_anti та right_anti

Два нові значення параметра how:

import pandas as pd

# Два DataFrame: клієнти та замовлення
customers = pd.DataFrame({
    "customer_id": [1, 2, 3, 4, 5],
    "name": ["Олена", "Максим", "Ірина", "Дмитро", "Наталія"]
})

orders = pd.DataFrame({
    "order_id": [101, 102, 103],
    "customer_id": [1, 3, 3],
    "amount": [500, 300, 750]
})

# LEFT ANTI JOIN — клієнти, які НЕ мають замовлень
no_orders = pd.merge(customers, orders, on="customer_id", how="left_anti")
print(no_orders)
#    customer_id     name
# 0            2   Максим
# 1            4   Дмитро
# 2            5  Наталія

# RIGHT ANTI JOIN — замовлення від клієнтів, яких немає у таблиці
unknown_orders_data = pd.DataFrame({
    "order_id": [201, 202, 203],
    "customer_id": [3, 7, 8],
    "amount": [100, 200, 150]
})

unknown = pd.merge(customers, unknown_orders_data, on="customer_id", how="right_anti")
print(unknown)
#    order_id  customer_id  amount
# 0       202            7     200
# 1       203            8     150

Порівняння зі старим підходом

Просто подивіться, наскільки простішим став код:

import pandas as pd

customers = pd.DataFrame({
    "customer_id": [1, 2, 3, 4, 5],
    "name": ["Олена", "Максим", "Ірина", "Дмитро", "Наталія"]
})

orders = pd.DataFrame({
    "order_id": [101, 102, 103],
    "customer_id": [1, 3, 3],
    "amount": [500, 300, 750]
})

# Старий спосіб (pandas 2.x) — громіздкий
merged = pd.merge(customers, orders, on="customer_id", how="left", indicator=True)
no_orders_old = merged[merged["_merge"] == "left_only"][customers.columns]

# Новий спосіб (pandas 3.0) — один рядок
no_orders_new = pd.merge(customers, orders, on="customer_id", how="left_anti")

# Результат однаковий, але різниця у читабельності — величезна

Зміни в API та застарілі функції

Pandas 3.0 містить чимало змін в API. Деякі з них анонсувались раніше через попередження, але тепер стали обов'язковими. Давайте пройдемося по найважливіших.

Зміна аліасів зміщень (Offset Aliases)

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

import pandas as pd

# СТАРІ аліаси (pandas 2.x) — більше НЕ працюють
# pd.date_range("2026-01-01", periods=3, freq="M")   # ПОМИЛКА!
# pd.date_range("2026-01-01", periods=4, freq="Q")   # ПОМИЛКА!
# pd.date_range("2026-01-01", periods=3, freq="Y")   # ПОМИЛКА!

# НОВІ аліаси (pandas 3.0)
monthly = pd.date_range("2026-01-01", periods=3, freq="ME")    # Month End
print(monthly)
# DatetimeIndex(['2026-01-31', '2026-02-28', '2026-03-31'], dtype='datetime64[us]')

quarterly = pd.date_range("2026-01-01", periods=4, freq="QE")  # Quarter End
print(quarterly)
# DatetimeIndex(['2026-03-31', '2026-06-30', '2026-09-30', '2026-12-31'], dtype='datetime64[us]')

yearly = pd.date_range("2026-01-01", periods=3, freq="YE")     # Year End
print(yearly)
# DatetimeIndex(['2026-12-31', '2027-12-31', '2028-12-31'], dtype='datetime64[us]')

# Аналогічно для початку періоду:
# MS → без змін (Month Start)
# QS → QS (Quarter Start) — без змін
# YS → YS (Year Start) — без змін

Видалення параметра copy

Параметр copy видалений з багатьох функцій — CoW робить його просто непотрібним:

import pandas as pd

df = pd.DataFrame({"A": [1, 2, 3]})

# Старий код (pandas 2.x)
# df2 = df.rename(columns={"A": "B"}, copy=True)   # ПОМИЛКА в 3.0!
# df3 = df.astype(float, copy=False)                # ПОМИЛКА в 3.0!

# Новий код (pandas 3.0) — просто видаліть параметр copy
df2 = df.rename(columns={"A": "B"})
df3 = df.astype(float)

# CoW автоматично оптимізує копіювання

inplace=True тепер повертає self

Цікава зміна: тепер методи з inplace=True повертають посилання на той самий об'єкт замість None. Це дозволяє використовувати inplace у ланцюжках (хоча, на мою думку, краще все ж уникати inplace у сучасному коді):

import pandas as pd

df = pd.DataFrame({"A": [3, 1, 2], "B": ["x", "y", "z"]})

# pandas 2.x: inplace=True повертав None
# result = df.sort_values("A", inplace=True)
# print(result)  # None

# pandas 3.0: inplace=True повертає self
result = df.sort_values("A", inplace=True)
print(result is df)  # True
print(df)
#    A  B
# 1  1  y
# 2  2  z
# 0  3  x

Мінімальна версія Python та інші зміни

  • Python 3.11 — мінімальна підтримувана версія. Python 3.9 та 3.10 більше не підтримуються.
  • pytz → zoneinfo — бібліотека pytz замінена на стандартну zoneinfo з Python.
import pandas as pd
from zoneinfo import ZoneInfo

# Старий спосіб (pandas 2.x з pytz)
# import pytz
# ts = pd.Timestamp("2026-01-21", tz=pytz.timezone("Europe/Kyiv"))

# Новий спосіб (pandas 3.0 з zoneinfo)
ts = pd.Timestamp("2026-01-21", tz=ZoneInfo("Europe/Kyiv"))
print(ts)
# 2026-01-21 00:00:00+02:00

# Або просто рядок — працює як і раніше
ts2 = pd.Timestamp("2026-01-21", tz="Europe/Kyiv")
print(type(ts2.tz))
# <class 'zoneinfo.ZoneInfo'>   (замість pytz.timezone)

Покращення введення/виведення

Pandas 3.0 приносить чимало покращень у сфері I/O — нативна підтримка Apache Iceberg, покращення Parquet та нові параметри для CSV і SQL.

read_iceberg() та to_iceberg()

Apache Iceberg — відкритий табличний формат для масштабних аналітичних даних. Pandas 3.0 додає нативну підтримку:

import pandas as pd

# Читання Iceberg-таблиці
df = pd.read_iceberg(
    "s3://my-bucket/warehouse/db/sales_table",
    snapshot_id=123456789  # опціонально: конкретний знімок
)

# Запис до Iceberg-таблиці
df.to_iceberg(
    "s3://my-bucket/warehouse/db/output_table",
    mode="append"  # або "overwrite"
)

# Читання з фільтрацією (predicate pushdown)
df_filtered = pd.read_iceberg(
    "s3://my-bucket/warehouse/db/sales_table",
    filters=[("year", "=", 2026), ("region", "=", "UA")]
)

Покращення read_parquet

Функція read_parquet() отримала покращення продуктивності та нові можливості:

import pandas as pd

# Покращена підтримка фільтрації при читанні
df = pd.read_parquet(
    "data/large_dataset.parquet",
    filters=[("date", ">=", "2026-01-01")],
    columns=["date", "value", "category"]
)

# Підтримка glob-патернів для читання декількох файлів
df_all = pd.read_parquet("data/partitioned_data/**/*.parquet")

Нові можливості to_csv та to_sql

Метод to_csv() тепер підтримує f-рядки у float_format, а to_sql() отримав новий режим if_exists="delete_rows" — дуже зручно, коли потрібно очистити таблицю перед вставкою, не руйнуючи її структуру:

import pandas as pd

df = pd.DataFrame({
    "item": ["Кава", "Чай", "Сік"],
    "price": [45.678, 32.123, 28.956]
})

# Новий float_format з f-рядками
df.to_csv("prices.csv", float_format="{:.2f}")
# Результат у CSV:
# item,price
# Кава,45.68
# Чай,32.12
# Сік,28.96

# Також підтримується f-рядок з відсотками, вирівнюванням тощо
df.to_csv("prices_formatted.csv", float_format="{:>10.1f}")

# to_sql з delete_rows — видаляє всі рядки перед вставкою,
# але зберігає структуру таблиці
from sqlalchemy import create_engine
engine = create_engine("sqlite:///mydb.sqlite")

df.to_sql("prices", engine, if_exists="delete_rows", index=False)
# Відрізняється від "replace" тим, що не видаляє/створює таблицю заново,
# а лише очищує дані. Це зберігає індекси, тригери та обмеження.

Покращення GroupBy

Операції групування — одна з найпотужніших функцій pandas. У версії 3.0 вони стали ще зручнішими.

Параметр skipna

Новий параметр skipna у методах агрегації GroupBy дає більше контролю над обробкою пропущених значень:

import pandas as pd
import numpy as np

df = pd.DataFrame({
    "group": ["A", "A", "A", "B", "B", "B"],
    "value": [10, np.nan, 30, 40, 50, np.nan]
})

# За замовчуванням — пропускає NaN (як і раніше)
result_skip = df.groupby("group")["value"].sum()
print(result_skip)
# group
# A    40.0
# B    90.0

# Новий параметр skipna=False — NaN поширюється на результат
result_no_skip = df.groupby("group")["value"].sum(skipna=False)
print(result_no_skip)
# group
# A     NaN
# B     NaN

# skipna працює з різними агрегатними функціями
result_mean = df.groupby("group")["value"].mean(skipna=False)
print(result_mean)
# group
# A   NaN
# B   NaN

Покращення NamedAgg

Іменоване агрегування стало ще зручнішим:

import pandas as pd
import numpy as np

df = pd.DataFrame({
    "department": ["IT", "IT", "HR", "HR", "IT", "HR"],
    "employee": ["Олена", "Максим", "Ірина", "Дмитро", "Наталія", "Андрій"],
    "salary": [55000, 62000, 48000, 51000, 58000, 45000],
    "experience_years": [5, 8, 3, 6, 4, 2]
})

# Покращене іменоване агрегування
summary = df.groupby("department").agg(
    avg_salary=pd.NamedAgg(column="salary", aggfunc="mean"),
    max_salary=pd.NamedAgg(column="salary", aggfunc="max"),
    min_salary=pd.NamedAgg(column="salary", aggfunc="min"),
    total_experience=pd.NamedAgg(column="experience_years", aggfunc="sum"),
    team_size=pd.NamedAgg(column="employee", aggfunc="count")
)

print(summary)
#             avg_salary  max_salary  min_salary  total_experience  team_size
# department
# HR           48000.000       51000       45000                11          3
# IT           58333.333       62000       55000                17          3

Нові методи Rolling

Ковзні обчислення отримали нові статистичні методи. Наприклад, ковзний rank:

import pandas as pd
import numpy as np

# Часовий ряд з денними даними
dates = pd.date_range("2026-01-01", periods=30, freq="D")
df = pd.DataFrame({
    "date": dates,
    "price": np.random.uniform(100, 200, 30).round(2)
})
df = df.set_index("date")

# Стандартні ковзні обчислення
df["rolling_mean_7d"] = df["price"].rolling(7).mean()
df["rolling_std_7d"] = df["price"].rolling(7).std()

# Нові можливості Rolling у pandas 3.0
df["rolling_rank"] = df["price"].rolling(7).rank()

print(df.head(10))
#               price  rolling_mean_7d  rolling_std_7d  rolling_rank
# date
# 2026-01-01   156.23              NaN             NaN           NaN
# 2026-01-02   142.87              NaN             NaN           NaN
# 2026-01-03   178.45              NaN             NaN           NaN
# ...

Покрокова міграція з pandas 2.x

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

Крок 1: Оновіть до pandas 2.3

Перед переходом на pandas 3.0 обов'язково оновіть до останньої версії 2.x. Pandas 2.3 містить усі попередження про майбутні зміни — це ваша «канарка у шахті»:

# Оновіть pandas до останньої версії 2.x
pip install "pandas>=2.3,<3.0"

# Увімкніть режим CoW для тестування
import pandas as pd
pd.options.mode.copy_on_write = True

# Увімкніть майбутню поведінку рядків
pd.options.future.infer_string = True

Крок 2: Виправте всі попередження

Запустіть тести з увімкненими попередженнями та виправте кожне:

# Запустіть тести з відображенням попереджень
python -W all -m pytest tests/

# Або додайте у конфігурацію pytest (pyproject.toml)
# [tool.pytest.ini_options]
# filterwarnings = ["error::FutureWarning"]

Типові попередження, на які варто звернути увагу:

  • FutureWarning: Setting an item of incompatible dtype — оновіть типи даних.
  • FutureWarning: ChainedAssignmentError — замініть ланцюгове присвоювання на .loc.
  • FutureWarning: 'M' is deprecated, use 'ME' — оновіть аліаси частот.
  • DeprecationWarning: the copy keyword is deprecated — видаліть параметр copy.

Крок 3: Оновіть перевірки рядкових типів

Знайдіть та оновіть усі місця, де код перевіряє тип object для рядків:

import pandas as pd

# Знайдіть у вашому коді всі такі паттерни:

# ЗАМІНІТЬ:
# if col.dtype == object:
# if col.dtype == "object":
# if str(col.dtype) == "object":

# НА:
if pd.api.types.is_string_dtype(col):
    pass  # обробка рядкових стовпців

# Також оновіть порівняння з NaN для рядків:
# ЗАМІНІТЬ:
# if pd.isna(value):  # працює, але поведінка змінилась
# НА:
# value is pd.NA  (для рядкових стовпців)

Крок 4: Видаліть ланцюгове присвоювання

Систематично знайдіть та замініть усі випадки:

import pandas as pd

df = pd.DataFrame({
    "category": ["A", "B", "A", "C"],
    "value": [10, 20, 30, 40]
})

# ПАТТЕРН 1: Індексування через [][]
# СТАРИЙ КОД:
# df["value"][df["category"] == "A"] = 100
# НОВИЙ КОД:
df.loc[df["category"] == "A", "value"] = 100

# ПАТТЕРН 2: Фільтрація + присвоювання
# СТАРИЙ КОД:
# df[df["value"] > 20]["category"] = "X"
# НОВИЙ КОД:
df.loc[df["value"] > 20, "category"] = "X"

# ПАТТЕРН 3: Присвоювання через зріз
# СТАРИЙ КОД:
# df[:2]["value"] = 0
# НОВИЙ КОД:
df.iloc[:2, df.columns.get_loc("value")] = 0

Крок 5: Оновіть аліаси зміщень

Ось повна таблиця замін (збережіть її — точно знадобиться):

import pandas as pd

# Таблиця замін:
# Старий → Новий
# M      → ME    (Month End)
# Q      → QE    (Quarter End)
# Y      → YE    (Year End)
# A      → YE    (Annual/Year End — A також замінено)
# BM     → BME   (Business Month End)
# BQ     → BQE   (Business Quarter End)
# BY     → BYE   (Business Year End)
# BA     → BYE   (Business Annual/Year End)
# H      → h     (Hour — зміна регістру)
# T      → min   (Minute)
# S      → s     (Second)
# L      → ms    (Millisecond)
# U      → us    (Microsecond)
# N      → ns    (Nanosecond)

# Приклад оновлення:
# СТАРИЙ КОД:
# monthly = pd.date_range("2026-01-01", periods=12, freq="M")
# quarterly = df.resample("Q").sum()

# НОВИЙ КОД:
monthly = pd.date_range("2026-01-01", periods=12, freq="ME")
df_ts = pd.DataFrame(
    {"value": range(365)},
    index=pd.date_range("2026-01-01", periods=365, freq="D")
)
quarterly = df_ts.resample("QE").sum()

Крок 6: Протестуйте операції з датами

Переконайтесь, що зміна роздільної здатності не зламала ваші обчислення:

import pandas as pd

# Перевірте dtype ваших часових стовпців
df = pd.DataFrame({
    "timestamp": pd.to_datetime(["2026-01-21 10:30:00", "2026-01-22 14:45:00"])
})
assert df["timestamp"].dtype == "datetime64[us]", "Очікується мікросекундна роздільна здатність"

# Перевірте обчислення різниці часу
diff = df["timestamp"].diff()
print(diff.dtype)
# timedelta64[us]

# Якщо ваш код покладався на .value (повертає ціле число)
# пам'ятайте, що тепер значення в мікросекундах, а не наносекундах
ts = pd.Timestamp("2026-01-21")
print(ts.value)  # Тепер у мікросекундах від epoch

# Для зворотної сумісності використовуйте as_unit()
ts_ns = ts.as_unit("ns")
print(ts_ns.value)  # У наносекундах від epoch

Крок 7: Оновіть до pandas 3.0

Після виправлення всіх попереджень — фінальний крок:

# Встановіть pandas 3.0
pip install "pandas>=3.0"

# Перевірте версію
import pandas as pd
print(pd.__version__)
# 3.0.0

# Переконайтесь, що Python >= 3.11
import sys
print(sys.version)
# 3.11.x або новіший

# Запустіть повний набір тестів
python -m pytest tests/ -v --tb=long

Скрипт автоматичної перевірки сумісності

Я підготував корисний скрипт, який допоможе виявити потенційні проблеми у вашому коді ще до міграції:

"""
Скрипт перевірки сумісності коду з pandas 3.0
Запустіть у кореневій директорії проекту:
    python check_pandas3_compat.py
"""
import ast
import sys
from pathlib import Path

PATTERNS = {
    'dtype == "object"': "Замініть на pd.api.types.is_string_dtype()",
    "dtype == object": "Замініть на pd.api.types.is_string_dtype()",
    'freq="M"': 'Замініть на freq="ME"',
    'freq="Q"': 'Замініть на freq="QE"',
    'freq="Y"': 'Замініть на freq="YE"',
    'freq="A"': 'Замініть на freq="YE"',
    "copy=True": "Видаліть параметр copy (CoW обробляє автоматично)",
    "copy=False": "Видаліть параметр copy (CoW обробляє автоматично)",
    "import pytz": "Замініть на from zoneinfo import ZoneInfo",
    "pytz.timezone": "Замініть на ZoneInfo()",
    "chained_assignment": "Видаліть — більше не потрібно в pandas 3.0",
}

def check_file(filepath):
    issues = []
    with open(filepath, "r", encoding="utf-8") as f:
        for i, line in enumerate(f, 1):
            for pattern, suggestion in PATTERNS.items():
                if pattern in line:
                    issues.append((i, pattern, suggestion))
    return issues

def main():
    project_dir = Path(".")
    total_issues = 0
    for py_file in project_dir.rglob("*.py"):
        issues = check_file(py_file)
        if issues:
            print(f"\n{py_file}:")
            for line_num, pattern, suggestion in issues:
                print(f"  Рядок {line_num}: знайдено '{pattern}'")
                print(f"    → {suggestion}")
                total_issues += 1

    print(f"\nЗнайдено {total_issues} потенційних проблем.")
    return total_issues

if __name__ == "__main__":
    sys.exit(main())

Додаткові зміни та покращення

Окрім основних змін, pandas 3.0 містить безліч менших, але важливих покращень.

Покращення продуктивності

Завдяки глибшій інтеграції з PyArrow та обов'язковому CoW, загальна продуктивність помітно зросла:

  • Рядкові операції — до 3-5 разів швидші завдяки бекенду PyArrow.
  • Споживання пам'яті — зменшено на 30-50% для типових робочих навантажень.
  • read_csv() — прискорено завдяки оптимізованому парсингу рядків.
  • GroupBy — покращена продуктивність агрегатних операцій.

Зміни у поведінці порівнянь

Порівняння з pd.NA тепер поводяться більш консистентно (і, чесно кажучи, більш логічно):

import pandas as pd

s = pd.Series(["hello", pd.NA, "world"])

# Порівняння з NA повертає NA (не True/False)
result = s == "hello"
print(result)
# 0     True
# 1     <NA>
# 2    False
# dtype: boolean

# Для фільтрації використовуйте .fillna(False) або .eq()
filtered = s[result.fillna(False)]
print(filtered)
# 0    hello
# dtype: str

Нові утиліти для перетворення типів

import pandas as pd

df = pd.DataFrame({
    "id": ["1", "2", "3"],
    "value": ["10.5", "20.3", "30.1"],
    "flag": ["true", "false", "true"]
})

# convert_dtypes() тепер працює ще краще
df_converted = df.convert_dtypes()
print(df_converted.dtypes)
# id        str
# value     str
# flag      str

# Для числових конвертацій використовуйте to_numeric
df_converted["value"] = pd.to_numeric(df_converted["value"])
df_converted["id"] = pd.to_numeric(df_converted["id"]).astype("Int64")
print(df_converted.dtypes)
# id        Int64
# value   float64
# flag        str

Висновки

Pandas 3.0 — це, без перебільшення, найважливіше оновлення бібліотеки за останні роки. Три фундаментальні зміни — новий рядковий тип за замовчуванням, обов'язковий Copy-on-Write та мікросекундна роздільна здатність часових міток — разом формують нову парадигму роботи з даними у Python.

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

Нові можливості — pd.col() для виразних трансформацій, анти-з'єднання у merge(), нативна підтримка Apache Iceberg та покращена інтеграція з Apache Arrow — значно розширюють арсенал інструментів і наближають pandas до сучасних стандартів обробки даних.

Ключові рекомендації для успішної міграції:

  1. Не поспішайте — спочатку оновіть до pandas 2.3 та виправте всі попередження.
  2. Увімкніть CoW у pandas 2.3 (pd.options.mode.copy_on_write = True) та протестуйте код.
  3. Оновіть перевірки типів — замініть dtype == "object" на pd.api.types.is_string_dtype().
  4. Видаліть ланцюгове присвоювання — використовуйте .loc та .iloc.
  5. Оновіть аліаси частотM→ME, Q→QE, Y→YE та інші.
  6. Перевірте часові мітки — зверніть увагу на зміну з наносекунд на мікросекунди.
  7. Оновіть залежності — Python >= 3.11, pytz замініть на zoneinfo.
  8. Запустіть тести після кожного кроку міграції.

Pandas залишається незамінним інструментом для аналізу даних у Python, і версія 3.0 це лише підтверджує. Починайте міграцію вже сьогодні — крок за кроком, слідуючи рекомендаціям цього посібника, і перехід буде максимально безболісним. Успіхів!

Про Автора Editorial Team

Our team of expert writers and editors.