Published on

Pandas SettingWithCopyWarning 완전 해결법

Authors

서브셋을 만든 뒤 값을 바꾸려는데 SettingWithCopyWarning이 뜨면, 대부분은 “경고만 없애면 되지 않나?”로 흘러갑니다. 하지만 이 경고는 단순한 소음이 아니라 내가 수정한 값이 원본에 반영될 수도, 안 될 수도 있는 모호한 상태를 알려줍니다. 즉, 같은 코드가 환경이나 데이터 상태에 따라 조용히 틀린 결과를 만들 수 있습니다.

이 글에서는 경고가 발생하는 구조(뷰 vs 복사본), 대표적인 위험 패턴(체이닝 인덱싱, 필터링 후 대입, 컬럼 슬라이싱 후 대입)을 분류하고, 항상 의도대로 동작하는 패턴으로 바꾸는 방법을 코드로 정리합니다.

참고로 CSV를 읽는 과정에서 인코딩 문제로 전처리 단계가 흔들리면(컬럼 타입이 달라지거나 결측이 달라짐) 경고 재현이 더 어려워집니다. CSV 인코딩이 불안정한 환경이라면 Python UnicodeDecodeError - CSV 인코딩 자동탐지 실전도 함께 점검하는 걸 추천합니다.

SettingWithCopyWarning이 의미하는 것

Pandas는 성능을 위해 어떤 연산은 원본의 뷰(view) 를 반환하고, 어떤 연산은 복사본(copy) 을 반환합니다. 문제는 사용자가 코드를 읽을 때 이 차이가 항상 명확하지 않다는 점입니다.

  • 뷰: 원본과 메모리를 공유할 수 있어, 뷰를 수정하면 원본도 바뀔 수 있습니다.
  • 복사본: 원본과 분리되어, 복사본을 수정해도 원본은 안 바뀝니다.

SettingWithCopyWarning은 “지금 너는 원본인지 복사본인지 확실하지 않은 객체에 대입하고 있다”고 경고합니다. 즉, 아래 두 의도가 섞여 있을 때 발생합니다.

  1. 원본 df의 일부 행만 골라서 원본을 수정하고 싶다
  2. 부분집합 sub만 수정하고 원본은 건드리고 싶지 않다

경고를 없애는 핵심은 단순합니다.

  • 원본을 수정하려면: 한 번의 인덱싱으로 원본에 대입한다 (loc 사용)
  • 부분집합만 수정하려면: 명시적으로 copy() 해서 “복사본이다”를 확정한다

가장 흔한 원인: 체이닝 인덱싱

다음 패턴이 대표적인 지뢰입니다.

import pandas as pd

df = pd.DataFrame({
    "name": ["a", "b", "c"],
    "age": [10, 20, 30],
    "score": [1.0, 2.0, 3.0],
})

# 체이닝 인덱싱: df[mask][col] = value
mask = df["age"] >= 20

df[mask]["score"] = 0  # SettingWithCopyWarning 가능

df[mask]가 뷰인지 복사본인지 확실하지 않은 상태에서, 그 결과에 다시 ["score"]로 접근해 대입합니다. 이때 Pandas는 “원본에 적용되는지 보장 못 한다”고 경고합니다.

해결: loc로 원본에 단일 대입

mask = df["age"] >= 20

df.loc[mask, "score"] = 0
  • loc[행조건, 컬럼] = 값원본 df에 대한 단일 대입이므로 의도가 명확합니다.
  • 이 패턴 하나만 습관화해도 경고의 80%는 사라집니다.

필터링 후 컬럼 추가/수정: 부분집합을 수정하려면 copy()

다음은 “부분집합만 따로 만들고 거기서 작업”하는 코드에서 자주 나옵니다.

sub = df[df["age"] >= 20]
sub["grade"] = "adult"  # SettingWithCopyWarning 가능

여기서 의도가 둘 중 무엇인지가 중요합니다.

의도 A: 원본 dfgrade를 채우고 싶다

mask = df["age"] >= 20

df.loc[mask, "grade"] = "adult"

의도 B: sub만 독립적으로 쓰고 싶다

sub = df[df["age"] >= 20].copy()
sub["grade"] = "adult"  # 경고 없이 안전

copy()는 비용이 있지만, “이 객체는 원본과 분리됐다”는 계약을 명시합니다. 데이터 크기가 큰 경우에는 아래 assign 패턴도 고려할 수 있습니다.

assign으로 함수형 스타일 유지하기

체이닝을 유지하고 싶다면 assign이 안전한 대안이 될 수 있습니다. assign은 새 DataFrame을 반환하므로, “원본 수정”이 아니라 “새 결과 생성”이라는 의도가 분명합니다.

sub = (
    df.loc[df["age"] >= 20]
      .assign(grade="adult")
)
  • 원본 df는 유지
  • sub는 새 객체
  • 경고 가능성이 낮고 파이프라인 구성에 유리

inplace=True와 경고의 관계

inplace=True가 경고를 해결해주지 않습니다. 오히려 뷰/복사본 모호성을 숨기고 디버깅을 어렵게 만들 수 있습니다. 예를 들어 sub가 복사본이라면 sub.rename(..., inplace=True)는 원본에 영향이 없고, 기대와 다르게 동작합니다.

권장 방향은 다음 중 하나입니다.

  • 원본을 바꿀 거면: df.loc[...] = ... 같은 명시적 대입
  • 새 객체를 만들 거면: df = df.rename(...) 처럼 반환값을 다시 받기

컬럼 슬라이스 후 대입도 위험하다

컬럼 부분집합을 만든 뒤 수정하는 경우도 마찬가지입니다.

cols = ["age", "score"]
sub = df[cols]
sub["score"] = sub["score"] * 10  # 경고 가능

해결: 원본을 바꿀지, 부분집합을 바꿀지 결정

원본을 바꾸려면:

df.loc[:, "score"] = df["score"] * 10

부분집합만 바꾸려면:

sub = df[cols].copy()
sub["score"] = sub["score"] * 10

안전한 패턴 모음 (치트시트)

1) 조건에 맞는 행의 특정 컬럼 업데이트

df.loc[df["age"] >= 20, "score"] = 0

2) 여러 컬럼을 한 번에 업데이트

mask = df["age"] >= 20

df.loc[mask, ["score", "name"]] = [0, "adult"]

3) 조건부 계산 결과를 넣기

mask = df["age"] >= 20

df.loc[mask, "score"] = df.loc[mask, "score"] / 2

4) 새 컬럼을 조건부로 채우기

import numpy as np

df["grade"] = np.where(df["age"] >= 20, "adult", "child")

np.where는 원본 DataFrame에 컬럼을 한 번에 할당하므로 명확합니다.

5) 그룹 연산 결과를 맞춰 넣기 (정렬/인덱스 정합성 주의)

mean_by_name = df.groupby("name")["score"].transform("mean")
df.loc[:, "score_mean_by_name"] = mean_by_name

transform은 원본 인덱스와 길이를 맞춰주기 때문에 대입이 안전해집니다.

왜 어떤 때는 경고가 안 뜨는데도 위험한가

SettingWithCopyWarning은 “항상” 뜨는 게 아닙니다. 같은 코드라도 내부적으로 뷰가 만들어졌는지, 최적화 경로가 무엇인지에 따라 경고가 생략될 수 있습니다. 그래서 다음이 중요합니다.

  • 경고가 안 떠도 체이닝 인덱싱은 피한다
  • “원본 수정”은 loc 단일 대입으로 고정한다
  • “부분집합 수정”은 .copy()로 고정한다

디버깅: 내가 가진 객체가 뷰인지 복사본인지 힌트 얻기

Pandas가 뷰/복사본을 완전히 명시적으로 알려주지는 않지만, 실무에서 도움이 되는 점검이 있습니다.

  1. 부분집합을 만든 직후에 원본을 바꿔보고 부분집합이 같이 바뀌는지 확인
sub = df[df["age"] >= 20]

before = sub["score"].copy()

df.loc[df["age"] >= 20, "score"] = 999

after = sub["score"]
print(before.equals(after))
  1. 애초에 불확실성을 없애기
  • 원본을 바꿀 목적이면 sub를 만들지 말고 loc로 바로 대입
  • sub가 필요하면 .copy()를 명시

경고를 “끄는” 접근이 위험한 이유

다음처럼 전역 옵션으로 경고를 끄는 글이 종종 있는데, 추천하지 않습니다.

import pandas as pd
pd.options.mode.chained_assignment = None

이건 “모호한 대입”을 해결하는 게 아니라 경고만 숨깁니다. 데이터 파이프라인이 길어질수록, 어느 지점에서 원본이 안 바뀌었는지 추적하기가 매우 어려워집니다.

특히 전처리 결과를 Parquet으로 저장하거나 Arrow 기반 처리로 넘길 때는 스키마/결측/타입이 조용히 달라져 장애로 이어질 수 있습니다. Parquet 스키마 충돌을 겪고 있다면 pyarrow TableInvalid - Parquet 스키마 충돌 해결처럼 후속 단계에서 폭발하기도 합니다.

실전 리팩터링 예시: 위험 코드를 안전 코드로

기존 코드 (경고 + 의도 불명)

# 1) 필터
sub = df[df["country"] == "KR"]

# 2) 파생 컬럼
sub["revenue_krw"] = sub["revenue"] * 1350

# 3) 일부 업데이트
sub[sub["revenue_krw"] < 0]["revenue_krw"] = 0

문제점:

  • sub = df[mask]가 뷰/복사본 불명
  • sub[cond][col] = ... 체이닝 인덱싱

의도 1: 원본 df에 반영하고 싶다

mask_kr = df["country"] == "KR"

df.loc[mask_kr, "revenue_krw"] = df.loc[mask_kr, "revenue"] * 1350

df.loc[mask_kr & (df["revenue_krw"] < 0), "revenue_krw"] = 0

의도 2: KR 데이터셋만 별도로 만들고 싶다

mask_kr = df["country"] == "KR"

sub = df.loc[mask_kr].copy()
sub["revenue_krw"] = sub["revenue"] * 1350
sub.loc[sub["revenue_krw"] < 0, "revenue_krw"] = 0

이렇게 바꾸면:

  • 경고가 사라지고
  • 원본 반영 여부가 명확해지고
  • 테스트가 쉬워집니다

체크리스트: SettingWithCopyWarning을 “완전 해결”하는 습관

  • df[mask][col] = ... 형태를 발견하면 즉시 df.loc[mask, col] = ...로 바꾸기
  • 필터 결과를 별도 변수로 들고 가야 하면 .copy()를 붙여 의도를 고정하기
  • 파이프라인 스타일이면 assign으로 “새 객체 생성”을 명시하기
  • 경고를 전역으로 끄지 말고, 경고가 가리키는 모호한 대입을 제거하기

마무리

SettingWithCopyWarning은 Pandas의 “불친절한 경고”가 아니라, 데이터가 조용히 틀어질 수 있는 지점을 알려주는 안전장치입니다. 해결의 핵심은 단 하나입니다.

  • 원본 수정은 loc 단일 대입
  • 부분집합 수정은 copy()로 분리

이 두 규칙을 팀 컨벤션으로 고정해두면, 경고를 없애는 수준을 넘어 재현 가능한 데이터 처리 코드를 만들 수 있습니다.