19. Polars#

In addition to what’s in Anaconda, this lecture will need the following libraries:

!pip install --upgrade polars yfinance

Hide code cell output

Collecting polars
  Downloading polars-1.43.2-py3-none-any.whl.metadata (11 kB)
Collecting yfinance
  Downloading yfinance-1.5.2-py2.py3-none-any.whl.metadata (6.2 kB)
Collecting polars-runtime-32==1.43.2 (from polars)
  Downloading polars_runtime_32-1.43.2-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl.metadata (1.5 kB)
Requirement already satisfied: pandas>=1.3.0 in /home/runner/miniconda3/envs/quantecon/lib/python3.13/site-packages (from yfinance) (3.0.3)
Requirement already satisfied: numpy>=1.16.5 in /home/runner/miniconda3/envs/quantecon/lib/python3.13/site-packages (from yfinance) (2.4.6)
Requirement already satisfied: requests>=2.31 in /home/runner/miniconda3/envs/quantecon/lib/python3.13/site-packages (from yfinance) (2.34.2)
Collecting multitasking>=0.0.7 (from yfinance)
  Downloading multitasking-0.0.13-py3-none-any.whl.metadata (16 kB)
Requirement already satisfied: platformdirs>=2.0.0 in /home/runner/miniconda3/envs/quantecon/lib/python3.13/site-packages (from yfinance) (4.9.4)
Collecting pytz>=2022.5 (from yfinance)
  Downloading pytz-2026.3.post1-py2.py3-none-any.whl.metadata (22 kB)
Collecting peewee>=3.16.2 (from yfinance)
  Downloading peewee-4.3.0-py3-none-any.whl.metadata (10 kB)
Requirement already satisfied: beautifulsoup4>=4.11.1 in /home/runner/miniconda3/envs/quantecon/lib/python3.13/site-packages (from yfinance) (4.15.0)
Collecting curl_cffi>=0.15 (from yfinance)
  Downloading curl_cffi-0.16.0-cp310-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl.metadata (17 kB)
Requirement already satisfied: protobuf>=3.19.0 in /home/runner/miniconda3/envs/quantecon/lib/python3.13/site-packages (from yfinance) (6.33.5)
Requirement already satisfied: websockets>=13.0 in /home/runner/miniconda3/envs/quantecon/lib/python3.13/site-packages (from yfinance) (16.0)
Requirement already satisfied: soupsieve>=1.6.1 in /home/runner/miniconda3/envs/quantecon/lib/python3.13/site-packages (from beautifulsoup4>=4.11.1->yfinance) (2.8.4)
Requirement already satisfied: typing-extensions>=4.0.0 in /home/runner/miniconda3/envs/quantecon/lib/python3.13/site-packages (from beautifulsoup4>=4.11.1->yfinance) (4.16.0)
Requirement already satisfied: cffi>=2.0.0 in /home/runner/miniconda3/envs/quantecon/lib/python3.13/site-packages (from curl_cffi>=0.15->yfinance) (2.1.0)
Requirement already satisfied: certifi>=2024.2.2 in /home/runner/miniconda3/envs/quantecon/lib/python3.13/site-packages (from curl_cffi>=0.15->yfinance) (2026.6.17)
Requirement already satisfied: pycparser in /home/runner/miniconda3/envs/quantecon/lib/python3.13/site-packages (from cffi>=2.0.0->curl_cffi>=0.15->yfinance) (3.0)
Requirement already satisfied: python-dateutil>=2.8.2 in /home/runner/miniconda3/envs/quantecon/lib/python3.13/site-packages (from pandas>=1.3.0->yfinance) (2.9.0.post0)
Requirement already satisfied: six>=1.5 in /home/runner/miniconda3/envs/quantecon/lib/python3.13/site-packages (from python-dateutil>=2.8.2->pandas>=1.3.0->yfinance) (1.17.0)
Requirement already satisfied: charset_normalizer<4,>=2 in /home/runner/miniconda3/envs/quantecon/lib/python3.13/site-packages (from requests>=2.31->yfinance) (3.4.7)
Requirement already satisfied: idna<4,>=2.5 in /home/runner/miniconda3/envs/quantecon/lib/python3.13/site-packages (from requests>=2.31->yfinance) (3.18)
Requirement already satisfied: urllib3<3,>=1.26 in /home/runner/miniconda3/envs/quantecon/lib/python3.13/site-packages (from requests>=2.31->yfinance) (2.7.0)
Downloading polars-1.43.2-py3-none-any.whl (847 kB)
?25l   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 0.0/847.1 kB ? eta -:--:--
   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 847.1/847.1 kB 42.3 MB/s  0:00:00
?25hDownloading polars_runtime_32-1.43.2-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl (57.3 MB)
?25l   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 0.0/57.3 MB ? eta -:--:--
   ━━━━━━━━━━━━━━━━━━━━╸━━━━━━━━━━━━━━━━━━━ 29.9/57.3 MB 148.7 MB/s eta 0:00:01
   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╸ 57.1/57.3 MB 159.7 MB/s eta 0:00:01
   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 57.3/57.3 MB 129.4 MB/s  0:00:00
?25hDownloading yfinance-1.5.2-py2.py3-none-any.whl (144 kB)
Downloading curl_cffi-0.16.0-cp310-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl (13.5 MB)
?25l   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 0.0/13.5 MB ? eta -:--:--
   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 13.5/13.5 MB 265.5 MB/s  0:00:00
?25hDownloading multitasking-0.0.13-py3-none-any.whl (16 kB)
Downloading peewee-4.3.0-py3-none-any.whl (179 kB)
Downloading pytz-2026.3.post1-py2.py3-none-any.whl (508 kB)
Installing collected packages: pytz, multitasking, polars-runtime-32, peewee, polars, curl_cffi, yfinance
?25l
   ━━━━━━━━━━━╺━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 2/7 [polars-runtime-32]
   ━━━━━━━━━━━╺━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 2/7 [polars-runtime-32]
   ━━━━━━━━━━━╺━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 2/7 [polars-runtime-32]
   ━━━━━━━━━━━╺━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 2/7 [polars-runtime-32]
   ━━━━━━━━━━━╺━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 2/7 [polars-runtime-32]
   ━━━━━━━━━━━╺━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 2/7 [polars-runtime-32]
   ━━━━━━━━━━━━━━━━━╺━━━━━━━━━━━━━━━━━━━━━━ 3/7 [peewee]
   ━━━━━━━━━━━━━━━━━━━━━━╸━━━━━━━━━━━━━━━━━ 4/7 [polars]
   ━━━━━━━━━━━━━━━━━━━━━━╸━━━━━━━━━━━━━━━━━ 4/7 [polars]
   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━╸━━━━━━━━━━━ 5/7 [curl_cffi]
   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━╸━━━━━━━━━━━ 5/7 [curl_cffi]
   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 7/7 [yfinance]
Successfully installed curl_cffi-0.16.0 multitasking-0.0.13 peewee-4.3.0 polars-1.43.2 polars-runtime-32-1.43.2 pytz-2026.3.post1 yfinance-1.5.2

19.1. Overview#

Polars is a fast data manipulation library for Python written in Rust.

It has gained significant popularity as a modern alternative to pandas due to its performance advantages.

Polars is designed with performance and memory efficiency in mind, leveraging:

Tip

Why consider Polars over pandas?

  • Memory: pandas typically needs 5–10x your dataset size in RAM; Polars needs only 2–4x

  • Speed: Polars is 10–100x faster for many common operations

  • See: Polars TPC-H benchmarks for up-to-date performance comparisons

Throughout the lecture, we will assume that the following imports have taken place

import polars as pl
import numpy as np
import matplotlib.pyplot as plt

Like Pandas, Polars defines two important data types: Series and DataFrame.

You can think of a Series as a column of data, such as a collection of observations on a single variable.

A DataFrame is a two-dimensional object for storing related columns of data.

19.2. Series#

Let’s start with Series.

We begin by creating a series of four random observations

s = pl.Series(name='daily returns', values=np.random.randn(4))
s
shape: (4,)
daily returns
f64
-1.0887
0.051126
-0.470457
-1.014881

Note

Unlike pandas Series, Polars Series have no row index. Polars is column-centric — data access is managed through column expressions and boolean masks rather than row labels. See the Polars migration guide for pandas users for more detail.

Polars Series are built on top of Apache Arrow arrays and support many familiar operations

s * 100
shape: (4,)
daily returns
f64
-108.870007
5.112558
-47.045728
-101.488137

Absolute values are available as a method

s.abs()
shape: (4,)
daily returns
f64
1.0887
0.051126
0.470457
1.014881

We can also get quick summary statistics

s.describe()
shape: (9, 2)
statisticvalue
strf64
"count"4.0
"null_count"0.0
"mean"-0.630728
"std"0.53164
"min"-1.0887
"25%"-1.014881
"50%"-0.470457
"75%"-0.470457
"max"0.051126

Since Polars has no row index, labelled data requires a DataFrame.

For example, to associate ticker symbols with returns:

df = pl.DataFrame({
    'company': ['AMZN', 'AAPL', 'MSFT', 'GOOG'],
    'daily returns': np.random.randn(4)
})
df
shape: (4, 2)
companydaily returns
strf64
"AMZN"-1.852153
"AAPL"0.008383
"MSFT"-0.482803
"GOOG"-2.283774

We access a value by filtering on a column expression

df.filter(
    pl.col('company') == 'AMZN'
).select('daily returns').item()
-1.8521529661706666

Updates also use expressions rather than index assignment

df = df.with_columns(
    pl.when(pl.col('company') == 'AMZN')
    .then(0)
    .otherwise(pl.col('daily returns'))
    .alias('daily returns')
)
df
shape: (4, 2)
companydaily returns
strf64
"AMZN"0.0
"AAPL"0.008383
"MSFT"-0.482803
"GOOG"-2.283774

We can also check membership

'AAPL' in df['company']
True

19.3. DataFrames#

While a Series is a single column of data, a DataFrame is several columns, one for each variable.

As in Pandas, let’s work with data from the Penn World Tables.

We read this in using pl.read_csv

url = ('https://raw.githubusercontent.com/QuantEcon/'
       'lecture-python-programming/main/lectures/_static/'
       'lecture_specific/pandas/data/test_pwt.csv')
df = pl.read_csv(url)
df
shape: (8, 8)
countrycountry isocodeyearPOPXRATtcgdpcccg
strstri64f64f64f64f64f64
"Argentina""ARG"200037335.6530.9995295072.2186975.7168055.578804
"Australia""AUS"200019053.1861.72483541804.652167.7590266.720098
"India""IND"20001.0063e644.94161.7281e664.57555114.072206
"Israel""ISR"20006114.574.07733129253.8942364.43645110.266688
"Malawi""MWI"200011801.50559.5438085026.22178474.70762411.658954
"South Africa""ZAF"200045064.0986.93983227242.3694972.718715.726546
"United States""USA"2000282171.9571.09.8987e672.3470546.032454
"Uruguay""URY"20003219.79312.09959225255.96169378.978745.108068

19.3.1. Selecting data#

We can select rows by slicing and columns by name

df[2:5]
shape: (3, 8)
countrycountry isocodeyearPOPXRATtcgdpcccg
strstri64f64f64f64f64f64
"India""IND"20001.0063e644.94161.7281e664.57555114.072206
"Israel""ISR"20006114.574.07733129253.8942364.43645110.266688
"Malawi""MWI"200011801.50559.5438085026.22178474.70762411.658954

To select specific columns, pass a list of names to select

df.select(['country', 'tcgdp'])
shape: (8, 2)
countrytcgdp
strf64
"Argentina"295072.21869
"Australia"541804.6521
"India"1.7281e6
"Israel"129253.89423
"Malawi"5026.221784
"South Africa"227242.36949
"United States"9.8987e6
"Uruguay"25255.961693

These can be combined

df[2:5].select(['country', 'tcgdp'])
shape: (3, 2)
countrytcgdp
strf64
"India"1.7281e6
"Israel"129253.89423
"Malawi"5026.221784

19.3.2. Filtering by conditions#

The filter method accepts boolean expressions built from pl.col

df.filter(pl.col('POP') >= 20000)
shape: (4, 8)
countrycountry isocodeyearPOPXRATtcgdpcccg
strstri64f64f64f64f64f64
"Argentina""ARG"200037335.6530.9995295072.2186975.7168055.578804
"India""IND"20001.0063e644.94161.7281e664.57555114.072206
"South Africa""ZAF"200045064.0986.93983227242.3694972.718715.726546
"United States""USA"2000282171.9571.09.8987e672.3470546.032454

Multiple conditions can be combined with & (and) and | (or)

df.filter(
    (pl.col('country').is_in(['Argentina', 'India', 'South Africa'])) &
    (pl.col('POP') > 40000)
)
shape: (2, 8)
countrycountry isocodeyearPOPXRATtcgdpcccg
strstri64f64f64f64f64f64
"India""IND"20001.0063e644.94161.7281e664.57555114.072206
"South Africa""ZAF"200045064.0986.93983227242.3694972.718715.726546

Expressions can involve arithmetic across columns

df.filter(
    (pl.col('cc') + pl.col('cg') >= 80) & (pl.col('POP') <= 20000)
)
shape: (2, 8)
countrycountry isocodeyearPOPXRATtcgdpcccg
strstri64f64f64f64f64f64
"Malawi""MWI"200011801.50559.5438085026.22178474.70762411.658954
"Uruguay""URY"20003219.79312.09959225255.96169378.978745.108068

Select the country with the largest household consumption share

df.filter(pl.col('cc') == pl.col('cc').max())
shape: (1, 8)
countrycountry isocodeyearPOPXRATtcgdpcccg
strstri64f64f64f64f64f64
"Uruguay""URY"20003219.79312.09959225255.96169378.978745.108068

19.3.3. Column expressions#

A key difference from pandas is that Polars uses column expressions for transformations rather than element-wise apply calls.

Here is an example computing the max of each numeric column

df.select(
    pl.col(['year', 'POP', 'XRAT', 'tcgdp', 'cc', 'cg'])
    .max()
    .name.suffix('_max')
)
shape: (1, 6)
year_maxPOP_maxXRAT_maxtcgdp_maxcc_maxcg_max
i64f64f64f64f64f64
20001.0063e659.5438089.8987e678.9787414.072206

Expressions can be used inside with_columns to add or modify columns

df.with_columns(
    (pl.col('XRAT') / 10).alias('XRAT_scaled'),
    pl.col(pl.Float64).round(2)
)
shape: (8, 9)
countrycountry isocodeyearPOPXRATtcgdpcccgXRAT_scaled
strstri64f64f64f64f64f64f64
"Argentina""ARG"200037335.651.0295072.2275.725.580.09995
"Australia""AUS"200019053.191.72541804.6567.766.720.172483
"India""IND"20001006300.344.941.7281e664.5814.074.49416
"Israel""ISR"20006114.574.08129253.8964.4410.270.407733
"Malawi""MWI"200011801.559.545026.2274.7111.665.954381
"South Africa""ZAF"200045064.16.94227242.3772.725.730.693983
"United States""USA"2000282171.961.09.8987e672.356.030.1
"Uruguay""URY"20003219.7912.125255.9678.985.111.209959

Conditional logic uses pl.when(...).then(...).otherwise(...)

df.with_columns(
    pl.when(pl.col('POP') >= 20000)
    .then(pl.col('POP'))
    .otherwise(None)
    .alias('POP_filtered')
).select(['country', 'POP', 'POP_filtered'])
shape: (8, 3)
countryPOPPOP_filtered
strf64f64
"Argentina"37335.65337335.653
"Australia"19053.186null
"India"1.0063e61.0063e6
"Israel"6114.57null
"Malawi"11801.505null
"South Africa"45064.09845064.098
"United States"282171.957282171.957
"Uruguay"3219.793null

Note

Polars provides map_elements as an escape hatch for applying arbitrary Python functions row-by-row, but it bypasses the optimized expression engine and should be avoided when a native expression exists.

19.3.4. Missing values#

Let’s insert some null values to demonstrate imputation techniques

df_nulls = df.with_row_index().with_columns(
    pl.when(pl.col('index') == 0)
    .then(None).otherwise(pl.col('XRAT')).alias('XRAT'),
    pl.when(pl.col('index') == 3)
    .then(None).otherwise(pl.col('cc')).alias('cc'),
    pl.when(pl.col('index') == 5)
    .then(None).otherwise(pl.col('tcgdp')).alias('tcgdp'),
    pl.when(pl.col('index') == 6)
    .then(None).otherwise(pl.col('POP')).alias('POP'),
).drop('index')
df_nulls
shape: (8, 8)
countrycountry isocodeyearPOPXRATtcgdpcccg
strstri64f64f64f64f64f64
"Argentina""ARG"200037335.653null295072.2186975.7168055.578804
"Australia""AUS"200019053.1861.72483541804.652167.7590266.720098
"India""IND"20001.0063e644.94161.7281e664.57555114.072206
"Israel""ISR"20006114.574.07733129253.89423null10.266688
"Malawi""MWI"200011801.50559.5438085026.22178474.70762411.658954
"South Africa""ZAF"200045064.0986.93983null72.718715.726546
"United States""USA"2000null1.09.8987e672.3470546.032454
"Uruguay""URY"20003219.79312.09959225255.96169378.978745.108068

Fill all nulls with zero

df_nulls.fill_null(0)
shape: (8, 8)
countrycountry isocodeyearPOPXRATtcgdpcccg
strstri64f64f64f64f64f64
"Argentina""ARG"200037335.6530.0295072.2186975.7168055.578804
"Australia""AUS"200019053.1861.72483541804.652167.7590266.720098
"India""IND"20001.0063e644.94161.7281e664.57555114.072206
"Israel""ISR"20006114.574.07733129253.894230.010.266688
"Malawi""MWI"200011801.50559.5438085026.22178474.70762411.658954
"South Africa""ZAF"200045064.0986.939830.072.718715.726546
"United States""USA"20000.01.09.8987e672.3470546.032454
"Uruguay""URY"20003219.79312.09959225255.96169378.978745.108068

Or fill with column means

cols = ['cc', 'tcgdp', 'POP', 'XRAT']
df_nulls.with_columns(
    pl.col(cols).fill_null(pl.col(cols).mean())
)
shape: (8, 8)
countrycountry isocodeyearPOPXRATtcgdpcccg
strstri64f64f64f64f64f64
"Argentina""ARG"200037335.65318.618141295072.2186975.7168055.578804
"Australia""AUS"200019053.1861.72483541804.652167.7590266.720098
"India""IND"20001.0063e644.94161.7281e664.57555114.072206
"Israel""ISR"20006114.574.07733129253.8942372.40050210.266688
"Malawi""MWI"200011801.50559.5438085026.22178474.70762411.658954
"South Africa""ZAF"200045064.0986.939831.8033e672.718715.726546
"United States""USA"2000161269.8717141.09.8987e672.3470546.032454
"Uruguay""URY"20003219.79312.09959225255.96169378.978745.108068

Polars also supports forward fill (fill_null(strategy='forward')) and interpolation.

There are more advanced imputation tools available in scikit-learn.

19.3.5. Visualization#

Let’s build a GDP per capita column and plot it

df = (df
    .select(['country', 'POP', 'tcgdp'])
    .rename({'POP': 'population', 'tcgdp': 'total GDP'})
    .with_columns(
        (pl.col('population') * 1e3).alias('population')
    )
    .with_columns(
        (pl.col('total GDP') * 1e6 / pl.col('population'))
        .alias('GDP percap')
    )
    .sort('GDP percap', descending=True)
)
df
shape: (8, 4)
countrypopulationtotal GDPGDP percap
strf64f64f64
"United States"2.82171957e89.8987e635080.381854
"Australia"1.9053186e7541804.652128436.433261
"Israel"6.11457e6129253.8942321138.672749
"Argentina"3.7335653e7295072.218697903.229085
"Uruguay"3.219793e625255.9616937843.97062
"South Africa"4.5064098e7227242.369495042.647686
"India"1.0063e91.7281e61717.324719
"Malawi"1.1801505e75026.221784425.896679

We can extract columns directly for matplotlib

Note

Polars also provides a built-in plotting API based on Altair (e.g., df.plot.bar(x=..., y=...)). We use matplotlib here for consistency with the rest of the lecture series.

fig, ax = plt.subplots()
ax.bar(df['country'].to_list(), df['GDP percap'].to_list())
ax.set_xlabel('country', fontsize=12)
ax.set_ylabel('GDP per capita', fontsize=12)
plt.xticks(rotation=45, ha='right')
plt.tight_layout()
plt.show()
_images/1086660a9c88f1b6fbcf4ee7a74cff8d6f85f83902ebd67b49e16bc20f052ab0.png

19.4. Lazy evaluation#

One of Polars’ most powerful features is lazy evaluation.

Instead of executing each operation immediately, lazy mode collects the full query plan and optimizes it before running.

19.4.1. Eager vs lazy#

# Reload the dataset
url = ('https://raw.githubusercontent.com/QuantEcon/'
       'lecture-python-programming/main/lectures/_static/'
       'lecture_specific/pandas/data/test_pwt.csv')
df_full = pl.read_csv(url)

The eager API executes immediately (like pandas)

result_eager = (df_full
    .filter(pl.col('tcgdp') > 1000)
    .select(['country', 'year', 'tcgdp'])
    .sort('tcgdp', descending=True)
)
result_eager.head()
shape: (5, 3)
countryyeartcgdp
stri64f64
"United States"20009.8987e6
"India"20001.7281e6
"Australia"2000541804.6521
"Argentina"2000295072.21869
"South Africa"2000227242.36949

The lazy API builds a query plan instead

lazy_query = (df_full.lazy()
    .filter(pl.col('tcgdp') > 1000)
    .select(['country', 'year', 'tcgdp'])
    .sort('tcgdp', descending=True)
)
print(lazy_query.explain())
SORT BY [descending: [true]] [col("tcgdp")]
  FILTER col("tcgdp") > 1000.0
  FROM
    DF ["country", "country isocode", "year", "POP", ...]; PROJECT["country", "year", "tcgdp"] 3/8 COLUMNS

Call collect to execute the plan

result_lazy = lazy_query.collect()
result_lazy.head()
shape: (5, 3)
countryyeartcgdp
stri64f64
"United States"20009.8987e6
"India"20001.7281e6
"Australia"2000541804.6521
"Argentina"2000295072.21869
"South Africa"2000227242.36949

19.4.2. Query optimization#

The lazy engine applies several optimizations automatically:

  • Predicate pushdown — filters are applied as early as possible

  • Projection pushdown — only required columns are read from the source

  • Common subexpression elimination — duplicate calculations are merged

Let’s see how Polars rewrites a multi-step query

optimized = (df_full.lazy()
    .select(['country', 'year', 'tcgdp', 'POP'])
    .filter(pl.col('tcgdp') > 500)
    .with_columns(
        (pl.col('tcgdp') / pl.col('POP')).alias('gdp_per_capita')
    )
    .filter(pl.col('gdp_per_capita') > 10)
    .select(['country', 'year', 'gdp_per_capita'])
)

print("Optimized plan:")
print(optimized.explain())
Optimized plan:
FILTER col("gdp_per_capita") > 10.0
FROM
  simple π 3/3 ["country", "year", ... 1 other column]
     WITH_COLUMNS:
     [(col("tcgdp") / col("POP")).alias("gdp_per_capita")] 
      FILTER col("tcgdp") > 500.0
      FROM
        DF ["country", "country isocode", "year", "POP", ...]; PROJECT["country", "year", "tcgdp", "POP"] 4/8 COLUMNS

Executing the plan gives us the final result

optimized.collect()
shape: (3, 3)
countryyeargdp_per_capita
stri64f64
"Australia"200028.436433
"Israel"200021.138673
"United States"200035.080382

19.4.3. Performance comparison#

Let’s compare pandas, Polars eager, and Polars lazy on the same task.

We start with a small dataset (the Penn World Tables we used above) to show that for small data the differences are negligible

import pandas as pd
import time

# Small dataset -- Penn World Tables (~8 rows)
url = ('https://raw.githubusercontent.com/QuantEcon/'
       'lecture-python-programming/main/lectures/_static/'
       'lecture_specific/pandas/data/test_pwt.csv')
small_pd = pd.read_csv(url)
small_pl = pl.read_csv(url)

Now we time the same filter-select-sort operation in each library

# pandas
start = time.perf_counter()
_ = (small_pd
     .query('tcgdp > 500')
     [['country', 'year', 'tcgdp', 'POP']]
     .assign(gdp_pc=lambda d: d['tcgdp'] / d['POP'])
     .sort_values('gdp_pc', ascending=False))
pd_small = time.perf_counter() - start

# Polars eager
start = time.perf_counter()
_ = (small_pl
     .filter(pl.col('tcgdp') > 500)
     .select(['country', 'year', 'tcgdp', 'POP'])
     .with_columns((pl.col('tcgdp') / pl.col('POP')).alias('gdp_pc'))
     .sort('gdp_pc', descending=True))
pl_small = time.perf_counter() - start

print(f"Small data  --  pandas: {pd_small:.4f}s | Polars eager: {pl_small:.4f}s")
Small data  --  pandas: 0.0060s | Polars eager: 0.0013s

On a handful of rows the speed difference is immaterial — use whichever API you find more convenient.

Now let’s scale up to 5 million rows where the difference becomes clear.

The task is: filter rows where value > 0, compute a weighted product value * weight, then take the mean of that product within each group — a grouped weighted average.

n = 5_000_000
np.random.seed(42)

groups = np.random.choice(['A', 'B', 'C', 'D'], n)
values = np.random.randn(n)
weights = np.random.rand(n)
extra1 = np.random.randn(n)
extra2 = np.random.randn(n)

big_pd = pd.DataFrame({
    'group': groups, 'value': values,
    'weight': weights, 'extra1': extra1, 'extra2': extra2
})
big_pl = pl.DataFrame({
    'group': groups, 'value': values,
    'weight': weights, 'extra1': extra1, 'extra2': extra2
})

First, the pandas baseline

start = time.perf_counter()
tmp = big_pd[big_pd['value'] > 0][['group', 'value', 'weight']].copy()
tmp['weighted'] = tmp['value'] * tmp['weight']
_ = tmp.groupby('group')['weighted'].mean()
pd_time = time.perf_counter() - start
print(f"pandas:       {pd_time:.4f}s")
pandas:       0.2139s

Next, Polars in eager mode

start = time.perf_counter()
_ = (big_pl
    .filter(pl.col('value') > 0)
    .select(['group', 'value', 'weight'])
    .with_columns(
        (pl.col('value') * pl.col('weight')).alias('weighted'))
    .group_by('group')
    .agg(pl.col('weighted').mean()))
eager_time = time.perf_counter() - start
print(f"Polars eager: {eager_time:.4f}s")
Polars eager: 0.0604s

And finally, Polars in lazy mode

start = time.perf_counter()
_ = (big_pl.lazy()
    .filter(pl.col('value') > 0)
    .select(['group', 'value', 'weight'])
    .with_columns(
        (pl.col('value') * pl.col('weight')).alias('weighted'))
    .group_by('group')
    .agg(pl.col('weighted').mean())
    .collect())
lazy_time = time.perf_counter() - start
print(f"Polars lazy:  {lazy_time:.4f}s")
Polars lazy:  0.0314s

The take-away:

  • For small data (thousands of rows), pandas and Polars perform similarly — choose based on API preference and ecosystem fit.

  • For medium to large data (hundreds of thousands of rows and above), Polars can be significantly faster thanks to its Rust engine, parallel execution, and (in lazy mode) query optimization.

The lazy API is particularly powerful when reading from disk — scan_csv returns a LazyFrame directly, so filters and projections are pushed down to the file reader.

Tip

Use pl.scan_csv(path) instead of pl.read_csv(path) when working with large CSV files. Only the columns and rows you actually need will be read from disk. See the Polars I/O documentation.

19.5. On-line data sources#

As in Pandas, Python makes it straightforward to query online databases.

An important database for economists is FRED — a vast collection of time series data maintained by the St. Louis Fed.

Polars’ read_csv can fetch data from a URL directly.

We use try_parse_dates=True to parse the date column automatically

fred_url = ('https://fred.stlouisfed.org/graph/fredgraph.csv?'
            'bgcolor=%23e1e9f0&chart_type=line&drp=0&'
            'fo=open%20sans&graph_bgcolor=%23ffffff&'
            'height=450&mode=fred&recession_bars=on&'
            'txtcolor=%23444444&ts=12&tts=12&width=1318&'
            'nt=0&thu=0&trc=0&show_legend=yes&'
            'show_axis_titles=yes&show_tooltip=yes&'
            'id=UNRATE&scale=left&cosd=1948-01-01&'
            'coed=2024-06-01&line_color=%234572a7&'
            'link_values=false&line_style=solid&'
            'mark_type=none&mw=3&lw=2&ost=-99999&'
            'oet=99999&mma=0&fml=a&fq=Monthly&fam=avg&'
            'fgst=lin&fgsnd=2020-02-01&line_index=1&'
            'transformation=lin&vintage_date=2024-07-29&'
            'revision_date=2024-07-29&nd=1948-01-01')
data = pl.read_csv(fred_url, try_parse_dates=True)

Let’s inspect the first few rows

data.head()
shape: (5, 2)
observation_dateUNRATE
datef64
1948-01-013.4
1948-02-013.8
1948-03-014.0
1948-04-013.9
1948-05-013.5

And get summary statistics

data.describe()
shape: (9, 3)
statisticobservation_dateUNRATE
strstrf64
"count""918"918.0
"null_count""0"0.0
"mean""1986-03-17 06:30:35.294117"5.693246
"std"null1.710248
"min""1948-01-01"2.5
"25%""1967-02-01"4.4
"50%""1986-04-01"5.5
"75%""2005-05-01"6.7
"max""2024-06-01"14.8

Plot the unemployment rate from 2006 to 2012

filtered = data.filter(
    (pl.col('observation_date') >= pl.date(2006, 1, 1)) &
    (pl.col('observation_date') <= pl.date(2012, 12, 31))
)

fig, ax = plt.subplots()
ax.plot(filtered['observation_date'].to_list(),
        filtered['UNRATE'].to_list())
ax.set_title('US Unemployment Rate')
ax.set_xlabel('year', fontsize=12)
ax.set_ylabel('%', fontsize=12)
plt.show()
_images/a61de207bcb9903916c9e95a2444a28fad81a433fb0183bbe235b91ff888599c.png

Polars supports many file formats including Excel, JSON, Parquet, and direct database connections.

19.6. Exercises#

Exercise 19.1

With these imports:

import datetime as dt
import yfinance as yf

Write a program to calculate the percentage price change over 2021 for the following shares:

ticker_list = {'INTC': 'Intel',
               'MSFT': 'Microsoft',
               'IBM': 'IBM',
               'BHP': 'BHP',
               'TM': 'Toyota',
               'AAPL': 'Apple',
               'AMZN': 'Amazon',
               'C': 'Citigroup',
               'QCOM': 'Qualcomm',
               'KO': 'Coca-Cola',
               'GOOG': 'Google'}

Here’s a function that reads closing prices into a Polars DataFrame:

def read_data_polars(ticker_list,
                     start=dt.datetime(2021, 1, 1),
                     end=dt.datetime(2021, 12, 31)):
    """
    Read closing price data from Yahoo Finance
    and return a Polars DataFrame.
    """
    dataframes = []

    for tick in ticker_list:
        stock = yf.Ticker(tick)
        prices = stock.history(start=start, end=end)
        df = pl.DataFrame({
            'Date': list(prices.index.date),
            tick: prices['Close'].values
        }).with_columns(pl.col('Date').cast(pl.Date))
        dataframes.append(df)

    result = dataframes[0]
    for df in dataframes[1:]:
        result = result.join(
            df, on='Date', how='full', coalesce=True
        )
    return result.sort('Date')

ticker = read_data_polars(ticker_list)

Note

Polars joins do not guarantee the order of the output rows — keys that match only one side are appended rather than slotted into place. This is the same “no index, no automatic alignment” theme from above: with no row labels to align on, ordering is something we ask for explicitly. Hence the sort('Date') before returning, which any later first()/last() calculation relies on.

Complete the program to plot the result as a bar graph.

Exercise 19.2

Using read_data_polars from Exercise 19.1, obtain year-on-year percentage change for these indices:

indices_list = {'^GSPC': 'S&P 500',
               '^IXIC': 'NASDAQ',
               '^DJI': 'Dow Jones',
               '^N225': 'Nikkei'}

Plot the result as a time series graph.