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.
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 runExpected 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
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.
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
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.
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
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.
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
Framework Comparison
| Aspect | Pandas | Dask | Vaex | Polars |
|---|---|---|---|---|
| Performance | Baseline (1x) | 2-3x (parallel) | 5-10x (aggregations) | 5-10x (general) |
| Memory Usage | High (all in RAM) | Medium (chunked) | Low (memory-mapped) | Medium-Low (Arrow) |
| Dataset Size | < 10GB | < 1TB (local) | < 1TB | < 100GB |
| Parallelization | No | Yes (automatic) | Yes (automatic) | Yes (automatic) |
| Learning Curve | Easy | Easy (if know Pandas) | Medium | Medium |
| Ecosystem | Huge | Large | Small | Growing |
| API Consistency | Mixed | Pandas-like | Custom | Excellent |
| Query Optimization | No | Yes (task graphs) | Limited | Yes (lazy mode) |
| Best Use Case | General analytics | Pandas at scale | Exploratory analysis | Production 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.
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
- 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