- Published on
pandas SettingWithCopyWarning 완전 해결 7가지
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서브셋 DataFrame에 값을 넣었는데 경고가 뜨고, 어떤 때는 값이 반영되고 어떤 때는 반영되지 않는 경험이 있다면 SettingWithCopyWarning을 이미 만난 겁니다. 이 경고는 단순히 “시끄러운 로그”가 아니라, 내가 수정했다고 믿는 데이터가 실제로는 수정되지 않을 수 있다는 신호입니다.
핵심은 하나입니다.
df[mask]["col"] = value같은 체이닝 인덱싱은 뷰(view)인지 복사(copy)인지 pandas가 확신하지 못한다- 그 결과, 할당이 원본에 반영될 수도 있고, 임시 객체에만 반영될 수도 있다
아래 7가지 패턴은 “경고를 끄는 요령”이 아니라, 할당의 목적지(원본 vs 복사본)를 명시적으로 고정하는 방법입니다.
참고로 이런 문제는 성격상 재현이 어렵고 환경에 따라 달라져서, 운영에서 더 위험합니다. 비슷한 유형의 “원인 불명 + 환경 의존 + 재현 어려움” 문제를 다룬 글로는 GitHub Actions 캐시 미적중 원인 - key·restore-keys·락파일 같은 사례도 참고할 만합니다.
SettingWithCopyWarning이 뜨는 전형적인 코드
아래는 가장 흔한 형태입니다.
import pandas as pd
df = pd.DataFrame({
"user": ["a", "b", "c", "d"],
"score": [10, 20, 30, 40],
"tier": ["bronze", "silver", "silver", "gold"],
})
# 체이닝 인덱싱: df[mask]가 뷰인지 복사인지 불명확
mask = df["tier"].isin(["silver", "gold"])
df[mask]["score"] = df[mask]["score"] + 100
이 코드는 경고가 뜨는 것뿐 아니라, 원본 df가 실제로 업데이트되지 않을 가능성이 있습니다.
이제부터는 같은 의도를 안전하게 표현하는 7가지 패턴을 소개합니다.
패턴 1. .loc[row_selector, col_selector]로 단일 단계 할당
가장 정석이자, 팀 컨벤션으로 강제하기 좋은 방식입니다.
mask = df["tier"].isin(["silver", "gold"])
df.loc[mask, "score"] = df.loc[mask, "score"] + 100
- 장점: 가장 명확하고 pandas 문서/커뮤니티에서 표준으로 취급
- 포인트:
row와col을 한 번에 지정해야 합니다
.loc를 쓰면 무조건 안전한가
대부분의 실무 상황에서 안전합니다. 다만 df 자체가 이미 어떤 서브셋에서 파생된 객체라면(예: df2 = df[mask]) 이후 할당의 의미가 달라질 수 있으니, 그 경우는 아래 패턴 2, 3을 참고하세요.
패턴 2. 서브셋을 새 객체로 쓸 거면 .copy()로 의도를 고정
서브셋을 “원본의 일부를 편집해서 원본에 반영”하려는 게 아니라, “필터된 데이터셋을 별도로 가공”하려는 경우가 많습니다. 이때는 아예 복사본임을 선언하세요.
silver = df.loc[df["tier"].eq("silver")].copy()
# 이제는 silver만 바뀌는 것이 정상이며, 경고도 없어야 함
silver["score"] = silver["score"] + 100
- 장점: 의도가 분명해져서 코드 리뷰가 쉬움
- 단점: 대용량 데이터에서는 메모리 비용이 커질 수 있음
대용량에서 비용이 걱정되면, “원본을 바꿀 것인지 / 파생 데이터셋을 만들 것인지”를 먼저 결정한 뒤, 원본을 바꿀 때는 패턴 1 또는 4를 사용하세요.
패턴 3. 파생 컬럼을 만들 때는 .assign()으로 체이닝을 안전하게
메서드 체이닝 스타일을 선호한다면 .assign()이 유용합니다. .assign()은 새 DataFrame을 반환하므로 “복사본을 만드는 파이프라인”으로 의도가 고정됩니다.
result = (
df
.loc[df["tier"].ne("bronze")]
.assign(score=lambda x: x["score"] + 100)
)
- 장점: 파이프라인이 깔끔하고, 중간 상태에서의 모호한 할당을 피함
- 단점: 원본을 직접 수정하는 패턴이 아니라 새 객체를 만드는 방식
패턴 4. 조건부 할당은 .where() 또는 np.where()로 벡터화
조건에 따라 값이 달라지는 컬럼을 만들거나 갱신할 때, .loc도 좋지만 .where()가 더 선언적으로 읽힐 때가 있습니다.
import numpy as np
mask = df["tier"].eq("gold")
df["bonus"] = np.where(mask, 50, 0)
기존 컬럼을 조건부로 갱신하려면 .where()가 특히 자연스럽습니다.
mask = df["tier"].isin(["silver", "gold"])
df["score"] = df["score"].where(~mask, df["score"] + 100)
- 장점: 브랜치 로직을 벡터화로 표현
- 주의: 조건식이 복잡해질수록 가독성이 떨어질 수 있음
패턴 5. 그룹 단위 계산은 transform로 원본 인덱스 정렬을 보장
그룹별 통계로 컬럼을 갱신할 때 groupby().apply() 결과를 억지로 맞춰 넣다가 경고나 정렬 문제를 만나는 경우가 많습니다. 이때는 transform을 우선 고려하세요.
# tier별 평균 대비 편차 컬럼
mean_by_tier = df.groupby("tier")["score"].transform("mean")
df["score_delta"] = df["score"] - mean_by_tier
- 장점: 결과가 원본과 동일한 인덱스를 유지하므로 안전하게 대입 가능
- 팁: “그룹별로 같은 길이로 돌아와야 하는 계산”이면
transform이 정답인 경우가 많음
패턴 6. update로 특정 컬럼만 원본에 병합 반영
필터된 데이터프레임에서 계산한 값을 원본에 반영하고 싶지만, .loc로 조건을 다시 쓰기 복잡할 때가 있습니다. 이럴 때는 인덱스를 키로 삼아 update를 사용할 수 있습니다.
# gold만 따로 계산
sub = df.loc[df["tier"].eq("gold"), ["score"]].copy()
sub["score"] = sub["score"] * 2
# 인덱스 기준으로 원본에 반영
# (같은 컬럼명일 때만 업데이트됨)
df.update(sub)
- 장점: “부분 집합에서 계산 후 원본에 머지”를 간단히 표현
- 주의:
update는 기본적으로NaN값 처리 규칙이 있으므로 문서 옵션을 확인하고 사용하세요
패턴 7. 인덱싱을 단일화: mask와 col을 변수로 분리해도 체이닝 금지
체이닝 인덱싱은 코드가 길어질수록 더 자주 발생합니다. 특히 컬럼명이 변수로 들어가면 실수하기 쉽습니다.
나쁜 예는 다음과 같습니다.
col = "score"
mask = df["tier"].ne("bronze")
# 체이닝 인덱싱
# df[mask]가 먼저 평가되고, 그 결과에 다시 [col]을 적용
# 그 다음 대입이 일어나면서 경고 가능
df[mask][col] = 0
좋은 예는 항상 .loc로 단일화하는 것입니다.
col = "score"
mask = df["tier"].ne("bronze")
df.loc[mask, col] = 0
이 패턴은 팀 단위로 정적 분석 규칙처럼 적용하기 좋습니다. “필터 후 컬럼 선택 후 대입”이라는 3단계를 하나로 합쳐 오류 가능성을 제거합니다.
실전 디버깅: 정말 원본이 바뀌었는지 확인하는 최소 체크
경고가 없더라도, 데이터가 기대대로 반영됐는지 확인하는 습관이 중요합니다.
before = df.copy()
mask = df["tier"].isin(["silver", "gold"])
df.loc[mask, "score"] = df.loc[mask, "score"] + 100
changed = df["score"].ne(before["score"])
print("changed rows:", changed.sum())
print(df.loc[changed, ["user", "tier", "score"]])
이런 “변경 행 수 확인”은 운영 데이터 파이프라인에서 회귀를 잡는 데 도움이 됩니다. 성격은 다르지만, 장애 원인을 체크리스트로 좁혀가는 접근은 Kubernetes CrashLoopBackOff 12가지 원인·해결 같은 글에서 다루는 방식과도 통합니다.
경고를 끄는 건 해결이 아니다
검색하면 아래 설정이 종종 나오지만, 권장하지 않습니다.
import pandas as pd
pd.options.mode.chained_assignment = None
- 이 설정은 경고만 숨깁니다
- “원본이 바뀌지 않는 버그”를 조기에 발견할 기회를 없앱니다
경고를 끄는 대신, 위 7가지 패턴 중 상황에 맞는 것을 선택해 의도를 코드로 고정하세요.
어떤 패턴을 언제 쓰면 좋은가: 선택 가이드
- 원본 DataFrame을 조건으로 갱신한다: 패턴 1, 4, 7
- 필터된 데이터셋을 별도 가공 결과로 쓴다: 패턴 2, 3
- 그룹 단위 통계/정규화 값을 컬럼으로 붙인다: 패턴 5
- 부분 집합에서 계산한 결과를 원본에 병합 반영한다: 패턴 6
마무리
SettingWithCopyWarning은 pandas의 “불친절한 경고”가 아니라, 데이터 파이프라인에서 가장 위험한 유형의 침묵 버그를 막기 위한 안전장치입니다.
정리하면 다음 두 가지 규칙만 지켜도 대부분의 문제는 사라집니다.
- 대입은 체이닝하지 말고
.loc[rows, cols]로 한 번에 한다 - 서브셋을 독립 객체로 다룰 거면
.copy()또는.assign()으로 의도를 명확히 한다
이 원칙 위에, 조건부/그룹/병합 반영 같은 상황별 패턴을 조합하면 경고를 “없애는” 수준이 아니라, 결과를 “보장”하는 코드로 바꿀 수 있습니다.