Thanks to visit codestin.com
Credit goes to github.com

Skip to content

BUG: read_csv dtype="category" now infers numeric and boolean categories - #64659

Merged
jbrockmendel merged 25 commits into
pandas-dev:mainfrom
jbrockmendel:api-categorical-csv
Sep 5, 2026
Merged

BUG: read_csv dtype="category" now infers numeric and boolean categories#64659
jbrockmendel merged 25 commits into
pandas-dev:mainfrom
jbrockmendel:api-categorical-csv

Conversation

@jbrockmendel

@jbrockmendel jbrockmendel commented Mar 17, 2026

Copy link
Copy Markdown
Member

Summary

read_csv(..., dtype="category") with the c and python engines produced string categories for numeric and boolean columns (e.g. categories=['1', '2'] with object dtype), while the pyarrow engine inferred them. Now all three engines infer numeric and boolean categories, matching the types those columns get without dtype="category".

import pandas as pd
from io import StringIO

data = "a,b,c\n1,a,3.4\n1,a,3.4\n2,b,4.5"

res = pd.read_csv(StringIO(data), dtype="category")

res["a"].cat.categories
# Before (c/python): Index(['1', '2'], dtype='object')
# After (all engines): Index([1, 2], dtype='int64')

Changes

  • Categorical._maybe_convert_categories / _from_converted_categories: new helpers that infer numeric and boolean categories when dtype does not provide them. Distinct strings that convert to the same value (e.g. "1" and "1.0", or "True" and "TRUE") are merged and recoded, matching the pyarrow engine. The c engine's tokenizer matches the default True/False spellings case-insensitively, so its category inference does too -- after consulting true_values/false_values, which the tokenizer checks first. The parsers call them; _from_inferred_categories keeps its previous signature.
  • Parsers skip the numeric inference when thousands/decimal are non-default, since to_numeric is unaware of those options — such columns keep string categories rather than mis-parsing (e.g. "1.000" with thousands="." becoming 1.0). Under quoting=csv.QUOTE_NONNUMERIC the inference produces float64 instead, matching the csv module's cast-every-unquoted-field-to-float semantics and the non-categorical read.
  • PythonParser._cast_types: removed the ensure_string_array hack that cast values back to strings "for consistency with the c-parser".
  • _maybe_convert_string_to_object: with future.infer_string disabled, an ordered categorical column kept str categories where an unordered one got object, because CategoricalDtype.__eq__ ignores the categories' dtype when ordered and the astype no-opped. This reaches every reader that builds a frame from an Arrow table (read_parquet, read_feather, read_orc, ...), not just read_csv, so it has its own whatsnew line and a read_feather test.
  • _post_convert_dtypes: convert IntegerDtype categories (from the BUG: read_csv loses precision when engine='pyarrow' and dtype Int64 #56136 lossy-float64 workaround) back to numpy, but only for read_csv with a user dtype, and never for categories the user explicitly requested.
  • _pyarrow_parse_type: a CategoricalDtype with no explicit categories is left to pyarrow's type inference instead of being parsed as large_string, so the pyarrow engine infers categories like the other two. See the note below.

Three further inconsistencies in the c/python engines are fixed along the way:

  • A CategoricalDtype specifying ordered=True without categories no longer discards ordered.
  • low_memory=True (the c engine default) no longer leaves categories in buffer-chunk order.
  • An all-NA buffer chunk no longer raises TypeError: dtype of categories must be the same when other chunks did infer categories.

Notes for reviewers

  • This is a direct behavior change (notable-bug-fix whatsnew entry), not a deprecation — a warning would fire on essentially every dtype="category" read. Flagging since API: categories.dtype with pd.read_csv(..., dtype='category') #56044 has no recorded discussion.
  • The inference lives in the shared text parser, so it also reaches read_table, read_fwf and read_excel, which already inferred numeric/boolean types for these columns without dtype="category". Covered by tests.
  • Integers too large for int64/uint64 become object-dtype categories of Python ints on c/python (lossless); pyarrow gives lossy float64. Pinned in tests at two magnitudes, including integers beyond the float64 range — those used to trip the position-dependent inference bug addressed upstream in BUG: position-dependent integer inference in maybe_convert_objects #66519.
  • Like every other dtype, the inference sees one chunk at a time under chunksize/iterator=True, so chunks of one column can now come back with different category dtypes; previously they were always strings and so always concat-able. The buffer chunking low_memory=True does inside a single read adds no such divergence -- it infers once, after those chunks are combined. Noted in the whatsnew and pinned in tests.
  • Interaction with BUG: correctly honor specified dtype=str in read_csv pyarrow engine #62242, merged while this was open: it made the pyarrow engine parse dtype="category" as large_string so the categories are the raw file text, aligning pyarrow with the c/python behavior as it stood at the time. This PR changes that behavior, so one branch of _pyarrow_parse_type is reversed here and the dtype="category" clause is dropped from its whatsnew entry. Its dtype=str and dtype=object handling is untouched, and all three issues it cites (index_col in read_csv and read_table ignores dtype argument #9435, BUG: pyarrow read_csv engine stripping leading zeros with dtype=str #57666, BUG: read_csv() with engine="pyarrow" converts numeric string even when dtype=str is specified #58260) are about dtype=str/index_col, not categoricals — its own tests for them still pass. Concretely: with the raw-text rule there is no way to ask for a categorical with inferred value type at read time, while string categories stay available via dtype="str" plus .astype("category"), or an explicit CategoricalDtype. One consequence worth calling out: zero-padded identifier columns (zip codes, account numbers) now lose their leading zeros under dtype="category", exactly as they already do without it. Noted in the whatsnew with the escape hatch.
  • Datetime-like strings still diverge (pyarrow infers, c/python keep string categories) — out of scope here.
  • A non-default dtype_backend is only partly honored for the categories: the c engine always gives numpy-backed categories, as does the python engine with dtype_backend="numpy_nullable" (BUG: read_csv(dtype="category") ignores dtype_backend for the c and python engines #66382). The current behavior is pinned in tests.

@jbrockmendel
jbrockmendel force-pushed the api-categorical-csv branch 4 times, most recently from d7d7f3f to 37f36ed Compare March 17, 2026 22:27
@jbrockmendel jbrockmendel added the IO CSV read_csv, to_csv label Mar 18, 2026
@jbrockmendel
jbrockmendel force-pushed the api-categorical-csv branch 3 times, most recently from 4cc29ce to 3c3a4ec Compare March 21, 2026 01:35
@jbrockmendel
jbrockmendel marked this pull request as ready for review March 21, 2026 01:36
@github-actions

Copy link
Copy Markdown
Contributor

This pull request is stale because it has been open for thirty days with no activity. Please update and respond to this comment if you're still interested in working on this.

@github-actions github-actions Bot added the Stale label Apr 21, 2026
@jbrockmendel
jbrockmendel force-pushed the api-categorical-csv branch from 3c3a4ec to a27655c Compare May 18, 2026 14:55
@jbrockmendel jbrockmendel added Bug Categorical Categorical Data Type labels May 28, 2026
@jbrockmendel
jbrockmendel marked this pull request as draft June 10, 2026 20:54
@jbrockmendel
jbrockmendel requested a review from mroeschke July 8, 2026 21:31
@jbrockmendel
jbrockmendel force-pushed the api-categorical-csv branch from cc26d98 to 7bce9e2 Compare July 11, 2026 21:58
@jbrockmendel
jbrockmendel marked this pull request as ready for review July 19, 2026 19:38
jbrockmendel and others added 8 commits July 19, 2026 13:22
Previously, the c and python parsers produced string categories for
numeric columns (e.g., categories=['1', '2'] with object dtype),
while the pyarrow engine correctly inferred numeric types. Now all
engines consistently infer proper types for categories.

closes pandas-dev#56044

Co-Authored-By: Claude Opus 4.6 <[email protected]>
Use explicit dtype mapping instead of accessing .numpy_dtype
on a union type that the type checkers can't narrow.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
…type_backend is defined

The block referenced `dtype_backend` inside `_normalize_timezone_dtypes`,
which only takes `df`. Every pyarrow-backed IO path hit a NameError at
runtime, breaking Type Checking, Doctests, Doc Build, and pyarrow IO
tests. Moved the block into `_post_convert_dtypes` (after the astype),
where `dtype_backend` is already a parameter.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
…read_csv dtype="category"

- Merge categories when distinct strings convert to the same number
  (e.g. "1" and "1.0") instead of raising on duplicate categories
- Skip numeric inference when thousands/decimal are set, since
  to_numeric is unaware of those options
- Only downcast IntegerDtype categories that the pyarrow workaround
  introduced, not categories the user explicitly requested; restrict
  the downcast to read_csv (dtype is not None) and make it safe for
  duplicate column labels
- Add tests for the above plus large-integer behavior
- Move the whatsnew entry to the notable bug fixes section

Co-Authored-By: Claude Fable 5 <[email protected]>
The c and python engines produced string categories for boolean columns
under dtype="category" while the pyarrow engine inferred bool categories.
All engines now infer boolean categories, mirroring each engine's
non-categorical inference (numeric first, then boolean). GH#56044

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
…ory"

- Defer category-dtype inference in low_memory mode until chunks are
  concatenated; per-chunk inference could produce differing category
  dtypes, breaking union_categoricals
- Keep string categories when to_numeric turns "" into NaN
  (na_filter=False / keep_default_na=False)
- Keep object dtype for empty inferred categories (all-NA columns)

Co-Authored-By: Claude Fable 5 <[email protected]>
… docs

- factor _from_converted_categories out of _from_inferred_categories so
  the low_memory deferred path runs to_numeric once on the unioned
  categories
- update stale io.rst note obsoleted by GH#56044 inference
- pin dtype_backend and QUOTE_NONNUMERIC category behavior in tests;
  scope the whatsnew claim to the default dtype_backend

Co-Authored-By: Claude Fable 5 <[email protected]>
@jbrockmendel
jbrockmendel force-pushed the api-categorical-csv branch from 2148c92 to 6bce594 Compare July 19, 2026 20:28
jbrockmendel and others added 3 commits July 19, 2026 14:48
…tegory"

With dtype_backend="pyarrow" the python engine's values arrive as
ArrowDtype strings, whose kind is "U" rather than "O", so the guard in
_maybe_convert_categories bailed and no inference was applied at all.
Categories now infer to int64[pyarrow], matching a non-categorical read.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
# Conflicts:
#	doc/source/whatsnew/v3.1.0.rst
#	pandas/_libs/parsers.pyx
jbrockmendel and others added 2 commits July 24, 2026 17:07
…tegories unsorted

With the c and python engines, a CategoricalDtype specifying ordered=True but
not categories silently produced an unordered result, while the pyarrow engine
honored it. Separately, low_memory=True (the c engine default) left string
categories in the order the file's buffer chunks happened to produce them,
rather than sorted as every other path gives.

Also restore the pre-GH#56044 defaults of Categorical._from_inferred_categories
so callers other than the parsers are unaffected, and collapse
_from_converted_categories to a single recode pass.

Co-Authored-By: Claude Opus 5 <[email protected]>
jbrockmendel and others added 3 commits July 31, 2026 15:37
…ed categories

Follow-up fixes on top of the GH#56044 inference change:

- with quoting=QUOTE_NONNUMERIC the deferred low-memory path now gives
  float64 categories like the eager path, casting before de-duplication so
  that values colliding at float64 precision merge into one category
- an all-NA chunk no longer breaks union_categoricals when the other chunks
  inferred a non-object category dtype
- to_numeric raises OverflowError for integers too large to represent as
  float64; such columns keep string categories rather than propagating it
- under PANDAS_FUTURE_INFER_STRING=0 the pyarrow engine kept StringDtype
  categories for ordered categoricals, since CategoricalDtype.__eq__ ignores
  the categories' dtype when ordered and the astype was a no-op
- read_fwf and read_excel coverage for the shared inference

Co-Authored-By: Claude Opus 5 <[email protected]>
# Conflicts:
#	doc/source/whatsnew/v3.1.0.rst
…ed_categories

The numeric/boolean inference now runs via _maybe_convert_categories and
_from_converted_categories at the parser call sites, so the CSV-specific
knobs (thousands/decimal, QUOTE_NONNUMERIC) stay in io/ and
_from_inferred_categories keeps its previous signature.

Also fixes signed-zero and huge-integer categories differing between
low_memory=True and low_memory=False, and corrects the io.rst advice to
convert thousands/decimal string categories with to_numeric, which
silently read "1.000" as 1.0 rather than 1000.

Co-Authored-By: Claude Opus 5 <[email protected]>
@jbrockmendel jbrockmendel changed the title BUG: read_csv dtype="category" now infers numeric categories BUG: read_csv dtype="category" now infers numeric and boolean categories Aug 5, 2026
# Conflicts:
#	pandas/_libs/parsers.pyx
# Conflicts:
#	pandas/_libs/parsers.pyx
# Conflicts:
#	pandas/io/parsers/c_parser_wrapper.py
@rhshadrach rhshadrach removed the Stale label Aug 11, 2026
@hypzz

hypzz commented Aug 12, 2026

Copy link
Copy Markdown

I hit the all-NA low-memory case addressed here when upgrading a legacy codebase from pandas 2.3.3 to 3.0.5

import io

import pandas as pd

N_COLS = 4097
empty_row = "," * (N_COLS - 1)
csv = "\n".join([empty_row] * 128 + [empty_row + "EQ"])

pd.read_csv(
    io.StringIO(csv),
    names=range(N_COLS),
    dtype={N_COLS - 1: "category"},
)

Results:

pandas 3.0.5                            -> TypeError: dtype of categories must be the same
pandas 2.3.3                            -> works
pandas 2.3.3 + future.infer_string=True -> same TypeError

The last comparison seems to pin the regression to the pandas-3 string inference change.

Since this affects the default read_csv(..., dtype="category") path, whats the chance of a backport to 3.0.x?

@jbrockmendel

Copy link
Copy Markdown
Member Author

@mroeschke ok with calling this a bugfix?

…gine

The c tokenizer matches the default True/False spellings with strncasecmp, so
a plain read infers bool for "tRuE" while dtype="category" kept string
categories. Fold those spellings for the c engine only, after consulting
true_values/false_values, which the tokenizer checks first.

Also keep the arrow backing for inferred boolean categories, document the
low_memory all-NA-chunk crash and the per-chunk inference that chunksize now
does, and cover the ordered-categorical fix that reaches every reader built on
an Arrow table.

Co-Authored-By: Claude Opus 5 <[email protected]>

@mroeschke mroeschke left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah I'm OK calling this a "bug fix"

Comment thread doc/source/whatsnew/v3.1.0.rst Outdated
Comment on lines +136 to +142
Three further inconsistencies in the ``c`` and ``python`` engines are fixed.
``low_memory=True`` (the default for the ``c`` engine) no longer leaves string
categories in the order the file's buffer chunks happened to produce them, and
no longer raises ``TypeError: dtype of categories must be the same`` when one
of a column's buffer chunks holds only missing values. A
:class:`CategoricalDtype` that specifies ``ordered=True`` without specifying
``categories`` no longer discards ``ordered``:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These listed as bullet-points would be easier to read

Comment thread doc/source/whatsnew/v3.1.0.rst Outdated
Comment on lines +129 to +130
which already inferred numeric and boolean types for these columns without
``dtype="category"``.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this specifically apply to read_excel or all 3 functions listed here

Comment thread doc/source/whatsnew/v3.1.0.rst Outdated
:class:`CategoricalDtype` that specifies ``ordered=True`` without specifying
``categories`` no longer discards ``ordered``:

.. ipython:: python

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would be good to add a "if you preferred the old behavior, do this" excerpt here too (if there is one)

@jbrockmendel

Copy link
Copy Markdown
Member Author

#62242 merged this morning and kinda went in the opposite direction. @jorisvandenbossche since you reviewed/merged that, do you feel strongly about this API:

data = "a\n1\n2"
df = pd.read_csv(io.StringIO(data), dtype="category")
dtype = df["a"].dtype.categories.dtype

assert dtype == np.int64  # <- this PR
assert dtype == "str"     # <- main

AFAICT that wasn't the main thrust of #62242, but I don't want to change this out from under you.

jbrockmendel and others added 2 commits August 26, 2026 15:18
The pyarrow engine parsed a categorical with no explicit categories as
large_string, so the categories were the raw text from the file instead of
the inferred values the other engines now give (GH#56044).

This reverses one branch of pandas-devGH-62242, which aligned the pyarrow engine with
the c and python engines as they behaved before this PR changed them. Its
dtype=str and dtype=object handling, and every issue it cites, are
unaffected.

Co-Authored-By: Claude Opus 5 <[email protected]>
# Conflicts:
#	doc/source/whatsnew/v3.1.0.rst
#	pandas/io/parsers/c_parser_wrapper.py
#	pandas/tests/io/excel/test_readers.py
#	pandas/tests/io/parser/dtypes/test_categorical.py
#	pandas/tests/io/parser/test_read_fwf.py
@jbrockmendel
jbrockmendel merged commit 14a35af into pandas-dev:main Sep 5, 2026
52 checks passed
@jbrockmendel
jbrockmendel deleted the api-categorical-csv branch September 5, 2026 01:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Bug Categorical Categorical Data Type IO CSV read_csv, to_csv

Projects

None yet

Development

Successfully merging this pull request may close these issues.

API: categories.dtype with pd.read_csv(..., dtype='category')

4 participants