Data Manipulation at Scale

Pandas, Dask, Vaex & 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.

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.
import pandas as pd

# Load from various sources
df = pd.read_csv('sales_data.csv')
df = pd.read_parquet('sales_data.parquet')  # Much faster!
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
print(df.info())       # Memory usage, non-null counts
print(df.describe())   # Statistical summary

# Quick preview
print(df.head())       # First 5 rows
print(df.tail(10))     # Last 10 rows
print(df.sample(5))    # 5 random rows
Expected Output:
# Example output:
         date  customer_id  product_id  quantity  price
0  2024-01-01         1001         501         2   29.99
1  2024-01-01         1002         502         1   49.99
2  2024-01-02         1001         501         3   29.99
...
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.
# 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)
filtered = df.query('price > 50 and quantity > 1')
Expected Output:
# Output:
print(len(filtered))  # 45231 rows match criteria
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.
# 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
def calculate_revenue(group):
    return (group['price'] * group['quantity']).sum()

revenue_by_customer = df.groupby('customer_id').apply(calculate_revenue)

# 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')
)
Expected Output:
# Output:
print(summary)
#              total_revenue  avg_order_value  order_count  unique_products
# customer_id
# 1001              12458.93            41.53          300               25
# 1002               8932.12            35.73          250               18
# ...
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.
# 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)
pivot = df.pivot_table(
    values='revenue',
    index='date',
    columns='product_id',
    aggfunc='sum',
    fill_value=0
)

# Melt (long format)
long_format = pivot.reset_index().melt(
    id_vars=['date'],
    var_name='product_id',
    value_name='revenue'
)
Expected Output:
# Output:
print(pivot.head())
# product_id    501     502     503
# date
# 2024-01-01  1234.5  2345.6  3456.7
# 2024-01-02  1567.8  2678.9  3789.0
# ...

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

# Load data (lazy - doesn't load immediately)
ddf = dd.read_csv('large_sales_*.csv')  # Multiple files
ddf = dd.read_parquet('sales_data.parquet')

# 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!

# Trigger computation
final_result = result.compute()  # Now it executes in parallel

# Check task graph
print(result.visualize())  # Shows computation plan

# Partitions (how Dask splits data)
print(ddf.npartitions)  # 20 - data split into 20 chunks
print(ddf.divisions)    # Partition boundaries

# Each partition is a Pandas DataFrame
# Dask processes partitions in parallel across cores
Expected Output:
# Example output after .compute():
# customer_id
# 1001    12.458
# 1002     8.932
# 1003    15.234
# ...
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

# Start local cluster (uses all cores)
client = Client()  # Dashboard at http://localhost:8787

# Load large dataset
ddf = dd.read_parquet('sales_100gb.parquet')

# Complex pipeline (all operations are lazy)
result = (
    ddf
    .query('price > 50 and quantity > 1')
    .groupby('customer_id')
    .agg({
        'price': ['sum', 'mean'],
        'quantity': 'sum',
        'product_id': 'nunique'
    })
    .compute()  # Execute in parallel
)

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

# Now operations on ddf_filtered are fast (already in memory)
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()

# 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()]

result = ddf.map_partitions(custom_analysis).compute()
Expected Output:
# Output:
print(f"Processed {len(ddf)} rows using {client.ncores()} cores")
# Processed 1000000000 rows using 16 cores
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('data.csv')
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('data.csv')  # Changed pd to dd
result = (
    ddf[ddf['price'] > 50]
    .groupby('customer_id')['price']
    .mean()
    .compute()  # Added .compute()
)

# 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
difficult_operation = ddf.compute()  # Convert to Pandas
result = difficult_operation.sort_values('complex_column')

# Then convert back to Dask if needed
ddf_result = dd.from_pandas(result, npartitions=20)
Expected Output:
# Output (both versions produce same result):
print(result)
# customer_id
# 1001    42.15
# 1002    38.92
# 1003    51.23
# ...

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 vaex

# Open dataset (instant, no loading!)
df = vaex.open('sales_1billion_rows.hdf5')
df = vaex.open('sales_data.parquet')
df = vaex.open('data/*.csv')  # Multiple files

# Only show what's needed (still not loading all data)
print(df.head())
print(df.tail())
print(df[1000:1010])  # Read only these rows from disk

# Statistics are computed on-demand
print(df.price.mean())      # Fast! Uses efficient algorithms
print(df.price.std())       # Doesn't load all data
print(df.customer_id.unique())  # Scans data, but efficiently

# Convert to Pandas (loads into memory - be careful!)
small_subset = df[df['price'] > 1000]
pandas_df = small_subset.to_pandas_df()  # Now it's in RAM
Expected Output:
# Output:
print(df)
#   #    customer_id    product_id    price    quantity
#   0         1001           501    29.99          2
#   1         1002           502    49.99          1
#   ...
# [1,000,000,000 rows x 10 columns]
# Opened in 0.001 seconds!
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.hdf5')

# Create virtual columns (no memory used!)
df['revenue'] = df.price * df.quantity
df['is_high_value'] = df.price > 100
df['price_category'] = df.price.apply(
    lambda x: 'High' if x > 100 else 'Medium' if x > 50 else 'Low'
)

# Virtual columns computed on-demand
print(df.revenue.mean())  # Computes revenue on-the-fly
print(df.revenue.sum())

# Filter with virtual columns
high_revenue = df[df.revenue > 1000]

# Export to materialize virtual columns
df.export('sales_with_revenue.hdf5')  # Now revenue is a real column

# Expressions are also virtual
df['complex_calc'] = (df.price * df.quantity * 1.1) + df.tax

# Chain operations (all virtual, no memory)
df['normalized_price'] = (df.price - df.price.mean()) / df.price.std()
df['price_zscore'] = df.normalized_price
Expected Output:
# Output:
print(df)  # Shows all columns, including virtual ones
# Shows original columns + revenue, is_high_value, price_category, etc.
# Memory usage: unchanged (virtual columns use 0 bytes)
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_1billion_rows.hdf5')

# Simple aggregations (very fast!)
print(df.price.mean())
print(df.price.sum())
print(df.price.std())
print(df.customer_id.nunique())

# Groupby (optimized for large data)
grouped = df.groupby('customer_id').agg({
    'price': 'mean',
    'quantity': 'sum',
    'product_id': vaex.agg.count()
})

# Multiple aggregations
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')
})

# Binning (histograms) - super fast
counts = df.count(binby='price', limits=[0, 1000], shape=100)

# 2D histograms
counts_2d = df.count(
    binby=[df.price, df.quantity],
    limits=[[0, 1000], [0, 100]],
    shape=[100, 50]
)
Expected Output:
# Output (computed efficiently on 1B rows):
print(grouped)
#   customer_id  total_revenue  avg_price  order_count  unique_products
# 0        1001       12458.93      41.53          300               25
# 1        1002        8932.12      35.73          250               18
# ...

# Time taken: ~5 seconds for 1 billion rows!

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')
result = (
    df
    .filter(pl.col('price') > 50)
    .groupby('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)
    .groupby('customer_id')
    .agg(pl.col('price').sum())
    # Nothing executed yet!
)

# Optimize and execute
result = result_lazy.collect()  # Now it runs (optimized)

# See the query plan
print(result_lazy.explain())

# Example: Lazy is much faster for complex pipelines
# Polars optimizes the entire chain before executing
df_lazy = (
    pl.scan_parquet('sales_*.parquet')
    .filter(pl.col('date') > '2024-01-01')  # Pushed to file read
    .select(['customer_id', 'price'])        # Only reads these columns
    .filter(pl.col('price') > 50)
    .groupby('customer_id')
    .agg(pl.col('price').mean())
)

result = df_lazy.collect()  # Optimized execution
Expected Output:
# Output:
print(result)
# customer_id  price
# 1001         42.15
# 1002         38.92
# ...

# Query plan shows optimizations:
# - Predicate pushdown (filter at file read)
# - Projection pruning (only read needed columns)
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.when(pl.col('price') > 100)
      .then('High')
      .otherwise('Low')
      .alias('price_category')
])

# Powerful aggregations
summary = df.groupby('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').cumsum().over('customer_id').alias('cumulative_spent')
])

# String operations
df = df.with_columns([
    pl.col('customer_name').str.to_uppercase().alias('name_upper'),
    pl.col('customer_name').str.split(' ').alias('name_parts')
])
Expected Output:
# Output:
print(result)
#   customer_id  original_price  revenue  avg_customer_price  price_category
# 0        1001           29.99    59.98               42.15            Low
# 1        1002          149.99   149.99               89.32           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 polars as pl
import time

# Automatic parallelization (uses all cores)
df = pl.read_csv('large_data.csv')

start = time.time()
result = (
    df
    .groupby('customer_id')
    .agg([
        pl.col('price').sum(),
        pl.col('quantity').mean(),
        pl.col('product_id').n_unique()
    ])
)
elapsed = time.time() - start

# Streaming for larger-than-memory data
result = (
    pl.scan_csv('huge_file.csv')
    .filter(pl.col('price') > 50)
    .groupby('customer_id')
    .agg(pl.col('price').sum())
    .collect(streaming=True)  # Process in chunks
)

# Predicate pushdown (filter at read time)
df = pl.scan_parquet('sales_*.parquet') \
    .filter(pl.col('date').is_between('2024-01-01', '2024-12-31')) \
    .collect()
# Only reads relevant row groups from Parquet!

# Projection pushdown (read only needed columns)
df = pl.scan_csv('data.csv') \
    .select(['customer_id', 'price']) \
    .collect()
# Only reads 2 columns, not all!

# Parallel CSV parsing
df = pl.read_csv('data.csv', n_threads=16)  # Uses 16 threads

# Memory-efficient joins
result = df1.join(df2, on='customer_id', how='left')
# Optimized hash join, multi-threaded
Expected Output:
# Performance comparison:
print(f"Polars: {elapsed:.2f} seconds")
# Polars: 0.85 seconds

# Compare with Pandas on same data:
# Pandas: 12.3 seconds (14x slower!)

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
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 50GB of sales data, filter high-value customers, calculate per-customer metrics, and export results. Data includes 500 million transactions.
Pandas Solution
Approach: Load entire dataset into memory, filter and aggregate in-memory.
import pandas as pd

df = pd.read_parquet('sales_50gb.parquet')
filtered = df[df['customer_lifetime_value'] > 10000]
result = filtered.groupby('customer_id').agg({
    'revenue': 'sum',
    'order_count': 'count',
    'avg_order_value': 'mean'
})
result.to_csv('high_value_customers.csv')
Expected Output:
# Performance:
# Time: 180 seconds
# Memory: 50GB (likely OOM - out of memory!)
# CPU: Single-threaded
# Result: Works only if data fits in RAM
Dask Solution
Approach: Parallel processing across all CPU cores, handles larger-than-memory datasets.
import dask.dataframe as dd

ddf = dd.read_parquet('sales_50gb.parquet')
filtered = ddf[ddf['customer_lifetime_value'] > 10000]
result = filtered.groupby('customer_id').agg({
    'revenue': 'sum',
    'order_count': 'count',
    'avg_order_value': 'mean'
}).compute()
result.to_csv('high_value_customers.csv')
Expected Output:
# Performance:
# Time: 60 seconds (3x faster than Pandas)
# Memory: 12GB (processes in chunks)
# CPU: Multi-threaded (uses all cores)
# Result: Good for Pandas users scaling up
Vaex Solution
Approach: Memory-mapped out-of-core processing, instant opening of large files.
import vaex

df = vaex.open('sales_50gb.hdf5')  # Instant!
filtered = df[df.customer_lifetime_value > 10000]
result = filtered.groupby('customer_id', agg={
    'revenue': vaex.agg.sum('revenue'),
    'order_count': vaex.agg.count(),
    'avg_order_value': vaex.agg.mean('order_value')
})
result.export('high_value_customers.csv')
Expected Output:
# Performance:
# Time: 45 seconds (4x faster than Pandas)
# Memory: 2GB (memory-mapped, minimal RAM usage)
# CPU: Multi-threaded
# Result: Great for exploration on massive datasets
Polars Solution
Approach: Rust-based parallel execution with query optimization and lazy evaluation.
import polars as pl

df = pl.scan_parquet('sales_50gb.parquet')
result = (
    df
    .filter(pl.col('customer_lifetime_value') > 10000)
    .groupby('customer_id')
    .agg([
        pl.col('revenue').sum(),
        pl.count().alias('order_count'),
        pl.col('order_value').mean().alias('avg_order_value')
    ])
    .collect()
)
result.write_csv('high_value_customers.csv')
Expected Output:
# Performance:
# Time: 25 seconds (7x faster than Pandas!)
# Memory: 8GB (optimized Arrow format)
# CPU: Multi-threaded + SIMD vectorization
# Result: Fastest overall, best for production pipelines
Comparison Summary:
  • Pandas: Familiar but slowest (180s), high memory (50GB), single-threaded
  • Dask: Good for Pandas users (60s), moderate memory (12GB), multi-core
  • Vaex: Great for exploration (45s), minimal memory (2GB), memory-mapped
  • Polars: Fastest overall (25s), efficient memory (8GB), Rust-optimized

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