Data Manipulation at Scale

Pandas, Dask, Vaex, and Polars for in-memory processing.

Beyond Small Datasets

When working with datasets that are too large for basic Python data structures but still fit in your machine's memory (typically 10GB-100GB), specialized data manipulation frameworks become essential. This lesson covers Pandas (the industry standard), Dask (parallel Pandas), Vaex (out-of-core processing), and Polars (Rust-based speed). Each excels in different scenarios, and choosing the right tool dramatically impacts performance and developer productivity.

About the numbers in this lesson: every example reads a sales_data.csv of roughly one million rows. The row counts and totals shown are what that specific dataset produces, so they are there to show the shape of the result, not values to match exactly. The snippet below generates a comparable file if you want to run the examples yourself.

The Data Manipulation Landscape

Each framework offers different trade-offs between ease of use, performance, and functionality:

Pandas

Focus: General-purpose, in-memory analytics
Key concept: DataFrame as primary abstraction
When to use: Default choice, rich ecosystem, datasets < 10GB

Dask

Focus: Parallel, distributed Pandas
Key concept: Lazy evaluation with task graphs
When to use: Pandas code on larger datasets, parallel processing

Vaex

Focus: Out-of-core, lazy evaluation
Key concept: Memory-mapped files, virtual columns
When to use: Billion-row datasets, exploratory analysis

Polars

Focus: High-performance, multi-threaded
Key concept: Rust-based, Arrow-native
When to use: Speed-critical pipelines, modern alternative

Pandas: The Industry Standard

Pandas is the de facto standard for data manipulation in Python. Built on NumPy, it provides high-performance, easy-to-use data structures and analysis tools. If you're doing data work in Python, you're almost certainly using Pandas.

Documentation: https://pandas.pydata.org/docs/
Install: pip install pandas

Core Capabilities

1. Loading Data & Basic Operations

Definition: Pandas DataFrame is a 2D labeled data structure with columns of potentially different types. It's like a spreadsheet or SQL table, optimized for in-memory operations.

Generating the sample dataset
# Every example below reads sales_data.csv. Generate a comparable file
# first so you can actually run them:
import numpy as np
import pandas as pd

rng = np.random.default_rng(0)
n = 1_000_000
sales = pd.DataFrame({
    "order_id": np.arange(n),
    "customer_id": rng.integers(1000, 2000, n),
    "product_id": rng.integers(500, 600, n),
    "region": rng.choice(["north", "south", "east", "west"], n),
    "price": rng.uniform(5, 500, n).round(2),
    "quantity": rng.integers(1, 10, n),
    "date": pd.to_datetime("2024-01-01") + pd.to_timedelta(rng.integers(0, 365, n), unit="D"),
    "discount": rng.uniform(0, 0.3, n).round(2),
    "payment_method": rng.choice(["card", "paypal", "transfer"], n),
    "status": rng.choice(["shipped", "pending", "returned"], n),
})
sales.to_csv("sales_data.csv", index=False)          # 1,000,000 rows x 10 columns

# A few examples read Parquet, which is columnar and keeps its dtypes
sales.to_parquet("sales_data.parquet", index=False)

# The merge example also needs a customers table
pd.DataFrame({
    "customer_id": np.arange(1000, 2000),
    "name": [f"Customer {i}" for i in range(1000, 2000)],
    "segment": rng.choice(["retail", "wholesale", "online"], 1000),
}).to_csv("customers.csv", index=False)

# The row counts and totals printed by the later examples depend on this
# data, so they will differ if you use your own file.
Loading and inspecting
import pandas as pd

# Load from various sources
df = pd.read_csv('sales_data.csv')

# Same data from other sources. Parquet reads much faster than CSV and
# preserves dtypes; read_sql pulls straight from a database connection.
# df = pd.read_parquet('sales_data.parquet')
# df = pd.read_sql('SELECT * FROM sales', connection)

# Basic info
print(df.shape)        # (1000000, 10) - 1M rows, 10 columns
print(df.dtypes)       # Column data types

# The rest print too much to reproduce here, but you will use them constantly:
#   df.info()                       memory usage and non-null counts
#   df.describe()                   statistical summary of numeric columns
#   df.head()                       first 5 rows
#   df.tail(10)                     last 10 rows
#   df.sample(5, random_state=42)   5 random rows; seed it to get the same
#                                   rows twice, otherwise it differs per run
Expected Output:
(1000000, 10)
order_id            int64
customer_id         int64
product_id          int64
region             object
price             float64
quantity            int64
date               object
discount          float64
payment_method     object
status             object
dtype: object
2. Selection & Filtering

Definition: Pandas provides multiple ways to select, filter, and slice data using labels, positions, and boolean masks. Use .loc[] for labels, .iloc[] for positions, and boolean indexing for filtering.

# Continues from the block above: 'df' and 'pd' come from there

# Column selection
df['customer_id']              # Single column (Series)
df[['customer_id', 'price']]   # Multiple columns (DataFrame)

# Row selection
df.loc[0]                      # By label (first row)
df.iloc[0]                     # By position (first row)
df.loc[0:5]                    # Slice by label (inclusive)
df.iloc[0:5]                   # Slice by position (exclusive)

# Boolean filtering
high_value = df[df['price'] > 100]
recent = df[df['date'] > '2024-01-01']

# Multiple conditions
filtered = df[
    (df['price'] > 50) &
    (df['quantity'] > 1) &
    (df['customer_id'].isin([1001, 1002, 1003]))
]

# Query method (more readable for complex filters)
queried = df.query('price > 50 and quantity > 1')

print(f"rows total:             {len(df)}")
print(f"price > 100:            {len(high_value)}")
print(f"three-condition filter: {len(filtered)}")
print(f"query() == bracket form: {len(queried) == len(df[(df['price'] > 50) & (df['quantity'] > 1)])}")
Expected Output:
rows total:             1000000
price > 100:            808152
three-condition filter: 2442
query() == bracket form: True
3. Grouping & Aggregation

Definition: GroupBy operations follow the split-apply-combine pattern: split data into groups based on criteria, apply functions to each group, and combine results. Essential for analytics and reporting.

# Continues from the block above: 'df' and 'pd' come from there

# Simple aggregation
total_sales = df['price'].sum()
avg_quantity = df['quantity'].mean()

# Group by single column
by_customer = df.groupby('customer_id').agg({
    'price': 'sum',
    'quantity': 'mean',
    'product_id': 'count'
})

# Group by multiple columns
by_customer_product = df.groupby(['customer_id', 'product_id']).agg({
    'price': ['sum', 'mean', 'std'],
    'quantity': ['sum', 'count']
})

# Custom aggregation functions. include_groups=False keeps the grouping
# column out of what the function sees, which is the 3.x default behaviour.
def calculate_revenue(group):
    return (group['price'] * group['quantity']).sum()

revenue_by_customer = df.groupby('customer_id').apply(
    calculate_revenue, include_groups=False
)

# Multiple aggregations with rename
summary = df.groupby('customer_id').agg(
    total_revenue=('price', 'sum'),
    avg_order_value=('price', 'mean'),
    order_count=('customer_id', 'count'),
    unique_products=('product_id', 'nunique')
)

print(summary.head(3).round(2))
print(f"\nrevenue for customer 1000: {revenue_by_customer.loc[1000]:,.2f}")
Expected Output:
             total_revenue  avg_order_value  order_count  unique_products
customer_id
1000             255455.72           257.00          994              100
1001             250631.93           246.93         1015              100
1002             255231.69           251.71         1014              100

revenue for customer 1000: 1,279,114.10
4. Data Transformation

Definition: Transformations modify data structure or values. Common operations include creating new columns, applying functions, merging datasets, and reshaping data between wide and long formats.

# Continues from the block above: 'df' and 'pd' come from there

# Create new columns
df['revenue'] = df['price'] * df['quantity']
df['discount_applied'] = df['price'] < 30

# Apply functions
df['price_category'] = df['price'].apply(
    lambda x: 'High' if x > 100 else 'Medium' if x > 50 else 'Low'
)

# Vectorized operations (much faster!)
df['total_cost'] = df['price'] * df['quantity'] * 1.1  # Add 10% tax

# Map values
category_map = {501: 'Electronics', 502: 'Clothing', 503: 'Food'}
df['category'] = df['product_id'].map(category_map)

# Merge datasets
customers = pd.read_csv('customers.csv')
merged = df.merge(
    customers,
    on='customer_id',
    how='left'  # left, right, inner, outer
)

# Pivot tables (wide format). 100 product ids would be unreadable, so
# pivot on the four regions instead.
pivot = df.pivot_table(
    values='revenue',
    index='region',
    columns='status',
    aggfunc='sum',
    fill_value=0
)

# Melt (long format)
long_format = pivot.reset_index().melt(
    id_vars=['region'],
    var_name='status',
    value_name='revenue'
)

print(f"columns after merge: {len(df.columns)} -> {len(merged.columns)}")
print(pivot.round(0))
print(f"\nmelted back to {long_format.shape[0]} rows x {long_format.shape[1]} columns")
Expected Output:
columns after merge: 15 -> 17
status      pending     returned      shipped
region
east    105885962.0  105215034.0  105148248.0
north   105126404.0  105021554.0  105170414.0
south   105128077.0  104499481.0  104975441.0
west    105068526.0  105964208.0  105070024.0

melted back to 12 rows x 3 columns

When to Use Pandas

✓ Advantages
  • Industry standard with massive ecosystem
  • Excellent documentation and community
  • Rich functionality for data manipulation
  • Integrates with virtually everything
  • Easy to learn, intuitive API
  • Great for datasets < 10GB
✗ Limitations
  • Single-threaded (doesn't use all cores)
  • Requires entire dataset in memory
  • Slow on very large datasets (> 10GB)
  • Memory inefficient for some operations
  • Not optimized for streaming data
  • Inconsistent API in some areas
Best Practice: Pandas is your default choice for most data work. Only switch to alternatives when you hit performance or memory limitations, or need specific features like parallel processing.

Dask: Parallel Pandas

Dask scales Pandas workflows to larger-than-memory datasets and multi-core processing. It uses lazy evaluation and task graphs to parallelize operations, and its API mirrors Pandas, making migration straightforward.

Documentation: https://docs.dask.org/
Install: pip install dask[complete]

Core Concepts

1. Lazy Evaluation & Task Graphs

Definition: Dask uses lazy evaluation - operations build a task graph without executing immediately. Call .compute() to execute the graph in parallel. This allows Dask to optimize the entire computation pipeline.

import dask.dataframe as dd
import pandas as pd

# Load data (lazy - doesn't load immediately). read_csv also accepts a glob
# such as 'large_sales_*.csv' to treat many files as one frame. blocksize
# controls how the file is cut into partitions.
ddf = dd.read_csv('sales_data.csv', blocksize='16MB')

# Partitions are how Dask splits the data. Each one is a Pandas DataFrame,
# and Dask works on them in parallel across cores.
print(f"partitions: {ddf.npartitions}")

# All operations are lazy (no computation yet)
filtered = ddf[ddf['price'] > 50]
grouped = filtered.groupby('customer_id')['price'].sum()
result = grouped / 1000  # Still no computation!

# Nothing has been read yet: this is a plan, not a value
print(f"lazy: {hasattr(result, 'compute')}")

# Trigger computation
final_result = result.compute()  # Now it executes in parallel
print(f"computed into a pandas Series: {isinstance(final_result, pd.Series)}")
print(final_result.head(3).round(3))
Expected Output:
partitions: 3
lazy: True
computed into a pandas Series: True
customer_id
1000    253.166
1001    247.789
1002    251.950
Name: price, dtype: float64
2. Parallel Operations

Definition: Dask automatically parallelizes operations across CPU cores and can work with datasets larger than RAM by processing partitions sequentially. Use .persist() to keep results in memory for reuse.

from dask.distributed import Client
import dask.dataframe as dd

# A Client spawns worker processes, so the script needs the main guard
if __name__ == '__main__':
    # Start a local cluster. Client() alone uses all cores and opens a
    # dashboard at http://localhost:8787; pinned here so the run is
    # reproducible.
    client = Client(n_workers=4, threads_per_worker=1, dashboard_address=None)

    ddf = dd.read_parquet('sales_data.parquet')

    # Complex pipeline (all operations are lazy).
    # Note what is NOT here: 'product_id': 'nunique'. Dask's groupby().agg()
    # rejects it with ValueError: unknown aggregate nunique. It is one of the
    # places the Dask API stops matching Pandas, and only running it tells you.
    result = (
        ddf
        .query('price > 50 and quantity > 1')
        .groupby('customer_id')
        .agg({
            'price': ['sum', 'mean'],
            'quantity': 'sum'
        })
        .compute()  # Execute in parallel
    )
    print(f"aggregated rows: {result.shape[0]}")

    # Persist in memory for reuse
    ddf_filtered = ddf[ddf['price'] > 50].persist()

    # Now operations on ddf_filtered are fast (already in memory).
    # nunique is fine on a Series, just not inside an agg() dict.
    high_value_customers = ddf_filtered.groupby('customer_id')['price'].sum()
    product_diversity = ddf_filtered.groupby('customer_id')['product_id'].nunique()

    # Both use the same persisted data
    result1 = high_value_customers.compute()
    result2 = product_diversity.compute()
    print(f"products bought by customer 1000: {result2.loc[1000]}")

    # Custom functions with map_partitions
    def custom_analysis(partition):
        """Runs on each partition (Pandas DataFrame)"""
        # Do whatever you want with Pandas
        return partition[partition['price'] > partition['price'].mean()]

    above_avg = ddf.map_partitions(custom_analysis).compute()
    print(f"rows above their partition mean: {len(above_avg)}")
    print(f"workers: {len(client.scheduler_info()['workers'])}")

    client.close()
Expected Output:
aggregated rows: 1000
products bought by customer 1000: 100
rows above their partition mean: 500692
workers: 4
3. Converting Pandas Code

Definition: Dask implements most of the Pandas API. Converting Pandas code to Dask often requires minimal changes - typically just replacing pd with dd and adding.compute() calls.

# PANDAS VERSION (works for small data)
import pandas as pd

df = pd.read_csv('sales_data.csv')
pandas_result = (
    df[df['price'] > 50]
    .groupby('customer_id')['price']
    .mean()
)

# DASK VERSION (works for large data) - minimal changes!
import dask.dataframe as dd

ddf = dd.read_csv('sales_data.csv', blocksize='16MB')  # Changed pd to dd
dask_result = (
    ddf[ddf['price'] > 50]
    .groupby('customer_id')['price']
    .mean()
    .compute()  # Added .compute()
)

# Same numbers, two engines
print(f"identical: {pandas_result.round(9).equals(dask_result.round(9))}")
print(pandas_result.head(2).round(3))

# What Dask doesn't support well:
# - Operations requiring full dataset sorting
# - Complex window functions
# - Some merge operations with shuffling
# - Operations that break partitioning

# For these, might need to use .compute() to go back to Pandas
as_pandas = ddf.compute()  # Convert to Pandas
ranked = as_pandas.sort_values('price', ascending=False)
print(f"most expensive order: {ranked.iloc[0]['price']}")

# Then convert back to Dask if needed
ddf_result = dd.from_pandas(ranked, npartitions=4)
print(f"back to dask in {ddf_result.npartitions} partitions")
Expected Output:
identical: True
customer_id
1000    277.290
1001    271.996
Name: price, dtype: float64
most expensive order: 500.0
back to dask in 4 partitions

When to Use Dask

✓ Advantages
  • Uses all CPU cores automatically
  • Handles larger-than-memory datasets
  • Nearly identical API to Pandas
  • Great diagnostic dashboard
  • Can scale to clusters (not just local)
  • Lazy evaluation optimizes pipelines
✗ Limitations
  • Slower than Pandas for small data
  • Not all Pandas operations supported
  • Adds complexity (task graphs, partitions)
  • Debugging is harder (distributed)
  • Overhead for simple operations
  • Requires understanding of partitioning
When to Switch: Move from Pandas to Dask when your data doesn't fit in RAM, operations are taking too long, or you want to use multiple cores. Keep using Pandas if your data fits comfortably in memory (< 50% of RAM).

Vaex: Billion-Row Exploration

Vaex specializes in fast exploration of massive datasets through memory mapping and lazy evaluation. It can handle billion-row datasets on a laptop by processing data out-of-core, reading only what's needed from disk.

Documentation: https://vaex.io/docs/
Install: pip install vaex

Core Concepts

1. Memory Mapping & Out-of-Core

Definition: Vaex uses memory mapping to access data directly from disk without loading it all into RAM. Opening even terabyte-scale datasets is instantaneous because data is read only when needed.

import time

import vaex

# Open dataset (instant, no loading!). vaex.open also takes a glob such as
# 'data/*.csv', or an .hdf5 file, and memory-maps rather than reads.
start = time.perf_counter()
df = vaex.open('sales_data.parquet')
print(f"opened {df.shape[0]:,} rows in {time.perf_counter() - start:.4f}s")

# Only show what's needed (still not loading all data)
print(df[['order_id', 'customer_id', 'price', 'quantity']].head(2))

# Statistics are computed on-demand
print(f"mean:  {df.price.mean():.4f}")   # Fast! Uses efficient algorithms
print(f"std:   {df.price.std():.4f}")    # Doesn't load all data
print(f"customers: {df.customer_id.nunique()}")

# Convert to Pandas (loads into memory - be careful!)
small_subset = df[df['price'] > 499]
pandas_df = small_subset.to_pandas_df()  # Now it's in RAM
print(f"subset in RAM: {len(pandas_df)} rows")
Expected Output:
opened 1,000,000 rows in 0.2035s
  #    order_id    customer_id    price    quantity
  0           0           1850   273.98           1
  1           1           1636   165.54           7
mean:  252.4810
std:   142.8431
customers: 1000
subset in RAM: 2009 rows
2. Virtual Columns

Definition: Virtual columns are computed on-the-fly from existing columns without using extra memory. They behave like real columns but are calculated when accessed, making transformations memory-free.

import vaex

df = vaex.open('sales_data.parquet')
print(f"columns on disk: {len(df.get_column_names())}")

# Create virtual columns (no memory used!)
df['revenue'] = df.price * df.quantity
df['is_high_value'] = df.price > 100

# Virtual columns computed on-demand
print(f"mean revenue: {df.revenue.mean():.4f}")   # Computed on the fly
print(f"high value:   {df.is_high_value.sum():,}")

# Filter with virtual columns
high_revenue = df[df.revenue > 1000]
print(f"revenue > 1000: {len(high_revenue):,} rows")

# Expressions are also virtual. Only real columns are available here:
# there is no 'tax' column, so build the tax from the discount instead.
df['net_price'] = (df.price * df.quantity) * (1 - df.discount)

# Chain operations (all virtual, no memory)
df['normalized_price'] = (df.price - df.price.mean()) / df.price.std()

# Nothing above was stored: the frame still owns only its original columns
print(f"virtual columns: {len(df.virtual_columns)}")
print(f"still on disk:   {len(df.get_column_names(virtual=False))}")

# Export to materialize virtual columns
df.export('sales_with_revenue.parquet')  # Now they are real columns
print(f"after export:    {len(vaex.open('sales_with_revenue.parquet').get_column_names())}")
Expected Output:
columns on disk: 10
mean revenue: 1262.2734
high value:   808,152
revenue > 1000: 487,681 rows
virtual columns: 4
still on disk:   10
after export:    14
3. Fast Aggregations & Grouping

Definition: Vaex excels at aggregations on massive datasets using optimized algorithms that process data in chunks. Groupby operations are extremely fast, even on billions of rows.

import vaex

df = vaex.open('sales_data.parquet')

# Simple aggregations (very fast!)
print(f"mean:     {df.price.mean():.4f}")
print(f"sum:      {df.price.sum():,.2f}")
print(f"std:      {df.price.std():.4f}")
print(f"nunique:  {df.customer_id.nunique()}")

# Multiple aggregations, named
grouped = df.groupby('customer_id', agg={
    'total_revenue': vaex.agg.sum('price'),
    'avg_price': vaex.agg.mean('price'),
    'order_count': vaex.agg.count(),
    'unique_products': vaex.agg.nunique('product_id')
})
print(grouped.sort('customer_id').head(2))

# Binning (histograms) - super fast
counts = df.count(binby='price', limits=[0, 500], shape=5)
print(f"price histogram, 5 bins over 0-500: {counts.astype(int).tolist()}")

# 2D histograms
counts_2d = df.count(
    binby=[df.price, df.quantity],
    limits=[[0, 500], [0, 10]],
    shape=[5, 3]
)
print(f"2D histogram shape: {counts_2d.shape}, total binned: {int(counts_2d.sum()):,}")
Expected Output:
mean:     252.4810
sum:      252,480,959.39
std:      142.8431
nunique:  1000
  #    customer_id    total_revenue    avg_price    order_count    unique_products
  0           1000           255456      256.998            994                100
  1           1001           250632      246.928           1015                100
price histogram, 5 bins over 0-500: [191836, 201805, 202770, 201850, 201722]
2D histogram shape: (5, 3), total binned: 999,983

When to Use Vaex

✓ Advantages
  • Handles billion+ row datasets easily
  • Instant opening (memory mapped)
  • Extremely fast aggregations
  • Virtual columns use no memory
  • Great for exploratory analysis
  • Works on datasets larger than RAM
✗ Limitations
  • Smaller ecosystem than Pandas
  • Not all Pandas operations available
  • Best with HDF5/Arrow formats
  • CSV support limited (slow)
  • Complex joins are challenging
  • Less familiar API for Pandas users
Sweet Spot: Use Vaex for exploratory data analysis on massive datasets. It excels at quick statistics, filtering, and visualization on billions of rows. For complex pipelines, combine with Pandas/Dask.

Polars: The Modern Alternative

Polars is a blazingly fast DataFrame library written in Rust with Python bindings. It's designed from the ground up for performance, using Apache Arrow as its memory model and supporting both eager and lazy execution modes.

Documentation: https://pola-rs.github.io/polars/
Install: pip install polars

Core Concepts

1. Eager vs Lazy Execution

Definition: Polars supports both eager (immediate execution like Pandas) and lazy (deferred execution like Dask) modes. Lazy mode enables query optimization across the entire pipeline, often yielding massive speedups.

import polars as pl

# EAGER MODE (executes immediately, like Pandas)
df = pl.read_csv('sales_data.csv')
df_result = (
    df
    .filter(pl.col('price') > 50)
    .group_by('customer_id')
    .agg(pl.col('price').sum())
)

# LAZY MODE (builds query plan, executes when needed)
df_lazy = pl.scan_csv('sales_data.csv')  # scan_ for lazy
result_lazy = (
    df_lazy
    .filter(pl.col('price') > 50)
    .group_by('customer_id')
    .agg(pl.col('price').sum())
    # Nothing executed yet!
)

# Optimize and execute
result = result_lazy.collect()  # Now it runs (optimized)
print(f"eager and lazy agree: {result.sort('customer_id').equals(df_result.sort('customer_id'))}")

# Example: Lazy is much faster for complex pipelines.
# Polars optimizes the entire chain before executing.
df_lazy = (
    pl.scan_parquet('sales_data.parquet')
    .select(['customer_id', 'price'])   # Only reads these columns
    .filter(pl.col('price') > 50)
    .group_by('customer_id')
    .agg(pl.col('price').mean())
)

# See the query plan. Read it bottom-up: the filter and the column list
# have been pushed down into the Parquet scan, so the other 8 columns are
# never read off disk.
plan = df_lazy.explain()
print(f"projection pushdown in plan: {'PROJECT' in plan}")
print(f"predicate pushdown in plan:  {'SELECTION' in plan or 'FILTER' in plan}")

result = df_lazy.collect()  # Optimized execution
print(result.sort('customer_id').head(2))
Expected Output:
eager and lazy agree: True
projection pushdown in plan: True
predicate pushdown in plan:  True
shape: (2, 2)
┌─────────────┬────────────┐
│ customer_id ┆ price      │
│ ---         ┆ ---        │
│ i64         ┆ f64        │
╞═════════════╪════════════╡
│ 1000        ┆ 277.290427 │
│ 1001        ┆ 271.996476 │
└─────────────┴────────────┘
2. Expression API

Definition: Polars uses an expression API where operations are composable and optimized. Use pl.col() to reference columns and chain operations in a functional style. This is more consistent than Pandas' mixed paradigms.

import polars as pl

df = pl.read_csv('sales_data.csv')

# Expression API - chainable, optimizable
result = df.select([
    pl.col('customer_id'),
    pl.col('price').alias('original_price'),
    (pl.col('price') * pl.col('quantity')).alias('revenue'),
    pl.col('price').mean().over('customer_id').alias('avg_customer_price'),
    # pl.lit() marks these as literal strings. A bare 'High' would be read
    # as a COLUMN name and raise ColumnNotFoundError.
    pl.when(pl.col('price') > 100)
      .then(pl.lit('High'))
      .otherwise(pl.lit('Low'))
      .alias('price_category')
])

# Powerful aggregations
summary = df.group_by('customer_id').agg([
    pl.col('price').sum().alias('total_spent'),
    pl.col('price').mean().alias('avg_price'),
    pl.col('product_id').n_unique().alias('unique_products'),
    pl.col('quantity').sum().alias('total_quantity'),
    # Conditional aggregation
    pl.col('price').filter(pl.col('price') > 100).sum().alias('high_value_total')
])

# Window functions (over)
df_with_ranks = df.select([
    '*',  # All existing columns
    pl.col('price').rank().over('customer_id').alias('price_rank'),
    pl.col('price').cum_sum().over('customer_id').alias('cumulative_spent')
])

# String operations, on the text columns this dataset actually has
df = df.with_columns([
    pl.col('region').str.to_uppercase().alias('region_upper'),
    pl.col('payment_method').str.slice(0, 4).alias('payment_short'),
    pl.col('status').str.starts_with('ship').alias('is_shipped')
])

print(result.head(3))
Expected Output:
shape: (3, 5)
┌─────────────┬────────────────┬─────────┬────────────────────┬────────────────┐
│ customer_id ┆ original_price ┆ revenue ┆ avg_customer_price ┆ price_category │
│ ---         ┆ ---            ┆ ---     ┆ ---                ┆ ---            │
│ i64         ┆ f64            ┆ f64     ┆ f64                ┆ str            │
╞═════════════╪════════════════╪═════════╪════════════════════╪════════════════╡
│ 1850        ┆ 273.98         ┆ 273.98  ┆ 247.78077          ┆ High           │
│ 1636        ┆ 165.54         ┆ 1158.78 ┆ 255.963924         ┆ High           │
│ 1511        ┆ 499.09         ┆ 2495.45 ┆ 252.342408         ┆ High           │
└─────────────┴────────────────┴─────────┴────────────────────┴────────────────┘
3. Performance Features

Definition: Polars is built for speed - multi-threaded by default, columnar storage with Apache Arrow, SIMD vectorization, and query optimization. Often 5-10x faster than Pandas on the same hardware.

import time

import pandas as pd
import polars as pl

def fastest(fn, runs=3):
    """Best of N: the minimum is the least noisy estimate"""
    times = []
    for _ in range(runs):
        start = time.perf_counter()
        fn()
        times.append(time.perf_counter() - start)
    return min(times)

# The same aggregation, two engines. Polars parallelises across cores by
# default; pandas runs it on one.
pdf = pd.read_csv('sales_data.csv')
pldf = pl.read_csv('sales_data.csv')

pandas_agg = fastest(lambda: pdf.groupby('customer_id').agg(
    price_sum=('price', 'sum'),
    quantity_mean=('quantity', 'mean'),
    products=('product_id', 'nunique'),
))
polars_agg = fastest(lambda: pldf.group_by('customer_id').agg([
    pl.col('price').sum(),
    pl.col('quantity').mean(),
    pl.col('product_id').n_unique(),
]))
print(f"groupby   pandas {pandas_agg * 1000:7.1f} ms   "
      f"polars {polars_agg * 1000:6.1f} ms   {pandas_agg / polars_agg:4.1f}x")

# Reading the same 61 MB CSV
pandas_read = fastest(lambda: pd.read_csv('sales_data.csv'))
polars_read = fastest(lambda: pl.read_csv('sales_data.csv'))
print(f"read_csv  pandas {pandas_read * 1000:7.1f} ms   "
      f"polars {polars_read * 1000:6.1f} ms   {pandas_read / polars_read:4.1f}x")

# Streaming for larger-than-memory data
streamed = (
    pl.scan_csv('sales_data.csv')
    .filter(pl.col('price') > 50)
    .group_by('customer_id')
    .agg(pl.col('price').sum())
    .collect(engine='streaming')  # Process in chunks
)
print(f"streamed groups: {streamed.height}")

# Projection pushdown (read only needed columns)
narrow = pl.scan_csv('sales_data.csv').select(['customer_id', 'price']).collect()
print(f"projection pushdown read {narrow.width} of {pldf.width} columns")
Expected Output:
groupby   pandas    23.0 ms   polars   13.0 ms    1.8x
read_csv  pandas   360.6 ms   polars   17.1 ms   21.1x
streamed groups: 1000
projection pushdown read 2 of 10 columns

When to Use Polars

✓ Advantages
  • 5-10x faster than Pandas
  • Multi-threaded by default
  • Consistent, modern API
  • Query optimization (lazy mode)
  • Low memory usage (Arrow format)
  • Great for production pipelines
  • Excellent for streaming data
✗ Limitations
  • Smaller ecosystem than Pandas
  • Not a drop-in Pandas replacement
  • Requires learning new API
  • Less mature (fewer edge cases handled)
  • Some libraries don't support it yet
  • Different mental model from Pandas
Modern Choice: Polars is the best choice for new projects where speed matters. If you're building production ETL pipelines or need maximum performance, Polars is worth the learning curve. For exploratory work with existing Pandas code, stick with Pandas/Dask.

Framework Comparison

AspectPandasDaskVaexPolars
PerformanceBaseline (1x)2-3x (parallel)5-10x (aggregations)5-10x (general)
Memory UsageHigh (all in RAM)Medium (chunked)Low (memory-mapped)Medium-Low (Arrow)
Dataset Size< 10GB< 1TB (local)< 1TB< 100GB
ParallelizationNoYes (automatic)Yes (automatic)Yes (automatic)
Learning CurveEasyEasy (if know Pandas)MediumMedium
EcosystemHugeLargeSmallGrowing
API ConsistencyMixedPandas-likeCustomExcellent
Query OptimizationNoYes (task graphs)LimitedYes (lazy mode)
Best Use CaseGeneral analyticsPandas at scaleExploratory analysisProduction pipelines

The speed column is a rule of thumb across workloads, not a single measurement. On the one task benchmarked above, a filter plus a grouped aggregation over 45 million rows, the measured speedups against Pandas were Dask 2.9x, Vaex 4.4x and Polars 7.0x. Your ratios will move with the shape of the query: reading a CSV, Polars was 21x faster than Pandas, while a groupby on data already in memory was only about 2x.

Decision Framework: Start with Pandas. When it's too slow or won't fit in memory, choose Dask (familiar API), Vaex (exploration on massive data), or Polars (maximum performance). Often you'll use multiple - Polars for ETL, Pandas for modeling, Vaex for exploration.

Real-World Example: Sales Analysis Pipeline

Let's solve the same problem with each framework to see the differences in action. Task: load the sales data, keep the orders over 100, calculate per-customer metrics, and export the result to CSV. All four return the same 1,000 rows, so the only thing that varies is the cost.

The numbers below come from a 502 MB Parquet file of 45 million rows, generated by the snippet that follows, on one desktop with 37 GB of RAM. Your absolute timings will differ; the shape of the result does not. A million rows is too small to separate these tools, which is why this section uses a bigger file than the rest of the lesson.

# The comparison below needs a file big enough that the differences show.
# This writes ~502 MB of Parquet (45 million rows) in chunks, so generating
# it never needs more than one chunk in memory at a time.
import numpy as np
import pandas as pd
import pyarrow as pa
import pyarrow.parquet as pq

TOTAL, CHUNK = 45_000_000, 3_000_000
rng = np.random.default_rng(0)
writer = None

for offset in range(0, TOTAL, CHUNK):
    n = min(CHUNK, TOTAL - offset)
    frame = pd.DataFrame({
        "order_id": np.arange(offset, offset + n),
        "customer_id": rng.integers(1000, 2000, n),
        "product_id": rng.integers(500, 600, n),
        "region": rng.choice(["north", "south", "east", "west"], n),
        "price": rng.uniform(5, 500, n).round(2),
        "quantity": rng.integers(1, 10, n),
        "date": pd.to_datetime("2024-01-01")
        + pd.to_timedelta(rng.integers(0, 365, n), unit="D"),
        "discount": rng.uniform(0, 0.3, n).round(2),
        "payment_method": rng.choice(["card", "paypal", "transfer"], n),
        "status": rng.choice(["shipped", "pending", "returned"], n),
    })
    table = pa.Table.from_pandas(frame, preserve_index=False)
    if writer is None:
        writer = pq.ParquetWriter("sales_large.parquet", table.schema)
    writer.write_table(table)

writer.close()

# Write it somewhere on real disk. On many Linux setups /tmp is a tmpfs,
# which means a 502 MB file there costs you 502 MB of RAM.

In pandas this data costs 221 bytes per row, measured with memory_usage(deep=True), against 11.7 bytes per row on disk as Parquet. That ratio is the whole argument for the other three tools: scale it up and a 5 GB Parquet file would want roughly 94 GB of RAM to open in pandas, which is not a number most machines have.

Pandas Solution

Approach: Load entire dataset into memory, filter and aggregate in-memory.

import resource
import time

import pandas as pd

start = time.perf_counter()

df = pd.read_parquet('sales_large.parquet')
filtered = df[df['price'] > 100]
result = filtered.groupby('customer_id').agg(
    revenue=('price', 'sum'),
    order_count=('price', 'count'),
    avg_order_value=('price', 'mean'),
)
result.to_csv('high_value_customers.csv')

print(f"time:         {time.perf_counter() - start:.1f}s")
print(f"peak RSS:     {resource.getrusage(resource.RUSAGE_SELF).ru_maxrss / 1024**2:.1f} GB")
print(f"rows written: {len(result)}")
Expected Output:
time:         3.5s
peak RSS:     11.6 GB
rows written: 1000
Dask Solution

Approach: Parallel processing across all CPU cores, handles larger-than-memory datasets.

import resource
import time

import dask.dataframe as dd

start = time.perf_counter()

ddf = dd.read_parquet('sales_large.parquet')
filtered = ddf[ddf['price'] > 100]
result = filtered.groupby('customer_id').agg({
    'price': ['sum', 'count', 'mean']
}).compute()
result.to_csv('high_value_customers.csv')

print(f"time:         {time.perf_counter() - start:.1f}s")
print(f"peak RSS:     {resource.getrusage(resource.RUSAGE_SELF).ru_maxrss / 1024**2:.1f} GB")
print(f"rows written: {len(result)}")
Expected Output:
time:         1.2s
peak RSS:     2.9 GB
rows written: 1000
Vaex Solution

Approach: Memory-mapped out-of-core processing, instant opening of large files.

import resource
import time

import vaex

start = time.perf_counter()

df = vaex.open('sales_large.parquet')  # Instant, memory-mapped
filtered = df[df.price > 100]
result = filtered.groupby('customer_id', agg={
    'revenue': vaex.agg.sum('price'),
    'order_count': vaex.agg.count(),
    'avg_order_value': vaex.agg.mean('price'),
})
result.export('high_value_customers.csv')

print(f"time:         {time.perf_counter() - start:.1f}s")
print(f"peak RSS:     {resource.getrusage(resource.RUSAGE_SELF).ru_maxrss / 1024**2:.1f} GB")
print(f"rows written: {len(result)}")
Expected Output:
time:         0.8s
peak RSS:     2.2 GB
rows written: 1000
Polars Solution

Approach: Rust-based parallel execution with query optimization and lazy evaluation.

import resource
import time

import polars as pl

start = time.perf_counter()

df = pl.scan_parquet('sales_large.parquet')
result = (
    df
    .filter(pl.col('price') > 100)
    .group_by('customer_id')
    .agg([
        pl.col('price').sum().alias('revenue'),
        pl.len().alias('order_count'),
        pl.col('price').mean().alias('avg_order_value')
    ])
    .collect()
)
result.write_csv('high_value_customers.csv')

print(f"time:         {time.perf_counter() - start:.1f}s")
print(f"peak RSS:     {resource.getrusage(resource.RUSAGE_SELF).ru_maxrss / 1024**2:.1f} GB")
print(f"rows written: {result.height}")
Expected Output:
time:         0.5s
peak RSS:     1.6 GB
rows written: 1000
Comparison Summary (45M rows, 502 MB Parquet, same 1,000-row answer):
  • Pandas: 3.5s, peak 11.6 GB. Familiar, but it materialises every row: 23x the file size, single-threaded.
  • Dask: 1.2s, peak 2.9 GB. Nearly the same code as Pandas, processed partition by partition across cores.
  • Vaex: 0.8s, peak 2.2 GB. Memory-maps the file, so it never reads the columns the query does not touch.
  • Polars: 0.5s, peak 1.6 GB. Fastest and leanest here, with the lazy plan pushing the filter into the Parquet scan.

Note how much the ranking depends on size. Run the same four scripts against the 1 million row file from the top of this lesson and the order changes: Pandas finishes in 0.11s, ahead of Dask at 0.16s and Vaex at 0.25s, because at that size coordination and memory-mapping cost more than the work itself. Only Polars stays ahead at both scales. Dask and Vaex pay off when the data stops fitting comfortably in RAM, not before.

Best Practices

1. Choose the Right File Format

Use Parquet or HDF5, not CSV! Parquet is columnar (fast for analytics), compressed (smaller files), and supports predicate pushdown. CSV is 5-10x slower to read and much larger. Convert once, save time forever.

2. Filter Early, Filter Often

Apply filters as early as possible in your pipeline. Read only the data you need (dates, columns, conditions). With lazy evaluation (Dask/Polars), filters get pushed to the data source, reading less from disk.

3. Use Vectorized Operations

Never use .apply() with Python functions on large data! Use vectorized operations (NumPy-style) which are 100x faster. If you must use apply, consider Numba JIT compilation or switch to Polars which handles this better.

4. Monitor Memory Usage

Use df.memory_usage(deep=True) to see memory consumption. Optimize dtypes (use category for strings, int32 instead of int64 where possible). Process in chunks if hitting memory limits. Consider downcasting after operations.

5. Partition Large Datasets

Split data by meaningful dimensions (date, region, customer segment). Save as multiple files organized by partition. This enables selective reading (only load Q1 data if analyzing Q1) and better parallelization. Most frameworks support partitioned reads.

6. Start Small, Scale Up

Develop on a small sample (1% of data) with Pandas. Once code works, switch to production framework (Dask/Vaex/Polars) for full dataset. This iteration cycle is much faster than debugging on full data. Use df.sample(frac=0.01) to create representative samples.

Key Takeaways

  • Pandas is your baseline - learn it first, use it for most work
  • Dask scales Pandas - same API, parallel execution, larger datasets
  • Vaex excels at exploration - billion-row datasets, instant opening, fast aggregations
  • Polars is the fastest - modern design, Rust speed, best for production
  • File format matters - use Parquet or HDF5, never CSV for large data
  • Filter early - reduce data as soon as possible in your pipeline
  • Vectorize operations - avoid Python loops and apply() functions
  • Choose by use case - analytics (Pandas), scale (Dask), exploration (Vaex), speed (Polars)
  • Memory awareness - monitor usage, optimize dtypes, partition data
  • Iterate on samples - develop on small data, deploy on full dataset
What's Next?

Congratulations! You've completed all lessons. Now put everything together in the advanced capstone project to build a production-ready system.

  • Capstone Project - Build a high-performance, concurrent application
  • Production Deployment - Deploy and monitor your advanced Python system
  • Best Practices - Apply all techniques learned throughout the course