Overview
Dask builds on pandas and NumPy to work on datasets larger than memory. It slices a DataFrame into chunks, called partitions, and each partition is just a normal pandas DataFrame. Dask builds a task graph of operations and runs them lazily, parallelizing across cores with the local scheduler or across machines with the distributed scheduler. Because the API mirrors pandas, most existing code works with only small changes.
When to Use
Dask shines when datasets range from a few gigabytes to roughly a terabyte, too big for memory but still manageable on disk. If you already have pandas code that needs to scale, Dask’s familiar API saves you from a major rewrite. It’s also handy when you want parallelism without the overhead of a Spark cluster, or when your ETL pipelines move Parquet, CSV, or HDF5 files around. Finally, choose Dask when your parallel logic is more than simple group-by and join.
When to Avoid
Skip Dask when datasets are under 1 GB. pandas is usually faster because it avoids Dask’s task graph overhead. Also avoid it if you need the full pandas ecosystem, because Dask doesn’t implement every pandas method. If you’re processing live streams, Flink or Structured Streaming fits better. And if Polars is sufficient, go with it — it’s often quicker and lighter for most DataFrame work.
Solution
Basic Dask DataFrame
import dask.dataframe as dd
# Read CSV lazily — does not load until compute()
ddf = dd.read_csv("data/orders_*.csv")
# Read Parquet
ddf = dd.read_parquet("data/orders/")
# From a pandas DataFrame
import pandas as pd
pdf = pd.read_csv("data.csv")
ddf = dd.from_pandas(pdf, npartitions=4)
# Inspect partitions
print(ddf.npartitions) # number of partitions
print(ddf.divisions) # partition boundaries, known if sorted
Lazy Operations
# Build task graph — no execution yet
result = (
ddf
.query("amount > 100")
.groupby("customer_id")
.agg({"amount": "sum"})
.reset_index()
.sort_values("amount", ascending=False)
)
# Execute the graph and get a pandas DataFrame
df = result.compute()
print(df.head(10))
Reading and Writing
# Read multiple CSV files
ddf = dd.read_csv("data/2025-*.csv", parse_dates=["order_date"])
# Read with explicit dtypes
ddf = dd.read_csv(
"data/orders.csv",
dtype={
"order_id": "int64",
"amount": "float64",
"customer_id": "object",
},
)
# Write to Parquet (one file per partition by default)
ddf.to_parquet("data/output/", write_index=False)
# Write to one file per partition as CSV
ddf.to_csv("data/output_*.csv", index=False)
Group-by and Aggregation
# Parallel group-by aggregation
result = (
ddf
.groupby("customer_id")
.agg({
"amount": ["sum", "mean", "count"],
"order_id": "nunique",
})
.compute()
)
# Custom named aggregations
result = (
ddf
.groupby("category")
.agg(
total_revenue=("amount", "sum"),
avg_order=("amount", "mean"),
order_count=("order_id", "count"),
)
.compute()
)
Joins
orders = dd.read_parquet("data/orders/")
customers = dd.read_parquet("data/customers/")
# Standard merge; may require a shuffle if not sorted by the join key
joined = orders.merge(customers, on="customer_id", how="left")
# Broadcast join when the right side is small
small_customers = customers.head(1000) # pandas DataFrame
joined = orders.merge(
dd.from_pandas(small_customers, npartitions=1),
on="customer_id",
how="left",
broadcast=True,
)
result = joined.compute()
Custom Computation with map_partitions
def process_partition(pdf: pd.DataFrame) -> pd.DataFrame:
pdf["amount_with_tax"] = pdf["amount"] * 1.1
pdf["order_date"] = pd.to_datetime(pdf["order_date"])
pdf["month"] = pdf["order_date"].dt.month
return pdf
ddf_processed = ddf.map_partitions(process_partition)
result = ddf_processed.compute()
Custom Task Graphs with delayed
import dask
@dask.delayed
def load_file(path):
return pd.read_csv(path)
@dask.delayed
def clean(df):
df["amount"] = pd.to_numeric(df["amount"], errors="coerce")
return df.dropna(subset=["amount"])
@dask.delayed
def combine(dfs):
return pd.concat(dfs).groupby("customer_id")["amount"].sum()
files = ["data/jan.csv", "data/feb.csv", "data/mar.csv"]
processed = [clean(load_file(f)) for f in files]
result = combine(processed)
df = result.compute()
Repartitioning
# Set a target number of partitions
ddf = ddf.repartition(npartitions=10)
# Or target a partition size
ddf = ddf.repartition(partition_size="100MB")
# Set a sorted index so divisions are known
ddf = ddf.reset_index(drop=True)
ddf = ddf.set_index("customer_id") # triggers a shuffle
Using Dask Distributed
from dask.distributed import Client
# Local cluster using all cores
client = Client(n_workers=4, threads_per_worker=2, memory_limit="4GB")
# All .compute() calls now use the distributed scheduler
ddf = dd.read_parquet("data/orders/")
result = ddf.groupby("customer_id")["amount"].sum().compute()
# Close the client when done
client.close()
Persisting Data in Memory
# Load into distributed memory across workers
ddf_persisted = ddf.persist()
# Reuse the persisted object for faster repeated operations
result = ddf_persisted.groupby("customer_id")["amount"].sum().compute()
Progress Monitoring
from dask.distributed import progress
result = ddf.groupby("customer_id")["amount"].sum()
future = client.compute(result)
progress(future)
df = future.result()
Variants
Dask with S3
ddf = dd.read_parquet(
"s3://my-bucket/data/orders/",
storage_options={"key": "aws-key", "secret": "aws-secret"},
)
ddf.to_parquet(
"s3://my-bucket/data/output/",
storage_options={"key": "aws-key", "secret": "aws-secret"},
)
Dask Bag for Unstructured Data
import dask.bag as db
bag = db.read_text("data/events_*.jsonl").map(json.loads)
result = (
bag
.filter(lambda x: x["event_type"] == "purchase")
.map(lambda x: {"user": x["user_id"], "amount": x["amount"]})
.to_dataframe()
.compute()
)
Dask Array for NumPy Workloads
import dask.array as da
x = da.random.random((10000, 10000), chunks=(1000, 1000))
mean = x.mean(axis=0)
result = mean.compute()
Best Practices
For local work, I start with npartitions around two to four times the number of cores. That balance gives
you parallelism without drowning the scheduler in tiny tasks. Shoot for 50–200 MB chunks. Tiny partitions
waste time on scheduling; huge ones leave cores idle.
Only call .compute() at the end of a chain so Dask can optimize the full task graph. Use .persist() when
you’ll touch a DataFrame more than once; that keeps it in worker memory.
I reach for Parquet instead of CSV because it keeps types, supports column pruning, and reads faster. For
operations that Dask’s API doesn’t expose directly, drop down to map_partitions and write normal pandas
code on each chunk. Avoid .set_index() on large DataFrames because it triggers a full shuffle. For local
development, use the Dask Distributed scheduler; it adds a dashboard and better diagnostics than the default
synchronous scheduler.
Common Mistakes
Calling .compute() too early materializes intermediate results and breaks graph optimization. Chain
operations and call .compute() once at the end. Too many partitions, such as 1,000 tiny 1 MB chunks,
create huge scheduling overhead. Call .repartition() to land in the 50–200 MB range.
Not using .persist() for reused data means Dask recomputes the task graph every time. Keep reused objects
in memory with .persist(). CSV is slower because it re-parses types on every read and skips column
pruning. Dask also reads a sample to infer CSV dtype, which can be wrong. Spell out the dtypes to avoid
nasty type errors.
Frequently Asked Questions
How is Dask different from pandas?
Dask breaks data into partitions and processes them in parallel. pandas puts the whole dataset into one
in-memory DataFrame. Dask mirrors the pandas API but evaluates lazily, so operations build a task graph that runs on
.compute().
How is Dask different from Spark?
Dask is Python-native and uses pandas DataFrames as partitions. Spark uses its own internal format and converts to and from pandas. Dask is lighter and easier to set up; Spark has a broader big-data ecosystem.
How many partitions should I use?
A good target is 50–200 MB per partition. A 10 GB file breaks into 50–200 chunks at that size. For local runs, I
usually start with two to four times my CPU core count, then I check ddf.npartitions after repartitioning.
Can I use Dask on a cluster?
Yes. Point a dask.distributed.Client to scheduler-address:8786 to connect to a remote scheduler. Start
the scheduler with dask-scheduler and workers with dask-worker.
Does Dask support all pandas operations?
Most common operations are supported, including groupby, merge, join, filter, map_partitions, and many
window operations. Some less common ones aren't implemented. Check the Dask API docs for the latest list.
Related Resources
High-Performance DataFrame Operations with Polars
How to use Polars for fast DataFrame operations with lazy evaluation, expression API, streaming, and interop with pandas for large datasets.
RecipeBuild an ETL Pipeline with pandas and Parquet
How to build an extract-transform-load pipeline using pandas for data processing and Parquet for columnar storage with type coercion and validation.
RecipeLarge-Scale Aggregation with PySpark
How to perform group-by aggregations on large datasets with PySpark, covering window functions, UDFs, broadcast joins, and performance tuning.
RecipeValidate DataFrame Schemas with Pandera
How to validate pandas and Polars DataFrame schemas with Pandera, covering column types, constraints, custom checks, hypothesis testing, and schema inheritance.
RecipeSchedule and Monitor DAGs with Apache Airflow
Define, schedule, and monitor Airflow DAGs with operators, sensors, XCom, task dependencies, and the TaskFlow API.
RecipeRead and Write Excel Files with Python
How to read, write, and format Excel spreadsheets using openpyxl and pandas in Python.