DataFrames
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.
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 and pandas, 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.
- Install
polarsorpandasin your Python environment. - Install
pyrefly. - Write DataFrame code as usual.
- 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 and pandas 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.
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-columnerror. - 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.
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.
DataFrame Construction
Pyrefly infers a Polars schema from a dictionary of columns, passed either
positionally or through data=.
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.
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.
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
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
intcarries no signedness or width, so Polars infersInt64. 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,StringorUtf8,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.
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]