프롤로그 — "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](https://github.com/pola-rs/polars/releases))이며, 깨지는 변경은 옵트인 새 API로 들어오고 기존 API는 deprecation 사이클을 거친다.
Rust 코어 + Python binding
- **코어는 Rust** — `polars` crate. 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의 데이터 모델
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 발표](https://pola.rs/posts/) 참조)는 메모리에 다 안 들어가는 데이터셋도 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이다.
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](https://pandas.pydata.org/docs/whatsnew/v2.2.0.html)는 사실상 표준이며 3.0이 길게 베타 중이다.
Arrow backend를 켜는 법
방법 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:
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):
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 벤치마크 페이지](https://pola.rs/posts/benchmarks/) — 측정은 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
result = duckdb.sql("""
SELECT user_id, SUM(amount) AS total
FROM 'sales.parquet'
WHERE country = 'KR'
GROUP BY user_id
""").to_df()
Polars
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로
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에서 실행한다.
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에 컴파일하는 라이브러리다.
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년의 정답은 둘 다 알고, 워크로드에 맞춰 고르는 것이다.**
데이터 엔지니어로서의 결정 트리:
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 공식 사이트](https://pola.rs/)
- [Polars GitHub 리포지토리](https://github.com/pola-rs/polars)
- [Polars GitHub Releases (1.x 노트)](https://github.com/pola-rs/polars/releases)
- [Polars User Guide](https://docs.pola.rs/)
- [Polars Python API 레퍼런스](https://docs.pola.rs/api/python/stable/reference/index.html)
- [Polars 벤치마크 페이지](https://pola.rs/posts/benchmarks/)
- [Polars 블로그 (streaming 등 발표)](https://pola.rs/posts/)
- [Pandas 공식 문서](https://pandas.pydata.org/docs/)
- [Pandas 2.2 What's New](https://pandas.pydata.org/docs/whatsnew/v2.2.0.html)
- [Pandas PyArrow backend 가이드](https://pandas.pydata.org/docs/user_guide/pyarrow.html)
- [Apache Arrow 공식](https://arrow.apache.org/)
- [Apache Arrow Columnar Format 명세](https://arrow.apache.org/docs/format/Columnar.html)
- [DuckDB 공식](https://duckdb.org/)
- [DuckDB Python API](https://duckdb.org/docs/api/python/overview)
- [DuckDB Polars 통합](https://duckdb.org/docs/guides/python/polars)
- [TPC-H 표준 명세](https://www.tpc.org/tpch/)
- [Dask 공식](https://www.dask.org/)
- [Dask DataFrame Query Optimizer 안내](https://www.coiled.io/blog/dask-dataframe-2024-q1)
- [Ibis 공식](https://ibis-project.org/)
- [Ibis 백엔드 목록](https://ibis-project.org/backends/)
- [PyO3 (Rust to Python)](https://pyo3.rs/)
- [Apache Spark 4.x What's New](https://spark.apache.org/releases/)
- [Modin 공식 (Pandas 호환 분산 옵션)](https://modin.readthedocs.io/)
- [Ray Data](https://docs.ray.io/en/latest/data/data.html)
- [scikit-learn 입력 타입 안내](https://scikit-learn.org/stable/modules/compose.html)
- [DataFrame Interchange Protocol (PEP)](https://data-apis.org/dataframe-protocol/latest/)
- [Awkward Array (참고로 Arrow와의 호환)](https://awkward-array.org/)
- [DataFusion (Arrow + Rust query engine)](https://datafusion.apache.org/)
현재 단락 (1/295)
2025~2026년에 dataframe 커뮤니티에서 가장 많이 들린 문장은 이거다.