- Published on
Polars CSV 파싱 실패·dtype 불일치 해결 가이드
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버 로그나 외부 벤더 데이터처럼 CSV 품질이 들쑥날쑥한 데이터를 Polars로 읽다 보면, 한 번쯤은 could not parse 류의 에러나 컬럼 dtype이 예상과 다르게 잡히는 문제를 만나게 됩니다. 특히 Polars는 빠른 대신(기본 설정 기준) 엄격하게 파싱하는 편이라, "조금만" 깨진 CSV도 바로 실패할 수 있습니다.
이 글에서는 Polars에서 CSV 파싱 실패·dtype 불일치가 생기는 대표 원인을 분류하고, 재현 가능한 최소 예제와 함께 실전 해결책(스키마 고정, null 처리, 에러 허용, 청크/배치 전략, 사전 클린징)을 정리합니다.
문제 1: dtype 추론이 흔들리며 불일치가 발생
Polars는 기본적으로 일부 행을 샘플링해 dtype을 추론합니다. 데이터 초반부엔 숫자만 있다가 뒤쪽에 문자열(예: N/A, -, NULL)이 섞이면, 추론 결과가 Int64 또는 Float64로 고정된 상태에서 후반부 파싱이 실패하거나, 반대로 전부 Utf8로 떨어져 후속 연산이 꼬입니다.
재현 예제
아래 CSV는 amount 컬럼이 대부분 숫자지만 중간에 N/A가 섞여 있습니다.
import polars as pl
from io import StringIO
csv_text = """id,amount
1,10
2,20
3,N/A
4,40
"""
df = pl.read_csv(StringIO(csv_text))
print(df)
환경/버전에 따라 동작이 달라질 수 있지만, 흔히 amount를 수치로 추론한 뒤 N/A에서 파싱 에러가 납니다.
해결 1) 스키마를 명시해 dtype 추론을 끊기
가장 확실한 방법은 schema_overrides(또는 dtypes)로 컬럼 dtype을 고정하고, 이후에 안전하게 캐스팅하는 것입니다.
import polars as pl
from io import StringIO
csv_text = """id,amount
1,10
2,20
3,N/A
4,40
"""
df = pl.read_csv(
StringIO(csv_text),
schema_overrides={
"id": pl.Int64,
"amount": pl.Utf8, # 일단 문자열로 안전하게 받기
},
)
clean = df.with_columns(
pl.col("amount")
.str.replace_all(r"\s+", "")
.replace({"N/A": None, "NULL": None, "-": None})
.cast(pl.Float64, strict=False)
.alias("amount")
)
print(clean)
print(clean.dtypes)
핵심은 두 단계입니다.
- 입력 단계: 실패하지 않도록 넉넉한 타입(주로
Utf8)으로 받기 - 정제 단계: 규칙 기반으로
None처리 후cast(..., strict=False)로 안전 캐스팅
strict=False는 변환 불가능한 값을 null로 떨어뜨려 파이프라인을 계속 진행하게 해줍니다.
해결 2) 추론 샘플을 늘려 dtype 흔들림 줄이기
데이터 초반이 편향돼 있으면 추론이 잘못될 수 있습니다. 이때는 infer_schema_length를 늘려 더 많은 행을 보고 추론하게 합니다.
import polars as pl
df = pl.read_csv(
"data.csv",
infer_schema_length=10000,
)
다만 이 방법은 "추론을 더 잘" 하게 할 뿐, 품질이 나쁜 CSV(문자/숫자 혼재)를 근본적으로 해결하진 못합니다. 실무에서는 보통 스키마 명시가 더 재현 가능하고 안전합니다.
문제 2: 천 단위 구분자, 통화 기호, 공백으로 수치 파싱 실패
실제 CSV에는 1,234, ₩12,000, 3.14 같은 값이 흔합니다. 이 값들은 숫자처럼 보이지만 그대로는 파싱이 실패하거나 Utf8로 떨어집니다.
해결: 문자열로 받고 정규화 후 캐스팅
import polars as pl
df = pl.read_csv(
"sales.csv",
schema_overrides={
"price": pl.Utf8,
"qty": pl.Utf8,
},
)
clean = df.with_columns(
pl.col("price")
.str.replace_all(r"[,$₩\s]", "")
.cast(pl.Int64, strict=False)
.alias("price"),
pl.col("qty")
.str.replace_all(r"\s+", "")
.cast(pl.Int64, strict=False)
.alias("qty"),
)
이 패턴은 "CSV 입력은 관대하게, 정제는 명시적으로"라는 원칙에 잘 맞습니다.
문제 3: 날짜/시간 파싱 실패(포맷 혼재, 타임존, 빈 값)
날짜 컬럼은 포맷이 섞이기 쉽습니다.
2026-02-242026/02/24 10:11:122026-02-24T10:11:12Z- 빈 문자열
Polars가 자동으로 Date 또는 Datetime로 추론했다가 일부 행에서 실패할 수 있습니다.
해결: Utf8로 받고 포맷을 단계적으로 파싱
import polars as pl
df = pl.read_csv(
"events.csv",
schema_overrides={"event_time": pl.Utf8},
)
clean = df.with_columns(
pl.when(pl.col("event_time").is_null() | (pl.col("event_time").str.strip_chars() == ""))
.then(None)
.otherwise(pl.col("event_time"))
.alias("event_time"),
)
# 1차: ISO-8601 우선
clean = clean.with_columns(
pl.col("event_time")
.str.strptime(pl.Datetime, strict=False)
.alias("event_time_dt")
)
# 2차: 특정 포맷 보정이 필요하면 별도 컬럼에서 추가 파싱 후 coalesce
clean = clean.with_columns(
pl.coalesce(
[
pl.col("event_time_dt"),
pl.col("event_time").str.strptime(pl.Datetime, format="%Y/%m/%d %H:%M:%S", strict=False),
pl.col("event_time").str.strptime(pl.Date, format="%Y-%m-%d", strict=False).cast(pl.Datetime, strict=False),
]
).alias("event_time_dt")
)
포맷이 여러 개면 한 번에 완벽히 파싱하려 하지 말고, 우선순위를 둬서 coalesce로 합치는 방식이 디버깅과 운영에 유리합니다.
문제 4: 따옴표/이스케이프/개행 포함 필드로 레코드가 깨짐
CSV에서 가장 골치 아픈 케이스는 "필드 안에 구분자나 개행이 들어가는" 경우입니다.
- 주소/메모 필드에 쉼표가 포함
- 멀티라인 텍스트가 따옴표로 감싸져 있음
- 어떤 행은 따옴표가 닫히지 않음(데이터 자체가 깨짐)
이 경우 단순 dtype 문제가 아니라 레코드 경계가 무너져 파싱 자체가 실패할 수 있습니다.
해결 1) CSV 파서 옵션을 데이터에 맞추기
import polars as pl
df = pl.read_csv(
"raw.csv",
separator=",",
quote_char='"',
has_header=True,
ignore_errors=False,
)
quote_char가 데이터와 다르면 멀티라인/쉼표 포함 텍스트가 정상적으로 묶이지 않습니다. 벤더가 홑따옴표를 쓰는 경우도 있으니 실제 파일을 확인하세요.
해결 2) 깨진 행은 우회 적재 후 격리(운영 친화)
완벽한 파싱이 불가능한 일부 행이 섞여 있다면, "전체 실패" 대신 "문제 행을 격리"하는 방식이 운영에 유리합니다. Polars에는 ignore_errors=True로 파싱 실패를 무시하는 옵션이 있지만, 어떤 행이 누락됐는지 추적이 어려워질 수 있습니다.
실무에서는 다음 중 하나를 권합니다.
- 사전 검증 단계에서 라인 수/컬럼 수가 맞는지 체크 후, 깨진 라인을 별도 파일로 분리
- 원본을 그대로 보관하고, 정제본만 파이프라인에 투입
이런 "파이프라인이 죽지 않게" 만드는 접근은 장애 대응 관점에서도 중요합니다. 대규모 배치가 죽고 재시작 루프에 빠지는 상황을 막는 패턴은 systemd 서비스 재시작 루프 10분 진단 가이드에서 다룬 운영 관점과도 연결됩니다.
문제 5: 대용량 CSV에서 메모리·성능 문제로 간헐적 실패
CSV가 수 GB 단위로 커지면, 파싱 실패처럼 보이지만 실제로는 메모리 압박(OOM)이나 임시 파일/디스크 문제로 작업이 죽는 경우가 있습니다. 특히 컨테이너 환경에서는 OOMKilled로 프로세스가 종료되면서 "파싱이 안 된다"로 오해하기 쉽습니다.
- 컨테이너 메모리 제한이 낮음
- 병렬 처리로 순간 메모리 피크가 큼
- 디스크 inode 고갈/No space로 임시 파일 생성 실패
이 경우에는 애플리케이션 로그만 보지 말고 런타임 환경도 같이 봐야 합니다. 컨테이너에서 갑자기 죽는다면 K8s CrashLoopBackOff에서 OOMKilled 원인 추적 체크리스트가 그대로 도움이 됩니다.
해결: Lazy + scan_csv로 스트리밍/푸시다운 활용
가능하면 read_csv로 한 번에 메모리에 올리기보다 scan_csv로 LazyFrame을 만들고, 필요한 컬럼/필터만 적용한 뒤 collect하세요.
import polars as pl
lf = pl.scan_csv(
"big.csv",
schema_overrides={
"id": pl.Int64,
"amount": pl.Utf8,
"event_time": pl.Utf8,
},
)
result = (
lf
.select([
pl.col("id"),
pl.col("amount"),
pl.col("event_time"),
])
.with_columns(
pl.col("amount")
.str.replace_all(r"[,$\s]", "")
.cast(pl.Float64, strict=False)
.alias("amount"),
pl.col("event_time")
.str.strptime(pl.Datetime, strict=False)
.alias("event_time"),
)
.filter(pl.col("amount").is_not_null())
.collect()
)
print(result.head())
Lazy 실행은 불필요한 컬럼 로딩을 줄이고(프로젝션 푸시다운), 필터도 가능한 앞단에서 적용돼 전체 비용을 낮출 수 있습니다.
실전 디버깅 루틴: "어느 컬럼, 어떤 값"이 문제인지 빠르게 찾기
파싱 실패를 해결하는 핵심은 "문제 행"이 아니라 보통 "문제 컬럼의 문제 값"을 특정하는 것입니다. 다음 루틴을 추천합니다.
- 우선 스키마를
Utf8위주로 느슨하게 읽어 전체 로딩을 성공시킨다. - 각 컬럼에 대해 캐스팅을 시도하되
strict=False로 null을 만들고, - 캐스팅 전후를 비교해 "null로 떨어진 비율"이 높은 컬럼을 찾는다.
예제: 캐스팅 실패율(오염도) 측정
import polars as pl
df = pl.read_csv(
"raw.csv",
infer_schema_length=0, # 가능하면 추론 최소화(환경에 따라 지원/동작 차이 가능)
)
# 안전하게: 관심 컬럼만 문자열로 강제
df = pl.read_csv(
"raw.csv",
schema_overrides={
"amount": pl.Utf8,
"qty": pl.Utf8,
},
)
profile = df.select([
pl.len().alias("rows"),
pl.col("amount").is_null().sum().alias("amount_null"),
pl.col("amount").cast(pl.Float64, strict=False).is_null().sum().alias("amount_cast_null"),
pl.col("qty").cast(pl.Int64, strict=False).is_null().sum().alias("qty_cast_null"),
])
print(profile)
amount_cast_null이 비정상적으로 크면, 해당 컬럼의 값 분포를 추가로 확인합니다.
bad = df.filter(pl.col("amount").cast(pl.Float64, strict=False).is_null() & pl.col("amount").is_not_null())
print(bad.select("amount").unique().head(50))
이렇게 "실패 값을 목록으로 뽑아" 규칙을 추가하는 식으로 정제 로직을 강화하면, 재발 방지에 효과적입니다.
권장 패턴 정리: 운영에서 안 깨지는 Polars CSV 적재
정리하면, Polars CSV 파싱 실패·dtype 불일치는 아래 패턴으로 대부분 안정화됩니다.
- 입력은 관대하게:
schema_overrides로 문제 가능성이 있는 컬럼을Utf8로 받기 - 정제는 명시적으로: 공백/구분자 제거, 결측 치환 후
cast(..., strict=False) - 날짜는 단계적 파싱: 여러 포맷을
coalesce로 합치기 - 대용량은 Lazy 우선:
scan_csv로 필요한 컬럼만 처리 후collect - 실패를 숨기지 말기: 캐스팅 실패율을 측정하고, 실패 값 리스트로 규칙을 확장
데이터 파이프라인은 "한 번 읽고 끝"이 아니라 매일/매시간 반복됩니다. 따라서 단발성으로 ignore_errors=True로 넘기는 것보다, 오염도를 수치화하고(실패율), 규칙을 코드로 남겨 재현 가능하게 만드는 편이 장기적으로 훨씬 싸게 먹힙니다.
추가로, 파이프라인이 컨테이너/배치 환경에서 간헐적으로 죽는다면 데이터 문제와 별개로 런타임 리소스(OOM, 디스크 inode, 재시작 루프)를 함께 점검하세요. 예를 들어 디스크는 남는데 파일 생성이 안 되는 경우는 inode 고갈일 수 있는데, 이 케이스는 용량 남는데 No space left? inode 고갈 해결법처럼 원인이 전혀 다른 층에 있을 수 있습니다.