Repository: regionmask/regionmask

Issue:
leverage cf_xarray to get x_coords (lon) and y_coords (lat)?
Currently - if the coordinates are not named `"lat"` and `"lon"` we need to pass them manually, e.g.:

```python
import regionmask
import xarray as xr

rasm = xr.tutorial.load_dataset("rasm")

regionmask.defined_regions.srex.mask(rasm.xc, rasm.yc)
```

cf_xarray uses `attrs` to determine cf compliant "coordinates" - irrespective of their name. We could use this to allow

```python
import cf_xarray # require manual import?

regionmask.defined_regions.srex.mask(rasm)
```

by using

```python
x_coords = data.cf["longitude"]
y_coords = data.cf["latitude"]
```
internally.

@jbusecke @aaronspring that may be of interest of you.

Discussion:
codecov-commenter: # [Codecov](https://codecov.io/gh/regionmask/regionmask/pull/393?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=regionmask) Report
> Merging [#393](https://codecov.io/gh/regionmask/regionmask/pull/393?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=regionmask) (4810c2c) into [main](https://codecov.io/gh/regionmask/regionmask/commit/9d263008b6b3f14b3ac646070e6ee36b3933364b?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=regionmask) (9d26300) will **decrease** coverage by `3.22%`.
> The diff coverage is `48.97%`.

```diff
@@            Coverage Diff             @@
##             main     #393      +/-   ##
==========================================
- Coverage   98.73%   95.50%   -3.23%     
==========================================
  Files          16       18       +2     
  Lines        1423     1469      +46     
==========================================
- Hits         1405     1403       -2     
- Misses         18       66      +48     
```

| Flag | Coverage Δ | |
|---|---|---|
| bare-minimum | `88.89% <48.97%> (-1.58%)` | :arrow_down: |
| min-all-deps | `?` | |
| shapely-2 | `?` | |
| unittests | `95.50% <48.97%> (-3.23%)` | :arrow_down: |

Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=regionmask#carryforward-flags-in-the-pull-request-comment) to find out more.

| [Impacted Files](https://codecov.io/gh/regionmask/regionmask/pull/393?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=regionmask) | Coverage Δ | |
|---|---|---|
| [regionmask/core/regions.py](https://codecov.io/gh/regionmask/regionmask/pull/393/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=regionmask#diff-cmVnaW9ubWFzay9jb3JlL3JlZ2lvbnMucHk=) | `97.53% <ø> (ø)` | |
| [regionmask/core/coords.py](https://codecov.io/gh/regionmask/regionmask/pull/393/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=regionmask#diff-cmVnaW9ubWFzay9jb3JlL2Nvb3Jkcy5weQ==) | `32.43% <32.43%> (ø)` | |
| [regionmask/core/mask.py](https://codecov.io/gh/regionmask/regionmask/pull/393/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=regionmask#diff-cmVnaW9ubWFzay9jb3JlL21hc2sucHk=) | `93.92% <100.00%> (-6.08%)` | :arrow_down: |
| [regionmask/defined\_regions/\_natural\_earth.py](https://codecov.io/gh/regionmask/regionmask/pull/393/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=regionmask#diff-cmVnaW9ubWFzay9kZWZpbmVkX3JlZ2lvbnMvX25hdHVyYWxfZWFydGgucHk=) | `96.20% <0.00%> (-0.48%)` | :arrow_down: |
| [regionmask/\_\_init\_\_.py](https://codecov.io/gh/regionmask/regionmask/pull/393/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=regionmask#diff-cmVnaW9ubWFzay9fX2luaXRfXy5weQ==) | `85.71% <0.00%> (ø)` | |

Help us with your feedback. Take ten seconds to tell us [how you rate us](https://about.codecov.io/nps?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=regionmask). Have a feature suggestion? [Share it here.](https://app.codecov.io/gh/feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=regionmask)


Patch:
diff --git a/ci/requirements/environment.yml b/ci/requirements/environment.yml
index fa6519b..2543c36 100644
--- a/ci/requirements/environment.yml
+++ b/ci/requirements/environment.yml
@@ -4,6 +4,7 @@ channels:
   - nodefaults
 dependencies:
   - cartopy
+  - cf_xarray
   - geopandas
   - matplotlib-base
   - numpy
diff --git a/ci/requirements/py37-min-all-deps.yml b/ci/requirements/py37-min-all-deps.yml
index 1b6ad9a..12d0b2d 100644
--- a/ci/requirements/py37-min-all-deps.yml
+++ b/ci/requirements/py37-min-all-deps.yml
@@ -5,6 +5,7 @@ channels:
 dependencies:
   - python=3.7
   - cartopy=0.17
+  - cf_xarray=0.6 # v0.7 not available for py37
   - geopandas=0.7
   - matplotlib-base=3.2
   - numpy=1.17
diff --git a/ci/requirements/py38-min-all-deps.yml b/ci/requirements/py38-min-all-deps.yml
index fcaad66..0faa6fb 100644
--- a/ci/requirements/py38-min-all-deps.yml
+++ b/ci/requirements/py38-min-all-deps.yml
@@ -5,6 +5,7 @@ channels:
 dependencies:
   - python=3.8
   - cartopy=0.20
+  - cf_xarray=0.7
   - geopandas=0.8
   - matplotlib-base=3.5
   - numpy=1.21
diff --git a/docs/conf.py b/docs/conf.py
index fa722b7..6b1f360 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -240,6 +240,7 @@ notebooks = (
     "notebooks/plotting",
     "notebooks/mask_2D",
     "notebooks/mask_3D",
+    "notebooks/detect_coords",
     "notebooks/geopandas",
     "notebooks/overlap",
     "notebooks/create_own_regions",
@@ -264,6 +265,7 @@ for nb in notebooks:
         " --to rst"
         " --template-file notebooks/tutorial_rst.tpl"
         " --ExecutePreprocessor.timeout=60"
+        " --ExecutePreprocessor.kernel_name=python3"
         " --execute " + nb
     )
     print(cmd)
diff --git a/docs/index.rst b/docs/index.rst
index 1ba6a3c..a06b6fd 100644
--- a/docs/index.rst
+++ b/docs/index.rst
@@ -71,6 +71,7 @@ Documentation
    notebooks/plotting
    notebooks/mask_2D
    notebooks/mask_3D
+   notebooks/detect_coords
    notebooks/geopandas
    notebooks/create_own_regions
    notebooks/overlap
diff --git a/docs/installation.rst b/docs/installation.rst
index 1f45d58..a27e542 100644
--- a/docs/installation.rst
+++ b/docs/installation.rst
@@ -23,12 +23,12 @@ For plotting
 - `cartopy <http://scitools.org.uk/cartopy/>`__ (0.17 or later) for plotting on
   geographical maps.
 
-For parsing flag values
-~~~~~~~~~~~~~~~~~~~~~~~
+For detecting coords and parsing flag values
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
 
 - `cf_xarray <https://cf-xarray.readthedocs.io/en/latest/>`__ (0.6.1 or later) allows
-  rich comparison of ``mask.cf`` for 2D masks with the abbreviations or names of the
-  regions.
+  to autodetect coordidate names and rich comparison of abbreviations or names of regions
+  for 2D masks via ``mask.cf``.
 
 For faster loading of shapefiles
 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
diff --git a/docs/notebooks/detect_coords.ipynb b/docs/notebooks/detect_coords.ipynb
new file mode 100644
index 0000000..f4908f6
--- /dev/null
+++ b/docs/notebooks/detect_coords.ipynb
@@ -0,0 +1,203 @@
+{
+ "cells": [
+  {
+   "cell_type": "markdown",
+   "id": "3b1f31ab",
+   "metadata": {},
+   "source": [
+    "# Detecting coordinates\n",
+    "\n",
+    "To create masks regionmask needs to know the x and y (longitude and latitude) coordinates of the grid. There are two ways to pass these to the `mask` and `mask_3D` methods:\n",
+    "\n",
+    "1. Passing the coordinates explicitly: `region.mask(longitude, latitude)`\n",
+    "2. Pass an xarray `DataArray` or `Dataset`: `region.mask(ds)` in which case the coordinates are either auto-detected using [cf_xarray](https://cf-xarray.readthedocs.io/en/latest/coord_axes.html) or determined as `ds[\"lon\"]` and `ds[\"lat\"]`"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "6acbc290",
+   "metadata": {},
+   "source": [
+    "## Illustration\n",
+    "\n",
+    "### Preparation\n",
+    "\n",
+    "Import regionmask and check the version:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "2906e976",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "import regionmask\n",
+    "\n",
+    "print(regionmask.__version__)"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "c387fe70",
+   "metadata": {},
+   "source": [
+    "Other imports"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "f1091042",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "import numpy as np\n",
+    "import xarray as xr\n",
+    "\n",
+    "xr.set_options(display_width=60, display_expand_data=False)"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "3df1a876",
+   "metadata": {},
+   "source": [
+    "Get data for examples\n",
+    "\n"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "be26ce8a",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "# example region\n",
+    "region = regionmask.defined_regions.ar6.land\n",
+    "\n",
+    "# dataset with coordinates named \"lon\" and \"lat\", without cf metadata\n",
+    "ds = xr.Dataset(coords={\"lon\": np.arange(0, 360),  \"lat\": np.arange(90, 0, -1)})\n",
+    "\n",
+    "# dataset with coordinates not named \"lon\" and \"lat,\" with cf metadata\n",
+    "rasm = xr.tutorial.open_dataset(\"rasm\")"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "5ae6e8bd",
+   "metadata": {},
+   "source": [
+    "### 1. Passing coordinates directly\n",
+    "\n",
+    "The coordinates can be passed individually - these must be \"array-like\" (typically numpy arrays or xarray DataArrays):"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "9cd16fbe",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "mask = region.mask(ds.lon, ds.lat)\n",
+    "mask.plot()"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "3f2236e5",
+   "metadata": {},
+   "source": [
+    "### 2. Not using cf_xarray\n",
+    "\n",
+    "If cf_xarray is not installed, the passed dataset does not have any cf metadata/ attributes, or `use_cf=False` is set, regionmask tries to read the coordinates from `ds[\"lon\"]` and `ds[\"lat\"]` and raises an error otherwise:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "0069bd7d",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "mask = region.mask(ds)\n",
+    "mask"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "407f667f",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "try:\n",
+    "    region.mask(rasm, use_cf=False)\n",
+    "except KeyError:\n",
+    "    print(\"Error raised\")"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "99f4e1c6",
+   "metadata": {},
+   "source": [
+    "### Using cf_xarray\n",
+    "\n",
+    "[cf_xarray](https://cf-xarray.readthedocs.io) uses [cf metadata](http://cfconventions.org/) saved in the attributes to determine coordinates (see [coordinate-criteria](https://cf-xarray.readthedocs.io/en/latest/coord_axes.html#coordinate-criteria)). For this the cf_xarray needs to be installed but importing it is not necessary."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "a8ea5701",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "mask = region.mask_3D(rasm)\n",
+    "\n",
+    "mask"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "332f0191",
+   "metadata": {},
+   "source": [
+    "## Details\n",
+    "\n",
+    "regionmask uses the following logic to determine the coordinates:\n",
+    "\n",
+    "1. Passing the coordinates directly (`region.mask(lon, lat)`) takes precedence.\n",
+    "1. If cf_xarray is _not_ installed regionmask will use `ds[\"lon\"]` and `ds[\"lat\"]` or raise a `KeyError`.\n",
+    "1. If cf_xarray is installed the behavior is determined via the `use_cf` keyword of the `mask` and `mask_3D` methods:\n",
+    "   * `use_cf=None` (default): use the cf metadata to determine the coordinates _or_ use `ds[\"lon\"]` and `ds[\"lat\"]` (raises a `ValueError` if ambigous).\n",
+    "   * `use_cf=True` : use the cf metadata to determine the coordinates\n",
+    "   * `use_cf=False`:  use `ds[\"lon\"]` and `ds[\"lat\"]` as coordinates"
+   ]
+  }
+ ],
+ "metadata": {
+  "kernelspec": {
+   "display_name": "Python [conda env:.conda-regionmask-docs]",
+   "language": "python",
+   "name": "conda-env-.conda-regionmask-docs-py"
+  },
+  "language_info": {
+   "codemirror_mode": {
+    "name": "ipython",
+    "version": 3
+   },
+   "file_extension": ".py",
+   "mimetype": "text/x-python",
+   "name": "python",
+   "nbconvert_exporter": "python",
+   "pygments_lexer": "ipython3",
+   "version": "3.10.8"
+  }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 5
+}
diff --git a/docs/whats_new.rst b/docs/whats_new.rst
index 53e910a..b854d8c 100644
--- a/docs/whats_new.rst
+++ b/docs/whats_new.rst
@@ -22,6 +22,9 @@ Breaking Changes
 Enhancements
 ~~~~~~~~~~~~
 
+- Can now autodetect longitude and latitude coordinates from `cf metadata <http://cfconventions.org/>`__`
+  if the optional dependency `cf_xarray <https://cf-xarray.readthedocs.io/en/latest/coord_axes.html>`__
+  is installed  (:pull:`393`, :issue:`364`).
 - 2D masks (e.g. :py:meth:`Regions.mask`) now contain `flag_values` and `flag_values` as
   attributes (`attrs`). Together with `cf_xarray <https://cf-xarray.readthedocs.io/en/latest/flags.html>`__
   these can be leveraged to select single (``mask.cf == "CNA"``) or multiple (``mask.cf.isin``)
diff --git a/regionmask/core/coords.py b/regionmask/core/coords.py
new file mode 100644
index 0000000..0a0385a
--- /dev/null
+++ b/regionmask/core/coords.py
@@ -0,0 +1,80 @@
+import xarray as xr
+
+try:
+    import cf_xarray  # noqa: F401
+
+    has_cf_xarray = True
+except ImportError:
+    has_cf_xarray = False
+
+
+def _get_coords(lon_or_obj, lat, lon_name, lat_name, use_cf):
+
+    if lat is not None:
+        return lon_or_obj, lat
+
+    is_xr_object = isinstance(lon_or_obj, (xr.Dataset, xr.DataArray))
+
+    if use_cf is None and has_cf_xarray and is_xr_object:
+        return _get_coords_cf_or_name(lon_or_obj, lon_name, lat_name)
+
+    if use_cf:
+        return _get_coords_cf(lon_or_obj)
+
+    return lon_or_obj[lon_name], lon_or_obj[lat_name]
+
+
+def _get_cf_coords(obj, name, required=False):
+
+    coord_name = obj.cf.coordinates.get(name)
+
+    if not coord_name:
+        if required:
+            raise ValueError(f"Found no coords for {name}")
+        return None
+
+    if len(coord_name) > 1:
+        raise ValueError(
+            f"Found more than one candidate for '{name}'. Either pass the lon and lat "
+            "coords directly or only pass a DataArray (instead of a Dataset) to the "
+            "mask method."
+        )
+
+    return coord_name[0]
+
+
+def _get_coords_cf_or_name(obj, lon_name, lat_name):
+
+    x_name = _get_cf_coords(obj, "longitude", required=False) or lon_name
+    y_name = _get_cf_coords(obj, "latitude", required=False) or lat_name
+
+    _assert_unambigous_coord_names(obj, x_name, lon_name)
+    _assert_unambigous_coord_names(obj, y_name, lat_name)
+
+    return obj[x_name], obj[y_name]
+
+
+def _assert_unambigous_coord_names(obj, cf_name, name):
+
+    if cf_name != name and name in obj.coords:
+        raise ValueError(
+            f"Ambigous name for coordinates: cf_xarray determined '{cf_name}' but "
+            f"'{name}' is also on the {type(obj).__name__}. Please set ``use_cf`` to "
+            "True or False to resolve this conflict."
+        )
+
+
+def _get_coords_cf(obj):
+
+    if not has_cf_xarray:
+        raise ImportError("cf_xarray required")
+
+    if not isinstance(obj, (xr.Dataset, xr.DataArray)):
+        raise TypeError(
+            f"Expected a ``Dataset`` or ``DataArray`` for ``use_cf=True``, got {type(obj)}"
+        )
+
+    x_name = _get_cf_coords(obj, "longitude", required=True)
+    y_name = _get_cf_coords(obj, "latitude", required=True)
+
+    return obj[x_name], obj[y_name]
diff --git a/regionmask/core/mask.py b/regionmask/core/mask.py
index b681aae..484b00c 100644
--- a/regionmask/core/mask.py
+++ b/regionmask/core/mask.py
@@ -5,6 +5,8 @@ import shapely
 import xarray as xr
 from packaging.version import Version
 
+from regionmask.core.coords import _get_coords
+
 from .utils import (
     _equally_spaced_on_split_lon,
     _find_splitpoint,
@@ -32,11 +34,11 @@ Parameters
 {gp_doc}lon_or_obj : object or array_like
     Can either be a longitude array and then ``lat`` needs to be
     given. Or an object where the longitude and latitude can be
-    retrieved as: ``lon = lon_or_obj[lon_name]`` and
-    ``lat = lon_or_obj[lat_name]``.
+    retrieved from, either using cf_xarray or by the names "lon"
+    and "lat". See also ``use_cf``.
 lat : array_like, optional
     If ``lon_or_obj`` is a longitude array, the latitude needs to be
-    specified here.
+    passed.
 {drop_doc}lon_name : str, optional
     Deprecated. Name of longitude in ``lon_or_obj``, default: "lon".
 lat_name : str, optional
@@ -59,7 +61,7 @@ wrap_lon : bool | 180 | 360, optional
     - ``180``: Wraps longitude coordinates to `[-180, 180[`
     - ``360``: Wraps longitude coordinates to `[0, 360[`
 
-{overlap}{flags}
+{overlap}{flags}{use_cf}
 
 Returns
 -------
@@ -102,7 +104,7 @@ overlap : bool, default: False
 """
 
 _FLAG_DOCSTRING = """\
-flag : str, default "abbrevs"
+flag : str, default: "abbrevs"
     Indicates if the "abbrevs" (abbreviations) or "names" should be added as
     `flag_values` and `flag_meanings` to the attributes (`attrs`) of the mask. If None
     nothing is added. Using cf_xarray these can be used to select single
@@ -110,6 +112,13 @@ flag : str, default "abbrevs"
     replaced by underscores.
 """
 
+_USE_CF_DOCSTRING = """\
+use_cf : bool, default: None
+    Whether to use ``cf_xarray`` to infer the names of the x and y coordinates. If None
+    uses cf_xarray if the coord names are unambigous. If True requires cf_xarray if
+    False does not use cf_xarray.
+"""
+
 
 def _inject_mask_docstring(is_3D, gp_method):
 
@@ -120,6 +129,7 @@ def _inject_mask_docstring(is_3D, gp_method):
     gp_doc = _GP_DOCSTRING if gp_method else ""
     overlap = _OVERLAP_DOCSTRING if (gp_method and is_3D) else ""
     flags = _FLAG_DOCSTRING if not (gp_method or is_3D) else ""
+    use_cf = _USE_CF_DOCSTRING if not gp_method else ""
 
     mask_docstring = _MASK_DOCSTRING_TEMPLATE.format(
         dtype=dtype,
@@ -129,6 +139,7 @@ def _inject_mask_docstring(is_3D, gp_method):
         gp_doc=gp_doc,
         overlap=overlap,
         flags=flags,
+        use_cf=use_cf,
     )
 
     return mask_docstring
@@ -145,6 +156,7 @@ def _mask(
     method=None,
     wrap_lon=None,
     as_3D=False,
+    use_cf=None,
 ):
     """
     internal function to create a mask
@@ -163,8 +175,7 @@ def _mask(
     if not _is_numeric(numbers):
         raise ValueError("'numbers' must be numeric")
 
-    lat_orig = lat
-    lon, lat = _extract_lon_lat(lon_or_obj, lat, lon_name, lat_name)
+    lon, lat = _get_coords(lon_or_obj, lat, lon_name, lat_name, use_cf)
 
     # determine whether unstructured grid
     # have to do this before np.asarray
@@ -181,8 +192,8 @@ def _mask(
                 "be converted to degree?"
             )
 
-    lon = np.asarray(lon)
-    lat = np.asarray(lat)
+    lon_arr = np.asarray(lon)
+    lat_arr = np.asarray(lat)
 
     # automatically detect whether wrapping is necessary
     if wrap_lon is None:
@@ -194,18 +205,18 @@ def _mask(
         wrap_lon_ = wrap_lon
 
     if wrap_lon_:
-        lon = _wrapAngle(lon, wrap_lon_, is_unstructured=is_unstructured)
+        lon_arr = _wrapAngle(lon_arr, wrap_lon_, is_unstructured=is_unstructured)
 
     if method not in (None, "rasterize", "shapely", "pygeos"):
         msg = "Method must be None or one of 'rasterize', 'shapely' and 'pygeos'."
         raise ValueError(msg)
 
     if method is None:
-        method = _determine_method(lon, lat)
+        method = _determine_method(lon_arr, lat_arr)
     elif method == "shapely" and has_shapely_2:
         method = "shapely_2"
     elif method == "rasterize":
-        method = _determine_method(lon, lat)
+        method = _determine_method(lon_arr, lat_arr)
         if "rasterize" not in method:
             msg = "`lat` and `lon` must be equally spaced to use `method='rasterize'`"
             raise ValueError(msg)
@@ -234,22 +245,22 @@ def _mask(
         mask_func = _mask_shapely_v2
         kwargs = {"is_unstructured": is_unstructured}
 
-    mask = mask_func(lon, lat, outlines, numbers=numbers, as_3D=as_3D, **kwargs)
+    mask = mask_func(lon_arr, lat_arr, outlines, numbers=numbers, as_3D=as_3D, **kwargs)
 
     # not False required
     if wrap_lon is not False:
         # treat the points at -180°E/0°E and -90°N
         mask = _mask_edgepoints_shapely(
             mask,
-            lon,
-            lat,
+            lon_arr,
+            lat_arr,
             outlines,
             numbers,
             is_unstructured=is_unstructured,
             as_3D=as_3D,
         )
 
-    return mask_to_dataarray(mask, lon_or_obj, lat_orig, lon_name, lat_name)
+    return mask_to_dataarray(mask, lon, lat, lon_name, lat_name)
 
 
 def _mask_2D(
@@ -262,6 +273,7 @@ def _mask_2D(
     lat_name=None,
     method=None,
     wrap_lon=None,
+    use_cf=None,
 ):
 
     mask = _mask(
@@ -274,6 +286,7 @@ def _mask_2D(
         lat_name=lat_name,
         method=method,
         wrap_lon=wrap_lon,
+        use_cf=use_cf,
     )
 
     if np.all(np.isnan(mask)):
@@ -297,6 +310,7 @@ def _mask_3D(
     method=None,
     wrap_lon=None,
     as_3D=False,
+    use_cf=None,
 ):
 
     mask = _mask(
@@ -310,6 +324,7 @@ def _mask_3D(
         method=method,
         wrap_lon=wrap_lon,
         as_3D=as_3D,
+        use_cf=use_cf,
     )
 
     if as_3D:
@@ -408,20 +423,7 @@ def _determine_method(lon, lat):
     return "shapely"
 
 
-def _extract_lon_lat(lon_or_obj, lat, lon_name, lat_name):
-    # extract lon/ lat via __getitem__
-    if lat is None:
-        lon = lon_or_obj[lon_name]
-        lat = lon_or_obj[lat_name]
-    else:
-        lon = lon_or_obj
-
-    return lon, lat
-
-
-def mask_to_dataarray(mask, lon_or_obj, lat=None, lon_name="lon", lat_name="lat"):
-
-    lon, lat = _extract_lon_lat(lon_or_obj, lat, lon_name, lat_name)
+def mask_to_dataarray(mask, lon, lat, lon_name="lon", lat_name="lat"):
 
     if sum(isinstance(c, xr.DataArray) for c in (lon, lat)) == 1:
         raise ValueError("Cannot handle coordinates with mixed types!")
diff --git a/regionmask/core/regions.py b/regionmask/core/regions.py
index 4dc15e4..e5315bc 100644
--- a/regionmask/core/regions.py
+++ b/regionmask/core/regions.py
@@ -325,6 +325,7 @@ class Regions:
         method=None,
         wrap_lon=None,
         flag="abbrevs",
+        use_cf=None,
     ):
 
         if self.overlap:
@@ -343,6 +344,7 @@ class Regions:
             lat_name=lat_name,
             method=method,
             wrap_lon=wrap_lon,
+            use_cf=use_cf,
         )
 
         if flag not in [None, "abbrevs", "names"]:
@@ -380,6 +382,7 @@ class Regions:
         lat_name=None,
         method=None,
         wrap_lon=None,
+        use_cf=None,
     ):
 
         mask_3D = _mask_3D(
@@ -394,6 +397,7 @@ class Regions:
             method=method,
             wrap_lon=wrap_lon,
             as_3D=self.overlap,
+            use_cf=use_cf,
         )
 
         numbers = mask_3D.region.values


Test patch:
diff --git a/regionmask/tests/__init__.py b/regionmask/tests/__init__.py
index 431fd36..caeb2bd 100644
--- a/regionmask/tests/__init__.py
+++ b/regionmask/tests/__init__.py
@@ -28,6 +28,7 @@ def assert_no_warnings():
 
 
 has_cartopy, requires_cartopy = _importorskip("cartopy")
+has_cf_xarray, requires_cf_xarray = _importorskip("cf_xarray")
 has_matplotlib, requires_matplotlib = _importorskip("matplotlib")
 has_pygeos, requires_pygeos = _importorskip("pygeos")
 has_shapely_2, requires_shapely_2 = _importorskip("shapely", minversion="2.0b1")
diff --git a/regionmask/tests/test_create_dataarray.py b/regionmask/tests/test_create_dataarray.py
index 12794f6..b8146ab 100644
--- a/regionmask/tests/test_create_dataarray.py
+++ b/regionmask/tests/test_create_dataarray.py
@@ -66,15 +66,6 @@ def create_test_datasets():
     return DATASETS
 
 
-@pytest.mark.parametrize("ds, lon_name, lat_name, mask", create_test_datasets())
-def test_mask_to_dataarray_ds_name(ds, lon_name, lat_name, mask):
-
-    actual = mask_to_dataarray(mask, ds, None, lon_name, lat_name)
-    expected = ds.coords.to_dataset()
-    xr.testing.assert_equal(expected, actual.coords.to_dataset())
-    np.testing.assert_equal(mask, actual.values)
-
-
 @pytest.mark.parametrize("ds, lon_name, lat_name, mask", create_test_datasets())
 def test_mask_to_dataarray_ds_indiv(ds, lon_name, lat_name, mask):
 
diff --git a/regionmask/tests/test_mask_cf_xarray.py b/regionmask/tests/test_mask_cf_xarray.py
new file mode 100644
index 0000000..b8c99a0
--- /dev/null
+++ b/regionmask/tests/test_mask_cf_xarray.py
@@ -0,0 +1,114 @@
+import pytest
+import xarray as xr
+
+from . import has_cf_xarray, requires_cf_xarray
+from .utils import dummy_ds, dummy_region, expected_mask_2D, expected_mask_3D
+
+MASK_METHODS = ["mask", "mask_3D"]
+
+dummy_ds_cf = dummy_ds.rename(lat="latitude", lon="longitude")
+dummy_ds_cf.latitude.attrs["standard_name"] = "latitude"
+dummy_ds_cf.longitude.attrs["standard_name"] = "longitude"
+
+
+@pytest.mark.skipif(has_cf_xarray, reason="must not have cf_xarray")
+@pytest.mark.parametrize("method", MASK_METHODS)
+def test_mask_use_cf_required(method):
+
+    mask = getattr(dummy_region, method)
+
+    with pytest.raises(ImportError, match="cf_xarray required"):
+        mask(dummy_ds, use_cf=True)
+
+
+@requires_cf_xarray
+@pytest.mark.parametrize("method", MASK_METHODS)
+def test_mask_use_cf_requires_da_ds(method):
+
+    mask = getattr(dummy_region, method)
+
+    with pytest.raises(TypeError, match="Expected a ``Dataset`` or ``DataArray``"):
+        mask(dummy_ds.lon.values, use_cf=True)
+
+
+@requires_cf_xarray
+@pytest.mark.parametrize("method", MASK_METHODS)
+@pytest.mark.parametrize("coords", ["latitude", "longitude"])
+def test_mask_use_cf_no_coords_found(method, coords):
+
+    ds = dummy_ds_cf.copy()
+    del ds[coords].attrs["standard_name"]
+
+    mask = getattr(dummy_region, method)
+
+    with pytest.raises(ValueError, match=f"Found no coords for {coords}"):
+        mask(ds, use_cf=True)
+
+
+@requires_cf_xarray
+@pytest.mark.parametrize("method", MASK_METHODS)
+@pytest.mark.parametrize("drop", ([], "lon", "longitude", ["lon", "longitude"]))
+def test_mask_use_cf_ambigous_name(method, drop):
+
+    ds = xr.merge([dummy_ds_cf, dummy_ds])
+    ds = ds.drop_vars(drop)
+
+    mask = getattr(dummy_region, method)
+
+    with pytest.raises(ValueError, match="Ambigous name for coordinates"):
+        mask(ds, use_cf=None)
+
+
+@requires_cf_xarray
+@pytest.mark.parametrize("method", MASK_METHODS)
+@pytest.mark.parametrize("coords, name", (("lon", "longitude"), ("lat", "latitude")))
+@pytest.mark.parametrize("use_cf", (True, None))
+def test_mask_use_cf_two_cf_coords(method, coords, name, use_cf):
+
+    ds = xr.merge([dummy_ds_cf, dummy_ds])
+
+    ds[coords].attrs["standard_name"] = name
+
+    mask = getattr(dummy_region, method)
+
+    with pytest.raises(ValueError, match=f"Found more than one candidate for '{name}'"):
+        mask(ds, use_cf=use_cf)
+
+
+@requires_cf_xarray
+@pytest.mark.parametrize("method", MASK_METHODS)
+@pytest.mark.parametrize("use_cf", (True, False))
+def test_mask_use_cf_ambigous_name_resolved(method, use_cf):
+
+    ds = xr.merge([dummy_ds_cf, dummy_ds])
+
+    mask = getattr(dummy_region, method)
+
+    result = mask(ds, use_cf=use_cf)
+
+    if use_cf:
+        assert "longitude" in result.coords
+        assert "latitude" in result.coords
+    else:
+        assert "lon" in result.coords
+        assert "lat" in result.coords
+
+
+@requires_cf_xarray
+@pytest.mark.parametrize("use_cf", (True, None))
+def test_mask_use_cf_mask_2D(use_cf):
+
+    result = dummy_region.mask(dummy_ds_cf, use_cf=use_cf)
+
+    expected = expected_mask_2D(lon_name="longitude", lat_name="latitude")
+    xr.testing.assert_equal(result, expected)
+
+
+@requires_cf_xarray
+@pytest.mark.parametrize("use_cf", (True, None))
+def test_mask_use_cf_mask_3D(use_cf):
+
+    result = dummy_region.mask_3D(dummy_ds_cf, use_cf=use_cf)
+
+    expected = expected_mask_3D(drop=True, lon_name="longitude", lat_name="latitude")
+    xr.testing.assert_equal(result, expected)