전체 글

Polars 2.0rc1 공개 — 스트리밍 엔진 기본화와 사라진 API 정리

xXfacebook페이스북

2026년 9월 2일 Polars 2.0 의 첫 릴리스 후보인 2.0rc1 이 공개됐습니다. 정식 2.0 이 나온 것이 아닙니다. PyPI 의 최신 안정판은 여전히 1.44.1 이라, 지금 pip install polars 를 해도 2.0 은 오지 않습니다.

바뀐 것 중 가장 큰 하나는 LazyFrame.collect() 가 이제 기본으로 스트리밍 엔진을 쓴다는 것입니다. 1.x 에서는 인자를 직접 넘겨야 켜지던 동작이었습니다. 그리고 저마다 다른 시점부터 폐기 예고가 걸려 있던 API 들이 이번에 실제로 사라졌습니다 — melt(), with_row_count(), profile(), fetch() 가 그렇습니다. 부르면 새로 생긴 예외 타입이 대체 API 를 알려주며 예외를 던집니다.

발표문은 정식 2.0 이 앞으로 몇 주 안에 나온다고 적었습니다. 그러니 지금 할 일은 옮기는 것이 아니라, 내 코드의 어디가 걸리는지 미리 확인하는 것입니다. 아래 내용은 발표문과 공식 업그레이드 가이드, 릴리스 페이지에 적힌 것을 정리한 것이고 코드를 실행해 확인한 내용은 아닙니다.

Polars 2.0rc1 에서 바뀐 것을 세 칸으로 나눠 보여주는 그림. 첫 칸에는 LazyFrame.collect 의 engine 인자는 auto 로 그대로지만 auto 의 해석이 인메모리 엔진에서 스트리밍 엔진으로 바뀌었다는 항목이, 둘째 칸에는 melt·with_row_count·join_nulls·profile·fetch 가 제거됐다는 항목이, 셋째 칸에는 is_in 의 손실 캐스팅 금지와 높이가 다른 가로 concat 의 ShapeError, 문자열에서 날짜로 가는 cast 제거가 나열된다.

2.0rc1 의 변경은 새 기능이 아니라 기본값과 제거에 몰려 있다.

Polars 2.0rc1, 무엇이 공개됐나

발표는 2026년 9월 2일 수요일, Ritchie Vink 명의로 나왔습니다. GitHub 릴리스 태그는 py-2.0.0-rc.1 이고 사전 릴리스 플래그가 켜져 있습니다. 같은 날 PyPI 에도 소스 배포판이 올라왔습니다. 직전 안정판인 py-1.44.1 이 2026년 8월 26일이었으니 일주일 만에 메이저 버전 후보가 나온 셈입니다.

발표문은 큰 기능 릴리스를 목표로 하지 않는다고 먼저 못을 박았습니다.

"We don't aim to make a big feature release of Polars 2.0. In fact we hope it to be a boring experience for you. The reason we bump this major version is that we can get rid of design decisions made in the past that currently block us and then we want to change defaults to more sensible settings that will benefit a greater audience."

메이저 버전을 올린 이유가 새 기능이 아니라 과거의 설계 결정을 걷어내고 기본값을 바꾸기 위해서라는 뜻입니다. 정식 릴리스 시점에 대해서는 The definite 2.0 release will land in the following weeks 라고만 적혀 있습니다.

설치할 때 헷갈리기 쉬운 지점이 하나 있습니다. 같은 배포물을 가리키는 버전 문자열이 자리마다 다릅니다.

자리 문자열
발표문의 설치 명령 2.0rc1
PyPI 정규화 버전·sdist 파일명 2.0.0rc1
GitHub 릴리스 태그 py-2.0.0-rc.1

셋 다 같은 배포물을 가리킵니다. 그래서 "PyPI 버전은 2.0rc1 이다" 라고 적어 두면 반쯤 틀린 말이 됩니다.

한 가지 더 있습니다. GitHub 릴리스 노트의 Breaking changes 항목은 딱 한 줄, SQL 의 기본 엔진을 스트리밍 엔진으로 바꿨다는 것뿐입니다. 깨지는 변경의 실제 목록은 릴리스 노트가 아니라 별도의 업그레이드 가이드 문서에 있습니다. 릴리스 노트만 훑고 넘기면 정작 내 코드를 깨뜨릴 항목을 통째로 놓치게 됩니다.

가장 큰 변경 — LazyFrame 이 기본으로 스트리밍 엔진을 씁니다

업그레이드 가이드의 문장은 이렇습니다.

"LazyFrame.collect and collect_async still default to engine="auto", but "auto" now resolves to the streaming engine for lazy queries (it used to resolve to the in-memory engine)."

여기서 정확히 읽어야 할 것은 함수의 기본 인자가 그대로라는 점입니다. collect() 의 기본값은 여전히 engine="auto" 이고 바뀐 것은 "auto" 를 무엇으로 해석하느냐입니다. 전에는 인메모리 엔진이었고 이제는 스트리밍 엔진입니다. "기본값이 streaming 으로 바뀌었다" 는 설명이 부정확한 이유가 여기 있습니다. 영향을 받는 호출은 LazyFrame.collect 와 collect_async 입니다.

1.x 에서는 이 엔진이 옵트인이었습니다. 1.44.1 유저 가이드는 To use streaming mode you simply pass the engine="streaming" argument to collect 라고 안내합니다. 명시적으로 켜던 것이 이번에 기본이 된 것입니다.

성능 수치는 조건과 함께 읽어야 합니다

발표문에는 we expect the streaming engine to be easily 5x faster 라는 문장이 있고 5x faster 에 벤치마크 페이지 링크가 걸려 있습니다. 먼저 expect(기대한다)라는 동사에 주의해야 합니다. 그리고 링크를 따라가면 측정 조건이 이렇습니다.

항목 값
페이지 Updated PDS-H benchmark results (May 2025)
발행일 2025년 6월 1일
측정된 버전 polars 1.30.0
벤치마크 PDS-H, scale factor 10 및 100
머신 AWS c7a.24xlarge (96 vCPU / 192 GB)

그 페이지 자신의 표현은 5배가 아닙니다.

"Compared to the in-memory engine, this shows that Polars streaming can be up to 3-7x faster. Your mileage will of course vary, depending on your use case and hardware."

정리하면 세 가지입니다. 첫째, 2.0rc1 을 측정한 수치가 아닙니다 — 1년 이상 전인 2025년 5월의 1.30.0 측정치입니다. 둘째, 비교 대상은 다른 라이브러리가 아니라 Polars 자신의 인메모리 엔진입니다. 셋째, 페이지의 문구는 최대 3~7배이고 발표문의 5배는 그것을 근거로 한 기대치입니다. 그러니 "Polars 2.0 이 5배 빨라졌다" 는 문장은 이 자료들로는 성립하지 않습니다. 참고로 벤치마크 페이지는 scale factor 1 을 대략 1GB 의 CSV 데이터에 해당한다고 설명합니다.

행 순서를 보장하지 않는 연산이 생깁니다

메이저 버전을 올려야 했던 실질적인 이유는 여기입니다. 가이드의 문장은 이렇습니다.

"The streaming engine does not guarantee row order for operations that don't require it (unpivot, group_by, joins, ...)."

가이드가 든 예시가 unpivot 입니다.

>>> lf = pl.LazyFrame({"a": ["x", "y", "z"], "b": [1, 2, 3], "c": [4, 5, 6]})
>>> lf.unpivot(pl.selectors.numeric(), index="a").collect()

인메모리 엔진에서는 x/b, y/b, z/b, x/c, y/c, z/c 처럼 열 단위로 뭉쳐 나오던 결과가, 스트리밍 엔진에서는 x/b, x/c, y/b, y/c, z/b, z/c 로 달라질 수 있습니다. 결과 집합은 같지만 순서가 다릅니다. 이 순서에 기대어 다음 단계를 짜 뒀다면 예외 없이 조용히 어긋납니다.

인메모리 엔진과 스트리밍 엔진에서 같은 unpivot 결과의 행 순서가 어떻게 달라지는지 두 개의 표로 비교하는 그림. 왼쪽 인메모리 표는 x/b, y/b, z/b, x/c, y/c, z/c 순으로 열 단위로 뭉쳐 있고, 오른쪽 스트리밍 표는 x/b, x/c, y/b, y/c, z/b, z/c 순으로 행 단위로 섞여 있으며, 아래에 순서가 필요하면 maintain_order 로 옵트인한다는 문구가 붙는다.

결과 집합은 같지만 순서가 다르다. 순서에 기대던 코드는 조용히 어긋난다.

순서가 필요하면 옵트인할 수 있습니다. 다만 연산마다 받는 값의 형태가 다릅니다. 발표문은 불리언으로 적었습니다.

"If you require observable row-order in those operations, you can opt in to that by setting maintain_order=True."

반면 가이드의 join 예시는 문자열을 넘깁니다.

left.join(right, on="k", how="left", maintain_order="left").collect()

그러니 전부 maintain_order=True 로 두면 된다고 외워 두면 안 됩니다. 쓰는 연산의 시그니처를 그때그때 확인해야 합니다.

엄격해진 타입 — is_in, concat, 캐스팅

발표문이 Stricter Polars 라는 소제목 아래 묶어 둔 변경들입니다. 공통점은 조용히 통과하던 것이 이제 예외를 던진다는 것입니다. 조용히 통과하던 쪽이 편해 보이지만 그 조용함이 곧 잘못된 결과를 뜻하는 경우가 있습니다.

is_in 이 손실 캐스팅을 거부합니다

가이드의 설명은 is_in() no longer lossily coerces operands to a shared supertype; it now only coerces when doing so is lossless, and raises otherwise 입니다. 손실이 없을 때만 자동 변환하고 아니면 예외를 던진다는 것입니다.

발표문이 든 사고 시나리오가 구체적입니다. JSON 으로 내보내는 과정에서 큰 정수 ID 가 실수가 되어 버린 경우입니다.

# Checking if a user ID matches a list of "flagged" account IDs
# (flagged_ids loaded from a JSON export, where large IDs became floats)
flagged_ids = pl.Series([9007199254740992.0])
user_id = pl.Series([9007199254740993])  # Int64 -> a different ID, off by 1
user_id.is_in(flagged_ids)

9007199254740992 는 2의 53제곱이고 float64 가 정확히 표현할 수 있는 가장 큰 정수입니다. 그보다 1 큰 9007199254740993 은 float64 로 옮기는 순간 내림이 일어나 9007199254740992.0 이 됩니다. 1.x 는 두 피연산자를 float64 로 맞춘 다음 비교했기 때문에, 서로 다른 ID 가 같다고 나오는 오탐이 조용히 발생했습니다. 발표문이 giving a false positive 라고 적은 대목입니다.

2.0 에서는 예외가 뜨고 메시지가 이유와 대처를 같이 알려줍니다.

>>> pl.Series([1]).is_in(pl.Series([1.99]))
polars.exceptions.InvalidOperationError: 'is_in' cannot check for Int64 values in List(Float64) data.
Hint: Before version 2.0, Polars would perform this check by lossily coercing the operands to Float64. However, since Polars 2.0, for is_in() it is required to explicitly cast (one of) the operands to a compatible type.

의도한 비교였다면 한쪽을 명시적으로 캐스팅하면 됩니다. 문서가 보여주는 우회 코드는 pl.Series([1]).is_in(pl.Series([1.99]).cast(pl.Int64)) 입니다.

is_in 의 손실 캐스팅이 만들던 오탐을 위아래 두 단으로 보여주는 그림. 위쪽에는 Int64 값 9007199254740993 이 float64 로 변환되면서 2의 53제곱인 9007199254740992 로 내림되어 서로 다른 ID 가 같다고 판정되는 1.x 의 흐름이, 아래쪽에는 2.0 이 같은 호출에서 InvalidOperationError 를 던지고 명시적 캐스팅을 요구하는 흐름이 나란히 그려진다.

2의 53제곱은 float64 가 정확히 담는 마지막 정수다. 한 칸만 넘어가도 값이 뭉개진다.

높이가 다른 프레임의 가로 결합이 막힙니다

발표문의 예시는 상류 작업이 조용히 실패한 상황입니다.

transactions = pl.DataFrame({"day": [1, 2, 3, 4, 5], "count": [120, 98, 143, 87, 156]})
# Upstream job for day 5 failed silently
fraud_flags = pl.DataFrame({"flagged": [2, 0, 5, 1]})  # only 4 rows
pl.concat([transactions, fraud_flags], how="horizontal")

5행짜리와 4행짜리를 가로로 붙이는 코드입니다. 1.x 는 모자란 5일차의 flagged 를 null 로 채우고 넘어갔습니다. 2.0 은 ShapeError: cannot concat dataframes with different heights in 'strict' mode 를 던집니다. 채우기가 원래 의도였다면 how="horizontal_extend" 로 명시하면 됩니다. 발표문의 표현으로는 그 의도를 읽는 사람에게 분명히 드러내자는 것입니다.

캐스팅 대신 전용 메서드를 씁니다

문자열을 날짜로 바꾸던 cast() 는 없어졌습니다.

>>> pl.Series(["2022-08-30"]).cast(pl.Date)
polars.exceptions.InvalidOperationError: casting from string to date is not supported.
It was removed in Polars 2.0. Use `str.to_date()` instead.

대체는 .str.to_date() 와 .str.to_datetime() 입니다. 발표문이 든 이유는 이쪽이 파싱 포맷을 지정할 수 있어 데이터가 어떻게 해석되는지에 대한 통제권이 더 크다는 것입니다.

카테고리형도 같은 방향입니다. 정수에서 Enum 으로 가는 .cast() 는 .cat.to(...) 로, 카테고리형을 정수로 되돌리던 .cast(pl.UInt32) 는 .cat.physical() 로 바뀌었습니다. 예외 메시지가 대체 메서드를 그대로 적어 줍니다.

>>> s.cast(pl.UInt32)
polars.exceptions.ComputeError: cannot cast categorical types to UInt32.
Instead of `.cast(UInt32)`, use `.cat.physical()`.

두 메서드 모두 rc1 소스에 실재하고 각각 카테고리형이나 enum 타입으로 변환하는 것과 카테고리형·enum 의 물리값을 가져오는 것으로 설명돼 있습니다.

이름이 바뀌거나 사라진 API

가이드는 제거된 API 를 표로 정리해 두었습니다. 폐기 예고 시점이 함께 적혀 있는데, with_row_count() 는 0.20 부터 예고돼 있던 항목입니다.

사라진 것 폐기 예고 대체
DataFrame/LazyFrame.melt() 1.0 unpivot() — id_vars 대신 index, value_vars 대신 on
DataFrame/LazyFrame.with_row_count() 0.20 with_row_index() — 기본 컬럼명이 row_nr 에서 index 로 바뀜
join(join_nulls=...) 1.24 nulls_equal=... — 인자 이름만 바뀜
LazyFrame.profile() 1.43 대체 없음
LazyFrame.fetch() 0.19 collect() 를 head() 와 함께

가장 조심할 것은 with_row_count() 입니다. 이름만 바뀐 것이 아니라 기본 컬럼명까지 바뀝니다. 예외 메시지가 그것까지 알려줍니다.

>>> df.with_row_count()
polars.exceptions.AttributeRemovedError: `with_row_count` was removed in version 2.0; use `with_row_index` instead. Note that the default column name has changed from 'row_nr' to 'index'.

호출부만 with_row_index() 로 고치고 넘어가면, 뒤에서 row_nr 컬럼을 참조하던 코드가 그때부터 깨집니다.

profile() 제거는 이번 릴리스에서 제거된 API 가운데 스트리밍 기본화와 인과가 문서에 명시된 유일한 항목입니다. 사유가 이렇게 적혀 있습니다.

"It was designed for the in-memory engine; the concurrent nature of the streaming engine, now the default, would make its per-node timings misleading."

인메모리 엔진을 전제로 만든 도구라, 동시성으로 도는 스트리밍 엔진 아래에서는 노드별 소요 시간이 오히려 잘못된 그림을 준다는 것입니다. 기본 엔진을 바꾸면 그 위에 얹혀 있던 도구도 같이 정리해야 한다는 이야기입니다.

Polars 2.0 에서 제거된 API 와 그 대체를 화살표로 짝지어 보여주는 그림. 왼쪽 작은 글씨는 폐기 예고 시점으로 melt 1.0, with_row_count 0.20, join_nulls 1.24, fetch 0.19, profile 1.43 이다. melt 는 unpivot 으로, with_row_count 는 with_row_index 로 이어지되 기본 컬럼명이 row_nr 에서 index 로 달라진다는 경고 표시가 붙고, join 의 join_nulls 인자는 nulls_equal 로 이름만 바뀌며, fetch 는 collect 와 head 조합으로 이어지며, profile 만 대체 없이 끊긴 채 스트리밍 엔진의 동시성 때문이라는 사유가 옆에 놓인다.

이름만 바뀐 것과 동작까지 바뀐 것이 섞여 있다.

show_graph() 도 기본값이 바뀌었습니다. plan_stage 파라미터의 기본값이 "ir" 에서 "physical" 로 옮겨 갔습니다. 다만 이 항목은 업그레이드 가이드의 서술과 rc1 릴리스 노트에 올라온 수정 항목이 서로 맞물리지 않아 기본 호출이 실제로 어떤 그림을 그리는지는 이 자료들만으로 단정할 수 없습니다.

DataFrame Interchange Protocol 도 빠졌습니다. DataFrame.__dataframe__() 이 없어졌고 polars.interchange 아래의 buffer·column·dataframe·from_dataframe·utils 서브모듈과 protocol 의 심볼들이 함께 제거됐습니다. 다만 polars.interchange 패키지 자체는 남아 있습니다 — 지금은 CompatLevel 하나만 들어 있습니다.

이런 제거를 만났을 때 뜨는 예외 타입이 새로 두 개 생겼습니다. 제거된 속성·메서드에는 polars.exceptions.AttributeRemovedError, 제거되거나 이름이 바뀐 파라미터에는 polars.exceptions.ArgumentRemovedError 가 뜹니다. 각각 AttributeError 와 TypeError 의 하위 타입이라, 기존에 이 둘을 잡던 except 절에는 그대로 걸립니다. 발표문은 사용자나 사용자의 에이전트가 옛 파라미터를 쓰더라도 이어서 작업할 수 있도록 하는 데 공을 들였다고 적었습니다.

지금 내 코드를 어떻게 하면 되나

정식 2.0 이 아직 나오지 않았으니, 지금 단계에서 할 수 있는 것은 이전이 아니라 확인입니다. 발표문이 안내하는 설치 명령은 이것입니다.

pip install polars==2.0rc1

--pre 옵션 없이도 되는 이유는 pip 문서에 있습니다. 요구 명세 자체에 사전 릴리스 버전이 들어 있으면 그 요구에 한해 사전 릴리스를 허용한다고 적혀 있습니다. 뒤집어 말하면 버전을 명시하지 않으면 2.0 은 오지 않습니다. 기존 requirements.txt 를 그대로 두면 계속 1.44.1 을 받습니다.

엔진을 되돌리는 방법은 가이드가 세 가지를 제시합니다. 두 가지는 코드에서 지정하는 방식입니다.

pl.Config.set_engine_affinity("in-memory")  # 프로세스 전체
lf.collect(engine="in-memory")              # 쿼리 하나

세 번째는 환경변수 POLARS_ENGINE_AFFINITY=in-memory 입니다. 다만 첫 번째 방법은 이름 그대로 선호(affinity)이지 보장이 아닙니다. rc1 소스의 docstring 에는 직접 이렇게 적혀 있습니다.

"The default execution engine Polars will attempt to use when calling .collect(). However, the query is not guaranteed to execute with the specified engine."

이 설정을 켰으니 모든 쿼리가 인메모리로 돈다고 가정하면 안 된다는 뜻입니다. 행 순서가 꼭 필요한 자리라면 엔진 설정에 기대기보다 해당 연산에 maintain_order 를 직접 거는 쪽이 문서가 보장하는 방법입니다.

먼저 훑어볼 자리를 정리하면 이 정도입니다.

  • melt(, with_row_count(, join_nulls=, .profile(, .fetch( 를 코드베이스에서 검색
  • with_row_index() 로 바꾼 뒤 row_nr 을 참조하는 곳이 남아 있는지 다시 검색
  • is_in() 의 두 피연산자 타입이 다른 곳 — 정수와 실수가 섞이면 예외가 날 수 있습니다
  • pl.concat(..., how="horizontal") 에 들어가는 프레임들의 높이가 항상 같은지
  • .cast(pl.Date) 처럼 문자열을 시간 타입으로 캐스팅하는 곳
  • 결과 순서에 의존하는 group_by·join·unpivot 뒤의 코드
  • DataFrame.__dataframe__() 이나 polars.interchange 서브모듈을 쓰는 연동 코드

앞으로 무엇이 오는지는 발표문의 마지막 문단이 한 문장으로 열거합니다 — 스트리밍 엔진의 제대로 된 out-of-core 지원, 새 IO 플러그인 설계, 가장 빠를 것으로 자평하는 S3 리더, SQL 커버리지 개선, 비용 기반 플래너, join 재정렬, 그리고 파이프라인을 끝에서 끝까지 비동기로 만들기 위한 mmap 제거입니다. 다만 이것들은 원문 표현으로 in flight, 즉 진행 중인 것들이지 2.0rc1 에 담긴 기능이 아닙니다. 2.0rc1 을 깔았다고 out-of-core 가 되는 것이 아니라는 뜻입니다.

근거와 참고

이 글 공유하기xXfacebook페이스북