Polars Essentials
What is Polars
Section titled βWhat is PolarsβPolars is a DataFrame library written in Rust. It is designed for speed and can handle large datasets much faster than pandas. Polars uses an expression system instead of method chaining. It also supports lazy evaluation, which lets Polars optimize your query before running it. Unlike pandas, Polars has no index column.
If you work with gene expression matrices or variant call tables with millions of rows, Polars will feel noticeably faster.
Creating a DataFrame
Section titled βCreating a DataFrameβStart by importing Polars and creating a DataFrame of differentially expressed genes.
import polars as pl
degs = pl.DataFrame({ "gene": ["TP53", "BRCA1", "EGFR", "KRAS", "MYC", "PTEN", "RB1", "APC"], "log2fc": [2.5, -1.8, 3.2, 0.3, -2.1, 1.5, -0.2, 0.8], "pvalue": [0.001, 0.003, 0.0001, 0.45, 0.002, 0.01, 0.67, 0.12], "biotype": ["protein_coding"] * 8,})print(degs)shape: (8, 4)βββββββββ¬βββββββββ¬βββββββββ¬ββββββββββββββββββ gene β log2fc β pvalue β biotype ββ --- β --- β --- β --- ββ str β f64 β f64 β str ββββββββββͺβββββββββͺβββββββββͺβββββββββββββββββ‘β TP53 β 2.5 β 0.001 β protein_coding ββ BRCA1 β -1.8 β 0.003 β protein_coding ββ EGFR β 3.2 β 0.0001 β protein_coding ββ KRAS β 0.3 β 0.45 β protein_coding ββ MYC β -2.1 β 0.002 β protein_coding ββ PTEN β 1.5 β 0.01 β protein_coding ββ RB1 β -0.2 β 0.67 β protein_coding ββ APC β 0.8 β 0.12 β protein_coding ββββββββββ΄βββββββββ΄βββββββββ΄βββββββββββββββββEach column has a strict type. Polars infers these types automatically from the data you provide.
Basic inspection
Section titled βBasic inspectionβCheck the shape of the DataFrame. This returns a tuple of rows and columns.
print(degs.shape)(8, 4)View the data types for each column.
print(degs.dtypes)[String, Float64, Float64, String]Preview the first few rows with head().
print(degs.head(3))shape: (3, 4)βββββββββ¬βββββββββ¬βββββββββ¬ββββββββββββββββββ gene β log2fc β pvalue β biotype ββ --- β --- β --- β --- ββ str β f64 β f64 β str ββββββββββͺβββββββββͺβββββββββͺβββββββββββββββββ‘β TP53 β 2.5 β 0.001 β protein_coding ββ BRCA1 β -1.8 β 0.003 β protein_coding ββ EGFR β 3.2 β 0.0001 β protein_coding ββββββββββ΄βββββββββ΄βββββββββ΄βββββββββββββββββSelecting columns
Section titled βSelecting columnsβUse select() to pick specific columns. Pass column names as strings.
print(degs.select("gene", "log2fc"))shape: (8, 2)βββββββββ¬ββββββββββ gene β log2fc ββ --- β --- ββ str β f64 ββββββββββͺβββββββββ‘β TP53 β 2.5 ββ BRCA1 β -1.8 ββ EGFR β 3.2 ββ KRAS β 0.3 ββ MYC β -2.1 ββ PTEN β 1.5 ββ RB1 β -0.2 ββ APC β 0.8 ββββββββββ΄βββββββββFiltering rows
Section titled βFiltering rowsβUse filter() with pl.col() expressions to select rows that match a condition. Here we keep only genes with a significant p-value.
sig = degs.filter(pl.col("pvalue") < 0.05)print(sig)shape: (5, 4)βββββββββ¬βββββββββ¬βββββββββ¬ββββββββββββββββββ gene β log2fc β pvalue β biotype ββ --- β --- β --- β --- ββ str β f64 β f64 β str ββββββββββͺβββββββββͺβββββββββͺβββββββββββββββββ‘β TP53 β 2.5 β 0.001 β protein_coding ββ BRCA1 β -1.8 β 0.003 β protein_coding ββ EGFR β 3.2 β 0.0001 β protein_coding ββ MYC β -2.1 β 0.002 β protein_coding ββ PTEN β 1.5 β 0.01 β protein_coding ββββββββββ΄βββββββββ΄βββββββββ΄βββββββββββββββββCombine multiple conditions with & for AND. Wrap each condition in parentheses. This filters for significantly upregulated genes.
sig_up = degs.filter( (pl.col("pvalue") < 0.05) & (pl.col("log2fc") > 1))print(sig_up)shape: (3, 4)ββββββββ¬βββββββββ¬βββββββββ¬ββββββββββββββββββ gene β log2fc β pvalue β biotype ββ --- β --- β --- β --- ββ str β f64 β f64 β str βββββββββͺβββββββββͺβββββββββͺβββββββββββββββββ‘β TP53 β 2.5 β 0.001 β protein_coding ββ EGFR β 3.2 β 0.0001 β protein_coding ββ PTEN β 1.5 β 0.01 β protein_coding βββββββββ΄βββββββββ΄βββββββββ΄βββββββββββββββββAdding columns with with_columns
Section titled βAdding columns with with_columnsβUse with_columns() to create new columns from existing ones. The original DataFrame is not modified. Polars returns a new DataFrame with the added columns.
Here we compute the negative log10 p-value and label each gene as βupβ or βdownβ.
result = degs.with_columns( (-pl.col("pvalue").log(base=10)).alias("neg_log10p"), pl.when(pl.col("log2fc") > 0) .then(pl.lit("up")) .otherwise(pl.lit("down")) .alias("direction"),)print(result.select("gene", "log2fc", "pvalue", "neg_log10p", "direction"))shape: (8, 5)βββββββββ¬βββββββββ¬βββββββββ¬βββββββββββββ¬βββββββββββββ gene β log2fc β pvalue β neg_log10p β direction ββ --- β --- β --- β --- β --- ββ str β f64 β f64 β f64 β str ββββββββββͺβββββββββͺβββββββββͺβββββββββββββͺββββββββββββ‘β TP53 β 2.5 β 0.001 β 3.0 β up ββ BRCA1 β -1.8 β 0.003 β 2.522879 β down ββ EGFR β 3.2 β 0.0001 β 4.0 β up ββ KRAS β 0.3 β 0.45 β 0.346787 β up ββ MYC β -2.1 β 0.002 β 2.69897 β down ββ PTEN β 1.5 β 0.01 β 2.0 β up ββ RB1 β -0.2 β 0.67 β 0.173925 β down ββ APC β 0.8 β 0.12 β 0.920819 β up ββββββββββ΄βββββββββ΄βββββββββ΄βββββββββββββ΄ββββββββββββThe pl.when().then().otherwise() pattern works like an if/else statement. You can chain multiple .when() calls to handle more conditions.
Sorting
Section titled βSortingβSort by p-value to see the most significant genes first.
print(degs.sort("pvalue").select("gene", "pvalue"))shape: (8, 2)βββββββββ¬ββββββββββ gene β pvalue ββ --- β --- ββ str β f64 ββββββββββͺβββββββββ‘β EGFR β 0.0001 ββ TP53 β 0.001 ββ MYC β 0.002 ββ BRCA1 β 0.003 ββ PTEN β 0.01 ββ APC β 0.12 ββ KRAS β 0.45 ββ RB1 β 0.67 ββββββββββ΄βββββββββUse descending=True to sort from highest to lowest. Here we find the top 3 upregulated genes.
print(degs.sort("log2fc", descending=True).select("gene", "log2fc").head(3))shape: (3, 2)ββββββββ¬ββββββββββ gene β log2fc ββ --- β --- ββ str β f64 βββββββββͺβββββββββ‘β EGFR β 3.2 ββ TP53 β 2.5 ββ PTEN β 1.5 βββββββββ΄βββββββββgroup_by
Section titled βgroup_byβUse group_by() to split data into groups and compute summary statistics with agg(). This is useful for comparing conditions in an experiment.
sample_counts = pl.DataFrame({ "sample": ["S1", "S1", "S2", "S2", "S3", "S3"], "gene": ["TP53", "BRCA1", "TP53", "BRCA1", "TP53", "BRCA1"], "condition": ["control", "control", "treated", "treated", "treated", "treated"], "count": [100, 250, 450, 120, 380, 95],})
grouped = sample_counts.group_by("condition").agg( pl.col("count").mean().alias("mean_count"), pl.col("count").count().alias("n_samples"),)print(grouped.sort("condition"))shape: (2, 3)βββββββββββββ¬βββββββββββββ¬βββββββββββββ condition β mean_count β n_samples ββ --- β --- β --- ββ str β f64 β u32 ββββββββββββββͺβββββββββββββͺββββββββββββ‘β control β 175.0 β 2 ββ treated β 261.25 β 4 ββββββββββββββ΄βββββββββββββ΄ββββββββββββNote that group_by() does not guarantee row order. Always chain .sort() if you need consistent output.
Lazy evaluation
Section titled βLazy evaluationβLazy mode is one of the biggest advantages of Polars. When you call .lazy(), Polars does not execute anything right away. Instead it builds a query plan. Polars then optimizes this plan before running it. For example, it may push filters before sorts to reduce the number of rows processed.
Call .collect() at the end to execute the plan and get a DataFrame back.
result = ( degs.lazy() .filter(pl.col("pvalue") < 0.05) .with_columns( pl.when(pl.col("log2fc") > 1).then(pl.lit("up")) .when(pl.col("log2fc") < -1).then(pl.lit("down")) .otherwise(pl.lit("ns")) .alias("direction") ) .sort("pvalue") .select("gene", "log2fc", "pvalue", "direction") .collect())print(result)shape: (5, 4)βββββββββ¬βββββββββ¬βββββββββ¬βββββββββββββ gene β log2fc β pvalue β direction ββ --- β --- β --- β --- ββ str β f64 β f64 β str ββββββββββͺβββββββββͺβββββββββͺββββββββββββ‘β EGFR β 3.2 β 0.0001 β up ββ TP53 β 2.5 β 0.001 β up ββ MYC β -2.1 β 0.002 β down ββ BRCA1 β -1.8 β 0.003 β down ββ PTEN β 1.5 β 0.01 β up ββββββββββ΄βββββββββ΄βββββββββ΄ββββββββββββFor small datasets the speed difference is negligible. For millions of rows, lazy evaluation can be significantly faster.
Reshaping data
Section titled βReshaping dataβGene expression data often needs to be converted between wide and long formats. Wide format has one column per sample. Long format has one row per measurement.
unpivot
Section titled βunpivotβThe unpivot() method converts wide data to long format. This is equivalent to melt() in pandas.
expression_wide = pl.DataFrame({ "gene": ["TP53", "BRCA1", "EGFR"], "Sample1": [100, 250, 50], "Sample2": [120, 300, 75], "Sample3": [90, 280, 60],})print(expression_wide)shape: (3, 4)βββββββββ¬ββββββββββ¬ββββββββββ¬βββββββββββ gene β Sample1 β Sample2 β Sample3 ββ --- β --- β --- β --- ββ str β i64 β i64 β i64 ββββββββββͺββββββββββͺββββββββββͺββββββββββ‘β TP53 β 100 β 120 β 90 ββ BRCA1 β 250 β 300 β 280 ββ EGFR β 50 β 75 β 60 ββββββββββ΄ββββββββββ΄ββββββββββ΄ββββββββββexpression_long = expression_wide.unpivot( on=["Sample1", "Sample2", "Sample3"], index="gene", variable_name="sample", value_name="count",)print(expression_long)shape: (9, 3)βββββββββ¬ββββββββββ¬βββββββββ gene β sample β count ββ --- β --- β --- ββ str β str β i64 ββββββββββͺββββββββββͺββββββββ‘β TP53 β Sample1 β 100 ββ BRCA1 β Sample1 β 250 ββ EGFR β Sample1 β 50 ββ TP53 β Sample2 β 120 ββ BRCA1 β Sample2 β 300 ββ EGFR β Sample2 β 75 ββ TP53 β Sample3 β 90 ββ BRCA1 β Sample3 β 280 ββ EGFR β Sample3 β 60 ββββββββββ΄ββββββββββ΄ββββββββLong format is useful for plotting and for group_by operations across samples.
The pivot() method converts long data back to wide format.
back_to_wide = expression_long.pivot( on="sample", index="gene", values="count",)print(back_to_wide)shape: (3, 4)βββββββββ¬ββββββββββ¬ββββββββββ¬βββββββββββ gene β Sample1 β Sample2 β Sample3 ββ --- β --- β --- β --- ββ str β i64 β i64 β i64 ββββββββββͺββββββββββͺββββββββββͺββββββββββ‘β TP53 β 100 β 120 β 90 ββ BRCA1 β 250 β 300 β 280 ββ EGFR β 50 β 75 β 60 ββββββββββ΄ββββββββββ΄ββββββββββ΄ββββββββββReading and writing files
Section titled βReading and writing filesβPolars can read and write CSV, TSV, Parquet, and other formats. Here we write a TSV and read it back.
degs.select("gene", "log2fc", "pvalue").write_csv("/tmp/degs_polars.tsv", separator="\t")read_back = pl.read_csv("/tmp/degs_polars.tsv", separator="\t")print(read_back)shape: (8, 3)βββββββββ¬βββββββββ¬ββββββββββ gene β log2fc β pvalue ββ --- β --- β --- ββ str β f64 β f64 ββββββββββͺβββββββββͺβββββββββ‘β TP53 β 2.5 β 0.001 ββ BRCA1 β -1.8 β 0.003 ββ EGFR β 3.2 β 0.0001 ββ KRAS β 0.3 β 0.45 ββ MYC β -2.1 β 0.002 ββ PTEN β 1.5 β 0.01 ββ RB1 β -0.2 β 0.67 ββ APC β 0.8 β 0.12 ββββββββββ΄βββββββββ΄βββββββββFor large files, consider using Parquet format with write_parquet() and read_parquet(). Parquet files are compressed and much faster to read than CSV.
Pandas vs Polars quick comparison
Section titled βPandas vs Polars quick comparisonβIf you already know pandas, this table maps common operations to their Polars equivalents.
| Operation | pandas | Polars |
|---|---|---|
| Select columns | df[["gene", "log2fc"]] |
df.select("gene", "log2fc") |
| Filter rows | df[df["pvalue"] < 0.05] |
df.filter(pl.col("pvalue") < 0.05) |
| Add column | df["new"] = values |
df.with_columns(expr.alias("new")) |
| Sort | df.sort_values("col") |
df.sort("col") |
| Group + aggregate | df.groupby("col").agg(...) |
df.group_by("col").agg(...) |
| Wide to long | df.melt(...) |
df.unpivot(...) |
| Long to wide | df.pivot(...) |
df.pivot(...) |
| Read CSV | pd.read_csv(...) |
pl.read_csv(...) |
The biggest difference is that Polars uses expressions like pl.col("gene") instead of bracket indexing. This takes some getting used to, but it makes complex queries more readable.
Next steps
Section titled βNext stepsβContinue to OOP with Classes to learn how to organize your bioinformatics code into reusable classes.