# PDEP-7: Consistent copy/view semantics in pandas with Copy-on-Write

- Created: July 2021
- Status: Implemented
- Discussion: [#36195](https://github.com/pandas-dev/pandas/issues/36195)
- Author: [Joris Van den Bossche](https://github.com/jorisvandenbossche)
- Revision: 1

[TOC]

## Abstract

Short summary of the proposal:

1. The result of _any_ indexing operation (subsetting a DataFrame or Series in any way,
   i.e. including accessing a DataFrame column as a Series) or any method returning a
   new DataFrame or Series, always _behaves as if_ it were a copy in terms of user
   API.
2. We implement Copy-on-Write (as implementation detail). This way, we can actually use
   views as much as possible under the hood, while ensuring the user API behaves as a
   copy.
3. As a consequence, if you want to modify an object (DataFrame or Series), the only way
   to do this is to directly modify that object itself .

This addresses multiple aspects: 1) a clear and consistent user API (a clear rule: _any_
subset or returned series/dataframe **always** behaves as a copy of the original, and
thus never modifies the original) and 2) improving performance by avoiding excessive
copies (e.g. a chained method workflow would no longer return an actual data copy at each
step).

Because every single indexing step behaves as a copy, this also means that with this
proposal, "chained assignment" (with multiple setitem steps) will _never_ work and
the `SettingWithCopyWarning` can be removed.

## Background

pandas' current behavior on whether indexing returns a view or copy is confusing. Even
for experienced users, it's hard to tell whether a view or copy will be returned (see
below for a summary). We'd like to provide an API that is consistent and sensible about
returning views vs. copies.

We also care about performance. Returning views from indexing operations is faster and
reduces memory usage. The same is true for several methods that don't modify the data
such as setting/resetting the index, renaming columns, etc. that can be used in a method
chaining workflow and currently return a new copy at each step.

Finally, there are API / usability issues around views. It can be challenging to know
the user's intent in operations that modify a subset of a DataFrame (column and/or row
selection), like:

```python
>>> df = pd.DataFrame({"A": [1, 2], "B": [3, 4], "C": [5, 6]})
>>> df2 = df[["A", "B"]]
>>> df2.loc[df2["A"] > 1, "A"] = 1
```

Did the user intend to modify `df` when they modified `df2` (setting aside issues with
the current implementation)? In other words, if we had a perfectly consistent world
where indexing the columns always returned views or always returned a copy, does the
code above imply that the user wants to mutate `df`?

There are two possible behaviours the user might intend:

1. Case 1: I know my subset might be a view of the original and I want to modify the
   original as well.
2. Case 2: I just want to modify the subset without modifying the original.

Today, pandas' inconsistency means _neither_ of these workflows is really possible. The
first is difficult, because indexing operations often (though not always) return copies,
and even when a view is returned you sometimes get a `SettingWithCopyWarning` when
mutating. The second is somewhat possible, but requires many defensive copies (to avoid
`SettingWithCopyWarning`, or to ensure that you have a copy when a view _was_ returned).

## Proposal

For these reasons (consistency, performance, code clarity), this PDEP proposes the
following changes:

1. The result of _any_ indexing operation (subsetting a DataFrame or Series in any way,
   i.e. including accessing a DataFrame column as a Series) or any method returning a
   new DataFrame or Series, always _behaves as if_ it were a copy in terms of user
   API.
2. We implement Copy-on-Write. This way, we can actually use views as much as possible
   under the hood, while ensuring the user API behaves as a copy.

The intent is to capture the performance benefits of views as much as possible, while
providing consistent and clear behaviour to the user. This essentially makes returning
views an internal optimization, without the user needing to know if the specific
indexing operation would return a view or a copy. The new rule would be simple: any
series/dataframe derived from another series/dataframe, through an indexing operation or
a method, always behaves as a copy of the original series/dataframe.

The mechanism to ensure this consistent behaviour, Copy-on-Write, would entail the
following: the setitem operation (i.e. `df[..] = ..` or `df.loc[..] = ..` or
`df.iloc[..] = ..`, or equivalent for Series) would check if the data that is being
modified is a view on another dataframe (or is being viewed by another dataframe). If it
is, then we would copy the data before mutating.

Taking the example from above, if the user wishes to not mutate the parent, we no longer
require a defensive copy just to avoid a `SettingWithCopyWarning`.

```python
# Case 2: The user does not want mutating df2 to mutate the parent df, via CoW
>>> df = pd.DataFrame({"A": [1, 2], "B": [3, 4], "C": [5, 6]})
>>> df2 = df[["A", "B"]]
>>> df2.loc[df2["A"] > 1, "A"] = 1
>>> df.iloc[1, 0]  # df was not mutated
2
```

On the other hand, if the user actually wants to modify the original df, they can no
longer rely on the fact that `df2` could be a view, as mutating a subset would now never
mutate the parent. The only way to modify the original df is by combining all indexing
steps in a single indexing operation on the original (no "chained" setitem):

```python
# Case 1: user wants mutations of df2 to be reflected in df -> no longer possible
>>> df = pd.DataFrame({"A": [1, 2], "B": [3, 4], "C": [5, 6]})
>>> df2 = df[["A", "B"]]
>>> df2.loc[df2["A"] > 1, "A"] = 1  # mutating df2 will not mutate df
>>> df.loc[df["A"] > 1, "A"] = 1  # need to directly mutate df instead
```

### This proposal also extends to methods

In principle, there's nothing special about indexing when it comes to defensive copying.
_Any_ method that returns a new series/dataframe without altering existing data (rename,
set_index, assign, dropping columns, etc.) currently returns a copy by default and is a
candidate for returning a view:

```python
>>> df2 = df.rename(columns=str.lower)
>>> df3 = df2.set_index("a")
```

Now, generally, pandas users won't expect `df2` or `df3` to be a view such that mutating
`df2` or `df3` would mutate `df`. Copy-on-Write allows us to also avoid
unnecessary copies in methods such as the above (or in the variant using method chaining
like `df.rename(columns=str.lower).set_index("a")`).

### Propagating mutation forwards

Thus far we have considered the (more common) case of taking a subset, mutating the
subset, and how that should affect the parent. What about the other direction, where the
parent is mutated?

```python
>>> df = pd.DataFrame({"A": [1, 2], "B": [3, 4]})
>>> df2 = df[["A"]]
>>> df.iloc[0, 0] = 10
>>> df2.iloc[0, 0]  # what is this value?
```

Given that `df2` is _considered_ as a copy of df under this proposal (i.e. behaves as a
copy), also mutating the parent `df` will not mutate the subset `df2`.

### When do mutations propagate to other objects and when not?

This proposal basically means that mutations _never_ propagate to _other_ objects (as
would happen with views). The only way to modify a DataFrame or Series is to modify the
object itself directly.

But let's illustrate this in Python terms. Consider that we have a DataFrame `df1`, and we
assign that to another name `df2`:

```python
>>> df1 = pd.DataFrame({"A": [1, 2], "B": [3, 4]})
>>> df2 = df1
```

Although we have now two variables (`df1` and `df2`), this assignment follows the standard
python semantics, and both names are pointing to the same object ("df1 and df2 are
_identical_"):

```python
>>> id(df1) == id(df2)  # or: df1 is df2
True
```

Thus, if you modify DataFrame `df2`, this is also reflected in the other variable `df1`, and
the other way around (since it's the same object):

```python
>>> df1.iloc[0, 0]
1
>>> df2.iloc[0, 0] = 10
>>> df1.iloc[0, 0]
10
```

In summary, modifications are only "propagated" between _identical_ objects (not just
equal (`==`), but identical (`is`) in python terms, see
[docs](https://docs.python.org/3/reference/expressions.html#is)). Propagation is not
really the proper term, since there is only one object that was modified.

However, when in some way creating a new object (even though it might be a DataFrame
with the same data, and thus be an "equal" DataFrame):

```python
>>> df1 = pd.DataFrame({"A": [1, 2], "B": [3, 4]})
>>> df2 = df1[:]  # or df1.loc[...] with some indexer
```

Those objects are no longer identical:

```python
>>> id(df1) == id(df2)  # or df1 is df2
False
```

And thus modifications to one will not propagate to the other:

```python
>>> df1.iloc[0, 0]
1
>>> df2.iloc[0, 0] = 10
>>> df1.iloc[0, 0]  # not changed
1
```

Currently, any getitem indexing operation returns _new_ objects, and also almost all
DataFrame/Series methods return a _new_ object (except with `inplace=True` in some
cases), and thus follow the above logic of never modifying its parent/child DataFrame or
Series (using the lazy Copy-on-Write mechanism where possible).

## Copy / view behaviour in NumPy versus pandas

NumPy has the concept of "views" (an array that shares data with another array, viewing
the same memory, see e.g.
[this explanation](https://scipy-cookbook.readthedocs.io/items/ViewsVsCopies.html) for
more details). Typically you create views as a slice of another array. But other
indexing methods, often called "fancy indexing", do not return views but copies: using a
list of indices or a boolean mask.

Pandas, being built on NumPy, uses those concepts, and also exposes the behaviour
consequences to its users. This basically means that pandas users, to understand the
details of how indexing works, also need to understand those view / fancy indexing
concepts of numpy.

However, because DataFrames are not an array, the copy/view rules still differ from
NumPy's rules with current pandas. Slicing rows generally gives a view (following
NumPy), but slicing columns doesn't always give a view (this could be changed to match
NumPy however, see "Alternatives" 1b below). Fancy indexing rows (e.g. with a list of
(positional) labels) gives a copy, but fancy indexing columns _could_ give a view
(currently this gives a copy as well, but one of the "Alternatives" (1b) is to have this
always return a view).

The proposal in this document is to decouple the pandas user-facing behaviour from those
NumPy concepts. Creating a subset of a DataFrame with a slice or with a mask would
behave in a similar way for the user (both return a new object and behave as a copy of
the original). We still use the concept of views internally in pandas to optimize the
implementation, but this becomes hidden from the user.

## Alternatives

The [original document](https://docs.google.com/document/d/1csGE4qigPR2vzmU2--jwURn3sK5k5eVewinxd8OUPk0/edit) and GitHub issue ([Proposal for future copy / view semantics in indexing operations - #36195](https://github.com/pandas-dev/pandas/issues/36195)) discussed several options for making the copy/view situation more consistent and clear:

1. **Well-Defined copy/view rules:** ensure we have more consistent rules about which
   operations result in a copy and which in a view, and then views result in mutating
   the parent, copies not.
   a. A minimal change would be to officialize the current behaviour. This comes down to
      fixing some bugs and clearly documenting and testing which operations are views,
      and which are copies.
   b. An alternative would be to simplify the set of rules. For example: selecting
      columns is always a view, subsetting rows is always a copy. Or: selecting columns
      is always a view, subsetting rows as a slice is a view otherwise always a copy.

2. **Copy-on-Write**: The setitem operation would check if it's a view on another
   dataframe. If it is, then we would copy our data before mutating. (i.e. this
   proposal)

3. **Error-on-Write**: The setitem operation would check if it's a subset of another
   dataframe (both view of copy). Only rather than copying in case of a view we would
   raise an exception telling the user to either copy the data with
   ``.copy_if_needed()`` (name TBD) or mark the frame as "a mutable view" with
   ``.as_mutable_view()`` (name TBD).

This document basically proposes an extended version of option 2 (Copy-on-Write). Some
arguments in favor of Copy-on-Write compared to the other options:

* Copy-on-Write will improve the copy/view efficiency of _methods_ (e.g. rename,
  (re)set_index, drop columns, etc. See section above). This will result in
  lower memory usage and better performance.

* This proposal can also be seen as a clear "well-defined rule". Using Copy-on-Write
  under the hood is an implementation detail to delay the actual copy until it is
  needed. The rule of "always copy" is the simplest "well-defined rule" we can get.

  Other "well-defined rule" ideas above would always include some specific cases (and
  deviations from the NumPy rules). And even with clear rules a user still needs to know
  the details of those rules to understand that `df['a'][df['b'] < 0] = 0` or
  `df[df['b'] < 0]['a'] = 0` does something differently (switched order of column/row
  indexing: the first mutates df (if selecting a column is a view) and the second
  doesn't). While with the "always copy" rule with Copy-on-Write, neither of those
  examples will work to update `df`.

On the other hand, the proposal in this document does not give the user control over
whether a subset should be a view (when possible) that mutates the parent when being
mutated. The only way to modify the parent dataframe is with a direct indexing operation
on this dataframe itself.

See the GitHub comment with some more detailed argumentation:
[https://github.com/pandas-dev/pandas/issues/36195#issuecomment-786654449](https://github.com/pandas-dev/pandas/issues/36195#issuecomment-786654449)

## Disadvantages

Other than the fact that this proposal would result in a backwards incompatible,
breaking change in behaviour (see next section), there are some other potential
disadvantages:

* Deviation from NumPy: NumPy uses the copy and view concepts, while in this proposal
  views would basically not exist anymore in pandas (for the user, at least; we would
  still use it internally as an implementation detail)
  * But as a counter argument: many pandas users are probably not familiar with those
    concepts, and pandas already deviates from the exact rules in NumPy.
* Performance cost of indexing and methods becomes harder to predict: because the copy
  of the data doesn't happen at the moment when actually creating the new object, but
  can happen at a later stage when modifying either the parent or child object, it
  becomes less transparent about when pandas copies data (but in general we should copy
  less often). This is somewhat mitigated because Copy-on-Write will only copy the columns
  that are mutated. Unrelated columns won't get copied.
* Increased memory usage for some use cases: while the majority of use cases will
  see an improvement in memory usage with this proposal, there are a few use
  cases where this might not be the case. Specifically in cases where pandas currently
  does return a view (e.g. slicing rows) and in the case you are fine with (or don't care
  about) the current behaviour of it being a view when mutating that subset (i.e.
  mutating the sliced subset also mutates the parent dataframe), in such a case the
  proposal would introduce a new copy compared to the current behaviour. There is a
  workaround for this though: the copy is not needed if the previous object goes out
  of scope, e.g. the variable is reassigned to something else.

## Backward compatibility

The proposal in this document is clearly a backwards incompatible change that breaks
existing behaviour. Because of the current inconsistencies and subtleties around views
vs. copies and mutation, it would be difficult to change anything without breaking
changes. The current proposal is not the proposal with the minimal changes, though. A
change like this will in any case need to be accompanied with a major version bump (for
example pandas 3.0).

Doing a traditional deprecation cycle that lives in several minor feature releases will
be too noisy. Indexing is too common an operation to include a warning (even if we limit
it to just those operations that previously returned views). However, this proposal is
already implemented and thus available. Users can opt-in and test their code (this is
possible starting with version 1.5 with `pd.options.mode.copy_on_write = True`).

Further we will add a warning mode for pandas 2.2 that raises warnings for all cases that
will change behaviour under the Copy-on-Write proposal. We can
provide a clearly documented upgrade path to first enable the warnings, fix all
warnings, and then enable the Copy-on-Write mode and ensure your code is still working,
and then finally upgrade to the new major release.

## Implementation

The implementation is available since pandas 1.5 (and significantly improved starting
with pandas 2.0). It uses weakrefs to keep track of whether the
data of a Dataframe/Series are viewing the data of another (pandas) object or are being
viewed by another object. This way, whenever the series/dataframe gets modified, we can
check if its data first needs to be copied before mutating it
(see [here](https://pandas.pydata.org/docs/development/copy_on_write.html)).

To test the implementation and experiment with the new behaviour, you can
enable it with the following option:

```python
>>> pd.options.mode.copy_on_write = True
```

after importing pandas (or setting the `PANDAS_COPY_ON_WRITE=1` environment variable
before importing pandas).

## Concrete examples

### Chained assignment

Consider a "classic" case of chained indexing, which was the original motivation for the SettingWithCopy warning:

```python
>>> df[df['B'] > 3]['B'] = 10
```

That is roughly equivalent to

```python
>>> df2 = df[df['B'] > 3]  # Copy under NumPy's rules
>>> df2['B'] = 10  # Update (the copy) df2, df not changed
>>> del df2  # All references to df2 are lost, goes out of scope
```

And so `df` is not modified. For this reason, the SettingWithCopyWarning was introduced.

_With this proposal_, any result of an indexing operation behaves as a copy
(Copy-on-Write), and thus chained assignment will _never_ work. Given that there is then
no ambiguity, the idea is to drop the warning.

The above example is a case where chained assignment doesn't work with current pandas.
But there are of course also patterns with chained assignment that currently _do_ work
and are used. _With this proposal_, any chained assignment will not work, and so those
cases will stop working (e.g. the case above but switching the order):

```python
>>> df['B'][df['B'] > 3] = 10
# or
>>> df['B'][0:5] = 10
```

These cases will raise a warning ``ChainedAssignmentError``, because they can never
accomplish what the user intended. There will be false-positive cases when these
operations are triggered from Cython, because Cython uses a different reference counting
mechanism. These cases should be rare, since calling pandas code from Cython does not
have any performance benefits.

### Filtered dataframe

A typical example where the current SettingWithCopyWarning becomes annoying is when
filtering a DataFrame (which always already returns a copy):

```python
>>> df = pd.DataFrame({"A": [1, 2], "B": [3, 4], "C": [5, 6]})
>>> df_filtered = df[df["A"] > 1]
>>> df_filtered["new_column"] = 1
SettingWithCopyWarning:
A value is trying to be set on a copy of a slice from a DataFrame.
Try using .loc[row_indexer,col_indexer] = value instead
```

If you then modify your filtered dataframe (e.g. adding a column), you get the
unnecessary SettingWithCopyWarning (with confusing message). The only way to get rid of
the warning is by doing a defensive copy (`df_filtered = df[df["A"] > 1].copy()`, which
results in copying the data twice in the current implementation, Copy-on-Write would
not require ``.copy()`` anymore).

_With this proposal_, the filtered dataframe is never a view and the above
workflow would work as expected without warning (and thus without needing the extra
copy).

### Modifying a Series (from DataFrame column)

_Currently_, accessing a column of a DataFrame as a Series is one of the few cases that
is actually guaranteed to always be a view:

```python
>>> df = pd.DataFrame({"A": [1, 2], "B": [3, 4], "C": [5, 6]})
>>> s = df["A"]
>>> s.loc[0] = 0   # will also modify df (but no longer with this proposal)
```

_With this proposal_, any indexing operation results in a copy, so also accessing a
column as a Series (in practice, it will still be a view of course, but behave as a copy
through Copy-on-Write). In the above example, mutating `s` will no longer modify the
parent `df`.

This situation is similar as the "chained assignment" case above, except with
an explicit intermediate variable. To actually change the original DataFrame,
the solution is the same: mutate directly the DataFrame in a single step.
For example:

```python
>>> df.loc[0, "A"] = 0
```

### "Shallow" copies

_Currently_, it is possible to create a "shallow" copy of a DataFrame with
`copy(deep=False)`. This creates a new DataFrame object but without copying the
underlying index and data. Any changes to the data of the original will be reflected in
the shallow copy (and vice versa). See the
[docs](https://pandas.pydata.org/pandas-docs/version/1.5/reference/api/pandas.DataFrame.copy.html).

```python
>>> df = pd.DataFrame({"A": [1, 2], "B": [3, 4], "C": [5, 6]})
>>> df2 = df.copy(deep=False)
>>> df2.iloc[0, 0] = 0   # will also modify df (but no longer with this proposal)
```

_With this proposal_, this kind of shallow copy is no longer possible. Only "identical"
objects (in Python terms: `df2 is df`) can share data without triggering Copy-on-Write.
A shallow copy will rather become a "delayed" copy through Copy-on-Write.

See
[#36195 (comment)](https://github.com/pandas-dev/pandas/issues/36195#issuecomment-830579242)
for a more detailed comment on this.

### Methods returning a new DataFrame with the same data

This example is already shown above as well, but so _currently_ almost all methods on a
Series/DataFrame by default return a new object that is a copy of the original data:

```python
>>> df2 = df.rename(columns=str.lower)
>>> df3 = df2.set_index("a")
```

In the above example, df2 holds a copy of the data of df, and df3 holds a copy of the
data of df2. Mutating any of those DataFrames would not modify the parent dataframe.

_With this proposal_, those methods would continue to return new objects, but would use
the shallow copy mechanism with Copy-on-Write so that in practice, those methods don't
need to copy the data at each step, while preserving the current behaviour.

### Series and DataFrame constructors

_Currently_, the Series and DataFrame constructors don't always copy the input
(depending on the type of the input). For example:

```python
>>> s = pd.Series([1, 2, 3])
>>> s2 = pd.Series(s)
>>> s2.iloc[0] = 0   # will also modify the parent Series s
>>> s
0	0  # <-- modified
1	2
2	3
dtype: int64
```

_With this proposal_, we can also use the shallow copy with Copy-on-Write approach _by
default_ in the constructors. This would mean that by default, a new Series or DataFrame
(like `s2` in the above example) would not modify the data from which it is being
constructed (when being modified itself), honoring the proposed rules.

## More background: Current behaviour of views vs copy

To the best of our knowledge, indexing operations currently return views in the
following cases:

* Selecting a single column (as a Series) out of a DataFrame is always a view
  (``df['a']``)
* Slicing columns from a DataFrame creating a subset DataFrame (``df[['a':'b']]`` or
  ``df.loc[:, 'a': 'b']``) is a view _if_ the original DataFrame consists of a
  single block (single dtype, consolidated) and _if_ you are slicing (so not a list
  selection). In all other cases, getting a subset is always a copy.
* Selecting rows _can_ return a view, when the row indexer is a `slice` object.

Remaining operations (subsetting rows with a list indexer or boolean mask) in practice
return a copy, and we will raise a SettingWithCopyWarning when the user tries to modify
the subset.

## More background: Previous attempts

We've discussed this general issue before. [https://github.com/pandas-dev/pandas/issues/10954](https://github.com/pandas-dev/pandas/issues/10954) and a few pull requests ([https://github.com/pandas-dev/pandas/pull/12036](https://github.com/pandas-dev/pandas/pull/12036), [https://github.com/pandas-dev/pandas/pull/11207](https://github.com/pandas-dev/pandas/pull/11207), [https://github.com/pandas-dev/pandas/pull/11500](https://github.com/pandas-dev/pandas/pull/11500)).

## Comparison with other languages / libraries

### R

For the user, R has somewhat similar behaviour. Most R objects can be considered
immutable, through "copy-on-modify"
([https://adv-r.hadley.nz/names-values.html#copy-on-modify](https://adv-r.hadley.nz/names-values.html#copy-on-modify)).
But in contrast to Python, in R this is a language feature, and any assignment (binding
a variable to a new name) or passing as function argument will essentially create a
"copy" (when mutating such an object, at that point the actual data get copied and
rebind to the name):

```r
x <- c(1, 2, 3)
y <- x
y[[1]] <- 10  # does not modify x
```

While if you would do the above example in Python with a list, x and y are "identical"
and mutating one will also mutate the other.

As a consequence of this language behaviour, modifying a `data.frame` will not modify
other data.frames that might share memory (before being copied with "copy-on-modify").

### Polars

Polars ([https://github.com/pola-rs/polars](https://github.com/pola-rs/polars)) is a
DataFrame library with a Python interface, mainly written in Rust on top of Arrow. It
explicitly
[mentions](https://pola-rs.github.io/polars-book/user-guide/introduction.html#current-status)
"Copy-on-Write" semantics as one its features.

Based on some experiments, the user-facing behaviour of Polars seems similar to the behaviour
described in this proposal (mutating a DataFrame/Series never mutates a parent/child
object, and so chained assignment also doesn't work)


## PDEP-7 History

- July 2021: Initial version
- February 2023: Converted into a PDEP

Note: this proposal has been discussed before it was turned into a PDEP. The main
discussion happened in [GH-36195](https://github.com/pandas-dev/pandas/issues/36195).
This document is modified from the original document discussing different options for
clear copy/view semantics started by Tom Augspurger
([google doc](https://docs.google.com/document/d/1csGE4qigPR2vzmU2--jwURn3sK5k5eVewinxd8OUPk0/edit)).

Related mailing list discussion: [https://mail.python.org/pipermail/pandas-dev/2021-July/001358.html](https://t.co/vT5dOMhNjV?amp=1)
