blob: 65e961543fd4aabcd23bc81ebe70f657231d8d8b [file] [edit]
---
title: DataFrames
description: Pyrefly support for Polars and pandas DataFrames, including column and dtype inference.
---
{/*
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/}
# DataFrames
:::warning Experimental
DataFrame support is experimental and a work in progress. In particular,
user-written `DataFrame[Schema]` annotations are a Pyrefly-specific extension
that other type checkers reject. This feature may change and will not be
considered stable until Pyrefly 1.3.
:::
Pyrefly knows which columns your DataFrame has and what type each one holds.
```python
sales = pl.DataFrame({"region": ["East", "West"], "units": [12, 8]})
report = sales.select("region", pl.col("units").alias("items_sold"))
reveal_type(report) # DataFrame[region: String, items_sold: Int64]
reveal_type(report["items_sold"]) # Series[Int64]
report["units"] # Error unknown-column, the column was renamed to items_sold
```
Inline comments show how Pyrefly can infer additional information for DataFrames
and Series.
Pyrefly includes **built-in support** for DataFrames from
[Polars](https://docs.pola.rs/) and
[pandas](https://pandas.pydata.org/docs/), two popular Python libraries for
working with tabular data. Pyrefly tracks column names and data types (dtypes),
reports accesses to columns that cannot exist, and follows schema changes
through common operations.
---
## How to Use
DataFrame inference works automatically in Pyrefly, without any special
configuration.
1. Install `polars` or `pandas` in your Python environment.
2. Install `pyrefly`.
3. Write DataFrame code as usual.
4. Run Pyrefly or use the Pyrefly language server in your editor.
---
## What is a DataFrame?
A DataFrame stores tabular data in named columns. Each column has a logical
data type (dtype) such as integer, string, or date. Together, the ordered
column names and their dtypes form the DataFrame **schema**. The
[Polars](https://docs.pola.rs/user-guide/concepts/data-types/overview/) and
[pandas](https://pandas.pydata.org/docs/user_guide/dsintro.html#dataframe)
user guides describe the underlying data model in more detail.
Your own code decides what the schema is. Every time you select, rename, drop,
or join columns, you change which columns exist further down the file, and a
typo or a stale column name only shows up when the program runs. Library stubs
describe the `DataFrame` class but say nothing about the columns any particular
value holds, so a type checker reading only those stubs cannot catch the
mistake.
```python
sales = pl.DataFrame({"region": ["East", "West"], "units": [12, 8]})
# An ordinary type checker accepts this. Polars raises ColumnNotFoundError.
sales["unit"]
```
Pyrefly reads the same source you wrote and reconstructs the schema from it, so
those mistakes surface in your editor instead of in a traceback. It also carries
the schema through the operations it supports, which means a column you renamed
twenty lines earlier is still tracked correctly at the point where you use it.
---
## Column and Dtype Inference
Pyrefly only records a schema when it can prove one from the source. Anything it
cannot prove degrades to a less precise result rather than to a guess.
### Schema Precision
Pyrefly keeps the most precise representation that static analysis supports.
The three levels below are Pyrefly's own terms, not Polars or pandas concepts.
- A **complete schema** lists every column in the DataFrame. Because the list is
known to be exhaustive, accessing a column that is not in it produces an
`unknown-column` error.
- A **partial schema** lists a set of known columns while allowing the DataFrame
to hold others as well. Pyrefly displays the open end as `...`. Since a
partial schema cannot prove a column is missing, Pyrefly reports no error for
an unrecognized name.
- An **opaque frame** carries no schema at all and behaves exactly like the
ordinary Polars or pandas stub type.
```python
complete = pl.DataFrame({"name": ["Alice"], "age": [30]})
reveal_type(complete) # DataFrame[name: String, age: Int64]
complete["nickname"] # Error unknown-column
partial = pd.DataFrame(data={"name": ["Alice"], "age": [30]})
reveal_type(partial) # DataFrame[name: String, age: Int64, ...]
partial["nickname"] # No error, a partial schema cannot prove the column is absent
opaque = pl.read_csv("data.csv")
reveal_type(opaque) # DataFrame
opaque["nickname"] # No error, there is no schema to check against
```
Pyrefly infers a dtype for every known column by following the library's
widening and coercion rules. When no dtype can be determined, the column keeps
its name and takes the `Unknown` dtype, so an access returns `Series[Unknown]`.
### How Polars and pandas Are Treated Differently
The two libraries get different levels of precision because they offer different
mutation guarantees.
In Polars, the common transformations such as `select`, `drop`, `rename`, and
`with_columns` are immutable and return a new DataFrame with a transformed
schema. That makes the result predictable, so Polars frames usually keep a
complete schema. Polars also offers explicit in-place APIs such as
`insert_column`, `replace_column`, and `hstack` with `in_place=True`. For those,
Pyrefly updates the schema when the mutation is statically known, downgrades to
a partial schema when extra columns may have appeared, and downgrades to an
opaque frame when no reliable schema remains.
In contrast, pandas permits direct column assignment and other open-ended
mutation on an existing DataFrame. A column can be added through
`df[name] = values`, including when `name` is only known at runtime. Pyrefly
therefore treats every inferred pandas schema as partial, keeping columns
defined at construction while allowing untracked columns to exist. This is why
an unrecognized column name is an error on a Polars frame but not on a pandas
one.
---
## Supported Features
For brevity, the examples below omit the imports for `polars`, `pandas`, and the
`typing` names they use, such as `reveal_type` and `Literal`. Unless a section
says otherwise, these features are Polars only. pandas support is described in
[pandas Support](#pandas-support).
### DataFrame Construction
Pyrefly infers a Polars schema from a dictionary of columns, passed either
positionally or through `data=`.
```python
reveal_type(pl.DataFrame({"name": ["Alice"], "age": [30]})) # DataFrame[name: String, age: Int64]
```
A list of dictionary records works the same way. Column names are taken in
first appearance order.
```python
rows = [
{"name": "Alice", "age": 30},
{"name": "Bob", "age": 25},
]
reveal_type(pl.DataFrame(rows)) # DataFrame[name: String, age: Int64]
```
A `TypedDict` whose fields hold supported primitive `Sequence` values also
produces a schema. Optional fields make the schema partial, because they may be
absent at runtime.
```python
class Columns(TypedDict):
name: Sequence[str]
age: Sequence[int]
columns: Columns = {"name": ["Alice"], "age": [30]}
reveal_type(pl.DataFrame(data=columns)) # DataFrame[name: String, age: Int64]
```
Pyrefly inspects the first 100 records, matching the
[Polars default](https://docs.pola.rs/api/python/stable/reference/dataframe/index.html)
of `infer_schema_length=100`. Polars documents that parameter as the maximum
number of rows to scan for schema inference, and notes that it applies only when
the input is a sequence or generator of rows. A call that sets
`infer_schema_length` explicitly is not modeled and produces an opaque
DataFrame.
### Dtype Inference
Polars stores each column under a Polars dtype rather than a Python type, so
Pyrefly has to map the Python values you wrote onto the dtype Polars will
actually choose.
| Python value | Polars dtype |
| --- | --- |
| `int` | `Int64` |
| `float` | `Float64` |
| `bool` | `Boolean` |
| `str` | `String` |
| `bytes` | `Binary` |
| `None` | `Null` when no non-null value establishes another dtype |
| `date(...)` | `Date` |
| `datetime(...)` | `Datetime` |
| `time(...)` | `Time` |
| `timedelta(...)` | `Duration` |
The four temporal rows apply to a constructor call written in place. Variables
and call results that resolve to a supported primitive type contribute their
types in the same way.
> **Note:** A Python `int` carries no signedness or width, so Polars infers
> `Int64`. Use an explicit schema or a schema override to select a different
> integer dtype. Pyrefly preserves signed and unsigned integer widths, floating
> point widths, `Boolean`, `String` or `Utf8`, `Binary`, and unparameterized
> temporal types whenever they are declared explicitly.
Nested `List` and `Struct` dtypes, custom time units, time zones, and integers
outside reliable `i64` inference are not modeled precisely. An unsupported
explicit dtype uses the ordinary stub type.
For a dictionary of columns, strict construction takes the dtype from the first
non-null value, and later values must fit it without widening it. Passing
`strict=False` instead looks for a supported common supertype. Record input
folds supported supertypes across rows even with the default strict setting,
because Polars itself scans the rows before choosing a dtype.
```python
reveal_type(pl.DataFrame({"value": [None, 1, True]})) # DataFrame[value: Int64]
pl.DataFrame({"value": [1, "wrong"]}) # Error column-type-mismatch
reveal_type(pl.DataFrame({"value": [1, 2.5]}, strict=False)) # DataFrame[value: Float64]
# Polars coerces this to String at runtime, but that supertype is outside
# Pyrefly's static model, so the column stays Unknown.
reveal_type(pl.DataFrame({"value": [1, "text"]}, strict=False)) # DataFrame[value: Unknown]
```
### Schema Declaration
`schema=` or the second positional argument supplies authoritative column names,
order, and dtypes. Pyrefly accepts a dictionary, an inline `pl.Schema({...})`,
or a schema class. `schema_overrides=` replaces inferred dtypes for selected
columns.
Declared dtypes are authoritative to Pyrefly. Polars remains responsible for
runtime coercion and can still raise when a value does not fit the declared
dtype. Pyrefly reports `column-schema-mismatch` when statically known data names
do not match the declared names.
A `pl.Schema` value bound to a name or imported from another module is not
modeled. Record input combined with `schema=` is accepted by Polars at runtime
but is not modeled statically.
```python
reveal_type(
pl.DataFrame(
{"delta": [-2], "count": [3]},
schema=pl.Schema({"delta": pl.Int32, "count": pl.UInt32}),
)
) # DataFrame[delta: Int32, count: UInt32]
pl.DataFrame(
{"name": ["Alice"]},
schema={"score": pl.Float64},
) # Error column-schema-mismatch
```
#### Schema Propagation Across Functions
A schema class gives you a reusable contract for function parameters and return
values, so a schema survives a function boundary instead of being erased there.
```python
from __future__ import annotations
class Reading:
station: pl.String
temperature_c: pl.Float64
def load_readings() -> pl.DataFrame[Reading]:
return pl.DataFrame(
{"station": ["North"], "temperature_c": [21.5]},
schema={"station": pl.String, "temperature_c": pl.Float64},
)
def temperatures(frame: pl.DataFrame[Reading]) -> pl.Series:
return frame["temperature_c"]
reveal_type(load_readings()) # DataFrame[station: String, temperature_c: Float64]
```
> **Note:** `DataFrame[Schema]` is a Pyrefly extension, not a standard Polars
> annotation. Polars does not currently define `DataFrame` as generic over its
> schema ([pola-rs/polars#22119](https://github.com/pola-rs/polars/issues/22119)),
> so other type checkers reject the subscript. mypy reports that `DataFrame`
> expects no type arguments and Pyright reports `Expected no type arguments for
> class DataFrame`. `pl.DataFrame` is also not generic at runtime, so executable
> code must quote the annotation or use `from __future__ import annotations`.
> Runtime construction still takes a schema mapping or a `pl.Schema`.
A plain `pl.DataFrame` annotation deliberately erases inferred schema
information at that boundary. Rich schemas appear in hovers and `reveal_type`
output, while infer output, stub generation, inlay hints, and quick fixes emit
the plain `DataFrame` annotation that other tools accept.
### CSV Reading
The previous section declares a schema for data you already hold in memory.
This section covers the same idea for data read from a file. Pyrefly never
inspects file contents, so a CSV reader gets a schema only when you declare one
inline at the call site.
Pyrefly infers complete schemas for `pl.read_csv` and `pl.scan_csv` when
`schema=` is an inline dictionary or inline `pl.Schema`. `read_csv` produces a
typed DataFrame, and `scan_csv` produces a typed LazyFrame whose schema survives
`collect()`.
```python
reveal_type(
pl.read_csv(
"data.csv",
schema={"id": pl.Int64, "name": pl.String},
columns=["name"],
)
) # DataFrame[name: String]
lazy = pl.scan_csv(
"data.csv",
schema={"id": pl.Int64, "name": pl.String},
row_index_name="row",
include_file_paths="path",
)
reveal_type(lazy) # LazyFrame[row: UInt32, id: Int64, name: String, path: String]
reveal_type(lazy.collect()) # DataFrame[row: UInt32, id: Int64, name: String, path: String]
```
Eager readers also model static name or index projections, sequence
`schema_overrides`, `row_index_name`, and prefix renaming through `new_columns`.
Lazy scans model `row_index_name` and `include_file_paths`.
Dynamic schemas and selections, missing or colliding names, and inconsistent
projection or override combinations use the ordinary stubs. Lazy scans do not
model `schema_overrides` or `new_columns`.
### Column Access
A complete Polars schema supports typed bracket accesses, ordered multi-column
projections, `get_column`, and positive or negative `to_series` indexes.
```python
df = pl.DataFrame({"name": ["Alice"], "age": [30], "active": [True]})
reveal_type(df["name"]) # Series[String]
reveal_type(df[["active", "name"]]) # DataFrame[active: Boolean, name: String]
reveal_type(df.get_column("age")) # Series[Int64]
reveal_type(df.to_series(-1)) # Series[Boolean]
NAME: Final = "name"
INDEX: Literal[-1] = -1
reveal_type(df[NAME]) # Series[String]
reveal_type(df.to_series(INDEX)) # Series[Boolean]
```
`Final` and `Literal` values work for column names and indexes. A broad `str`
name or broad `int` index uses the ordinary untyped return and reports no column
error. Static values also resolve construction keys, schema keys, cast keys,
expression names, join modes, and `strict` flags.
### Series Construction
Pyrefly infers `pl.Series(...)` from literal values. An explicit `dtype=` is
authoritative, and `strict=False` uses the same supertype rules as DataFrame
construction.
```python
reveal_type(pl.Series("age", [30, 25])) # Series[Int64]
reveal_type(pl.Series("score", [1, 2], dtype=pl.Float64)) # Series[Float64]
reveal_type(pl.Series("value", [1, 2.5], strict=False)) # Series[Float64]
```
Typed Series accesses require a complete Polars schema. Partial Polars schemas
and pandas schemas return the ordinary Series type.
### Column Transformation
Pyrefly tracks the schema produced by the main Polars column operations.
- `select` narrows and orders columns.
- `drop` removes known columns.
- `rename` changes names while preserving dtypes and order.
- Keyword `with_columns` adds or replaces columns from supported expressions.
- `cast` changes every dtype or selected dtypes from a mapping.
Positional `with_columns`, mapping spreads, keyword `select`, and list-wrapped
`select` expressions use the ordinary stub return types.
```python
df = pl.DataFrame({"name": ["Alice"], "age": [30], "active": [True]})
reveal_type(df.select("age", "name")) # DataFrame[age: Int64, name: String]
reveal_type(df.drop("active")) # DataFrame[name: String, age: Int64]
reveal_type(df.rename({"age": "years"})) # DataFrame[name: String, years: Int64, active: Boolean]
reveal_type(df.cast({"age": pl.Float64})) # DataFrame[name: String, age: Float64, active: Boolean]
```
#### Expression Inference
The expression model understands `pl.col`, `pl.lit`, casts, aliases, arithmetic,
unary operations, comparisons, and modeled reducers. Pyrefly uses these
expressions in keyword `with_columns` and positional `select` calls. Aliases set
the output name of a positional `select` expression, and casts and unary
operations preserve it. Binary operations use the left expression's name, except
that scalar-left comparisons use the right expression's name. Scalar literals
and `pl.lit(...)` use `literal`.
```python
df = pl.DataFrame({"name": ["Alice"], "age": [30]})
reveal_type(
df.with_columns(
next_age=pl.col("age") + 1,
is_adult=pl.col("age") >= 18,
)
) # DataFrame[name: String, age: Int64, next_age: Int64, is_adult: Boolean]
reveal_type(
df.select(
pl.col("name"),
pl.col("age").cast(pl.Float64).alias("score"),
)
) # DataFrame[name: String, score: Float64]
```
`select("*")` preserves the schema. Regular expression selectors and unresolved
selectors produce an opaque frame.
### Grouping and Aggregation
A direct `group_by(...).agg(...)` chain produces the ordered group keys followed
by aggregate columns. Pyrefly models `min`, `max`, `first`, `last`, `mean`,
`median`, `std`, `var`, `count`, `n_unique`, `sum`, and `product`. `count`,
`n_unique`, and `pl.len()` produce `UInt32`.
A GroupBy value saved to a variable, dynamic or rolling grouping, and unmodeled
aggregate expressions use the ordinary stub types.
```python
df = pl.DataFrame({"team": ["a", "a"], "score": [1, 2]})
reveal_type(
df.group_by("team").agg(
pl.col("score").sum().alias("total"),
pl.col("score").mean().alias("average"),
pl.len().alias("rows"),
)
) # DataFrame[team: String, total: Int64, average: Float64, rows: UInt32]
```
### DataFrame Combination
Pyrefly models common ways to combine complete Polars schemas.
- `hstack` appends non-colliding columns from another schema-aware frame.
- `vstack` and `extend` preserve the receiver schema because they add rows.
- `pl.concat` supports `vertical` and `vertical_relaxed` for inline list or
tuple inputs.
- `join` supports same-name `on=` keys and the `semi`, `anti`, `inner`, `left`,
`right`, `full`, and `cross` modes with default suffix and coalesce behavior.
Separate `left_on` and `right_on` keys, custom suffixes, explicit coalesce
settings, other concat modes, mismatched concat schemas, and `hstack` with a
list of Series are not modeled.
```python
left = pl.DataFrame({"id": [1], "name": ["Alice"]})
right = pl.DataFrame({"id": [1], "score": [4.5]})
reveal_type(
left.join(right, on="id", how="inner")
) # DataFrame[id: Int64, name: String, score: Float64]
extra = pl.DataFrame({"active": [True]})
reveal_type(left.hstack(extra)) # DataFrame[id: Int64, name: String, active: Boolean]
integers = pl.DataFrame({"value": [1]})
floats = pl.DataFrame({"value": [2.5]})
reveal_type(pl.concat([integers, floats], how="vertical_relaxed")) # DataFrame[value: Float64]
```
### Schema Preservation and Mutation
Operations that change rows without changing columns preserve the schema,
including `filter`, `sort`, `drop_nulls`, `unique`, `head`, and `slice`. The
`lazy()` and `collect()` pair also preserves the schema while changing the frame
class.
`fill_null` preserves column names but can change dtypes. Filling with a Python
float widens signed and unsigned integer columns to `Float64` when
`matches_supertype=True`, which is the default. Existing float and nonnumeric
dtypes are unchanged, and `matches_supertype=False` preserves integer dtypes.
Pyrefly models this widening only when the fill value resolves statically to a
Python `float`. Dynamic values and Polars expressions leave the inferred schema
unchanged.
```python
df = pl.DataFrame(
{"count": [1, None], "ratio": [1.0, None], "label": ["x", None]},
schema={"count": pl.Int64, "ratio": pl.Float32, "label": pl.String},
)
value: float = 0.0
reveal_type(df.fill_null(value)) # DataFrame[count: Float64, ratio: Float32, label: String]
reveal_type(df.fill_null(value, matches_supertype=False)) # DataFrame[count: Int64, ratio: Float32, label: String]
```
In-place column mutation is where precision is most often lost.
| Operation | Pyrefly result |
| --- | --- |
| `insert_column` with a static index and Series name | The schema stays complete and the inserted name receives the `Unknown` dtype. |
| `insert_column` with an unresolved position or value | The schema becomes partial. |
| `hstack` with `in_place=True` | The schema becomes partial. |
| `replace_column` | The frame becomes opaque. |
```python
df = pl.DataFrame({"name": ["Alice"], "age": [30]})
reveal_type(df.filter(pl.col("age") > 18).head(1)) # DataFrame[name: String, age: Int64]
reveal_type(df.lazy().select("age").collect()) # DataFrame[age: Int64]
df.insert_column(1, pl.Series("active", [True]))
reveal_type(df) # DataFrame[name: String, active: Unknown, age: Int64]
```
### pandas Support
Everything above this section describes Polars. pandas gets a smaller feature
set, because its open-ended mutation model means an inferred schema is always
partial.
Pyrefly infers visible primitive columns from a pandas dictionary constructor
passed positionally or through `data=`. A static `columns=` list projects and
reorders known columns. Because the schema is partial, column accesses return
the ordinary pandas Series type and an unrecognized name is not reported as an
error.
Record input, `TypedDict`, mixed or temporal column values, `None`, `dtype=`,
subclasses, and uncertain coercions use the ordinary pandas stubs. A projected
name missing from the input also produces an opaque frame, and pandas CSV
readers remain opaque.
```python
df = pd.DataFrame(
data={"name": ["Alice"], "age": [30], "active": [True]},
columns=["age", "name"],
)
reveal_type(df) # DataFrame[age: Int64, name: String, ...]
reveal_type(df["age"]) # Series
```
---
## Diagnostics
DataFrame diagnostics use the normal Pyrefly suppression and severity
configuration.
- [`unknown-column`](../error-kinds/#unknown-column) reports a statically known
name that is absent from a complete schema.
- [`column-type-mismatch`](../error-kinds/#column-type-mismatch) reports a
strict Polars construction value that does not fit the dtype established by
the first non-null value.
- [`column-schema-mismatch`](../error-kinds/#column-schema-mismatch) reports
statically known data names that do not match declared schema names.
- [`duplicate-column`](../error-kinds/#duplicate-column) reports a projection
that would produce two columns with the same name, which Polars rejects when
an eager projection runs or a lazy projection is collected.
---
## Feedback
Pyrefly's DataFrame support continues to evolve. If you encounter a missing
operation or an incorrect result, please
[open a GitHub issue](https://github.com/facebook/pyrefly/issues) so we can
prioritize it.