
결론부터 말씀드리면, 메모리보다 큰 파일을 Polars로 처리할 때 실제 관건은 lazy API가 아니라 streaming 실행 여부입니다. scan_csv나 scan_parquet로 파일을 lazy하게 열어도 collect()를 그냥 호출하면 결과를 통째로 메모리에 쌓아버려서 똑같이 메모리 부족이 납니다. 이 글은 Polars의 lazy 평가와 streaming 엔진이 정확히 어느 지점에서 갈라지는지, 그리고 어떤 코드를 써야 메모리 사용량이 실제로 줄어드는지를 다룹니다. 아래 내용은 파이썬용 Polars 1.x대 버전을 기준으로 합니다.
메모리보다 큰 파일을 Polars로 열면 무슨 일이 먼저 일어날까요
pl.read_csv나 pl.read_parquet 같은 eager 함수는 호출하는 순간 파일 전체를 읽어 DataFrame으로 메모리에 올립니다. 파일이 8GB인데 가용 RAM이 4GB라면 이 시점에서 바로 OOM이 나거나 스왑이 걸려 사실상 멈춰버립니다.
반면 pl.scan_csv, pl.scan_parquet는 파일을 읽지 않고 스키마와 쿼리 플랜만 만듭니다. Polars는 내부적으로 데이터를 컬럼 단위 메모리 포맷인 아파치 애로우로 들고 있기 때문에, 쿼리 플랜 단계에서 어떤 컬럼과 어떤 행이 실제로 필요한지 먼저 결정할 수 있습니다. 이 차이가 뒤에 나올 최적화의 출발점입니다.
Lazy API는 읽는 양을 줄일 뿐, 담는 그릇을 바꾸진 않습니다
scan_*로 만든 LazyFrame에 .filter(), .select()를 걸고 .collect()를 호출하면 옵티마이저가 projection pushdown과 predicate pushdown을 적용합니다. 사용하지 않는 컬럼은 아예 디스크에서 읽지 않고, Parquet라면 통계 정보를 보고 조건에 안 맞는 row group을 통째로 건너뜁니다. 그래서 원본 파일이 커도 실제로 읽히는 바이트 양은 크게 줄어들 수 있습니다.
문제는 그다음입니다. 기본 .collect()는 최적화된 플랜을 실행한 뒤 결과를 하나의 완성된 DataFrame으로 메모리에 통째로 구성합니다. 필터링 후에도 남는 행이 여전히 수천만 건이거나, group_by 집계 자체가 전체 데이터를 훑어야 하는 연산이라면 lazy를 쓰든 안 쓰든 결과 구성 단계에서 메모리가 그대로 부족해질 수 있습니다. 즉 lazy는 “얼마나 읽을지”를 줄여주는 기술이지, “어떻게 들고 있을지”를 바꿔주는 기술이 아닙니다.
스트리밍 엔진이 켜지는 조건과 조용히 꺼지는 조건
.collect(streaming=True)를 붙이면 Polars는 쿼리 플랜을 한 번에 실행하지 않고, 데이터를 작은 배치 단위로 잘라 파이프라인 각 단계(필터 → 집계 → …)에 순서대로 흘려보냅니다. 이 방식에서는 특정 시점에 메모리에 올라오는 양이 배치 크기 수준으로 제한되기 때문에, 원본 파일이 물리 메모리보다 훨씬 커도 처리 자체는 끝까지 진행될 수 있습니다.
다만 모든 연산이 스트리밍으로 도는 건 아닙니다. 전체 데이터를 한 번에 봐야 하는 완전 정렬이나 일부 조인·윈도우 연산이 플랜에 섞이면, 그 구간은 배치 단위 처리가 불가능해서 내부적으로 비스트리밍 방식으로 전환됩니다. 겉으로는 streaming=True를 그대로 켜둔 상태라도 특정 연산에서 메모리 사용량이 갑자기 튀어 오르는 이유가 여기에 있습니다.
결과 자체가 큰 경우엔 sink_parquet로 흘려보내세요
집계 결과가 몇 줄로 줄어드는 게 아니라 변환 결과 자체가 여전히 큰 경우도 있습니다. 예를 들어 20GB CSV를 컬럼 타입만 정리해서 Parquet로 바꾸는 작업이 그렇습니다. 이럴 때는 결과를 파이썬 객체로 받는 .collect() 대신 .sink_parquet(), .sink_csv(), .sink_ipc()를 씁니다. 이 함수들은 쿼리 결과를 메모리에 올리는 과정 없이 스트리밍 방식으로 곧장 디스크에 씁니다.
import polars as pl
(
pl.scan_csv("huge_sales.csv")
.filter(pl.col("amount") > 0)
.with_columns(pl.col("amount").cast(pl.Float32))
.sink_parquet("result.parquet")
)
이 코드는 실행 중에도 result.parquet가 점진적으로 채워지는 방식이라, 중간 단계에서 전체 결과를 담은 DataFrame이 파이썬 메모리에 따로 생기지 않습니다. 다만 플랜 중간에 스트리밍이 안 되는 연산이 끼어 있으면 동작이 달라질 수 있으니, 실제 대용량 파일에 돌리기 전에 작은 샘플 파일로 먼저 검증해 보는 걸 권합니다.
작은 예제로 직접 차이를 찍어보면
실제 대용량 파일 없이도 동작 방식은 작은 데이터로 그대로 확인할 수 있습니다. 아래 예제는 scan_csv 대신 메모리 위의 작은 테이블을 lazy로 바꿔서 같은 문법을 재현한 것입니다.

import polars as pl
lf = pl.DataFrame({
"region": ["서울", "부산", "서울", "대구", "부산"],
"amount": [100, 200, 150, 50, 300],
}).lazy()
result = (
lf.filter(pl.col("amount") > 60)
.group_by("region")
.agg(pl.col("amount").sum().alias("total_amount"))
.collect(streaming=True)
)
print(result)
조건 amount > 60을 통과한 행은 서울 100·150, 부산 200·300이고 대구 50은 걸러집니다. 지역별로 합산하면 아래와 같은 결과가 나옵니다. group_by는 순서를 보장하지 않으므로 실행할 때마다 행 순서는 달라질 수 있습니다.
shape: (2, 2)
┌────────┬──────────────┐
│ region ┆ total_amount │
│ --- ┆ --- │
│ str ┆ i64 │
╞════════╪══════════════╡
│ 서울 ┆ 250 │
│ 부산 ┆ 500 │
└────────┴──────────────┘
이 예제 자체는 작아서 streaming 여부와 관계없이 결과가 같지만, scan_csv("huge_sales.csv")처럼 원본을 파일 스캔으로 바꾸면 동일한 코드가 실제 대용량 처리에도 그대로 적용됩니다. 방식별 차이를 정리하면 다음과 같습니다.
| 방식 | 데이터를 메모리에 올리는 시점 | 파일 크기 제약 |
|---|---|---|
read_csv / read_parquet (eager) |
호출 즉시 전체 로드 | RAM보다 작아야 함 |
scan_* + .collect() |
최적화된 플랜 실행 후 결과 전체를 한 번에 적재 | 필터링 후 결과가 RAM보다 작아야 함 |
scan_* + .collect(streaming=True) |
배치 단위로 순차 적재 | 원본은 커도 되나 비스트리밍 연산이 섞이면 예외 발생 |
scan_* + .sink_parquet() 등 |
파이썬 메모리에 결과를 아예 적재하지 않음 | 출력 자체가 커도 처리 가능 |
collect(streaming=True)인데 왜 메모리가 안 줄어들까요?
가장 흔한 원인은 플랜 안에 스트리밍을 지원하지 않는 연산이 섞여 있는 경우입니다. 복잡한 조인, 전체 정렬, 일부 윈도우 함수가 여기에 해당하는데, 이런 연산을 만나면 그 구간만 기존 방식으로 되돌아가 전체 데이터를 한 번에 올립니다. 파이프라인을 나눠서 어느 연산 다음에 메모리가 튀는지 확인해 보면 원인을 좁힐 수 있습니다.
또 하나 짚어둘 점은, Polars의 streaming 관련 옵션과 엔진 구현은 릴리스마다 계속 바뀌고 있다는 사실입니다. streaming=True 매개변수의 동작 범위나 대체 방식은 버전에 따라 달라질 수 있으므로, 실제 운영 코드에 쓰기 전에는 사용 중인 버전의 릴리스 노트를 확인해 보는 편이 안전합니다.
메모리보다 큰 파일을 Polars로 다뤄야 하는 상황이라면, 우선 scan_*로 lazy 플랜을 짜서 읽는 양부터 줄이고, 그래도 결과가 크면 collect(streaming=True)로 배치 처리를 시도하고, 결과 자체를 디스크로 바로 내보내도 되는 작업이라면 sink_parquet 계열을 쓰는 순서로 접근해 보시길 권합니다. 이 세 단계를 구분해서 적용하면 같은 4GB RAM 노트북에서도 처리할 수 있는 파일 크기가 눈에 띄게 달라집니다.
