Skip to content

Split View: Polars 1.x vs Pandas — Pandas 시대의 끝인가? Rust·Arrow·lazy 시대의 dataframe 심층 분석 2026

|

Polars 1.x vs Pandas — Pandas 시대의 끝인가? Rust·Arrow·lazy 시대의 dataframe 심층 분석 2026

프롤로그 — "Pandas의 시대가 끝났다"는 말의 정확한 의미

2025~2026년에 dataframe 커뮤니티에서 가장 많이 들린 문장은 이거다.

"Polars로 갈아탔더니 같은 코드가 10배 빨라졌어요."

그리고 그 옆에 항상 따라붙는 또 다른 문장.

"그런데 sklearn에 넣으려면 결국 to_pandas()를 부르더라고요."

이 두 문장이 2026년 dataframe 세계를 정확히 요약한다. Polars는 새 워크로드를 거의 다 가져가고 있다. 그러나 Pandas는 사라지지 않는다. 15년 치 ecosystem inertia — sklearn 입력, statsmodels, 시각화 라이브러리, 수십만 개의 stackoverflow 답변, 학교 강의자료 — 가 Pandas를 안전 자산으로 만들었다.

이 글은 그 사이의 균형점을 정확히 본다. Polars 1.x의 stability 약속, Rust + Arrow 아키텍처, lazy evaluation과 query optimizer가 실제로 무엇을 하는지, Pandas 2.2 + PyArrow backend가 어디까지 따라잡았고 어디서 막혔는지, DuckDB가 같은 Arrow-native query engine으로서 어디서 Polars를 이기고 어디서 지는지, Dask·Ibis가 가진 자리, 마이그레이션의 진짜 함정. 그리고 마지막에 — 그래도 Pandas를 떠나면 안 되는 경우.


1장 · Arrow가 깔린 바닥 — dataframe revolution의 진짜 주인공

Polars vs Pandas 비교에서 가장 자주 빠뜨리는 것: 두 라이브러리 모두 Apache Arrow를 깔고 있다. 진짜 혁명은 Polars가 아니다. Arrow다.

왜 Arrow가 중요한가

Arrow는 columnar in-memory 포맷이다. NumPy도 columnar지만, 차이가 크다.

  • 언어 중립 메모리 레이아웃 — Python, Rust, C++, Java, Go가 같은 메모리를 zero-copy로 공유한다.
  • column-major — 같은 column의 값이 메모리에서 연속(contiguous)이다. SIMD에 최적.
  • type system 내장 — string, list, struct, dictionary(category), date/time/decimal까지 표준 정의.
  • null bitmap이 별도NaN을 null로 오용하는 NumPy/Pandas 1.x의 고질병이 사라진다.
  • chunked array — 큰 column을 chunk로 나누고 lazy/streaming에 쓴다.

이 한 줄이 핵심이다.

Arrow 위에서는 Polars·Pandas·DuckDB·Spark가 같은 메모리를 본다. 변환 비용이 0에 수렴한다.

df.to_arrow() → DuckDB가 그대로 읽는다. DuckDB가 쓴 Arrow → Polars가 그대로 받는다. zero-copy. 이게 2020년대 dataframe 세계가 합쳐지는 이유다.

Arrow vs NumPy 백엔드의 실용 차이

항목NumPy backend (Pandas 1.x)Arrow backend (Polars / Pandas 2.2+)
문자열object dtype, Python 객체 liststring dtype, 연속 메모리, 5–10배 빠름
nullNaN 강제, int column이 float로 변환됨정식 null bitmap, dtype 보존
카테고리category dtype, 별도 구현dictionary type, 표준
시계열datetime64[ns] 고정timestamp[us, tz] 등 풍부
zero-copy 공유어렵다DuckDB·Polars·Spark와 즉시
SIMD 활용제한적columnar 구조에 최적

이 표가 곧 Polars가 Pandas를 압도하는 거의 모든 이유다. Polars는 이걸 처음부터 했고, Pandas는 2.x에서 옵션으로 따라잡고 있다.


2장 · Polars 1.x — Rust 코어와 안정성 약속

Polars는 Ritchie Vink가 2020년에 시작한 Rust dataframe 라이브러리다. 2024년 8월 1.0이 나왔고, 그 이후로 minor를 빠르게 올리며 public API stability를 약속했다. 2026년 현재 1.x 후반(릴리즈 노트는 Polars GitHub Releases)이며, 깨지는 변경은 옵트인 새 API로 들어오고 기존 API는 deprecation 사이클을 거친다.

Rust 코어 + Python binding

  • 코어는 Rustpolars crate. memory safety, SIMD, no GIL 병렬.
  • Python은 bindingpy-polars. Rust 코어를 PyO3로 감싼다.
  • R, NodeJS binding도 같은 코어 위.

이게 의미하는 바: Python의 GIL이 Polars의 발목을 잡지 않는다. group-by가 진짜로 모든 코어를 쓴다. NumPy도 BLAS로 병렬이지만 dataframe 연산(group-by, join) 전체는 GIL에 묶여 있다.

Polars의 데이터 모델

import polars as pl

df = pl.DataFrame({
    "user_id": [1, 2, 3, 4],
    "country": ["KR", "US", "KR", "JP"],
    "amount": [120.0, 95.0, 200.0, 75.0],
})
print(df.schema)
# Schema({'user_id': Int64, 'country': String, 'amount': Float64})

schemacolumn 이름 -> Arrow dtype의 OrderedDict. 모든 dtype이 명시적이고 추론 마법이 적다.

Eager vs Lazy

Polars는 두 모드를 가진다.

  • eagerpl.DataFrame. Pandas처럼 즉시 실행.
  • lazypl.LazyFrame. 표현식을 plan으로 쌓아두고 .collect()에서 한 번에 최적화·실행.
# eager
df = pl.read_parquet("sales.parquet")
big_kr = df.filter(pl.col("country") == "KR").select(["user_id", "amount"])

# lazy — query plan을 쌓고 .collect()에서 실행
plan = (
    pl.scan_parquet("sales.parquet")          # I/O를 미룬다
      .filter(pl.col("country") == "KR")
      .select(["user_id", "amount"])
)
big_kr = plan.collect()

차이가 보이는가? scan_parquet파일을 아직 읽지 않는다. 다음 장에서 그 의미가 폭발한다.


3장 · Lazy evaluation과 query optimizer — Polars가 마법처럼 빠른 이유

Polars 성능의 절반은 lazy plan을 최적화하는 query optimizer에서 나온다. 무엇을 하는가?

3.1 Predicate pushdown — 필터를 I/O 단계로 밀어넣는다

plan = (
    pl.scan_parquet("sales.parquet")          # 1억 row
      .filter(pl.col("country") == "KR")       # KR만 100만 row
      .select(["user_id", "amount"])
)

순진한 실행: 1억 row 다 읽고 -> 메모리에서 필터링. 100배 비싸다.

Polars optimizer가 하는 일: filter를 scan으로 밀어넣는다. Parquet의 row group 통계(min/max)를 보고 country != "KR"인 row group은 건너뛴다. 즉, 디스크 I/O가 100배 줄어든다.

3.2 Projection pushdown — 필요한 column만 읽는다

select(["user_id", "amount"])가 끝에 있어도, Parquet은 columnar이므로 그 두 column만 디스크에서 읽는다. Pandas는 pd.read_parquet() 시점에 column 전체를 읽고 나중에 select하면 늦다. (Pandas도 columns= 인자를 받지만, plan 차원의 자동 추론은 아니다.)

3.3 Common subexpression elimination

plan = (
    df.lazy()
      .with_columns([
          (pl.col("price") * pl.col("qty")).alias("revenue"),
          (pl.col("price") * pl.col("qty") * 0.1).alias("commission"),
      ])
)

price * qty가 두 번 등장한다. optimizer는 한 번만 계산하고 재사용한다.

3.4 Slice pushdown, type coercion, dead column 제거

.head(100)을 plan 끝에 두면 그 100개를 만족시킬 만큼만 위에서 끌어올린다. join 후 안 쓰는 column은 join 전에 제거. 명시적이지 않은 cast를 줄인다.

3.5 Streaming engine

collect(streaming=True)(2026년에는 새 streaming engine이 stable에 가까워졌다 — Polars 블로그의 streaming 발표 참조)는 메모리에 다 안 들어가는 데이터셋도 chunk 단위로 처리한다. out-of-core를 한 라이브러리 안에서 한다.

plan 시각화

plan.explain()       # 사람이 읽는 plan
plan.show_graph()    # graphviz, optimized vs naive 비교 가능

이게 Polars의 두뇌다. Pandas는 이런 게 없다. 명령을 받으면 그대로 실행한다.


4장 · Polars expression API — "Series 1개 = 식 1개"의 사고

Polars의 가장 큰 mental model 변화는 expression이다.

import polars as pl

df.select([
    pl.col("amount").sum().alias("total"),
    pl.col("amount").mean().over("country").alias("country_avg"),
    (pl.col("amount") - pl.col("amount").mean()).alias("diff_from_mean"),
])

각 expression은 **column 변환의 식(DAG)**이다. Pandas의 column 조작이 imperative라면, Polars expression은 declarative — Spark DataFrame이나 SQL에 가깝다.

Pandas와의 사고 차이:

사고PandasPolars
column 새로 만들기df["x"] = df["a"] + df["b"] (대입)df.with_columns((pl.col("a") + pl.col("b")).alias("x")) (식)
group-by 집계df.groupby("k")["x"].sum() (체이닝)df.group_by("k").agg(pl.col("x").sum()) (expression list)
windowdf.groupby("k")["x"].transform("mean")pl.col("x").mean().over("k")
다중 column 동시 변환for loop or applyexpression list로 한 번에

처음엔 어색하지만 며칠이면 익숙해진다. 그리고 expression이 plan optimizer의 노드라는 사실이 중요하다. 식을 declarative로 표현하기 때문에 Polars가 최적화할 수 있는 것이다.


5장 · Pandas 2.2 — PyArrow backend로 어디까지 따라잡았는가

Pandas는 2.0(2023)에서 PyArrow backend를 도입하고, 2.2(2024)에서 안정화했다. 2026년 현재 Pandas 2.2.x는 사실상 표준이며 3.0이 길게 베타 중이다.

Arrow backend를 켜는 법

import pandas as pd

# 방법 A — 명시적으로 Arrow dtype 지정
df = pd.read_parquet("sales.parquet", dtype_backend="pyarrow")

# 방법 B — Series dtype을 Arrow로
s = pd.Series([1, 2, None], dtype="int64[pyarrow]")

이게 켜지면 무엇이 좋아지나:

  • 문자열 column이 진짜 빠르고(연속 메모리), 메모리도 절반 이상 줄어든다.
  • int column에 null이 있어도 float로 변환되지 않는다 (Int64[pyarrow] nullable).
  • date/time이 Arrow timestamp 그대로.
  • DuckDB·Polars·Spark와 zero-copy 가능.

그런데 Pandas 2.2가 못 한 것

  • lazy evaluation이 여전히 없다. 모든 연산이 eager. predicate pushdown은 read_parquet(filters=)로만 부분 지원.
  • query optimizer가 없다. Pandas는 사용자가 쓴 순서대로 실행한다.
  • GIL 안에서 single-thread가 기본. numba/numexpr/pyarrow.compute를 케이스별로 호출해야 병렬 혜택.
  • expression API가 없다. group-by 직후의 agg에서 multi-column 식을 깔끔히 표현하기 힘들다.
  • API 표면이 거대 — pandas 자체가 거대한 dictionary다. 마법 같은 일이 많이 일어난다.

요약: 데이터 구조는 Arrow에 와 있지만, 실행 엔진은 여전히 1990년대식 imperative다. 그래서 Polars가 같은 Arrow 위에서도 더 빠른 것이다.


6장 · Polars vs Pandas — 본격 비교 매트릭스

같은 일을 두 라이브러리로 나란히 써본다.

6.1 Parquet 읽고, 필터링, group-by

Pandas:

import pandas as pd

df = pd.read_parquet("sales.parquet", dtype_backend="pyarrow")
kr = df[df["country"] == "KR"]
result = (
    kr.groupby("user_id", as_index=False)["amount"]
      .agg(["sum", "mean", "count"])
)

Polars (lazy):

import polars as pl

result = (
    pl.scan_parquet("sales.parquet")
      .filter(pl.col("country") == "KR")
      .group_by("user_id")
      .agg([
          pl.col("amount").sum().alias("total"),
          pl.col("amount").mean().alias("avg"),
          pl.col("amount").count().alias("n"),
      ])
      .collect()
)

코드 양이 비슷해 보여도 plan 차원에서 일어나는 일이 다르다. Polars는 filter를 scan으로 밀고, amount·user_id·country 세 column만 디스크에서 읽는다.

6.2 join

Pandas:

merged = pd.merge(orders, users, on="user_id", how="left")

Polars:

merged = orders.lazy().join(users.lazy(), on="user_id", how="left").collect()

Polars의 join은 기본적으로 hash join, parallel. 큰 데이터에서 차이가 크다.

6.3 window function

Pandas:

df["avg_country"] = df.groupby("country")["amount"].transform("mean")
df["rank_in_country"] = df.groupby("country")["amount"].rank(method="dense")

Polars:

df = df.with_columns([
    pl.col("amount").mean().over("country").alias("avg_country"),
    pl.col("amount").rank(method="dense").over("country").alias("rank_in_country"),
])

여러 window를 한 plan에 묶으면 Polars는 같은 partition을 한 번만 계산한다.

6.4 비교 매트릭스

항목Pandas 2.2 + ArrowPolars 1.x
메모리 백엔드NumPy 기본 / Arrow 옵션Arrow 전용
문자열 성능Arrow 켜면 좋음처음부터 빠름
null handlingArrow dtype이면 정상표준 null bitmap
멀티코어numba/numexpr 호출 시 부분적기본 병렬, GIL 영향 없음
lazy plan없음LazyFrame
query optimizer없음predicate/projection/CSE 등
streaming없음streaming engine (out-of-core)
expression API없음 (체이닝 중심)1급 시민
시계열강력하고 성숙좋아지고 있으나 일부 기능 부족
시각화 통합seaborn/matplotlib 직결to_pandas() 필요한 경우 多
sklearn 호환입력으로 그대로to_pandas() 또는 to_numpy() 필요
학습 자료압도적으로 많음빠르게 늘고 있으나 적음

성능만 보면 Polars의 압승. 그러나 ecosystem 통합·자료·legacy를 더하면 Pandas가 여전히 안전.


7장 · TPC-H 벤치마크 — 실제 숫자

Polars 팀은 TPC-H 22개 query를 직접 다 돌려 결과를 공개한다(원본은 Polars 벤치마크 페이지 — 측정은 SF=10 / 32 core 기준 등 환경별로 다름). 2026년 시점에서 자주 인용되는 대략적 형태를 표로 정리한다(정확한 절대값은 환경/버전에 따라 변하므로, 결정의 근거로 쓸 때는 원문 표를 확인하라).

QueryPandas 2.xPolars 1.x (lazy)DuckDBDask
Q1 (단순 group-by)기준선약 1/10 시간대약 1/15약 1/3
Q3 (join + filter)기준선약 1/8약 1/10약 1/2
Q9 (multi-join, 복잡)기준선약 1/7약 1/8비슷~약간 빠름
Q21 (correlated subquery)자주 OOM잘 돔매우 빠름OOM 가능
메모리 다 안 들어가는 dataset불가streaming engine으로 가능가능분산으로 가능

핵심 관찰:

  • Polars와 DuckDB는 single-node에서 같은 리그다 — query optimizer + Arrow + multi-core. Pandas는 거기서 한참 뒤다.
  • 분산이 필요한 규모면 Dask/Spark/Ray로 간다. Polars는 단일 노드(혹은 cloud의 큰 VM) 최적화에 집중한다.
  • TPC-H 같은 SQL 스타일 분석에서는 DuckDB가 가장 자주 1등. dataframe API 그대로가 필요하면 Polars.

벤치마크는 "어떤 워크로드"인지가 모든 것을 결정한다. 위 표는 의사결정의 출발점이지 결론이 아니다.


8장 · Polars vs DuckDB — 같은 엔진의 두 얼굴

이 비교가 2026년에 가장 흥미롭다. 둘 다 Arrow-native, 둘 다 vectorized columnar engine, 둘 다 single-node에서 큰 데이터 학살. 그런데 인터페이스가 다르다.

  • DuckDB — embeddable SQL engine. duckdb.sql("SELECT ... FROM parquet_scan('sales.parquet') WHERE ..."). 무거운 분석 SQL, OLAP에 강하다. window function, complex join, subquery에서 최강.
  • Polars — dataframe API. expression 기반의 declarative dataframe.
# 같은 일, 두 인터페이스
# DuckDB
import duckdb
result = duckdb.sql("""
    SELECT user_id, SUM(amount) AS total
    FROM 'sales.parquet'
    WHERE country = 'KR'
    GROUP BY user_id
""").to_df()

# Polars
import polars as pl
result = (
    pl.scan_parquet("sales.parquet")
      .filter(pl.col("country") == "KR")
      .group_by("user_id")
      .agg(pl.col("amount").sum().alias("total"))
      .collect()
)

언제 DuckDB가 이기는가

  • 복잡한 multi-table join, CTE 폭주, correlated subquery — SQL이 더 자연스럽다.
  • 분석가가 SQL을 이미 쓰고 있다 — 인지 비용이 0.
  • BI 도구·Jupyter Magic·dbt 통합 — 거의 모든 BI 도구가 DuckDB를 받는다.

언제 Polars가 이기는가

  • column 단위 변환 파이프라인이 길다 — expression이 SQL CTE보다 깔끔하다.
  • 기존 코드가 Pandas다 — 마이그레이션 표면이 작다.
  • ML feature engineering처럼 program 안에서 dataframe을 손으로 잡고 싶다.
  • 사용자 정의 Python 함수가 끼어 있다 (map_elements, pipe).

둘 다 쓰는 패턴

# Polars로 받고, 무거운 SQL은 DuckDB에 넘기고, 결과를 다시 Polars로
import duckdb, polars as pl

lf = pl.scan_parquet("sales.parquet").filter(pl.col("amount") > 100)
out = duckdb.sql("SELECT country, percentile_cont(0.95) WITHIN GROUP (ORDER BY amount) AS p95 FROM lf GROUP BY country").pl()

Arrow 위에서 둘은 zero-copy로 데이터를 주고받는다. 하나만 골라야 한다는 강박을 버려라.


9장 · Dask와 Ibis — 보조 출연자들의 자리

Dask — out-of-core·분산이 필요할 때

Dask는 Pandas API를 가진 분산 dataframe이다. partition 단위로 Pandas DataFrame을 띄우고, lazy graph로 묶어 cluster에서 실행한다.

import dask.dataframe as dd
df = dd.read_parquet("s3://bucket/sales-*.parquet")
result = df[df.country == "KR"].groupby("user_id").amount.sum().compute()

Dask는 "Pandas의 분산 버전"이라는 정체성을 유지한다. 2026년 현재 Dask 2024.x 이상은 query optimizer를 점진적으로 도입(Coiled가 주도)했고, Pandas 2.x의 PyArrow dtype과 잘 어울린다. 그러나 단일 노드 성능에서는 Polars/DuckDB에 진다 — 그게 목표가 아니니까. 수십 TB, S3에 흩어진 데이터가 진짜 사용 사례다.

Ibis — backend-agnostic dataframe spec

Ibis는 dataframe 표현식을 쓰고, 그걸 DuckDB·BigQuery·Snowflake·Polars·Pandas·Spark 등 20여 개 backend에 컴파일하는 라이브러리다.

import ibis
t = ibis.read_parquet("sales.parquet")  # 기본 backend는 DuckDB
expr = t.filter(t.country == "KR").group_by("user_id").agg(t.amount.sum().name("total"))
expr.execute()  # Pandas로 반환

같은 query를 DuckDB에서 돌리든, BigQuery로 보내든, Snowflake로 보내든 — 코드를 안 바꾼다. data warehouse에 무거운 일을 던지고, 로컬에서 같은 코드로 테스트한다.

2026년의 Ibis 위치:

  • "dataframe interface 표준" 후보. PyData ecosystem이 이쪽으로 결집하는 분위기.
  • analytics team이 cloud warehouse를 쓰면 강력한 선택.
  • 다만 backend별 quirks가 새는 경우가 있다 — 식 자체가 모든 backend에서 똑같이 도는 건 아니다.

10장 · 마이그레이션 함정 — Pandas → Polars의 진짜 비용

성능만 보면 Polars로 가는 게 자명해 보인다. 그런데 실제 마이그레이션에서 사람들이 부딪히는 함정들이 있다.

10.1 index가 없다

Pandas의 모든 것이 index 중심이라면 Polars는 index가 없다. .set_index(), .reset_index(), .loc[], MultiIndex 같은 게 다 사라진다. join은 명시적 key column, 정렬은 명시적 sort. 처음엔 답답하지만, 결과적으로 버그가 줄어든다 — 암묵적 index alignment로 인한 silent data corruption이 사라진다.

10.2 inplace가 없다

# Pandas
df["x"] = df["a"] + df["b"]            # 동작
df.rename(columns={"a": "A"}, inplace=True)

# Polars
df = df.with_columns((pl.col("a") + pl.col("b")).alias("x"))
df = df.rename({"a": "A"})

모든 연산이 새 DataFrame을 돌려준다. immutable 사고로 강제 전환된다.

10.3 apply의 죽음(같은 의미)

# Pandas — apply가 너무 흔해서 boilerplate처럼 보임
df["y"] = df.apply(lambda r: complicated_func(r["a"], r["b"]), axis=1)

Polars에서 map_elements명시적으로 느린 길이다. 가능하면 expression을 합쳐라. 정 안 되면 map_batches로 batch 단위 vectorized 함수를 써라.

10.4 group-by의 결과 형태

Pandas group-by의 결과가 Series냐 DataFrame이냐 헷갈리는 모든 경우가 Polars에서는 같은 형태(DataFrame)로 통일된다. 그러나 column 이름 규칙이 달라서 KeyError가 나는 경우가 있다 — Polars는 명시적으로 .alias()를 쓰게 한다.

10.5 datetime이 다르다

Pandas의 datetime64[ns]는 nanosecond 고정. Polars(Arrow)는 Datetime("us", "UTC") 같은 형식. tz 비교가 엄격해진다. mixed-tz column을 그냥 두면 Polars는 화를 낸다. 이게 처음엔 짜증, 나중엔 축복이다.

10.6 csv 추론의 차이

read_csv에서 dtype 추론이 다르다. Pandas는 column 일부를 보고 추측, Polars는 더 보수적으로 본다. dtype을 명시하는 습관을 들여야 한다.

10.7 method 이름이 미묘하게 다름

reset_index → 그냥 없다. concat(axis=1)pl.concat([..], how="horizontal"). pd.meltpl.DataFrame.unpivot. mergejoin. rename → 시그니처가 다르다. 코드 검색·치환만으로는 안 되고 사람이 읽어야 한다.

10.8 점진적 마이그레이션 전략

# 데이터를 받을 때부터 Polars로
df = pl.read_parquet("a.parquet")
# 무거운 변환은 Polars
df = df.filter(...).group_by(...).agg(...)
# 마지막에 sklearn/seaborn 단계에서만 pandas로
pdf = df.to_pandas()

이 패턴이 가장 현실적이다. 모든 코드를 한 번에 갈아엎지 마라. 새 파이프라인부터 Polars로, 기존은 그대로 둔다.


11장 · 그래도 Pandas를 떠나면 안 되는 경우

이 글의 가장 정직한 장이다. 2026년에도 Pandas를 쓰는 게 옳은 경우가 분명히 있다.

11.1 sklearn 입력이 끝점인 경우

sklearn은 pandas DataFrame을 1급 시민으로 본다. ColumnTransformer, OneHotEncoder(sparse_output=False), pipeline의 모든 단계가 column 이름을 보존하며 흐른다. Polars로 가도 결국 .to_pandas() 직전에 도는 거면, 굳이 갈 이유가 적다.

11.2 statsmodels·causal-inference 라이브러리

대부분의 통계 라이브러리(statsmodels, lifelines, dowhy, econml)가 Pandas를 입력 가정으로 한다. 식 표기(y ~ x1 + x2)가 column 이름에 묶여 있다.

11.3 시각화 통합

seaborn, plotnine, 많은 plotly 예제가 Pandas를 받는다. Polars도 __dataframe__ protocol을 지원하면서 직접 받는 라이브러리가 늘고 있지만, 가장 매끄러운 길은 여전히 Pandas다.

11.4 작은 데이터 + 빠른 prototyping

100만 row 미만이면 성능 차이는 수십~수백 ms — 의미 없다. 자료가 많고 손에 익은 라이브러리가 이긴다.

11.5 팀이 Pandas만 알고 있고, 마이그레이션 비용 > 이득

성능 병목이 dataframe이 아니라 다른 곳이면(예: 네트워크 I/O, 모델 추론), Polars로 가도 전체 latency가 별로 안 줄어든다. 이런 곳에서 마이그레이션은 비용일 뿐이다.

11.6 학교·교육

15년 치 강의자료, stackoverflow, 책이 Pandas 기준이다. 학습 자료의 양이 곧 학습 비용을 결정한다.

Polars는 새 파이프라인의 기본값이 되어가지만, Pandas는 글루(glue) 언어로 살아남는다. 둘 다 알아두는 게 합리적이다.


12장 · 2026년 dataframe stack의 결론

같은 표를 다른 각도로 한 번 더 정리한다 — "이 워크로드면 무엇을 쓰는가" 관점.

워크로드1순위2순위
메모리에 들어가는 분석 dataframe, Python 안에서PolarsDuckDB
무거운 SQL 분석, BI/Jupyter에서DuckDBPolars
TB 단위 분산, S3에 흩어진 데이터Dask / SparkRay Data
Snowflake/BigQuery로 push downIbis(각 warehouse SDK)
ML feature engineeringPolarsPandas (+Arrow)
sklearn 모델 학습 입력PandasPolars → to_pandas()
시계열 분석 (statsmodels)Pandas(간단한 건 Polars)
작은 데이터·빠른 prototypingPandasPolars
표준화된 backend-agnostic 식Ibis(직접 wrapper)

에필로그 — 균형 잡힌 한 줄

이 글의 한 문장 요약:

Polars는 새 워크로드를 거의 다 가져가고 있다. Pandas는 sklearn·시각화·교육이라는 ecosystem 늪에서 살아남는다. 2026년의 정답은 둘 다 알고, 워크로드에 맞춰 고르는 것이다.

데이터 엔지니어로서의 결정 트리:

  1. 새 파이프라인이고, 단일 노드 분석이라면 → Polars 기본값.
  2. SQL이 더 자연스럽고 분석가가 같이 본다면 → DuckDB.
  3. 데이터가 한 노드에 안 들어간다면 → Dask/Spark/Ray.
  4. cloud warehouse가 진짜 저장소라면 → Ibis + warehouse.
  5. ML 끝점이 sklearn이라면 → 변환은 Polars, 마지막에 to_pandas().
  6. 시각화·통계 마지막 단계 → Pandas로 받기.

12개 항목 체크리스트

  1. dataframe 백엔드가 Arrow인가(Pandas면 dtype_backend="pyarrow")?
  2. I/O가 Parquet인가? (CSV에 묶여 있으면 어떤 라이브러리든 손해)
  3. lazy plan을 쓰는가? (Polars의 가장 큰 이점)
  4. filter/select를 plan 위쪽에 두는가? (predicate/projection pushdown)
  5. group-by 안의 식을 expression list로 모았는가?
  6. 같은 식이 두 번 나오는지 확인했는가? (CSE의 대상)
  7. join key의 dtype이 양쪽에서 일치하는가?
  8. timestamp의 tz가 명시되어 있는가?
  9. map_elements를 남발하지 않는가?
  10. streaming engine이 필요한 데이터 규모인지 측정했는가?
  11. 결과를 시각화/ML에 넘기기 전 to_pandas() 한 번에 모았는가?
  12. 벤치마크를 본인 환경/데이터로 다시 돌렸는가?

안티패턴 10가지

  1. Polars로 받자마자 to_pandas()로 변환해서 다시 Pandas 코드 — 이득이 0.
  2. lazy plan을 안 만들고 모든 단계를 collect() — 최적화 기회 다 버림.
  3. map_elements를 vectorized expression으로 바꿀 수 있는데 그대로 둠.
  4. Parquet 대신 CSV로 모든 것을 저장 — Arrow의 이점 절반 손실.
  5. Pandas inplace=True 마인드를 Polars에 그대로 — 안 통한다.
  6. tz를 무시한 datetime 비교 — Polars가 에러를 던지면 그제야 깨달음.
  7. 분산이 필요 없는 데이터에 Dask/Spark을 미리 도입 — 복잡도 폭증, 성능 손해.
  8. SQL이 더 자연스러운 분석을 dataframe API로 억지로 짜기.
  9. 벤치마크 표를 자기 데이터/환경으로 재현 안 하고 그대로 인용 — 결정의 기반이 약함.
  10. "Pandas는 곧 사라진다"는 가정으로 갑작스러운 전면 마이그레이션 — 비용만 큼.

다음 글 예고

다음 글 후보: DuckDB 심층 — embedded analytics engine의 모든 것, Apache Arrow 데이터 표준 — Flight·DataFusion·ADBC까지, PySpark 4.x와 Polars/Ray Data — 분산 dataframe의 2026년 지도, Pandas → Polars 실전 마이그레이션 케이스 스터디.

"엔진은 같다 — Arrow다. dataframe API가 어떻게 그 위에 앉느냐만 다르다. 2026년의 데이터 엔지니어는 한 라이브러리의 신자가 아니라, 그 위의 stack을 읽는 사람이다."

— Polars 1.x vs Pandas, 끝.


참고 / References

Polars 1.x vs Pandas — End of an Era? A Modern DataFrame Deep Dive for 2026

Prologue — What "The End of the Pandas Era" Actually Means

In 2025 and 2026, the most-quoted sentence in the dataframe community was this:

"I switched to Polars and the same code got 10x faster."

And right next to it, always:

"But to push it into sklearn I ended up calling to_pandas() anyway."

Those two sentences summarize the 2026 dataframe world precisely. Polars is taking almost all of the new workloads. And yet Pandas is not going away. Fifteen years of ecosystem inertia — sklearn input, statsmodels, plotting libraries, hundreds of thousands of Stack Overflow answers, every classroom — make Pandas the safe default.

This post tries to locate the balance point. The Polars 1.x stability promise, the Rust plus Arrow architecture, what the query optimizer actually does, how far Pandas 2.2 plus PyArrow caught up and where it stopped, how DuckDB — the other Arrow-native engine — beats Polars on some workloads and loses on others, where Dask and Ibis sit, the real cost of migrating. And at the very end — when you should still stay on Pandas.


1. The Foundation Is Arrow — The Real Hero of the DataFrame Revolution

The most common omission in Polars vs Pandas comparisons: both libraries sit on top of Apache Arrow. The revolution is not Polars. It is Arrow.

Why Arrow Matters

Arrow is a columnar in-memory format. NumPy is columnar too, but the differences are huge:

  • Language-neutral memory layout — Python, Rust, C++, Java, Go all share the same memory zero-copy.
  • Column-major — values of the same column live contiguously in memory. Ideal for SIMD.
  • Built-in type system — strings, lists, structs, dictionaries (categories), date, time, decimal all standardized.
  • Separate null bitmap — no more abusing NaN as null the way NumPy and Pandas 1.x had to.
  • Chunked arrays — split big columns into chunks, the substrate for lazy and streaming execution.

The one-line takeaway:

On top of Arrow, Polars, Pandas, DuckDB and Spark see the same memory. Conversion cost collapses toward zero.

df.to_arrow() then DuckDB reads it directly. DuckDB writes Arrow then Polars reads it directly. Zero-copy. This is why the dataframe world of the 2020s is converging.

Arrow vs NumPy Backend in Practice

AspectNumPy backend (Pandas 1.x)Arrow backend (Polars / Pandas 2.2+)
Stringsobject dtype, list of Python objectsstring dtype, contiguous memory, 5–10x faster
Nullforced NaN, int column silently promoted to floatproper null bitmap, dtype preserved
Categoricalcategory dtype, custom implstandard dictionary type
Timestampslocked to datetime64[ns]rich timestamp[us, tz] and friends
Zero-copy sharinghardimmediate with DuckDB, Polars, Spark
SIMD utilizationlimitednatural fit for columnar

That table is most of the reason Polars beats Pandas. Polars started here; Pandas is catching up via an opt-in backend in 2.x.


2. Polars 1.x — A Rust Core and a Stability Promise

Polars is a Rust dataframe library started by Ritchie Vink in 2020. 1.0 shipped in August 2024, and since then minors have been moving fast while the project promises public API stability. As of 2026 we are deep in the 1.x line (see the release notes at Polars GitHub Releases); breaking changes arrive as opt-in new APIs, and old APIs go through a deprecation cycle.

A Rust Core With Python Bindings

  • Core is Rust — the polars crate. Memory safety, SIMD, no-GIL parallelism.
  • Python is a bindingpy-polars. Wraps the Rust core via PyO3.
  • R and NodeJS bindings sit on the same core.

What this means: Python's GIL does not slow Polars down. Group-by genuinely uses every core. NumPy parallelizes via BLAS, but the dataframe operations themselves (group-by, join) are stuck behind the GIL in Pandas.

The Polars Data Model

import polars as pl

df = pl.DataFrame({
    "user_id": [1, 2, 3, 4],
    "country": ["KR", "US", "KR", "JP"],
    "amount": [120.0, 95.0, 200.0, 75.0],
})
print(df.schema)
# Schema({'user_id': Int64, 'country': String, 'amount': Float64})

The schema is an ordered map from column name to Arrow dtype. Every dtype is explicit; there is very little inference magic.

Eager vs Lazy

Polars has two modes:

  • Eagerpl.DataFrame. Executes immediately, just like Pandas.
  • Lazypl.LazyFrame. Builds a query plan and only executes at .collect(), after optimization.
# eager
df = pl.read_parquet("sales.parquet")
big_kr = df.filter(pl.col("country") == "KR").select(["user_id", "amount"])

# lazy — build the plan, optimize and execute on .collect()
plan = (
    pl.scan_parquet("sales.parquet")          # I/O is deferred
      .filter(pl.col("country") == "KR")
      .select(["user_id", "amount"])
)
big_kr = plan.collect()

See the difference? scan_parquet does not read the file yet. The next chapter shows why that matters so much.


3. Lazy Evaluation and the Query Optimizer — Why Polars Feels Magical

Half of Polars' performance comes from the optimizer that rewrites the lazy plan. What does it do?

3.1 Predicate Pushdown — Push the Filter Into I/O

plan = (
    pl.scan_parquet("sales.parquet")          # 100 million rows
      .filter(pl.col("country") == "KR")       # 1 million KR rows
      .select(["user_id", "amount"])
)

The naive execution: read all 100M rows, then filter in memory. A hundred times too expensive.

What the optimizer does: push the filter into the scan. It reads Parquet row-group statistics (min and max) and skips row groups where country != "KR". Disk I/O drops by 100x.

3.2 Projection Pushdown — Read Only the Columns You Need

Even though select(["user_id", "amount"]) is at the end, Parquet is columnar, so only those two columns are read off disk. Pandas reads everything at pd.read_parquet() time and selecting later is too late. (Pandas does accept a columns= parameter, but it has no plan-level inference.)

3.3 Common Subexpression Elimination

plan = (
    df.lazy()
      .with_columns([
          (pl.col("price") * pl.col("qty")).alias("revenue"),
          (pl.col("price") * pl.col("qty") * 0.1).alias("commission"),
      ])
)

price * qty appears twice. The optimizer computes it once and reuses it.

3.4 Slice Pushdown, Type Coercion, Dead-Column Removal

If .head(100) sits at the bottom of a plan, only enough rows are pulled up to satisfy those 100. Columns unused after a join are dropped before the join. Implicit casts get reduced.

3.5 The Streaming Engine

collect(streaming=True) — and in 2026 the new streaming engine is approaching stable status (see the Polars blog for the streaming write-ups) — processes datasets that do not fit in memory in chunks. Out-of-core inside a single library.

Visualize the Plan

plan.explain()       # human-readable plan
plan.show_graph()    # graphviz; compare optimized vs naive

This is Polars' brain. Pandas has nothing like it. Pandas takes orders and executes them verbatim.


4. The Polars Expression API — One Series, One Expression

The biggest mental model shift in Polars is the expression.

import polars as pl

df.select([
    pl.col("amount").sum().alias("total"),
    pl.col("amount").mean().over("country").alias("country_avg"),
    (pl.col("amount") - pl.col("amount").mean()).alias("diff_from_mean"),
])

Each expression is a DAG that transforms columns. Where Pandas column manipulation is imperative, Polars expressions are declarative — closer to Spark DataFrame or SQL.

Mental model differences vs Pandas:

OperationPandasPolars
Create a new columndf["x"] = df["a"] + df["b"] (assign)df.with_columns((pl.col("a") + pl.col("b")).alias("x")) (expression)
Group-by aggregationdf.groupby("k")["x"].sum() (chained)df.group_by("k").agg(pl.col("x").sum()) (expression list)
Windowdf.groupby("k")["x"].transform("mean")pl.col("x").mean().over("k")
Multi-column transformfor loop or applyone expression list, all at once

It feels awkward for a few days, then it clicks. And the key point is that an expression is a node in the optimizer's plan. Because expressions are declarative, Polars can optimize them.


5. Pandas 2.2 — How Far Did It Catch Up With the PyArrow Backend?

Pandas introduced the PyArrow backend in 2.0 (2023) and stabilized it in 2.2 (2024). As of 2026, Pandas 2.2.x is the de-facto standard while 3.0 has been in a long beta.

Turning On the Arrow Backend

import pandas as pd

# Option A — explicit Arrow dtypes
df = pd.read_parquet("sales.parquet", dtype_backend="pyarrow")

# Option B — set a Series dtype to Arrow
s = pd.Series([1, 2, None], dtype="int64[pyarrow]")

What you gain:

  • String columns become genuinely fast and use much less memory.
  • An int column with nulls no longer silently promotes to float (Int64[pyarrow] is nullable).
  • Date and time stay as Arrow timestamps.
  • Zero-copy interop with DuckDB, Polars, Spark.

What Pandas 2.2 Still Cannot Do

  • No lazy evaluation. Everything is eager. Predicate pushdown only via read_parquet(filters=).
  • No query optimizer. Pandas runs operations in the order you wrote them.
  • Single-threaded inside the GIL by default. You opt into numba, numexpr, or pyarrow.compute case by case.
  • No expression API. Composing multi-column expressions inside a group-by stays awkward.
  • Huge API surface. Pandas is a sprawling dictionary; a lot of implicit magic happens.

To summarize: the data structures moved to Arrow, but the execution engine is still 1990s imperative. That gap is why Polars wins even on the same Arrow foundation.


6. Polars vs Pandas — The Full Comparison Matrix

The same task, side by side in both libraries.

6.1 Read Parquet, Filter, Group-By

Pandas:

import pandas as pd

df = pd.read_parquet("sales.parquet", dtype_backend="pyarrow")
kr = df[df["country"] == "KR"]
result = (
    kr.groupby("user_id", as_index=False)["amount"]
      .agg(["sum", "mean", "count"])
)

Polars (lazy):

import polars as pl

result = (
    pl.scan_parquet("sales.parquet")
      .filter(pl.col("country") == "KR")
      .group_by("user_id")
      .agg([
          pl.col("amount").sum().alias("total"),
          pl.col("amount").mean().alias("avg"),
          pl.col("amount").count().alias("n"),
      ])
      .collect()
)

The line counts look similar, but the plan-level work is not. Polars pushes the filter into the scan and only reads three columns off disk.

6.2 Join

Pandas:

merged = pd.merge(orders, users, on="user_id", how="left")

Polars:

merged = orders.lazy().join(users.lazy(), on="user_id", how="left").collect()

A Polars join is parallel hash-join by default. The gap widens fast as the data grows.

6.3 Window Functions

Pandas:

df["avg_country"] = df.groupby("country")["amount"].transform("mean")
df["rank_in_country"] = df.groupby("country")["amount"].rank(method="dense")

Polars:

df = df.with_columns([
    pl.col("amount").mean().over("country").alias("avg_country"),
    pl.col("amount").rank(method="dense").over("country").alias("rank_in_country"),
])

If you group multiple windows into a single plan, Polars computes each partition only once.

6.4 The Comparison Matrix

AspectPandas 2.2 + ArrowPolars 1.x
Memory backendNumPy default, Arrow optionalArrow only
String performancegood with Arrow onfast from day one
Null handlingOK with Arrow dtypesstandard null bitmap
Multi-corepartial via numba and numexprparallel by default, GIL-free
Lazy plannoneLazyFrame
Query optimizernonepredicate, projection, CSE and more
Streamingnonestreaming engine (out-of-core)
Expression APInone (chaining only)first-class
Time seriesmature and powerfulimproving; some gaps remain
Plotting integrationseaborn and matplotlib directlyoften needs to_pandas()
sklearn inputdirectneeds to_pandas() or to_numpy()
Learning materialoverwhelminggrowing but smaller

On raw performance Polars wins decisively. Add ecosystem integration, learning material and legacy and Pandas still has the safety edge.


7. TPC-H Benchmarks — The Real Numbers

The Polars team publishes its own runs of all 22 TPC-H queries (see the Polars benchmark page; concrete numbers depend on SF, hardware and version, so this table is shape-only and meant to anchor decisions, not replace the originals).

QueryPandas 2.xPolars 1.x (lazy)DuckDBDask
Q1 (simple group-by)baselineroughly 1/10 the timeroughly 1/15roughly 1/3
Q3 (join + filter)baselineroughly 1/8roughly 1/10roughly 1/2
Q9 (multi-join, complex)baselineroughly 1/7roughly 1/8similar or slightly faster
Q21 (correlated subquery)often OOMsruns finevery fastcan OOM
Larger-than-memory datasetimpossiblestreaming engine handles ithandles itdistributed handles it

Key observations:

  • Polars and DuckDB are in the same league on a single node — query optimizer plus Arrow plus multi-core. Pandas trails far behind.
  • If you need distribution, you reach for Dask, Spark or Ray. Polars optimizes for single-node (or one large cloud VM).
  • For SQL-style analytics like TPC-H, DuckDB tops the chart most often. For a dataframe API, Polars wins.

Benchmarks live and die on "which workload". The table is a starting point, not the verdict.


8. Polars vs DuckDB — Two Faces of the Same Engine

This is the most interesting comparison of 2026. Both are Arrow-native, both are vectorized columnar engines, both massacre large data on a single node. The interface is what differs.

  • DuckDB — an embeddable SQL engine. duckdb.sql("SELECT ... FROM parquet_scan('sales.parquet') WHERE ..."). Heavy analytical SQL and OLAP. Windowing, complex joins and subqueries are unmatched.
  • Polars — a dataframe API. Declarative dataframe with expressions.
# Same task, two interfaces
# DuckDB
import duckdb
result = duckdb.sql("""
    SELECT user_id, SUM(amount) AS total
    FROM 'sales.parquet'
    WHERE country = 'KR'
    GROUP BY user_id
""").to_df()

# Polars
import polars as pl
result = (
    pl.scan_parquet("sales.parquet")
      .filter(pl.col("country") == "KR")
      .group_by("user_id")
      .agg(pl.col("amount").sum().alias("total"))
      .collect()
)

Where DuckDB Wins

  • Complex multi-table joins, deep CTEs, correlated subqueries — SQL reads more naturally.
  • Analysts already write SQL — zero cognitive cost.
  • BI tooling, Jupyter magic, dbt integration — nearly every BI tool can read DuckDB.

Where Polars Wins

  • Long pipelines of column-level transforms — expressions are cleaner than nested CTEs.
  • The codebase is already in Pandas — the migration surface is smaller.
  • ML feature engineering, where you want to hold the dataframe imperatively in Python.
  • User-defined Python functions sneak in (map_elements, pipe).

The Pattern of Using Both

# Receive in Polars, hand heavy SQL off to DuckDB, take the result back as Polars
import duckdb, polars as pl

lf = pl.scan_parquet("sales.parquet").filter(pl.col("amount") > 100)
out = duckdb.sql("SELECT country, percentile_cont(0.95) WITHIN GROUP (ORDER BY amount) AS p95 FROM lf GROUP BY country").pl()

On Arrow, the two exchange data zero-copy. Drop the "pick exactly one" obsession.


9. Dask and Ibis — The Supporting Cast

Dask — When You Need Out-Of-Core or Distribution

Dask is a distributed dataframe with a Pandas-like API. It launches Pandas DataFrames per partition and binds them into a lazy graph, then executes across a cluster.

import dask.dataframe as dd
df = dd.read_parquet("s3://bucket/sales-*.parquet")
result = df[df.country == "KR"].groupby("user_id").amount.sum().compute()

Dask keeps its identity: "Pandas, but distributed". By 2026 the 2024.x line and beyond ships a gradually-rolled-out query optimizer (Coiled has led the work) and Pandas 2.x PyArrow dtypes interop cleanly. On a single node it loses to Polars and DuckDB — by design. Tens of terabytes scattered across S3 is the real use case.

Ibis — A Backend-Agnostic DataFrame Spec

Ibis lets you write dataframe expressions and compile them to 20-plus backends: DuckDB, BigQuery, Snowflake, Polars, Pandas, Spark and more.

import ibis
t = ibis.read_parquet("sales.parquet")  # default backend is DuckDB
expr = t.filter(t.country == "KR").group_by("user_id").agg(t.amount.sum().name("total"))
expr.execute()  # returns Pandas

The same query runs against DuckDB, gets pushed to BigQuery, or hits Snowflake — without code changes. Push heavy work into the warehouse, test the same code locally.

Where Ibis sits in 2026:

  • A candidate for the dataframe interface standard. The PyData ecosystem is gradually coalescing here.
  • Strong choice for analytics teams that live in a cloud warehouse.
  • Backend quirks still leak through occasionally — the same expression does not run identically everywhere.

10. Migration Traps — The Real Cost of Pandas to Polars

On performance alone the move to Polars looks obvious. In practice, people hit a handful of traps.

10.1 No Index

Pandas centers on the index. Polars has no index. .set_index(), .reset_index(), .loc[], MultiIndex — all gone. Joins use explicit key columns and sorts are explicit. At first it feels annoying, but you end up with fewer bugs — no more silent corruption from implicit index alignment.

10.2 No inplace

# Pandas
df["x"] = df["a"] + df["b"]            # works
df.rename(columns={"a": "A"}, inplace=True)

# Polars
df = df.with_columns((pl.col("a") + pl.col("b")).alias("x"))
df = df.rename({"a": "A"})

Every operation returns a new DataFrame. You are forced into an immutable mindset.

10.3 The Death of apply (Kind Of)

# Pandas — apply is so common it feels like boilerplate
df["y"] = df.apply(lambda r: complicated_func(r["a"], r["b"]), axis=1)

In Polars, map_elements is the explicitly slow path. Combine expressions when you can. If you must, prefer map_batches for a vectorized batch function.

10.4 Group-By Result Shape

Whether a Pandas group-by returns a Series or a DataFrame depends on subtleties; Polars always returns a DataFrame. But the column-naming rules differ enough to cause KeyErrors — Polars makes you call .alias() explicitly.

10.5 Datetime Is Different

Pandas pins datetime64[ns]. Polars (via Arrow) uses Datetime("us", "UTC") and friends. Timezone comparisons get strict. Mixed-timezone columns will make Polars complain. Annoying at first, a blessing later.

10.6 CSV Type Inference Diverges

read_csv infers dtypes differently. Pandas peeks at a slice and guesses; Polars is more conservative. Get into the habit of declaring dtypes explicitly.

10.7 Method Names Are Subtly Different

reset_index — does not exist. concat(axis=1)pl.concat([..], how="horizontal"). pd.meltpl.DataFrame.unpivot. mergejoin. rename — different signature. Find-and-replace alone will not get you there; a human has to read the code.

10.8 An Incremental Migration Strategy

# Receive data in Polars from the start
df = pl.read_parquet("a.parquet")
# Heavy transforms in Polars
df = df.filter(...).group_by(...).agg(...)
# Only convert to pandas at the sklearn or seaborn boundary
pdf = df.to_pandas()

This is the realistic pattern. Do not rewrite everything in one go. New pipelines go to Polars; existing ones stay as they are.


11. When You Should Still Stay on Pandas

The most honest chapter of this post. In 2026 it is still right to stay on Pandas in several cases.

11.1 sklearn Is the Endpoint

sklearn treats pandas DataFrames as first-class citizens. ColumnTransformer, OneHotEncoder(sparse_output=False), every step of a pipeline preserves column names. If you are going to call .to_pandas() right before sklearn anyway, the case for moving thins out.

11.2 statsmodels and Causal-Inference Libraries

Most statistics libraries (statsmodels, lifelines, dowhy, econml) assume Pandas input. Formula notation (y ~ x1 + x2) is bound to column names.

11.3 Plotting Integration

seaborn, plotnine and many plotly examples want Pandas. Polars now supports the __dataframe__ protocol so more libraries can read it directly, but Pandas is still the smoothest path.

11.4 Small Data and Quick Prototyping

Below a million rows the difference is tens to hundreds of milliseconds — irrelevant. Familiarity wins.

11.5 The Team Only Knows Pandas, and Migration Cost Exceeds the Gain

If the bottleneck is somewhere else (network I/O, model inference), moving to Polars barely changes end-to-end latency. The migration is pure cost.

11.6 Education

Fifteen years of lectures, Stack Overflow and books are in Pandas. The mass of learning material drives the learning cost.

Polars is becoming the default for new pipelines. Pandas survives as a glue language. Knowing both is the rational position.


12. The 2026 DataFrame Stack — A Decision Map

The same table from a different angle — "which workload picks which tool".

WorkloadFirst choiceSecond choice
In-memory analytical dataframe, from PythonPolarsDuckDB
Heavy SQL analytics in BI or JupyterDuckDBPolars
TB scale, scattered across S3Dask or SparkRay Data
Push down to Snowflake or BigQueryIbis(warehouse SDK)
ML feature engineeringPolarsPandas (with Arrow)
sklearn model inputPandasPolars then to_pandas()
Time series (statsmodels)Pandas(simple cases in Polars)
Small data, quick prototypingPandasPolars
Standardized backend-agnostic expressionsIbis(custom wrapper)

Epilogue — The Balanced One-Liner

The one-sentence summary of this post:

Polars is winning almost all of the new workloads. Pandas survives in the swamp of sklearn, plotting and education. The 2026 answer is to know both and choose by workload.

A decision tree for the data engineer:

  1. New pipeline, single-node analytics → Polars by default.
  2. SQL is more natural and analysts share the work → DuckDB.
  3. The data does not fit on one node → Dask, Spark or Ray.
  4. The cloud warehouse is the real store → Ibis plus the warehouse.
  5. The ML endpoint is sklearn → transform in Polars, call to_pandas() last.
  6. Plotting or statistics is the final step → receive into Pandas.

12-Item Checklist

  1. Is the dataframe backend Arrow? (Pandas: dtype_backend="pyarrow".)
  2. Is your I/O Parquet? (Anchoring to CSV is a loss in any library.)
  3. Are you using a lazy plan? (Polars' biggest win.)
  4. Are filter and select near the top of the plan? (Predicate and projection pushdown.)
  5. Are expressions inside group-by collected into an expression list?
  6. Did you check whether the same subexpression appears twice? (CSE target.)
  7. Do the join-key dtypes match on both sides?
  8. Are timestamp timezones explicit?
  9. Are you abusing map_elements?
  10. Did you measure whether you need the streaming engine?
  11. Did you batch the final to_pandas() once, right before plotting or ML?
  12. Did you re-run the benchmarks on your own environment and data?

10 Anti-Patterns

  1. Loading into Polars and immediately calling to_pandas() to keep writing Pandas code — no gain.
  2. Skipping the lazy plan and collect()ing at every step — the optimizer cannot help.
  3. Leaving map_elements in place where a vectorized expression would do.
  4. Storing everything in CSV instead of Parquet — half of the Arrow benefit gone.
  5. Bringing inplace=True mindset into Polars — it does not exist.
  6. Comparing datetimes without timezones — Polars yells first; that is when you learn.
  7. Introducing Dask or Spark before you actually need distribution — complexity spikes, performance drops.
  8. Forcing SQL-shaped analytics into a dataframe API.
  9. Quoting benchmark tables without re-running on your own data — weak foundation.
  10. Mass-migrating off Pandas on the assumption it will "disappear soon" — pure cost.

Next Up

Candidates for the next post: DuckDB deep dive — everything about the embedded analytical engine, Apache Arrow — Flight, DataFusion, ADBC, PySpark 4.x with Polars and Ray Data — the 2026 distributed-dataframe map, A real Pandas to Polars migration case study.

"The engine is the same — it is Arrow. Only the dataframe API sitting on top differs. In 2026 the data engineer is not a believer in one library; they are someone who reads the whole stack."

— Polars 1.x vs Pandas, end.


References