Published on

pandas SettingWithCopyWarning 완벽 해결 7가지

Authors

서론

pandas를 쓰다 보면 한 번쯤은 다음 경고를 만나게 됩니다.

> SettingWithCopyWarning: A value is trying to be set on a copy of a slice from a DataFrame

이 경고는 단순히 “시끄러운 경고”가 아니라, 내가 수정했다고 믿는 값이 실제 원본 DataFrame에 반영되지 않을 수 있음을 알려주는 신호입니다. 특히 체이닝 인덱싱(chained indexing)과 슬라이싱 결과가 view(원본을 바라보는 얕은 객체) 인지 copy(복사본) 인지 pandas가 확신하지 못할 때 발생합니다.

이 글에서는 SettingWithCopyWarning의 핵심 원리를 짚고, 현업에서 가장 자주 쓰는 완벽 해결 7가지 패턴을 코드로 정리합니다. (경고를 “끄는” 방법도 다루지만, 최후의 수단으로만 권장합니다.)

참고로, 장애 원인-해결을 체크리스트로 정리하는 스타일이 익숙하다면 OpenAI Responses API 502 Bad Gateway 원인과 해결 같은 글의 접근 방식과 유사하게, 이 글도 “원인 → 안전한 조치” 순서로 진행합니다.


SettingWithCopyWarning이 발생하는 이유(정확한 메커니즘)

핵심은 아래 한 줄입니다.

  • df[조건][컬럼] = 값 같은 체이닝 인덱싱은 중간 결과(df[조건])가 view인지 copy인지 보장되지 않습니다.

즉, 다음 코드는 “될 때도 있고 안 될 때도” 있습니다.

import pandas as pd

df = pd.DataFrame({
    "name": ["a", "b", "c", "d"],
    "score": [10, 60, 30, 80]
})

# 문제 패턴: chained indexing
# df[df["score"] >= 50] 가 view/copy 무엇인지 불명확

df[df["score"] >= 50]["grade"] = "pass"  # SettingWithCopyWarning 가능

이 경고의 무서운 점은 경고가 떠도 결과가 우연히 맞아 보일 수 있다는 것입니다. 하지만 데이터 파이프라인/피처 엔지니어링에서 이런 “우연”은 재현 불가능한 버그로 변합니다.


해결 1) .loc로 단일 단계에서 대입하기(정석)

가장 권장되는 해결책은 .loc[행조건, 열] = 값 형태로 한 번에 대입하는 것입니다.

import pandas as pd

df = pd.DataFrame({
    "name": ["a", "b", "c", "d"],
    "score": [10, 60, 30, 80]
})

mask = df["score"] >= 50

df.loc[mask, "grade"] = "pass"
df.loc[~mask, "grade"] = "fail"

print(df)
  • 장점: 경고 없음, 의도가 명확, 성능도 안정적
  • 팁: 열이 여러 개면 리스트로 지정
cols = ["grade", "flag"]
df.loc[mask, cols] = ["pass", 1]

해결 2) 필터링한 결과를 ‘명시적으로’ .copy()로 만들기

슬라이스 결과를 독립적으로 다룰 목적이라면, 처음부터 복사본임을 선언하세요.

passed = df[df["score"] >= 50].copy()
passed["grade"] = "pass"  # 경고 없음
  • 언제 쓰나: “원본 df는 그대로 두고”, 부분집합을 따로 가공/저장/리포팅할 때
  • 주의: .copy()는 메모리를 사용합니다. 큰 데이터면 필요한 컬럼만 먼저 줄여서 복사하세요.
passed = df.loc[df["score"] >= 50, ["name", "score"]].copy()

해결 3) .assign()으로 체인형 파이프라인을 안전하게 만들기

메서드 체이닝을 즐겨 쓴다면 .assign()이 깔끔합니다. .assign()새 DataFrame을 반환하며, 경고를 피하기 쉽습니다.

import numpy as np

out = (
    df
    .assign(grade=lambda x: np.where(x["score"] >= 50, "pass", "fail"))
)

print(out)
  • 장점: 함수형 파이프라인에 잘 맞음
  • 주의: out은 새 객체이므로 원본 df를 바꾸려면 df = out처럼 재할당하세요.

해결 4) where/mask로 조건부 대입(브로드캐스팅 안전)

특정 컬럼에 조건부로 값을 채우는 경우, .where() 또는 .mask()가 간결하고 안전합니다.

import numpy as np

df = df.copy()
df["grade"] = "fail"

df["grade"] = df["grade"].mask(df["score"] >= 50, "pass")
# 또는
# df["grade"] = df["grade"].where(df["score"] < 50, "pass")

print(df)
  • .mask(cond, value)는 cond가 True인 곳을 value로 바꿈
  • .where(cond, value)는 cond가 False인 곳을 value로 바꿈

해결 5) merge/join으로 “원본에 덧씌우기” 패턴 사용

부분집합에서 계산한 결과를 원본에 안전하게 반영하려면, 인덱스/키를 기준으로 다시 합치는 방식이 강력합니다.

# 예: score>=50인 사람에게 bonus 부여
passed = df.loc[df["score"] >= 50, ["name"]].copy()
passed["bonus"] = 100

# name을 키로 병합
out = df.merge(passed, on="name", how="left")
out["bonus"] = out["bonus"].fillna(0).astype(int)

print(out)
  • 장점: 복잡한 파생 컬럼도 안전하게 합칠 수 있음
  • 주의: 키 중복/조인 카디널리티(1:1, 1:N)를 반드시 확인

해결 6) 그룹 연산은 transform/agg 결과를 .loc로 대입

groupby() 이후 일부 행만 수정하려고 하면 경고를 유발하기 쉽습니다. 그룹 기반 파생값은 transform으로 원본 길이에 맞춰 만든 뒤 .loc로 대입하세요.

df = pd.DataFrame({
    "team": ["A", "A", "B", "B"],
    "score": [10, 60, 30, 80]
})

# 팀 평균을 각 행에 맞춰 확장
team_mean = df.groupby("team")["score"].transform("mean")

df.loc[:, "team_mean"] = team_mean
# 팀 평균 이상이면 1

df.loc[:, "above_mean"] = (df["score"] >= df["team_mean"]).astype(int)

print(df)
  • 포인트: transform은 결과 길이가 원본과 같아서 대입이 안전

해결 7) (최후의 수단) 경고를 끄지 말고, 원인을 추적하거나 옵션을 제한적으로 사용

가끔 레거시 코드/노트북에서 경고가 너무 많이 떠서 “일단 끄고 싶다”는 요구가 있습니다. 하지만 SettingWithCopyWarning은 실제 버그를 가릴 수 있으므로 비추천입니다.

그래도 불가피하다면, 최소한 다음 원칙을 지키세요.

  1. 경고를 끄기 전에, 문제 라인을 .loc 또는 .copy()로 고친다.
  2. 정말 외부 라이브러리 내부에서만 발생한다면, 컨텍스트 범위를 좁혀서 무시한다.
import warnings
import pandas as pd

with warnings.catch_warnings():
    warnings.simplefilter("ignore", category=pd.errors.SettingWithCopyWarning)
    # 외부 함수 호출 등, 통제 불가능한 구간만 감싸기
    # external_library.do_something(df)

또는 전역 옵션(권장하지 않음):

pd.options.mode.chained_assignment = None  # 'warn'(기본), 'raise', None
  • 디버깅 팁: 개발/테스트 환경에서는 오히려 raise로 올려서 조기 발견하는 것도 좋습니다.
pd.options.mode.chained_assignment = "raise"

자주 터지는 패턴과 “안전한 대체” 빠른 치트시트

1) df[cond][col] = valuedf.loc[cond, col] = value

# bad
# df[df["score"] >= 50]["grade"] = "pass"

# good
mask = df["score"] >= 50
df.loc[mask, "grade"] = "pass"

2) 필터 결과를 수정하고 원본도 바뀌길 기대 → 처음부터 원본에 .loc

subset = df[df["score"] >= 50]
subset["x"] = 1  # subset만 바뀔 수도

# 원본을 바꾸려면
mask = df["score"] >= 50
df.loc[mask, "x"] = 1

3) “부분집합을 따로 저장/가공” 목적 → .copy()

report = df.loc[df["score"] >= 50].copy()
report["tag"] = "report"

디버깅 체크리스트(경고가 떴을 때 3분 진단)

  1. 대입 라인이 체이닝 인덱싱인지 확인: ][ 형태가 보이면 거의 확정
  2. 원본을 바꾸려는지, 복사본을 만들려는지 의도를 결정
    • 원본 변경: .loc[...] = ...
    • 복사본 작업: .copy() 후 수정
  3. 인덱스 정합성 확인: 마스크(mask)가 원본 df와 같은 인덱스를 기준으로 만들어졌는가?
  4. 함수형 파이프라인이면 .assign() 고려
  5. groupby 결과는 transform으로 길이 맞추기

이런 식의 “원인-증상-해결” 접근은 인프라/런타임 트러블슈팅에서도 동일하게 통합니다. 예를 들어 EKS Pod→RDS 504 타임아웃 - SG·NACL·NAT 10분 진단처럼, 증상(504)만 보고 우회(타임아웃 증가)하기보다 근본 원인(SG/NACL/NAT)을 먼저 잡는 방식이죠. SettingWithCopyWarning도 “경고 끄기”가 아니라 “뷰/복사 불명확성 제거”가 정답입니다.


결론

SettingWithCopyWarning을 완벽히 해결하는 핵심은 단 하나입니다.

  • 대입은 항상 단일 단계로(= .loc)
  • 부분집합을 독립적으로 다룰 땐 .copy()로 의도를 명시

여기에 .assign, mask/where, merge/join, transform까지 익혀두면 대부분의 데이터 전처리 파이프라인에서 경고 없이, 그리고 무엇보다 예측 가능한 결과를 얻을 수 있습니다.

원한다면, 실제로 자주 등장하는 “전처리 함수 템플릿(입력 df를 변경하지 않는 순수 함수형 스타일)”로 SettingWithCopyWarning을 원천 차단하는 구조도 예제로 확장해드릴 수 있습니다.