Repository: pandas-dev/pandas (python)

Issue:
DEPR: casting in DatetimeLikeArrayMixin.isin
### Pandas version checks

- [X] I have checked that this issue has not already been reported.

- [X] I have confirmed this bug exists on the [latest version](https://pandas.pydata.org/docs/whatsnew/index.html) of pandas.

- [X] I have confirmed this bug exists on the [main branch](https://pandas.pydata.org/docs/dev/getting_started/install.html#installing-the-development-version-of-pandas) of pandas.


### Reproducible Example

```python
NA
```


### Issue Description

DatetimeLikeArrayMixin.isin casts pretty aggressively.  In particular I think we shouldn't be parsing strings here.  We should deprecate and tell users to cast explicitly.

### Expected Behavior

NA

### Installed Versions

<details>

Replace this line with the output of pd.show_versions()

</details>


Pull request:
DEPR: casting in datetimelike isin
- [x] closes #53111 (Replace xxxx with the GitHub issue number)
- [x] [Tests added and passed](https://pandas.pydata.org/pandas-docs/dev/development/contributing_codebase.html#writing-tests) if fixing a bug or adding a new feature
- [ ] All [code checks passed](https://pandas.pydata.org/pandas-docs/dev/development/contributing_codebase.html#pre-commit).
- [ ] Added [type annotations](https://pandas.pydata.org/pandas-docs/dev/development/contributing_codebase.html#type-hints) to new arguments/methods/functions.
- [x] Added an entry in the latest `doc/source/whatsnew/vX.X.X.rst` file if fixing a bug or adding a new feature.


Patch:
diff --git a/doc/source/whatsnew/v2.2.0.rst b/doc/source/whatsnew/v2.2.0.rst
index 7a0075ab88..e51a347dec 100644
--- a/doc/source/whatsnew/v2.2.0.rst
+++ b/doc/source/whatsnew/v2.2.0.rst
@@ -455,6 +455,7 @@ Other Deprecations
 - Deprecated allowing non-keyword arguments in :meth:`DataFrame.to_xml` except ``path_or_buffer``. (:issue:`54229`)
 - Deprecated allowing passing :class:`BlockManager` objects to :class:`DataFrame` or :class:`SingleBlockManager` objects to :class:`Series` (:issue:`52419`)
 - Deprecated behavior of :meth:`Index.insert` with an object-dtype index silently performing type inference on the result, explicitly call ``result.infer_objects(copy=False)`` for the old behavior instead (:issue:`51363`)
+- Deprecated casting non-datetimelike values (mainly strings) in :meth:`Series.isin` and :meth:`Index.isin` with ``datetime64``, ``timedelta64``, and :class:`PeriodDtype` dtypes (:issue:`53111`)
 - Deprecated downcasting behavior in :meth:`Series.where`, :meth:`DataFrame.where`, :meth:`Series.mask`, :meth:`DataFrame.mask`, :meth:`Series.clip`, :meth:`DataFrame.clip`; in a future version these will not infer object-dtype columns to non-object dtype, or all-round floats to integer dtype. Call ``result.infer_objects(copy=False)`` on the result for object inference, or explicitly cast floats to ints. To opt in to the future version, use ``pd.set_option("future.no_silent_downcasting", True)`` (:issue:`53656`)
 - Deprecated including the groups in computations when using :meth:`.DataFrameGroupBy.apply` and :meth:`.DataFrameGroupBy.resample`; pass ``include_groups=False`` to exclude the groups (:issue:`7155`)
 - Deprecated indexing an :class:`Index`  with a boolean indexer of length zero (:issue:`55820`)
@@ -474,6 +475,7 @@ Other Deprecations
 - Deprecated the ``kind`` keyword in :meth:`Series.resample` and :meth:`DataFrame.resample`, explicitly cast the object's ``index`` instead (:issue:`55895`)
 - Deprecated the ``ordinal`` keyword in :class:`PeriodIndex`, use :meth:`PeriodIndex.from_ordinals` instead (:issue:`55960`)
 - Deprecated the ``unit`` keyword in :class:`TimedeltaIndex` construction, use :func:`to_timedelta` instead (:issue:`55499`)
+- Deprecated the behavior of :meth:`DataFrame.replace` and :meth:`Series.replace` with :class:`CategoricalDtype`; in a future version replace will change the values while preserving the categories. To change the categories, use ``ser.cat.rename_categories`` instead (:issue:`55147`)
 - Deprecated the behavior of :meth:`Series.value_counts` and :meth:`Index.value_counts` with object dtype; in a future version these will not perform dtype inference on the resulting :class:`Index`, do ``result.index = result.index.infer_objects()`` to retain the old behavior (:issue:`56161`)
 - Deprecated the default of ``observed=False`` in :meth:`DataFrame.pivot_table`; will be ``True`` in a future version (:issue:`56236`)
 - Deprecated the extension test classes ``BaseNoReduceTests``, ``BaseBooleanReduceTests``, and ``BaseNumericReduceTests``, use ``BaseReduceTests`` instead (:issue:`54663`)
@@ -525,6 +527,7 @@ Datetimelike
 ^^^^^^^^^^^^
 - Bug in :class:`DatetimeIndex` construction when passing both a ``tz`` and either ``dayfirst`` or ``yearfirst`` ignoring dayfirst/yearfirst (:issue:`55813`)
 - Bug in :class:`DatetimeIndex` when passing an object-dtype ndarray of float objects and a ``tz`` incorrectly localizing the result (:issue:`55780`)
+- Bug in :func:`Series.isin` with :class:`DatetimeTZDtype` dtype and comparison values that are all ``NaT`` incorrectly returning all-``False`` even if the series contains ``NaT`` entries (:issue:`56427`)
 - Bug in :func:`concat` raising ``AttributeError`` when concatenating all-NA DataFrame with :class:`DatetimeTZDtype` dtype DataFrame. (:issue:`52093`)
 - Bug in :func:`testing.assert_extension_array_equal` that could use the wrong unit when comparing resolutions (:issue:`55730`)
 - Bug in :func:`to_datetime` and :class:`DatetimeIndex` when passing a list of mixed-string-and-numeric types incorrectly raising (:issue:`55780`)
diff --git a/pandas/_libs/lib.pyx b/pandas/_libs/lib.pyx
index 8493f8bd06..c483f35513 100644
--- a/pandas/_libs/lib.pyx
+++ b/pandas/_libs/lib.pyx
@@ -2756,8 +2756,11 @@ def maybe_convert_objects(ndarray[object] objects,
                     res[:] = NPY_NAT
                     return res
             elif dtype is not None:
-                # EA, we don't expect to get here, but _could_ implement
-                raise NotImplementedError(dtype)
+                # i.e. PeriodDtype, DatetimeTZDtype
+                cls = dtype.construct_array_type()
+                obj = cls._from_sequence([], dtype=dtype)
+                taker = -np.ones((<object>objects).shape, dtype=np.intp)
+                return obj.take(taker, allow_fill=True)
             else:
                 # we don't guess
                 seen.object_ = True
diff --git a/pandas/core/arrays/arrow/array.py b/pandas/core/arrays/arrow/array.py
index 5abdfe69e5..d8b074fe61 100644
--- a/pandas/core/arrays/arrow/array.py
+++ b/pandas/core/arrays/arrow/array.py
@@ -1023,7 +1023,7 @@ class ArrowExtensionArray(
 
         return super().fillna(value=value, method=method, limit=limit, copy=copy)
 
-    def isin(self, values) -> npt.NDArray[np.bool_]:
+    def isin(self, values: ArrayLike) -> npt.NDArray[np.bool_]:
         # short-circuit to return all False array.
         if not len(values):
             return np.zeros(len(self), dtype=bool)
diff --git a/pandas/core/arrays/base.py b/pandas/core/arrays/base.py
index e61e374009..3272a594f4 100644
--- a/pandas/core/arrays/base.py
+++ b/pandas/core/arrays/base.py
@@ -1355,7 +1355,7 @@ class ExtensionArray:
             equal_na = self.isna() & other.isna()  # type: ignore[operator]
             return bool((equal_values | equal_na).all())
 
-    def isin(self, values) -> npt.NDArray[np.bool_]:
+    def isin(self, values: ArrayLike) -> npt.NDArray[np.bool_]:
         """
         Pointwise comparison for set containment in the given values.
 
@@ -1363,7 +1363,7 @@ class ExtensionArray:
 
         Parameters
         ----------
-        values : Sequence
+        values : np.ndarray or ExtensionArray
 
         Returns
         -------
diff --git a/pandas/core/arrays/categorical.py b/pandas/core/arrays/categorical.py
index eec833c600..20aec52b60 100644
--- a/pandas/core/arrays/categorical.py
+++ b/pandas/core/arrays/categorical.py
@@ -2570,7 +2570,7 @@ class Categorical(NDArrayBackedExtensionArray, PandasObject, ObjectStringArrayMi
 
         return result
 
-    def isin(self, values) -> npt.NDArray[np.bool_]:
+    def isin(self, values: ArrayLike) -> npt.NDArray[np.bool_]:
         """
         Check whether `values` are contained in Categorical.
 
@@ -2580,7 +2580,7 @@ class Categorical(NDArrayBackedExtensionArray, PandasObject, ObjectStringArrayMi
 
         Parameters
         ----------
-        values : set or list-like
+        values : np.ndarray or ExtensionArray
             The sequence of values to test. Passing in a single string will
             raise a ``TypeError``. Instead, turn a single string into a
             list of one element.
@@ -2611,13 +2611,6 @@ class Categorical(NDArrayBackedExtensionArray, PandasObject, ObjectStringArrayMi
         >>> s.isin(['lama'])
         array([ True, False,  True, False,  True, False])
         """
-        if not is_list_like(values):
-            values_type = type(values).__name__
-            raise TypeError(
-                "only list-like objects are allowed to be passed "
-                f"to isin(), you passed a `{values_type}`"
-            )
-        values = sanitize_array(values, None, None)
         null_mask = np.asarray(isna(values))
         code_values = self.categories.get_indexer_for(values)
         code_values = code_values[null_mask | (code_values >= 0)]
@@ -2626,6 +2619,8 @@ class Categorical(NDArrayBackedExtensionArray, PandasObject, ObjectStringArrayMi
     def _replace(self, *, to_replace, value, inplace: bool = False):
         from pandas import Index
 
+        orig_dtype = self.dtype
+
         inplace = validate_bool_kwarg(inplace, "inplace")
         cat = self if inplace else self.copy()
 
@@ -2656,6 +2651,17 @@ class Categorical(NDArrayBackedExtensionArray, PandasObject, ObjectStringArrayMi
         new_dtype = CategoricalDtype(new_categories, ordered=self.dtype.ordered)
         NDArrayBacked.__init__(cat, new_codes, new_dtype)
 
+        if new_dtype != orig_dtype:
+            warnings.warn(
+                # GH#55147
+                "The behavior of Series.replace (and DataFrame.replace) with "
+                "CategoricalDtype is deprecated. In a future version, replace "
+                "will only be used for cases that preserve the categories. "
+                "To change the categories, use ser.cat.rename_categories "
+                "instead.",
+                FutureWarning,
+                stacklevel=find_stack_level(),
+            )
         if not inplace:
             return cat
 
diff --git a/pandas/core/arrays/datetimelike.py b/pandas/core/arrays/datetimelike.py
index eb1c2ecc0b..3249f432e2 100644
--- a/pandas/core/arrays/datetimelike.py
+++ b/pandas/core/arrays/datetimelike.py
@@ -734,26 +734,25 @@ class DatetimeLikeArrayMixin(  # type: ignore[misc]
         else:
             return result.array
 
-    def isin(self, values) -> npt.NDArray[np.bool_]:
+    def isin(self, values: ArrayLike) -> npt.NDArray[np.bool_]:
         """
         Compute boolean array of whether each value is found in the
         passed set of values.
 
         Parameters
         ----------
-        values : set or sequence of values
+        values : np.ndarray or ExtensionArray
 
         Returns
         -------
         ndarray[bool]
         """
-        if not hasattr(values, "dtype"):
-            values = np.asarray(values)
-
         if values.dtype.kind in "fiuc":
             # TODO: de-duplicate with equals, validate_comparison_value
             return np.zeros(self.shape, dtype=bool)
 
+        values = ensure_wrapped_if_datetimelike(values)
+
         if not isinstance(values, type(self)):
             inferable = [
                 "timedelta",
@@ -764,6 +763,14 @@ class DatetimeLikeArrayMixin(  # type: ignore[misc]
                 "period",
             ]
             if values.dtype == object:
+                values = lib.maybe_convert_objects(
+                    values,
+                    convert_non_numeric=True,
+                    dtype_if_all_nat=self.dtype,
+                )
+                if values.dtype != object:
+                    return self.isin(values)
+
                 inferred = lib.infer_dtype(values, skipna=False)
                 if inferred not in inferable:
                     if inferred == "string":
@@ -778,18 +785,36 @@ class DatetimeLikeArrayMixin(  # type: ignore[misc]
                 values = type(self)._from_sequence(values)
             except ValueError:
                 return isin(self.astype(object), values)
+            else:
+                warnings.warn(
+                    # GH#53111
+                    f"The behavior of 'isin' with dtype={self.dtype} and "
+                    "castable values (e.g. strings) is deprecated. In a "
+                    "future version, these will not be considered matching "
+                    "by isin. Explicitly cast to the appropriate dtype before "
+                    "calling isin instead.",
+                    FutureWarning,
+                    stacklevel=find_stack_level(),
+                )
 
         if self.dtype.kind in "mM":
             self = cast("DatetimeArray | TimedeltaArray", self)
-            values = values.as_unit(self.unit)
+            # error: Item "ExtensionArray" of "ExtensionArray | ndarray[Any, Any]"
+            # has no attribute "as_unit"
+            values = values.as_unit(self.unit)  # type: ignore[union-attr]
 
         try:
-            self._check_compatible_with(values)
+            # error: Argument 1 to "_check_compatible_with" of "DatetimeLikeArrayMixin"
+            # has incompatible type "ExtensionArray | ndarray[Any, Any]"; expected
+            # "Period | Timestamp | Timedelta | NaTType"
+            self._check_compatible_with(values)  # type: ignore[arg-type]
         except (TypeError, ValueError):
             # Includes tzawareness mismatch and IncompatibleFrequencyError
             return np.zeros(self.shape, dtype=bool)
 
-        return isin(self.asi8, values.asi8)
+        # error: Item "ExtensionArray" of "ExtensionArray | ndarray[Any, Any]"
+        # has no attribute "asi8"
+        return isin(self.asi8, values.asi8)  # type: ignore[union-attr]
 
     # ------------------------------------------------------------------
     # Null Handling
diff --git a/pandas/core/arrays/interval.py b/pandas/core/arrays/interval.py
index 126484ed4a..383f8a49fd 100644
--- a/pandas/core/arrays/interval.py
+++ b/pandas/core/arrays/interval.py
@@ -1789,12 +1789,8 @@ class IntervalArray(IntervalMixin, ExtensionArray):
             other < self._right if self.open_right else other <= self._right
         )
 
-    def isin(self, values) -> npt.NDArray[np.bool_]:
-        if not hasattr(values, "dtype"):
-            values = np.array(values)
-        values = extract_array(values, extract_numpy=True)
-
-        if isinstance(values.dtype, IntervalDtype):
+    def isin(self, values: ArrayLike) -> npt.NDArray[np.bool_]:
+        if isinstance(values, IntervalArray):
             if self.closed != values.closed:
                 # not comparable -> no overlap
                 return np.zeros(self.shape, dtype=bool)
diff --git a/pandas/core/arrays/masked.py b/pandas/core/arrays/masked.py
index 201ce44ed0..2f0cf7a67c 100644
--- a/pandas/core/arrays/masked.py
+++ b/pandas/core/arrays/masked.py
@@ -955,7 +955,7 @@ class BaseMaskedArray(OpsMixin, ExtensionArray):
 
     # error: Return type "BooleanArray" of "isin" incompatible with return type
     # "ndarray" in supertype "ExtensionArray"
-    def isin(self, values) -> BooleanArray:  # type: ignore[override]
+    def isin(self, values: ArrayLike) -> BooleanArray:  # type: ignore[override]
         from pandas.core.arrays import BooleanArray
 
         # algorithms.isin will eventually convert values to an ndarray, so no extra
diff --git a/pandas/core/arrays/string_arrow.py b/pandas/core/arrays/string_arrow.py
index 976a8d3c32..21fe7cd818 100644
--- a/pandas/core/arrays/string_arrow.py
+++ b/pandas/core/arrays/string_arrow.py
@@ -54,6 +54,7 @@ if TYPE_CHECKING:
     from collections.abc import Sequence
 
     from pandas._typing import (
+        ArrayLike,
         AxisInt,
         Dtype,
         Scalar,
@@ -212,7 +213,7 @@ class ArrowStringArray(ObjectStringArrayMixin, ArrowExtensionArray, BaseStringAr
                     raise TypeError("Scalar must be NA or str")
         return super()._maybe_convert_setitem_value(value)
 
-    def isin(self, values) -> npt.NDArray[np.bool_]:
+    def isin(self, values: ArrayLike) -> npt.NDArray[np.bool_]:
         value_set = [
             pa_scalar.as_py()
             for pa_scalar in [pa.scalar(value, from_pandas=True) for value in values]
diff --git a/pandas/core/indexes/base.py b/pandas/core/indexes/base.py
index 3d3056f47f..febceeb762 100644
--- a/pandas/core/indexes/base.py
+++ b/pandas/core/indexes/base.py
@@ -6534,18 +6534,6 @@ class Index(IndexOpsMixin, PandasObject):
 
         >>> midx.isin([(1, 'red'), (3, 'red')])
         array([ True, False, False])
-
-        For a DatetimeIndex, string values in `values` are converted to
-        Timestamps.
-
-        >>> dates = ['2000-03-11', '2000-03-12', '2000-03-13']
-        >>> dti = pd.to_datetime(dates)
-        >>> dti
-        DatetimeIndex(['2000-03-11', '2000-03-12', '2000-03-13'],
-        dtype='datetime64[ns]', freq=None)
-
-        >>> dti.isin(['2000-03-11'])
-        array([ True, False, False])
         """
         if level is not None:
             self._validate_index_level(level)


Test patch:
diff --git a/pandas/tests/arrays/categorical/test_replace.py b/pandas/tests/arrays/categorical/test_replace.py
index 0611d04d36..3c67714284 100644
--- a/pandas/tests/arrays/categorical/test_replace.py
+++ b/pandas/tests/arrays/categorical/test_replace.py
@@ -31,6 +31,9 @@ import pandas._testing as tm
         ([1, 2, "3"], "5", ["5", "5", 3], True),
     ],
 )
+@pytest.mark.filterwarnings(
+    "ignore:.*with CategoricalDtype is deprecated:FutureWarning"
+)
 def test_replace_categorical_series(to_replace, value, expected, flip_categories):
     # GH 31720
 
@@ -60,7 +63,13 @@ def test_replace_categorical(to_replace, value, result, expected_error_msg):
     # GH#26988
     cat = Categorical(["a", "b"])
     expected = Categorical(result)
-    result = pd.Series(cat, copy=False).replace(to_replace, value)._values
+    msg = (
+        r"The behavior of Series\.replace \(and DataFrame.replace\) "
+        "with CategoricalDtype"
+    )
+    warn = FutureWarning if expected_error_msg is not None else None
+    with tm.assert_produces_warning(warn, match=msg):
+        result = pd.Series(cat, copy=False).replace(to_replace, value)._values
 
     tm.assert_categorical_equal(result, expected)
     if to_replace == "b":  # the "c" test is supposed to be unchanged
@@ -69,14 +78,20 @@ def test_replace_categorical(to_replace, value, result, expected_error_msg):
             tm.assert_categorical_equal(cat, expected)
 
     ser = pd.Series(cat, copy=False)
-    ser.replace(to_replace, value, inplace=True)
+    with tm.assert_produces_warning(warn, match=msg):
+        ser.replace(to_replace, value, inplace=True)
     tm.assert_categorical_equal(cat, expected)
 
 
 def test_replace_categorical_ea_dtype():
     # GH49404
     cat = Categorical(pd.array(["a", "b"], dtype="string"))
-    result = pd.Series(cat).replace(["a", "b"], ["c", pd.NA])._values
+    msg = (
+        r"The behavior of Series\.replace \(and DataFrame.replace\) "
+        "with CategoricalDtype"
+    )
+    with tm.assert_produces_warning(FutureWarning, match=msg):
+        result = pd.Series(cat).replace(["a", "b"], ["c", pd.NA])._values
     expected = Categorical(pd.array(["c", pd.NA], dtype="string"))
     tm.assert_categorical_equal(result, expected)
 
@@ -85,7 +100,12 @@ def test_replace_maintain_ordering():
     # GH51016
     dtype = pd.CategoricalDtype([0, 1, 2], ordered=True)
     ser = pd.Series([0, 1, 2], dtype=dtype)
-    result = ser.replace(0, 2)
+    msg = (
+        r"The behavior of Series\.replace \(and DataFrame.replace\) "
+        "with CategoricalDtype"
+    )
+    with tm.assert_produces_warning(FutureWarning, match=msg):
+        result = ser.replace(0, 2)
     expected_dtype = pd.CategoricalDtype([1, 2], ordered=True)
     expected = pd.Series([2, 1, 2], dtype=expected_dtype)
     tm.assert_series_equal(expected, result, check_category_order=True)
diff --git a/pandas/tests/copy_view/test_replace.py b/pandas/tests/copy_view/test_replace.py
index 268e859e78..6d16bc3083 100644
--- a/pandas/tests/copy_view/test_replace.py
+++ b/pandas/tests/copy_view/test_replace.py
@@ -162,13 +162,19 @@ def test_replace_to_replace_wrong_dtype(using_copy_on_write):
 def test_replace_list_categorical(using_copy_on_write):
     df = DataFrame({"a": ["a", "b", "c"]}, dtype="category")
     arr = get_array(df, "a")
-    df.replace(["c"], value="a", inplace=True)
+    msg = (
+        r"The behavior of Series\.replace \(and DataFrame.replace\) "
+        "with CategoricalDtype"
+    )
+    with tm.assert_produces_warning(FutureWarning, match=msg):
+        df.replace(["c"], value="a", inplace=True)
     assert np.shares_memory(arr.codes, get_array(df, "a").codes)
     if using_copy_on_write:
         assert df._mgr._has_no_reference(0)
 
     df_orig = df.copy()
-    df2 = df.replace(["b"], value="a")
+    with tm.assert_produces_warning(FutureWarning, match=msg):
+        df2 = df.replace(["b"], value="a")
     assert not np.shares_memory(arr.codes, get_array(df2, "a").codes)
 
     tm.assert_frame_equal(df, df_orig)
@@ -178,7 +184,12 @@ def test_replace_list_inplace_refs_categorical(using_copy_on_write):
     df = DataFrame({"a": ["a", "b", "c"]}, dtype="category")
     view = df[:]
     df_orig = df.copy()
-    df.replace(["c"], value="a", inplace=True)
+    msg = (
+        r"The behavior of Series\.replace \(and DataFrame.replace\) "
+        "with CategoricalDtype"
+    )
+    with tm.assert_produces_warning(FutureWarning, match=msg):
+        df.replace(["c"], value="a", inplace=True)
     if using_copy_on_write:
         assert not np.shares_memory(
             get_array(view, "a").codes, get_array(df, "a").codes
@@ -238,7 +249,13 @@ def test_replace_categorical_inplace_reference(using_copy_on_write, val, to_repl
     df_orig = df.copy()
     arr_a = get_array(df, "a")
     view = df[:]
-    df.replace(to_replace=to_replace, value=val, inplace=True)
+    msg = (
+        r"The behavior of Series\.replace \(and DataFrame.replace\) "
+        "with CategoricalDtype"
+    )
+    warn = FutureWarning if val == 1.5 else None
+    with tm.assert_produces_warning(warn, match=msg):
+        df.replace(to_replace=to_replace, value=val, inplace=True)
 
     if using_copy_on_write:
         assert not np.shares_memory(get_array(df, "a").codes, arr_a.codes)
@@ -253,7 +270,13 @@ def test_replace_categorical_inplace_reference(using_copy_on_write, val, to_repl
 def test_replace_categorical_inplace(using_copy_on_write, val):
     df = DataFrame({"a": Categorical([1, 2, 3])})
     arr_a = get_array(df, "a")
-    df.replace(to_replace=1, value=val, inplace=True)
+    msg = (
+        r"The behavior of Series\.replace \(and DataFrame.replace\) "
+        "with CategoricalDtype"
+    )
+    warn = FutureWarning if val == 1.5 else None
+    with tm.assert_produces_warning(warn, match=msg):
+        df.replace(to_replace=1, value=val, inplace=True)
 
     assert np.shares_memory(get_array(df, "a").codes, arr_a.codes)
     if using_copy_on_write:
@@ -267,7 +290,13 @@ def test_replace_categorical_inplace(using_copy_on_write, val):
 def test_replace_categorical(using_copy_on_write, val):
     df = DataFrame({"a": Categorical([1, 2, 3])})
     df_orig = df.copy()
-    df2 = df.replace(to_replace=1, value=val)
+    msg = (
+        r"The behavior of Series\.replace \(and DataFrame.replace\) "
+        "with CategoricalDtype"
+    )
+    warn = FutureWarning if val == 1.5 else None
+    with tm.assert_produces_warning(warn, match=msg):
+        df2 = df.replace(to_replace=1, value=val)
 
     if using_copy_on_write:
         assert df._mgr._has_no_reference(0)
diff --git a/pandas/tests/frame/methods/test_replace.py b/pandas/tests/frame/methods/test_replace.py
index 13e2c1a249..53c45a5f4b 100644
--- a/pandas/tests/frame/methods/test_replace.py
+++ b/pandas/tests/frame/methods/test_replace.py
@@ -1279,7 +1279,9 @@ class TestDataFrameReplace:
         b = pd.Categorical(final_data[:, 1], categories=ex_cat)
 
         expected = DataFrame({"a": a, "b": b})
-        result = df.replace(replace_dict, 3)
+        msg2 = "with CategoricalDtype is deprecated"
+        with tm.assert_produces_warning(FutureWarning, match=msg2):
+            result = df.replace(replace_dict, 3)
         tm.assert_frame_equal(result, expected)
         msg = (
             r"Attributes of DataFrame.iloc\[:, 0\] \(column name=\"a\"\) are "
@@ -1288,7 +1290,8 @@ class TestDataFrameReplace:
         with pytest.raises(AssertionError, match=msg):
             # ensure non-inplace call does not affect original
             tm.assert_frame_equal(df, expected)
-        return_value = df.replace(replace_dict, 3, inplace=True)
+        with tm.assert_produces_warning(FutureWarning, match=msg2):
+            return_value = df.replace(replace_dict, 3, inplace=True)
         assert return_value is None
         tm.assert_frame_equal(df, expected)
 
@@ -1438,9 +1441,14 @@ class TestDataFrameReplace:
         )
 
         # replace values in input dataframe
-        input_df = input_df.replace("d", "z")
-        input_df = input_df.replace("obj1", "obj9")
-        result = input_df.replace("cat2", "catX")
+        msg = (
+            r"The behavior of Series\.replace \(and DataFrame.replace\) "
+            "with CategoricalDtype"
+        )
+        with tm.assert_produces_warning(FutureWarning, match=msg):
+            input_df = input_df.replace("d", "z")
+            input_df = input_df.replace("obj1", "obj9")
+            result = input_df.replace("cat2", "catX")
 
         tm.assert_frame_equal(result, expected)
 
@@ -1466,7 +1474,12 @@ class TestDataFrameReplace:
         )
 
         # replace values in input dataframe using a dict
-        result = input_df.replace({"a": "z", "obj1": "obj9", "cat1": "catX"})
+        msg = (
+            r"The behavior of Series\.replace \(and DataFrame.replace\) "
+            "with CategoricalDtype"
+        )
+        with tm.assert_produces_warning(FutureWarning, match=msg):
+            result = input_df.replace({"a": "z", "obj1": "obj9", "cat1": "catX"})
 
         tm.assert_frame_equal(result, expected)
 
diff --git a/pandas/tests/groupby/test_groupby_dropna.py b/pandas/tests/groupby/test_groupby_dropna.py
index b40c8f45b6..4f54621b19 100644
--- a/pandas/tests/groupby/test_groupby_dropna.py
+++ b/pandas/tests/groupby/test_groupby_dropna.py
@@ -546,9 +546,9 @@ def test_categorical_reducers(reduction_func, observed, sort, as_index, index_ki
 
     gb_filled = df_filled.groupby(keys, observed=observed, sort=sort, as_index=True)
     expected = getattr(gb_filled, reduction_func)(*args_filled).reset_index()
-    expected["x"] = expected["x"].replace(4, None)
+    expected["x"] = expected["x"].cat.remove_categories([4])
     if index_kind == "multi":
-        expected["x2"] = expected["x2"].replace(4, None)
+        expected["x2"] = expected["x2"].cat.remove_categories([4])
     if as_index:
         if index_kind == "multi":
             expected = expected.set_index(["x", "x2"])
diff --git a/pandas/tests/io/parser/test_parse_dates.py b/pandas/tests/io/parser/test_parse_dates.py
index 113402cda1..9355d6089b 100644
--- a/pandas/tests/io/parser/test_parse_dates.py
+++ b/pandas/tests/io/parser/test_parse_dates.py
@@ -12,13 +12,11 @@ from datetime import (
 from io import StringIO
 
 from dateutil.parser import parse as du_parse
-from hypothesis import given
 import numpy as np
 import pytest
 import pytz
 
 from pandas._libs.tslibs import parsing
-from pandas._libs.tslibs.parsing import py_parse_datetime_string
 
 import pandas as pd
 from pandas import (
@@ -30,7 +28,6 @@ from pandas import (
     Timestamp,
 )
 import pandas._testing as tm
-from pandas._testing._hypothesis import DATETIME_NO_TZ
 from pandas.core.indexes.datetimes import date_range
 from pandas.core.tools.datetimes import start_caching_at
 
@@ -1838,49 +1835,6 @@ def test_parse_multiple_delimited_dates_with_swap_warnings():
         pd.to_datetime(["01/01/2000", "31/05/2000", "31/05/2001", "01/02/2000"])
 
 
-def _helper_hypothesis_delimited_date(call, date_string, **kwargs):
-    msg, result = None, None
-    try:
-        result = call(date_string, **kwargs)
-    except ValueError as err:
-        msg = str(err)
-    return msg, result
-
-
-@given(DATETIME_NO_TZ)
-@pytest.mark.parametrize("delimiter", list(" -./"))
-@pytest.mark.parametrize("dayfirst", [True, False])
-@pytest.mark.parametrize(
-    "date_format",
-    ["%d %m %Y", "%m %d %Y", "%m %Y", "%Y %m %d", "%y %m %d", "%Y%m%d", "%y%m%d"],
-)
-def test_hypothesis_delimited_date(
-    request, date_format, dayfirst, delimiter, test_datetime
-):
-    if date_format == "%m %Y" and delimiter == ".":
-        request.applymarker(
-            pytest.mark.xfail(
-                reason="parse_datetime_string cannot reliably tell whether "
-                "e.g. %m.%Y is a float or a date"
-            )
-        )
-    date_string = test_datetime.strftime(date_format.replace(" ", delimiter))
-
-    except_out_dateutil, result = _helper_hypothesis_delimited_date(
-        py_parse_datetime_string, date_string, dayfirst=dayfirst
-    )
-    except_in_dateutil, expected = _helper_hypothesis_delimited_date(
-        du_parse,
-        date_string,
-        default=datetime(1, 1, 1),
-        dayfirst=dayfirst,
-        yearfirst=False,
-    )
-
-    assert except_out_dateutil == except_in_dateutil
-    assert result == expected
-
-
 # ArrowKeyError: Column 'fdate1' in include_columns does not exist in CSV file
 @skip_pyarrow
 @pytest.mark.parametrize(
diff --git a/pandas/tests/io/pytables/test_file_handling.py b/pandas/tests/io/pytables/test_file_handling.py
index e292bc7fe2..d93de16816 100644
--- a/pandas/tests/io/pytables/test_file_handling.py
+++ b/pandas/tests/io/pytables/test_file_handling.py
@@ -341,7 +341,15 @@ def test_latin_encoding(tmp_path, setup_path, dtype, val):
     ser.to_hdf(store, key=key, format="table", encoding=enc, nan_rep=nan_rep)
     retr = read_hdf(store, key)
 
-    s_nan = ser.replace(nan_rep, np.nan)
+    # TODO:(3.0): once Categorical replace deprecation is enforced,
+    #  we may be able to re-simplify the construction of s_nan
+    if dtype == "category":
+        if nan_rep in ser.cat.categories:
+            s_nan = ser.cat.remove_categories([nan_rep])
+        else:
+            s_nan = ser
+    else:
+        s_nan = ser.replace(nan_rep, np.nan)
 
     tm.assert_series_equal(s_nan, retr)
 
diff --git a/pandas/tests/io/sas/test_sas7bdat.py b/pandas/tests/io/sas/test_sas7bdat.py
index 181405dbfe..2c4fe10ea9 100644
--- a/pandas/tests/io/sas/test_sas7bdat.py
+++ b/pandas/tests/io/sas/test_sas7bdat.py
@@ -41,15 +41,15 @@ def data_test_ix(request, dirpath):
 class TestSAS7BDAT:
     @pytest.mark.slow
     def test_from_file(self, dirpath, data_test_ix):
-        df0, test_ix = data_test_ix
+        expected, test_ix = data_test_ix
         for k in test_ix:
             fname = os.path.join(dirpath, f"test{k}.sas7bdat")
             df = pd.read_sas(fname, encoding="utf-8")
-            tm.assert_frame_equal(df, df0)
+            tm.assert_frame_equal(df, expected)
 
     @pytest.mark.slow
     def test_from_buffer(self, dirpath, data_test_ix):
-        df0, test_ix = data_test_ix
+        expected, test_ix = data_test_ix
         for k in test_ix:
             fname = os.path.join(dirpath, f"test{k}.sas7bdat")
             with open(fname, "rb") as f:
@@ -59,37 +59,37 @@ class TestSAS7BDAT:
                 buf, format="sas7bdat", iterator=True, encoding="utf-8"
             ) as rdr:
                 df = rdr.read()
-            tm.assert_frame_equal(df, df0)
+            tm.assert_frame_equal(df, expected)
 
     @pytest.mark.slow
     def test_from_iterator(self, dirpath, data_test_ix):
-        df0, test_ix = data_test_ix
+        expected, test_ix = data_test_ix
         for k in test_ix:
             fname = os.path.join(dirpath, f"test{k}.sas7bdat")
             with pd.read_sas(fname, iterator=True, encoding="utf-8") as rdr:
                 df = rdr.read(2)
-                tm.assert_frame_equal(df, df0.iloc[0:2, :])
+                tm.assert_frame_equal(df, expected.iloc[0:2, :])
                 df = rdr.read(3)
-                tm.assert_frame_equal(df, df0.iloc[2:5, :])
+                tm.assert_frame_equal(df, expected.iloc[2:5, :])
 
     @pytest.mark.slow
     def test_path_pathlib(self, dirpath, data_test_ix):
-        df0, test_ix = data_test_ix
+        expected, test_ix = data_test_ix
         for k in test_ix:
             fname = Path(os.path.join(dirpath, f"test{k}.sas7bdat"))
             df = pd.read_sas(fname, encoding="utf-8")
-            tm.assert_frame_equal(df, df0)
+            tm.assert_frame_equal(df, expected)
 
     @td.skip_if_no("py.path")
     @pytest.mark.slow
     def test_path_localpath(self, dirpath, data_test_ix):
         from py.path import local as LocalPath
 
-        df0, test_ix = data_test_ix
+        expected, test_ix = data_test_ix
         for k in test_ix:
             fname = LocalPath(os.path.join(dirpath, f"test{k}.sas7bdat"))
             df = pd.read_sas(fname, encoding="utf-8")
-            tm.assert_frame_equal(df, df0)
+            tm.assert_frame_equal(df, expected)
 
     @pytest.mark.slow
     @pytest.mark.parametrize("chunksize", (3, 5, 10, 11))
diff --git a/pandas/tests/io/test_feather.py b/pandas/tests/io/test_feather.py
index 572abbf7c4..15c5953e79 100644
--- a/pandas/tests/io/test_feather.py
+++ b/pandas/tests/io/test_feather.py
@@ -15,7 +15,7 @@ pytestmark = pytest.mark.filterwarnings(
     "ignore:Passing a BlockManager to DataFrame:DeprecationWarning"
 )
 
-pyarrow = pytest.importorskip("pyarrow")
+pa = pytest.importorskip("pyarrow")
 
 
 @pytest.mark.single_cpu
@@ -169,7 +169,6 @@ class TestFeather:
 
     def test_read_feather_dtype_backend(self, string_storage, dtype_backend):
         # GH#50765
-        pa = pytest.importorskip("pyarrow")
         df = pd.DataFrame(
             {
                 "a": pd.Series([1, np.nan, 3], dtype="Int64"),
diff --git a/pandas/tests/series/methods/test_replace.py b/pandas/tests/series/methods/test_replace.py
index 477f36bdf4..4330153c18 100644
--- a/pandas/tests/series/methods/test_replace.py
+++ b/pandas/tests/series/methods/test_replace.py
@@ -403,6 +403,7 @@ class TestSeriesReplace:
         # GH 24971, GH#23305
         ser = pd.Series(categorical)
         msg = "Downcasting behavior in `replace`"
+        msg = "with CategoricalDtype is deprecated"
         with tm.assert_produces_warning(FutureWarning, match=msg):
             result = ser.replace({"A": 1, "B": 2})
         expected = pd.Series(numeric).astype("category")
@@ -418,7 +419,9 @@ class TestSeriesReplace:
     def test_replace_categorical_inplace(self, data, data_exp):
         # GH 53358
         result = pd.Series(data, dtype="category")
-        result.replace(to_replace="a", value="b", inplace=True)
+        msg = "with CategoricalDtype is deprecated"
+        with tm.assert_produces_warning(FutureWarning, match=msg):
+            result.replace(to_replace="a", value="b", inplace=True)
         expected = pd.Series(data_exp, dtype="category")
         tm.assert_series_equal(result, expected)
 
@@ -434,16 +437,22 @@ class TestSeriesReplace:
         expected = expected.cat.remove_unused_categories()
         assert c[2] != "foo"
 
-        result = c.replace(c[2], "foo")
+        msg = "with CategoricalDtype is deprecated"
+        with tm.assert_produces_warning(FutureWarning, match=msg):
+            result = c.replace(c[2], "foo")
         tm.assert_series_equal(expected, result)
         assert c[2] != "foo"  # ensure non-inplace call does not alter original
 
-        return_value = c.replace(c[2], "foo", inplace=True)
+        msg = "with CategoricalDtype is deprecated"
+        with tm.assert_produces_warning(FutureWarning, match=msg):
+            return_value = c.replace(c[2], "foo", inplace=True)
         assert return_value is None
         tm.assert_series_equal(expected, c)
 
         first_value = c[0]
-        return_value = c.replace(c[1], c[0], inplace=True)
+        msg = "with CategoricalDtype is deprecated"
+        with tm.assert_produces_warning(FutureWarning, match=msg):
+            return_value = c.replace(c[1], c[0], inplace=True)
         assert return_value is None
         assert c[0] == c[1] == first_value  # test replacing with existing value
 
diff --git a/pandas/tests/test_algos.py b/pandas/tests/test_algos.py
index 5356704cc6..d9162c428b 100644
--- a/pandas/tests/test_algos.py
+++ b/pandas/tests/test_algos.py
@@ -992,6 +992,45 @@ class TestIsin:
         expected[1] = True
         tm.assert_numpy_array_equal(result, expected)
 
+    @pytest.mark.parametrize("dtype", ["m8[ns]", "M8[ns]", "M8[ns, UTC]", "period[D]"])
+    def test_isin_datetimelike_all_nat(self, dtype):
+        # GH#56427
+        dta = date_range("2013-01-01", periods=3)._values
+        arr = Series(dta.view("i8")).array.view(dtype)
+
+        arr[0] = NaT
+        result = algos.isin(arr, [NaT])
+        expected = np.array([True, False, False], dtype=bool)
+        tm.assert_numpy_array_equal(result, expected)
+
+    @pytest.mark.parametrize("dtype", ["m8[ns]", "M8[ns]", "M8[ns, UTC]"])
+    def test_isin_datetimelike_strings_deprecated(self, dtype):
+        # GH#53111
+        dta = date_range("2013-01-01", periods=3)._values
+        arr = Series(dta.view("i8")).array.view(dtype)
+
+        vals = [str(x) for x in arr]
+        msg = "The behavior of 'isin' with dtype=.* is deprecated"
+        with tm.assert_produces_warning(FutureWarning, match=msg):
+            res = algos.isin(arr, vals)
+        assert res.all()
+
+        vals2 = np.array(vals, dtype=str)
+        with tm.assert_produces_warning(FutureWarning, match=msg):
+            res2 = algos.isin(arr, vals2)
+        assert res2.all()
+
+    def test_isin_dt64tz_with_nat(self):
+        # the all-NaT values used to get inferred to tznaive, which was evaluated
+        #  as non-matching GH#56427
+        dti = date_range("2016-01-01", periods=3, tz="UTC")
+        ser = Series(dti)
+        ser[0] = NaT
+
+        res = algos.isin(ser._values, [NaT])
+        exp = np.array([True, False, False], dtype=bool)
+        tm.assert_numpy_array_equal(res, exp)
+
     def test_categorical_from_codes(self):
         # GH 16639
         vals = np.array([0, 1, 2, 0])
diff --git a/pandas/tests/tslibs/test_parsing.py b/pandas/tests/tslibs/test_parsing.py
index 425decc142..d8f23156bd 100644
--- a/pandas/tests/tslibs/test_parsing.py
+++ b/pandas/tests/tslibs/test_parsing.py
@@ -6,6 +6,7 @@ import re
 
 from dateutil.parser import parse as du_parse
 from dateutil.tz import tzlocal
+from hypothesis import given
 import numpy as np
 import pytest
 
@@ -21,6 +22,7 @@ from pandas.compat import (
 import pandas.util._test_decorators as td
 
 import pandas._testing as tm
+from pandas._testing._hypothesis import DATETIME_NO_TZ
 
 
 @pytest.mark.skipif(
@@ -367,3 +369,46 @@ def test_guess_datetime_format_f(input):
     result = parsing.guess_datetime_format(input)
     expected = "%Y-%m-%dT%H:%M:%S.%f"
     assert result == expected
+
+
+def _helper_hypothesis_delimited_date(call, date_string, **kwargs):
+    msg, result = None, None
+    try:
+        result = call(date_string, **kwargs)
+    except ValueError as err:
+        msg = str(err)
+    return msg, result
+
+
+@given(DATETIME_NO_TZ)
+@pytest.mark.parametrize("delimiter", list(" -./"))
+@pytest.mark.parametrize("dayfirst", [True, False])
+@pytest.mark.parametrize(
+    "date_format",
+    ["%d %m %Y", "%m %d %Y", "%m %Y", "%Y %m %d", "%y %m %d", "%Y%m%d", "%y%m%d"],
+)
+def test_hypothesis_delimited_date(
+    request, date_format, dayfirst, delimiter, test_datetime
+):
+    if date_format == "%m %Y" and delimiter == ".":
+        request.applymarker(
+            pytest.mark.xfail(
+                reason="parse_datetime_string cannot reliably tell whether "
+                "e.g. %m.%Y is a float or a date"
+            )
+        )
+    date_string = test_datetime.strftime(date_format.replace(" ", delimiter))
+
+    except_out_dateutil, result = _helper_hypothesis_delimited_date(
+        parsing.py_parse_datetime_string, date_string, dayfirst=dayfirst
+    )
+    except_in_dateutil, expected = _helper_hypothesis_delimited_date(
+        du_parse,
+        date_string,
+        default=datetime(1, 1, 1),
+        dayfirst=dayfirst,
+        yearfirst=False,
+    )
+
+    assert except_out_dateutil == except_in_dateutil
+    assert result == expected