'1.44.2'
Lecture 09
polars (2020) is a newer data frame library written in Rust on top of Apache Arrow’s memory layout, with bindings for Python, R, and JavaScript. Rather than extend NumPy conventions it was designed from scratch around a few ideas,
No index - rows are only ever positions
A strict schema - columns have explicit types and incompatible values are rejected by default; expressions can promote types or replace columns
Expressions - computations on columns are objects, built up and then evaluated in a context
Lazy evaluation - queries are planned and optimized before running, on all available cores
A polars Series is a name, a dtype, and values - nothing else. With no index there is nothing to align on, so arithmetic is positional and lengths must match (or be length one).
Print method shows the shape and column dtypes. Note that the integer columns stayed integers despite the missing values, and that "NA" had to be declared as a missing value marker.
| species | island | bill_length_mm | … | body_mass_g | sex | year |
|---|---|---|---|---|---|---|
| str | str | f64 | … | i64 | str | i64 |
| "Adelie" | "Torgersen" | 39.1 | … | 3750 | "male" | 2007 |
| "Adelie" | "Torgersen" | 39.5 | … | 3800 | "female" | 2007 |
| "Adelie" | "Torgersen" | 40.3 | … | 3250 | "female" | 2007 |
| … | … | … | … | … | … | … |
| "Chinstrap" | "Dream" | 49.6 | … | 3775 | "male" | 2009 |
| "Chinstrap" | "Dream" | 50.8 | … | 4100 | "male" | 2009 |
| "Chinstrap" | "Dream" | 50.2 | … | 3775 | "female" | 2009 |
Without an index there is no .loc / .iloc distinction - df[...] takes column names or row positions and a slice is just a slice.
Missing data is null in every dtype (like R’s NA) and NaN is a floating point value, distinct from missing.
Aggregation functions like mean() and sum() skip null by default and propagate NaN. Ordinary min() and max() ignore NaN; nan_min() and nan_max() propagate it.
pl.col("x") does not return a column, it returns an expression - a description of a computation that is not attached to any DataFrame and has not been evaluated. Expressions compose with operators and methods and are only evaluated when handed to a context. This is polars’ answer to data masking - build an object that represents the computation.
A context is a DataFrame method that evaluates expressions against its columns,
| Context | Result | dplyr |
|---|---|---|
select() |
a new frame containing only these expressions | select(), summarize() |
with_columns() |
the frame with these expressions added | mutate() |
filter() |
the rows where these expressions are true | filter() |
group_by().agg() |
one row per group of these expressions | group_by() |> summarize() |
Predicates work like pandas boolean masks: & and | with each comparison parenthesized, and is_in() in place of isin(). The one addition is that filter() accepts several predicates and ANDs them.
One expression can expand to many columns - by listing names, by dtype, or with selectors (from polars.selectors). This is similar to dplyr’s across().
group_by().agg() is the polars group_by() |> summarize() - each expression is evaluated once per group and the result has one row per group (in no guaranteed order). For a grouped mutate, add .over("group") to an expression and it is computed within each group but returns a value for every row.
Default to_pandas() converts nullable integer columns to floats. Arrow extension arrays preserve their integer types and nulls.
species large_string[pyarrow]
island large_string[pyarrow]
bill_length_mm double[pyarrow]
bill_depth_mm double[pyarrow]
flipper_length_mm int64[pyarrow]
body_mass_g int64[pyarrow]
sex large_string[pyarrow]
year int64[pyarrow]
dtype: object
Using polars and data/flights.parquet, repeat the questions from last lecture’s pandas exercise. Questions 1 and 2 are the core; 3 and 4 are extensions if time allows.
How many flights to LAX did each legacy carrier (AA, UA, DL, US) have in May from JFK, and what was their mean actual air time (air_time)? Report only carriers with matching records.
Which plane (tailnum) has the most flight records from each New York airport? Exclude missing tail numbers and return all ties.
Which five calendar dates (year, month, day) had the lowest mean departure delay? Skip missing delays and break ties by earliest date.
Which flight has the largest arrival delay as a percentage of its actual air time (100 * arr_delay / air_time)? Exclude missing values and nonpositive air times, and return all ties.
Happy families are all alike; every unhappy family is unhappy in its own way
— Leo Tolstoy, Anna Karenina
Is this data tidy? What are the variables, and what is an observation?
# A tibble: 317 × 7
artist track date.entered wk1 wk2 wk3 wk4
<chr> <chr> <date> <dbl> <dbl> <dbl> <dbl>
1 2 Pac Baby Don't Cry (Keep... 2000-02-26 87 82 72 77
2 2Ge+her The Hardest Part Of ... 2000-09-02 91 87 92 NA
3 3 Doors Down Kryptonite 2000-04-08 81 70 68 67
4 3 Doors Down Loser 2000-10-21 76 76 72 69
5 504 Boyz Wobble Wobble 2000-04-15 57 34 25 17
6 98^0 Give Me Just One Nig... 2000-08-19 51 39 34 26
# ℹ 311 more rows
pivot_longer() stacks a set of columns into a pair of columns - one holding the old column names and one holding the values. The columns to pivot are chosen with tidy selection.
Column names are strings, so the new names column is a character vector that often needs further work. pivot_longer() can strip a prefix, convert the type, and drop the structural missing values created by the wide layout.
# A tibble: 5,307 × 5
artist track date.entered week rank
<chr> <chr> <date> <int> <dbl>
1 2 Pac Baby Don't Cry (Keep... 2000-02-26 1 87
2 2 Pac Baby Don't Cry (Keep... 2000-02-26 2 82
3 2 Pac Baby Don't Cry (Keep... 2000-02-26 3 72
4 2 Pac Baby Don't Cry (Keep... 2000-02-26 4 77
5 2 Pac Baby Don't Cry (Keep... 2000-02-26 5 87
6 2 Pac Baby Don't Cry (Keep... 2000-02-26 6 94
# ℹ 5,301 more rows
pandas calls this melt() and polars unpivot(). Both name the columns to keep (id_vars / index) and pivot everything else unless told otherwise. wide_pd and wide_pl hold the same data as table4a.
pivot_wider() is the inverse - the values of one column become new column names and the values of another fill them. The remaining columns (id_cols) identify the rows.
# A tibble: 6 × 4
country year cases population
<chr> <dbl> <dbl> <dbl>
1 Afghanistan 1999 745 19987071
2 Afghanistan 2000 2666 20595360
3 Brazil 1999 37737 172006362
4 Brazil 2000 80488 174504898
5 China 1999 212258 1272915272
6 China 2000 213766 1280428583
Both use pivot(). pandas moves the id columns into a row MultiIndex and names the column index after the pivoted column, polars returns ordinary columns. long_pd and long_pl hold the same data as table2.
| country | year | cases | population |
|---|---|---|---|
| str | i64 | i64 | i64 |
| "Afghanistan" | 1999 | 745 | 19987071 |
| "Afghanistan" | 2000 | 2666 | 20595360 |
| "Brazil" | 1999 | 37737 | 172006362 |
| "Brazil" | 2000 | 80488 | 174504898 |
| "China" | 1999 | 212258 | 1272915272 |
| "China" | 2000 | 213766 | 1280428583 |
If more than one row maps to the same cell a pivot wider is ambiguous - tidyr warns and returns list columns, pandas and polars error. Each has a way to aggregate as part of the pivot: values_fn in tidyr, pivot_table() in pandas, and aggregate_function in polars.
The code below counts the penguins of each species measured on each island, species and island pairs that do not appear were never observed together.
Using tidyr and then polars, construct a contingency table of these counts with islands as rows and species as columns, with zeros for the missing pairs.
Tidy data is often spread across several tables, each describing one kind of thing - nycflights13 has flights, airlines, planes, airports, and weather. Tables are connected by keys, columns whose values identify the matching rows in the other table. Two small tables make the behavior easy to see,
Mutating joins add the columns of y to x, and differ only in which unmatched rows are kept. Filtering joins keep or drop rows of x based on whether they have a match in y, without adding any columns.
One function per join type, with keys given by by. If by is omitted all columns with common names are used, with a message. Unmatched rows are filled with NA.
A single method, merge(), with the type given by how (default is "inner"). indicator=True adds a column recording where each row came from.
join() also takes the join method via how with a default of "inner".
Keys rarely share a name across tables. dplyr uses join_by(), pandas and polars use left_on and right_on. pandas keeps both key columns in the result, dplyr and polars keep only the left.
A row is repeated once for every match, so duplicated keys silently change the number of rows. If you expect a key to be unique, say so and the join will check - relationship in dplyr, validate in pandas and polars.
semi_join() keeps the rows of x with a match in y and anti_join() the rows without one. polars has both as how options, pandas has neither and uses isin() instead.
dplyr and pandas treat NA as equal to NA, so rows with missing keys are paired up even though nothing is known about either. polars follows SQL, where null is never equal to anything, so a row with a null key is unmatched.
dplyr switches to the SQL behavior with na_matches = "never" and polars to the dplyr behavior with nulls_equal=True. pandas has no equivalent, so the rows with missing keys must be dropped before merging.
data/planes.parquet contains a record for each plane (tailnum) known to the FAA, including its manufacturer, model, and number of seats. Using dplyr and then polars, together with data/flights.parquet, answer the following.
How many flights have a tail number with no matching record in planes? Which carrier accounts for the most of them?
Which five manufacturers’ planes flew the most flights out of New York?
Both tables have a year column. What happens to these columns in a join, and do they mean the same thing?
| Concept | R / tibble | pandas | polars |
|---|---|---|---|
| column | atomic vector | Series (values + index) |
Series (values only) |
| row labels | row.names, rarely used |
Index, central |
none |
| alignment | by position, with recycling | by index label | by position, lengths must match |
| mixed types | coerced to a common type | object dtype |
error |
| missing values | NA in any type |
NaN / None / NaT / pd.NA by dtype |
null in any type |
| column references | data masking | strings, callables, pd.col() |
pl.col() expressions |
| mutation | copy-on-modify | in place, copy-on-write for subsets | methods return new frames |
| grouped result | keys as columns | keys as (Multi)Index | keys as columns |
| laziness | none (dbplyr, dtplyr) | none | LazyFrame + optimizer |
| engine | C, single threaded | NumPy, single threaded | Rust + Arrow, multithreaded |
| dplyr | pandas | polars |
|---|---|---|
filter(x > 1) |
query("x > 1"), [pd.col("x") > 1] |
filter(pl.col("x") > 1) |
select(x, y) |
[["x", "y"]], filter(regex=) |
select("x", "y"), select(cs.…()) |
mutate(z = x * 2) |
assign(z = pd.col("x") * 2) |
with_columns(z = pl.col("x") * 2) |
arrange(desc(x)) |
sort_values("x", ascending=False) |
sort("x", descending=True) |
summarize(m = mean(x)) |
agg(m = ("x", "mean")) |
select(m = pl.col("x").mean()) |
group_by(g) |
groupby("g", as_index=False) |
group_by("g") |
mutate(m = mean(x), .by = g) |
groupby("g")["x"].transform("mean") |
pl.col("x").mean().over("g") |
across(where(is.numeric), f) |
select_dtypes("number").apply(f) |
cs.numeric().f() |
rename(new = old) |
rename(columns={"old": "new"}) |
rename({"old": "new"}) |
left_join(y, by = "k") |
merge(y, on="k", how="left") |
join(y, on="k", how="left") |
anti_join(y, by = "k") |
[~pd.col("k").isin(y["k"])] |
join(y, on="k", how="anti") |
pivot_longer() |
melt() |
unpivot() |
pivot_wider() |
pivot(), pivot_table() |
pivot() |
polars drops the index and adds a strict schema, null for every dtype, expressions evaluated inside contexts, and a lazy mode with a query optimizer - much of which will look familiar again when we get to SQL and DuckDB.
An expression is polars’ answer to data masking - pl.col() builds an object describing a computation, and the same expression can be reused in select(), with_columns(), filter(), and agg().
Pivoting moves information between column names and cell values. The three libraries differ mostly in naming, but pandas returns the id columns as an index and a wider pivot fails on duplicates unless an aggregation is given.
Joins differ in which unmatched rows they keep. Check the row count before and after, state the expected relationship so that duplicate keys are caught, and remember that polars does not match null keys while dplyr and pandas do.
Sta 523 - Fall 2026