It’s rare that a data analysis involves only a single data frame. Typically you have many data frames, and you must join them together to answer the questions that you’re interested in.
polars has a really rich set of options for combining one or more data frames, with the two most important being concatenate and joins. Some of the examples in this chapter show you how to join a pair of data frames. Fortunately this is enough, since you can combine three data frames by combining two pairs.
Prerequisites
This chapter will use the polars data analysis package. For reading Stata (.dta) files, we’ll use pandas as a bridge (pd.read_stata()) and then convert to polars with pl.from_pandas().
Concatenate
If you have two or more data frames with the same columns, you can glue them together into a single data frame using pl.concat().
For the same columns, use pl.concat() to stack rows vertically. Polars doesn’t have a row index like pandas; pl.concat(how="horizontal") joins DataFrames by position, aligning rows in order.
Here’s an example using data on two different states’ populations:
import pandas as pdimport polars as plbase_url = ("https://github.com/aeturrell/coding-for-economists/raw/refs/heads/main/data/")state_codes = ["ca", "il"]end_url ="pop.dta"# Read the Stata files and convert to polarslist_of_state_dfs = []for state in state_codes: temp_df = pd.read_stata(base_url + state + end_url) list_of_state_dfs.append(pl.from_pandas(temp_df))# shows example of the 2 dataframesprint(list_of_state_dfs[0])print(list_of_state_dfs[1])# concatenate the list of dataframesdf = pl.concat(list_of_state_dfs)df
Note that when concatenating, you can track the origin of rows by adding a column before concatenation:
# Add a state identifier column to each dataframelist_with_state = []for state, df_state inzip(state_codes, list_of_state_dfs): df_state = df_state.with_columns(pl.lit(state).alias("state")) list_with_state.append(df_state)# Concatenate with state identifiersdf_with_state = pl.concat(list_with_state)df_with_state
There are many options for joining data frames using pl.DataFrame.join(). The most important features are: the two data frames to be joined, what variables (aka keys) to join on via on=, and how to do the join (eg left, right, outer, inner) via how=. This diagram shows an example of a left join:
The how= keyword works in the following ways:
how=“left” uses keys from the left data frame only to join.
how=“right” uses keys from the right data frame only to join.
how=“inner” uses keys that appear in both data frames to join.
how=“full” uses all keys from both data frames to join on (full outter join).
how=“semi” returns rows from the left where keys appear in the right.
how=“anti” returns rows from the left where keys do NOT appear in the right.
Note that the key combination of K2 and K0 did not exist in the left-hand data frame, and so its entries in the final data frame are null values. But it does have entries because we chose the keys from the right-hand data frame.
Now we see that the combination K2 and K0 are excluded because they didn’t exist in the overlap of keys in both data frames.
Finally, let’s take a look at an outer join:
left.join(right, on=["key1", "key2"], how="full")
shape: (6, 8)
key1
key2
A
B
key1_right
key2_right
C
D
str
str
str
str
str
str
str
str
"K0"
"K0"
"A0"
"B0"
"K0"
"K0"
"C0"
"D0"
"K1"
"K0"
"A2"
"B2"
"K1"
"K0"
"C1"
"D1"
"K1"
"K0"
"A2"
"B2"
"K1"
"K0"
"C2"
"D2"
null
null
null
null
"K2"
"K0"
"C3"
"D3"
"K0"
"K1"
"A1"
"B1"
null
null
null
null
"K2"
"K1"
"A3"
"B3"
null
null
null
null
This performs a full outer join, keeping all rows from both data frames.
Polars doesn’t include a built-in indicator parameter to track which side contributed each row in a join. On a full join, polars keeps the join keys from both sides with suffixes. However, you can achieve the same effect by comparing the two DataFrames after the join:
# Perform an outer joinjoined = left.join(right, on=["key1", "key2"], how="full")# Add indicator column manually# Rows with nulls in B only came from right# Rows with nulls in D only came from left# Rows with no nulls came from bothjoined = joined.with_columns( pl.when(pl.col("B").is_null() & pl.col("D").is_not_null()) .then(pl.lit("right_only")) .when(pl.col("B").is_not_null() & pl.col("D").is_null()) .then(pl.lit("left_only")) .otherwise(pl.lit("both")) .alias("_merge"))joined
shape: (6, 9)
key1
key2
A
B
key1_right
key2_right
C
D
_merge
str
str
str
str
str
str
str
str
str
"K0"
"K0"
"A0"
"B0"
"K0"
"K0"
"C0"
"D0"
"both"
"K1"
"K0"
"A2"
"B2"
"K1"
"K0"
"C1"
"D1"
"both"
"K1"
"K0"
"A2"
"B2"
"K1"
"K0"
"C2"
"D2"
"both"
null
null
null
null
"K2"
"K0"
"C3"
"D3"
"right_only"
"K2"
"K1"
"A3"
"B3"
null
null
null
null
"left_only"
"K0"
"K1"
"A1"
"B1"
null
null
null
null
"left_only"
TipExercise
Join the following two data frames using the left_on and right_on keyword arguments to specify a join on lkey and rkey respectively:
What do you notice about the resulting row for "baz"?
Semi and Anti Joins
Polars also supports semi and anti joins, which are useful for filtering:
Semi join: Keep rows from the left where keys appear in the right:
# Semi join - only keeps left rows that have matches in rightleft.join(right, on=["key1", "key2"], how="semi")
shape: (2, 4)
key1
key2
A
B
str
str
str
str
"K0"
"K0"
"A0"
"B0"
"K1"
"K0"
"A2"
"B2"
Anti join: Keep rows from the left where keys do NOT appear in the right:
# Anti join - only keeps left rows without matches in rightleft.join(right, on=["key1", "key2"], how="anti")
shape: (2, 4)
key1
key2
A
B
str
str
str
str
"K0"
"K1"
"A1"
"B1"
"K2"
"K1"
"A3"
"B3"
Handling Duplicate Column Names
When joining, if both DataFrames have columns with the same name (other than the join keys), polars will add suffixes to differentiate them. By default it adds _right: