Repository: pandas-dev/pandas
Pull request: DEPR MultiIndex.is_lexsorted and MultiIndex.lexsort_depth
- [x] closes #32259
- [x] tests added / passed
- [x] passes `black pandas`
- [x] passes `git diff upstream/master -u -- "*.py" | flake8 --diff`
- [x] whatsnew entry

The issue is that `is_lexsorted` only looks at whether the _codes_ are sorted - the order of the codes doesn't necessarily reflect the sorting of the index values, e.g. if the index is created from a `.groupby(sort=False)` operation (see the linked issue for an example).

So, instead of `is_lexsorted`, users should use `is_monotonic_increasing`. There isn't an equivalent function to point them to for `lexsort_depth`, right?

Problem statement:
Bug: groupby with sort=False creates buggy MultiIndex
```python
d = pd.to_datetime(['2020-11-02', '2019-01-02', '2020-01-02', '2020-02-04', '2020-11-03', '2019-11-03', '2019-11-13', '2019-11-13'])
a = np.arange(len(d))
b = np.random.rand(len(d))
df = pd.DataFrame({'d': d, 'a': a, 'b': b})
t = df.groupby(['d', 'a'], sort=False).mean()
```

The index of `t` is certainly not sorted, but `t.index.is_lexsorted()` returns True.


Another more subtle example is 
```python
d = [3,4,10,0,1,2,5,3]
a = np.arange(len(d))
b = np.random.rand(len(d))
df = pd.DataFrame({'d': d, 'a': a, 'b': b})
t = df.groupby(['d', 'a'], sort=False).mean()
```

This time the lexsort flag is correct. However, calling sortlevel will not sort the new MultiIndex correctly, that is, `t.index.sortlevel(['d', 'a'])[0]` returns

```
MultiIndex([( 3, 0),
            ( 3, 7),
            ( 4, 1),
            (10, 2),
            ( 0, 3),
            ( 1, 4),
            ( 2, 5),
            ( 5, 6)],
           names=['d', 'a'])
```

#### Output of ``pd.show_versions()``

<details>

[paste the output of ``pd.show_versions()`` here below this line]

INSTALLED VERSIONS
------------------
commit           : None
python           : 3.7.4.final.0
python-bits      : 64
OS               : Linux
OS-release       : 4.15.0-72-generic
machine          : x86_64
processor        : x86_64
byteorder        : little
LC_ALL           : None
LANG             : en_US.UTF-8
LOCALE           : en_US.UTF-8

pandas           : 1.0.1
numpy            : 1.18.1
pytz             : 2019.3
dateutil         : 2.8.1
pip              : 20.0.2
setuptools       : 45.2.0.post20200210
Cython           : 0.29.15
pytest           : 5.3.5
hypothesis       : 5.5.4
sphinx           : 2.4.0
blosc            : None
feather          : None
xlsxwriter       : 1.2.7
lxml.etree       : 4.5.0
html5lib         : 1.0.1
pymysql          : None
psycopg2         : None
jinja2           : 2.11.1
IPython          : 7.12.0
pandas_datareader: 0.8.1
bs4              : 4.8.2
bottleneck       : 1.3.2
fastparquet      : None
gcsfs            : None
lxml.etree       : 4.5.0
matplotlib       : 3.1.3
numexpr          : 2.7.1
odfpy            : None
openpyxl         : 3.0.3
pandas_gbq       : None
pyarrow          : 0.13.0
pytables         : None
pytest           : 5.3.5
pyxlsb           : None
s3fs             : None
scipy            : 1.4.1
sqlalchemy       : 1.3.13
tables           : 3.6.1
tabulate         : None
xarray           : None
xlrd             : 1.2.0
xlwt             : 1.3.0
xlsxwriter       : 1.2.7
numba            : 0.45.1

</details>

Hints from issue discussion:
take
(just some notes, will come back to this)

`is_lexsorted` assumes the order of `.codes` reflects the order of the elements in the index. But `.codes` is actually determined by
```
                codes, uniques = algorithms.factorize(self.grouper, sort=self.sort)

```
in `pandas/core/groupby/grouper.py`, where `self.sort` may be False
I found that stack/unstack has a similar bug:

```
d = pd.to_datetime(['2020-11-02', '2019-01-02', '2020-01-02', '2020-02-04', '2020-11-03', '2019-11-03', '2019-11-13', '2019-11-14'])
a = np.arange(len(d))
b = np.random.rand(len(d))
df = pd.DataFrame({'d': d, 'a': a, 'b': b}).set_index(['d', 'a']).take([3,2,4,1,0,5,6,7])
```
If you do `df.unstack('d').stack().index`, the resulting index is unsorted on level 'd', but its `.is_lexsorted()` returns True.

I see that stack also relies on algorithms.factorize, which probably leads to the same issue.
> If you do df.unstack('d').stack().index, the resulting index is unsorted on level 'd', but its .is_lexsorted() returns True.

In this case, isn't the current result correct though?
```
In [1]: import pandas as pd; import numpy as np                                                                                                                                                                  

In [2]: d = pd.to_datetime(['2020-11-02', '2019-01-02', '2020-01-02', '2020-02-04', '2020-11-03', '2019-11-03', '2019-11-13', '2019-11-14']) 
   ...: a = np.arange(len(d)) 
   ...: b = np.random.rand(len(d)) 
   ...: df = pd.DataFrame({'d': d, 'a': a, 'b': b}).set_index(['d', 'a']).take([3,2,4,1,0,5,6,7])                                                                                                                

In [3]: df.unstack('d').stack().index                                                                                                                                                                            
Out[3]: 
MultiIndex([(0, '2020-11-02'),
            (1, '2019-01-02'),
            (2, '2020-01-02'),
            (3, '2020-02-04'),
            (4, '2020-11-03'),
            (5, '2019-11-03'),
            (6, '2019-11-13'),
            (7, '2019-11-14')],
           names=['a', 'd'])
```
The index is sorted by its first argument, and so it is correct to say that it's lexically sorted

> > If you do df.unstack('d').stack().index, the resulting index is unsorted on level 'd', but its .is_lexsorted() returns True.
> 
> In this case, isn't the current result correct though?
> 
> ```
> In [1]: import pandas as pd; import numpy as np                                                                                                                                                                  
> 
> In [2]: d = pd.to_datetime(['2020-11-02', '2019-01-02', '2020-01-02', '2020-02-04', '2020-11-03', '2019-11-03', '2019-11-13', '2019-11-14']) 
>    ...: a = np.arange(len(d)) 
>    ...: b = np.random.rand(len(d)) 
>    ...: df = pd.DataFrame({'d': d, 'a': a, 'b': b}).set_index(['d', 'a']).take([3,2,4,1,0,5,6,7])                                                                                                                
> 
> In [3]: df.unstack('d').stack().index                                                                                                                                                                            
> Out[3]: 
> MultiIndex([(0, '2020-11-02'),
>             (1, '2019-01-02'),
>             (2, '2020-01-02'),
>             (3, '2020-02-04'),
>             (4, '2020-11-03'),
>             (5, '2019-11-03'),
>             (6, '2019-11-13'),
>             (7, '2019-11-14')],
>            names=['a', 'd'])
> ```
> 
> The index is sorted by its first argument, and so it is correct to say that it's lexically sorted

Oh you are right. I was confused a bit. Yes, this is actually correctly sorted.
Decision has been to deprecate `is_lexsorted` as a public method - users should use `is_monotonic_increasing`
> Decision has been to deprecate is_lexsorted as a public method - users should use is_monotonic_increasing

Where has this been discussed?
Hey @jorisvandenbossche - it was in call when we had the sprint on the 31st of August

Commit message: update old release notes

Patch to review:
diff --git a/doc/source/reference/indexing.rst b/doc/source/reference/indexing.rst
index 8446b384b8fb8..1a8c21a2c1a74 100644
--- a/doc/source/reference/indexing.rst
+++ b/doc/source/reference/indexing.rst
@@ -301,7 +301,6 @@ MultiIndex components
    MultiIndex.set_codes
    MultiIndex.to_flat_index
    MultiIndex.to_frame
-   MultiIndex.is_lexsorted
    MultiIndex.sortlevel
    MultiIndex.droplevel
    MultiIndex.swaplevel
diff --git a/doc/source/user_guide/advanced.rst b/doc/source/user_guide/advanced.rst
index 2cd48ac7adb0e..6377aeb0e7c74 100644
--- a/doc/source/user_guide/advanced.rst
+++ b/doc/source/user_guide/advanced.rst
@@ -658,20 +658,18 @@ Furthermore, if you try to index something that is not fully lexsorted, this can
     In [5]: dfm.loc[(0, 'y'):(1, 'z')]
     UnsortedIndexError: 'Key length (2) was greater than MultiIndex lexsort depth (1)'
 
-The :meth:`~MultiIndex.is_lexsorted` method on a ``MultiIndex`` shows if the
-index is sorted, and the ``lexsort_depth`` property returns the sort depth:
+The :meth:`~MultiIndex.is_monotonic_increasing` method on a ``MultiIndex`` shows if the
+index is sorted:
 
 .. ipython:: python
 
-   dfm.index.is_lexsorted()
-   dfm.index.lexsort_depth
+   dfm.index.is_monotonic_increasing
 
 .. ipython:: python
 
    dfm = dfm.sort_index()
    dfm
-   dfm.index.is_lexsorted()
-   dfm.index.lexsort_depth
+   dfm.index.is_monotonic_increasing
 
 And now selection works as expected.
 
diff --git a/doc/source/whatsnew/v0.20.0.rst b/doc/source/whatsnew/v0.20.0.rst
index 2cb8e13e9a18a..01966c9f50fb1 100644
--- a/doc/source/whatsnew/v0.20.0.rst
+++ b/doc/source/whatsnew/v0.20.0.rst
@@ -873,10 +873,13 @@ This is *unchanged* from prior versions, but shown for illustration purposes:
                      index=pd.MultiIndex.from_product([list('BA'), range(3)]))
    df
 
-.. ipython:: python
+.. code-block:: python
 
-   df.index.is_lexsorted()
-   df.index.is_monotonic
+   In [87]: df.index.is_lexsorted()
+   Out[87]: False
+
+   In [88]: df.index.is_monotonic
+   Out[88]: False
 
 Sorting works as expected
 
@@ -884,10 +887,13 @@ Sorting works as expected
 
    df.sort_index()
 
-.. ipython:: python
+.. code-block:: python
+
+   In [90]: df.sort_index().index.is_lexsorted()
+   Out[90]: True
 
-   df.sort_index().index.is_lexsorted()
-   df.sort_index().index.is_monotonic
+   In [91]: df.sort_index().index.is_monotonic
+   Out[91]: True
 
 However, this example, which has a non-monotonic 2nd level,
 doesn't behave as desired.
@@ -919,11 +925,23 @@ Previous behavior:
 
 New behavior:
 
-.. ipython:: python
+.. code-block:: python
 
-   df.sort_index()
-   df.sort_index().index.is_lexsorted()
-   df.sort_index().index.is_monotonic
+   In [94]: df.sort_index()
+   Out[94]:
+         value
+   a aa      2
+   bb      1
+   b aa      4
+   bb      3
+
+   [4 rows x 1 columns]
+
+   In [95]: df.sort_index().index.is_lexsorted()
+   Out[95]: True
+
+   In [96]: df.sort_index().index.is_monotonic
+   Out[96]: True
 
 
 .. _whatsnew_0200.api_breaking.groupby_describe:
diff --git a/doc/source/whatsnew/v1.3.0.rst b/doc/source/whatsnew/v1.3.0.rst
index 931ec895cc73f..808707f1dde08 100644
--- a/doc/source/whatsnew/v1.3.0.rst
+++ b/doc/source/whatsnew/v1.3.0.rst
@@ -155,6 +155,7 @@ Deprecations
 - Deprecating allowing scalars passed to the :class:`Categorical` constructor (:issue:`38433`)
 - Deprecated allowing subclass-specific keyword arguments in the :class:`Index` constructor, use the specific subclass directly instead (:issue:`14093`,:issue:`21311`,:issue:`22315`,:issue:`26974`)
 - Deprecated ``astype`` of datetimelike (``timedelta64[ns]``, ``datetime64[ns]``, ``Datetime64TZDtype``, ``PeriodDtype``) to integer dtypes, use ``values.view(...)`` instead (:issue:`38544`)
+- Deprecated :meth:`MultiIndex.is_lexsorted` and :meth:`MultiIndex.lexsort_depth` as a public methods, users should use :meth:`MultiIndex.is_monotonic_increasing` instead (:issue:`32259`)
 - Deprecated keyword ``try_cast`` in :meth:`Series.where`, :meth:`Series.mask`, :meth:`DataFrame.where`, :meth:`DataFrame.mask`; cast results manually if desired (:issue:`38836`)
 -
 
diff --git a/pandas/core/indexes/multi.py b/pandas/core/indexes/multi.py
index c80dadcc42022..6c414ef4f0d69 100644
--- a/pandas/core/indexes/multi.py
+++ b/pandas/core/indexes/multi.py
@@ -391,11 +391,11 @@ def _verify_integrity(
                     f"Level values must be unique: {list(level)} on level {i}"
                 )
         if self.sortorder is not None:
-            if self.sortorder > self._lexsort_depth():
+            if self.sortorder > self._codes_lexsort_depth():
                 raise ValueError(
                     "Value for sortorder must be inferior or equal to actual "
                     f"lexsort_depth: sortorder {self.sortorder} "
-                    f"with lexsort_depth {self._lexsort_depth()}"
+                    f"with lexsort_depth {self._codes_lexsort_depth()}"
                 )
 
         codes = [
@@ -1809,6 +1809,15 @@ def _is_all_dates(self) -> bool:
         return False
 
     def is_lexsorted(self) -> bool:
+        warnings.warn(
+            "MultiIndex.is_lexsorted is deprecated as a public function, "
+            "users should use MultiIndex.is_monotonic_increasing instead.",
+            FutureWarning,
+            stacklevel=2,
+        )
+        return self._is_lexsorted()
+
+    def _is_lexsorted(self) -> bool:
         """
         Return True if the codes are lexicographically sorted.
 
@@ -1840,15 +1849,19 @@ def is_lexsorted(self) -> bool:
         ...                            ['bb', 'aa', 'aa', 'bb']]).is_lexsorted()
         False
         """
-        return self.lexsort_depth == self.nlevels
+        return self._lexsort_depth == self.nlevels
 
     @cache_readonly
     def lexsort_depth(self):
-        if self.sortorder is not None:
-            return self.sortorder
-
-        return self._lexsort_depth()
+        warnings.warn(
+            "MultiIndex.is_lexsorted is deprecated as a public function, "
+            "users should use MultiIndex.is_monotonic_increasing instead.",
+            FutureWarning,
+            stacklevel=2,
+        )
+        return self._lexsort_depth
 
+    @cache_readonly
     def _lexsort_depth(self) -> int:
         """
         Compute and return the lexsort_depth, the number of levels of the
@@ -1858,6 +1871,11 @@ def _lexsort_depth(self) -> int:
         -------
         int
         """
+        if self.sortorder is not None:
+            return self.sortorder
+        return self._codes_lexsort_depth()
+
+    def _codes_lexsort_depth(self) -> int:
         int64_codes = [ensure_int64(level_codes) for level_codes in self.codes]
         for k in range(self.nlevels, 0, -1):
             if libalgos.is_lexsorted(int64_codes[:k]):
@@ -1898,7 +1916,7 @@ def _sort_levels_monotonic(self):
                     ('b', 'bb')],
                    )
         """
-        if self.is_lexsorted() and self.is_monotonic:
+        if self._is_lexsorted() and self.is_monotonic:
             return self
 
         new_levels = []
@@ -2184,7 +2202,7 @@ def drop(self, codes, level=None, errors="raise"):
                     step = loc.step if loc.step is not None else 1
                     inds.extend(range(loc.start, loc.stop, step))
                 elif com.is_bool_indexer(loc):
-                    if self.lexsort_depth == 0:
+                    if self._lexsort_depth == 0:
                         warnings.warn(
                             "dropping on a non-lexsorted multi-index "
                             "without a level parameter may impact performance.",
@@ -2755,10 +2773,10 @@ def slice_locs(self, start=None, end=None, step=None, kind=None):
         return super().slice_locs(start, end, step, kind=kind)
 
     def _partial_tup_index(self, tup, side="left"):
-        if len(tup) > self.lexsort_depth:
+        if len(tup) > self._lexsort_depth:
             raise UnsortedIndexError(
                 f"Key length ({len(tup)}) was greater than MultiIndex lexsort depth "
-                f"({self.lexsort_depth})"
+                f"({self._lexsort_depth})"
             )
 
         n = len(tup)
@@ -2897,7 +2915,7 @@ def _maybe_to_slice(loc):
         # break the key into 2 parts based on the lexsort_depth of the index;
         # the first part returns a continuous slice of the index; the 2nd part
         # needs linear search within the slice
-        i = self.lexsort_depth
+        i = self._lexsort_depth
         lead_key, follow_key = key[:i], key[i:]
         start, stop = (
             self.slice_locs(lead_key, lead_key) if lead_key else (0, len(self))
@@ -3150,7 +3168,7 @@ def convert_indexer(start, stop, step, indexer=indexer, codes=level_codes):
                 stop = getattr(stop, "stop", stop)
                 return convert_indexer(start, stop, step)
 
-            elif level > 0 or self.lexsort_depth == 0 or step is not None:
+            elif level > 0 or self._lexsort_depth == 0 or step is not None:
                 # need to have like semantics here to right
                 # searching as when we are using a slice
                 # so include the stop+1 (so we include stop)
@@ -3165,7 +3183,7 @@ def convert_indexer(start, stop, step, indexer=indexer, codes=level_codes):
 
             idx = self._get_loc_single_level_index(level_index, key)
 
-            if level > 0 or self.lexsort_depth == 0:
+            if level > 0 or self._lexsort_depth == 0:
                 # Desired level is not sorted
                 locs = np.array(level_codes == idx, dtype=bool, copy=False)
                 if not locs.any():
@@ -3222,10 +3240,10 @@ def get_locs(self, seq):
 
         # must be lexsorted to at least as many levels
         true_slices = [i for (i, s) in enumerate(com.is_true_slices(seq)) if s]
-        if true_slices and true_slices[-1] >= self.lexsort_depth:
+        if true_slices and true_slices[-1] >= self._lexsort_depth:
             raise UnsortedIndexError(
                 "MultiIndex slicing requires the index to be lexsorted: slicing "
-                f"on levels {true_slices}, lexsort depth {self.lexsort_depth}"
+                f"on levels {true_slices}, lexsort depth {self._lexsort_depth}"
             )
         # indexer
         # this is the list of all values that we want to select
@@ -3347,7 +3365,7 @@ def _reorder_indexer(
         """
         # If the index is lexsorted and the list_like label in seq are sorted
         # then we do not need to sort
-        if self.is_lexsorted():
+        if self._is_lexsorted():
             need_sort = False
             for i, k in enumerate(seq):
                 if is_list_like(k):
diff --git a/pandas/core/reshape/reshape.py b/pandas/core/reshape/reshape.py
index c197e142fecbc..d855886fb725f 100644
--- a/pandas/core/reshape/reshape.py
+++ b/pandas/core/reshape/reshape.py
@@ -617,7 +617,7 @@ def _convert_level_number(level_num, columns):
             roll_columns = roll_columns.swaplevel(lev1, lev2)
         this.columns = roll_columns
 
-    if not this.columns.is_lexsorted():
+    if not this.columns._is_lexsorted():
         # Workaround the edge case where 0 is one of the column names,
         # which interferes with trying to sort based on the first
         # level
diff --git a/pandas/tests/frame/methods/test_drop.py b/pandas/tests/frame/methods/test_drop.py
index dd93eac8b7110..747ee25e97331 100644
--- a/pandas/tests/frame/methods/test_drop.py
+++ b/pandas/tests/frame/methods/test_drop.py
@@ -162,7 +162,7 @@ def test_drop_multiindex_not_lexsorted(self):
             [("a", ""), ("b1", "c1"), ("b2", "c2")], names=["b", "c"]
         )
         lexsorted_df = DataFrame([[1, 3, 4]], columns=lexsorted_mi)
-        assert lexsorted_df.columns.is_lexsorted()
+        assert lexsorted_df.columns._is_lexsorted()
 
         # define the non-lexsorted version
         not_lexsorted_df = DataFrame(
@@ -172,7 +172,7 @@ def test_drop_multiindex_not_lexsorted(self):
             index="a", columns=["b", "c"], values="d"
         )
         not_lexsorted_df = not_lexsorted_df.reset_index()
-        assert not not_lexsorted_df.columns.is_lexsorted()
+        assert not not_lexsorted_df.columns._is_lexsorted()
 
         # compare the results
         tm.assert_frame_equal(lexsorted_df, not_lexsorted_df)
diff --git a/pandas/tests/frame/methods/test_sort_index.py b/pandas/tests/frame/methods/test_sort_index.py
index de847c12723b2..3be6a8453420e 100644
--- a/pandas/tests/frame/methods/test_sort_index.py
+++ b/pandas/tests/frame/methods/test_sort_index.py
@@ -24,7 +24,7 @@ def test_sort_index_and_reconstruction_doc_example(self):
                 levels=[["a", "b"], ["bb", "aa"]], codes=[[0, 0, 1, 1], [0, 1, 0, 1]]
             ),
         )
-        assert df.index.is_lexsorted()
+        assert df.index._is_lexsorted()
         assert not df.index.is_monotonic
 
         # sort it
@@ -35,7 +35,6 @@ def test_sort_index_and_reconstruction_doc_example(self):
             ),
         )
         result = df.sort_index()
-        assert result.index.is_lexsorted()
         assert result.index.is_monotonic
 
         tm.assert_frame_equal(result, expected)
@@ -43,7 +42,6 @@ def test_sort_index_and_reconstruction_doc_example(self):
         # reconstruct
         result = df.sort_index().copy()
         result.index = result.index._sort_levels_monotonic()
-        assert result.index.is_lexsorted()
         assert result.index.is_monotonic
 
         tm.assert_frame_equal(result, expected)
@@ -524,14 +522,13 @@ def test_sort_index_and_reconstruction(self):
                 [(0.5, "a"), (0.5, "b"), (0.8, "a"), (0.8, "b")]
             ),
         )
-        assert expected.index.is_lexsorted()
+        assert expected.index._is_lexsorted()
 
         result = DataFrame(
             [[1, 1], [2, 2], [1, 1], [2, 2]],
             index=MultiIndex.from_product([[0.5, 0.8], list("ab")]),
         )
         result = result.sort_index()
-        assert result.index.is_lexsorted()
         assert result.index.is_monotonic
 
         tm.assert_frame_equal(result, expected)
@@ -543,14 +540,13 @@ def test_sort_index_and_reconstruction(self):
             ),
         )
         result = result.sort_index()
-        assert result.index.is_lexsorted()
+        assert result.index._is_lexsorted()
 
         tm.assert_frame_equal(result, expected)
 
         concatted = pd.concat([df, df], keys=[0.8, 0.5])
         result = concatted.sort_index()
 
-        assert result.index.is_lexsorted()
         assert result.index.is_monotonic
 
         tm.assert_frame_equal(result, expected)
@@ -567,13 +563,10 @@ def test_sort_index_and_reconstruction(self):
         df.columns = df.columns.set_levels(
             pd.to_datetime(df.columns.levels[1]), level=1
         )
-        assert not df.columns.is_lexsorted()
         assert not df.columns.is_monotonic
         result = df.sort_index(axis=1)
-        assert result.columns.is_lexsorted()
         assert result.columns.is_monotonic
         result = df.sort_index(axis=1, level=1)
-        assert result.columns.is_lexsorted()
         assert result.columns.is_monotonic
 
     # TODO: better name, de-duplicate with test_sort_index_level above
@@ -614,7 +607,7 @@ def test_sort_index_level_large_cardinality(self):
 
         # it works!
         result = df.sort_index(level=0)
-        assert result.index.lexsort_depth == 3
+        assert result.index._lexsort_depth == 3
 
         # GH#2684 (int32)
         index = MultiIndex.from_arrays([np.arange(4000)] * 3)
@@ -623,7 +616,7 @@ def test_sort_index_level_large_cardinality(self):
         # it works!
         result = df.sort_index(level=0)
         assert (result.dtypes.values == df.dtypes.values).all()
-        assert result.index.lexsort_depth == 3
+        assert result.index._lexsort_depth == 3
 
     def test_sort_index_level_by_name(self):
         mi = MultiIndex(
diff --git a/pandas/tests/groupby/test_groupby.py b/pandas/tests/groupby/test_groupby.py
index e5021b7b4dd5f..ae30879a094ab 100644
--- a/pandas/tests/groupby/test_groupby.py
+++ b/pandas/tests/groupby/test_groupby.py
@@ -1579,7 +1579,7 @@ def test_groupby_multiindex_not_lexsorted():
         [("a", ""), ("b1", "c1"), ("b2", "c2")], names=["b", "c"]
     )
     lexsorted_df = DataFrame([[1, 3, 4]], columns=lexsorted_mi)
-    assert lexsorted_df.columns.is_lexsorted()
+    assert lexsorted_df.columns._is_lexsorted()
 
     # define the non-lexsorted version
     not_lexsorted_df = DataFrame(
@@ -1589,7 +1589,7 @@ def test_groupby_multiindex_not_lexsorted():
         index="a", columns=["b", "c"], values="d"
     )
     not_lexsorted_df = not_lexsorted_df.reset_index()
-    assert not not_lexsorted_df.columns.is_lexsorted()
+    assert not not_lexsorted_df.columns._is_lexsorted()
 
     # compare the results
     tm.assert_frame_equal(lexsorted_df, not_lexsorted_df)
@@ -1604,7 +1604,7 @@ def test_groupby_multiindex_not_lexsorted():
     df = DataFrame(
         {"x": ["a", "a", "b", "a"], "y": [1, 1, 2, 2], "z": [1, 2, 3, 4]}
     ).set_index(["x", "y"])
-    assert not df.index.is_lexsorted()
+    assert not df.index._is_lexsorted()
 
     for level in [0, 1, [0, 1]]:
         for sort in [False, True]:
diff --git a/pandas/tests/indexes/multi/test_drop.py b/pandas/tests/indexes/multi/test_drop.py
index f7b1bc4729428..f43f1beba1cea 100644
--- a/pandas/tests/indexes/multi/test_drop.py
+++ b/pandas/tests/indexes/multi/test_drop.py
@@ -126,7 +126,7 @@ def test_drop_not_lexsorted():
     # define the lexsorted version of the multi-index
     tuples = [("a", ""), ("b1", "c1"), ("b2", "c2")]
     lexsorted_mi = MultiIndex.from_tuples(tuples, names=["b", "c"])
-    assert lexsorted_mi.is_lexsorted()
+    assert lexsorted_mi._is_lexsorted()
 
     # and the not-lexsorted version
     df = pd.DataFrame(
@@ -135,7 +135,7 @@ def test_drop_not_lexsorted():
     df = df.pivot_table(index="a", columns=["b", "c"], values="d")
     df = df.reset_index()
     not_lexsorted_mi = df.columns
-    assert not not_lexsorted_mi.is_lexsorted()
+    assert not not_lexsorted_mi._is_lexsorted()
 
     # compare the results
     tm.assert_index_equal(lexsorted_mi, not_lexsorted_mi)
diff --git a/pandas/tests/indexes/multi/test_lexsort.py b/pandas/tests/indexes/multi/test_lexsort.py
index 1d2ad8e02697e..c37172ad7a980 100644
--- a/pandas/tests/indexes/multi/test_lexsort.py
+++ b/pandas/tests/indexes/multi/test_lexsort.py
@@ -1,4 +1,5 @@
 from pandas import MultiIndex
+import pandas._testing as tm
 
 
 class TestIsLexsorted:
@@ -8,18 +9,23 @@ def test_is_lexsorted(self):
         index = MultiIndex(
             levels=levels, codes=[[0, 0, 0, 1, 1, 1], [0, 1, 2, 0, 1, 2]]
         )
-        assert index.is_lexsorted()
+        assert index._is_lexsorted()
 
         index = MultiIndex(
             levels=levels, codes=[[0, 0, 0, 1, 1, 1], [0, 1, 2, 0, 2, 1]]
         )
-        assert not index.is_lexsorted()
+        assert not index._is_lexsorted()
 
         index = MultiIndex(
             levels=levels, codes=[[0, 0, 1, 0, 1, 1], [0, 1, 0, 2, 2, 1]]
         )
-        assert not index.is_lexsorted()
-        assert index.lexsort_depth == 0
+        assert not index._is_lexsorted()
+        assert index._lexsort_depth == 0
+
+    def test_is_lexsorted_deprecation(self):
+        # GH 32259
+        with tm.assert_produces_warning():
+            MultiIndex.from_arrays([["a", "b", "c"], ["d", "f", "e"]]).is_lexsorted()
 
 
 class TestLexsortDepth:
@@ -33,14 +39,19 @@ def test_lexsort_depth(self):
         index = MultiIndex(
             levels=levels, codes=[[0, 0, 0, 1, 1, 1], [0, 1, 2, 0, 1, 2]], sortorder=2
         )
-        assert index.lexsort_depth == 2
+        assert index._lexsort_depth == 2
 
         index = MultiIndex(
             levels=levels, codes=[[0, 0, 0, 1, 1, 1], [0, 1, 2, 0, 2, 1]], sortorder=1
         )
-        assert index.lexsort_depth == 1
+        assert index._lexsort_depth == 1
 
         index = MultiIndex(
             levels=levels, codes=[[0, 0, 1, 0, 1, 1], [0, 1, 0, 2, 2, 1]], sortorder=0
         )
-        assert index.lexsort_depth == 0
+        assert index._lexsort_depth == 0
+
+    def test_lexsort_depth_deprecation(self):
+        # GH 32259
+        with tm.assert_produces_warning():
+            MultiIndex.from_arrays([["a", "b", "c"], ["d", "f", "e"]]).lexsort_depth
diff --git a/pandas/tests/indexes/multi/test_sorting.py b/pandas/tests/indexes/multi/test_sorting.py
index 3de78c5e982d3..5f957f54c05cd 100644
--- a/pandas/tests/indexes/multi/test_sorting.py
+++ b/pandas/tests/indexes/multi/test_sorting.py
@@ -132,27 +132,25 @@ def test_unsortedindex_doc_examples():
     with pytest.raises(UnsortedIndexError, match=msg):
         dfm.loc[(0, "y"):(1, "z")]
 
-    assert not dfm.index.is_lexsorted()
-    assert dfm.index.lexsort_depth == 1
+    assert not dfm.index._is_lexsorted()
+    assert dfm.index._lexsort_depth == 1
 
     # sort it
     dfm = dfm.sort_index()
     dfm.loc[(1, "z")]
     dfm.loc[(0, "y"):(1, "z")]
 
-    assert dfm.index.is_lexsorted()
-    assert dfm.index.lexsort_depth == 2
+    assert dfm.index._is_lexsorted()
+    assert dfm.index._lexsort_depth == 2
 
 
 def test_reconstruct_sort():
 
     # starts off lexsorted & monotonic
     mi = MultiIndex.from_arrays([["A", "A", "B", "B", "B"], [1, 2, 1, 2, 3]])
-    assert mi.is_lexsorted()
     assert mi.is_monotonic
 
     recons = mi._sort_levels_monotonic()
-    assert recons.is_lexsorted()
     assert recons.is_monotonic
     assert mi is recons
 
@@ -164,11 +162,9 @@ def test_reconstruct_sort():
         [("z", "a"), ("x", "a"), ("y", "b"), ("x", "b"), ("y", "a"), ("z", "b")],
         names=["one", "two"],
     )
-    assert not mi.is_lexsorted()
     assert not mi.is_monotonic
 
     recons = mi._sort_levels_monotonic()
-    assert not recons.is_lexsorted()
     assert not recons.is_monotonic
 
     assert mi.equals(recons)
@@ -180,11 +176,9 @@ def test_reconstruct_sort():
         codes=[[0, 1, 0, 2], [2, 0, 0, 1]],
         names=["col1", "col2"],
     )
-    assert not mi.is_lexsorted()
     assert not mi.is_monotonic
 
     recons = mi._sort_levels_monotonic()
-    assert not recons.is_lexsorted()
     assert not recons.is_monotonic
 
     assert mi.equals(recons)
diff --git a/pandas/tests/indexing/multiindex/test_indexing_slow.py b/pandas/tests/indexing/multiindex/test_indexing_slow.py
index efe1e0f0d75b5..eaa7029b118b1 100644
--- a/pandas/tests/indexing/multiindex/test_indexing_slow.py
+++ b/pandas/tests/indexing/multiindex/test_indexing_slow.py
@@ -86,5 +86,5 @@ def validate(mi, df, key):
             df = frame.sort_values(by=cols[:lexsort_depth])
 
         mi = df.set_index(cols[:-1])
-        assert not mi.index.lexsort_depth < lexsort_depth
+        assert not mi.index._lexsort_depth < lexsort_depth
         validate(mi, df, key)
diff --git a/pandas/tests/indexing/multiindex/test_slice.py b/pandas/tests/indexing/multiindex/test_slice.py
index 51684f092aefd..b60135a802b8e 100644
--- a/pandas/tests/indexing/multiindex/test_slice.py
+++ b/pandas/tests/indexing/multiindex/test_slice.py
@@ -144,9 +144,9 @@ def test_per_axis_per_level_getitem(self):
         tm.assert_frame_equal(result, expected)
 
         # not lexsorted
-        assert df.index.lexsort_depth == 2
+        assert df.index._lexsort_depth == 2
         df = df.sort_index(level=1, axis=0)
-        assert df.index.lexsort_depth == 0
+        assert df.index._lexsort_depth == 0
 
         msg = (
             "MultiIndex slicing requires the index to be "
diff --git a/pandas/tests/indexing/multiindex/test_sorted.py b/pandas/tests/indexing/multiindex/test_sorted.py
index 8a013c769f2cc..b3e8c4a83b9fc 100644
--- a/pandas/tests/indexing/multiindex/test_sorted.py
+++ b/pandas/tests/indexing/multiindex/test_sorted.py
@@ -50,16 +50,13 @@ def test_frame_getitem_not_sorted2(self, key):
         with tm.assert_produces_warning(FutureWarning):
             return_value = df2.index.set_codes([0, 1, 0, 2], level="col1", inplace=True)
         assert return_value is None
-        assert not df2.index.is_lexsorted()
         assert not df2.index.is_monotonic
 
         assert df2_original.index.equals(df2.index)
         expected = df2.sort_index(key=key)
-        assert expected.index.is_lexsorted()
         assert expected.index.is_monotonic
 
         result = df2.sort_index(level=0, key=key)
-        assert result.index.is_lexsorted()
         assert result.index.is_monotonic
         tm.assert_frame_equal(result, expected)
 
diff --git a/pandas/tests/test_multilevel.py b/pandas/tests/test_multilevel.py
index 84aa8ec6f970f..88fecc7635475 100644
--- a/pandas/tests/test_multilevel.py
+++ b/pandas/tests/test_multilevel.py
@@ -401,11 +401,9 @@ def test_sort_non_lexsorted(self):
         )
 
         df = DataFrame({"col": range(len(idx))}, index=idx, dtype="int64")
-        assert df.index.is_lexsorted() is False
         assert df.index.is_monotonic is False
 
         sorted = df.sort_index()
-        assert sorted.index.is_lexsorted() is True
         assert sorted.index.is_monotonic is True
 
         expected = DataFrame(


Code review comments:
diff_hunk: @@ -1858,6 +1871,11 @@ def _lexsort_depth(self) -> int:
         -------
         int
         """
+        if self.sortorder is not None:
+            return self.sortorder
+        return self._codes_lexsort_depth()
+
+    def _codes_lexsort_depth(self) -> int:
path: pandas/core/indexes/multi.py
text: @user1:
this is *very* confusing, why do you think you need yet another method here? we want to reduce the api

@author:
Yes, I totally agree - see this comment for why I thought it was necessary: https://github.com/pandas-dev/pandas/pull/38701#discussion_r550780878 :

> 
> the difficulty I'm running into is that in `pandas/core/indexes/multi.py` there's
> ```python
>         if self.sortorder is not None:
>             if self.sortorder > self._lexsort_depth():
>                 raise ValueError(
>                     "Value for sortorder must be inferior or equal to actual "
>                     f"lexsort_depth: sortorder {self.sortorder} "
>                     f"with lexsort_depth {self._lexsort_depth()}"
>                 )
> ```
> so I can't just move `self.sortorder` into `self._lexsort_depth` else this will never raise, as `self._lexsort_depth()` would always just return `self.sortorder` in this snippet.

Would it be less confusing if this was a module-level function? There must be some way of calling only this part of `_lexsort_depth` because it's used in `_verify_integrity` from pandas/core/indexes/multi.py :

https://github.com/pandas-dev/pandas/blob/d642b67569fdd1f3f3784d3ea79708211dd6a70c/pandas/core/indexes/multi.py#L393-L399

@author:
I've made it a module-level function, is this clearer?