---
title: How to Use polars for DataFrames in Rust
summary: Install the polars crate via Cargo and use the prelude to create DataFrames from Series vectors.
lang: en
slug: how-to-use-polars-for-dataframes-in-rust
published: 2026-04-17T12:05:42+00:00
updated: 2026-04-25T22:00:13+00:00
canonical: https://www.rustfaq.org/en/how-to-use-polars-for-dataframes-in-rust/
---

# How to Use polars for DataFrames in Rust



## When tabular data meets Rust

You are used to pandas. You load a CSV, filter rows, group by a column, and you are done. The workflow is linear and immediate. Now you are in Rust. You try to replicate that workflow with `Vec<Vec<T>>` and `HashMap<String, Vec<T>>`. The compiler screams about lifetimes. You spend hours debugging a borrow error while trying to filter a list. You realize the data structure itself is fighting you.

`polars` exists to stop that fight. It gives you a DataFrame that feels familiar but runs with the performance of compiled code. It also introduces a twist. The default mode is lazy. You describe what you want, and `polars` figures out how to do it efficiently. That shift changes how you write data code. You stop thinking about loops and start thinking about query plans.

## The DataFrame concept and columnar storage

A DataFrame is a two-dimensional table. Rows are records, columns are fields. `polars` stores columns separately. This is called a columnar format. Most databases and analytical tools use this because it lets you read only the columns you need and process them in bulk.

`polars` takes this further with lazy evaluation. When you chain operations, `polars` does not execute them immediately. It builds a query plan. The plan is a graph of operations. When you finally ask for the result, `polars` optimizes the graph. It pushes filters down, projects only needed columns, and fuses operations. You write code that looks like a sequence of steps. `polars` runs it like a single optimized pipeline.

Think of `polars` like a construction foreman. In Python, you hand the foreman a list of tasks, and he does them one by one immediately. In `polars`, you hand him the blueprint. He reads the whole plan, realizes he can combine three steps into one, skips a step that is unnecessary, and then executes the optimized plan in one pass. This is lazy evaluation. You describe the work. `polars` decides the best way to do it.

Columnar storage means data is stored by column, not by row. In a row-based format, the memory layout looks like `[row1_col1, row1_col2, row2_col1, row2_col2]`. In columnar storage, it is `[col1_row1, col1_row2, col2_row1, col2_row2]`. When you sum a column, columnar storage lets you read contiguous memory. The CPU prefetcher works better. You can use SIMD instructions to process multiple values at once. `polars` leverages this for aggregations. If you only need two columns from a ten-column file, `polars` reads only those two columns from disk. Row-based formats force you to read the whole row even if you ignore eight columns. This difference becomes massive with large files. Columnar formats also compress better because values in a single column often repeat or follow patterns. `polars` uses efficient compression codecs to reduce memory usage and I/O.

Trust the columnar layout. It is the reason `polars` outperforms row-based approaches on analytical workloads.

## Minimal example: creating a DataFrame

You start by adding the crate to your dependencies. The `lazy` feature is essential for the query optimizer. Without it, you get eager execution only.

```toml
[dependencies]
polars = { version = "0.40", features = ["lazy"] }
```

The prelude brings the core types and functions into scope. You create `Series` objects for each column and assemble them into a `DataFrame`.

```rust
use polars::prelude::*;

fn main() -> PolarsResult<()> {
    // Series hold a single column of data.
    // They enforce type safety per column.
    let foo = Series::new("foo", &[1, 2, 3]);
    let bar = Series::new("bar", &["a", "b", "c"]);

    // DataFrame::new takes a Vec of Series.
    // All Series must have the same length.
    // The ? operator returns early if lengths mismatch.
    let df = DataFrame::new(vec![foo, bar])?;

    println!("{df}");
    Ok(())
}
```

The `polars::prelude` brings everything you need into scope. `Series` is the core type. A `Series` can hold integers, strings, floats, or other types. It knows its type at compile time, which lets `polars` use optimized code paths. `DataFrame::new` validates that all columns have the same number of rows. If you pass columns of different lengths, it returns an error. The function signature `fn main() -> PolarsResult<()>` tells the compiler that `main` can fail. This is standard Rust practice for code that might error. You use `?` to propagate the error up. If `DataFrame::new` fails, `main` returns immediately with that error. `PolarsResult` is a type alias for `Result<T, PolarsError>`. It wraps the error type so you do not have to type the full name everywhere.

Convention aside: the community prefers explicit `Rc::clone` style naming when cloning reference-counted types, but `polars` handles its own internal sharing. When you clone a `DataFrame`, you are usually cloning the metadata and sharing the underlying column data. This is cheap. Do not assume a clone copies all the data.

## Realistic workflow: lazy evaluation and expressions

Real data work involves loading files, filtering, and aggregating. `polars` shines here with the `LazyFrame` API. You build a plan and execute it at the end.

Expressions are the heart of `polars`. An expression describes a computation on a column or set of columns. `col("amount")` refers to a column. `lit(100)` creates a literal value. `col("amount").gt(lit(100))` creates a boolean expression. You chain these to build logic. The expression API is declarative. You describe the transformation, not the iteration. This allows `polars` to optimize. For example, `col("a").cast(DataType::Float64) + col("b")` can be fused into a single kernel that casts and adds in one pass. If you write a loop in Rust to do this, you iterate multiple times. Expressions let `polars` handle the iteration and optimization. The community calls this the "expression DSL". You learn the vocabulary of `col`, `lit`, `when`, `then`, `otherwise`, `alias`, and `agg`. Once you know these, you can express almost any data transformation without writing custom loops.

```rust
use polars::prelude::*;

fn main() -> PolarsResult<()> {
    // LazyCsvReader builds a query plan for CSV loading.
    // Data is not loaded until collect() is called.
    let lf = LazyCsvReader::new("sales.csv")
        .with_has_header(true)
        .finish()?
        // Filter rows where amount > 100.
        // This adds a filter node to the plan.
        .filter(col("amount").gt(lit(100)))
        // Group by region and sum the amount.
        // This adds aggregation nodes.
        .group_by([col("region")])
        .agg([col("amount").sum().alias("total_sales")])
        // Execute the plan and collect the result.
        // polars optimizes the graph before running.
        .collect()?;

    println!("{lf}");
    Ok(())
}
```

The `LazyCsvReader` configures how the CSV is parsed. You set options like headers and delimiters. The `finish` method returns a `LazyFrame`. You chain methods on the `LazyFrame` to add operations. Each method returns a new `LazyFrame` with the updated plan. The `collect` method triggers execution. `polars` optimizes the plan, pushes the filter down to the CSV reader, and reads only the `amount` and `region` columns. It groups and aggregates in a single pass. The result is a `DataFrame`.

Convention aside: `polars` has many features. You enable only what you need. This keeps compile times down and binary size small. The `lazy` feature is essential. The community standard is to enable `lazy` and the file formats you use. Avoid `default-features = false` unless you are building a minimal binary, because some features are interdependent. Pin your version in `Cargo.toml`. `polars` moves fast. Breaking changes can happen between minor versions. Check the changelog before upgrading.

## Pitfalls and compiler interactions

If you mix types in a `Series`, the compiler rejects you. `Series::new("mixed", &[1, "a"])` fails with a type error. Rust requires homogeneous types in slices. You must cast or use `AnyValue` for mixed data, though `AnyValue` is slower. If you forget the `?` operator on a `PolarsResult`, the compiler warns you about an unused result. If you try to mutate a `DataFrame` that is borrowed, you hit `E0502` (cannot borrow as mutable because it is also borrowed as immutable). `polars` DataFrames are immutable by default. You create new DataFrames from transformations rather than modifying in place. This matches Rust's ownership model and prevents data races. If you pass a `Series` of the wrong type to a function expecting integers, you get `E0308` (mismatched types). `polars` uses Rust's type system to catch schema errors at compile time when possible. Runtime errors happen when data is invalid, like a CSV containing text in a numeric column. Those return `PolarsError`.

A common mistake is using the eager API for complex pipelines. Eager execution runs each step immediately. This creates intermediate DataFrames in memory. The query optimizer cannot fuse operations. You lose performance and memory efficiency. If your pipeline has more than three steps, switch to `LazyFrame`. The optimizer will save you time and memory.

Another pitfall is ignoring errors. `polars` operations return `PolarsResult`. If you discard the result, you risk silent data corruption or panics later. Handle the result or return it. The `?` operator is your friend. It propagates errors cleanly.

Treat `PolarsResult` as a signal. If you ignore errors, you risk silent data corruption. Handle the result or return it.

## Decision: polars versus other tools

Use `polars` when you need tabular data analysis with filtering, grouping, and joins. Use `polars` when you want lazy evaluation to optimize complex query pipelines automatically. Use `polars` when you are processing CSV, Parquet, or JSON files and need high throughput. Reach for `Vec<T>` when you have a simple list of items and do not need columnar operations or schema enforcement. Reach for `HashMap<K, V>` when you need key-value lookups without the overhead of a full DataFrame structure. Reach for `ndarray` when you are doing dense numerical matrix operations like linear algebra, where row-major or column-major contiguous memory access matters more than schema flexibility.

## Where to go next

- [How to Use Vec<T> (Vectors) in Rust: The Complete Guide](/en/how-to-use-vect-vectors-in-rust-the-complete-guide/)
- [How to Use HashMap in Rust: The Complete Guide](/en/how-to-use-hashmap-in-rust-the-complete-guide/)
- [How to Filter a Vector in Rust](/en/how-to-filter-a-vector-in-rust/)