Repository: narwhals-dev/narwhals
Pull request #2192 (2025-03-11 19:44:38)

feat: let `nw.Enum` accept categories, map pandas ordered categorical to Enum (only in main namespace, not stable.v1)
## What type of PR is this? (check all applicable)

- [ ] 💾 Refactor
- [x] ✨ Feature
- [ ] 🐛 Bug Fix
- [ ] 🔧 Optimization
- [ ] 📝 Documentation
- [ ] ✅ Test
- [ ] 🐳 Other

## Related issues

- Related issue #1541

## Checklist

- [x] Code follows style guide (ruff)
- [x] Tests added
- [ ] Documented the changes

## If you have comments or can explain your changes, please do so below

Adds support for the `nw.Enum` datatype for pandas (backed by `pandas.CategoricalDtype(…, ordered=True)`

The current implementation diverges from pandas/Polars in two broad ways
1. We do not check for None, NaN, or Null (both pandas and Polars raise when they construct a CategoricalDtype/Enum with these in the categories list.
2. pandas allows arbitrary (hashable) objects to be stored as the categories, whereas Polars only allows integers. The current implementation is type-hinted to follow suit with pandas, but we do not perform this check instead letting the backend library raise as needed.

```python
>>> import narwhals as nw
>>> import pandas as pd
>>> s = nw.new_series('foo', ['a', 'b', 'c'], dtype=nw.Enum(['a', 'b', 'c', 'd']), native_namespace=pd)
>>> s
┌───────────────────────────────────────────────┐
|                Narwhals Series                |
|-----------------------------------------------|
|0    a                                         |
|1    b                                         |
|2    c                                         |
|Name: foo, dtype: category                     |
|Categories (4, object): ['a' < 'b' < 'c' < 'd']|
└───────────────────────────────────────────────┘
```

MarcoGorelli: thanks! it's encouraging that this doesn't break downstream tests

sorry i didn't get round to it for tomorrow's release, will try to get it in for next week's one 👍 
MarcoGorelli: thanks Cam - looks like there's a xpass
```
FAILED tests/series_only/cast_test.py::test_cast_to_enum_v1[modin[pyarrow]]
```

dangotbanned: > thanks @camriddell , and @dangotbanned for reviews!
> 
> i think this is mostly ready?

Thanks @MarcoGorelli 

Yeah I think after (https://github.com/narwhals-dev/narwhals/pull/2192#discussion_r2041081799) - my only other unresolved note was adding some guards like in https://github.com/pola-rs/polars/blob/py-1.26.0/py-polars/polars/datatypes/classes.py#L645-L727

@camriddell seemed on board with the suggestion - we've just been a bit more active 😉
- https://github.com/narwhals-dev/narwhals/pull/2192#discussion_r2035728791
- https://github.com/narwhals-dev/narwhals/pull/2192#discussion_r2035794136
camriddell: > > thanks @camriddell , and @dangotbanned for reviews!
> > i think this is mostly ready?
> 
> Thanks @MarcoGorelli
> 
> Yeah I think after ([#2192 (comment)](https://github.com/narwhals-dev/narwhals/pull/2192#discussion_r2041081799)) - my only other unresolved note was adding some guards like in https://github.com/pola-rs/polars/blob/py-1.26.0/py-polars/polars/datatypes/classes.py#L645-L727
> 
> @camriddell seemed on board with the suggestion - we've just been a bit more active 😉
> 
> * [Feat: let `nw.Enum` accept categories, map pandas ordered categorical to Enum (only in main namespace, not stable.v1) #2192 (comment)](https://github.com/narwhals-dev/narwhals/pull/2192#discussion_r2035728791)
> * [Feat: let `nw.Enum` accept categories, map pandas ordered categorical to Enum (only in main namespace, not stable.v1) #2192 (comment)](https://github.com/narwhals-dev/narwhals/pull/2192#discussion_r2035794136)
> 
> My concern there is if we leave it to raise when it reaches each backend - we won't have consistent errors and the traceback might not be super clear where the origin was

I think I'm in favor of taking the strictest route here, and that is to mimic Polars again:
1. Only allow containers of strings as valid categories
2. Explicitly disallow `None` and `float('nan')`
3. Explicitly disallow

 
Adding onto this discussion:

If we only let backends raise, we will hit an issue where some code only work with specific backends which reduces the purpose of Narwhals. With the current Enum targeting pandas_like and Polars, I see this primarily happening in the space that Polars ONLY allows strings as valid category members, yet pandas allows any hashable Py object. I'm leaning towards taking the stricter set of requirements (only strings) and sticking with Polars behavior.

For the checks themselves:
- detecting duplicates is very doable.
- detecting if inputs are `NaN` can likely be relied on by verifying `value != value` as this follows IEEE-754 impl. so it should be fairly uniform across backends (I believe we also use this semantic in other places internally).
- detecting if inputs are Null may be problematic since we would need to verify is one passed along None, `pandas.NA`, and any other "Null" representing value out there.

Is it too aggressive to propose that categories should only be a container of Python built-in types and error on duplicate values, `float('nan')` and `None`? I'll need to do a usage check to see how frequently `Enum`s are derived in patterns opposite to this, I would guess that they are most often created from explicit user-defined sequences using Python built-ins. 

That said, maybe supporting both arbitrary Py Sequences and narwhals Series can be done for the most flexible approach? That way we can support either pattern for users.
dangotbanned: https://github.com/narwhals-dev/narwhals/pull/2192#pullrequestreview-2775252785

> This is going to cause issues for people even just inspecting the schema of a dataframe

That's a good point @MarcoGorelli 

If someone is currently doing that operation, on `v1` would look like this:

```py
import pandas as pd

from narwhals.stable import v1 as nw_v1

s = pd.Series([1, 2, 3], dtype=pd.CategoricalDtype(ordered=True))
>>> nw_v1.from_native(s, series_only=True).dtype
Categorical
```

So far we've had two options:
1. Being lax with categories [1](https://github.com/narwhals-dev/narwhals/pull/2192#discussion_r2035728791), [2](https://github.com/narwhals-dev/narwhals/pull/2192#discussion_r2040722920)
2. Using the stricter rules from `polars` [3](https://github.com/narwhals-dev/narwhals/pull/2192#issuecomment-2803171089), [4](https://github.com/narwhals-dev/narwhals/pull/2192#issuecomment-2799896654)

I see two other tweaks we could do to option **2** - when we can't meet the constraints of [`pl.Enum`](https://docs.pola.rs/api/python/stable/reference/api/polars.datatypes.Enum.html)
- Just continue mapping `pd.CategoricalDtype` -> `nw.Categorical`
  - No change in behavior, breaks no-one
- Use an alternative constructor for `pd.CategoricalDtype` -> `nw.Enum`
  - So we'd still reject `nw.Enum([1, 2, 3])`
  - But we'd allow existing ordered categoricals to be represented by an ordered type

I think either of those would solve the problem, but I think the simplest is to just keep using `nw.Categorical`
dangotbanned: ## `altair`-related
@MarcoGorelli 
> In particular, it would be a breaking change for Altair users, who'd no longer be able to plot pandas dataframes where columns are of categorical dtype and have non-string categories. 
> It's probably not showing up at the moment in the downstream tests because we were careful to use `narwhals.stable.v1`

I could be wrong, but I don't think we have any paths that would hit this - even if we *weren't* using `v1`?
AFAICT, `pandas` is handled natively - since the type conversion logic predates `narwhals` and (I assume) we didn't wanna make a breaking change.

### Impl
- https://github.com/vega/altair/blob/a6d0b7d5720cb414f3a3b61e58cdbe2bcbadcce4/altair/utils/data.py#L316-L333
- https://github.com/vega/altair/blob/a6d0b7d5720cb414f3a3b61e58cdbe2bcbadcce4/altair/utils/core.py#L674-L680
- https://github.com/vega/altair/blob/a6d0b7d5720cb414f3a3b61e58cdbe2bcbadcce4/altair/utils/core.py#L704-L714
- https://github.com/vega/altair/blob/a6d0b7d5720cb414f3a3b61e58cdbe2bcbadcce4/altair/utils/core.py#L388-L394
- https://github.com/vega/altair/blob/a6d0b7d5720cb414f3a3b61e58cdbe2bcbadcce4/altair/utils/core.py#L249-L252

### Tests
- https://github.com/vega/altair/blob/a6d0b7d5720cb414f3a3b61e58cdbe2bcbadcce4/tests/utils/test_utils.py#L46-L47
- https://github.com/vega/altair/blob/a6d0b7d5720cb414f3a3b61e58cdbe2bcbadcce4/tests/vegalite/v5/test_api.py#L238

### Docs
- https://altair-viz.github.io/user_guide/encodings/index.html#encoding-data-types
MarcoGorelli: This is the part that would break in Altair:

https://github.com/vega/altair/blob/f1e0049e6f6669ec46ec462cec81ce62aae8cbf2/altair/utils/core.py#L670-L671

It would also affect Plotly and other libraries

I think it's fine to be laxer here - Polars only allows string column names, but we allow pandas dataframe with non-string column names. Similarly, we can allow pandas dataframes with non-string categories

I think it's legit to do something like
```
s: nw.Series
categories = list(s.dtype.categories)
categories.append(new_value)
nw.new_series('a', values, dtype=nw.Enum(categories))
```
where the categories are taken from user inputs. If the user is starting with something which a backend permits, they can continue with that, no issues
MarcoGorelli: sure, thanks for explaining

true, there is a risk that someone writes something which doesn't end up working for polars, but i'd rather accept that risk that disallow people from passing valid pandas dataframes to narwhals

i think one reason for narwhals' relatively rapid growth has been that, relative to similar/competing projects, we've put a lot of emphasis on there not being any cost to existing pandas users
camriddell: @MarcoGorelli I believe the current version meets the changes you requested? When you have a chance can you take another look?
dangotbanned: > thanks, looks good to me!
> 
> @dangotbanned any objections?
> 

@MarcoGorelli just wanna double check this was what you asked for?

[remove enum duplication/null checks](https://github.com/narwhals-dev/narwhals/pull/2192/commits/3902a29f6aea952125484bfdd45018e29c87c4dc)

I thought in (https://github.com/narwhals-dev/narwhals/pull/2192#issuecomment-2812967946) you just wanted to allow non-strings - not allow duplicates and `None`
MarcoGorelli: thanks!

the backends themselves already disallow duplicates and nulls, so tbh i'm not super-bothered, especially given that people will usually be inspecting schemas of dataframes containing enums rather than making new ones

Patch:
diff --git a/docs/backcompat.md b/docs/backcompat.md
index e1ffa329..185d99a1 100644
--- a/docs/backcompat.md
+++ b/docs/backcompat.md
@@ -103,6 +103,7 @@ Here are exceptions to our backwards compatibility policy:
   need to rethink Narwhals. However, we expect such radical changes to be exceedingly unlikely.
 - We may consider making some type hints more precise.
 - Anything labelled "unstable".
+- We may sometimes need to bump the minimum versions of supported backends.
 
 In general, decision are driven by use-cases, and we conduct a search of public GitHub repositories
 before making any change.
@@ -113,6 +114,11 @@ before making any change.
 
 The following are differences between the main Narwhals namespace and `narwhals.stable.v1`:
 
+- Since Narwhals 1.35:
+
+  - pandas' ordered categoricals get mapped to `nw.Enum` instead of `nw.Categorical`.
+  - `nw.Enum` must be provided `categories` at instantiation.
+
 - Since Narwhals 1.29.0, `LazyFrame.gather_every` has been deprecated from the main namespace.
 
 - Since Narwhals 1.24.1, an empty or all-null object-dtype pandas Series is inferred to
diff --git a/narwhals/_dask/utils.py b/narwhals/_dask/utils.py
index 3d4ddbe6..ba152bd9 100644
--- a/narwhals/_dask/utils.py
+++ b/narwhals/_dask/utils.py
@@ -9,6 +9,7 @@ from narwhals._pandas_like.utils import select_columns_by_name
 from narwhals.dependencies import get_pandas
 from narwhals.dependencies import get_pyarrow
 from narwhals.utils import Implementation
+from narwhals.utils import Version
 from narwhals.utils import import_dtypes_module
 from narwhals.utils import isinstance_or_issubclass
 from narwhals.utils import parse_version
@@ -24,7 +25,6 @@ if TYPE_CHECKING:
     from narwhals._dask.dataframe import DaskLazyFrame
     from narwhals._dask.expr import DaskExpr
     from narwhals.dtypes import DType
-    from narwhals.utils import Version
 
 
 def maybe_evaluate_expr(df: DaskLazyFrame, obj: DaskExpr | object) -> dx.Series | object:
@@ -125,6 +125,20 @@ def narwhals_to_native_dtype(dtype: DType | type[DType], version: Version) -> An
         return "object"  # pragma: no cover
     if isinstance_or_issubclass(dtype, dtypes.Boolean):
         return "bool"
+    if isinstance_or_issubclass(dtype, dtypes.Enum):
+        if version is Version.V1:
+            msg = "Converting to Enum is not supported in narwhals.stable.v1"
+            raise NotImplementedError(msg)
+        if isinstance(dtype, dtypes.Enum):
+            import pandas as pd
+
+            # NOTE: `pandas-stubs.core.dtypes.dtypes.CategoricalDtype.categories` is too narrow
+            # Should be one of the `ListLike*` types
+            # https://github.com/pandas-dev/pandas-stubs/blob/8434bde95460b996323cc8c0fea7b0a8bb00ea26/pandas-stubs/_typing.pyi#L497-L505
+            return pd.CategoricalDtype(dtype.categories, ordered=True)  # pyright: ignore[reportArgumentType]
+        msg = "Can not cast / initialize Enum without categories present"
+        raise ValueError(msg)
+
     if isinstance_or_issubclass(dtype, dtypes.Categorical):
         return "category"
     if isinstance_or_issubclass(dtype, dtypes.Datetime):
diff --git a/narwhals/_pandas_like/utils.py b/narwhals/_pandas_like/utils.py
index e73369d1..7f1a0ea0 100644
--- a/narwhals/_pandas_like/utils.py
+++ b/narwhals/_pandas_like/utils.py
@@ -211,7 +211,9 @@ def rename(
 
 
 @functools.lru_cache(maxsize=16)
-def non_object_native_to_narwhals_dtype(dtype: str, version: Version) -> DType:
+def non_object_native_to_narwhals_dtype(native_dtype: Any, version: Version) -> DType:
+    dtype = str(native_dtype)
+
     dtypes = import_dtypes_module(version)
     if dtype in {"int64", "Int64", "Int64[pyarrow]", "int64[pyarrow]"}:
         return dtypes.Int64()
@@ -249,7 +251,13 @@ def non_object_native_to_narwhals_dtype(dtype: str, version: Version) -> DType:
         return dtypes.String()
     if dtype in {"bool", "boolean", "boolean[pyarrow]", "bool[pyarrow]"}:
         return dtypes.Boolean()
-    if dtype == "category" or dtype.startswith("dictionary<"):
+    if dtype.startswith("dictionary<"):
+        return dtypes.Categorical()
+    if dtype == "category":
+        if version is Version.V1:
+            return dtypes.Categorical()
+        if native_dtype.ordered:
+            return dtypes.Enum(native_dtype.categories)
         return dtypes.Categorical()
     if (match_ := PATTERN_PD_DATETIME.match(dtype)) or (
         match_ := PATTERN_PA_DATETIME.match(dtype)
@@ -310,7 +318,7 @@ def native_to_narwhals_dtype(
             return arrow_native_to_narwhals_dtype(native_dtype.to_arrow(), version)
         return arrow_native_to_narwhals_dtype(native_dtype.pyarrow_dtype, version)
     if str_dtype != "object":
-        return non_object_native_to_narwhals_dtype(str_dtype, version)
+        return non_object_native_to_narwhals_dtype(native_dtype, version)
     elif implementation is Implementation.DASK:
         # Per conversations with their maintainers, they don't support arbitrary
         # objects, so we can just return String.
@@ -471,8 +479,15 @@ def narwhals_to_native_dtype(  # noqa: PLR0915
             msg = "PyArrow>=11.0.0 is required for `Date` dtype."
         return "date32[pyarrow]"
     if isinstance_or_issubclass(dtype, dtypes.Enum):
-        msg = "Converting to Enum is not (yet) supported"
-        raise NotImplementedError(msg)
+        if version is Version.V1:
+            msg = "Converting to Enum is not supported in narwhals.stable.v1"
+            raise NotImplementedError(msg)
+        if isinstance(dtype, dtypes.Enum):
+            ns = implementation.to_native_namespace()
+            return ns.CategoricalDtype(dtype.categories, ordered=True)
+        msg = "Can not cast / initialize Enum without categories present"
+        raise ValueError(msg)
+
     if isinstance_or_issubclass(
         dtype, (dtypes.Struct, dtypes.Array, dtypes.List, dtypes.Time, dtypes.Binary)
     ):
diff --git a/narwhals/_polars/utils.py b/narwhals/_polars/utils.py
index 9452877e..dd0df4d8 100644
--- a/narwhals/_polars/utils.py
+++ b/narwhals/_polars/utils.py
@@ -17,6 +17,7 @@ from narwhals.exceptions import DuplicateError
 from narwhals.exceptions import InvalidOperationError
 from narwhals.exceptions import NarwhalsError
 from narwhals.exceptions import ShapeError
+from narwhals.utils import Version
 from narwhals.utils import import_dtypes_module
 from narwhals.utils import isinstance_or_issubclass
 
@@ -26,7 +27,6 @@ if TYPE_CHECKING:
     from narwhals._polars.expr import PolarsExpr
     from narwhals._polars.series import PolarsSeries
     from narwhals.dtypes import DType
-    from narwhals.utils import Version
 
     T = TypeVar("T")
 
@@ -110,8 +110,10 @@ def native_to_narwhals_dtype(
         return dtypes.Object()
     if dtype == pl.Categorical:
         return dtypes.Categorical()
-    if dtype == pl.Enum:
-        return dtypes.Enum()
+    if isinstance_or_issubclass(dtype, pl.Enum):
+        if version is Version.V1:
+            return dtypes.Enum()  # type: ignore[call-arg]
+        return dtypes.Enum(dtype.categories)
     if dtype == pl.Date:
         return dtypes.Date()
     if isinstance_or_issubclass(dtype, pl.Datetime):
@@ -185,9 +187,14 @@ def narwhals_to_native_dtype(
         return pl.Object()
     if dtype == dtypes.Categorical:
         return pl.Categorical()
-    if dtype == dtypes.Enum:
-        msg = "Converting to Enum is not (yet) supported"
-        raise NotImplementedError(msg)
+    if isinstance_or_issubclass(dtype, dtypes.Enum):
+        if version is Version.V1:
+            msg = "Converting to Enum is not supported in narwhals.stable.v1"
+            raise NotImplementedError(msg)
+        if isinstance(dtype, dtypes.Enum):
+            return pl.Enum(dtype.categories)
+        msg = "Can not cast / initialize Enum without categories present"
+        raise ValueError(msg)
     if dtype == dtypes.Date:
         return pl.Date()
     if dtype == dtypes.Time:
diff --git a/narwhals/dtypes.py b/narwhals/dtypes.py
index b31a0131..79da0b26 100644
--- a/narwhals/dtypes.py
+++ b/narwhals/dtypes.py
@@ -1,5 +1,6 @@
 from __future__ import annotations
 
+import enum
 from collections import OrderedDict
 from datetime import timezone
 from itertools import starmap
@@ -9,6 +10,7 @@ from typing import Mapping
 from narwhals.utils import isinstance_or_issubclass
 
 if TYPE_CHECKING:
+    from typing import Iterable
     from typing import Iterator
     from typing import Sequence
 
@@ -464,14 +466,31 @@ class Enum(DType):
     Polars has an Enum data type, while pandas and PyArrow do not.
 
     Examples:
-       >>> import polars as pl
        >>> import narwhals as nw
-       >>> data = ["beluga", "narwhal", "orca"]
-       >>> s_native = pl.Series(data, dtype=pl.Enum(data))
-       >>> nw.from_native(s_native, series_only=True).dtype
-       Enum
+       >>> nw.Enum(["beluga", "narwhal", "orca"])
+       Enum(categories=['beluga', 'narwhal', 'orca'])
     """
 
+    categories: Sequence[str]
+
+    def __init__(self, categories: Iterable[str] | type[enum.Enum]) -> None:
+        if isinstance(categories, type) and issubclass(categories, enum.Enum):
+            self.categories = tuple(member.value for member in categories)
+        else:
+            self.categories = tuple(categories)
+
+    def __eq__(self: Self, other: object) -> bool:
+        # allow comparing object instances to class
+        if type(other) is type:
+            return other is Enum
+        return isinstance(other, type(self)) and self.categories == other.categories
+
+    def __hash__(self: Self) -> int:  # pragma: no cover
+        return hash((self.__class__, tuple(self.categories)))
+
+    def __repr__(self: Self) -> str:  # pragma: no cover
+        return f"{type(self).__name__}(categories={list(self.categories)!r})"
+
 
 class Field:
     """Definition of a single field within a `Struct` DataType.
diff --git a/narwhals/stable/v1/_dtypes.py b/narwhals/stable/v1/_dtypes.py
index c3905bc1..5be99ec3 100644
--- a/narwhals/stable/v1/_dtypes.py
+++ b/narwhals/stable/v1/_dtypes.py
@@ -11,7 +11,7 @@ from narwhals.dtypes import Datetime as NwDatetime
 from narwhals.dtypes import Decimal
 from narwhals.dtypes import DType
 from narwhals.dtypes import Duration as NwDuration
-from narwhals.dtypes import Enum
+from narwhals.dtypes import Enum as NwEnum
 from narwhals.dtypes import Field
 from narwhals.dtypes import Float32
 from narwhals.dtypes import Float64
@@ -72,6 +72,35 @@ class Duration(NwDuration):
         return hash(self.__class__)
 
 
+class Enum(NwEnum):
+    """A fixed categorical encoding of a unique set of strings.
+
+    Polars has an Enum data type, while pandas and PyArrow do not.
+
+    Examples:
+       >>> import polars as pl
+       >>> import narwhals.stable.v1 as nw
+       >>> data = ["beluga", "narwhal", "orca"]
+       >>> s_native = pl.Series(data, dtype=pl.Enum(data))
+       >>> nw.from_native(s_native, series_only=True).dtype
+       Enum
+    """
+
+    def __init__(self: Self) -> None:
+        super(NwEnum, self).__init__()
+
+    def __eq__(self, other: DType | type[DType]) -> bool:  # type: ignore[override]
+        if type(other) is type:
+            return other in {type(self), NwEnum}
+        return isinstance(other, type(self))
+
+    def __hash__(self: Self) -> int:  # pragma: no cover
+        return super(NwEnum, self).__hash__()
+
+    def __repr__(self: Self) -> str:  # pragma: no cover
+        return super(NwEnum, self).__repr__()
+
+
 __all__ = [
     "Array",
     "Binary",


Test patch:
diff --git a/tests/dtypes_test.py b/tests/dtypes_test.py
index f7a447c6..ca5f0071 100644
--- a/tests/dtypes_test.py
+++ b/tests/dtypes_test.py
@@ -1,9 +1,12 @@
 from __future__ import annotations
 
+import enum
 from datetime import datetime
 from datetime import timedelta
 from datetime import timezone
 from typing import TYPE_CHECKING
+from typing import Any
+from typing import Iterable
 from typing import Literal
 
 import numpy as np
@@ -400,3 +403,35 @@ def test_cast_decimal_to_native() -> None:
                 .with_columns(a=nw.col("a").cast(nw.Decimal()))
                 .to_native()
             )
+
+
+@pytest.mark.parametrize(
+    "categories",
+    [
+        ["a", "b"],
+        [np.str_("a"), np.str_("b")],
+        enum.Enum("Test", "a b"),
+        [1, 2, 3],
+    ],
+)
+def test_enum_valid(categories: Iterable[Any] | type[enum.Enum]) -> None:
+    dtype = nw.Enum(categories)
+    assert dtype == nw.Enum
+    assert len(dtype.categories) == len([*categories])
+
+
+def test_enum_from_series() -> None:
+    pytest.importorskip("polars")
+    import polars as pl
+
+    elements = "a", "d", "e", "b", "c"
+    categories = pl.Series(elements)
+    categories_nw = nw.from_native(categories, series_only=True)
+    assert nw.Enum(categories_nw).categories == elements
+    assert nw.Enum(categories).categories == elements
+
+
+def test_enum_categories_immutable() -> None:
+    dtype = nw.Enum(["a", "b"])
+    with pytest.raises(TypeError, match="does not support item assignment"):
+        dtype.categories[0] = "c"  # type: ignore[index]
diff --git a/tests/frame/schema_test.py b/tests/frame/schema_test.py
index 964146da..a2876eda 100644
--- a/tests/frame/schema_test.py
+++ b/tests/frame/schema_test.py
@@ -176,6 +176,19 @@ def test_dtypes() -> None:
     assert df_from_pd.schema == df_from_pd.collect_schema() == expected
     assert {name: df_from_pd[name].dtype for name in df_from_pd.columns} == expected
 
+    df_from_pd = nw.from_native(df_pl.to_pandas(), eager_only=True)
+
+    pure_pd_expected = {
+        **expected,
+        "n": nw.Datetime,
+        "s": nw.Object,
+        "u": nw.Object,
+    }
+    assert df_from_pd.schema == df_from_pd.collect_schema() == pure_pd_expected
+    assert {
+        name: df_from_pd[name].dtype for name in df_from_pd.columns
+    } == pure_pd_expected
+
     df_from_pa = nw.from_native(df_pl.to_arrow(), eager_only=True)
 
     assert df_from_pa.schema == df_from_pa.collect_schema() == expected
diff --git a/tests/series_only/cast_test.py b/tests/series_only/cast_test.py
index 5d6d2ebd..8a465945 100644
--- a/tests/series_only/cast_test.py
+++ b/tests/series_only/cast_test.py
@@ -10,6 +10,7 @@ import narwhals as nw
 from tests.utils import PANDAS_VERSION
 
 if TYPE_CHECKING:
+    from tests.utils import Constructor
     from tests.utils import ConstructorEager
 
 
@@ -113,29 +114,23 @@ def test_unknown_to_int() -> None:
     assert nw.from_native(df).select(nw.col("a").cast(nw.Int64)).schema == {"a": nw.Int64}
 
 
-def test_cast_to_enum_polars() -> None:
-    pytest.importorskip("polars")
-    import polars as pl
-
-    # we don't yet support metadata in dtypes, so for now disallow this
-    # seems like a very niche use case anyway, and allowing it later wouldn't be
-    # backwards-incompatible
-    df_pl = pl.DataFrame({"a": ["a", "b"]}, schema={"a": pl.Categorical})
-    with pytest.raises(
-        NotImplementedError, match=r"Converting to Enum is not \(yet\) supported"
+def test_cast_to_enum_vmain(
+    request: pytest.FixtureRequest, constructor: Constructor
+) -> None:
+    # Backends that do not (yet) support Enum dtype
+    if any(
+        backend in str(constructor)
+        for backend in ["pyarrow_table", "duckdb", "sqlframe", "pyspark", "modin"]
     ):
-        nw.from_native(df_pl).select(nw.col("a").cast(nw.Enum))
+        request.applymarker(pytest.mark.xfail)
 
+    df_nw = nw.from_native(constructor({"a": ["a", "b"]}))
+    col_a = nw.col("a")
 
-def test_cast_to_enum_pandas() -> None:
-    pytest.importorskip("pandas")
-    import pandas as pd
-
-    # we don't yet support metadata in dtypes, so for now disallow this
-    # seems like a very niche use case anyway, and allowing it later wouldn't be
-    # backwards-incompatible
-    df_pd = pd.DataFrame({"a": ["a", "b"]}, dtype="category")
     with pytest.raises(
-        NotImplementedError, match=r"Converting to Enum is not \(yet\) supported"
+        ValueError, match="Can not cast / initialize Enum without categories present"
     ):
-        nw.from_native(df_pd).select(nw.col("a").cast(nw.Enum))
+        df_nw.select(col_a.cast(nw.Enum))
+
+    df_nw = df_nw.select(col_a.cast(nw.Enum(["a", "b"])))
+    assert df_nw.collect_schema() == {"a": nw.Enum(["a", "b"])}
diff --git a/tests/v1_test.py b/tests/v1_test.py
index 8c54624a..d8dc6084 100644
--- a/tests/v1_test.py
+++ b/tests/v1_test.py
@@ -7,10 +7,12 @@ import numpy as np
 import pandas as pd
 import pytest
 
+import narwhals as nw
 import narwhals.stable.v1 as nw_v1
 from tests.utils import PANDAS_VERSION
 from tests.utils import POLARS_VERSION
 from tests.utils import PYARROW_VERSION
+from tests.utils import Constructor
 from tests.utils import ConstructorEager
 from tests.utils import assert_equal_data
 
@@ -176,3 +178,56 @@ def test_int_select_pandas() -> None:
         nw_v1.exceptions.InvalidIntoExprError, match="\n\nHint:\n- if you were trying"
     ):
         nw_v1.to_native(df.lazy().select(0))  # type: ignore[arg-type]
+
+
+def test_enum_v1_is_enum_unstable() -> None:
+    enum_v1 = nw_v1.Enum()
+    enum_unstable = nw.Enum(("a", "b", "c"))
+    assert isinstance(enum_v1, nw.Enum)
+    assert issubclass(nw_v1.Enum, nw.Enum)
+    assert enum_v1 == nw.Enum
+    assert enum_v1 != enum_unstable
+    assert enum_unstable != nw_v1.Enum
+    assert enum_unstable == nw.Enum
+
+    with pytest.raises(TypeError, match=r"takes 1 positional argument"):
+        nw_v1.Enum(("a", "b"))  # type: ignore[call-arg]
+
+
+def test_cast_to_enum_v1(
+    request: pytest.FixtureRequest, constructor: Constructor
+) -> None:
+    # Backends that do not (yet) support Enum dtype
+    if (
+        any(
+            backend in str(constructor)
+            for backend in ["pyarrow_table", "duckdb", "sqlframe", "pyspark"]
+        )
+        or str(constructor) == "modin"
+    ):
+        request.applymarker(pytest.mark.xfail)
+
+    df_native = constructor({"a": ["a", "b"]})
+
+    with pytest.raises(
+        NotImplementedError,
+        match="Converting to Enum is not supported in narwhals.stable.v1",
+    ):
+        nw_v1.from_native(df_native).select(nw_v1.col("a").cast(nw_v1.Enum))
+
+
+def test_v1_ordered_categorical_pandas() -> None:
+    s = nw_v1.from_native(
+        pd.Series([0, 1], dtype=pd.CategoricalDtype(ordered=True)), series_only=True
+    )
+    assert s.dtype == nw_v1.Categorical
+
+
+def test_v1_enum_polars() -> None:
+    pytest.importorskip("polars")
+    import polars as pl
+
+    s = nw_v1.from_native(
+        pl.Series(["a", "b"], dtype=pl.Enum(["a", "b"])), series_only=True
+    )
+    assert s.dtype == nw_v1.Enum