
판다스 코드를 Polars로 바꿔봤다는 이야기는 최근 데이터 파이프라인을 다루는 개발자들 사이에서 자주 나옵니다. 하지만 실제로 어떤 문법이 어떻게 대응되는지, 그리고 바꿨을 때 정말 체감할 만한 이득이 있는지는 막상 코드를 옮겨보기 전까지 알기 어렵습니다. 이 글은 pandas 2.x와 Polars 1.x(2024년 7월 정식 1.0 출시 이후 버전) 기준으로, 실무에서 자주 쓰는 문법을 1:1로 매핑한 변환표와 실행 예시, 그리고 마이그레이션이 손해가 되는 지점까지 함께 정리합니다.
판다스와 Polars, 실행 구조부터 다른 이유
pandas는 numpy 배열을 기반으로 동작하고, 연산을 호출하는 즉시 결과를 계산하는 즉시 실행(eager) 방식입니다. Polars는 Rust로 작성된 엔진 위에서 동작하며, 메모리 표현으로 컬럼 지향(columnar) 포맷인 Apache Arrow를 사용합니다. 이 구조 덕분에 문자열이나 범주형 데이터를 다룰 때 메모리 복사가 줄어들고, 여러 스레드로 연산을 병렬 분배하기가 pandas보다 쉽습니다.
더 중요한 차이는 실행 모드입니다. Polars는 즉시 계산하는 eager API(pl.DataFrame)와, 쿼리를 먼저 그래프로 구성한 뒤 .collect() 호출 시점에 최적화해서 실행하는 lazy API(pl.LazyFrame, pl.scan_csv 등)를 모두 제공합니다. lazy 모드에서는 불필요한 컬럼을 미리 걸러내는 projection pushdown, 조건절을 데이터 읽기 단계로 끌어올리는 predicate pushdown 같은 최적화가 자동으로 적용됩니다. pandas에는 이런 쿼리 최적화 단계 자체가 없습니다.
또 하나, Polars에는 pandas의 index 개념이 없습니다. reset_index()나 set_index()를 습관적으로 쓰던 코드는 Polars로 옮기면 아예 필요 없어지는 경우가 많습니다. 이 구조적 차이에 대한 공식 설명은 Polars 공식 문서의 판다스 마이그레이션 가이드에 정리되어 있습니다.
자주 쓰는 판다스 문법 → Polars 변환표
실무에서 반복적으로 쓰는 문법 위주로 정리했습니다. 괄호 안 비고는 단순 이름만 바뀐 게 아니라 동작 방식에 차이가 있는 항목입니다.
| pandas | Polars | 비고 |
|---|---|---|
df[df['a'] > 5] |
df.filter(pl.col('a') > 5) |
불리언 인덱싱 대신 filter 표현식 사용 |
df.groupby('a')['b'].sum() |
df.group_by('a').agg(pl.col('b').sum()) |
결과 행 순서가 기본적으로 보장되지 않음 |
df.sort_values('a') |
df.sort('a') |
|
df.rename(columns={'a':'b'}) |
df.rename({'a':'b'}) |
|
df.merge(other, on='key', how='left') |
df.join(other, on='key', how='left') |
how 값 이름은 동일 |
df.reset_index() / set_index() |
사용하지 않음 | Polars에는 인덱스 개념이 없음 |
df.apply(func, axis=1) |
pl.col(...).map_elements(func) |
가능하면 네이티브 expression으로 대체 권장 |
pd.read_csv('f.csv') |
pl.scan_csv('f.csv') |
scan은 lazy, 쿼리 최적화 적용됨 |
df.pivot_table(...) |
df.pivot(...) |
인자 이름이 다소 다름 |
df.isna().sum() |
df.null_count() |
NaN과 null을 별개로 취급 |
표에서 눈여겨볼 부분은 apply입니다. Polars는 컬럼 단위 벡터 연산(expression)을 쓰라고 강하게 권장하고, map_elements로 순수 파이썬 함수를 행마다 호출하면 파이썬 GIL 병목이 그대로 남아 pandas의 apply와 속도 차이가 거의 없습니다.

group_by·필터 결과를 실제 코드로 비교해보기
동일한 데이터로 두 라이브러리의 결과 형태 차이를 확인해보면 이해가 빠릅니다.
import pandas as pd
df = pd.DataFrame({
'city': ['서울', '서울', '부산', '부산'],
'sales': [100, 150, 80, 120]
})
result = df.groupby('city')['sales'].sum().reset_index()
print(result)
출력:
city sales
0 부산 200
1 서울 250
같은 로직을 Polars로 옮기면 다음과 같습니다.
import polars as pl
df = pl.DataFrame({
'city': ['서울', '서울', '부산', '부산'],
'sales': [100, 150, 80, 120]
})
result = df.group_by('city').agg(pl.col('sales').sum()).sort('city')
print(result)
출력:
shape: (2, 2)
┌──────┬───────┐
│ city ┆ sales │
│ --- ┆ --- │
│ str ┆ i64 │
╞══════╪═══════╡
│ 부산 ┆ 200 │
│ 서울 ┆ 250 │
└──────┴───────┘
여기서 .sort('city')를 붙인 이유가 핵심입니다. Polars의 group_by는 해시 기반으로 그룹을 나누기 때문에 기본적으로 결과 순서를 보장하지 않습니다. maintain_order=True 옵션을 주거나 이후에 .sort()를 붙여야 pandas처럼 예측 가능한 순서가 나옵니다. 이 차이를 모르고 옮기면, 그룹 순서에 의존하는 후속 로직(예: 상위 N개 슬라이싱)에서 조용히 잘못된 결과가 나올 수 있습니다.

Polars로 바꿔서 이득 보는 경우와 안 통하는 경우
이득이 뚜렷한 조건은 세 가지로 좁혀집니다. 첫째, 데이터가 수백만 행 이상이고 group_by·join·filter 같은 컬럼 연산이 반복될 때입니다. Arrow 기반 컬럼 저장과 멀티스레드 실행이 이 구간에서 효과를 냅니다. 둘째, CSV나 Parquet 같은 파일을 읽으면서 특정 컬럼·행만 필요할 때입니다. pl.scan_parquet()으로 lazy하게 읽으면 필요한 부분만 디스크에서 읽어오는 최적화가 적용됩니다. 셋째, 여러 단계의 변환을 체이닝하는 파이프라인 코드일 때입니다. lazy 쿼리 플랜은 .explain()으로 확인할 수 있어 디버깅도 pandas보다 명시적입니다.
반대로 이득이 없거나 오히려 손해인 경우도 있습니다. 데이터가 수천~수만 행 수준으로 작으면 pandas와 Polars의 실행 속도 차이는 무시할 만한 수준이고, 코드를 새로 작성하는 비용이 더 큽니다. 또한 matplotlib, seaborn, scikit-learn, statsmodels 같은 라이브러리 상당수는 pandas DataFrame을 입력으로 기대하므로, Polars로 전처리한 뒤 .to_pandas()로 변환하는 과정이 필요합니다. 이 변환에는 pyarrow 패키지가 설치되어 있어야 하며, 변환 자체에도 비용이 듭니다. 마지막으로 팀 전체가 pandas에 익숙하고 유지보수 인력이 pandas 문법에만 익숙하다면, 성능 이득보다 학습·리뷰 비용이 더 클 수 있습니다. 참고로 pandas 2.0부터는 read_csv(..., dtype_backend="pyarrow")처럼 Arrow 기반 dtype을 부분적으로 쓸 수 있어, 전면 마이그레이션 대신 이 옵션으로 절충하는 방법도 검토할 만합니다.
Polars가 의존하는 Arrow 포맷 자체는 pandas 진영에서도 점차 채택되고 있는 표준 포맷으로, 관련 배경은 Apache Arrow 문서에서 확인할 수 있습니다.
NaN이 null로 안 바뀌고 그대로 남아있는데, 이거 버그인가요?
버그가 아닙니다. pandas는 결측값을 대부분 NaN(float) 하나로 통일해서 표현하지만, Polars는 결측(null)과 “숫자가 아님을 뜻하는 부동소수점 값”인 NaN을 서로 다른 개념으로 구분합니다. 예를 들어 0.0 / 0.0 연산의 결과는 Polars에서 null이 아니라 NaN으로 남고, df.null_count()로는 잡히지 않습니다. 대신 df.select(pl.col('x').is_nan())처럼 별도로 확인해야 합니다.
pandas 코드에서 df.isna().sum()만 그대로 옮겨서 df.null_count()로 바꾸면, 연산 과정에서 생긴 NaN은 결측치 집계에서 빠지는 경우가 생깁니다. CSV를 읽어 들여올 때 생기는 빈 셀은 보통 null로 들어오지만, 연산 도중 발생한 NaN은 별개로 취급된다는 점을 기억해두는 편이 안전합니다.
지금 판다스 코드를 Polars로 바꿔볼지 판단하는 세 가지 기준
정리하면, 판다스 코드를 Polars로 바꾸는 작업은 문법 변환표를 참고해 filter, group_by, join, sort 같은 핵심 연산부터 옮기고, 인덱스 관련 코드는 통째로 걷어내는 방식이 가장 효율적입니다. 다만 바꾸기 전에 세 가지를 먼저 점검하는 편이 좋습니다. 데이터 규모가 성능 차이를 체감할 만큼 큰지, 파이프라인 뒷단에서 pandas 전용 라이브러리에 의존하고 있지는 않은지, 그리고 NaN/null 구분처럼 미묘한 의미 차이가 실제 로직에 영향을 주는 지점은 없는지입니다.
실행 단계로는, 우선 전체 코드를 한 번에 바꾸기보다 데이터 로딩과 집계 구간처럼 무거운 연산이 몰린 부분부터 pl.scan_csv와 LazyFrame으로 옮겨 .explain()으로 쿼리 플랜을 확인해보는 방법을 추천합니다. 그 구간에서 체감 속도 차이가 없다면, 나머지 코드는 pandas에 그대로 두어도 무방합니다.
GPT·Claude·Gemini API 실제 월 비용 계산: 1만 요청이면 얼마 나올까 (+캐싱·배치 절감법)
