Published on

Python Parquet 읽다 ArrowInvalid 스키마 충돌 해결

Authors

서론

데이터 레이크나 S3에 쌓인 Parquet를 pandas.read_parquet()로 읽다가 갑자기 pyarrow.lib.ArrowInvalid를 마주치면, 메시지는 대개 “스키마가 다르다”, “타입이 호환되지 않는다” 정도로만 말해주고 끝납니다. 문제는 Parquet 자체가 파일 단위로 스키마를 갖고 있고, 디렉터리(데이터셋)로 읽을 때는 여러 파일의 스키마를 병합(merge) 해야 한다는 점입니다. 이 과정에서 컬럼 타입이 조금이라도 다르면 Arrow가 엄격하게 실패합니다.

이 글은 다음을 목표로 합니다.

  • ArrowInvalid가 발생하는 전형적인 스키마 충돌 패턴을 빠르게 분류
  • 어떤 파일/파티션이 문제인지 정확히 찾아내는 진단 코드 제공
  • 상황별로 안전한 해결 전략(캐스팅, 스키마 고정, 재작성) 제시

참고로 pandas 경고를 무시했다가 데이터가 조용히 깨지는 케이스도 흔합니다. 데이터프레임 조작 중 경고/예외를 제대로 다루는 습관은 pandas SettingWithCopyWarning 완전 정복 - 원인·해결도 함께 보면 도움이 됩니다.


ArrowInvalid가 나는 대표 증상(에러 메시지 유형)

현장에서 자주 보는 메시지 패턴은 다음과 같습니다.

  1. 파일 간 타입 불일치
    • ArrowInvalid: Field <col> has incompatible types: int64 vs string
  2. timestamp 단위/타임존 불일치
    • timestamp[us] vs timestamp[ns], timestamp[us, tz=UTC] vs timestamp[us]
  3. decimal/float 혼용
    • decimal128(10,2) vs double
  4. dictionary encoding 관련(특정 버전/설정에서)
    • DictionaryType vs string
  5. partition column 충돌
    • 디렉터리 파티션(예: dt=2026-02-24/)이 컬럼으로 해석되며 파일 내부 컬럼과 충돌

핵심은 “데이터셋을 구성하는 모든 파일이 동일한 논리 스키마를 공유해야 한다”는 점입니다.


1) 먼저: 문제 재현을 위한 최소 코드

일단 어떤 API로 읽고 있는지부터 정리합니다.

import pandas as pd

path = "s3://bucket/path/to/dataset/"  # 또는 로컬 디렉터리

# pandas -> pyarrow 엔진 사용(기본이 pyarrow인 환경이 많음)
df = pd.read_parquet(path, engine="pyarrow")

위 코드에서 ArrowInvalid가 터지면, 이제부터는 pandas 레벨이 아니라 pyarrow.dataset / parquet 레벨로 내려가서 원인을 추적하는 게 빠릅니다.


2) 진단: 어떤 파일이 스키마를 깨는지 찾아내기

2.1 파일별 스키마를 수집해 diff 찍기

다음 코드는 디렉터리(또는 S3 prefix) 아래의 Parquet 파일을 나열하고, 각 파일의 스키마를 출력/비교합니다.

> 로컬 디렉터리 기준 예시입니다. S3라면 s3fs로 파일 목록을 얻거나, pyarrow.fs.S3FileSystem을 사용하세요.

from pathlib import Path
import pyarrow.parquet as pq

base = Path("./dataset")
files = sorted(base.rglob("*.parquet"))

schemas = {}
for f in files:
    pf = pq.ParquetFile(f)
    schemas[str(f)] = pf.schema_arrow

# 스키마 문자열 요약 출력
for f, s in schemas.items():
    print("\n==>", f)
    print(s)

스키마가 많아 눈으로 비교가 어렵다면, 컬럼별 타입을 딕셔너리로 만들고 “서로 다른 타입이 나온 컬럼”만 추려냅니다.

from collections import defaultdict

col_types = defaultdict(set)
for f, schema in schemas.items():
    for field in schema:
        col_types[field.name].add(str(field.type))

conflicts = {c: t for c, t in col_types.items() if len(t) > 1}
print("conflicts:")
for c, t in conflicts.items():
    print("-", c, "=>", t)

이렇게 하면 user_id가 어떤 파일에서는 int64, 어떤 파일에서는 string인 식의 충돌을 바로 확인할 수 있습니다.

2.2 “딱 한 파일만” 깨졌는지 빠르게 확인

대부분의 사고는 특정 날짜 파티션 하나에서 ETL이 잘못 나가며 발생합니다. 아래처럼 충돌 컬럼이 등장하는 파일만 필터링합니다.

bad_files = []
for f, schema in schemas.items():
    for col, types in conflicts.items():
        # 이 파일의 타입이 가장 흔한 타입과 다르면 bad로 간주하는 등 정책 가능
        pass

실무에서는 “정상 파티션의 스키마”를 기준으로 삼아, 기준과 다른 파일을 바로 격리(삭제/재생성)하는 방식이 가장 빠릅니다.


3) 원인별 해결 전략

3.1 int ↔ string 혼용: 읽을 때 스키마를 강제하거나, 재작성으로 정리

A안) 읽을 때 강제 캐스팅(권장: 재작성 전 임시 대응)

pyarrow.dataset로 읽으면서 특정 컬럼을 원하는 타입으로 맞출 수 있습니다.

import pyarrow as pa
import pyarrow.dataset as ds

dataset = ds.dataset("./dataset", format="parquet")

# 원하는 스키마를 정의
wanted = pa.schema([
    pa.field("user_id", pa.string()),
    # 필요한 컬럼만 정의해도 되지만, 전체 고정이 더 안전
])

table = dataset.to_table(schema=wanted)  # 캐스팅/정합 시도

단, 데이터 자체가 캐스팅 불가능(예: "abc"를 int로)하면 여기서 또 실패합니다. 그 경우는 “읽기 단계에서 해결”이 아니라 “데이터 정제 후 재작성”이 맞습니다.

B안) 문제 파일(또는 전체)을 정규 스키마로 재작성(근본 해결)

정규 스키마를 하나 정하고, 모든 파일을 그 스키마로 변환해 다시 씁니다.

import pyarrow as pa
import pyarrow.parquet as pq
import pyarrow.compute as pc
from pathlib import Path

base = Path("./dataset")
out = Path("./dataset_fixed")
out.mkdir(parents=True, exist_ok=True)

for f in base.rglob("*.parquet"):
    t = pq.read_table(f)

    # 예: user_id를 string으로 통일
    if "user_id" in t.column_names:
        t = t.set_column(
            t.schema.get_field_index("user_id"),
            "user_id",
            pc.cast(t["user_id"], pa.string(), safe=False)
        )

    pq.write_table(t, out / f.name)

safe=False는 위험할 수 있습니다(정보 손실/예상치 못한 변환). 가능하면 변환 전에 pc.is_valid, 정규식 검사 등을 넣고, 실패 레코드는 별도 격리하는 파이프라인이 안전합니다.


3.2 timestamp 단위(us/ns) 충돌: 단위를 하나로 고정

서로 다른 시스템(예: Spark는 us, pandas는 ns)을 거치면 timestamp 단위가 섞이기 쉽습니다.

import pyarrow as pa
import pyarrow.compute as pc

# timestamp를 ns로 통일
ns_type = pa.timestamp("ns")

# table t가 있다고 가정
if "event_time" in t.column_names:
    t = t.set_column(
        t.schema.get_field_index("event_time"),
        "event_time",
        pc.cast(t["event_time"], ns_type)
    )

타임존이 섞이는 경우(timestamp[us, tz=UTC] vs timestamp[us])는 더 까다롭습니다. “타임존 있는 타입”으로 통일하거나, 모두 naive로 만들지 정책을 정해야 합니다. 일반적으로 분석/집계 파이프라인에서는 UTC로 통일하는 편이 안전합니다.


3.3 decimal ↔ float 혼용: decimal로 통일(금액/정밀도 컬럼)

금액 컬럼이 어떤 날은 decimal(10,2), 어떤 날은 double로 저장되면 병합이 깨집니다. 이 경우는 float로 통일하면 오차가 누적되므로 decimal을 권합니다.

import pyarrow as pa
import pyarrow.compute as pc

money_type = pa.decimal128(12, 2)

if "amount" in t.column_names:
    # float -> decimal 변환은 반올림/스케일 정책을 명확히 해야 함
    t = t.set_column(
        t.schema.get_field_index("amount"),
        "amount",
        pc.cast(t["amount"], money_type, safe=False)
    )

ETL 소스에서부터 타입을 강제하는 것이 최선입니다(예: Spark write 시 schema 지정).


3.4 partition column 충돌: 파티션 스키마를 명시하거나, 컬럼명 충돌을 피하기

디렉터리 파티셔닝 dt=2026-02-24/ 같은 구조를 pyarrow.dataset이 자동으로 파티션 컬럼 dt로 인식합니다. 그런데 파일 내부에도 dt 컬럼이 있거나 타입이 다르면 충돌이 날 수 있습니다.

해결은 둘 중 하나입니다.

  • 파일 내부 컬럼명을 바꾸기(가장 깔끔)
  • dataset 읽을 때 파티션 스키마/파서 정책을 명시
import pyarrow.dataset as ds
import pyarrow as pa

partitioning = ds.partitioning(
    schema=pa.schema([pa.field("dt", pa.string())])
)

dataset = ds.dataset("./dataset", format="parquet", partitioning=partitioning)
table = dataset.to_table()

4) pandas에서의 실전 팁: 실패를 “조용히” 넘기지 않기

pandas는 내부적으로 pyarrow를 호출하므로, 스키마 충돌은 예외로 바로 드러나지만, 그 외의 데이터프레임 조작 단계에서 경고가 나와도 무시하고 넘어가면 데이터 품질 이슈로 이어질 수 있습니다. 특히 필터링 후 대입에서 흔한 SettingWithCopyWarning은 결과가 환경/버전에 따라 달라질 수 있어, 파이프라인에서 재현이 어려운 버그가 됩니다.

ArrowInvalid를 해결한 뒤에도 “정규화한 컬럼에 대한 후처리”에서 이런 경고가 나오면, 그 단계까지 함께 점검하는 게 좋습니다.


5) 운영 관점: 왜 갑자기 터졌나(재발 방지 체크리스트)

스키마 충돌은 보통 코드 한 줄이 아니라 파이프라인 계약(contract) 문제입니다. 다음을 체크하면 재발률이 크게 줄어듭니다.

  1. 작성(Write) 단계에서 스키마 고정
    • Spark/Arrow/Polars 등 어떤 writer든 “자동 추론”에 맡기지 말고 명시적 스키마를 사용
  2. 새 파티션 생성 시 스키마 검증
    • 당일 생성된 Parquet의 스키마를 기준 스키마와 비교해 다르면 배포/적재를 실패 처리
  3. 문제 파일 격리 전략
    • 한 파일 때문에 전체 데이터셋이 읽히지 않는 상황을 막기 위해, 검증 실패 파일은 quarantine prefix로 이동
  4. 라이브러리 버전 고정
    • pyarrow/pandas 버전 차이로 timestamp/dictionary 처리 방식이 달라질 수 있음

운영 환경에서 “어제까지 되던 작업이 오늘만 실패”하는 패턴은 인프라/배포 이슈와도 닮아 있습니다. 쿠버네티스 기반 배치라면 원인 추적 접근법 자체는 비슷하니, 장애 대응 루틴은 Kubernetes CrashLoopBackOff 10가지 원인과 15분 진단 같은 체크리스트를 참고해도 좋습니다.


결론

ArrowInvalid는 “Parquet가 깨졌다”기보다, 대부분 데이터셋 내부 파일들 간 스키마 계약이 깨졌다는 신호입니다. 해결의 핵심은 다음 3단계입니다.

  1. 파일별 스키마를 수집해 충돌 컬럼과 문제 파일을 특정
  2. 충돌 유형(int/string, timestamp 단위, decimal 등)에 맞춰 캐스팅 또는 재작성
  3. Write 단계에서 스키마를 고정하고, 신규 파티션에 스키마 검증 게이트를 두어 재발 방지

한 번 기준 스키마를 정하고 “생성 즉시 검증 → 실패 시 격리 → 정상만 merge” 흐름으로 바꾸면, ArrowInvalid는 디버깅 이벤트가 아니라 단순한 품질 체크 결과가 됩니다.