One attribute, zero parentheses: df.dtypes is the X-ray of any DataFrame — read it before you trust a single calculation.
Merge keys that refuse to match, integers that came back floats — the culprit is almost always visible in one attribute: df.dtypes.
df.dtypes is an attribute, not a method: it returns a Series mapping every column name to its dtype, in column order, at zero cost. Its sibling df.select_dtypes(include=..., exclude=...) speaks the same vocabulary and grabs whole column groups — 'number' for math, 'datetime' for time logic. df.dtypes.value_counts() summarizes the schema in one line, and df.info() prints dtypes plus null counts when you want the full story. The attribute is read-only: changing types is astype()'s or the constructor's dtype= job.
Every downstream failure mode is a dtype failure in disguise: merge keys that don't match because one side loaded int64 and the other object, a units column that became float64 because one row was empty, numbers polluted by a stray 'N/A'. Reading dtypes immediately after every load takes one line and catches most of these before any logic runs. And the answer itself just changed: since pandas 3.0 (Jan 2026) text columns report str instead of object, so object now reliably means 'mixed types in here — go look'.
The default first check after building or loading a table — one attribute, every column's type in column order:
order_id int64 city str units float64 dtype: object
df.dtypes on a 3-column order table. order_id stayed int64, city is the new str text dtype (pandas 3.0 — PyArrow-backed when pyarrow is installed, python fallback otherwise), and units is float64: one empty value upcast the whole column. Verified on pandas 3.0.3.
On a 40-column import, the line-by-line view is noise. value_counts() turns dtypes into a one-line schema audit:
int64 1 str 1 float64 1 Name: count, dtype: int64
df.dtypes.value_counts(). If a column you expected to be int64 shows up under float64, you just caught a missing-value upcast; if something reports object on pandas 3.0, you caught mixed types. Verified.
Payroll table: sum only the numeric columns per row, without naming them. select_dtypes uses the same dtype vocabulary as dtypes:
hours rate_eur name gross_eur 0 38 42.0 Ada 80.0 1 40 38.5 Bo 78.5 2 35 51.0 Cyd 86.0
pay["gross_eur"] = pay.select_dtypes("number").sum(axis=1) — 'number' catches int64 and float64 at once, no column lists. Check types with dtypes, then select by them. Verified.
CSV roundtrip: see what read_csv guessed before you compute on it:
sensor str temp_c float64 dtype: object
pd.read_csv(io.StringIO("sensor,temp_c\nth-1,21.4\nth-2,19.8")).dtypes. The parser guesses types per column; when a guess is wrong, fix it at the boundary: pd.read_csv(f, dtype={"units": "Int64"}) keeps a mostly-int column integer even with gaps (verified).
| Flag | Meaning |
|---|---|
df.dtypes | attribute, not method — no parentheses; Series of column name → dtype in column order |
df.dtypes.value_counts() | one-line schema summary; the quickest audit after any load |
df.select_dtypes(include='number') | pick whole column groups by dtype ('number', 'datetime', object/str exclusions too) |
df['col'].dtype | one column's dtype object — compare against dtype objects, not just display strings |
df.convert_dtypes() | re-infer best nullable dtypes: float-with-NaN → Int64, text → string |
df.astype({'units': 'Int64'}) | cast per column; float-with-NaN → nullable Int64 works directly, no NaN cleanup |
dtype= in read_csv | fix types at the boundary, before the DataFrame exists — beats astype afterwards |
dtypes shipped with pandas' earliest public releases in the 0.x era of 2009 — per-column types were a founding design goal, inherited from R's data.frame, which (unlike a numpy 2-D array of a single dtype) lets every column carry its own type. The attribute itself never changed: what kept changing is the type system underneath it.
Nullable integer arrays arrived in 0.24 (Jan 25, 2019). pandas 1.0 (Jan 29, 2020) introduced pd.NA and a dedicated string dtype. pandas 2.0 (2023) added Arrow-backed StringDtype, and pandas 3.0 (Jan 21, 2026) made a str dtype the default for text — the first release where reading dtypes no longer shows object for plain text columns.
A DataFrame stores its columns as 1-D numpy (or Arrow) arrays grouped into blocks by dtype — one block for all int64 columns, one for floats, and so on, stacked inside a BlockManager. df.dtypes doesn't scan or infer anything: it walks the existing blocks and zips their dtype objects with the column names. That's also why heterogeneous frames cost memory — each distinct dtype means another block — and why operations mixing many dtypes force consolidation. The dtype you read in df.dtypes is literally the array's own dtype attribute.