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 객체 list | string dtype, 연속 메모리, 5–10배 빠름 |
| null | NaN 강제, 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
- 코어는 Rust —
polarscrate. memory safety, SIMD, no GIL 병렬. - Python은 binding —
py-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})
schema는 column 이름 -> Arrow dtype의 OrderedDict. 모든 dtype이 명시적이고 추론 마법이 적다.
Eager vs Lazy
Polars는 두 모드를 가진다.
- eager —
pl.DataFrame. Pandas처럼 즉시 실행. - lazy —
pl.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와의 사고 차이:
| 사고 | Pandas | Polars |
|---|---|---|
| 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) |
| window | df.groupby("k")["x"].transform("mean") | pl.col("x").mean().over("k") |
| 다중 column 동시 변환 | for loop or apply | expression 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 + Arrow | Polars 1.x |
|---|---|---|
| 메모리 백엔드 | NumPy 기본 / Arrow 옵션 | Arrow 전용 |
| 문자열 성능 | Arrow 켜면 좋음 | 처음부터 빠름 |
| null handling | Arrow 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년 시점에서 자주 인용되는 대략적 형태를 표로 정리한다(정확한 절대값은 환경/버전에 따라 변하므로, 결정의 근거로 쓸 때는 원문 표를 확인하라).
| Query | Pandas 2.x | Polars 1.x (lazy) | DuckDB | Dask |
|---|---|---|---|---|
| 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.melt → pl.DataFrame.unpivot. merge → join. 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 안에서 | Polars | DuckDB |
| 무거운 SQL 분석, BI/Jupyter에서 | DuckDB | Polars |
| TB 단위 분산, S3에 흩어진 데이터 | Dask / Spark | Ray Data |
| Snowflake/BigQuery로 push down | Ibis | (각 warehouse SDK) |
| ML feature engineering | Polars | Pandas (+Arrow) |
| sklearn 모델 학습 입력 | Pandas | Polars → to_pandas() |
| 시계열 분석 (statsmodels) | Pandas | (간단한 건 Polars) |
| 작은 데이터·빠른 prototyping | Pandas | Polars |
| 표준화된 backend-agnostic 식 | Ibis | (직접 wrapper) |
에필로그 — 균형 잡힌 한 줄
이 글의 한 문장 요약:
Polars는 새 워크로드를 거의 다 가져가고 있다. Pandas는 sklearn·시각화·교육이라는 ecosystem 늪에서 살아남는다. 2026년의 정답은 둘 다 알고, 워크로드에 맞춰 고르는 것이다.
데이터 엔지니어로서의 결정 트리:
- 새 파이프라인이고, 단일 노드 분석이라면 → Polars 기본값.
- SQL이 더 자연스럽고 분석가가 같이 본다면 → DuckDB.
- 데이터가 한 노드에 안 들어간다면 → Dask/Spark/Ray.
- cloud warehouse가 진짜 저장소라면 → Ibis + warehouse.
- ML 끝점이 sklearn이라면 → 변환은 Polars, 마지막에
to_pandas(). - 시각화·통계 마지막 단계 → Pandas로 받기.
12개 항목 체크리스트
- dataframe 백엔드가 Arrow인가(Pandas면
dtype_backend="pyarrow")? - I/O가 Parquet인가? (CSV에 묶여 있으면 어떤 라이브러리든 손해)
- lazy plan을 쓰는가? (Polars의 가장 큰 이점)
- filter/select를 plan 위쪽에 두는가? (predicate/projection pushdown)
- group-by 안의 식을 expression list로 모았는가?
- 같은 식이 두 번 나오는지 확인했는가? (CSE의 대상)
- join key의 dtype이 양쪽에서 일치하는가?
- timestamp의 tz가 명시되어 있는가?
map_elements를 남발하지 않는가?- streaming engine이 필요한 데이터 규모인지 측정했는가?
- 결과를 시각화/ML에 넘기기 전
to_pandas()한 번에 모았는가? - 벤치마크를 본인 환경/데이터로 다시 돌렸는가?
안티패턴 10가지
- Polars로 받자마자
to_pandas()로 변환해서 다시 Pandas 코드 — 이득이 0. - lazy plan을 안 만들고 모든 단계를
collect()— 최적화 기회 다 버림. map_elements를 vectorized expression으로 바꿀 수 있는데 그대로 둠.- Parquet 대신 CSV로 모든 것을 저장 — Arrow의 이점 절반 손실.
- Pandas
inplace=True마인드를 Polars에 그대로 — 안 통한다. - tz를 무시한 datetime 비교 — Polars가 에러를 던지면 그제야 깨달음.
- 분산이 필요 없는 데이터에 Dask/Spark을 미리 도입 — 복잡도 폭증, 성능 손해.
- SQL이 더 자연스러운 분석을 dataframe API로 억지로 짜기.
- 벤치마크 표를 자기 데이터/환경으로 재현 안 하고 그대로 인용 — 결정의 기반이 약함.
- "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 공식 사이트
- Polars GitHub 리포지토리
- Polars GitHub Releases (1.x 노트)
- Polars User Guide
- Polars Python API 레퍼런스
- Polars 벤치마크 페이지
- Polars 블로그 (streaming 등 발표)
- Pandas 공식 문서
- Pandas 2.2 What's New
- Pandas PyArrow backend 가이드
- Apache Arrow 공식
- Apache Arrow Columnar Format 명세
- DuckDB 공식
- DuckDB Python API
- DuckDB Polars 통합
- TPC-H 표준 명세
- Dask 공식
- Dask DataFrame Query Optimizer 안내
- Ibis 공식
- Ibis 백엔드 목록
- PyO3 (Rust to Python)
- Apache Spark 4.x What's New
- Modin 공식 (Pandas 호환 분산 옵션)
- Ray Data
- scikit-learn 입력 타입 안내
- DataFrame Interchange Protocol (PEP)
- Awkward Array (참고로 Arrow와의 호환)
- DataFusion (Arrow + Rust query engine)
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
NaNas 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
| Aspect | NumPy backend (Pandas 1.x) | Arrow backend (Polars / Pandas 2.2+) |
|---|---|---|
| Strings | object dtype, list of Python objects | string dtype, contiguous memory, 5–10x faster |
| Null | forced NaN, int column silently promoted to float | proper null bitmap, dtype preserved |
| Categorical | category dtype, custom impl | standard dictionary type |
| Timestamps | locked to datetime64[ns] | rich timestamp[us, tz] and friends |
| Zero-copy sharing | hard | immediate with DuckDB, Polars, Spark |
| SIMD utilization | limited | natural 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
polarscrate. Memory safety, SIMD, no-GIL parallelism. - Python is a binding —
py-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:
- Eager —
pl.DataFrame. Executes immediately, just like Pandas. - Lazy —
pl.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:
| Operation | Pandas | Polars |
|---|---|---|
| Create a new column | df["x"] = df["a"] + df["b"] (assign) | df.with_columns((pl.col("a") + pl.col("b")).alias("x")) (expression) |
| Group-by aggregation | df.groupby("k")["x"].sum() (chained) | df.group_by("k").agg(pl.col("x").sum()) (expression list) |
| Window | df.groupby("k")["x"].transform("mean") | pl.col("x").mean().over("k") |
| Multi-column transform | for loop or apply | one 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, orpyarrow.computecase 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
| Aspect | Pandas 2.2 + Arrow | Polars 1.x |
|---|---|---|
| Memory backend | NumPy default, Arrow optional | Arrow only |
| String performance | good with Arrow on | fast from day one |
| Null handling | OK with Arrow dtypes | standard null bitmap |
| Multi-core | partial via numba and numexpr | parallel by default, GIL-free |
| Lazy plan | none | LazyFrame |
| Query optimizer | none | predicate, projection, CSE and more |
| Streaming | none | streaming engine (out-of-core) |
| Expression API | none (chaining only) | first-class |
| Time series | mature and powerful | improving; some gaps remain |
| Plotting integration | seaborn and matplotlib directly | often needs to_pandas() |
| sklearn input | direct | needs to_pandas() or to_numpy() |
| Learning material | overwhelming | growing 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).
| Query | Pandas 2.x | Polars 1.x (lazy) | DuckDB | Dask |
|---|---|---|---|---|
| Q1 (simple group-by) | baseline | roughly 1/10 the time | roughly 1/15 | roughly 1/3 |
| Q3 (join + filter) | baseline | roughly 1/8 | roughly 1/10 | roughly 1/2 |
| Q9 (multi-join, complex) | baseline | roughly 1/7 | roughly 1/8 | similar or slightly faster |
| Q21 (correlated subquery) | often OOMs | runs fine | very fast | can OOM |
| Larger-than-memory dataset | impossible | streaming engine handles it | handles it | distributed 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.melt — pl.DataFrame.unpivot. merge — join. 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".
| Workload | First choice | Second choice |
|---|---|---|
| In-memory analytical dataframe, from Python | Polars | DuckDB |
| Heavy SQL analytics in BI or Jupyter | DuckDB | Polars |
| TB scale, scattered across S3 | Dask or Spark | Ray Data |
| Push down to Snowflake or BigQuery | Ibis | (warehouse SDK) |
| ML feature engineering | Polars | Pandas (with Arrow) |
| sklearn model input | Pandas | Polars then to_pandas() |
| Time series (statsmodels) | Pandas | (simple cases in Polars) |
| Small data, quick prototyping | Pandas | Polars |
| Standardized backend-agnostic expressions | Ibis | (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:
- New pipeline, single-node analytics → Polars by default.
- SQL is more natural and analysts share the work → DuckDB.
- The data does not fit on one node → Dask, Spark or Ray.
- The cloud warehouse is the real store → Ibis plus the warehouse.
- The ML endpoint is sklearn → transform in Polars, call
to_pandas()last. - Plotting or statistics is the final step → receive into Pandas.
12-Item Checklist
- Is the dataframe backend Arrow? (Pandas:
dtype_backend="pyarrow".) - Is your I/O Parquet? (Anchoring to CSV is a loss in any library.)
- Are you using a lazy plan? (Polars' biggest win.)
- Are filter and select near the top of the plan? (Predicate and projection pushdown.)
- Are expressions inside group-by collected into an expression list?
- Did you check whether the same subexpression appears twice? (CSE target.)
- Do the join-key dtypes match on both sides?
- Are timestamp timezones explicit?
- Are you abusing
map_elements? - Did you measure whether you need the streaming engine?
- Did you batch the final
to_pandas()once, right before plotting or ML? - Did you re-run the benchmarks on your own environment and data?
10 Anti-Patterns
- Loading into Polars and immediately calling
to_pandas()to keep writing Pandas code — no gain. - Skipping the lazy plan and
collect()ing at every step — the optimizer cannot help. - Leaving
map_elementsin place where a vectorized expression would do. - Storing everything in CSV instead of Parquet — half of the Arrow benefit gone.
- Bringing
inplace=Truemindset into Polars — it does not exist. - Comparing datetimes without timezones — Polars yells first; that is when you learn.
- Introducing Dask or Spark before you actually need distribution — complexity spikes, performance drops.
- Forcing SQL-shaped analytics into a dataframe API.
- Quoting benchmark tables without re-running on your own data — weak foundation.
- 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
- Polars official site
- Polars GitHub repository
- Polars GitHub Releases (1.x notes)
- Polars User Guide
- Polars Python API reference
- Polars benchmarks page
- Polars blog (streaming and more)
- Pandas official docs
- Pandas 2.2 What's New
- Pandas PyArrow backend guide
- Apache Arrow official
- Apache Arrow Columnar Format spec
- DuckDB official
- DuckDB Python API
- DuckDB Polars integration
- TPC-H specification
- Dask official
- Dask DataFrame Query Optimizer announcement
- Ibis official
- Ibis backend list
- PyO3 (Rust to Python)
- Apache Spark 4.x releases
- Modin official (Pandas-compatible distributed)
- Ray Data
- scikit-learn input types
- DataFrame Interchange Protocol
- Awkward Array (Arrow interop reference)
- DataFusion (Arrow plus Rust query engine)