Pandas 3.0 Made PyArrow Strings The Default
Pandas has spent a decade storing text columns as object — a Python list of individually boxed string objects, dressed up as a DataFrame column. Pandas 3.0 replaces that with an Arrow-backed string type by default, and it is a bigger migration than a version bump usually implies, because the change is silent everywhere your code doesn't specifically check for it.
What actually changed
Two things landed together, and they compound. String columns default to string[pyarrow] instead of object, when PyArrow is installed — which is now effectively always, since it ships as a resolved dependency in most environments. And copy-on-write is on by default, which changes when a chained assignment mutates the original DataFrame versus silently operating on a copy. As the migration guide puts it, the new type falls back to object only when PyArrow is missing — which means two identical-looking environments can now hold the same column in two different backing types. Content was rephrased for compliance with licensing restrictions.
Neither change is cosmetic. Arrow strings store contiguous UTF-8 buffers instead of a pointer per Python string, so .str operations vectorize instead of looping in the interpreter — a real win, and the reason this shipped as a default rather than an opt-in. Copy-on-write closes a long-standing class of SettingWithCopyWarning bugs by making the ambiguous case impossible rather than merely flagged.
Where it bites
The failure mode is a dtype check, not a crash. df['category'].dtype == object was a common way to detect "this is a text column, treat it as categorical" — that comparison is now False for the same column on a default install, and code branching on it silently takes the wrong path. Categorical encoding pipelines that dispatch on dtype.kind rather than on pandas.api.types.is_string_dtype are the ones that go quiet rather than loud.
Missingness is the second trap. Arrow strings use pd.NA rather than np.nan for missing values, and pd.NA == pd.NA is itself pd.NA, not True or False — three-valued logic where your comparison expected two. Any missing-data check written as df.isna().sum() still works; one written as df == np.nan or a manual equality chain does not, and it will not raise an exception to tell you.
Copy-on-write changes a different habit: a function that mutates a DataFrame passed by reference, expecting the caller's copy to change too, now operates on an isolated copy instead. The function runs, returns nothing wrong-looking, and the caller's data is simply unchanged.
What to check before upgrading
Run df.dtypes on your ingestion output and diff it against what your encoding and validation code assumes, rather than assuming a version bump is safe by default. Replace any dtype == object check with pandas.api.types.is_string_dtype(series), which is stable across both backends. Audit for in-place mutation through function boundaries — anywhere a helper is expected to modify a DataFrame the caller passed in. And test missingness logic against pd.NA explicitly; a test suite written entirely against np.nan will pass in an old environment and stay green in a new one while quietly checking the wrong thing.
None of this is a reason to stay on 2.x — vectorized string operations and copy-on-write are both real improvements. It is a reason to treat the upgrade as a data-contract change, not a patch release.