Repository: SciTools/iris (language: python)

Issue:
Mesh nonexperimental
Addresses #6057 

**WIP** will need some rebase when outstanding changes are merged :
  - [x] #6052 
  - [x] #6055 
  - [x] #5940
  - [x] #6054


### Also remaining
tidying to-do : 
  *  re-integrate various 'extra' code elements back into legacy sourcefiles
     - [ ] ugrid.cf --> iris.fileformats.cf
     - [ ] ugrid.load --> iris.fileformats.netcdf.loader
     - [ ] ugrid.save --> iris.fileformats.netcdf.saver
     - [ ] ugrid.metadata--> iris.common.metadata
  * that leaves ugrid.mesh and ugrid.utils in place,
    - [ ] which we should probably rename as `iris.mesh` and `iris.mesh.utils`

For simplicity, I'm going to ***defer that to another PR*** : see #6073
remove ugrid from experimental
Only the top-level items in `iris.experimental.ugrid` still exist

Ideally the import of that raises a deprecation warning (if easy?)

The code that is already marked to be merged back into the 'main' codebase should be done
(e.g. iris.experimental.ugrid.cf.CFUgridReader extensions are merged into iris.fileformats.cf.CFReader)

See SciTools/iris#5990

### Blockers

- SciTools/iris#5993
- SciTools/iris#5940
- SciTools/iris#6052
- Also be wary of other conflicts
Mesh nonexperimental
Addresses #6057 

**WIP** will need some rebase when outstanding changes are merged :
  - [x] #6052 
  - [x] #6055 
  - [x] #5940
  - [x] #6054


### Also remaining
tidying to-do : 
  *  re-integrate various 'extra' code elements back into legacy sourcefiles
     - [ ] ugrid.cf --> iris.fileformats.cf
     - [ ] ugrid.load --> iris.fileformats.netcdf.loader
     - [ ] ugrid.save --> iris.fileformats.netcdf.saver
     - [ ] ugrid.metadata--> iris.common.metadata
  * that leaves ugrid.mesh and ugrid.utils in place,
    - [ ] which we should probably rename as `iris.mesh` and `iris.mesh.utils`

For simplicity, I'm going to ***defer that to another PR*** : see #6073


Discussion / hints:

Fix patch:
diff --git a/benchmarks/benchmarks/cperf/__init__.py b/benchmarks/benchmarks/cperf/__init__.py
index df28a66265..05a086bc44 100644
--- a/benchmarks/benchmarks/cperf/__init__.py
+++ b/benchmarks/benchmarks/cperf/__init__.py
@@ -14,9 +14,6 @@
 
 from iris import load_cube
 
-# TODO: remove uses of PARSE_UGRID_ON_LOAD once UGRID parsing is core behaviour.
-from iris.experimental.ugrid import PARSE_UGRID_ON_LOAD
-
 from ..generate_data import BENCHMARK_DATA
 from ..generate_data.ugrid import make_cubesphere_testfile
 
@@ -92,5 +89,4 @@ def setup(self, file_type, three_d, three_times):
         self.file_type = file_type
 
     def load(self):
-        with PARSE_UGRID_ON_LOAD.context():
-            return load_cube(str(self.file_path))
+        return load_cube(str(self.file_path))
diff --git a/benchmarks/benchmarks/generate_data/stock.py b/benchmarks/benchmarks/generate_data/stock.py
index 61f085195a..04698e8ff5 100644
--- a/benchmarks/benchmarks/generate_data/stock.py
+++ b/benchmarks/benchmarks/generate_data/stock.py
@@ -14,7 +14,7 @@
 
 import iris
 from iris import cube
-from iris.experimental.ugrid import PARSE_UGRID_ON_LOAD, load_mesh
+from iris.mesh import load_mesh
 
 from . import BENCHMARK_DATA, REUSE_DATA, load_realised, run_function_elsewhere
 
@@ -90,7 +90,7 @@ def sample_mesh(n_nodes=None, n_faces=None, n_edges=None, lazy_values=False):
     """Sample mesh wrapper for :meth:iris.tests.stock.mesh.sample_mesh`."""
 
     def _external(*args, **kwargs):
-        from iris.experimental.ugrid import save_mesh
+        from iris.mesh import save_mesh
         from iris.tests.stock.mesh import sample_mesh
 
         save_path_ = kwargs.pop("save_path")
@@ -104,13 +104,12 @@ def _external(*args, **kwargs):
     save_path = (BENCHMARK_DATA / f"sample_mesh_{args_hash}").with_suffix(".nc")
     if not REUSE_DATA or not save_path.is_file():
         _ = run_function_elsewhere(_external, *arg_list, save_path=str(save_path))
-    with PARSE_UGRID_ON_LOAD.context():
-        if not lazy_values:
-            # Realise everything.
-            with load_realised():
-                mesh = load_mesh(str(save_path))
-        else:
+    if not lazy_values:
+        # Realise everything.
+        with load_realised():
             mesh = load_mesh(str(save_path))
+    else:
+        mesh = load_mesh(str(save_path))
     return mesh
 
 
@@ -118,7 +117,7 @@ def sample_meshcoord(sample_mesh_kwargs=None, location="face", axis="x"):
     """Sample meshcoord wrapper for :meth:`iris.tests.stock.mesh.sample_meshcoord`.
 
     Parameters deviate from the original as cannot pass a
-    :class:`iris.experimental.ugrid.Mesh to the separate Python instance - must
+    :class:`iris.mesh.Mesh to the separate Python instance - must
     instead generate the Mesh as well.
 
     MeshCoords cannot be saved to file, so the _external method saves the
@@ -127,7 +126,7 @@ def sample_meshcoord(sample_mesh_kwargs=None, location="face", axis="x"):
     """
 
     def _external(sample_mesh_kwargs_, save_path_):
-        from iris.experimental.ugrid import save_mesh
+        from iris.mesh import save_mesh
         from iris.tests.stock.mesh import sample_mesh, sample_meshcoord
 
         if sample_mesh_kwargs_:
@@ -147,9 +146,8 @@ def _external(sample_mesh_kwargs_, save_path_):
             sample_mesh_kwargs_=sample_mesh_kwargs,
             save_path_=str(save_path),
         )
-    with PARSE_UGRID_ON_LOAD.context():
-        with load_realised():
-            source_mesh = load_mesh(str(save_path))
+    with load_realised():
+        source_mesh = load_mesh(str(save_path))
     # Regenerate MeshCoord from its Mesh, which we saved.
     return source_mesh.to_MeshCoord(location=location, axis=axis)
 
@@ -180,7 +178,6 @@ def _external(w_mesh_: str, save_path_: str):
     )
     if not REUSE_DATA or not save_path.is_file():
         _ = run_function_elsewhere(_external, w_mesh_=w_mesh, save_path_=str(save_path))
-    with PARSE_UGRID_ON_LOAD.context():
-        context = nullcontext() if lazy else load_realised()
-        with context:
-            return iris.load_cube(save_path, "air_potential_temperature")
+    context = nullcontext() if lazy else load_realised()
+    with context:
+        return iris.load_cube(save_path, "air_potential_temperature")
diff --git a/benchmarks/benchmarks/generate_data/ugrid.py b/benchmarks/benchmarks/generate_data/ugrid.py
index de76d63798..2cef4752ee 100644
--- a/benchmarks/benchmarks/generate_data/ugrid.py
+++ b/benchmarks/benchmarks/generate_data/ugrid.py
@@ -5,7 +5,6 @@
 """Scripts for generating supporting data for UGRID-related benchmarking."""
 
 from iris import load_cube as iris_loadcube
-from iris.experimental.ugrid import PARSE_UGRID_ON_LOAD
 
 from . import BENCHMARK_DATA, REUSE_DATA, load_realised, run_function_elsewhere
 from .stock import (
@@ -85,8 +84,7 @@ def make_cube_like_2d_cubesphere(n_cube: int, with_mesh: bool):
         )
 
     # File now *should* definitely exist: content is simply the desired cube.
-    with PARSE_UGRID_ON_LOAD.context():
-        cube = iris_loadcube(str(filepath))
+    cube = iris_loadcube(str(filepath))
 
     # Ensure correct laziness.
     _ = cube.data
@@ -155,9 +153,8 @@ def _external(xy_dims_, save_path_):
     )
     if not REUSE_DATA or not save_path.is_file():
         _ = run_function_elsewhere(_external, xy_dims, str(save_path))
-    with PARSE_UGRID_ON_LOAD.context():
-        with load_realised():
-            cube = iris_loadcube(str(save_path))
+    with load_realised():
+        cube = iris_loadcube(str(save_path))
 
     return cube
 
diff --git a/benchmarks/benchmarks/load/ugrid.py b/benchmarks/benchmarks/load/ugrid.py
index 47e23dc050..5ad0086ef3 100644
--- a/benchmarks/benchmarks/load/ugrid.py
+++ b/benchmarks/benchmarks/load/ugrid.py
@@ -5,8 +5,7 @@
 """Mesh data loading benchmark tests."""
 
 from iris import load_cube as iris_load_cube
-from iris.experimental.ugrid import PARSE_UGRID_ON_LOAD
-from iris.experimental.ugrid import load_mesh as iris_load_mesh
+from iris.mesh import load_mesh as iris_load_mesh
 
 from ..generate_data.stock import create_file__xios_2d_face_half_levels
 
@@ -18,13 +17,11 @@ def synthetic_data(**kwargs):
 
 
 def load_cube(*args, **kwargs):
-    with PARSE_UGRID_ON_LOAD.context():
-        return iris_load_cube(*args, **kwargs)
+    return iris_load_cube(*args, **kwargs)
 
 
 def load_mesh(*args, **kwargs):
-    with PARSE_UGRID_ON_LOAD.context():
-        return iris_load_mesh(*args, **kwargs)
+    return iris_load_mesh(*args, **kwargs)
 
 
 class BasicLoading:
diff --git a/benchmarks/benchmarks/experimental/__init__.py b/benchmarks/benchmarks/mesh/__init__.py
similarity index 77%
rename from benchmarks/benchmarks/experimental/__init__.py
rename to benchmarks/benchmarks/mesh/__init__.py
index ce727a7286..9cc76ce0aa 100644
--- a/benchmarks/benchmarks/experimental/__init__.py
+++ b/benchmarks/benchmarks/mesh/__init__.py
@@ -2,4 +2,4 @@
 #
 # This file is part of Iris and is released under the BSD license.
 # See LICENSE in the root of the repository for full licensing details.
-"""Benchmark tests for the experimental module."""
+"""Benchmark tests for the iris.mesh module."""
diff --git a/lib/iris/tests/unit/ugrid/__init__.py b/benchmarks/benchmarks/mesh/utils/__init__.py
similarity index 76%
rename from lib/iris/tests/unit/ugrid/__init__.py
rename to benchmarks/benchmarks/mesh/utils/__init__.py
index d80eae287c..e20973c0a7 100644
--- a/lib/iris/tests/unit/ugrid/__init__.py
+++ b/benchmarks/benchmarks/mesh/utils/__init__.py
@@ -2,4 +2,4 @@
 #
 # This file is part of Iris and is released under the BSD license.
 # See LICENSE in the root of the repository for full licensing details.
-"""Unit tests for the :mod:`iris.ugrid` package."""
+"""Benchmark tests for the iris.mesh.utils module."""
diff --git a/benchmarks/benchmarks/experimental/ugrid/regions_combine.py b/benchmarks/benchmarks/mesh/utils/regions_combine.py
similarity index 94%
rename from benchmarks/benchmarks/experimental/ugrid/regions_combine.py
rename to benchmarks/benchmarks/mesh/utils/regions_combine.py
index d3781a183f..1a1a43a622 100644
--- a/benchmarks/benchmarks/experimental/ugrid/regions_combine.py
+++ b/benchmarks/benchmarks/mesh/utils/regions_combine.py
@@ -5,7 +5,7 @@
 """Benchmarks stages of operation.
 
 Benchmarks stages of operation of the function
-:func:`iris.experimental.ugrid.utils.recombine_submeshes`.
+:func:`iris.mesh.utils.recombine_submeshes`.
 
 """
 
@@ -15,8 +15,7 @@
 import numpy as np
 
 from iris import load, load_cube, save
-from iris.experimental.ugrid import PARSE_UGRID_ON_LOAD
-from iris.experimental.ugrid.utils import recombine_submeshes
+from iris.mesh.utils import recombine_submeshes
 
 from ... import TrackAddedMemoryAllocation
 from ...generate_data.ugrid import make_cube_like_2d_cubesphere
@@ -103,13 +102,12 @@ def setup(self, n_cubesphere, imaginary_data=True, create_result_cube=True):
 
         """
         # Load source cubes (full-mesh and regions)
-        with PARSE_UGRID_ON_LOAD.context():
-            self.full_mesh_cube = load_cube(
-                self._parametrised_cache_filename(n_cubesphere, "meshcube")
-            )
-            self.region_cubes = load(
-                self._parametrised_cache_filename(n_cubesphere, "regioncubes")
-            )
+        self.full_mesh_cube = load_cube(
+            self._parametrised_cache_filename(n_cubesphere, "meshcube")
+        )
+        self.region_cubes = load(
+            self._parametrised_cache_filename(n_cubesphere, "regioncubes")
+        )
 
         # Remove all var-names from loaded cubes, which can otherwise cause
         # problems.  Also implement 'imaginary' data.
diff --git a/benchmarks/benchmarks/save.py b/benchmarks/benchmarks/save.py
index f78734835d..aaa8480d64 100644
--- a/benchmarks/benchmarks/save.py
+++ b/benchmarks/benchmarks/save.py
@@ -5,7 +5,7 @@
 """File saving benchmarks."""
 
 from iris import save
-from iris.experimental.ugrid import save_mesh
+from iris.mesh import save_mesh
 
 from . import TrackAddedMemoryAllocation, on_demand_benchmark
 from .generate_data.ugrid import make_cube_like_2d_cubesphere
diff --git a/benchmarks/benchmarks/sperf/__init__.py b/benchmarks/benchmarks/sperf/__init__.py
index e51bef5ca2..2b8b508fd5 100644
--- a/benchmarks/benchmarks/sperf/__init__.py
+++ b/benchmarks/benchmarks/sperf/__init__.py
@@ -10,9 +10,6 @@
 
 from iris import load_cube
 
-# TODO: remove uses of PARSE_UGRID_ON_LOAD once UGRID parsing is core behaviour.
-from iris.experimental.ugrid import PARSE_UGRID_ON_LOAD
-
 from ..generate_data.ugrid import make_cubesphere_testfile
 
 
@@ -38,5 +35,4 @@ def setup(self, c_size, n_levels, n_times):
         )
 
     def load_cube(self):
-        with PARSE_UGRID_ON_LOAD.context():
-            return load_cube(str(self.file_path))
+        return load_cube(str(self.file_path))
diff --git a/benchmarks/benchmarks/sperf/combine_regions.py b/benchmarks/benchmarks/sperf/combine_regions.py
index d375f44719..b106befcae 100644
--- a/benchmarks/benchmarks/sperf/combine_regions.py
+++ b/benchmarks/benchmarks/sperf/combine_regions.py
@@ -10,8 +10,7 @@
 import numpy as np
 
 from iris import load, load_cube, save
-from iris.experimental.ugrid import PARSE_UGRID_ON_LOAD
-from iris.experimental.ugrid.utils import recombine_submeshes
+from iris.mesh.utils import recombine_submeshes
 
 from .. import TrackAddedMemoryAllocation, on_demand_benchmark
 from ..generate_data.ugrid import BENCHMARK_DATA, make_cube_like_2d_cubesphere
@@ -102,13 +101,12 @@ def setup(self, n_cubesphere, imaginary_data=True, create_result_cube=True):
 
         """
         # Load source cubes (full-mesh and regions)
-        with PARSE_UGRID_ON_LOAD.context():
-            self.full_mesh_cube = load_cube(
-                self._parametrised_cache_filename(n_cubesphere, "meshcube")
-            )
-            self.region_cubes = load(
-                self._parametrised_cache_filename(n_cubesphere, "regioncubes")
-            )
+        self.full_mesh_cube = load_cube(
+            self._parametrised_cache_filename(n_cubesphere, "meshcube")
+        )
+        self.region_cubes = load(
+            self._parametrised_cache_filename(n_cubesphere, "regioncubes")
+        )
 
         # Remove all var-names from loaded cubes, which can otherwise cause
         # problems.  Also implement 'imaginary' data.
diff --git a/benchmarks/benchmarks/sperf/equality.py b/benchmarks/benchmarks/sperf/equality.py
index f67935c9ef..ddee90cd28 100644
--- a/benchmarks/benchmarks/sperf/equality.py
+++ b/benchmarks/benchmarks/sperf/equality.py
@@ -13,7 +13,7 @@ class CubeEquality(FileMixin):
     r"""Benchmark time and memory costs.
 
     Benchmark time and memory costs of comparing :class:`~iris.cube.Cube`\\ s
-    with attached :class:`~iris.experimental.ugrid.mesh.MeshXY`\\ es.
+    with attached :class:`~iris.mesh.MeshXY`\\ es.
 
     Uses :class:`FileMixin` as the realistic case will be comparing
     :class:`~iris.cube.Cube`\\ s that have been loaded from file.
diff --git a/benchmarks/benchmarks/sperf/save.py b/benchmarks/benchmarks/sperf/save.py
index 8d9a90f7cf..d8a03798f0 100644
--- a/benchmarks/benchmarks/sperf/save.py
+++ b/benchmarks/benchmarks/sperf/save.py
@@ -7,7 +7,7 @@
 import os.path
 
 from iris import save
-from iris.experimental.ugrid import save_mesh
+from iris.mesh import save_mesh
 
 from .. import TrackAddedMemoryAllocation, on_demand_benchmark
 from ..generate_data.ugrid import make_cube_like_2d_cubesphere
diff --git a/benchmarks/benchmarks/unit_style/ugrid.py b/benchmarks/benchmarks/unit_style/mesh.py
similarity index 93%
rename from benchmarks/benchmarks/unit_style/ugrid.py
rename to benchmarks/benchmarks/unit_style/mesh.py
index e2f235eb28..ed3aad1428 100644
--- a/benchmarks/benchmarks/unit_style/ugrid.py
+++ b/benchmarks/benchmarks/unit_style/mesh.py
@@ -2,22 +2,22 @@
 #
 # This file is part of Iris and is released under the BSD license.
 # See LICENSE in the root of the repository for full licensing details.
-"""Benchmark tests for the experimental.ugrid module."""
+"""Benchmark tests for the iris.mesh module."""
 
 from copy import deepcopy
 
 import numpy as np
 
-from iris.experimental import ugrid
+from iris import mesh
 
 from .. import disable_repeat_between_setup
 from ..generate_data.stock import sample_mesh
 
 
 class UGridCommon:
-    """Run a generalised suite of benchmarks for any ugrid object.
+    """Run a generalised suite of benchmarks for any mesh object.
 
-    A base class running a generalised suite of benchmarks for any ugrid object.
+    A base class running a generalised suite of benchmarks for any mesh object.
     Object to be specified in a subclass.
 
     ASV will run the benchmarks within this class for any subclasses.
@@ -53,7 +53,7 @@ def setup(self, n_faces):
         super().setup(n_faces)
 
     def create(self):
-        return ugrid.Connectivity(indices=self.array, cf_role="face_node_connectivity")
+        return mesh.Connectivity(indices=self.array, cf_role="face_node_connectivity")
 
     def time_indices(self, n_faces):
         _ = self.object.indices
@@ -123,7 +123,7 @@ def get_coords_and_axes(location):
         self.eq_object = deepcopy(self.object)
 
     def create(self):
-        return ugrid.MeshXY(**self.mesh_kwargs)
+        return mesh.MeshXY(**self.mesh_kwargs)
 
     def time_add_connectivities(self, n_faces):
         self.object.add_connectivities(self.face_node)
@@ -170,7 +170,7 @@ def setup(self, n_faces, lazy=False):
         super().setup(n_faces)
 
     def create(self):
-        return ugrid.MeshCoord(mesh=self.mesh, location="face", axis="x")
+        return mesh.MeshCoord(mesh=self.mesh, location="face", axis="x")
 
     def time_points(self, n_faces):
         _ = self.object.points
diff --git a/docs/src/further_topics/ugrid/data_model.rst b/docs/src/further_topics/ugrid/data_model.rst
index 9e74647e96..1660f6d08c 100644
--- a/docs/src/further_topics/ugrid/data_model.rst
+++ b/docs/src/further_topics/ugrid/data_model.rst
@@ -298,7 +298,7 @@ How Iris Represents This
 .. seealso::
 
         Remember this is a prose summary. Precise documentation is at:
-        :mod:`iris.ugrid`.
+        :mod:`iris.mesh`.
 
 .. note::
 
@@ -310,7 +310,7 @@ The Basics
 The Iris :class:`~iris.cube.Cube` has several new members:
 
 * | :attr:`~iris.cube.Cube.mesh`
-  | The :class:`iris.ugrid.MeshXY` that describes the
+  | The :class:`iris.mesh.MeshXY` that describes the
     :class:`~iris.cube.Cube`\'s horizontal geography.
 * | :attr:`~iris.cube.Cube.location`
   | ``node``/``edge``/``face`` - the mesh element type with which this
@@ -320,10 +320,10 @@ The Iris :class:`~iris.cube.Cube` has several new members:
     indexes over the horizontal :attr:`~iris.cube.Cube.data` positions.
 
 These members will all be ``None`` for a :class:`~iris.cube.Cube` with no
-associated :class:`~iris.ugrid.MeshXY`.
+associated :class:`~iris.mesh.MeshXY`.
 
 This :class:`~iris.cube.Cube`\'s unstructured dimension has multiple attached
-:class:`iris.ugrid.MeshCoord`\s (one for each axis e.g.
+:class:`iris.mesh.MeshCoord`\s (one for each axis e.g.
 ``x``/``y``), which can be used to infer the points and bounds of any index on
 the :class:`~iris.cube.Cube`\'s unstructured dimension.
 
@@ -333,7 +333,7 @@ the :class:`~iris.cube.Cube`\'s unstructured dimension.
 
         from iris.coords import AuxCoord, DimCoord
         from iris.cube import Cube
-        from iris.ugrid import Connectivity, MeshXY
+        from iris.mesh import Connectivity, MeshXY
 
         node_x = AuxCoord(
                      points=[0.0, 5.0, 0.0, 5.0, 8.0],
@@ -422,38 +422,38 @@ The Detail
 ----------
 How UGRID information is stored
 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
-* | :class:`iris.ugrid.MeshXY`
+* | :class:`iris.mesh.MeshXY`
   | Contains all information about the mesh.
   | Includes:
 
-  * | :attr:`~iris.ugrid.MeshXY.topology_dimension`
+  * | :attr:`~iris.mesh.MeshXY.topology_dimension`
     | The maximum dimensionality of shape (1D=edge, 2D=face) supported
-      by this :class:`~iris.ugrid.MeshXY`. Determines which
-      :class:`~iris.ugrid.Connectivity`\s are required/optional
+      by this :class:`~iris.mesh.MeshXY`. Determines which
+      :class:`~iris.mesh.Connectivity`\s are required/optional
       (see below).
 
   * 1-3 collections of :class:`iris.coords.AuxCoord`\s:
 
-    * | **Required**: :attr:`~iris.ugrid.MeshXY.node_coords`
+    * | **Required**: :attr:`~iris.mesh.MeshXY.node_coords`
       | The nodes that are the basis for the mesh.
-    * | Optional: :attr:`~iris.ugrid.Mesh.edge_coords`,
-        :attr:`~iris.ugrid.MeshXY.face_coords`
+    * | Optional: :attr:`~iris.mesh.Mesh.edge_coords`,
+        :attr:`~iris.mesh.MeshXY.face_coords`
       | For indicating the 'centres' of the edges/faces.
-      | **NOTE:** generating a :class:`~iris.ugrid.MeshCoord` from
-        a :class:`~iris.ugrid.MeshXY` currently (``Jan 2022``)
+      | **NOTE:** generating a :class:`~iris.mesh.MeshCoord` from
+        a :class:`~iris.mesh.MeshXY` currently (``Jan 2022``)
         requires centre coordinates for the given ``location``; to be rectified
         in future.
 
-  * 1 or more :class:`iris.ugrid.Connectivity`\s:
+  * 1 or more :class:`iris.mesh.Connectivity`\s:
 
     * | **Required for 1D (edge) elements**:
-        :attr:`~iris.ugrid.MeshXY.edge_node_connectivity`
+        :attr:`~iris.mesh.MeshXY.edge_node_connectivity`
       | Define the edges by connecting nodes.
     * | **Required for 2D (face) elements**:
-        :attr:`~iris.ugrid.MeshXY.face_node_connectivity`
+        :attr:`~iris.mesh.MeshXY.face_node_connectivity`
       | Define the faces by connecting nodes.
     * Optional: any other connectivity type. See
-      :attr:`iris.ugrid.mesh.Connectivity.UGRID_CF_ROLES` for the
+      :attr:`iris.mesh.Connectivity.UGRID_CF_ROLES` for the
       full list of types.
 
 .. doctest:: ugrid_summaries
@@ -480,30 +480,30 @@ How UGRID information is stored
                     <AuxCoord: latitude / (degrees_north)  [...]  shape(2,)>
             long_name: 'my_mesh'
 
-* | :class:`iris.ugrid.MeshCoord`
+* | :class:`iris.mesh.MeshCoord`
   | Described in detail in `MeshCoords`_.
   | Stores the following information:
 
-  * | :attr:`~iris.ugrid.MeshCoord.mesh`
-    | The :class:`~iris.ugrid.MeshXY` associated with this
-      :class:`~iris.ugrid.MeshCoord`. This determines the
+  * | :attr:`~iris.mesh.MeshCoord.mesh`
+    | The :class:`~iris.mesh.MeshXY` associated with this
+      :class:`~iris.mesh.MeshCoord`. This determines the
       :attr:`~iris.cube.Cube.mesh` attribute of any :class:`~iris.cube.Cube`
-      this :class:`~iris.ugrid.MeshCoord` is attached to (see
+      this :class:`~iris.mesh.MeshCoord` is attached to (see
       `The Basics`_)
 
-  * | :attr:`~iris.ugrid.MeshCoord.location`
+  * | :attr:`~iris.mesh.MeshCoord.location`
     | ``node``/``edge``/``face`` - the element detailed by this
-      :class:`~iris.ugrid.MeshCoord`. This determines the
+      :class:`~iris.mesh.MeshCoord`. This determines the
       :attr:`~iris.cube.Cube.location` attribute of any
       :class:`~iris.cube.Cube` this
-      :class:`~iris.ugrid.MeshCoord` is attached to (see
+      :class:`~iris.mesh.MeshCoord` is attached to (see
       `The Basics`_).
 
 .. _ugrid MeshCoords:
 
 MeshCoords
 ~~~~~~~~~~
-Links a :class:`~iris.cube.Cube` to a :class:`~iris.ugrid.MeshXY` by
+Links a :class:`~iris.cube.Cube` to a :class:`~iris.mesh.MeshXY` by
 attaching to the :class:`~iris.cube.Cube`\'s unstructured dimension, in the
 same way that all :class:`~iris.coords.Coord`\s attach to
 :class:`~iris.cube.Cube` dimensions. This allows a single
@@ -511,23 +511,23 @@ same way that all :class:`~iris.coords.Coord`\s attach to
 dimensions (e.g. horizontal mesh plus vertical levels and a time series),
 using the same logic for every dimension.
 
-:class:`~iris.ugrid.MeshCoord`\s are instantiated using a given
-:class:`~iris.ugrid.MeshXY`, ``location``
+:class:`~iris.mesh.MeshCoord`\s are instantiated using a given
+:class:`~iris.mesh.MeshXY`, ``location``
 ("node"/"edge"/"face") and ``axis``. The process interprets the
-:class:`~iris.ugrid.MeshXY`\'s
-:attr:`~iris.ugrid.MeshXY.node_coords` and if appropriate the
-:attr:`~iris.ugrid.MeshXY.edge_node_connectivity`/
-:attr:`~iris.ugrid.MeshXY.face_node_connectivity` and
-:attr:`~iris.ugrid.MeshXY.edge_coords`/
-:attr:`~iris.ugrid.MeshXY.face_coords`
+:class:`~iris.mesh.MeshXY`\'s
+:attr:`~iris.mesh.MeshXY.node_coords` and if appropriate the
+:attr:`~iris.mesh.MeshXY.edge_node_connectivity`/
+:attr:`~iris.mesh.MeshXY.face_node_connectivity` and
+:attr:`~iris.mesh.MeshXY.edge_coords`/
+:attr:`~iris.mesh.MeshXY.face_coords`
 to produce a :class:`~iris.coords.Coord`
 :attr:`~iris.coords.Coord.points` and :attr:`~iris.coords.Coord.bounds`
-representation of all the :class:`~iris.ugrid.MeshXY`\'s
+representation of all the :class:`~iris.mesh.MeshXY`\'s
 nodes/edges/faces for the given axis.
 
-The method :meth:`iris.ugrid.MeshXY.to_MeshCoords` is available to
-create a :class:`~iris.ugrid.MeshCoord` for
-every axis represented by that :class:`~iris.ugrid.MeshXY`,
+The method :meth:`iris.mesh.MeshXY.to_MeshCoords` is available to
+create a :class:`~iris.mesh.MeshCoord` for
+every axis represented by that :class:`~iris.mesh.MeshXY`,
 given only the ``location`` argument
 
 .. doctest:: ugrid_summaries
diff --git a/docs/src/further_topics/ugrid/index.rst b/docs/src/further_topics/ugrid/index.rst
index e21730bb6e..c247a9dc6d 100644
--- a/docs/src/further_topics/ugrid/index.rst
+++ b/docs/src/further_topics/ugrid/index.rst
@@ -9,7 +9,7 @@ Iris includes specialised handling of mesh-located data (as opposed to
 grid-located data). Iris and its :ref:`partner packages <ugrid partners>` are
 designed to make working with mesh-located data as simple as possible, with new
 capabilities being added all the time. More detail is in this section and in
-the :mod:`iris.ugrid` API documentation.
+the :mod:`iris.mesh` API documentation.
 
 This mesh support is based on the `CF-UGRID Conventions`__; UGRID-conformant
 meshes + data can be loaded from a file into Iris' data model, and meshes +
diff --git a/docs/src/further_topics/ugrid/operations.rst b/docs/src/further_topics/ugrid/operations.rst
index f7b9eb2fca..97dfaaa5b1 100644
--- a/docs/src/further_topics/ugrid/operations.rst
+++ b/docs/src/further_topics/ugrid/operations.rst
@@ -61,7 +61,7 @@ subsequent example operations on this page.
         >>> import numpy as np
 
         >>> from iris.coords import AuxCoord
-        >>> from iris.ugrid import Connectivity, MeshXY
+        >>> from iris.mesh import Connectivity, MeshXY
 
         # Going to create the following mesh
         #  (node indices are shown to aid understanding):
@@ -143,8 +143,8 @@ Making a Cube (with a Mesh)
 .. rubric:: |tagline: making a cube|
 
 Creating a :class:`~iris.cube.Cube` is unchanged; the
-:class:`~iris.ugrid.MeshXY` is linked via a
-:class:`~iris.ugrid.MeshCoord` (see :ref:`ugrid MeshCoords`):
+:class:`~iris.mesh.MeshXY` is linked via a
+:class:`~iris.mesh.MeshCoord` (see :ref:`ugrid MeshCoords`):
 
 .. dropdown:: Code
     :icon: code
@@ -205,7 +205,7 @@ Save
 .. note:: UGRID saving support is limited to the NetCDF file format.
 
 The Iris saving process automatically detects if the :class:`~iris.cube.Cube`
-has an associated :class:`~iris.ugrid.MeshXY` and automatically
+has an associated :class:`~iris.mesh.MeshXY` and automatically
 saves the file in a UGRID-conformant format:
 
 .. dropdown:: Code
@@ -282,8 +282,8 @@ saves the file in a UGRID-conformant format:
         }
         <BLANKLINE>
 
-The :func:`iris.ugrid.save_mesh` function allows
-:class:`~iris.ugrid.MeshXY`\es to be saved to file without
+The :func:`iris.mesh.save_mesh` function allows
+:class:`~iris.mesh.MeshXY`\es to be saved to file without
 associated :class:`~iris.cube.Cube`\s:
 
 .. dropdown:: Code
@@ -293,7 +293,7 @@ associated :class:`~iris.cube.Cube`\s:
 
         >>> from subprocess import run
 
-        >>> from iris.ugrid import save_mesh
+        >>> from iris.mesh import save_mesh
 
         >>> mesh_path = "my_mesh.nc"
         >>> save_mesh(my_mesh, mesh_path)
@@ -347,16 +347,14 @@ associated :class:`~iris.cube.Cube`\s:
 
 Load
 ----
-.. |tagline: load| replace:: |different| - UGRID parsing is opt-in
+.. |tagline: load| replace:: |unchanged|
 
 .. rubric:: |tagline: load|
 
 .. note:: UGRID loading support is limited to the NetCDF file format.
 
-While Iris' UGRID support remains :mod:`~iris.experimental`, parsing UGRID when
-loading a file remains **optional**. To load UGRID data from a file into the
-Iris mesh data model, use the
-:const:`iris.ugrid.PARSE_UGRID_ON_LOAD` context manager:
+Iris mesh support detects + parses any UGRID information when loading files, to
+produce cubes with a non-empty ".mesh" property.
 
 .. dropdown:: Code
     :icon: code
@@ -364,10 +362,8 @@ Iris mesh data model, use the
     .. doctest:: ugrid_operations
 
         >>> from iris import load
-        >>> from iris.ugrid import PARSE_UGRID_ON_LOAD
 
-        >>> with PARSE_UGRID_ON_LOAD.context():
-        ...     loaded_cubelist = load(cubelist_path)
+        >>> loaded_cubelist = load(cubelist_path)
 
         # Sort CubeList to ensure consistent result.
         >>> loaded_cubelist.sort(key=lambda cube: cube.name())
@@ -386,9 +382,8 @@ etcetera:
 
         >>> from iris import Constraint, load_cube
 
-        >>> with PARSE_UGRID_ON_LOAD.context():
-        ...     ground_cubelist = load(cubelist_path, Constraint(height=0))
-        ...     face_cube = load_cube(cubelist_path, "face_data")
+        >>> ground_cubelist = load(cubelist_path, Constraint(height=0))
+        >>> face_cube = load_cube(cubelist_path, "face_data")
 
         # Sort CubeList to ensure consistent result.
         >>> ground_cubelist.sort(key=lambda cube: cube.name())
@@ -412,15 +407,15 @@ etcetera:
 .. note::
 
     We recommend caution if constraining on coordinates associated with a
-    :class:`~iris.ugrid.MeshXY`. An individual coordinate value
+    :class:`~iris.mesh.MeshXY`. An individual coordinate value
     might not be shared by any other data points, and using a coordinate range
     will demand notably higher performance given the size of the dimension
     versus structured grids
     (:ref:`see the data model detail <ugrid implications>`).
 
-The :func:`iris.ugrid.load_mesh` and
-:func:`~iris.ugrid.load_meshes` functions allow only
-:class:`~iris.ugrid.MeshXY`\es to be loaded from a file without
+The :func:`iris.mesh.load_mesh` and
+:func:`~iris.mesh.load_meshes` functions allow only
+:class:`~iris.mesh.MeshXY`\es to be loaded from a file without
 creating any associated :class:`~iris.cube.Cube`\s:
 
 .. dropdown:: Code
@@ -428,10 +423,9 @@ creating any associated :class:`~iris.cube.Cube`\s:
 
     .. doctest:: ugrid_operations
 
-        >>> from iris.ugrid import load_mesh
+        >>> from iris.mesh import load_mesh
 
-        >>> with PARSE_UGRID_ON_LOAD.context():
-        ...     loaded_mesh = load_mesh(cubelist_path)
+        >>> loaded_mesh = load_mesh(cubelist_path)
 
         >>> print(loaded_mesh)
         MeshXY : 'my_mesh'
@@ -493,10 +487,8 @@ GeoVista :external+geovista:doc:`generated/gallery/index`.
 
         >>> from iris import load_cube, sample_data_path
         >>> from iris.experimental.geovista import cube_to_polydata
-        >>> from iris.ugrid import PARSE_UGRID_ON_LOAD
 
-        >>> with PARSE_UGRID_ON_LOAD.context():
-        ...     sample_mesh_cube = load_cube(sample_data_path("mesh_C4_synthetic_float.nc"))
+        >>> sample_mesh_cube = load_cube(sample_data_path("mesh_C4_synthetic_float.nc"))
         >>> print(sample_mesh_cube)
         synthetic / (1)                     (-- : 96)
             Mesh coordinates:
@@ -541,11 +533,11 @@ As described in :doc:`data_model`, indexing for a range along a
 :class:`~iris.cube.Cube`\'s :meth:`~iris.cube.Cube.mesh_dim` will not provide
 a contiguous region, since **position on the unstructured dimension is
 unrelated to spatial position**. This means that subsetted
-:class:`~iris.ugrid.MeshCoord`\s cannot be reliably interpreted
-as intended, and subsetting a :class:`~iris.ugrid.MeshCoord` is
+:class:`~iris.mesh.MeshCoord`\s cannot be reliably interpreted
+as intended, and subsetting a :class:`~iris.mesh.MeshCoord` is
 therefore set to return an :class:`~iris.coords.AuxCoord` instead - breaking
 the link between :class:`~iris.cube.Cube` and
-:class:`~iris.ugrid.MeshXY`:
+:class:`~iris.mesh.MeshXY`:
 
 .. dropdown:: Code
     :icon: code
@@ -595,10 +587,8 @@ below:
         >>> from geovista.geodesic import BBox
         >>> from iris import load_cube, sample_data_path
         >>> from iris.experimental.geovista import cube_to_polydata, extract_unstructured_region
-        >>> from iris.ugrid import PARSE_UGRID_ON_LOAD
 
-        >>> with PARSE_UGRID_ON_LOAD.context():
-        ...     sample_mesh_cube = load_cube(sample_data_path("mesh_C4_synthetic_float.nc"))
+        >>> sample_mesh_cube = load_cube(sample_data_path("mesh_C4_synthetic_float.nc"))
         >>> print(sample_mesh_cube)
         synthetic / (1)                     (-- : 96)
             Mesh coordinates:
@@ -667,7 +657,6 @@ with the
 
         >>> from esmf_regrid.experimental.unstructured_scheme import MeshToGridESMFRegridder
         >>> from iris import load, load_cube
-        >>> from iris.ugrid import PARSE_UGRID_ON_LOAD
 
         # You could also download these files from github.com/SciTools/iris-test-data.
         >>> from iris.tests import get_data_path
@@ -679,8 +668,7 @@ with the
         ... )
 
         # Load a list of cubes defined on the same Mesh.
-        >>> with PARSE_UGRID_ON_LOAD.context():
-        ...     mesh_cubes = load(mesh_file)
+        >>> mesh_cubes = load(mesh_file)
 
         # Extract a specific cube.
         >>> mesh_cube1 = mesh_cubes.extract_cube("sea_surface_temperature")
@@ -751,7 +739,7 @@ with the
 The initialisation process is computationally expensive so we use caching to
 improve performance. Once a regridder has been initialised, it can be used on
 any :class:`~iris.cube.Cube` which has been defined on the same
-:class:`~iris.ugrid.MeshXY` (or on the same **grid** in the case of
+:class:`~iris.mesh.MeshXY` (or on the same **grid** in the case of
 :class:`~esmf_regrid.experimental.unstructured_scheme.GridToMeshESMFRegridder`).
 Since calling a regridder is usually a lot faster than initialising, reusing
 regridders can save a lot of time. We can demonstrate the reuse of the
@@ -819,19 +807,19 @@ Equality
 
 .. rubric:: |tagline: equality|
 
-:class:`~iris.ugrid.MeshXY` comparison is supported, and comparing
-two ':class:`~iris.ugrid.MeshXY`-:class:`~iris.cube.Cube`\s' will
+:class:`~iris.mesh.MeshXY` comparison is supported, and comparing
+two ':class:`~iris.mesh.MeshXY`-:class:`~iris.cube.Cube`\s' will
 include a comparison of the respective
-:class:`~iris.ugrid.MeshXY`\es, with no extra action needed by the
+:class:`~iris.mesh.MeshXY`\es, with no extra action needed by the
 user.
 
 .. note::
 
     Keep an eye on memory demand when comparing large
-    :class:`~iris.ugrid.MeshXY`\es, but note that
-    :class:`~iris.ugrid.MeshXY`\ equality is enabled for lazy
+    :class:`~iris.mesh.MeshXY`\es, but note that
+    :class:`~iris.mesh.MeshXY`\ equality is enabled for lazy
     processing (:doc:`/userguide/real_and_lazy_data`), so if the
-    :class:`~iris.ugrid.MeshXY`\es being compared are lazy the
+    :class:`~iris.mesh.MeshXY`\es being compared are lazy the
     process will use less memory than their total size.
 
 Combining Cubes
@@ -842,23 +830,23 @@ Combining Cubes
 
 Merging or concatenating :class:`~iris.cube.Cube`\s (described in
 :doc:`/userguide/merge_and_concat`) with two different
-:class:`~iris.ugrid.MeshXY`\es is not possible - a
+:class:`~iris.mesh.MeshXY`\es is not possible - a
 :class:`~iris.cube.Cube` must be associated with just a single
-:class:`~iris.ugrid.MeshXY`, and merge/concatenate are not yet
-capable of combining multiple :class:`~iris.ugrid.MeshXY`\es into
+:class:`~iris.mesh.MeshXY`, and merge/concatenate are not yet
+capable of combining multiple :class:`~iris.mesh.MeshXY`\es into
 one.
 
 :class:`~iris.cube.Cube`\s that include
-:class:`~iris.ugrid.MeshCoord`\s can still be merged/concatenated
-on dimensions other than the :meth:`~iris.cube.Cube.mesh_dim`, since such
-:class:`~iris.cube.Cube`\s will by definition share the same
-:class:`~iris.ugrid.MeshXY`.
+:class:`~iris.mesh.MeshCoord`\s can still be merged/concatenated
+on dimensions other than the :meth:`~iris.cube.Cube.mesh_dim`, but only if their
+:class:`~iris.cube.Cube.mesh`\es are *equal* (in practice, identical, even to
+matching ``var_name``\s).
 
 .. seealso::
 
     You may wish to investigate
-    :func:`iris.ugrid.recombine_submeshes`, which can be used
-    for a very specific type of :class:`~iris.ugrid.MeshXY`
+    :func:`iris.mesh.recombine_submeshes`, which can be used
+    for a very specific type of :class:`~iris.mesh.MeshXY`
     combination not detailed here.
 
 Arithmetic
@@ -869,7 +857,7 @@ Arithmetic
 
 Cube Arithmetic (described in :doc:`/userguide/cube_maths`)
 has been extended to handle :class:`~iris.cube.Cube`\s that include
-:class:`~iris.ugrid.MeshCoord`\s, and hence have a ``cube.mesh``.
+:class:`~iris.mesh.MeshCoord`\s, and hence have a ``cube.mesh``.
 
 Cubes with meshes can be combined in arithmetic operations like
 "ordinary" cubes. They can combine with other cubes without that mesh
diff --git a/docs/src/further_topics/ugrid/partner_packages.rst b/docs/src/further_topics/ugrid/partner_packages.rst
index 5dea58b752..f69546446c 100644
--- a/docs/src/further_topics/ugrid/partner_packages.rst
+++ b/docs/src/further_topics/ugrid/partner_packages.rst
@@ -58,7 +58,7 @@ PyVista is described as "VTK for humans" - VTK is a very powerful toolkit for
 working with meshes, and PyVista brings that power into the Python ecosystem.
 GeoVista in turn makes it easy to use PyVista specifically for cartographic
 work, designed from the start with the Iris
-:class:`~iris.ugrid.MeshXY` in mind.
+:class:`~iris.mesh.MeshXY` in mind.
 
 Applications
 ------------
diff --git a/lib/iris/analysis/_regrid.py b/lib/iris/analysis/_regrid.py
index 31ceafb025..431871de2c 100644
--- a/lib/iris/analysis/_regrid.py
+++ b/lib/iris/analysis/_regrid.py
@@ -997,8 +997,7 @@ def _create_cube(data, src, src_dims, tgt_coords, num_tgt_dims, regrid_callback)
     src_dims : tuple of int
         The dimensions of the X and Y coordinate within the source Cube.
     tgt_coords : tuple of :class:`iris.coords.Coord
-        Either two 1D :class:`iris.coords.DimCoord`, two 1D
-        :class:`iris.ugrid.DimCoord` or two n-D
+        Either two 1D :class:`iris.coords.DimCoord`, or two n-D
         :class:`iris.coords.AuxCoord` representing the new grid's
         X and Y coordinates.
     num_tgt_dims : int
diff --git a/lib/iris/common/metadata.py b/lib/iris/common/metadata.py
index c705054725..6f3e455b4d 100644
--- a/lib/iris/common/metadata.py
+++ b/lib/iris/common/metadata.py
@@ -1334,6 +1334,357 @@ def equal(self, other, lenient=None):
         return super().equal(other, lenient=lenient)
 
 
+class ConnectivityMetadata(BaseMetadata):
+    """Metadata container for a :class:`~iris.mesh.Connectivity`."""
+
+    # The "location_axis" member is stateful only, and does not participate in
+    # lenient/strict equivalence.
+    _members = ("cf_role", "start_index", "location_axis")
+
+    __slots__ = ()
+
+    @wraps(BaseMetadata.__eq__, assigned=("__doc__",), updated=())
+    @lenient_service
+    def __eq__(self, other):
+        return super().__eq__(other)
+
+    def _combine_lenient(self, other):
+        """Perform lenient combination of metadata members for connectivities.
+
+        Parameters
+        ----------
+        other : ConnectivityMetadata
+            The other connectivity metadata participating in the lenient
+            combination.
+
+        Returns
+        -------
+        A list of combined metadata member values.
+
+        """
+
+        # Perform "strict" combination for "cf_role", "start_index", "location_axis".
+        def func(field):
+            left = getattr(self, field)
+            right = getattr(other, field)
+            return left if left == right else None
+
+        # Note that, we use "_members" not "_fields".
+        values = [func(field) for field in ConnectivityMetadata._members]
+        # Perform lenient combination of the other parent members.
+        result = super()._combine_lenient(other)
+        result.extend(values)
+
+        return result
+
+    def _compare_lenient(self, other):
+        """Perform lenient equality of metadata members for connectivities.
+
+        Parameters
+        ----------
+        other : ConnectivityMetadata
+            The other connectivity metadata participating in the lenient
+            comparison.
+
+        Returns
+        -------
+        bool
+
+        """
+        # Perform "strict" comparison for "cf_role", "start_index".
+        # The "location_axis" member is not part of lenient equivalence.
+        members = filter(
+            lambda member: member != "location_axis",
+            ConnectivityMetadata._members,
+        )
+        result = all(
+            [getattr(self, field) == getattr(other, field) for field in members]
+        )
+        if result:
+            # Perform lenient comparison of the other parent members.
+            result = super()._compare_lenient(other)
+
+        return result
+
+    def _difference_lenient(self, other):
+        """Perform lenient difference of metadata members for connectivities.
+
+        Parameters
+        ----------
+        other : ConnectivityMetadata
+            The other connectivity metadata participating in the lenient
+            difference.
+
+        Returns
+        -------
+        A list of difference metadata member values.
+
+        """
+
+        # Perform "strict" difference for "cf_role", "start_index", "location_axis".
+        def func(field):
+            left = getattr(self, field)
+            right = getattr(other, field)
+            return None if left == right else (left, right)
+
+        # Note that, we use "_members" not "_fields".
+        values = [func(field) for field in ConnectivityMetadata._members]
+        # Perform lenient difference of the other parent members.
+        result = super()._difference_lenient(other)
+        result.extend(values)
+
+        return result
+
+    @wraps(BaseMetadata.combine, assigned=("__doc__",), updated=())
+    @lenient_service
+    def combine(self, other, lenient=None):
+        return super().combine(other, lenient=lenient)
+
+    @wraps(BaseMetadata.difference, assigned=("__doc__",), updated=())
+    @lenient_service
+    def difference(self, other, lenient=None):
+        return super().difference(other, lenient=lenient)
+
+    @wraps(BaseMetadata.equal, assigned=("__doc__",), updated=())
+    @lenient_service
+    def equal(self, other, lenient=None):
+        return super().equal(other, lenient=lenient)
+
+
+class MeshMetadata(BaseMetadata):
+    """Metadata container for a :class:`~iris.mesh.MeshXY`."""
+
+    # The node_dimension", "edge_dimension" and "face_dimension" members are
+    # stateful only; they not participate in lenient/strict equivalence.
+    _members = (
+        "topology_dimension",
+        "node_dimension",
+        "edge_dimension",
+        "face_dimension",
+    )
+
+    __slots__ = ()
+
+    @wraps(BaseMetadata.__eq__, assigned=("__doc__",), updated=())
+    @lenient_service
+    def __eq__(self, other):
+        return super().__eq__(other)
+
+    def _combine_lenient(self, other):
+        """Perform lenient combination of metadata members for meshes.
+
+        Parameters
+        ----------
+        other : MeshMetadata
+            The other mesh metadata participating in the lenient
+            combination.
+
+        Returns
+        -------
+        A list of combined metadata member values.
+
+        """
+
+        # Perform "strict" combination for "topology_dimension",
+        # "node_dimension", "edge_dimension" and "face_dimension".
+        def func(field):
+            left = getattr(self, field)
+            right = getattr(other, field)
+            return left if left == right else None
+
+        # Note that, we use "_members" not "_fields".
+        values = [func(field) for field in MeshMetadata._members]
+        # Perform lenient combination of the other parent members.
+        result = super()._combine_lenient(other)
+        result.extend(values)
+
+        return result
+
+    def _compare_lenient(self, other):
+        """Perform lenient equality of metadata members for meshes.
+
+        Parameters
+        ----------
+        other : MeshMetadata
+            The other mesh metadata participating in the lenient
+            comparison.
+
+        Returns
+        -------
+        bool
+
+        """
+        # Perform "strict" comparison for "topology_dimension".
+        # "node_dimension", "edge_dimension" and "face_dimension" are not part
+        # of lenient equivalence at all.
+        result = self.topology_dimension == other.topology_dimension
+        if result:
+            # Perform lenient comparison of the other parent members.
+            result = super()._compare_lenient(other)
+
+        return result
+
+    def _difference_lenient(self, other):
+        """Perform lenient difference of metadata members for meshes.
+
+        Parameters
+        ----------
+        other : MeshMetadata
+            The other mesh metadata participating in the lenient
+            difference.
+
+        Returns
+        -------
+        A list of difference metadata member values.
+
+        """
+
+        # Perform "strict" difference for "topology_dimension",
+        # "node_dimension", "edge_dimension" and "face_dimension".
+        def func(field):
+            left = getattr(self, field)
+            right = getattr(other, field)
+            return None if left == right else (left, right)
+
+        # Note that, we use "_members" not "_fields".
+        values = [func(field) for field in MeshMetadata._members]
+        # Perform lenient difference of the other parent members.
+        result = super()._difference_lenient(other)
+        result.extend(values)
+
+        return result
+
+    @wraps(BaseMetadata.combine, assigned=("__doc__",), updated=())
+    @lenient_service
+    def combine(self, other, lenient=None):
+        return super().combine(other, lenient=lenient)
+
+    @wraps(BaseMetadata.difference, assigned=("__doc__",), updated=())
+    @lenient_service
+    def difference(self, other, lenient=None):
+        return super().difference(other, lenient=lenient)
+
+    @wraps(BaseMetadata.equal, assigned=("__doc__",), updated=())
+    @lenient_service
+    def equal(self, other, lenient=None):
+        return super().equal(other, lenient=lenient)
+
+
+class MeshCoordMetadata(BaseMetadata):
+    """Metadata container for a :class:`~iris.coords.MeshCoord`."""
+
+    _members = ("location", "axis")
+    # NOTE: in future, we may add 'mesh' as part of this metadata,
+    # as the MeshXY seems part of the 'identity' of a MeshCoord.
+    # For now we omit it, particularly as we don't yet implement MeshXY.__eq__.
+    #
+    # Thus, for now, the MeshCoord class will need to handle 'mesh' explicitly
+    # in identity / comparison, but in future that may be simplified.
+
+    __slots__ = ()
+
+    @wraps(BaseMetadata.__eq__, assigned=("__doc__",), updated=())
+    @lenient_service
+    def __eq__(self, other):
+        return super().__eq__(other)
+
+    def _combine_lenient(self, other):
+        """Perform lenient combination of metadata members for MeshCoord.
+
+        Parameters
+        ----------
+        other : MeshCoordMetadata
+            The other metadata participating in the lenient combination.
+
+        Returns
+        -------
+        A list of combined metadata member values.
+
+        """
+
+        # It is actually "strict" : return None except where members are equal.
+        def func(field):
+            left = getattr(self, field)
+            right = getattr(other, field)
+            return left if left == right else None
+
+        # Note that, we use "_members" not "_fields".
+        values = [func(field) for field in self._members]
+        # Perform lenient combination of the other parent members.
+        result = super()._combine_lenient(other)
+        result.extend(values)
+
+        return result
+
+    def _compare_lenient(self, other):
+        """Perform lenient equality of metadata members for MeshCoord.
+
+        Parameters
+        ----------
+        other : MeshCoordMetadata
+            The other metadata participating in the lenient comparison.
+
+        Returns
+        -------
+        bool
+
+        """
+        # Perform "strict" comparison for the MeshCoord specific members
+        # 'location', 'axis' : for equality, they must all match.
+        result = all(
+            [getattr(self, field) == getattr(other, field) for field in self._members]
+        )
+        if result:
+            # Perform lenient comparison of the other parent members.
+            result = super()._compare_lenient(other)
+
+        return result
+
+    def _difference_lenient(self, other):
+        """Perform lenient difference of metadata members for MeshCoord.
+
+        Parameters
+        ----------
+        other : MeshCoordMetadata
+            The other MeshCoord metadata participating in the lenient
+            difference.
+
+        Returns
+        -------
+        A list of different metadata member values.
+
+        """
+
+        # Perform "strict" difference for location / axis.
+        def func(field):
+            left = getattr(self, field)
+            right = getattr(other, field)
+            return None if left == right else (left, right)
+
+        # Note that, we use "_members" not "_fields".
+        values = [func(field) for field in self._members]
+        # Perform lenient difference of the other parent members.
+        result = super()._difference_lenient(other)
+        result.extend(values)
+
+        return result
+
+    @wraps(BaseMetadata.combine, assigned=("__doc__",), updated=())
+    @lenient_service
+    def combine(self, other, lenient=None):
+        return super().combine(other, lenient=lenient)
+
+    @wraps(BaseMetadata.difference, assigned=("__doc__",), updated=())
+    @lenient_service
+    def difference(self, other, lenient=None):
+        return super().difference(other, lenient=lenient)
+
+    @wraps(BaseMetadata.equal, assigned=("__doc__",), updated=())
+    @lenient_service
+    def equal(self, other, lenient=None):
+        return super().equal(other, lenient=lenient)
+
+
 def metadata_filter(
     instances,
     item=None,
@@ -1601,46 +1952,54 @@ def metadata_manager_factory(cls, **kwargs):
 
 
 #: Convenience collection of lenient metadata combine services.
-# TODO: change lists back to tuples once CellMeasureMetadata is re-integrated
-# here (currently in iris.ugrid).
-# TODO: complete iris.ugrid replacement
-SERVICES_COMBINE = [
+SERVICES_COMBINE = (
     AncillaryVariableMetadata.combine,
     BaseMetadata.combine,
     CellMeasureMetadata.combine,
+    ConnectivityMetadata.combine,
     CoordMetadata.combine,
     CubeMetadata.combine,
     DimCoordMetadata.combine,
-]
+    MeshCoordMetadata.combine,
+    MeshMetadata.combine,
+)
 
 
 #: Convenience collection of lenient metadata difference services.
-SERVICES_DIFFERENCE = [
+SERVICES_DIFFERENCE = (
     AncillaryVariableMetadata.difference,
     BaseMetadata.difference,
     CellMeasureMetadata.difference,
+    ConnectivityMetadata.difference,
     CoordMetadata.difference,
     CubeMetadata.difference,
     DimCoordMetadata.difference,
-]
+    MeshCoordMetadata.difference,
+    MeshMetadata.difference,
+)
 
 
 #: Convenience collection of lenient metadata equality services.
-SERVICES_EQUAL = [
+SERVICES_EQUAL = (
     AncillaryVariableMetadata.__eq__,
     AncillaryVariableMetadata.equal,
     BaseMetadata.__eq__,
     BaseMetadata.equal,
     CellMeasureMetadata.__eq__,
     CellMeasureMetadata.equal,
+    ConnectivityMetadata.__eq__,
+    ConnectivityMetadata.equal,
     CoordMetadata.__eq__,
     CoordMetadata.equal,
     CubeMetadata.__eq__,
     CubeMetadata.equal,
     DimCoordMetadata.__eq__,
     DimCoordMetadata.equal,
-]
-
+    MeshCoordMetadata.__eq__,
+    MeshCoordMetadata.equal,
+    MeshMetadata.__eq__,
+    MeshMetadata.equal,
+)
 
 #: Convenience collection of lenient metadata services.
 SERVICES = SERVICES_COMBINE + SERVICES_DIFFERENCE + SERVICES_EQUAL
diff --git a/lib/iris/common/resolve.py b/lib/iris/common/resolve.py
index d678d13cf8..87ad05791b 100644
--- a/lib/iris/common/resolve.py
+++ b/lib/iris/common/resolve.py
@@ -71,7 +71,7 @@ class _PreparedItem:
     axis: Any = None
 
     def create_coord(self, metadata):
-        from iris.ugrid.mesh import MeshCoord
+        from iris.mesh import MeshCoord
 
         if issubclass(self.container, MeshCoord):
             # Make a MeshCoord, for which we have mesh/location/axis.
@@ -741,7 +741,7 @@ def _create_prepared_item(
         if container is None:
             container = type(coord)
 
-        from iris.ugrid.mesh import MeshCoord
+        from iris.mesh import MeshCoord
 
         if issubclass(container, MeshCoord):
             # Build a prepared-item to make a MeshCoord.
diff --git a/lib/iris/coords.py b/lib/iris/coords.py
index 40b131e496..a56c13d9af 100644
--- a/lib/iris/coords.py
+++ b/lib/iris/coords.py
@@ -804,6 +804,9 @@ def xml_element(self, doc):
             :class:`_DimensionalMetadata`.
 
         """
+        # deferred import to avoid possible circularity
+        from iris.mesh import Connectivity
+
         # Create the XML element as the camelCaseEquivalent of the
         # class name.
         element_name = type(self).__name__
@@ -843,10 +846,7 @@ def xml_element(self, doc):
         # otherwise.
         if isinstance(self, Coord):
             values_term = "points"
-        # TODO: replace with isinstance(self, Connectivity) once Connectivity
-        # is re-integrated here (currently in iris.ugrid).
-        # TODO: complete iris.ugrid replacement
-        elif hasattr(self, "indices"):
+        elif isinstance(self, Connectivity):
             values_term = "indices"
         else:
             values_term = "data"
diff --git a/lib/iris/cube.py b/lib/iris/cube.py
index eb4b82fd73..54e086937d 100644
--- a/lib/iris/cube.py
+++ b/lib/iris/cube.py
@@ -2088,7 +2088,7 @@ def coords(
             If ``None``, returns all coordinates.
         mesh_coords : optional
             Set to ``True`` to return only coordinates which are
-            :class:`~iris.ugrid.MeshCoord`\'s.
+            :class:`~iris.mesh.MeshCoord`\'s.
             Set to ``False`` to return only non-mesh coordinates.
             If ``None``, returns all coordinates.
 
@@ -2115,7 +2115,7 @@ def coords(
         if mesh_coords is not None:
             # Select on mesh or non-mesh.
             mesh_coords = bool(mesh_coords)
-            # Use duck typing to avoid importing from iris.ugrid,
+            # Use duck typing to avoid importing from iris.mesh,
             # which could be a circular import.
             if mesh_coords:
                 # *only* MeshCoords
@@ -2245,7 +2245,7 @@ def coord(
             If ``None``, returns all coordinates.
         mesh_coords : optional
             Set to ``True`` to return only coordinates which are
-            :class:`~iris.ugrid.MeshCoord`\'s.
+            :class:`~iris.mesh.MeshCoord`\'s.
             Set to ``False`` to return only non-mesh coordinates.
             If ``None``, returns all coordinates.
 
@@ -2365,18 +2365,18 @@ def _any_meshcoord(self):
 
     @property
     def mesh(self):
-        r"""Return the unstructured :class:`~iris.ugrid.MeshXY` associated with the cube.
+        r"""Return the unstructured :class:`~iris.mesh.MeshXY` associated with the cube.
 
-        Return the unstructured :class:`~iris.ugrid.MeshXY`
+        Return the unstructured :class:`~iris.mesh.MeshXY`
         associated with the cube, if the cube has any
-        :class:`~iris.ugrid.MeshCoord`,
+        :class:`~iris.mesh.MeshCoord`,
         or ``None`` if it has none.
 
         Returns
         -------
-        :class:`iris.ugrid.mesh.MeshXY` or None
+        :class:`iris.mesh.MeshXY` or None
             The mesh of the cube
-            :class:`~iris.ugrid.MeshCoord`'s,
+            :class:`~iris.mesh.MeshCoord`'s,
             or ``None``.
 
         """
@@ -2390,14 +2390,14 @@ def location(self):
         r"""Return the mesh "location" of the cube data.
 
         Return the mesh "location" of the cube data, if the cube has any
-        :class:`~iris.ugrid.MeshCoord`,
+        :class:`~iris.mesh.MeshCoord`,
         or ``None`` if it has none.
 
         Returns
         -------
         str or None
             The mesh location of the cube
-            :class:`~iris.ugrid.MeshCoords`
+            :class:`~iris.mesh.MeshCoords`
             (i.e. one of 'face' / 'edge' / 'node'), or ``None``.
 
         """
@@ -2410,14 +2410,14 @@ def mesh_dim(self):
         r"""Return the cube dimension of the mesh.
 
         Return the cube dimension of the mesh, if the cube has any
-        :class:`~iris.ugrid.MeshCoord`,
+        :class:`~iris.mesh.MeshCoord`,
         or ``None`` if it has none.
 
         Returns
         -------
         int or None
             The cube dimension which the cube
-            :class:`~iris.ugrid.MeshCoord` map to,
+            :class:`~iris.mesh.MeshCoord` map to,
             or ``None``.
 
         """
diff --git a/lib/iris/experimental/geovista.py b/lib/iris/experimental/geovista.py
index 690e19d543..07413f1529 100644
--- a/lib/iris/experimental/geovista.py
+++ b/lib/iris/experimental/geovista.py
@@ -8,7 +8,7 @@
 from geovista.common import VTK_CELL_IDS, VTK_POINT_IDS
 
 from iris.exceptions import CoordinateNotFoundError
-from iris.ugrid import MeshXY
+from iris.mesh import MeshXY
 
 
 def _get_coord(cube, axis):
@@ -52,7 +52,7 @@ def cube_to_polydata(cube, **kwargs):
         If a :class:`~iris.cube.Cube` with too many dimensions is passed. Only
         the horizontal data can be represented, meaning a 2D Cube, or 1D Cube
         if the horizontal space is described by
-        :class:`~iris.ugrid.MeshCoord`\ s.
+        :class:`~iris.mesh.MeshCoord`\ s.
 
     Examples
     --------
diff --git a/lib/iris/experimental/ugrid.py b/lib/iris/experimental/ugrid.py
index bbc8ef93b3..6e036ad96e 100644
--- a/lib/iris/experimental/ugrid.py
+++ b/lib/iris/experimental/ugrid.py
@@ -5,7 +5,7 @@
 
 """Legacy import location for mesh support.
 
-See :mod:`iris.ugrid` for the new, correct import location.
+See :mod:`iris.mesh` for the new, correct import location.
 
 Notes
 -----
@@ -17,37 +17,39 @@
 experimental.
 
 .. deprecated:: 3.10
-    All the former :mod:`iris.experimental.ugrid` modules have been relocated to
-    :mod:`iris.ugrid` and its submodules.  Please re-write code to import from the new
-    module path.
-    This import path alios is provided for backwards compatibility, but will be removed
+    All the former :mod:`iris.experimental.mesh` modules have been relocated to
+    :mod:`iris.mesh` and its submodules.  Please re-write code to import from the new
+    module path, and replace any 'iris.experimental.ugrid.Mesh' with
+    'iris.mesh.MeshXY'.
+
+    This import path alias is provided for backwards compatibility, but will be removed
     in a future release : N.B. removing this does **not** need to wait for a major
-    release, since the former API was experimental.
+    release, since the API is experimental.
 
 """
 
 from __future__ import annotations
 
+from contextlib import contextmanager
+import threading
+
 from .._deprecation import warn_deprecated
-from ..ugrid.load import PARSE_UGRID_ON_LOAD, load_mesh, load_meshes
-from ..ugrid.mesh import Connectivity as _Connectivity
-from ..ugrid.mesh import MeshCoord as _MeshCoord
-from ..ugrid.mesh import MeshXY as _MeshXY
-from ..ugrid.save import save_mesh
-from ..ugrid.utils import recombine_submeshes
+from ..mesh import Connectivity as _Connectivity
+from ..mesh import MeshCoord as _MeshCoord
+from ..mesh import MeshXY as _MeshXY
+from ..mesh import load_mesh, load_meshes, recombine_submeshes, save_mesh
 
 
 # NOTE: publishing the original Mesh, MeshCoord and Connectivity here causes a Sphinx
 # Sphinx warning, E.G.:
-#   "WARNING: duplicate object description of iris.ugrid.mesh.Mesh, other instance
-#       in generated/api/iris.experimental.ugrid, use :no-index: for one of them"
+#   "WARNING: duplicate object description of iris.mesh.Mesh, other instance
+#       in generated/api/iris.experimental.mesh, use :no-index: for one of them"
 # For some reason, this only happens for the classes, and not the functions.
 #
 # This is a fatal problem, i.e. breaks the build since we are building with -W.
 # We couldn't fix this with "autodoc_suppress_warnings", so the solution for now is to
 # wrap the classes.  Which is really ugly.
-# TODO: remove this when we remove iris.experimental.ugrid
-class MeshXY(_MeshXY):
+class Mesh(_MeshXY):
     pass
 
 
@@ -59,10 +61,85 @@ class Connectivity(_Connectivity):
     pass
 
 
+class ParseUGridOnLoad(threading.local):
+    def __init__(self):
+        """Thead-safe state to enable UGRID-aware NetCDF loading.
+
+        A flag for dictating whether to use the experimental UGRID-aware
+        version of Iris NetCDF loading. Object is thread-safe.
+
+        Use via the run-time switch
+        :const:`~iris.mesh.load.PARSE_UGRID_ON_LOAD`.
+        Use :meth:`context` to temporarily activate.
+
+        Notes
+        -----
+            .. deprecated:: 1.10
+        Do not use -- due to be removed at next major release :
+        UGRID loading is now **always** active for files containing a UGRID mesh.
+
+        """
+
+    def __bool__(self):
+        return True
+
+    @contextmanager
+    def context(self):
+        """Activate UGRID-aware NetCDF loading.
+
+        Use the standard Iris loading API while within the context manager. If
+        the loaded file(s) include any UGRID content, this will be parsed and
+        attached to the resultant cube(s) accordingly.
+
+        Use via the run-time switch
+        :const:`~iris.mesh.load.PARSE_UGRID_ON_LOAD`.
+
+        For example::
+
+            with PARSE_UGRID_ON_LOAD.context():
+                my_cube_list = iris.load([my_file_path, my_file_path2],
+                                         constraint=my_constraint,
+                                         callback=my_callback)
+
+        Notes
+        -----
+            .. deprecated:: 1.10
+        Do not use -- due to be removed at next major release :
+        UGRID loading is now **always** active for files containing a UGRID mesh.
+
+        Examples
+        --------
+        Replace usage, for example:
+
+        .. code-block:: python
+
+            with iris.experimental.mesh.PARSE_UGRID_ON_LOAD.context():
+                mesh_cubes = iris.load(path)
+
+        with:
+
+        .. code-block:: python
+
+            mesh_cubes = iris.load(path)
+
+        """
+        wmsg = (
+            "iris.experimental.mesh.load.PARSE_UGRID_ON_LOAD has been deprecated "
+            "and will be removed. Please remove all uses : these are no longer needed, "
+            "as UGRID loading is now applied to any file containing a mesh."
+        )
+        warn_deprecated(wmsg)
+        yield
+
+
+#: Run-time switch for experimental UGRID-aware NetCDF loading. See :class:`~iris.mesh.load.ParseUGridOnLoad`.
+PARSE_UGRID_ON_LOAD = ParseUGridOnLoad()
+
+
 __all__ = [
     "Connectivity",
+    "Mesh",
     "MeshCoord",
-    "MeshXY",
     "PARSE_UGRID_ON_LOAD",
     "load_mesh",
     "load_meshes",
@@ -71,7 +148,8 @@ class Connectivity(_Connectivity):
 ]
 
 warn_deprecated(
-    "All the former :mod:`iris.experimental.ugrid` modules have been relocated to "
-    "module 'iris.ugrid' and its submodules. "
-    "Please re-write code to import from the new module path."
+    "All the former :mod:`iris.experimental.mesh` modules have been relocated to "
+    "module 'iris.mesh' and its submodules. "
+    "Please re-write code to import from the new module path, and replace any "
+    "'iris.experimental.ugrid.Mesh' with 'iris.mesh.MeshXY'."
 )
diff --git a/lib/iris/fileformats/cf.py b/lib/iris/fileformats/cf.py
index b4c754e4a6..556642003a 100644
--- a/lib/iris/fileformats/cf.py
+++ b/lib/iris/fileformats/cf.py
@@ -25,8 +25,10 @@
 import numpy.ma as ma
 
 from iris.fileformats.netcdf import _thread_safe_nc
+from iris.mesh.components import Connectivity
 import iris.util
 import iris.warnings
+from iris.warnings import IrisCfLabelVarWarning, IrisCfMissingVarWarning
 
 #
 # CF parse pattern common to both formula terms and measure CF variables.
@@ -889,6 +891,226 @@ def identify(cls, variables, ignore=None, target=None, warn=True):
         return result
 
 
+class CFUGridConnectivityVariable(CFVariable):
+    """A CF_UGRID connectivity variable.
+
+    A CF_UGRID connectivity variable points to an index variable identifying
+    for every element (edge/face/volume) the indices of its corner nodes. The
+    connectivity array will thus be a matrix of size n-elements x n-corners.
+    For the indexing one may use either 0- or 1-based indexing; the convention
+    used should be specified using a ``start_index`` attribute to the index
+    variable.
+
+    For face elements: the corner nodes should be specified in anticlockwise
+    direction as viewed from above. For volume elements: use the
+    additional attribute ``volume_shape_type`` which points to a flag variable
+    that specifies for every volume its shape.
+
+    Identified by a CF-netCDF variable attribute equal to any one of the values
+    in :attr:`~iris.mesh.Connectivity.UGRID_CF_ROLES`.
+
+    .. seealso::
+
+        The UGRID Conventions, https://ugrid-conventions.github.io/ugrid-conventions/
+
+    """
+
+    cf_identity = NotImplemented
+    cf_identities = Connectivity.UGRID_CF_ROLES
+
+    @classmethod
+    def identify(cls, variables, ignore=None, target=None, warn=True):
+        result = {}
+        ignore, target = cls._identify_common(variables, ignore, target)
+
+        # Identify all CF-UGRID connectivity variables.
+        for nc_var_name, nc_var in target.items():
+            # Check for connectivity variable references, iterating through
+            # the valid cf roles.
+            for identity in cls.cf_identities:
+                nc_var_att = getattr(nc_var, identity, None)
+
+                if nc_var_att is not None:
+                    # UGRID only allows for one of each connectivity cf role.
+                    name = nc_var_att.strip()
+                    if name not in ignore:
+                        if name not in variables:
+                            message = (
+                                f"Missing CF-UGRID connectivity variable "
+                                f"{name}, referenced by netCDF variable "
+                                f"{nc_var_name}"
+                            )
+                            if warn:
+                                warnings.warn(message, category=IrisCfMissingVarWarning)
+                        else:
+                            # Restrict to non-string type i.e. not a
+                            # CFLabelVariable.
+                            if not _is_str_dtype(variables[name]):
+                                result[name] = CFUGridConnectivityVariable(
+                                    name, variables[name]
+                                )
+                            else:
+                                message = (
+                                    f"Ignoring variable {name}, identified "
+                                    f"as a CF-UGRID connectivity - is a "
+                                    f"CF-netCDF label variable."
+                                )
+                                if warn:
+                                    warnings.warn(
+                                        message, category=IrisCfLabelVarWarning
+                                    )
+
+        return result
+
+
+class CFUGridAuxiliaryCoordinateVariable(CFVariable):
+    """A CF-UGRID auxiliary coordinate variable.
+
+    A CF-UGRID auxiliary coordinate variable is a CF-netCDF auxiliary
+    coordinate variable representing the element (node/edge/face/volume)
+    locations (latitude, longitude or other spatial coordinates, and optional
+    elevation or other coordinates). These auxiliary coordinate variables will
+    have length n-elements.
+
+    For elements other than nodes, these auxiliary coordinate variables may
+    have in turn a ``bounds`` attribute that specifies the bounding coordinates
+    of the element (thereby duplicating the data in the ``node_coordinates``
+    variables).
+
+    Identified by the CF-netCDF variable attribute
+    ``node_``/``edge_``/``face_``/``volume_coordinates``.
+
+    .. seealso::
+
+        The UGRID Conventions, https://ugrid-conventions.github.io/ugrid-conventions/
+
+    """
+
+    cf_identity = NotImplemented
+    cf_identities = [
+        "node_coordinates",
+        "edge_coordinates",
+        "face_coordinates",
+        "volume_coordinates",
+    ]
+
+    @classmethod
+    def identify(cls, variables, ignore=None, target=None, warn=True):
+        result = {}
+        ignore, target = cls._identify_common(variables, ignore, target)
+
+        # Identify any CF-UGRID-relevant auxiliary coordinate variables.
+        for nc_var_name, nc_var in target.items():
+            # Check for UGRID auxiliary coordinate variable references.
+            for identity in cls.cf_identities:
+                nc_var_att = getattr(nc_var, identity, None)
+
+                if nc_var_att is not None:
+                    for name in nc_var_att.split():
+                        if name not in ignore:
+                            if name not in variables:
+                                message = (
+                                    f"Missing CF-netCDF auxiliary coordinate "
+                                    f"variable {name}, referenced by netCDF "
+                                    f"variable {nc_var_name}"
+                                )
+                                if warn:
+                                    warnings.warn(
+                                        message,
+                                        category=IrisCfMissingVarWarning,
+                                    )
+                            else:
+                                # Restrict to non-string type i.e. not a
+                                # CFLabelVariable.
+                                if not _is_str_dtype(variables[name]):
+                                    result[name] = CFUGridAuxiliaryCoordinateVariable(
+                                        name, variables[name]
+                                    )
+                                else:
+                                    message = (
+                                        f"Ignoring variable {name}, "
+                                        f"identified as a CF-netCDF "
+                                        f"auxiliary coordinate - is a "
+                                        f"CF-netCDF label variable."
+                                    )
+                                    if warn:
+                                        warnings.warn(
+                                            message,
+                                            category=IrisCfLabelVarWarning,
+                                        )
+
+        return result
+
+
+class CFUGridMeshVariable(CFVariable):
+    """A CF-UGRID mesh variable is a dummy variable for storing topology information as attributes.
+
+    A CF-UGRID mesh variable is a dummy variable for storing topology
+    information as attributes. The mesh variable has the ``cf_role``
+    'mesh_topology'.
+
+    The UGRID conventions describe define the mesh topology as the
+    interconnection of various geometrical elements of the mesh. The pure
+    interconnectivity is independent of georeferencing the individual
+    geometrical elements, but for the practical applications for which the
+    UGRID CF extension is defined, coordinate data will always be added.
+
+    Identified by the CF-netCDF variable attribute 'mesh'.
+
+    .. seealso::
+
+        The UGRID Conventions, https://ugrid-conventions.github.io/ugrid-conventions/
+
+    """
+
+    cf_identity = "mesh"
+
+    @classmethod
+    def identify(cls, variables, ignore=None, target=None, warn=True):
+        result = {}
+        ignore, target = cls._identify_common(variables, ignore, target)
+
+        # Identify all CF-UGRID mesh variables.
+        all_vars = target == variables
+        for nc_var_name, nc_var in target.items():
+            if all_vars:
+                # SPECIAL BEHAVIOUR FOR MESH VARIABLES.
+                # We are looking for all mesh variables. Check if THIS variable
+                #  is a mesh using its own attributes.
+                if getattr(nc_var, "cf_role", "") == "mesh_topology":
+                    result[nc_var_name] = CFUGridMeshVariable(nc_var_name, nc_var)
+
+            # Check for mesh variable references.
+            nc_var_att = getattr(nc_var, cls.cf_identity, None)
+
+            if nc_var_att is not None:
+                # UGRID only allows for 1 mesh per variable.
+                name = nc_var_att.strip()
+                if name not in ignore:
+                    if name not in variables:
+                        message = (
+                            f"Missing CF-UGRID mesh variable {name}, "
+                            f"referenced by netCDF variable {nc_var_name}"
+                        )
+                        if warn:
+                            warnings.warn(message, category=IrisCfMissingVarWarning)
+                    else:
+                        # Restrict to non-string type i.e. not a
+                        # CFLabelVariable.
+                        if not _is_str_dtype(variables[name]):
+                            result[name] = CFUGridMeshVariable(name, variables[name])
+                        else:
+                            message = (
+                                f"Ignoring variable {name}, identified as a "
+                                f"CF-UGRID mesh - is a CF-netCDF label "
+                                f"variable."
+                            )
+                            if warn:
+                                warnings.warn(message, category=IrisCfLabelVarWarning)
+
+        return result
+
+
 ################################################################################
 class CFGroup(MutableMapping):
     """Collection of 'NetCDF CF Metadata Conventions variables and netCDF global attributes.
@@ -980,12 +1202,30 @@ def non_data_variable_names(self):
             self.grid_mappings,
             self.labels,
             self.cell_measures,
+            self.connectivities,
+            self.ugrid_coords,
+            self.meshes,
         )
         result = set()
         for variable in non_data_variables:
             result |= set(variable)
         return result
 
+    @property
+    def connectivities(self):
+        """Collection of CF-UGRID connectivity variables."""
+        return self._cf_getter(CFUGridConnectivityVariable)
+
+    @property
+    def ugrid_coords(self):
+        """Collection of CF-UGRID-relevant auxiliary coordinate variables."""
+        return self._cf_getter(CFUGridAuxiliaryCoordinateVariable)
+
+    @property
+    def meshes(self):
+        """Collection of CF-UGRID mesh variables."""
+        return self._cf_getter(CFUGridMeshVariable)
+
     def keys(self):
         """Return the names of all the CF-netCDF variables in the group."""
         return self._cf_variables.keys()
@@ -1054,10 +1294,11 @@ class CFReader:
         CFGridMappingVariable,
         CFLabelVariable,
         CFMeasureVariable,
+        CFUGridConnectivityVariable,
+        CFUGridAuxiliaryCoordinateVariable,
+        CFUGridMeshVariable,
     )
 
-    # TODO: remove once iris.ugrid.CFUGridReader is folded in.
-    # TODO: complete iris.ugrid replacement
     CFGroup = CFGroup
 
     def __init__(self, file_source, warn=False, monotonic=False):
@@ -1174,10 +1415,7 @@ def _build_cf_groups(self):
         """Build the first order relationships between CF-netCDF variables."""
 
         def _build(cf_variable):
-            # TODO: isinstance(cf_variable, UGridMeshVariable)
-            #  UGridMeshVariable currently in iris.ugrid - circular import.
-            # TODO: complete iris.ugrid replacement
-            is_mesh_var = cf_variable.cf_identity == "mesh"
+            is_mesh_var = isinstance(cf_variable, CFUGridMeshVariable)
             ugrid_coord_names = []
             ugrid_coords = getattr(self.cf_group, "ugrid_coords", None)
             if ugrid_coords is not None:
diff --git a/lib/iris/fileformats/netcdf/loader.py b/lib/iris/fileformats/netcdf/loader.py
index 2bdfed9fff..55d0a88b79 100644
--- a/lib/iris/fileformats/netcdf/loader.py
+++ b/lib/iris/fileformats/netcdf/loader.py
@@ -578,13 +578,11 @@ def load_cubes(file_sources, callback=None, constraints=None):
     Generator of loaded NetCDF :class:`iris.cube.Cube`.
 
     """
-    # TODO: rationalise UGRID/mesh handling once iris.ugrid is folded
-    # TODO: complete iris.ugrid replacement
-    # into standard behaviour.
     # Deferred import to avoid circular imports.
+    from iris.fileformats.cf import CFReader
     from iris.io import run_callback
-    from iris.ugrid.cf import CFUGridReader
-    from iris.ugrid.load import (
+
+    from .ugrid_load import (
         _build_mesh_coords,
         _meshes_from_cf,
     )
@@ -600,7 +598,7 @@ def load_cubes(file_sources, callback=None, constraints=None):
 
     for file_source in file_sources:
         # Ingest the file.  At present may be a filepath or an open netCDF4.Dataset.
-        with CFUGridReader(file_source) as cf:
+        with CFReader(file_source) as cf:
             meshes = _meshes_from_cf(cf)
 
             # Process each CF data variable.
@@ -684,8 +682,8 @@ def __init__(self, var_dim_chunksizes=None):
         :class:`~iris.coords.AncillaryVariable` etc.
         This can be overridden, if required, by variable-specific settings.
 
-        For this purpose, :class:`~iris.ugrid.mesh.MeshCoord` and
-        :class:`~iris.ugrid.mesh.Connectivity` are not
+        For this purpose, :class:`~iris.mesh.MeshCoord` and
+        :class:`~iris.mesh.Connectivity` are not
         :class:`~iris.cube.Cube` components, and chunk control on a
         :class:`~iris.cube.Cube` data-variable will not affect them.
 
diff --git a/lib/iris/fileformats/netcdf/saver.py b/lib/iris/fileformats/netcdf/saver.py
index 1981091717..cfc69143ae 100644
--- a/lib/iris/fileformats/netcdf/saver.py
+++ b/lib/iris/fileformats/netcdf/saver.py
@@ -271,7 +271,7 @@ def _setncattr(variable, name, attribute):
     return variable.setncattr(name, attribute)
 
 
-# NOTE : this matches :class:`iris.ugrid.mesh.MeshXY.ELEMENTS`,
+# NOTE : this matches :class:`iris.mesh.MeshXY.ELEMENTS`,
 # but in the preferred order for coord/connectivity variables in the file.
 MESH_ELEMENTS = ("node", "edge", "face")
 
@@ -766,7 +766,7 @@ def _add_mesh(self, cube_or_mesh):
 
         Parameters
         ----------
-        cube_or_mesh : :class:`iris.cube.Cube` or :class:`iris.ugrid.MeshXY`
+        cube_or_mesh : :class:`iris.cube.Cube` or :class:`iris.mesh.MeshXY`
             The Cube or Mesh being saved to the netCDF file.
 
         Returns
@@ -941,7 +941,7 @@ def _add_aux_coords(self, cube, cf_var_cube, dimension_names):
         dimension_names : list
             Names associated with the dimensions of the cube.
         """
-        from iris.ugrid.mesh import (
+        from iris.mesh.components import (
             MeshEdgeCoords,
             MeshFaceCoords,
             MeshNodeCoords,
@@ -1120,7 +1120,7 @@ def _get_dim_names(self, cube_or_mesh):
 
         Parameters
         ----------
-        cube_or_mesh : :class:`iris.cube.Cube` or :class:`iris.ugrid.MeshXY`
+        cube_or_mesh : :class:`iris.cube.Cube` or :class:`iris.mesh.MeshXY`
             The Cube or Mesh being saved to the netCDF file.
 
         Returns
@@ -1482,7 +1482,7 @@ def _get_coord_variable_name(self, cube_or_mesh, coord):
 
         Parameters
         ----------
-        cube_or_mesh : :class:`iris.cube.Cube` or :class:`iris.ugrid.MeshXY`
+        cube_or_mesh : :class:`iris.cube.Cube` or :class:`iris.mesh.MeshXY`
             The Cube or Mesh being saved to the netCDF file.
         coord : :class:`iris.coords._DimensionalMetadata`
             An instance of a coordinate (or similar), for which a CF-netCDF
@@ -1524,7 +1524,7 @@ def _get_coord_variable_name(self, cube_or_mesh, coord):
                     # element-coordinate of the mesh.
                     # Name it for it's first dim, i.e. mesh-dim of its location.
 
-                    from iris.ugrid.mesh import Connectivity
+                    from iris.mesh import Connectivity
 
                     # At present, a location-coord cannot be nameless, as the
                     # MeshXY code relies on guess_coord_axis.
@@ -1544,7 +1544,7 @@ def _get_mesh_variable_name(self, mesh):
 
         Parameters
         ----------
-        mesh : :class:`iris.ugrid.mesh.MeshXY`
+        mesh : :class:`iris.mesh.MeshXY`
             An instance of a Mesh for which a CF-netCDF variable name is
             required.
 
@@ -1570,7 +1570,7 @@ def _create_mesh(self, mesh):
 
         Parameters
         ----------
-        mesh : :class:`iris.ugrid.mesh.MeshXY`
+        mesh : :class:`iris.mesh.MeshXY`
             The Mesh to be saved to CF-netCDF file.
 
         Returns
@@ -1660,7 +1660,7 @@ def _create_generic_cf_array_var(
 
         Parameters
         ----------
-        cube_or_mesh : :class:`iris.cube.Cube` or :class:`iris.ugrid.MeshXY`
+        cube_or_mesh : :class:`iris.cube.Cube` or :class:`iris.mesh.MeshXY`
             The Cube or Mesh being saved to the netCDF file.
         cube_dim_names : list of str
             The name of each dimension of the cube.
@@ -2796,3 +2796,40 @@ def is_valid_packspec(p):
         result = sman.delayed_completion()
 
     return result
+
+
+def save_mesh(mesh, filename, netcdf_format="NETCDF4"):
+    """Save mesh(es) to a netCDF file.
+
+    Parameters
+    ----------
+    mesh : :class:`iris.mesh.MeshXY` or iterable
+        Mesh(es) to save.
+    filename : str
+        Name of the netCDF file to create.
+    netcdf_format : str, default="NETCDF4"
+        Underlying netCDF file format, one of 'NETCDF4', 'NETCDF4_CLASSIC',
+        'NETCDF3_CLASSIC' or 'NETCDF3_64BIT'. Default is 'NETCDF4' format.
+
+    """
+    if isinstance(mesh, typing.Iterable):
+        meshes = mesh
+    else:
+        meshes = [mesh]
+
+    # Initialise Manager for saving
+    with Saver(filename, netcdf_format) as sman:
+        # Iterate through the list.
+        for mesh in meshes:
+            # Get suitable dimension names.
+            mesh_dimensions, _ = sman._get_dim_names(mesh)
+
+            # Create dimensions.
+            sman._create_cf_dimensions(cube=None, dimension_names=mesh_dimensions)
+
+            # Create the mesh components.
+            sman._add_mesh(mesh)
+
+        # Add a conventions attribute.
+        # TODO: add 'UGRID' to conventions, when this is agreed with CF ?
+        sman.update_global_attributes(Conventions=CF_CONVENTIONS_VERSION)
diff --git a/lib/iris/ugrid/load.py b/lib/iris/fileformats/netcdf/ugrid_load.py
similarity index 71%
rename from lib/iris/ugrid/load.py
rename to lib/iris/fileformats/netcdf/ugrid_load.py
index a3f278ef23..210e112629 100644
--- a/lib/iris/ugrid/load.py
+++ b/lib/iris/fileformats/netcdf/ugrid_load.py
@@ -3,12 +3,10 @@
 # This file is part of Iris and is released under the BSD license.
 # See LICENSE in the root of the repository for full licensing details.
 
-r"""Allow the construction of :class:`~iris.ugrid.mesh.MeshXY`.
+r"""Allow the construction of :class:`~iris.mesh.MeshXY`.
 
-Extensions to Iris' NetCDF loading to allow the construction of
-:class:`~iris.ugrid.mesh.MeshXY` from UGRID data in the file.
-
-Eventual destination: :mod:`iris.fileformats.netcdf`.
+Extension functions for Iris NetCDF loading, to construct
+:class:`~iris.mesh.MeshXY` from UGRID data in files.
 
 .. seealso::
 
@@ -17,27 +15,21 @@
 
 """
 
-from contextlib import contextmanager
 from itertools import groupby
 from pathlib import Path
-import threading
 import warnings
 
-from .._deprecation import warn_deprecated
-from ..config import get_logger
-from ..coords import AuxCoord
-from ..fileformats._nc_load_rules.helpers import get_attr_units, get_names
-from ..fileformats.netcdf import loader as nc_loader
-from ..io import decode_uri, expand_filespecs
-from ..util import guess_coord_axis
-from ..warnings import IrisCfWarning, IrisDefaultingWarning, IrisIgnoringWarning
-from .cf import (
-    CFUGridAuxiliaryCoordinateVariable,
-    CFUGridConnectivityVariable,
-    CFUGridMeshVariable,
-    CFUGridReader,
-)
-from .mesh import Connectivity, MeshXY
+from iris.config import get_logger
+from iris.coords import AuxCoord
+from iris.io import decode_uri, expand_filespecs
+from iris.mesh.components import Connectivity, MeshXY
+from iris.util import guess_coord_axis
+from iris.warnings import IrisCfWarning, IrisDefaultingWarning, IrisIgnoringWarning
+
+# NOTE: all imports from iris.fileformats.netcdf must be deferred, to avoid circular
+# imports.
+# This is needed so that load_mesh/load_meshes can be included in the iris.mesh API.
+
 
 # Configure the logger.
 logger = get_logger(__name__, propagate=True, handler=False)
@@ -55,81 +47,6 @@ class _WarnComboCfDefaultingIgnoring(_WarnComboCfDefaulting, IrisIgnoringWarning
     pass
 
 
-class ParseUGridOnLoad(threading.local):
-    def __init__(self):
-        """Thead-safe state to enable UGRID-aware NetCDF loading.
-
-        A flag for dictating whether to use the experimental UGRID-aware
-        version of Iris NetCDF loading. Object is thread-safe.
-
-        Use via the run-time switch
-        :const:`~iris.ugrid.load.PARSE_UGRID_ON_LOAD`.
-        Use :meth:`context` to temporarily activate.
-
-        Notes
-        -----
-            .. deprecated:: 1.10
-        Do not use -- due to be removed at next major release :
-        UGRID loading is now **always** active for files containing a UGRID mesh.
-
-        """
-
-    def __bool__(self):
-        return True
-
-    @contextmanager
-    def context(self):
-        """Activate UGRID-aware NetCDF loading.
-
-        Use the standard Iris loading API while within the context manager. If
-        the loaded file(s) include any UGRID content, this will be parsed and
-        attached to the resultant cube(s) accordingly.
-
-        Use via the run-time switch
-        :const:`~iris.ugrid.load.PARSE_UGRID_ON_LOAD`.
-
-        For example::
-
-            with PARSE_UGRID_ON_LOAD.context():
-                my_cube_list = iris.load([my_file_path, my_file_path2],
-                                         constraint=my_constraint,
-                                         callback=my_callback)
-
-        Notes
-        -----
-            .. deprecated:: 1.10
-        Do not use -- due to be removed at next major release :
-        UGRID loading is now **always** active for files containing a UGRID mesh.
-
-        Examples
-        --------
-        Replace usage, for example:
-
-        .. code-block:: python
-
-            with iris.experimental.ugrid.PARSE_UGRID_ON_LOAD.context():
-                mesh_cubes = iris.load(path)
-
-        with:
-
-        .. code-block:: python
-
-            mesh_cubes = iris.load(path)
-
-        """
-        wmsg = (
-            "iris.experimental.ugrid.load.PARSE_UGRID_ON_LOAD has been deprecated "
-            "and will be removed. Please remove all uses : these are no longer needed, "
-            "as UGRID loading is now applied to any file containing a mesh."
-        )
-        warn_deprecated(wmsg)
-        yield
-
-
-#: Run-time switch for experimental UGRID-aware NetCDF loading. See :class:`~iris.ugrid.load.ParseUGridOnLoad`.
-PARSE_UGRID_ON_LOAD = ParseUGridOnLoad()
-
-
 def _meshes_from_cf(cf_reader):
     """Mesh from cf, common behaviour for extracting meshes from a CFReader.
 
@@ -148,10 +65,10 @@ def _meshes_from_cf(cf_reader):
 
 
 def load_mesh(uris, var_name=None):
-    """Load a single :class:`~iris.ugrid.mesh.MeshXY` object from one or more NetCDF files.
+    """Load a single :class:`~iris.mesh.MeshXY` object from one or more NetCDF files.
 
     Raises an error if more/less than one
-    :class:`~iris.ugrid.mesh.MeshXY` is found.
+    :class:`~iris.mesh.MeshXY` is found.
 
     Parameters
     ----------
@@ -159,12 +76,12 @@ def load_mesh(uris, var_name=None):
         One or more filenames/URI's. Filenames can include wildcards. Any URI's
         must support OpenDAP.
     var_name : str, optional
-        Only return a :class:`~iris.ugrid.mesh.MeshXY` if its
+        Only return a :class:`~iris.mesh.MeshXY` if its
         var_name matches this value.
 
     Returns
     -------
-    :class:`iris.ugrid.mesh.MeshXY`
+    :class:`iris.mesh.MeshXY`
 
     """
     meshes_result = load_meshes(uris, var_name)
@@ -177,7 +94,7 @@ def load_mesh(uris, var_name=None):
 
 
 def load_meshes(uris, var_name=None):
-    r"""Load :class:`~iris.ugrid.mesh.MeshXY` objects from one or more NetCDF files.
+    r"""Load :class:`~iris.mesh.MeshXY` objects from one or more NetCDF files.
 
     Parameters
     ----------
@@ -185,7 +102,7 @@ def load_meshes(uris, var_name=None):
         One or more filenames/URI's. Filenames can include wildcards. Any URI's
         must support OpenDAP.
     var_name : str, optional
-        Only return :class:`~iris.ugrid.mesh.MeshXY` that have
+        Only return :class:`~iris.mesh.MeshXY` that have
         var_names matching this value.
 
     Returns
@@ -193,16 +110,15 @@ def load_meshes(uris, var_name=None):
     dict
         A dictionary mapping each mesh-containing file path/URL in the input
         ``uris`` to a list of the
-        :class:`~iris.ugrid.mesh.MeshXY` returned from each.
+        :class:`~iris.mesh.MeshXY` returned from each.
 
     """
-    # TODO: rationalise UGRID/mesh handling once iris.ugrid is folded
-    #  into standard behaviour.
-    # TODO: complete iris.ugrid replacement
-    # No constraints or callbacks supported - these assume they are operating
+    # NOTE: no constraints or callbacks supported - these assume they are operating
     #  on a Cube.
-
-    from ..fileformats import FORMAT_AGENT
+    # NOTE: dynamic imports avoid circularity : see note with module imports
+    from iris.fileformats import FORMAT_AGENT
+    from iris.fileformats.cf import CFReader
+    import iris.fileformats.netcdf.loader as nc_loader
 
     if isinstance(uris, str):
         uris = [uris]
@@ -238,7 +154,7 @@ def load_meshes(uris, var_name=None):
 
     result = {}
     for source in valid_sources:
-        with CFUGridReader(source) as cf_reader:
+        with CFReader(source) as cf_reader:
             meshes_dict = _meshes_from_cf(cf_reader)
         meshes = list(meshes_dict.values())
         if var_name is not None:
@@ -249,23 +165,19 @@ def load_meshes(uris, var_name=None):
     return result
 
 
-############
-# Object construction.
-# Helper functions, supporting netcdf.load_cubes ONLY, expected to
-# altered/moved when pyke is removed.
-
-
 def _build_aux_coord(coord_var, file_path):
     """Construct a :class:`~iris.coords.AuxCoord`.
 
     Construct a :class:`~iris.coords.AuxCoord` from a given
-    :class:`~iris.ugrid.cf.CFUGridAuxiliaryCoordinateVariable`,
+    :class:`~iris.fileformats.cf.CFUGridAuxiliaryCoordinateVariable`,
     and guess its mesh axis.
 
-    todo: integrate with standard loading API post-pyke.
-
     """
-    # TODO: integrate with standard saving API when no longer 'experimental'.
+    # NOTE: dynamic imports avoid circularity : see note with module imports
+    from iris.fileformats._nc_load_rules.helpers import get_attr_units, get_names
+    from iris.fileformats.cf import CFUGridAuxiliaryCoordinateVariable
+    from iris.fileformats.netcdf import loader as nc_loader
+
     assert isinstance(coord_var, CFUGridAuxiliaryCoordinateVariable)
     attributes = {}
     attr_units = get_attr_units(coord_var, attributes)
@@ -311,16 +223,18 @@ def _build_aux_coord(coord_var, file_path):
 
 
 def _build_connectivity(connectivity_var, file_path, element_dims):
-    """Construct a :class:`~iris.ugrid.mesh.Connectivity`.
+    """Construct a :class:`~iris.mesh.Connectivity`.
 
-    Construct a :class:`~iris.ugrid.mesh.Connectivity` from a
-    given :class:`~iris.ugrid.cf.CFUGridConnectivityVariable`,
+    Construct a :class:`~iris.mesh.Connectivity` from a
+    given :class:`~iris.fileformats.cf.CFUGridConnectivityVariable`,
     and identify the name of its first dimension.
 
-    todo: integrate with standard loading API post-pyke.
-
     """
-    # TODO: integrate with standard saving API when no longer 'experimental'.
+    # NOTE: dynamic imports avoid circularity : see note with module imports
+    from iris.fileformats._nc_load_rules.helpers import get_attr_units, get_names
+    from iris.fileformats.cf import CFUGridConnectivityVariable
+    from iris.fileformats.netcdf import loader as nc_loader
+
     assert isinstance(connectivity_var, CFUGridConnectivityVariable)
     attributes = {}
     attr_units = get_attr_units(connectivity_var, attributes)
@@ -355,15 +269,17 @@ def _build_connectivity(connectivity_var, file_path, element_dims):
 
 
 def _build_mesh(cf, mesh_var, file_path):
-    """Construct a :class:`~iris.ugrid.mesh.MeshXY`.
-
-    Construct a :class:`~iris.ugrid.mesh.MeshXY` from a given
-    :class:`~iris.ugrid.cf.CFUGridMeshVariable`.
+    """Construct a :class:`~iris.mesh.MeshXY`.
 
-    TODO: integrate with standard loading API post-pyke.
+    Construct a :class:`~iris.mesh.MeshXY` from a given
+    :class:`~iris.fileformats.cf.CFUGridMeshVariable`.
 
     """
-    # TODO: integrate with standard saving API when no longer 'experimental'.
+    # NOTE: dynamic imports avoid circularity : see note with module imports
+    from iris.fileformats._nc_load_rules.helpers import get_attr_units, get_names
+    from iris.fileformats.cf import CFUGridMeshVariable
+    from iris.fileformats.netcdf import loader as nc_loader
+
     assert isinstance(mesh_var, CFUGridMeshVariable)
     attributes = {}
     attr_units = get_attr_units(mesh_var, attributes)
@@ -490,16 +406,13 @@ def _build_mesh(cf, mesh_var, file_path):
 
 
 def _build_mesh_coords(mesh, cf_var):
-    """Construct a tuple of :class:`~iris.ugrid.mesh.MeshCoord`.
+    """Construct a tuple of :class:`~iris.mesh.MeshCoord`.
 
-    Construct a tuple of :class:`~iris.ugrid.mesh.MeshCoord` using
-    from a given :class:`~iris.ugrid.mesh.MeshXY`
+    Construct a tuple of :class:`~iris.mesh.MeshCoord` using
+    from a given :class:`~iris.mesh.MeshXY`
     and :class:`~iris.fileformats.cf.CFVariable`.
 
-    TODO: integrate with standard loading API post-pyke.
-
     """
-    # TODO: integrate with standard saving API when no longer 'experimental'.
     # Identify the cube's mesh dimension, for attaching MeshCoords.
     element_dimensions = {
         "node": mesh.node_dimension,
diff --git a/lib/iris/ugrid/__init__.py b/lib/iris/mesh/__init__.py
similarity index 51%
rename from lib/iris/ugrid/__init__.py
rename to lib/iris/mesh/__init__.py
index 1337724a6f..9a2c10b7ca 100644
--- a/lib/iris/ugrid/__init__.py
+++ b/lib/iris/mesh/__init__.py
@@ -5,32 +5,20 @@
 
 """Infra-structure for unstructured mesh support.
 
-.. deprecated:: 1.10
-
-    :data:`PARSE_UGRID_ON_LOAD` is due to be removed at next major release.
-    Please remove all uses of this, which are no longer needed :
-    UGRID loading is now **always** active for files containing a UGRID mesh.
-
 Based on CF UGRID Conventions (v1.0), https://ugrid-conventions.github.io/ugrid-conventions/.
-
-.. note::
-
-    For the docstring of :const:`PARSE_UGRID_ON_LOAD`: see the original
-    definition at :const:`iris.ugrid.load.PARSE_UGRID_ON_LOAD`.
-
 """
 
-from ..config import get_logger
-from .load import PARSE_UGRID_ON_LOAD, load_mesh, load_meshes
-from .mesh import Connectivity, MeshCoord, MeshXY
-from .save import save_mesh
+from iris.config import get_logger
+from iris.fileformats.netcdf.saver import save_mesh
+from iris.fileformats.netcdf.ugrid_load import load_mesh, load_meshes
+
+from .components import Connectivity, MeshCoord, MeshXY
 from .utils import recombine_submeshes
 
 __all__ = [
     "Connectivity",
     "MeshCoord",
     "MeshXY",
-    "PARSE_UGRID_ON_LOAD",
     "load_mesh",
     "load_meshes",
     "recombine_submeshes",
diff --git a/lib/iris/ugrid/mesh.py b/lib/iris/mesh/components.py
similarity index 95%
rename from lib/iris/ugrid/mesh.py
rename to lib/iris/mesh/components.py
index 476aaed7fa..a5936388f8 100644
--- a/lib/iris/ugrid/mesh.py
+++ b/lib/iris/mesh/components.py
@@ -20,6 +20,8 @@
 from dask import array as da
 import numpy as np
 
+from iris.common.metadata import ConnectivityMetadata, MeshCoordMetadata, MeshMetadata
+
 from .. import _lazy_data as _lazy
 from ..common import CFVariableMixin, metadata_filter, metadata_manager_factory
 from ..common.metadata import BaseMetadata
@@ -28,7 +30,6 @@
 from ..exceptions import ConnectivityNotFoundError, CoordinateNotFoundError
 from ..util import array_equal, clip_string, guess_coord_axis
 from ..warnings import IrisVagueMetadataWarning
-from .metadata import ConnectivityMetadata, MeshCoordMetadata, MeshMetadata
 
 # Configure the logger.
 logger = get_logger(__name__, propagate=True, handler=False)
@@ -71,9 +72,9 @@
 # MeshXY connectivity manager namedtuples.
 #
 
-#: Namedtuple for 1D mesh :class:`~iris.ugrid.mesh.Connectivity` instances.
+#: Namedtuple for 1D mesh :class:`~iris.mesh.Connectivity` instances.
 Mesh1DConnectivities = namedtuple("Mesh1DConnectivities", ["edge_node"])
-#: Namedtuple for 2D mesh :class:`~iris.ugrid.mesh.Connectivity` instances.
+#: Namedtuple for 2D mesh :class:`~iris.mesh.Connectivity` instances.
 Mesh2DConnectivities = namedtuple(
     "Mesh2DConnectivities",
     [
@@ -785,7 +786,7 @@ def from_coords(cls, *coords):
         .. testsetup::
 
             from iris import load_cube, sample_data_path
-            from iris.ugrid import (
+            from iris.mesh import (
                 MeshXY,
                 MeshCoord,
             )
@@ -1134,7 +1135,7 @@ def _set_dimension_names(self, node, edge, face, reset=False):
 
     @property
     def all_connectivities(self):
-        """All the :class:`~iris.ugrid.mesh.Connectivity` instances of the :class:`MeshXY`."""
+        """All the :class:`~iris.mesh.Connectivity` instances of the :class:`MeshXY`."""
         return self._connectivity_manager.all_members
 
     @property
@@ -1144,10 +1145,10 @@ def all_coords(self):
 
     @property
     def boundary_node_connectivity(self):
-        """The *optional* UGRID ``boundary_node_connectivity`` :class:`~iris.ugrid.mesh.Connectivity`.
+        """The *optional* UGRID ``boundary_node_connectivity`` :class:`~iris.mesh.Connectivity`.
 
         The *optional* UGRID ``boundary_node_connectivity``
-        :class:`~iris.ugrid.mesh.Connectivity` of the
+        :class:`~iris.mesh.Connectivity` of the
         :class:`MeshXY`.
 
         """
@@ -1173,10 +1174,10 @@ def edge_dimension(self, name):
 
     @property
     def edge_face_connectivity(self):
-        """The *optional* UGRID ``edge_face_connectivity`` :class:`~iris.ugrid.mesh.Connectivity`.
+        """The *optional* UGRID ``edge_face_connectivity`` :class:`~iris.mesh.Connectivity`.
 
         The *optional* UGRID ``edge_face_connectivity``
-        :class:`~iris.ugrid.mesh.Connectivity` of the
+        :class:`~iris.mesh.Connectivity` of the
         :class:`MeshXY`.
 
         """
@@ -1184,10 +1185,10 @@ def edge_face_connectivity(self):
 
     @property
     def edge_node_connectivity(self):
-        """The UGRID ``edge_node_connectivity`` :class:`~iris.ugrid.mesh.Connectivity`.
+        """The UGRID ``edge_node_connectivity`` :class:`~iris.mesh.Connectivity`.
 
         The UGRID ``edge_node_connectivity``
-        :class:`~iris.ugrid.mesh.Connectivity` of the
+        :class:`~iris.mesh.Connectivity` of the
         :class:`MeshXY`, which is **required** for :attr:`MeshXY.topology_dimension`
         of ``1``, and *optionally required* for
         :attr:`MeshXY.topology_dimension` ``>=2``.
@@ -1224,10 +1225,10 @@ def face_dimension(self, name):
 
     @property
     def face_edge_connectivity(self):
-        """The *optional* UGRID ``face_edge_connectivity``:class:`~iris.ugrid.mesh.Connectivity`.
+        """The *optional* UGRID ``face_edge_connectivity``:class:`~iris.mesh.Connectivity`.
 
         The *optional* UGRID ``face_edge_connectivity``
-        :class:`~iris.ugrid.mesh.Connectivity` of the
+        :class:`~iris.mesh.Connectivity` of the
         :class:`MeshXY`.
 
         """
@@ -1236,10 +1237,10 @@ def face_edge_connectivity(self):
 
     @property
     def face_face_connectivity(self):
-        """The *optional* UGRID ``face_face_connectivity`` :class:`~iris.ugrid.mesh.Connectivity`.
+        """The *optional* UGRID ``face_face_connectivity`` :class:`~iris.mesh.Connectivity`.
 
         The *optional* UGRID ``face_face_connectivity``
-        :class:`~iris.ugrid.mesh.Connectivity` of the
+        :class:`~iris.mesh.Connectivity` of the
         :class:`MeshXY`.
 
         """
@@ -1247,10 +1248,10 @@ def face_face_connectivity(self):
 
     @property
     def face_node_connectivity(self):
-        """Return ``face_node_connectivity``:class:`~iris.ugrid.mesh.Connectivity`.
+        """Return ``face_node_connectivity``:class:`~iris.mesh.Connectivity`.
 
         The UGRID ``face_node_connectivity``
-        :class:`~iris.ugrid.mesh.Connectivity` of the
+        :class:`~iris.mesh.Connectivity` of the
         :class:`MeshXY`, which is **required** for :attr:`MeshXY.topology_dimension`
         of ``2``, and *optionally required* for :attr:`MeshXY.topology_dimension`
         of ``3``.
@@ -1277,13 +1278,13 @@ def node_dimension(self, name):
         self._metadata_manager.node_dimension = node_dimension
 
     def add_connectivities(self, *connectivities):
-        """Add one or more :class:`~iris.ugrid.mesh.Connectivity` instances to the :class:`MeshXY`.
+        """Add one or more :class:`~iris.mesh.Connectivity` instances to the :class:`MeshXY`.
 
         Parameters
         ----------
         *connectivities : iterable of object
             A collection of one or more
-            :class:`~iris.ugrid.mesh.Connectivity` instances to
+            :class:`~iris.mesh.Connectivity` instances to
             add to the :class:`MeshXY`.
 
         """
@@ -1342,10 +1343,10 @@ def connectivities(
         contains_edge=None,
         contains_face=None,
     ):
-        """Return all :class:`~iris.ugrid.mesh.Connectivity`.
+        r"""Return all :class:`~iris.mesh.Connectivity`\s.
 
-        Return all :class:`~iris.ugrid.mesh.Connectivity`
-        instances from the :class:`MeshXY` that match the provided criteria.
+        Return all :class:`~iris.mesh.Connectivity`
+        instances from the :class:`~iris.mesh.MeshXY` which match the provided criteria.
 
         Criteria can be either specific properties or other objects with
         metadata to be matched.
@@ -1366,44 +1367,44 @@ def connectivities(
 
             * a connectivity or metadata instance equal to that of
               the desired objects e.g.,
-              :class:`~iris.ugrid.mesh.Connectivity` or
-              :class:`~iris.ugrid.metadata.ConnectivityMetadata`.
+              :class:`~iris.mesh.Connectivity` or
+              :class:`~iris.common.metadata.ConnectivityMetadata`.
         standard_name : str, optional
             The CF standard name of the desired
-            :class:`~iris.ugrid.mesh.Connectivity`. If ``None``,
+            :class:`~iris.mesh.Connectivity`. If ``None``,
             does not check for ``standard_name``.
         long_name : str, optional
             An unconstrained description of the
-            :class:`~iris.ugrid.mesh.Connectivity`. If ``None``,
+            :class:`~iris.mesh.Connectivity`. If ``None``,
             does not check for ``long_name``.
         var_name : str, optional
             The NetCDF variable name of the desired
-            :class:`~iris.ugrid.mesh.Connectivity`. If ``None``,
+            :class:`~iris.mesh.Connectivity`. If ``None``,
             does not check for ``var_name``.
         attributes : dict, optional
             A dictionary of attributes desired on the
-            :class:`~iris.ugrid.mesh.Connectivity`. If ``None``,
+            :class:`~iris.mesh.Connectivity`. If ``None``,
             does not check for ``attributes``.
         cf_role : str, optional
             The UGRID ``cf_role`` of the desired
-            :class:`~iris.ugrid.mesh.Connectivity`.
+            :class:`~iris.mesh.Connectivity`.
         contains_node : bool, optional
             Contains the ``node`` element as part of the
-            :attr:`~iris.ugrid.metadata.ConnectivityMetadata.cf_role`
+            :attr:`~iris.common.metadata.ConnectivityMetadata.cf_role`
             in the list of objects to be matched.
         contains_edge : bool, optional
             Contains the ``edge`` element as part of the
-            :attr:`~iris.ugrid.metadata.ConnectivityMetadata.cf_role`
+            :attr:`~iris.common.metadata.ConnectivityMetadata.cf_role`
             in the list of objects to be matched.
         contains_face : bool, optional
             Contains the ``face`` element as part of the
-            :attr:`~iris.ugrid.metadata.ConnectivityMetadata.cf_role`
+            :attr:`~iris.common.metadata.ConnectivityMetadata.cf_role`
             in the list of objects to be matched.
 
         Returns
         -------
-        list of :class:`~iris.ugrid.mesh.Connectivity`
-            A list of :class:`~iris.ugrid.mesh.Connectivity`
+        list of :class:`~iris.mesh.Connectivity`
+            A list of :class:`~iris.mesh.Connectivity`
             instances from the :class:`MeshXY` that matched the given criteria.
 
         """
@@ -1432,9 +1433,9 @@ def connectivity(
         contains_edge=None,
         contains_face=None,
     ):
-        """Return a single :class:`~iris.ugrid.mesh.Connectivity`.
+        """Return a single :class:`~iris.mesh.Connectivity`.
 
-        Return a single :class:`~iris.ugrid.mesh.Connectivity`
+        Return a single :class:`~iris.mesh.Connectivity`
         from the :class:`MeshXY` that matches the provided criteria.
 
         Criteria can be either specific properties or other objects with
@@ -1443,7 +1444,7 @@ def connectivity(
         .. note::
 
             If the given criteria do not return **precisely one**
-            :class:`~iris.ugrid.mesh.Connectivity`, then a
+            :class:`~iris.mesh.Connectivity`, then a
             :class:`~iris.exceptions.ConnectivityNotFoundError` is raised.
 
         .. seealso::
@@ -1462,44 +1463,44 @@ def connectivity(
 
             * a connectivity or metadata instance equal to that of
               the desired object e.g.,
-              :class:`~iris.ugrid.mesh.Connectivity` or
-              :class:`~iris.ugrid.metadata.ConnectivityMetadata`.
+              :class:`~iris.mesh.Connectivity` or
+              :class:`~iris.common.metadata.ConnectivityMetadata`.
         standard_name : str, optional
             The CF standard name of the desired
-            :class:`~iris.ugrid.mesh.Connectivity`. If ``None``,
+            :class:`~iris.mesh.Connectivity`. If ``None``,
             does not check for ``standard_name``.
         long_name : str, optional
             An unconstrained description of the
-            :class:`~iris.ugrid.mesh.Connectivity`. If ``None``,
+            :class:`~iris.mesh.Connectivity`. If ``None``,
             does not check for ``long_name``.
         var_name : str, optional
             The NetCDF variable name of the desired
-            :class:`~iris.ugrid.mesh.Connectivity`. If ``None``,
+            :class:`~iris.mesh.Connectivity`. If ``None``,
             does not check for ``var_name``.
         attributes : dict, optional
             A dictionary of attributes desired on the
-            :class:`~iris.ugrid.mesh.Connectivity`. If ``None``,
+            :class:`~iris.mesh.Connectivity`. If ``None``,
             does not check for ``attributes``.
         cf_role : str, optional
             The UGRID ``cf_role`` of the desired
-            :class:`~iris.ugrid.mesh.Connectivity`.
+            :class:`~iris.mesh.Connectivity`.
         contains_node : bool, optional
             Contains the ``node`` element as part of the
-            :attr:`~iris.ugrid.metadata.ConnectivityMetadata.cf_role`
+            :attr:`~iris.common.metadata.ConnectivityMetadata.cf_role`
             in the list of objects to be matched.
         contains_edge : bool, optional
             Contains the ``edge`` element as part of the
-            :attr:`~iris.ugrid.metadata.ConnectivityMetadata.cf_role`
+            :attr:`~iris.common.metadata.ConnectivityMetadata.cf_role`
             in the list of objects to be matched.
         contains_face : bool, optional
             Contains the ``face`` element as part of the
-            :attr:`~iris.ugrid.metadata.ConnectivityMetadata.cf_role`
+            :attr:`~iris.common.metadata.ConnectivityMetadata.cf_role`
             in the list of objects to be matched.
 
         Returns
         -------
-        :class:`~iris.ugrid.mesh.Connectivity`
-            The :class:`~iris.ugrid.mesh.Connectivity` from the
+        :class:`~iris.mesh.Connectivity`
+            The :class:`~iris.mesh.Connectivity` from the
             :class:`MeshXY` that matched the given criteria.
 
         """
@@ -1605,8 +1606,8 @@ def coords(
     ):
         """Return all :class:`~iris.coords.AuxCoord` coordinates from the :class:`MeshXY`.
 
-        Return all :class:`~iris.coords.AuxCoord` coordinates from the :class:`MeshXY` that
-        match the provided criteria.
+        Return all :class:`~iris.coords.AuxCoord` coordinates from the :class:`MeshXY`
+        which match the provided criteria.
 
         Criteria can be either specific properties or other objects with
         metadata to be matched.
@@ -1677,10 +1678,10 @@ def remove_connectivities(
         contains_edge=None,
         contains_face=None,
     ):
-        """Remove one or more :class:`~iris.ugrid.mesh.Connectivity`.
+        """Remove one or more :class:`~iris.mesh.Connectivity`.
 
-        Remove one or more :class:`~iris.ugrid.mesh.Connectivity`
-        from the :class:`MeshXY` that match the provided criteria.
+        Remove one or more :class:`~iris.mesh.Connectivity`
+        from the :class:`MeshXY` which match the provided criteria.
 
         Criteria can be either specific properties or other objects with
         metadata to be matched.
@@ -1697,44 +1698,44 @@ def remove_connectivities(
 
             * a connectivity or metadata instance equal to that of
               the desired objects e.g.,
-              :class:`~iris.ugrid.mesh.Connectivity` or
-              :class:`~iris.ugrid.metadata.ConnectivityMetadata`.
+              :class:`~iris.mesh.Connectivity` or
+              :class:`~iris.common.metadata.ConnectivityMetadata`.
         standard_name : str, optional
             The CF standard name of the desired
-            :class:`~iris.ugrid.mesh.Connectivity`. If ``None``,
+            :class:`~iris.mesh.Connectivity`. If ``None``,
             does not check for ``standard_name``.
         long_name : str, optional
             An unconstrained description of the
-            :class:`~iris.ugrid.mesh.Connectivity`. If ``None``,
+            :class:`~iris.mesh.Connectivity`. If ``None``,
             does not check for ``long_name``.
         var_name : str, optional
             The NetCDF variable name of the desired
-            :class:`~iris.ugrid.mesh.Connectivity`. If ``None``,
+            :class:`~iris.mesh.Connectivity`. If ``None``,
             does not check for ``var_name``.
         attributes : dict, optional
             A dictionary of attributes desired on the
-            :class:`~iris.ugrid.mesh.Connectivity`. If ``None``,
+            :class:`~iris.mesh.Connectivity`. If ``None``,
             does not check for ``attributes``.
         cf_role : str, optional
             The UGRID ``cf_role`` of the desired
-            :class:`~iris.ugrid.mesh.Connectivity`.
+            :class:`~iris.mesh.Connectivity`.
         contains_node : bool, optional
             Contains the ``node`` element as part of the
-            :attr:`~iris.ugrid.metadata.ConnectivityMetadata.cf_role`
+            :attr:`~iris.common.metadata.ConnectivityMetadata.cf_role`
             in the list of objects to be matched for potential removal.
         contains_edge : bool, optional
             Contains the ``edge`` element as part of the
-            :attr:`~iris.ugrid.metadata.ConnectivityMetadata.cf_role`
+            :attr:`~iris.common.metadata.ConnectivityMetadata.cf_role`
             in the list of objects to be matched for potential removal.
         contains_face : bool, optional
             Contains the ``face`` element as part of the
-            :attr:`~iris.ugrid.metadata.ConnectivityMetadata.cf_role`
+            :attr:`~iris.common.metadata.ConnectivityMetadata.cf_role`
             in the list of objects to be matched for potential removal.
 
         Returns
         -------
-        list of :class:`~iris.ugrid.mesh.Connectivity`
-            A list of :class:`~iris.ugrid.mesh.Connectivity`
+        list of :class:`~iris.mesh.Connectivity`
+            A list of :class:`~iris.mesh.Connectivity`
             instances removed from the :class:`MeshXY` that matched the given
             criteria.
 
@@ -1764,7 +1765,7 @@ def remove_coords(
         """Remove one or more :class:`~iris.coords.AuxCoord` from the :class:`MeshXY`.
 
         Remove one or more :class:`~iris.coords.AuxCoord` from the :class:`MeshXY`
-        that match the provided criteria.
+        which match the provided criteria.
 
         Criteria can be either specific properties or other objects with
         metadata to be matched.
@@ -1852,9 +1853,9 @@ def xml_element(self, doc):
     #     # return the lazy AuxCoord(...), AuxCoord(...)
 
     def to_MeshCoord(self, location, axis):
-        """Generate a :class:`~iris.ugrid.mesh.MeshCoord`.
+        """Generate a :class:`~iris.mesh.MeshCoord`.
 
-        Generate a :class:`~iris.ugrid.mesh.MeshCoord` that
+        Generate a :class:`~iris.mesh.MeshCoord` that
         references the current :class:`MeshXY`, and passing through the
         ``location`` and ``axis`` arguments.
 
@@ -1866,25 +1867,25 @@ def to_MeshCoord(self, location, axis):
         ----------
         location : str
             The ``location`` argument for
-            :class:`~iris.ugrid.mesh.MeshCoord` instantiation.
+            :class:`~iris.mesh.MeshCoord` instantiation.
         axis : str
             The ``axis`` argument for
-            :class:`~iris.ugrid.mesh.MeshCoord` instantiation.
+            :class:`~iris.mesh.MeshCoord` instantiation.
 
         Returns
         -------
-        :class:`~iris.ugrid.mesh.MeshCoord`
-            A :class:`~iris.ugrid.mesh.MeshCoord` referencing the
+        :class:`~iris.mesh.mesh.MeshCoord`
+            A :class:`~iris.mesh.mesh.MeshCoord` referencing the
             current :class:`MeshXY`.
 
         """
         return MeshCoord(mesh=self, location=location, axis=axis)
 
     def to_MeshCoords(self, location):
-        r"""Generate a tuple of :class:`~iris.ugrid.mesh.MeshCoord`.
+        r"""Generate a tuple of :class:`~iris.mesh.mesh.MeshCoord`.
 
         Generate a tuple of
-        :class:`~iris.ugrid.mesh.MeshCoord`, each referencing
+        :class:`~iris.mesh.mesh.MeshCoord`, each referencing
         the current :class:`MeshXY`, one for each :attr:`AXES` value, passing
         through the ``location`` argument.
 
@@ -1899,8 +1900,8 @@ def to_MeshCoords(self, location):
 
         Returns
         -------
-        tuple of :class:`~iris.ugrid.mesh.MeshCoord`
-            Tuple of :class:`~iris.ugrid.mesh.MeshCoord`
+        tuple of :class:`~iris.mesh.mesh.MeshCoord`
+            Tuple of :class:`~iris.mesh.mesh.MeshCoord`
             referencing the current :class:`MeshXY`. One for each value in
             :attr:`AXES`, using the value for the ``axis`` argument.
 
@@ -2651,7 +2652,7 @@ def face_node(self):
 class MeshCoord(AuxCoord):
     """Geographic coordinate values of data on an unstructured mesh.
 
-    A MeshCoord references a `~iris.ugrid.mesh.MeshXY`.
+    A MeshCoord references a `~iris.mesh.mesh.MeshXY`.
     When contained in a `~iris.cube.Cube` it connects the cube to the Mesh.
     It records (a) which 1-D cube dimension represents the unstructured mesh,
     and (b) which  mesh 'location' the cube data is mapped to -- i.e. is it
diff --git a/lib/iris/ugrid/utils.py b/lib/iris/mesh/utils.py
similarity index 99%
rename from lib/iris/ugrid/utils.py
rename to lib/iris/mesh/utils.py
index def9c1fccf..1117c3c7d7 100644
--- a/lib/iris/ugrid/utils.py
+++ b/lib/iris/mesh/utils.py
@@ -31,7 +31,7 @@ def recombine_submeshes(
         Describes the mesh and mesh-location onto which the all the
         ``submesh-cubes``' data are mapped, and acts as a template for the
         result.
-        Must have a :class:`~iris.ugrid.mesh.MeshXY`.
+        Must have a :class:`~iris.mesh.MeshXY`.
 
     submesh_cubes : iterable of Cube, or Cube
         Cubes, each with data on a _subset_ of the ``mesh_cube`` datapoints
diff --git a/lib/iris/ugrid/cf.py b/lib/iris/ugrid/cf.py
deleted file mode 100644
index 27856b5d52..0000000000
--- a/lib/iris/ugrid/cf.py
+++ /dev/null
@@ -1,292 +0,0 @@
-# Copyright Iris contributors
-#
-# This file is part of Iris and is released under the BSD license.
-# See LICENSE in the root of the repository for full licensing details.
-
-"""Extensions to Iris' CF variable representation to represent CF UGrid variables.
-
-Eventual destination: :mod:`iris.fileformats.cf`.
-
-"""
-
-import warnings
-
-from ..fileformats import cf
-from ..warnings import IrisCfLabelVarWarning, IrisCfMissingVarWarning
-from .mesh import Connectivity
-
-
-class CFUGridConnectivityVariable(cf.CFVariable):
-    """A CF_UGRID connectivity variable.
-
-    A CF_UGRID connectivity variable points to an index variable identifying
-    for every element (edge/face/volume) the indices of its corner nodes. The
-    connectivity array will thus be a matrix of size n-elements x n-corners.
-    For the indexing one may use either 0- or 1-based indexing; the convention
-    used should be specified using a ``start_index`` attribute to the index
-    variable.
-
-    For face elements: the corner nodes should be specified in anticlockwise
-    direction as viewed from above. For volume elements: use the
-    additional attribute ``volume_shape_type`` which points to a flag variable
-    that specifies for every volume its shape.
-
-    Identified by a CF-netCDF variable attribute equal to any one of the values
-    in :attr:`~iris.ugrid.mesh.Connectivity.UGRID_CF_ROLES`.
-
-    .. seealso::
-
-        The UGRID Conventions, https://ugrid-conventions.github.io/ugrid-conventions/
-
-    """
-
-    cf_identity = NotImplemented
-    cf_identities = Connectivity.UGRID_CF_ROLES
-
-    @classmethod
-    def identify(cls, variables, ignore=None, target=None, warn=True):
-        result = {}
-        ignore, target = cls._identify_common(variables, ignore, target)
-
-        # Identify all CF-UGRID connectivity variables.
-        for nc_var_name, nc_var in target.items():
-            # Check for connectivity variable references, iterating through
-            # the valid cf roles.
-            for identity in cls.cf_identities:
-                nc_var_att = getattr(nc_var, identity, None)
-
-                if nc_var_att is not None:
-                    # UGRID only allows for one of each connectivity cf role.
-                    name = nc_var_att.strip()
-                    if name not in ignore:
-                        if name not in variables:
-                            message = (
-                                f"Missing CF-UGRID connectivity variable "
-                                f"{name}, referenced by netCDF variable "
-                                f"{nc_var_name}"
-                            )
-                            if warn:
-                                warnings.warn(message, category=IrisCfMissingVarWarning)
-                        else:
-                            # Restrict to non-string type i.e. not a
-                            # CFLabelVariable.
-                            if not cf._is_str_dtype(variables[name]):
-                                result[name] = CFUGridConnectivityVariable(
-                                    name, variables[name]
-                                )
-                            else:
-                                message = (
-                                    f"Ignoring variable {name}, identified "
-                                    f"as a CF-UGRID connectivity - is a "
-                                    f"CF-netCDF label variable."
-                                )
-                                if warn:
-                                    warnings.warn(
-                                        message, category=IrisCfLabelVarWarning
-                                    )
-
-        return result
-
-
-class CFUGridAuxiliaryCoordinateVariable(cf.CFVariable):
-    """A CF-UGRID auxiliary coordinate variable.
-
-    A CF-UGRID auxiliary coordinate variable is a CF-netCDF auxiliary
-    coordinate variable representing the element (node/edge/face/volume)
-    locations (latitude, longitude or other spatial coordinates, and optional
-    elevation or other coordinates). These auxiliary coordinate variables will
-    have length n-elements.
-
-    For elements other than nodes, these auxiliary coordinate variables may
-    have in turn a ``bounds`` attribute that specifies the bounding coordinates
-    of the element (thereby duplicating the data in the ``node_coordinates``
-    variables).
-
-    Identified by the CF-netCDF variable attribute
-    ``node_``/``edge_``/``face_``/``volume_coordinates``.
-
-    .. seealso::
-
-        The UGRID Conventions, https://ugrid-conventions.github.io/ugrid-conventions/
-
-    """
-
-    cf_identity = NotImplemented
-    cf_identities = [
-        "node_coordinates",
-        "edge_coordinates",
-        "face_coordinates",
-        "volume_coordinates",
-    ]
-
-    @classmethod
-    def identify(cls, variables, ignore=None, target=None, warn=True):
-        result = {}
-        ignore, target = cls._identify_common(variables, ignore, target)
-
-        # Identify any CF-UGRID-relevant auxiliary coordinate variables.
-        for nc_var_name, nc_var in target.items():
-            # Check for UGRID auxiliary coordinate variable references.
-            for identity in cls.cf_identities:
-                nc_var_att = getattr(nc_var, identity, None)
-
-                if nc_var_att is not None:
-                    for name in nc_var_att.split():
-                        if name not in ignore:
-                            if name not in variables:
-                                message = (
-                                    f"Missing CF-netCDF auxiliary coordinate "
-                                    f"variable {name}, referenced by netCDF "
-                                    f"variable {nc_var_name}"
-                                )
-                                if warn:
-                                    warnings.warn(
-                                        message,
-                                        category=IrisCfMissingVarWarning,
-                                    )
-                            else:
-                                # Restrict to non-string type i.e. not a
-                                # CFLabelVariable.
-                                if not cf._is_str_dtype(variables[name]):
-                                    result[name] = CFUGridAuxiliaryCoordinateVariable(
-                                        name, variables[name]
-                                    )
-                                else:
-                                    message = (
-                                        f"Ignoring variable {name}, "
-                                        f"identified as a CF-netCDF "
-                                        f"auxiliary coordinate - is a "
-                                        f"CF-netCDF label variable."
-                                    )
-                                    if warn:
-                                        warnings.warn(
-                                            message,
-                                            category=IrisCfLabelVarWarning,
-                                        )
-
-        return result
-
-
-class CFUGridMeshVariable(cf.CFVariable):
-    """A CF-UGRID mesh variable is a dummy variable for storing topology information as attributes.
-
-    A CF-UGRID mesh variable is a dummy variable for storing topology
-    information as attributes. The mesh variable has the ``cf_role``
-    'mesh_topology'.
-
-    The UGRID conventions describe define the mesh topology as the
-    interconnection of various geometrical elements of the mesh. The pure
-    interconnectivity is independent of georeferencing the individual
-    geometrical elements, but for the practical applications for which the
-    UGRID CF extension is defined, coordinate data will always be added.
-
-    Identified by the CF-netCDF variable attribute 'mesh'.
-
-    .. seealso::
-
-        The UGRID Conventions, https://ugrid-conventions.github.io/ugrid-conventions/
-
-    """
-
-    cf_identity = "mesh"
-
-    @classmethod
-    def identify(cls, variables, ignore=None, target=None, warn=True):
-        result = {}
-        ignore, target = cls._identify_common(variables, ignore, target)
-
-        # Identify all CF-UGRID mesh variables.
-        all_vars = target == variables
-        for nc_var_name, nc_var in target.items():
-            if all_vars:
-                # SPECIAL BEHAVIOUR FOR MESH VARIABLES.
-                # We are looking for all mesh variables. Check if THIS variable
-                #  is a mesh using its own attributes.
-                if getattr(nc_var, "cf_role", "") == "mesh_topology":
-                    result[nc_var_name] = CFUGridMeshVariable(nc_var_name, nc_var)
-
-            # Check for mesh variable references.
-            nc_var_att = getattr(nc_var, cls.cf_identity, None)
-
-            if nc_var_att is not None:
-                # UGRID only allows for 1 mesh per variable.
-                name = nc_var_att.strip()
-                if name not in ignore:
-                    if name not in variables:
-                        message = (
-                            f"Missing CF-UGRID mesh variable {name}, "
-                            f"referenced by netCDF variable {nc_var_name}"
-                        )
-                        if warn:
-                            warnings.warn(message, category=IrisCfMissingVarWarning)
-                    else:
-                        # Restrict to non-string type i.e. not a
-                        # CFLabelVariable.
-                        if not cf._is_str_dtype(variables[name]):
-                            result[name] = CFUGridMeshVariable(name, variables[name])
-                        else:
-                            message = (
-                                f"Ignoring variable {name}, identified as a "
-                                f"CF-UGRID mesh - is a CF-netCDF label "
-                                f"variable."
-                            )
-                            if warn:
-                                warnings.warn(message, category=IrisCfLabelVarWarning)
-
-        return result
-
-
-class CFUGridGroup(cf.CFGroup):
-    """Represents a collection of CF Metadata Conventions variables and netCDF global attributes.
-
-    Represents a collection of 'NetCDF Climate and Forecast (CF) Metadata
-    Conventions' variables and netCDF global attributes.
-
-    Specialisation of :class:`~iris.fileformats.cf.CFGroup` that includes extra
-    collections for CF-UGRID-specific variable types.
-
-    """
-
-    @property
-    def connectivities(self):
-        """Collection of CF-UGRID connectivity variables."""
-        return self._cf_getter(CFUGridConnectivityVariable)
-
-    @property
-    def ugrid_coords(self):
-        """Collection of CF-UGRID-relevant auxiliary coordinate variables."""
-        return self._cf_getter(CFUGridAuxiliaryCoordinateVariable)
-
-    @property
-    def meshes(self):
-        """Collection of CF-UGRID mesh variables."""
-        return self._cf_getter(CFUGridMeshVariable)
-
-    @property
-    def non_data_variable_names(self):
-        """:class:`set` of names of the CF-netCDF/CF-UGRID variables that are not the data pay-load."""
-        extra_variables = (self.connectivities, self.ugrid_coords, self.meshes)
-        extra_result = set()
-        for variable in extra_variables:
-            extra_result |= set(variable)
-        return super().non_data_variable_names | extra_result
-
-
-class CFUGridReader(cf.CFReader):
-    """Allows the contents of a netCDF file to be.
-
-    This class allows the contents of a netCDF file to be interpreted according
-    to the 'NetCDF Climate and Forecast (CF) Metadata Conventions'.
-
-    Specialisation of :class:`~iris.fileformats.cf.CFReader` that can also
-    handle CF-UGRID-specific variable types.
-
-    """
-
-    _variable_types = cf.CFReader._variable_types + (  # type: ignore[assignment]
-        CFUGridConnectivityVariable,
-        CFUGridAuxiliaryCoordinateVariable,
-        CFUGridMeshVariable,
-    )
-
-    CFGroup = CFUGridGroup
diff --git a/lib/iris/ugrid/metadata.py b/lib/iris/ugrid/metadata.py
deleted file mode 100644
index 0165852d5f..0000000000
--- a/lib/iris/ugrid/metadata.py
+++ /dev/null
@@ -1,398 +0,0 @@
-# Copyright Iris contributors
-#
-# This file is part of Iris and is released under the BSD license.
-# See LICENSE in the root of the repository for full licensing details.
-
-"""The common metadata API classes for :mod:`iris.ugrid.mesh`.
-
-Eventual destination: :mod:`iris.common.metadata`.
-
-"""
-
-from functools import wraps
-
-from ..common import BaseMetadata
-from ..common.lenient import _lenient_service as lenient_service
-from ..common.metadata import (
-    SERVICES,
-    SERVICES_COMBINE,
-    SERVICES_DIFFERENCE,
-    SERVICES_EQUAL,
-)
-
-
-class ConnectivityMetadata(BaseMetadata):
-    """Metadata container for a :class:`~iris.ugrid.mesh.Connectivity`."""
-
-    # The "location_axis" member is stateful only, and does not participate in
-    # lenient/strict equivalence.
-    _members = ("cf_role", "start_index", "location_axis")
-
-    __slots__ = ()
-
-    @wraps(BaseMetadata.__eq__, assigned=("__doc__",), updated=())
-    @lenient_service
-    def __eq__(self, other):
-        return super().__eq__(other)
-
-    def _combine_lenient(self, other):
-        """Perform lenient combination of metadata members for connectivities.
-
-        Parameters
-        ----------
-        other : ConnectivityMetadata
-            The other connectivity metadata participating in the lenient
-            combination.
-
-        Returns
-        -------
-        A list of combined metadata member values.
-
-        """
-
-        # Perform "strict" combination for "cf_role", "start_index", "location_axis".
-        def func(field):
-            left = getattr(self, field)
-            right = getattr(other, field)
-            return left if left == right else None
-
-        # Note that, we use "_members" not "_fields".
-        values = [func(field) for field in ConnectivityMetadata._members]
-        # Perform lenient combination of the other parent members.
-        result = super()._combine_lenient(other)
-        result.extend(values)
-
-        return result
-
-    def _compare_lenient(self, other):
-        """Perform lenient equality of metadata members for connectivities.
-
-        Parameters
-        ----------
-        other : ConnectivityMetadata
-            The other connectivity metadata participating in the lenient
-            comparison.
-
-        Returns
-        -------
-        bool
-
-        """
-        # Perform "strict" comparison for "cf_role", "start_index".
-        # The "location_axis" member is not part of lenient equivalence.
-        members = filter(
-            lambda member: member != "location_axis",
-            ConnectivityMetadata._members,
-        )
-        result = all(
-            [getattr(self, field) == getattr(other, field) for field in members]
-        )
-        if result:
-            # Perform lenient comparison of the other parent members.
-            result = super()._compare_lenient(other)
-
-        return result
-
-    def _difference_lenient(self, other):
-        """Perform lenient difference of metadata members for connectivities.
-
-        Parameters
-        ----------
-        other : ConnectivityMetadata
-            The other connectivity metadata participating in the lenient
-            difference.
-
-        Returns
-        -------
-        A list of difference metadata member values.
-
-        """
-
-        # Perform "strict" difference for "cf_role", "start_index", "location_axis".
-        def func(field):
-            left = getattr(self, field)
-            right = getattr(other, field)
-            return None if left == right else (left, right)
-
-        # Note that, we use "_members" not "_fields".
-        values = [func(field) for field in ConnectivityMetadata._members]
-        # Perform lenient difference of the other parent members.
-        result = super()._difference_lenient(other)
-        result.extend(values)
-
-        return result
-
-    @wraps(BaseMetadata.combine, assigned=("__doc__",), updated=())
-    @lenient_service
-    def combine(self, other, lenient=None):
-        return super().combine(other, lenient=lenient)
-
-    @wraps(BaseMetadata.difference, assigned=("__doc__",), updated=())
-    @lenient_service
-    def difference(self, other, lenient=None):
-        return super().difference(other, lenient=lenient)
-
-    @wraps(BaseMetadata.equal, assigned=("__doc__",), updated=())
-    @lenient_service
-    def equal(self, other, lenient=None):
-        return super().equal(other, lenient=lenient)
-
-
-class MeshMetadata(BaseMetadata):
-    """Metadata container for a :class:`~iris.ugrid.mesh.MeshXY`."""
-
-    # The node_dimension", "edge_dimension" and "face_dimension" members are
-    # stateful only; they not participate in lenient/strict equivalence.
-    _members = (
-        "topology_dimension",
-        "node_dimension",
-        "edge_dimension",
-        "face_dimension",
-    )
-
-    __slots__ = ()
-
-    @wraps(BaseMetadata.__eq__, assigned=("__doc__",), updated=())
-    @lenient_service
-    def __eq__(self, other):
-        return super().__eq__(other)
-
-    def _combine_lenient(self, other):
-        """Perform lenient combination of metadata members for meshes.
-
-        Parameters
-        ----------
-        other : MeshMetadata
-            The other mesh metadata participating in the lenient
-            combination.
-
-        Returns
-        -------
-        A list of combined metadata member values.
-
-        """
-
-        # Perform "strict" combination for "topology_dimension",
-        # "node_dimension", "edge_dimension" and "face_dimension".
-        def func(field):
-            left = getattr(self, field)
-            right = getattr(other, field)
-            return left if left == right else None
-
-        # Note that, we use "_members" not "_fields".
-        values = [func(field) for field in MeshMetadata._members]
-        # Perform lenient combination of the other parent members.
-        result = super()._combine_lenient(other)
-        result.extend(values)
-
-        return result
-
-    def _compare_lenient(self, other):
-        """Perform lenient equality of metadata members for meshes.
-
-        Parameters
-        ----------
-        other : MeshMetadata
-            The other mesh metadata participating in the lenient
-            comparison.
-
-        Returns
-        -------
-        bool
-
-        """
-        # Perform "strict" comparison for "topology_dimension".
-        # "node_dimension", "edge_dimension" and "face_dimension" are not part
-        # of lenient equivalence at all.
-        result = self.topology_dimension == other.topology_dimension
-        if result:
-            # Perform lenient comparison of the other parent members.
-            result = super()._compare_lenient(other)
-
-        return result
-
-    def _difference_lenient(self, other):
-        """Perform lenient difference of metadata members for meshes.
-
-        Parameters
-        ----------
-        other : MeshMetadata
-            The other mesh metadata participating in the lenient
-            difference.
-
-        Returns
-        -------
-        A list of difference metadata member values.
-
-        """
-
-        # Perform "strict" difference for "topology_dimension",
-        # "node_dimension", "edge_dimension" and "face_dimension".
-        def func(field):
-            left = getattr(self, field)
-            right = getattr(other, field)
-            return None if left == right else (left, right)
-
-        # Note that, we use "_members" not "_fields".
-        values = [func(field) for field in MeshMetadata._members]
-        # Perform lenient difference of the other parent members.
-        result = super()._difference_lenient(other)
-        result.extend(values)
-
-        return result
-
-    @wraps(BaseMetadata.combine, assigned=("__doc__",), updated=())
-    @lenient_service
-    def combine(self, other, lenient=None):
-        return super().combine(other, lenient=lenient)
-
-    @wraps(BaseMetadata.difference, assigned=("__doc__",), updated=())
-    @lenient_service
-    def difference(self, other, lenient=None):
-        return super().difference(other, lenient=lenient)
-
-    @wraps(BaseMetadata.equal, assigned=("__doc__",), updated=())
-    @lenient_service
-    def equal(self, other, lenient=None):
-        return super().equal(other, lenient=lenient)
-
-
-class MeshCoordMetadata(BaseMetadata):
-    """Metadata container for a :class:`~iris.coords.MeshCoord`."""
-
-    _members = ("location", "axis")
-    # NOTE: in future, we may add 'mesh' as part of this metadata,
-    # as the MeshXY seems part of the 'identity' of a MeshCoord.
-    # For now we omit it, particularly as we don't yet implement MeshXY.__eq__.
-    #
-    # Thus, for now, the MeshCoord class will need to handle 'mesh' explicitly
-    # in identity / comparison, but in future that may be simplified.
-
-    __slots__ = ()
-
-    @wraps(BaseMetadata.__eq__, assigned=("__doc__",), updated=())
-    @lenient_service
-    def __eq__(self, other):
-        return super().__eq__(other)
-
-    def _combine_lenient(self, other):
-        """Perform lenient combination of metadata members for MeshCoord.
-
-        Parameters
-        ----------
-        other : MeshCoordMetadata
-            The other metadata participating in the lenient combination.
-
-        Returns
-        -------
-        A list of combined metadata member values.
-
-        """
-
-        # It is actually "strict" : return None except where members are equal.
-        def func(field):
-            left = getattr(self, field)
-            right = getattr(other, field)
-            return left if left == right else None
-
-        # Note that, we use "_members" not "_fields".
-        values = [func(field) for field in self._members]
-        # Perform lenient combination of the other parent members.
-        result = super()._combine_lenient(other)
-        result.extend(values)
-
-        return result
-
-    def _compare_lenient(self, other):
-        """Perform lenient equality of metadata members for MeshCoord.
-
-        Parameters
-        ----------
-        other : MeshCoordMetadata
-            The other metadata participating in the lenient comparison.
-
-        Returns
-        -------
-        bool
-
-        """
-        # Perform "strict" comparison for the MeshCoord specific members
-        # 'location', 'axis' : for equality, they must all match.
-        result = all(
-            [getattr(self, field) == getattr(other, field) for field in self._members]
-        )
-        if result:
-            # Perform lenient comparison of the other parent members.
-            result = super()._compare_lenient(other)
-
-        return result
-
-    def _difference_lenient(self, other):
-        """Perform lenient difference of metadata members for MeshCoord.
-
-        Parameters
-        ----------
-        other : MeshCoordMetadata
-            The other MeshCoord metadata participating in the lenient
-            difference.
-
-        Returns
-        -------
-        A list of different metadata member values.
-
-        """
-
-        # Perform "strict" difference for location / axis.
-        def func(field):
-            left = getattr(self, field)
-            right = getattr(other, field)
-            return None if left == right else (left, right)
-
-        # Note that, we use "_members" not "_fields".
-        values = [func(field) for field in self._members]
-        # Perform lenient difference of the other parent members.
-        result = super()._difference_lenient(other)
-        result.extend(values)
-
-        return result
-
-    @wraps(BaseMetadata.combine, assigned=("__doc__",), updated=())
-    @lenient_service
-    def combine(self, other, lenient=None):
-        return super().combine(other, lenient=lenient)
-
-    @wraps(BaseMetadata.difference, assigned=("__doc__",), updated=())
-    @lenient_service
-    def difference(self, other, lenient=None):
-        return super().difference(other, lenient=lenient)
-
-    @wraps(BaseMetadata.equal, assigned=("__doc__",), updated=())
-    @lenient_service
-    def equal(self, other, lenient=None):
-        return super().equal(other, lenient=lenient)
-
-
-# Add our new optional metadata operations into the 'convenience collections'
-# of lenient metadata services.
-# TODO: when included in 'iris.common.metadata', install each one directly ?
-_op_names_and_service_collections = [
-    ("combine", SERVICES_COMBINE),
-    ("difference", SERVICES_DIFFERENCE),
-    ("__eq__", SERVICES_EQUAL),
-    ("equal", SERVICES_EQUAL),
-]
-_metadata_classes = [ConnectivityMetadata, MeshMetadata, MeshCoordMetadata]
-for _cls in _metadata_classes:
-    for _name, _service_collection in _op_names_and_service_collections:
-        _method = getattr(_cls, _name)
-        _service_collection.append(_method)
-        SERVICES.append(_method)
-
-del (
-    _op_names_and_service_collections,
-    _metadata_classes,
-    _cls,
-    _name,
-    _service_collection,
-    _method,
-)
diff --git a/lib/iris/ugrid/save.py b/lib/iris/ugrid/save.py
deleted file mode 100644
index 79933cbd08..0000000000
--- a/lib/iris/ugrid/save.py
+++ /dev/null
@@ -1,53 +0,0 @@
-# Copyright Iris contributors
-#
-# This file is part of Iris and is released under the BSD license.
-# See LICENSE in the root of the repository for full licensing details.
-
-"""Extension to Iris' NetCDF saving to allow :class:`~iris.ugrid.mesh.MeshXY` saving in UGRID format.
-
-Eventual destination: :mod:`iris.fileformats.netcdf`.
-
-"""
-
-from collections.abc import Iterable
-
-from ..fileformats import netcdf
-
-
-def save_mesh(mesh, filename, netcdf_format="NETCDF4"):
-    """Save mesh(es) to a netCDF file.
-
-    Parameters
-    ----------
-    mesh : :class:`iris.ugrid.MeshXY` or iterable
-        Mesh(es) to save.
-    filename : str
-        Name of the netCDF file to create.
-    netcdf_format : str, default="NETCDF4"
-        Underlying netCDF file format, one of 'NETCDF4', 'NETCDF4_CLASSIC',
-        'NETCDF3_CLASSIC' or 'NETCDF3_64BIT'. Default is 'NETCDF4' format.
-
-    """
-    # TODO: integrate with standard saving API when no longer 'experimental'.
-
-    if isinstance(mesh, Iterable):
-        meshes = mesh
-    else:
-        meshes = [mesh]
-
-    # Initialise Manager for saving
-    with netcdf.Saver(filename, netcdf_format) as sman:
-        # Iterate through the list.
-        for mesh in meshes:
-            # Get suitable dimension names.
-            mesh_dimensions, _ = sman._get_dim_names(mesh)
-
-            # Create dimensions.
-            sman._create_cf_dimensions(cube=None, dimension_names=mesh_dimensions)
-
-            # Create the mesh components.
-            sman._add_mesh(mesh)
-
-        # Add a conventions attribute.
-        # TODO: add 'UGRID' to conventions, when this is agreed with CF ?
-        sman.update_global_attributes(Conventions=netcdf.CF_CONVENTIONS_VERSION)


Test patch:
diff --git a/docs/src/whatsnew/latest.rst b/docs/src/whatsnew/latest.rst
index 2976ae56ca..e04b832e23 100644
--- a/docs/src/whatsnew/latest.rst
+++ b/docs/src/whatsnew/latest.rst
@@ -88,6 +88,13 @@ This document explains the changes made to Iris for this release
    :meth:`~iris.experimental.ugrid.Mesh.coord`, :meth:`~iris.experimental.ugrid.Mesh.coords`
    and :meth:`~iris.experimental.ugrid.Mesh.remove_coords`. (:pull:`6055`)
 
+#. `@pp-mo`_ moved all the mesh API from the :mod:`iris.experimental.ugrid` module to
+   to :mod:`iris.mesh`, making this public supported API.  Note that the
+   :class:`iris.experimental.ugrid.Mesh` class is renamed as :class:`iris.mesh.MeshXY`,
+   to allow for possible future mesh types with different properties to exist as
+   subclasses of a common generic :class:`~iris.mesh.components.Mesh` class.
+   (:issue:`6057`, :pull:`6061`, :pull:`6077`)
+
 
 🚀 Performance Enhancements
 ===========================
diff --git a/lib/iris/tests/integration/experimental/test_meshcoord_coordsys.py b/lib/iris/tests/integration/mesh/test_meshcoord_coordsys.py
similarity index 93%
rename from lib/iris/tests/integration/experimental/test_meshcoord_coordsys.py
rename to lib/iris/tests/integration/mesh/test_meshcoord_coordsys.py
index 7a1ac80823..9e14b12c9a 100644
--- a/lib/iris/tests/integration/experimental/test_meshcoord_coordsys.py
+++ b/lib/iris/tests/integration/mesh/test_meshcoord_coordsys.py
@@ -9,7 +9,6 @@
 import iris
 from iris.coord_systems import GeogCS
 from iris.tests.stock.netcdf import ncgen_from_cdl
-from iris.ugrid.load import PARSE_UGRID_ON_LOAD
 
 TEST_CDL = """
 netcdf mesh_test {
@@ -80,8 +79,7 @@ def test_default_mesh_cs(tmp_path, cs_axes):
     do_x = "x" in cs_axes
     do_y = "y" in cs_axes
     make_file(nc_path, node_x_crs=do_x, node_y_crs=do_y)
-    with PARSE_UGRID_ON_LOAD.context():
-        cube = iris.load_cube(nc_path, "node_data")
+    cube = iris.load_cube(nc_path, "node_data")
     meshco_x, meshco_y = [cube.coord(mesh_coords=True, axis=ax) for ax in ("x", "y")]
     # NOTE: at present, none of these load with a coordinate system,
     #  because we don't support the extended grid-mapping syntax.
@@ -95,8 +93,7 @@ def test_assigned_mesh_cs(tmp_path):
     # the corresponding meshcoord reports the same cs.
     nc_path = tmp_path / "test_temp.nc"
     make_file(nc_path)
-    with PARSE_UGRID_ON_LOAD.context():
-        cube = iris.load_cube(nc_path, "node_data")
+    cube = iris.load_cube(nc_path, "node_data")
     nodeco_x = cube.mesh.coord(location="node", axis="x")
     meshco_x, meshco_y = [cube.coord(axis=ax) for ax in ("x", "y")]
     assert nodeco_x.coord_system is None
@@ -116,8 +113,7 @@ def test_meshcoord_coordsys_copy(tmp_path):
     # Check that copying a meshcoord with a coord system works properly.
     nc_path = tmp_path / "test_temp.nc"
     make_file(nc_path)
-    with PARSE_UGRID_ON_LOAD.context():
-        cube = iris.load_cube(nc_path, "node_data")
+    cube = iris.load_cube(nc_path, "node_data")
     node_coord = cube.mesh.coord(location="node", axis="x")
     assigned_cs = GeogCS(1.0)
     node_coord.coord_system = assigned_cs
diff --git a/lib/iris/tests/integration/ugrid/test_ugrid_save.py b/lib/iris/tests/integration/mesh/test_ugrid_save.py
similarity index 100%
rename from lib/iris/tests/integration/ugrid/test_ugrid_save.py
rename to lib/iris/tests/integration/mesh/test_ugrid_save.py
diff --git a/lib/iris/tests/integration/ugrid/ugrid_conventions_examples/README.txt b/lib/iris/tests/integration/mesh/ugrid_conventions_examples/README.txt
similarity index 100%
rename from lib/iris/tests/integration/ugrid/ugrid_conventions_examples/README.txt
rename to lib/iris/tests/integration/mesh/ugrid_conventions_examples/README.txt
diff --git a/lib/iris/tests/integration/ugrid/ugrid_conventions_examples/ugrid_ex1_1d_mesh.cdl b/lib/iris/tests/integration/mesh/ugrid_conventions_examples/ugrid_ex1_1d_mesh.cdl
similarity index 100%
rename from lib/iris/tests/integration/ugrid/ugrid_conventions_examples/ugrid_ex1_1d_mesh.cdl
rename to lib/iris/tests/integration/mesh/ugrid_conventions_examples/ugrid_ex1_1d_mesh.cdl
diff --git a/lib/iris/tests/integration/ugrid/ugrid_conventions_examples/ugrid_ex2_2d_triangular.cdl b/lib/iris/tests/integration/mesh/ugrid_conventions_examples/ugrid_ex2_2d_triangular.cdl
similarity index 100%
rename from lib/iris/tests/integration/ugrid/ugrid_conventions_examples/ugrid_ex2_2d_triangular.cdl
rename to lib/iris/tests/integration/mesh/ugrid_conventions_examples/ugrid_ex2_2d_triangular.cdl
diff --git a/lib/iris/tests/integration/ugrid/ugrid_conventions_examples/ugrid_ex3_2d_flexible.cdl b/lib/iris/tests/integration/mesh/ugrid_conventions_examples/ugrid_ex3_2d_flexible.cdl
similarity index 100%
rename from lib/iris/tests/integration/ugrid/ugrid_conventions_examples/ugrid_ex3_2d_flexible.cdl
rename to lib/iris/tests/integration/mesh/ugrid_conventions_examples/ugrid_ex3_2d_flexible.cdl
diff --git a/lib/iris/tests/integration/ugrid/ugrid_conventions_examples/ugrid_ex4_3d_layered.cdl b/lib/iris/tests/integration/mesh/ugrid_conventions_examples/ugrid_ex4_3d_layered.cdl
similarity index 100%
rename from lib/iris/tests/integration/ugrid/ugrid_conventions_examples/ugrid_ex4_3d_layered.cdl
rename to lib/iris/tests/integration/mesh/ugrid_conventions_examples/ugrid_ex4_3d_layered.cdl
diff --git a/lib/iris/tests/integration/ugrid/test_ugrid_load.py b/lib/iris/tests/integration/netcdf/test_ugrid_load.py
similarity index 95%
rename from lib/iris/tests/integration/ugrid/test_ugrid_load.py
rename to lib/iris/tests/integration/netcdf/test_ugrid_load.py
index 2281e54b3c..82098a5d5a 100644
--- a/lib/iris/tests/integration/ugrid/test_ugrid_load.py
+++ b/lib/iris/tests/integration/netcdf/test_ugrid_load.py
@@ -2,13 +2,7 @@
 #
 # This file is part of Iris and is released under the BSD license.
 # See LICENSE in the root of the repository for full licensing details.
-"""Integration tests for NetCDF-UGRID file loading.
-
-todo: fold these tests into netcdf tests when iris.ugrid is folded into
- standard behaviour.
-TODO: complete iris.ugrid replacement
-
-"""
+"""Integration tests for NetCDF-UGRID file loading."""
 
 # Import iris.tests first so that some things can be initialised before
 # importing anything else.
@@ -19,12 +13,12 @@
 import pytest
 
 from iris import Constraint, load
+from iris.fileformats.netcdf.ugrid_load import load_mesh, load_meshes
+from iris.mesh import MeshXY
 from iris.tests.stock.netcdf import (
     _file_from_cdl_template as create_file_from_cdl_template,
 )
 from iris.tests.unit.tests.stock.test_netcdf import XIOSFileMixin
-from iris.ugrid.load import load_mesh, load_meshes
-from iris.ugrid.mesh import MeshXY
 from iris.warnings import IrisCfWarning
 
 
@@ -59,7 +53,7 @@ def common_test(self, load_filename, assert_filename):
         )
         self.assertEqual(1, len(cube_list))
         cube = cube_list[0]
-        self.assertCML(cube, ["ugrid", assert_filename])
+        self.assertCML(cube, ["mesh", assert_filename])
 
     def test_2D_1t_face_half_levels(self):
         self.common_test(
@@ -126,7 +120,7 @@ def test_multiple_phenomena(self):
                 ["NetCDF", "unstructured_grid", "lfric_surface_mean.nc"]
             ),
         )
-        self.assertCML(cube_list, ("ugrid", "surface_mean.cml"))
+        self.assertCML(cube_list, ("mesh", "surface_mean.cml"))
 
 
 class TestTolerantLoading(XIOSFileMixin):
diff --git a/lib/iris/tests/results/ugrid/2D_1t_face_half_levels.cml b/lib/iris/tests/results/mesh/2D_1t_face_half_levels.cml
similarity index 100%
rename from lib/iris/tests/results/ugrid/2D_1t_face_half_levels.cml
rename to lib/iris/tests/results/mesh/2D_1t_face_half_levels.cml
diff --git a/lib/iris/tests/results/ugrid/2D_72t_face_half_levels.cml b/lib/iris/tests/results/mesh/2D_72t_face_half_levels.cml
similarity index 100%
rename from lib/iris/tests/results/ugrid/2D_72t_face_half_levels.cml
rename to lib/iris/tests/results/mesh/2D_72t_face_half_levels.cml
diff --git a/lib/iris/tests/results/ugrid/3D_1t_face_full_levels.cml b/lib/iris/tests/results/mesh/3D_1t_face_full_levels.cml
similarity index 100%
rename from lib/iris/tests/results/ugrid/3D_1t_face_full_levels.cml
rename to lib/iris/tests/results/mesh/3D_1t_face_full_levels.cml
diff --git a/lib/iris/tests/results/ugrid/3D_1t_face_half_levels.cml b/lib/iris/tests/results/mesh/3D_1t_face_half_levels.cml
similarity index 100%
rename from lib/iris/tests/results/ugrid/3D_1t_face_half_levels.cml
rename to lib/iris/tests/results/mesh/3D_1t_face_half_levels.cml
diff --git a/lib/iris/tests/results/ugrid/3D_snow_pseudo_levels.cml b/lib/iris/tests/results/mesh/3D_snow_pseudo_levels.cml
similarity index 100%
rename from lib/iris/tests/results/ugrid/3D_snow_pseudo_levels.cml
rename to lib/iris/tests/results/mesh/3D_snow_pseudo_levels.cml
diff --git a/lib/iris/tests/results/ugrid/3D_soil_pseudo_levels.cml b/lib/iris/tests/results/mesh/3D_soil_pseudo_levels.cml
similarity index 100%
rename from lib/iris/tests/results/ugrid/3D_soil_pseudo_levels.cml
rename to lib/iris/tests/results/mesh/3D_soil_pseudo_levels.cml
diff --git a/lib/iris/tests/results/ugrid/3D_tile_pseudo_levels.cml b/lib/iris/tests/results/mesh/3D_tile_pseudo_levels.cml
similarity index 100%
rename from lib/iris/tests/results/ugrid/3D_tile_pseudo_levels.cml
rename to lib/iris/tests/results/mesh/3D_tile_pseudo_levels.cml
diff --git a/lib/iris/tests/results/ugrid/3D_veg_pseudo_levels.cml b/lib/iris/tests/results/mesh/3D_veg_pseudo_levels.cml
similarity index 100%
rename from lib/iris/tests/results/ugrid/3D_veg_pseudo_levels.cml
rename to lib/iris/tests/results/mesh/3D_veg_pseudo_levels.cml
diff --git a/lib/iris/tests/results/ugrid/surface_mean.cml b/lib/iris/tests/results/mesh/surface_mean.cml
similarity index 100%
rename from lib/iris/tests/results/ugrid/surface_mean.cml
rename to lib/iris/tests/results/mesh/surface_mean.cml
diff --git a/lib/iris/tests/stock/__init__.py b/lib/iris/tests/stock/__init__.py
index f664ce012b..31fa7e653d 100644
--- a/lib/iris/tests/stock/__init__.py
+++ b/lib/iris/tests/stock/__init__.py
@@ -15,7 +15,7 @@
 import numpy as np
 import numpy.ma as ma
 
-from iris import ugrid
+from iris import mesh as ugrid
 from iris.analysis import cartography
 import iris.aux_factory
 from iris.coord_systems import GeogCS, RotatedGeogCS
diff --git a/lib/iris/tests/stock/mesh.py b/lib/iris/tests/stock/mesh.py
index 22dcff18ce..3824ba84fc 100644
--- a/lib/iris/tests/stock/mesh.py
+++ b/lib/iris/tests/stock/mesh.py
@@ -8,7 +8,7 @@
 
 from iris.coords import AuxCoord, DimCoord
 from iris.cube import Cube
-from iris.ugrid.mesh import Connectivity, MeshCoord, MeshXY
+from iris.mesh import Connectivity, MeshCoord, MeshXY
 
 # Default creation controls for creating a test MeshXY.
 # Note: we're not creating any kind of sensible 'normal' mesh here, the numbers
diff --git a/lib/iris/tests/stock/netcdf.py b/lib/iris/tests/stock/netcdf.py
index c063f3af23..0f5fb0f144 100644
--- a/lib/iris/tests/stock/netcdf.py
+++ b/lib/iris/tests/stock/netcdf.py
@@ -116,7 +116,7 @@ def _add_standard_data(nc_path, unlimited_dim_size=0):
             var[:] = data
         else:
             # Fill with a plain value.  But avoid zeros, so we can simulate
-            # valid ugrid connectivities even when start_index=1.
+            # valid mesh connectivities even when start_index=1.
             with dask.config.set({"array.chunk-size": "2048MiB"}):
                 data = da.ones(shape, dtype=var.dtype)  # Do not use zero
             da.store(data, var)
diff --git a/lib/iris/tests/unit/ugrid/metadata/test_ConnectivityMetadata.py b/lib/iris/tests/unit/common/metadata/test_ConnectivityMetadata.py
similarity index 99%
rename from lib/iris/tests/unit/ugrid/metadata/test_ConnectivityMetadata.py
rename to lib/iris/tests/unit/common/metadata/test_ConnectivityMetadata.py
index e53a3d7002..6f3c9f7429 100644
--- a/lib/iris/tests/unit/ugrid/metadata/test_ConnectivityMetadata.py
+++ b/lib/iris/tests/unit/common/metadata/test_ConnectivityMetadata.py
@@ -2,7 +2,7 @@
 #
 # This file is part of Iris and is released under the BSD license.
 # See LICENSE in the root of the repository for full licensing details.
-"""Unit tests for the :class:`iris.ugrid.metadata.ConnectivityMetadata`."""
+"""Unit tests for the :class:`iris.common.metadata.ConnectivityMetadata`."""
 
 # Import iris.tests first so that some things can be initialised before
 # importing anything else.
@@ -13,8 +13,7 @@
 from unittest.mock import sentinel
 
 from iris.common.lenient import _LENIENT, _qualname
-from iris.common.metadata import BaseMetadata
-from iris.ugrid.metadata import ConnectivityMetadata
+from iris.common.metadata import BaseMetadata, ConnectivityMetadata
 
 
 class Test(tests.IrisTest):
diff --git a/lib/iris/tests/unit/ugrid/metadata/test_MeshCoordMetadata.py b/lib/iris/tests/unit/common/metadata/test_MeshCoordMetadata.py
similarity index 99%
rename from lib/iris/tests/unit/ugrid/metadata/test_MeshCoordMetadata.py
rename to lib/iris/tests/unit/common/metadata/test_MeshCoordMetadata.py
index 403425fb56..3bdf261165 100644
--- a/lib/iris/tests/unit/ugrid/metadata/test_MeshCoordMetadata.py
+++ b/lib/iris/tests/unit/common/metadata/test_MeshCoordMetadata.py
@@ -2,7 +2,7 @@
 #
 # This file is part of Iris and is released under the BSD license.
 # See LICENSE in the root of the repository for full licensing details.
-"""Unit tests for the :class:`iris.ugrid.metadata.MeshCoordMetadata`."""
+"""Unit tests for the :class:`iris.common.metadata.MeshCoordMetadata`."""
 
 # Import iris.tests first so that some things can be initialised before
 # importing anything else.
@@ -13,8 +13,7 @@
 from unittest.mock import sentinel
 
 from iris.common.lenient import _LENIENT, _qualname
-from iris.common.metadata import BaseMetadata
-from iris.ugrid.metadata import MeshCoordMetadata
+from iris.common.metadata import BaseMetadata, MeshCoordMetadata
 
 
 class Test__identity(tests.IrisTest):
diff --git a/lib/iris/tests/unit/ugrid/metadata/test_MeshMetadata.py b/lib/iris/tests/unit/common/metadata/test_MeshMetadata.py
similarity index 99%
rename from lib/iris/tests/unit/ugrid/metadata/test_MeshMetadata.py
rename to lib/iris/tests/unit/common/metadata/test_MeshMetadata.py
index e96a2441c3..7dae56bbe6 100644
--- a/lib/iris/tests/unit/ugrid/metadata/test_MeshMetadata.py
+++ b/lib/iris/tests/unit/common/metadata/test_MeshMetadata.py
@@ -2,7 +2,7 @@
 #
 # This file is part of Iris and is released under the BSD license.
 # See LICENSE in the root of the repository for full licensing details.
-"""Unit tests for the :class:`iris.ugrid.metadata.MeshMetadata`."""
+"""Unit tests for the :class:`iris.common.metadata.MeshMetadata`."""
 
 # Import iris.tests first so that some things can be initialised before
 # importing anything else.
@@ -13,8 +13,7 @@
 from unittest.mock import sentinel
 
 from iris.common.lenient import _LENIENT, _qualname
-from iris.common.metadata import BaseMetadata
-from iris.ugrid.metadata import MeshMetadata
+from iris.common.metadata import BaseMetadata, MeshMetadata
 
 
 class Test(tests.IrisTest):
diff --git a/lib/iris/tests/unit/common/metadata/test_metadata_manager_factory.py b/lib/iris/tests/unit/common/metadata/test_metadata_manager_factory.py
index 49449ad63b..1fbf0da084 100644
--- a/lib/iris/tests/unit/common/metadata/test_metadata_manager_factory.py
+++ b/lib/iris/tests/unit/common/metadata/test_metadata_manager_factory.py
@@ -17,11 +17,11 @@
     AncillaryVariableMetadata,
     BaseMetadata,
     CellMeasureMetadata,
+    ConnectivityMetadata,
     CoordMetadata,
     CubeMetadata,
     metadata_manager_factory,
 )
-from iris.ugrid.metadata import ConnectivityMetadata
 
 BASES = [
     AncillaryVariableMetadata,
diff --git a/lib/iris/tests/unit/common/mixin/test_CFVariableMixin.py b/lib/iris/tests/unit/common/mixin/test_CFVariableMixin.py
index 57d56ecfe7..7d414bfb54 100644
--- a/lib/iris/tests/unit/common/mixin/test_CFVariableMixin.py
+++ b/lib/iris/tests/unit/common/mixin/test_CFVariableMixin.py
@@ -17,11 +17,11 @@
     AncillaryVariableMetadata,
     BaseMetadata,
     CellMeasureMetadata,
+    ConnectivityMetadata,
     CoordMetadata,
     CubeMetadata,
 )
 from iris.common.mixin import CFVariableMixin, LimitedAttributeDict
-from iris.ugrid.metadata import ConnectivityMetadata
 
 
 class Test__getter(tests.IrisTest):
diff --git a/lib/iris/tests/unit/coords/test__DimensionalMetadata.py b/lib/iris/tests/unit/coords/test__DimensionalMetadata.py
index 60d4459ff3..64246261ca 100644
--- a/lib/iris/tests/unit/coords/test__DimensionalMetadata.py
+++ b/lib/iris/tests/unit/coords/test__DimensionalMetadata.py
@@ -21,9 +21,9 @@
     DimCoord,
     _DimensionalMetadata,
 )
+from iris.mesh import Connectivity
 from iris.tests.stock import climatology_3d as cube_with_climatology
 from iris.tests.stock.mesh import sample_meshcoord
-from iris.ugrid.mesh import Connectivity
 
 
 class Test___init____abstractmethod(tests.IrisTest):
diff --git a/lib/iris/tests/unit/experimental/ugrid/__init__.py b/lib/iris/tests/unit/experimental/ugrid/__init__.py
new file mode 100644
index 0000000000..27d7921e5f
--- /dev/null
+++ b/lib/iris/tests/unit/experimental/ugrid/__init__.py
@@ -0,0 +1,5 @@
+# Copyright Iris contributors
+#
+# This file is part of Iris and is released under the BSD license.
+# See LICENSE in the root of the repository for full licensing details.
+"""Unit tests for the :mod:`iris.experimental.ugrid` package."""
diff --git a/lib/iris/tests/unit/ugrid/load/test_ParseUgridOnLoad.py b/lib/iris/tests/unit/experimental/ugrid/test_ParseUgridOnLoad.py
similarity index 81%
rename from lib/iris/tests/unit/ugrid/load/test_ParseUgridOnLoad.py
rename to lib/iris/tests/unit/experimental/ugrid/test_ParseUgridOnLoad.py
index e47dfe8d50..62961157d8 100644
--- a/lib/iris/tests/unit/ugrid/load/test_ParseUgridOnLoad.py
+++ b/lib/iris/tests/unit/experimental/ugrid/test_ParseUgridOnLoad.py
@@ -2,7 +2,7 @@
 #
 # This file is part of Iris and is released under the BSD license.
 # See LICENSE in the root of the repository for full licensing details.
-"""Unit tests for the :class:`iris.ugrid.load.ParseUgridOnLoad` class.
+"""Unit tests for the :class:`iris.experimental.ugrid.ParseUgridOnLoad` class.
 
 TODO: remove this module when ParseUGridOnLoad itself is removed.
 
@@ -11,7 +11,7 @@
 import pytest
 
 from iris._deprecation import IrisDeprecation
-from iris.ugrid.load import PARSE_UGRID_ON_LOAD, ParseUGridOnLoad
+from iris.experimental.ugrid import PARSE_UGRID_ON_LOAD, ParseUGridOnLoad
 
 
 def test_creation():
diff --git a/lib/iris/tests/unit/fileformats/cf/test_CFGroup.py b/lib/iris/tests/unit/fileformats/cf/test_CFGroup.py
index e1b4b7a7cd..25f64319af 100644
--- a/lib/iris/tests/unit/fileformats/cf/test_CFGroup.py
+++ b/lib/iris/tests/unit/fileformats/cf/test_CFGroup.py
@@ -11,6 +11,9 @@
     CFCoordinateVariable,
     CFDataVariable,
     CFGroup,
+    CFUGridAuxiliaryCoordinateVariable,
+    CFUGridConnectivityVariable,
+    CFUGridMeshVariable,
 )
 
 # Import iris.tests first so that some things can be initialised before
@@ -42,3 +45,67 @@ def test_non_data_names(self):
         expected_names = [var.cf_name for var in (aux_var, coord_var, coord_var2)]
         expected = set(expected_names)
         self.assertEqual(expected, self.cf_group.non_data_variable_names)
+
+
+class Ugrid(tests.IrisTest):
+    """Separate class to test UGRID functionality."""
+
+    def setUp(self):
+        self.cf_group = CFGroup()
+
+    def test_inherited(self):
+        coord_var = MagicMock(spec=CFCoordinateVariable, cf_name="coord_var")
+        self.cf_group[coord_var.cf_name] = coord_var
+        self.assertEqual(coord_var, self.cf_group.coordinates[coord_var.cf_name])
+
+    def test_connectivities(self):
+        conn_var = MagicMock(spec=CFUGridConnectivityVariable, cf_name="conn_var")
+        self.cf_group[conn_var.cf_name] = conn_var
+        self.assertEqual(conn_var, self.cf_group.connectivities[conn_var.cf_name])
+
+    def test_ugrid_coords(self):
+        coord_var = MagicMock(
+            spec=CFUGridAuxiliaryCoordinateVariable, cf_name="coord_var"
+        )
+        self.cf_group[coord_var.cf_name] = coord_var
+        self.assertEqual(coord_var, self.cf_group.ugrid_coords[coord_var.cf_name])
+
+    def test_meshes(self):
+        mesh_var = MagicMock(spec=CFUGridMeshVariable, cf_name="mesh_var")
+        self.cf_group[mesh_var.cf_name] = mesh_var
+        self.assertEqual(mesh_var, self.cf_group.meshes[mesh_var.cf_name])
+
+    def test_non_data_names(self):
+        data_var = MagicMock(spec=CFDataVariable, cf_name="data_var")
+        coord_var = MagicMock(spec=CFCoordinateVariable, cf_name="coord_var")
+        conn_var = MagicMock(spec=CFUGridConnectivityVariable, cf_name="conn_var")
+        ugrid_coord_var = MagicMock(
+            spec=CFUGridAuxiliaryCoordinateVariable, cf_name="ugrid_coord_var"
+        )
+        mesh_var = MagicMock(spec=CFUGridMeshVariable, cf_name="mesh_var")
+        mesh_var2 = MagicMock(spec=CFUGridMeshVariable, cf_name="mesh_var2")
+        duplicate_name_var = MagicMock(spec=CFUGridMeshVariable, cf_name="coord_var")
+
+        for var in (
+            data_var,
+            coord_var,
+            conn_var,
+            ugrid_coord_var,
+            mesh_var,
+            mesh_var2,
+            duplicate_name_var,
+        ):
+            self.cf_group[var.cf_name] = var
+
+        expected_names = [
+            var.cf_name
+            for var in (
+                coord_var,
+                conn_var,
+                ugrid_coord_var,
+                mesh_var,
+                mesh_var2,
+            )
+        ]
+        expected = set(expected_names)
+        self.assertEqual(expected, self.cf_group.non_data_variable_names)
diff --git a/lib/iris/tests/unit/fileformats/cf/test_CFReader.py b/lib/iris/tests/unit/fileformats/cf/test_CFReader.py
index 80338ea71e..12c1510413 100644
--- a/lib/iris/tests/unit/fileformats/cf/test_CFReader.py
+++ b/lib/iris/tests/unit/fileformats/cf/test_CFReader.py
@@ -12,7 +12,15 @@
 
 import numpy as np
 
-from iris.fileformats.cf import CFReader
+from iris.fileformats.cf import (
+    CFCoordinateVariable,
+    CFDataVariable,
+    CFGroup,
+    CFReader,
+    CFUGridAuxiliaryCoordinateVariable,
+    CFUGridConnectivityVariable,
+    CFUGridMeshVariable,
+)
 
 
 def netcdf_variable(
@@ -35,6 +43,12 @@ def netcdf_variable(
         ndim = len(dimensions)
     else:
         dimensions = []
+
+    ugrid_identities = (
+        CFUGridAuxiliaryCoordinateVariable.cf_identities
+        + CFUGridConnectivityVariable.cf_identities
+        + [CFUGridMeshVariable.cf_identity]
+    )
     ncvar = mock.Mock(
         name=name,
         dimensions=dimensions,
@@ -49,6 +63,7 @@ def netcdf_variable(
         grid_mapping=grid_mapping,
         cell_measures=cell_measures,
         standard_name=standard_name,
+        **{name: None for name in ugrid_identities},
     )
     return ncvar
 
@@ -350,5 +365,84 @@ def test_promoted_auxiliary_ignore(self):
             self.assertEqual(warn.call_count, 2)
 
 
+class Test_build_cf_groups__ugrid(tests.IrisTest):
+    @classmethod
+    def setUpClass(cls):
+        # Replicating syntax from test_CFReader.Test_build_cf_groups__formula_terms.
+        cls.mesh = netcdf_variable("mesh", "", int)
+        cls.node_x = netcdf_variable("node_x", "node", float)
+        cls.node_y = netcdf_variable("node_y", "node", float)
+        cls.face_x = netcdf_variable("face_x", "face", float)
+        cls.face_y = netcdf_variable("face_y", "face", float)
+        cls.face_nodes = netcdf_variable("face_nodes", "face vertex", int)
+        cls.levels = netcdf_variable("levels", "levels", int)
+        cls.data = netcdf_variable(
+            "data", "levels face", float, coordinates="face_x face_y"
+        )
+
+        # Add necessary attributes for mesh recognition.
+        cls.mesh.cf_role = "mesh_topology"
+        cls.mesh.node_coordinates = "node_x node_y"
+        cls.mesh.face_coordinates = "face_x face_y"
+        cls.mesh.face_node_connectivity = "face_nodes"
+        cls.face_nodes.cf_role = "face_node_connectivity"
+        cls.data.mesh = "mesh"
+
+        cls.variables = dict(
+            mesh=cls.mesh,
+            node_x=cls.node_x,
+            node_y=cls.node_y,
+            face_x=cls.face_x,
+            face_y=cls.face_y,
+            face_nodes=cls.face_nodes,
+            levels=cls.levels,
+            data=cls.data,
+        )
+        ncattrs = mock.Mock(return_value=[])
+        cls.dataset = mock.Mock(
+            file_format="NetCDF4", variables=cls.variables, ncattrs=ncattrs
+        )
+
+    def setUp(self):
+        # Restrict the CFReader functionality to only performing
+        # translations and building first level cf-groups for variables.
+        self.patch("iris.fileformats.cf.CFReader._reset")
+        self.patch(
+            "iris.fileformats.netcdf._thread_safe_nc.DatasetWrapper",
+            return_value=self.dataset,
+        )
+        cf_reader = CFReader("dummy")
+        self.cf_group = cf_reader.cf_group
+
+    def test_inherited(self):
+        for expected_var, collection in (
+            [CFCoordinateVariable("levels", self.levels), "coordinates"],
+            [CFDataVariable("data", self.data), "data_variables"],
+        ):
+            expected = {expected_var.cf_name: expected_var}
+            self.assertDictEqual(expected, getattr(self.cf_group, collection))
+
+    def test_connectivities(self):
+        expected_var = CFUGridConnectivityVariable("face_nodes", self.face_nodes)
+        expected = {expected_var.cf_name: expected_var}
+        self.assertDictEqual(expected, self.cf_group.connectivities)
+
+    def test_mesh(self):
+        expected_var = CFUGridMeshVariable("mesh", self.mesh)
+        expected = {expected_var.cf_name: expected_var}
+        self.assertDictEqual(expected, self.cf_group.meshes)
+
+    def test_ugrid_coords(self):
+        names = [f"{loc}_{ax}" for loc in ("node", "face") for ax in ("x", "y")]
+        expected = {
+            name: CFUGridAuxiliaryCoordinateVariable(name, getattr(self, name))
+            for name in names
+        }
+        self.assertDictEqual(expected, self.cf_group.ugrid_coords)
+
+    def test_is_cf_ugrid_group(self):
+        self.assertIsInstance(self.cf_group, CFGroup)
+
+
 if __name__ == "__main__":
     tests.main()
diff --git a/lib/iris/tests/unit/ugrid/cf/test_CFUGridAuxiliaryCoordinateVariable.py b/lib/iris/tests/unit/fileformats/cf/test_CFUGridAuxiliaryCoordinateVariable.py
similarity index 93%
rename from lib/iris/tests/unit/ugrid/cf/test_CFUGridAuxiliaryCoordinateVariable.py
rename to lib/iris/tests/unit/fileformats/cf/test_CFUGridAuxiliaryCoordinateVariable.py
index abd4442d3b..d056de4aff 100644
--- a/lib/iris/tests/unit/ugrid/cf/test_CFUGridAuxiliaryCoordinateVariable.py
+++ b/lib/iris/tests/unit/fileformats/cf/test_CFUGridAuxiliaryCoordinateVariable.py
@@ -2,13 +2,7 @@
 #
 # This file is part of Iris and is released under the BSD license.
 # See LICENSE in the root of the repository for full licensing details.
-"""Unit tests for the :class:`iris.ugrid.cf.CFUGridAuxiliaryCoordinateVariable` class.
-
-todo: fold these tests into cf tests when iris.ugrid is folded into
- standard behaviour.
-TODO: complete iris.ugrid replacement
-
-"""
+"""Unit tests for :class:`iris.fileformats.cf.CFUGridAuxiliaryCoordinateVariable`."""
 
 # Import iris.tests first so that some things can be initialised before
 # importing anything else.
@@ -20,16 +14,14 @@
 import numpy as np
 import pytest
 
-from iris.tests.unit.ugrid.cf.test_CFUGridReader import (
-    netcdf_ugrid_variable,
-)
-from iris.ugrid.cf import CFUGridAuxiliaryCoordinateVariable
+from iris.fileformats.cf import CFUGridAuxiliaryCoordinateVariable
+from iris.tests.unit.fileformats.cf.test_CFReader import netcdf_variable
 import iris.warnings
 
 
 def named_variable(name):
     # Don't need to worry about dimensions or dtype for these tests.
-    return netcdf_ugrid_variable(name, "", int)
+    return netcdf_variable(name, "", int)
 
 
 class TestIdentify(tests.IrisTest):
@@ -130,7 +122,7 @@ def test_string_type_ignored(self):
         ref_source = named_variable("ref_source")
         setattr(ref_source, self.cf_identities[0], subject_name)
         vars_all = {
-            subject_name: netcdf_ugrid_variable(subject_name, "", np.bytes_),
+            subject_name: netcdf_variable(subject_name, "", np.bytes_),
             "ref_not_subject": named_variable("ref_not_subject"),
             "ref_source": ref_source,
         }
@@ -221,7 +213,7 @@ def operation(warn: bool):
 
         # String variable warning.
         warn_regex = r".*is a CF-netCDF label variable.*"
-        vars_all[subject_name] = netcdf_ugrid_variable(subject_name, "", np.bytes_)
+        vars_all[subject_name] = netcdf_variable(subject_name, "", np.bytes_)
         with pytest.warns(iris.warnings.IrisCfLabelVarWarning, match=warn_regex):
             operation(warn=True)
         with pytest.warns() as record:
diff --git a/lib/iris/tests/unit/ugrid/cf/test_CFUGridConnectivityVariable.py b/lib/iris/tests/unit/fileformats/cf/test_CFUGridConnectivityVariable.py
similarity index 92%
rename from lib/iris/tests/unit/ugrid/cf/test_CFUGridConnectivityVariable.py
rename to lib/iris/tests/unit/fileformats/cf/test_CFUGridConnectivityVariable.py
index 743410a849..573e6f799f 100644
--- a/lib/iris/tests/unit/ugrid/cf/test_CFUGridConnectivityVariable.py
+++ b/lib/iris/tests/unit/fileformats/cf/test_CFUGridConnectivityVariable.py
@@ -2,13 +2,7 @@
 #
 # This file is part of Iris and is released under the BSD license.
 # See LICENSE in the root of the repository for full licensing details.
-"""Unit tests for the :class:`iris.ugrid.cf.CFUGridConnectivityVariable` class.
-
-todo: fold these tests into cf tests when iris.ugrid is folded into
- standard behaviour.
-TODO: complete iris.ugrid replacement
-
-"""
+"""Unit tests for :class:`iris.fileformats.cf.CFUGridConnectivityVariable`."""
 
 # Import iris.tests first so that some things can be initialised before
 # importing anything else.
@@ -20,17 +14,15 @@
 import numpy as np
 import pytest
 
-from iris.tests.unit.ugrid.cf.test_CFUGridReader import (
-    netcdf_ugrid_variable,
-)
-from iris.ugrid.cf import CFUGridConnectivityVariable
-from iris.ugrid.mesh import Connectivity
+from iris.fileformats.cf import CFUGridConnectivityVariable
+from iris.mesh import Connectivity
+from iris.tests.unit.fileformats.cf.test_CFReader import netcdf_variable
 import iris.warnings
 
 
 def named_variable(name):
     # Don't need to worry about dimensions or dtype for these tests.
-    return netcdf_ugrid_variable(name, "", int)
+    return netcdf_variable(name, "", int)
 
 
 class TestIdentify(tests.IrisTest):
@@ -119,7 +111,7 @@ def test_string_type_ignored(self):
         ref_source = named_variable("ref_source")
         setattr(ref_source, Connectivity.UGRID_CF_ROLES[0], subject_name)
         vars_all = {
-            subject_name: netcdf_ugrid_variable(subject_name, "", np.bytes_),
+            subject_name: netcdf_variable(subject_name, "", np.bytes_),
             "ref_not_subject": named_variable("ref_not_subject"),
             "ref_source": ref_source,
         }
@@ -204,7 +196,7 @@ def operation(warn: bool):
 
         # String variable warning.
         warn_regex = r".*is a CF-netCDF label variable.*"
-        vars_all[subject_name] = netcdf_ugrid_variable(subject_name, "", np.bytes_)
+        vars_all[subject_name] = netcdf_variable(subject_name, "", np.bytes_)
         with pytest.warns(iris.warnings.IrisCfLabelVarWarning, match=warn_regex):
             operation(warn=True)
         with pytest.warns() as record:
diff --git a/lib/iris/tests/unit/ugrid/cf/test_CFUGridMeshVariable.py b/lib/iris/tests/unit/fileformats/cf/test_CFUGridMeshVariable.py
similarity index 94%
rename from lib/iris/tests/unit/ugrid/cf/test_CFUGridMeshVariable.py
rename to lib/iris/tests/unit/fileformats/cf/test_CFUGridMeshVariable.py
index f93c1e89a1..5205c6a018 100644
--- a/lib/iris/tests/unit/ugrid/cf/test_CFUGridMeshVariable.py
+++ b/lib/iris/tests/unit/fileformats/cf/test_CFUGridMeshVariable.py
@@ -2,13 +2,7 @@
 #
 # This file is part of Iris and is released under the BSD license.
 # See LICENSE in the root of the repository for full licensing details.
-"""Unit tests for the :class:`iris.ugrid.cf.CFUGridMeshVariable` class.
-
-todo: fold these tests into cf tests when ugrid is folded into
- standard behaviour.
-TODO: complete iris.ugrid replacement
-
-"""
+"""Unit tests for :class:`iris.fileformats.cf.CFUGridMeshVariable`."""
 
 # Import iris.tests first so that some things can be initialised before
 # importing anything else.
@@ -20,16 +14,14 @@
 import numpy as np
 import pytest
 
-from iris.tests.unit.ugrid.cf.test_CFUGridReader import (
-    netcdf_ugrid_variable,
-)
-from iris.ugrid.cf import CFUGridMeshVariable
+from iris.fileformats.cf import CFUGridMeshVariable
+from iris.tests.unit.fileformats.cf.test_CFReader import netcdf_variable
 import iris.warnings
 
 
 def named_variable(name):
     # Don't need to worry about dimensions or dtype for these tests.
-    return netcdf_ugrid_variable(name, "", int)
+    return netcdf_variable(name, "", int)
 
 
 class TestIdentify(tests.IrisTest):
@@ -166,7 +158,7 @@ def test_string_type_ignored(self):
         ref_source = named_variable("ref_source")
         setattr(ref_source, self.cf_identity, subject_name)
         vars_all = {
-            subject_name: netcdf_ugrid_variable(subject_name, "", np.bytes_),
+            subject_name: netcdf_variable(subject_name, "", np.bytes_),
             "ref_not_subject": named_variable("ref_not_subject"),
             "ref_source": ref_source,
         }
@@ -251,7 +243,7 @@ def operation(warn: bool):
 
         # String variable warning.
         warn_regex = r".*is a CF-netCDF label variable.*"
-        vars_all[subject_name] = netcdf_ugrid_variable(subject_name, "", np.bytes_)
+        vars_all[subject_name] = netcdf_variable(subject_name, "", np.bytes_)
         with pytest.warns(iris.warnings.IrisCfLabelVarWarning, match=warn_regex):
             operation(warn=True)
         with pytest.warns() as record:
diff --git a/lib/iris/tests/unit/fileformats/netcdf/loader/test_load_cubes.py b/lib/iris/tests/unit/fileformats/netcdf/loader/test_load_cubes.py
index c90f710110..09ee679adf 100644
--- a/lib/iris/tests/unit/fileformats/netcdf/loader/test_load_cubes.py
+++ b/lib/iris/tests/unit/fileformats/netcdf/loader/test_load_cubes.py
@@ -23,8 +23,8 @@
 from iris.coords import AncillaryVariable, CellMeasure
 from iris.fileformats.netcdf import logger
 from iris.fileformats.netcdf.loader import load_cubes
+from iris.mesh import MeshCoord
 from iris.tests.stock.netcdf import ncgen_from_cdl
-from iris.ugrid.mesh import MeshCoord
 
 
 def setUpModule():
diff --git a/lib/iris/tests/unit/fileformats/netcdf/loader/ugrid_load/__init__.py b/lib/iris/tests/unit/fileformats/netcdf/loader/ugrid_load/__init__.py
new file mode 100644
index 0000000000..993d106ba3
--- /dev/null
+++ b/lib/iris/tests/unit/fileformats/netcdf/loader/ugrid_load/__init__.py
@@ -0,0 +1,5 @@
+# Copyright Iris contributors
+#
+# This file is part of Iris and is released under the BSD license.
+# See LICENSE in the root of the repository for full licensing details.
+"""Unit tests for the :mod:`iris.fileformats.netcdf.ugrid_load` package."""
diff --git a/lib/iris/tests/unit/ugrid/load/test_load_mesh.py b/lib/iris/tests/unit/fileformats/netcdf/loader/ugrid_load/test_load_mesh.py
similarity index 86%
rename from lib/iris/tests/unit/ugrid/load/test_load_mesh.py
rename to lib/iris/tests/unit/fileformats/netcdf/loader/ugrid_load/test_load_mesh.py
index 5f2308c68e..0e618c7d55 100644
--- a/lib/iris/tests/unit/ugrid/load/test_load_mesh.py
+++ b/lib/iris/tests/unit/fileformats/netcdf/loader/ugrid_load/test_load_mesh.py
@@ -2,20 +2,21 @@
 #
 # This file is part of Iris and is released under the BSD license.
 # See LICENSE in the root of the repository for full licensing details.
-"""Unit tests for the :func:`iris.ugrid.load.load_mesh` function."""
+"""Unit tests for the :func:`iris.mesh.load_mesh` function."""
 
 # Import iris.tests first so that some things can be initialised before
 # importing anything else.
 import iris.tests as tests  # isort:skip
 
-from iris.ugrid.load import load_mesh
+from iris.fileformats.netcdf.ugrid_load import load_mesh
 
 
 class Tests(tests.IrisTest):
     # All 'real' tests have been done for load_meshes(). Here we just check
     #  that load_mesh() works with load_meshes() correctly, using mocking.
     def setUp(self):
-        self.load_meshes_mock = self.patch("iris.ugrid.load.load_meshes")
+        tgt = "iris.fileformats.netcdf.ugrid_load.load_meshes"
+        self.load_meshes_mock = self.patch(tgt)
         # The expected return from load_meshes - a dict of files, each with
         #  a list of meshes.
         self.load_meshes_mock.return_value = {"file": ["mesh"]}
diff --git a/lib/iris/tests/unit/ugrid/load/test_load_meshes.py b/lib/iris/tests/unit/fileformats/netcdf/loader/ugrid_load/test_load_meshes.py
similarity index 98%
rename from lib/iris/tests/unit/ugrid/load/test_load_meshes.py
rename to lib/iris/tests/unit/fileformats/netcdf/loader/ugrid_load/test_load_meshes.py
index 3847514a02..424c321098 100644
--- a/lib/iris/tests/unit/ugrid/load/test_load_meshes.py
+++ b/lib/iris/tests/unit/fileformats/netcdf/loader/ugrid_load/test_load_meshes.py
@@ -2,7 +2,7 @@
 #
 # This file is part of Iris and is released under the BSD license.
 # See LICENSE in the root of the repository for full licensing details.
-"""Unit tests for the :func:`iris.ugrid.load.load_meshes` function."""
+"""Unit tests for the :func:`iris.mesh.load_meshes` function."""
 
 # Import iris.tests first so that some things can be initialised before
 # importing anything else.
@@ -13,8 +13,8 @@
 import tempfile
 from uuid import uuid4
 
+from iris.fileformats.netcdf.ugrid_load import load_meshes, logger
 from iris.tests.stock.netcdf import ncgen_from_cdl
-from iris.ugrid.load import load_meshes, logger
 
 
 def setUpModule():
diff --git a/lib/iris/tests/unit/ugrid/load/test_meshload_checks.py b/lib/iris/tests/unit/fileformats/netcdf/loader/ugrid_load/test_meshload_checks.py
similarity index 100%
rename from lib/iris/tests/unit/ugrid/load/test_meshload_checks.py
rename to lib/iris/tests/unit/fileformats/netcdf/loader/ugrid_load/test_meshload_checks.py
diff --git a/lib/iris/tests/unit/fileformats/netcdf/saver/test_Saver__ugrid.py b/lib/iris/tests/unit/fileformats/netcdf/saver/test_Saver__ugrid.py
index c8b422c7b3..7508376840 100644
--- a/lib/iris/tests/unit/fileformats/netcdf/saver/test_Saver__ugrid.py
+++ b/lib/iris/tests/unit/fileformats/netcdf/saver/test_Saver__ugrid.py
@@ -23,9 +23,8 @@
 from iris.coords import AuxCoord
 from iris.cube import Cube, CubeList
 from iris.fileformats.netcdf import _thread_safe_nc
+from iris.mesh import Connectivity, MeshXY, save_mesh
 from iris.tests.stock import realistic_4d
-from iris.ugrid.mesh import Connectivity, MeshXY
-from iris.ugrid.save import save_mesh
 
 XY_LOCS = ("x", "y")
 XY_NAMES = ("longitude", "latitude")
@@ -196,7 +195,7 @@ def make_cube(mesh=None, location="face", **kwargs):
 
     Parameters
     ----------
-    mesh : :class:`iris.ugrid.mesh.MeshXY` or None, optional
+    mesh : :class:`iris.mesh.MeshXY` or None, optional
         If None, use 'default_mesh()'
     location : str, optional, default="face"
         Which mesh element to map the cube to.
diff --git a/lib/iris/tests/unit/ugrid/utils/__init__.py b/lib/iris/tests/unit/mesh/__init__.py
similarity index 78%
rename from lib/iris/tests/unit/ugrid/utils/__init__.py
rename to lib/iris/tests/unit/mesh/__init__.py
index 7bc5f68717..1305bda078 100644
--- a/lib/iris/tests/unit/ugrid/utils/__init__.py
+++ b/lib/iris/tests/unit/mesh/__init__.py
@@ -2,6 +2,6 @@
 #
 # This file is part of Iris and is released under the BSD license.
 # See LICENSE in the root of the repository for full licensing details.
-"""Unit tests for the :mod:`iris.ugrid.utils` package."""
+"""Unit tests for the :mod:`iris.mesh` package."""
 
 from __future__ import annotations
diff --git a/lib/iris/tests/unit/ugrid/cf/__init__.py b/lib/iris/tests/unit/mesh/components/__init__.py
similarity index 73%
rename from lib/iris/tests/unit/ugrid/cf/__init__.py
rename to lib/iris/tests/unit/mesh/components/__init__.py
index dc57b9d980..cc0effb1f6 100644
--- a/lib/iris/tests/unit/ugrid/cf/__init__.py
+++ b/lib/iris/tests/unit/mesh/components/__init__.py
@@ -2,4 +2,4 @@
 #
 # This file is part of Iris and is released under the BSD license.
 # See LICENSE in the root of the repository for full licensing details.
-"""Unit tests for the :mod:`iris.ugrid.cf` package."""
+"""Unit tests for the :mod:`iris.mesh.components` package."""
diff --git a/lib/iris/tests/unit/ugrid/mesh/test_Connectivity.py b/lib/iris/tests/unit/mesh/components/test_Connectivity.py
similarity index 99%
rename from lib/iris/tests/unit/ugrid/mesh/test_Connectivity.py
rename to lib/iris/tests/unit/mesh/components/test_Connectivity.py
index 507176a943..de8d7de3d7 100644
--- a/lib/iris/tests/unit/ugrid/mesh/test_Connectivity.py
+++ b/lib/iris/tests/unit/mesh/components/test_Connectivity.py
@@ -2,7 +2,7 @@
 #
 # This file is part of Iris and is released under the BSD license.
 # See LICENSE in the root of the repository for full licensing details.
-"""Unit tests for the :class:`iris.ugrid.mesh.Connectivity` class."""
+"""Unit tests for the :class:`iris.mesh.Connectivity` class."""
 
 # Import iris.tests first so that some things can be initialised before
 # importing anything else.
@@ -16,7 +16,7 @@
 from packaging import version
 
 from iris._lazy_data import as_lazy_data, is_lazy_data
-from iris.ugrid.mesh import Connectivity
+from iris.mesh import Connectivity
 
 
 class TestStandard(tests.IrisTest):
diff --git a/lib/iris/tests/unit/ugrid/mesh/test_MeshCoord.py b/lib/iris/tests/unit/mesh/components/test_MeshCoord.py
similarity index 99%
rename from lib/iris/tests/unit/ugrid/mesh/test_MeshCoord.py
rename to lib/iris/tests/unit/mesh/components/test_MeshCoord.py
index 38d48b8be8..0acec1985d 100644
--- a/lib/iris/tests/unit/ugrid/mesh/test_MeshCoord.py
+++ b/lib/iris/tests/unit/mesh/components/test_MeshCoord.py
@@ -2,7 +2,7 @@
 #
 # This file is part of Iris and is released under the BSD license.
 # See LICENSE in the root of the repository for full licensing details.
-"""Unit tests for the :class:`iris.ugrid.mesh.MeshCoord`."""
+"""Unit tests for the :class:`iris.mesh.MeshCoord`."""
 
 # Import iris.tests first so that some things can be initialised before
 # importing anything else.
@@ -21,9 +21,9 @@
 from iris.common.metadata import BaseMetadata, CoordMetadata
 from iris.coords import AuxCoord, Coord
 from iris.cube import Cube
+from iris.mesh import Connectivity, MeshCoord, MeshXY
 import iris.tests.stock.mesh
 from iris.tests.stock.mesh import sample_mesh, sample_meshcoord
-from iris.ugrid.mesh import Connectivity, MeshCoord, MeshXY
 from iris.warnings import IrisVagueMetadataWarning
 
 
diff --git a/lib/iris/tests/unit/ugrid/mesh/test_Mesh.py b/lib/iris/tests/unit/mesh/components/test_MeshXY.py
similarity index 92%
rename from lib/iris/tests/unit/ugrid/mesh/test_Mesh.py
rename to lib/iris/tests/unit/mesh/components/test_MeshXY.py
index 4b26c7b064..c1977633e2 100644
--- a/lib/iris/tests/unit/ugrid/mesh/test_Mesh.py
+++ b/lib/iris/tests/unit/mesh/components/test_MeshXY.py
@@ -2,7 +2,7 @@
 #
 # This file is part of Iris and is released under the BSD license.
 # See LICENSE in the root of the repository for full licensing details.
-"""Unit tests for the :class:`mesh` class."""
+"""Unit tests for the :class:`iris.mesh.MeshXY` class."""
 
 # Import iris.tests first so that some things can be initialised before
 # importing anything else.
@@ -10,10 +10,11 @@
 
 import numpy as np
 
+from iris.common.metadata import MeshMetadata
 from iris.coords import AuxCoord
 from iris.exceptions import ConnectivityNotFoundError, CoordinateNotFoundError
-from iris.ugrid import mesh, metadata
-from iris.ugrid.mesh import logger
+from iris.mesh import components
+from iris.mesh.components import logger
 
 
 class TestMeshCommon(tests.IrisTest):
@@ -40,22 +41,28 @@ def setUpClass(cls):
         cls.FACE_LON = AuxCoord([0.5], standard_name="longitude", var_name="face_lon")
         cls.FACE_LAT = AuxCoord([0.5], standard_name="latitude", var_name="face_lat")
 
-        cls.EDGE_NODE = mesh.Connectivity(
+        cls.EDGE_NODE = components.Connectivity(
             [[0, 1], [1, 2], [2, 0]],
             cf_role="edge_node_connectivity",
             long_name="long_name",
             var_name="var_name",
             attributes={"test": 1},
         )
-        cls.FACE_NODE = mesh.Connectivity([[0, 1, 2]], cf_role="face_node_connectivity")
-        cls.FACE_EDGE = mesh.Connectivity([[0, 1, 2]], cf_role="face_edge_connectivity")
+        cls.FACE_NODE = components.Connectivity(
+            [[0, 1, 2]], cf_role="face_node_connectivity"
+        )
+        cls.FACE_EDGE = components.Connectivity(
+            [[0, 1, 2]], cf_role="face_edge_connectivity"
+        )
         # (Actually meaningless:)
-        cls.FACE_FACE = mesh.Connectivity([[0, 0, 0]], cf_role="face_face_connectivity")
+        cls.FACE_FACE = components.Connectivity(
+            [[0, 0, 0]], cf_role="face_face_connectivity"
+        )
         # (Actually meaningless:)
-        cls.EDGE_FACE = mesh.Connectivity(
+        cls.EDGE_FACE = components.Connectivity(
             [[0, 0], [0, 0], [0, 0]], cf_role="edge_face_connectivity"
         )
-        cls.BOUNDARY_NODE = mesh.Connectivity(
+        cls.BOUNDARY_NODE = components.Connectivity(
             [[0, 1], [1, 2], [2, 0]], cf_role="boundary_node_connectivity"
         )
 
@@ -78,12 +85,12 @@ def setUpClass(cls):
             "edge_dimension": "EdgeDim",
             "edge_coords_and_axes": ((cls.EDGE_LON, "x"), (cls.EDGE_LAT, "y")),
         }
-        cls.mesh = mesh.MeshXY(**cls.kwargs)
+        cls.mesh = components.MeshXY(**cls.kwargs)
 
     def test__metadata_manager(self):
         self.assertEqual(
             self.mesh._metadata_manager.cls.__name__,
-            metadata.MeshMetadata.__name__,
+            MeshMetadata.__name__,
         )
 
     def test___getstate__(self):
@@ -127,13 +134,13 @@ def test___eq__(self):
         # The dimension names do not participate in equality.
         equivalent_kwargs = self.kwargs.copy()
         equivalent_kwargs["node_dimension"] = "something_else"
-        equivalent = mesh.MeshXY(**equivalent_kwargs)
+        equivalent = components.MeshXY(**equivalent_kwargs)
         self.assertEqual(equivalent, self.mesh)
 
     def test_different(self):
         different_kwargs = self.kwargs.copy()
         different_kwargs["long_name"] = "new_name"
-        different = mesh.MeshXY(**different_kwargs)
+        different = components.MeshXY(**different_kwargs)
         self.assertNotEqual(different, self.mesh)
 
         different_kwargs = self.kwargs.copy()
@@ -141,22 +148,22 @@ def test_different(self):
         new_lat = ncaa[1][0].copy(points=ncaa[1][0].points + 1)
         new_ncaa = (ncaa[0], (new_lat, "y"))
         different_kwargs["node_coords_and_axes"] = new_ncaa
-        different = mesh.MeshXY(**different_kwargs)
+        different = components.MeshXY(**different_kwargs)
         self.assertNotEqual(different, self.mesh)
 
         different_kwargs = self.kwargs.copy()
         conns = self.kwargs["connectivities"]
         new_conn = conns[0].copy(conns[0].indices + 1)
         different_kwargs["connectivities"] = new_conn
-        different = mesh.MeshXY(**different_kwargs)
+        different = components.MeshXY(**different_kwargs)
         self.assertNotEqual(different, self.mesh)
 
     def test_all_connectivities(self):
-        expected = mesh.Mesh1DConnectivities(self.EDGE_NODE)
+        expected = components.Mesh1DConnectivities(self.EDGE_NODE)
         self.assertEqual(expected, self.mesh.all_connectivities)
 
     def test_all_coords(self):
-        expected = mesh.Mesh1DCoords(
+        expected = components.Mesh1DCoords(
             self.NODE_LON, self.NODE_LAT, self.EDGE_LON, self.EDGE_LAT
         )
         self.assertEqual(expected, self.mesh.all_coords)
@@ -181,7 +188,9 @@ def test_connectivities(self):
             {"cf_role": "edge_node_connectivity"},
         )
 
-        fake_connectivity = tests.mock.Mock(__class__=mesh.Connectivity, cf_role="fake")
+        fake_connectivity = tests.mock.Mock(
+            __class__=components.Connectivity, cf_role="fake"
+        )
         negative_kwargs = (
             {"item": fake_connectivity},
             {"item": "foo"},
@@ -295,7 +304,7 @@ def test_edge_dimension(self):
         self.assertEqual(self.kwargs["edge_dimension"], self.mesh.edge_dimension)
 
     def test_edge_coords(self):
-        expected = mesh.MeshEdgeCoords(self.EDGE_LON, self.EDGE_LAT)
+        expected = components.MeshEdgeCoords(self.EDGE_LON, self.EDGE_LAT)
         self.assertEqual(expected, self.mesh.edge_coords)
 
     def test_edge_face(self):
@@ -325,7 +334,7 @@ def test_face_node(self):
             _ = self.mesh.face_node_connectivity
 
     def test_node_coords(self):
-        expected = mesh.MeshNodeCoords(self.NODE_LON, self.NODE_LAT)
+        expected = components.MeshNodeCoords(self.NODE_LON, self.NODE_LAT)
         self.assertEqual(expected, self.mesh.node_coords)
 
     def test_node_dimension(self):
@@ -360,7 +369,7 @@ def setUpClass(cls):
             (cls.FACE_LON, "x"),
             (cls.FACE_LAT, "y"),
         )
-        cls.mesh = mesh.MeshXY(**cls.kwargs)
+        cls.mesh = components.MeshXY(**cls.kwargs)
 
     def test___repr__(self):
         expected = "<MeshXY: 'my_topology_mesh'>"
@@ -417,7 +426,7 @@ def test___str__(self):
     def test___str__noedgecoords(self):
         mesh_kwargs = self.kwargs.copy()
         del mesh_kwargs["edge_coords_and_axes"]
-        alt_mesh = mesh.MeshXY(**mesh_kwargs)
+        alt_mesh = components.MeshXY(**mesh_kwargs)
         expected = [
             "MeshXY : 'my_topology_mesh'",
             "    topology_dimension: 2",
@@ -462,7 +471,7 @@ def test___str__noedgecoords(self):
         self.assertEqual(expected, str(alt_mesh).split("\n"))
 
     def test_all_connectivities(self):
-        expected = mesh.Mesh2DConnectivities(
+        expected = components.Mesh2DConnectivities(
             self.FACE_NODE,
             self.EDGE_NODE,
             self.FACE_EDGE,
@@ -473,7 +482,7 @@ def test_all_connectivities(self):
         self.assertEqual(expected, self.mesh.all_connectivities)
 
     def test_all_coords(self):
-        expected = mesh.Mesh2DCoords(
+        expected = components.Mesh2DCoords(
             self.NODE_LON,
             self.NODE_LAT,
             self.EDGE_LON,
@@ -583,7 +592,7 @@ def test_edge_face(self):
         self.assertEqual(self.EDGE_FACE, self.mesh.edge_face_connectivity)
 
     def test_face_coords(self):
-        expected = mesh.MeshFaceCoords(self.FACE_LON, self.FACE_LAT)
+        expected = components.MeshFaceCoords(self.FACE_LON, self.FACE_LAT)
         self.assertEqual(expected, self.mesh.face_coords)
 
     def test_face_dimension(self):
@@ -624,7 +633,7 @@ def setUp(self):
                 (self.EDGE_LAT, "y"),
             ),
         }
-        self.mesh = mesh.MeshXY(**self.kwargs)
+        self.mesh = components.MeshXY(**self.kwargs)
 
     def test___repr__basic(self):
         expected = "<MeshXY: 'my_topology_mesh'>"
@@ -667,7 +676,7 @@ def test___str__units_stdname(self):
         mesh_kwargs = self.kwargs.copy()
         mesh_kwargs["standard_name"] = "height"  # Odd choice !
         mesh_kwargs["units"] = "m"
-        alt_mesh = mesh.MeshXY(**mesh_kwargs)
+        alt_mesh = components.MeshXY(**mesh_kwargs)
         result = str(alt_mesh)
         # We expect these to appear at the end.
         expected = "\n".join(
@@ -690,7 +699,7 @@ class TestOperations1D(TestMeshCommon):
     # Tests that cannot reuse an existing MeshXY instance, instead need a new
     # one each time.
     def setUp(self):
-        self.mesh = mesh.MeshXY(
+        self.mesh = components.MeshXY(
             topology_dimension=1,
             node_coords_and_axes=((self.NODE_LON, "x"), (self.NODE_LAT, "y")),
             connectivities=self.EDGE_NODE,
@@ -740,7 +749,7 @@ def test_add_connectivities(self):
             edge_node = self.new_connectivity(self.EDGE_NODE, new_len)
             self.mesh.add_connectivities(edge_node)
             self.assertEqual(
-                mesh.Mesh1DConnectivities(edge_node),
+                components.Mesh1DConnectivities(edge_node),
                 self.mesh.all_connectivities,
             )
 
@@ -771,7 +780,7 @@ def test_add_coords(self):
         edge_kwargs = {"edge_x": self.EDGE_LON, "edge_y": self.EDGE_LAT}
         self.mesh.add_coords(**edge_kwargs)
         self.assertEqual(
-            mesh.MeshEdgeCoords(**edge_kwargs),
+            components.MeshEdgeCoords(**edge_kwargs),
             self.mesh.edge_coords,
         )
 
@@ -788,11 +797,11 @@ def test_add_coords(self):
             }
             self.mesh.add_coords(**node_kwargs, **edge_kwargs)
             self.assertEqual(
-                mesh.MeshNodeCoords(**node_kwargs),
+                components.MeshNodeCoords(**node_kwargs),
                 self.mesh.node_coords,
             )
             self.assertEqual(
-                mesh.MeshEdgeCoords(**edge_kwargs),
+                components.MeshEdgeCoords(**edge_kwargs),
                 self.mesh.edge_coords,
             )
 
@@ -833,17 +842,17 @@ def test_add_coords_invalid(self):
     def test_add_coords_single(self):
         # ADD coord.
         edge_x = self.EDGE_LON
-        expected = mesh.MeshEdgeCoords(edge_x=edge_x, edge_y=None)
+        expected = components.MeshEdgeCoords(edge_x=edge_x, edge_y=None)
         self.mesh.add_coords(edge_x=edge_x)
         self.assertEqual(expected, self.mesh.edge_coords)
 
         # REPLACE coords.
         node_x = self.new_coord(self.NODE_LON)
         edge_x = self.new_coord(self.EDGE_LON)
-        expected_nodes = mesh.MeshNodeCoords(
+        expected_nodes = components.MeshNodeCoords(
             node_x=node_x, node_y=self.mesh.node_coords.node_y
         )
-        expected_edges = mesh.MeshEdgeCoords(edge_x=edge_x, edge_y=None)
+        expected_edges = components.MeshEdgeCoords(edge_x=edge_x, edge_y=None)
         self.mesh.add_coords(node_x=node_x, edge_x=edge_x)
         self.assertEqual(expected_nodes, self.mesh.node_coords)
         self.assertEqual(expected_edges, self.mesh.edge_coords)
@@ -867,14 +876,14 @@ def test_add_coords_single_face(self):
 
     def test_dimension_names(self):
         # Test defaults.
-        default = mesh.Mesh1DNames("Mesh1d_node", "Mesh1d_edge")
+        default = components.Mesh1DNames("Mesh1d_node", "Mesh1d_edge")
         self.assertEqual(default, self.mesh.dimension_names())
 
         log_regex = r"Not setting face_dimension.*"
         with self.assertLogs(logger, level="DEBUG", msg_regex=log_regex):
             self.mesh.dimension_names("foo", "bar", "baz")
         self.assertEqual(
-            mesh.Mesh1DNames("foo", "bar"),
+            components.Mesh1DNames("foo", "bar"),
             self.mesh.dimension_names(),
         )
 
@@ -918,7 +927,9 @@ def test_remove_connectivities(self):
             {"contains_edge": True, "contains_node": True},
         )
 
-        fake_connectivity = tests.mock.Mock(__class__=mesh.Connectivity, cf_role="fake")
+        fake_connectivity = tests.mock.Mock(
+            __class__=components.Connectivity, cf_role="fake"
+        )
         negative_kwargs = (
             {"item": fake_connectivity},
             {"item": "foo"},
@@ -995,7 +1006,7 @@ def test_to_MeshCoord(self):
         location = "node"
         axis = "x"
         result = self.mesh.to_MeshCoord(location, axis)
-        self.assertIsInstance(result, mesh.MeshCoord)
+        self.assertIsInstance(result, components.MeshCoord)
         self.assertEqual(location, result.location)
         self.assertEqual(axis, result.axis)
 
@@ -1012,7 +1023,7 @@ def test_to_MeshCoords(self):
         self.assertEqual(len(self.mesh.AXES), len(result))
         for ix, axis in enumerate(self.mesh.AXES):
             coord = result[ix]
-            self.assertIsInstance(coord, mesh.MeshCoord)
+            self.assertIsInstance(coord, components.MeshCoord)
             self.assertEqual(location, coord.location)
             self.assertEqual(axis, coord.axis)
 
@@ -1024,7 +1035,7 @@ def test_to_MeshCoords_face(self):
 class TestOperations2D(TestOperations1D):
     # Additional/specialised tests for topology_dimension=2.
     def setUp(self):
-        self.mesh = mesh.MeshXY(
+        self.mesh = components.MeshXY(
             topology_dimension=2,
             node_coords_and_axes=((self.NODE_LON, "x"), (self.NODE_LAT, "y")),
             connectivities=(self.FACE_NODE),
@@ -1039,7 +1050,7 @@ def test_add_connectivities(self):
             "edge_face": self.EDGE_FACE,
             "boundary_node": self.BOUNDARY_NODE,
         }
-        expected = mesh.Mesh2DConnectivities(
+        expected = components.Mesh2DConnectivities(
             face_node=self.mesh.face_node_connectivity, **kwargs
         )
         self.mesh.add_connectivities(*kwargs.values())
@@ -1053,7 +1064,7 @@ def test_add_connectivities(self):
             kwargs = {k: self.new_connectivity(v, new_len) for k, v in kwargs.items()}
             self.mesh.add_connectivities(*kwargs.values())
             self.assertEqual(
-                mesh.Mesh2DConnectivities(**kwargs),
+                components.Mesh2DConnectivities(**kwargs),
                 self.mesh.all_connectivities,
             )
 
@@ -1081,7 +1092,7 @@ def test_add_connectivities_inconsistent(self):
             )
 
     def test_add_connectivities_invalid(self):
-        fake_cf_role = tests.mock.Mock(__class__=mesh.Connectivity, cf_role="foo")
+        fake_cf_role = tests.mock.Mock(__class__=components.Connectivity, cf_role="foo")
         log_regex = r"Not adding connectivity.*"
         with self.assertLogs(logger, level="DEBUG", msg_regex=log_regex):
             self.mesh.add_connectivities(fake_cf_role)
@@ -1091,7 +1102,7 @@ def test_add_coords_face(self):
         kwargs = {"face_x": self.FACE_LON, "face_y": self.FACE_LAT}
         self.mesh.add_coords(**kwargs)
         self.assertEqual(
-            mesh.MeshFaceCoords(**kwargs),
+            components.MeshFaceCoords(**kwargs),
             self.mesh.face_coords,
         )
 
@@ -1104,20 +1115,20 @@ def test_add_coords_face(self):
             }
             self.mesh.add_coords(**kwargs)
             self.assertEqual(
-                mesh.MeshFaceCoords(**kwargs),
+                components.MeshFaceCoords(**kwargs),
                 self.mesh.face_coords,
             )
 
     def test_add_coords_single_face(self):
         # ADD coord.
         face_x = self.FACE_LON
-        expected = mesh.MeshFaceCoords(face_x=face_x, face_y=None)
+        expected = components.MeshFaceCoords(face_x=face_x, face_y=None)
         self.mesh.add_coords(face_x=face_x)
         self.assertEqual(expected, self.mesh.face_coords)
 
         # REPLACE coord.
         face_x = self.new_coord(self.FACE_LON)
-        expected = mesh.MeshFaceCoords(face_x=face_x, face_y=None)
+        expected = components.MeshFaceCoords(face_x=face_x, face_y=None)
         self.mesh.add_coords(face_x=face_x)
         self.assertEqual(expected, self.mesh.face_coords)
 
@@ -1132,12 +1143,12 @@ def test_add_coords_single_face(self):
 
     def test_dimension_names(self):
         # Test defaults.
-        default = mesh.Mesh2DNames("Mesh2d_node", "Mesh2d_edge", "Mesh2d_face")
+        default = components.Mesh2DNames("Mesh2d_node", "Mesh2d_edge", "Mesh2d_face")
         self.assertEqual(default, self.mesh.dimension_names())
 
         self.mesh.dimension_names("foo", "bar", "baz")
         self.assertEqual(
-            mesh.Mesh2DNames("foo", "bar", "baz"),
+            components.Mesh2DNames("foo", "bar", "baz"),
             self.mesh.dimension_names(),
         )
 
@@ -1179,7 +1190,7 @@ def test_to_MeshCoord_face(self):
         location = "face"
         axis = "x"
         result = self.mesh.to_MeshCoord(location, axis)
-        self.assertIsInstance(result, mesh.MeshCoord)
+        self.assertIsInstance(result, components.MeshCoord)
         self.assertEqual(location, result.location)
         self.assertEqual(axis, result.axis)
 
@@ -1190,7 +1201,7 @@ def test_to_MeshCoords_face(self):
         self.assertEqual(len(self.mesh.AXES), len(result))
         for ix, axis in enumerate(self.mesh.AXES):
             coord = result[ix]
-            self.assertIsInstance(coord, mesh.MeshCoord)
+            self.assertIsInstance(coord, components.MeshCoord)
             self.assertEqual(location, coord.location)
             self.assertEqual(axis, coord.axis)
 
@@ -1208,7 +1219,7 @@ def test_invalid_topology(self):
         self.assertRaisesRegex(
             ValueError,
             "Expected 'topology_dimension'.*",
-            mesh.MeshXY,
+            components.MeshXY,
             **kwargs,
         )
 
@@ -1220,7 +1231,7 @@ def test_invalid_axes(self):
         self.assertRaisesRegex(
             ValueError,
             "Invalid axis specified for node.*",
-            mesh.MeshXY,
+            components.MeshXY,
             node_coords_and_axes=(
                 (self.NODE_LON, "foo"),
                 (self.NODE_LAT, "y"),
@@ -1234,14 +1245,14 @@ def test_invalid_axes(self):
         self.assertRaisesRegex(
             ValueError,
             "Invalid axis specified for edge.*",
-            mesh.MeshXY,
+            components.MeshXY,
             edge_coords_and_axes=((self.EDGE_LON, "foo"),),
             **kwargs,
         )
         self.assertRaisesRegex(
             ValueError,
             "Invalid axis specified for face.*",
-            mesh.MeshXY,
+            components.MeshXY,
             face_coords_and_axes=((self.FACE_LON, "foo"),),
             **kwargs,
         )
@@ -1261,7 +1272,7 @@ def test_minimum_connectivities(self):
         self.assertRaisesRegex(
             ValueError,
             ".*requires a edge_node_connectivity.*",
-            mesh.MeshXY,
+            components.MeshXY,
             **kwargs,
         )
 
@@ -1275,7 +1286,7 @@ def test_minimum_coords(self):
         self.assertRaisesRegex(
             ValueError,
             ".*is a required coordinate.*",
-            mesh.MeshXY,
+            components.MeshXY,
             **kwargs,
         )
 
diff --git a/lib/iris/tests/unit/ugrid/mesh/test_Mesh__from_coords.py b/lib/iris/tests/unit/mesh/components/test_MeshXY__from_coords.py
similarity index 98%
rename from lib/iris/tests/unit/ugrid/mesh/test_Mesh__from_coords.py
rename to lib/iris/tests/unit/mesh/components/test_MeshXY__from_coords.py
index 3043ae81b5..8114fbe92e 100644
--- a/lib/iris/tests/unit/ugrid/mesh/test_Mesh__from_coords.py
+++ b/lib/iris/tests/unit/mesh/components/test_MeshXY__from_coords.py
@@ -2,7 +2,7 @@
 #
 # This file is part of Iris and is released under the BSD license.
 # See LICENSE in the root of the repository for full licensing details.
-"""Unit tests for the :meth:`iris.ugrid.mesh.MeshXY.from_coords`."""
+"""Unit tests for the :meth:`iris.mesh.MeshXY.from_coords`."""
 
 # Import iris.tests first so that some things can be initialised before
 # importing anything else.
@@ -11,9 +11,8 @@
 import numpy as np
 
 from iris.coords import AuxCoord, DimCoord
+from iris.mesh import Connectivity, MeshXY, logger
 from iris.tests.stock import simple_2d_w_multidim_coords
-from iris.ugrid import logger
-from iris.ugrid.mesh import Connectivity, MeshXY
 
 
 class Test1Dim(tests.IrisTest):
diff --git a/benchmarks/benchmarks/experimental/ugrid/__init__.py b/lib/iris/tests/unit/mesh/utils/__init__.py
similarity index 75%
rename from benchmarks/benchmarks/experimental/ugrid/__init__.py
rename to lib/iris/tests/unit/mesh/utils/__init__.py
index 4976054178..5a252f1529 100644
--- a/benchmarks/benchmarks/experimental/ugrid/__init__.py
+++ b/lib/iris/tests/unit/mesh/utils/__init__.py
@@ -2,4 +2,4 @@
 #
 # This file is part of Iris and is released under the BSD license.
 # See LICENSE in the root of the repository for full licensing details.
-"""Benchmark tests for the experimental.ugrid module."""
+"""Unit tests for the :mod:`iris.mesh.utils` package."""
diff --git a/lib/iris/tests/unit/ugrid/utils/test_recombine_submeshes.py b/lib/iris/tests/unit/mesh/utils/test_recombine_submeshes.py
similarity index 99%
rename from lib/iris/tests/unit/ugrid/utils/test_recombine_submeshes.py
rename to lib/iris/tests/unit/mesh/utils/test_recombine_submeshes.py
index 2617000a0e..5323dd5883 100644
--- a/lib/iris/tests/unit/ugrid/utils/test_recombine_submeshes.py
+++ b/lib/iris/tests/unit/mesh/utils/test_recombine_submeshes.py
@@ -2,7 +2,7 @@
 #
 # This file is part of Iris and is released under the BSD license.
 # See LICENSE in the root of the repository for full licensing details.
-"""Unit tests for :func:`iris.ugrid.utils.recombine_submeshes`."""
+"""Unit tests for :func:`iris.mesh.utils.recombine_submeshes`."""
 
 # Import iris.tests first so that some things can be initialised before
 # importing anything else.
@@ -13,8 +13,8 @@
 
 from iris.coords import AuxCoord
 from iris.cube import CubeList
+from iris.mesh.utils import recombine_submeshes
 from iris.tests.stock.mesh import sample_mesh, sample_mesh_cube
-from iris.ugrid.utils import recombine_submeshes
 
 
 def common_test_setup(self, shape_3d=(0, 2), data_chunks=None):
diff --git a/lib/iris/tests/unit/tests/stock/test_netcdf.py b/lib/iris/tests/unit/tests/stock/test_netcdf.py
index 521f03e053..cccb93d28b 100644
--- a/lib/iris/tests/unit/tests/stock/test_netcdf.py
+++ b/lib/iris/tests/unit/tests/stock/test_netcdf.py
@@ -12,8 +12,8 @@
 # Import iris.tests first so that some things can be initialised before
 # importing anything else.
 import iris.tests as tests  # isort:skip
+from iris.mesh import MeshCoord, MeshXY
 from iris.tests.stock import netcdf
-from iris.ugrid.mesh import MeshCoord, MeshXY
 
 
 class XIOSFileMixin(tests.IrisTest):
diff --git a/lib/iris/tests/unit/ugrid/cf/test_CFUGridGroup.py b/lib/iris/tests/unit/ugrid/cf/test_CFUGridGroup.py
deleted file mode 100644
index 0226ec76e2..0000000000
--- a/lib/iris/tests/unit/ugrid/cf/test_CFUGridGroup.py
+++ /dev/null
@@ -1,87 +0,0 @@
-# Copyright Iris contributors
-#
-# This file is part of Iris and is released under the BSD license.
-# See LICENSE in the root of the repository for full licensing details.
-"""Unit tests for the :class:`iris.ugrid.cf.CFUGridGroup` class.
-
-todo: fold these tests into cf tests when iris.ugrid is folded into
- standard behaviour.
-TODO: complete iris.ugrid replacement
-
-"""
-
-# Import iris.tests first so that some things can be initialised before
-# importing anything else.
-import iris.tests as tests  # isort:skip
-
-from unittest.mock import MagicMock
-
-from iris.fileformats.cf import CFCoordinateVariable, CFDataVariable
-from iris.ugrid.cf import (
-    CFUGridAuxiliaryCoordinateVariable,
-    CFUGridConnectivityVariable,
-    CFUGridGroup,
-    CFUGridMeshVariable,
-)
-
-
-class Tests(tests.IrisTest):
-    def setUp(self):
-        self.cf_group = CFUGridGroup()
-
-    def test_inherited(self):
-        coord_var = MagicMock(spec=CFCoordinateVariable, cf_name="coord_var")
-        self.cf_group[coord_var.cf_name] = coord_var
-        self.assertEqual(coord_var, self.cf_group.coordinates[coord_var.cf_name])
-
-    def test_connectivities(self):
-        conn_var = MagicMock(spec=CFUGridConnectivityVariable, cf_name="conn_var")
-        self.cf_group[conn_var.cf_name] = conn_var
-        self.assertEqual(conn_var, self.cf_group.connectivities[conn_var.cf_name])
-
-    def test_ugrid_coords(self):
-        coord_var = MagicMock(
-            spec=CFUGridAuxiliaryCoordinateVariable, cf_name="coord_var"
-        )
-        self.cf_group[coord_var.cf_name] = coord_var
-        self.assertEqual(coord_var, self.cf_group.ugrid_coords[coord_var.cf_name])
-
-    def test_meshes(self):
-        mesh_var = MagicMock(spec=CFUGridMeshVariable, cf_name="mesh_var")
-        self.cf_group[mesh_var.cf_name] = mesh_var
-        self.assertEqual(mesh_var, self.cf_group.meshes[mesh_var.cf_name])
-
-    def test_non_data_names(self):
-        data_var = MagicMock(spec=CFDataVariable, cf_name="data_var")
-        coord_var = MagicMock(spec=CFCoordinateVariable, cf_name="coord_var")
-        conn_var = MagicMock(spec=CFUGridConnectivityVariable, cf_name="conn_var")
-        ugrid_coord_var = MagicMock(
-            spec=CFUGridAuxiliaryCoordinateVariable, cf_name="ugrid_coord_var"
-        )
-        mesh_var = MagicMock(spec=CFUGridMeshVariable, cf_name="mesh_var")
-        mesh_var2 = MagicMock(spec=CFUGridMeshVariable, cf_name="mesh_var2")
-        duplicate_name_var = MagicMock(spec=CFUGridMeshVariable, cf_name="coord_var")
-
-        for var in (
-            data_var,
-            coord_var,
-            conn_var,
-            ugrid_coord_var,
-            mesh_var,
-            mesh_var2,
-            duplicate_name_var,
-        ):
-            self.cf_group[var.cf_name] = var
-
-        expected_names = [
-            var.cf_name
-            for var in (
-                coord_var,
-                conn_var,
-                ugrid_coord_var,
-                mesh_var,
-                mesh_var2,
-            )
-        ]
-        expected = set(expected_names)
-        self.assertEqual(expected, self.cf_group.non_data_variable_names)
diff --git a/lib/iris/tests/unit/ugrid/cf/test_CFUGridReader.py b/lib/iris/tests/unit/ugrid/cf/test_CFUGridReader.py
deleted file mode 100644
index 5f36958e9a..0000000000
--- a/lib/iris/tests/unit/ugrid/cf/test_CFUGridReader.py
+++ /dev/null
@@ -1,129 +0,0 @@
-# Copyright Iris contributors
-#
-# This file is part of Iris and is released under the BSD license.
-# See LICENSE in the root of the repository for full licensing details.
-"""Unit tests for the :class:`iris.ugrid.cf.CFUGridGroup` class.
-
-todo: fold these tests into cf tests when iris.ugrid is folded into
- standard behaviour.
-TODO: complete iris.ugrid replacement
-
-"""
-
-# Import iris.tests first so that some things can be initialised before
-# importing anything else.
-import iris.tests as tests  # isort:skip
-
-from unittest import mock
-
-from iris.fileformats.cf import CFCoordinateVariable, CFDataVariable
-from iris.tests.unit.fileformats.cf.test_CFReader import netcdf_variable
-from iris.ugrid.cf import (
-    CFUGridAuxiliaryCoordinateVariable,
-    CFUGridConnectivityVariable,
-    CFUGridGroup,
-    CFUGridMeshVariable,
-    CFUGridReader,
-)
-
-
-def netcdf_ugrid_variable(
-    name,
-    dimensions,
-    dtype,
-    coordinates=None,
-):
-    ncvar = netcdf_variable(
-        name=name, dimensions=dimensions, dtype=dtype, coordinates=coordinates
-    )
-
-    # Fill in all the extra UGRID attributes to prevent problems with getattr
-    # and Mock. Any attribute can be replaced in downstream setUp if present.
-    ugrid_attrs = (
-        CFUGridAuxiliaryCoordinateVariable.cf_identities
-        + CFUGridConnectivityVariable.cf_identities
-        + [CFUGridMeshVariable.cf_identity]
-    )
-    for attr in ugrid_attrs:
-        setattr(ncvar, attr, None)
-
-    return ncvar
-
-
-class Test_build_cf_groups(tests.IrisTest):
-    @classmethod
-    def setUpClass(cls):
-        # Replicating syntax from test_CFReader.Test_build_cf_groups__formula_terms.
-        cls.mesh = netcdf_ugrid_variable("mesh", "", int)
-        cls.node_x = netcdf_ugrid_variable("node_x", "node", float)
-        cls.node_y = netcdf_ugrid_variable("node_y", "node", float)
-        cls.face_x = netcdf_ugrid_variable("face_x", "face", float)
-        cls.face_y = netcdf_ugrid_variable("face_y", "face", float)
-        cls.face_nodes = netcdf_ugrid_variable("face_nodes", "face vertex", int)
-        cls.levels = netcdf_ugrid_variable("levels", "levels", int)
-        cls.data = netcdf_ugrid_variable(
-            "data", "levels face", float, coordinates="face_x face_y"
-        )
-
-        # Add necessary attributes for mesh recognition.
-        cls.mesh.cf_role = "mesh_topology"
-        cls.mesh.node_coordinates = "node_x node_y"
-        cls.mesh.face_coordinates = "face_x face_y"
-        cls.mesh.face_node_connectivity = "face_nodes"
-        cls.face_nodes.cf_role = "face_node_connectivity"
-        cls.data.mesh = "mesh"
-
-        cls.variables = dict(
-            mesh=cls.mesh,
-            node_x=cls.node_x,
-            node_y=cls.node_y,
-            face_x=cls.face_x,
-            face_y=cls.face_y,
-            face_nodes=cls.face_nodes,
-            levels=cls.levels,
-            data=cls.data,
-        )
-        ncattrs = mock.Mock(return_value=[])
-        cls.dataset = mock.Mock(
-            file_format="NetCDF4", variables=cls.variables, ncattrs=ncattrs
-        )
-
-    def setUp(self):
-        # Restrict the CFUGridReader functionality to only performing
-        # translations and building first level cf-groups for variables.
-        self.patch("iris.ugrid.cf.CFUGridReader._reset")
-        self.patch(
-            "iris.fileformats.netcdf._thread_safe_nc.DatasetWrapper",
-            return_value=self.dataset,
-        )
-        cf_reader = CFUGridReader("dummy")
-        self.cf_group = cf_reader.cf_group
-
-    def test_inherited(self):
-        for expected_var, collection in (
-            [CFCoordinateVariable("levels", self.levels), "coordinates"],
-            [CFDataVariable("data", self.data), "data_variables"],
-        ):
-            expected = {expected_var.cf_name: expected_var}
-            self.assertDictEqual(expected, getattr(self.cf_group, collection))
-
-    def test_connectivities(self):
-        expected_var = CFUGridConnectivityVariable("face_nodes", self.face_nodes)
-        expected = {expected_var.cf_name: expected_var}
-        self.assertDictEqual(expected, self.cf_group.connectivities)
-
-    def test_mesh(self):
-        expected_var = CFUGridMeshVariable("mesh", self.mesh)
-        expected = {expected_var.cf_name: expected_var}
-        self.assertDictEqual(expected, self.cf_group.meshes)
-
-    def test_ugrid_coords(self):
-        names = [f"{loc}_{ax}" for loc in ("node", "face") for ax in ("x", "y")]
-        expected = {
-            name: CFUGridAuxiliaryCoordinateVariable(name, getattr(self, name))
-            for name in names
-        }
-        self.assertDictEqual(expected, self.cf_group.ugrid_coords)
-
-    def test_is_cf_ugrid_group(self):
-        self.assertIsInstance(self.cf_group, CFUGridGroup)
diff --git a/lib/iris/tests/unit/ugrid/load/__init__.py b/lib/iris/tests/unit/ugrid/load/__init__.py
deleted file mode 100644
index b3552cc441..0000000000
--- a/lib/iris/tests/unit/ugrid/load/__init__.py
+++ /dev/null
@@ -1,5 +0,0 @@
-# Copyright Iris contributors
-#
-# This file is part of Iris and is released under the BSD license.
-# See LICENSE in the root of the repository for full licensing details.
-"""Unit tests for the :mod:`iris.ugrid.load` package."""
diff --git a/lib/iris/tests/unit/ugrid/mesh/__init__.py b/lib/iris/tests/unit/ugrid/mesh/__init__.py
deleted file mode 100644
index f41d747a68..0000000000
--- a/lib/iris/tests/unit/ugrid/mesh/__init__.py
+++ /dev/null
@@ -1,5 +0,0 @@
-# Copyright Iris contributors
-#
-# This file is part of Iris and is released under the BSD license.
-# See LICENSE in the root of the repository for full licensing details.
-"""Unit tests for the :mod:`iris.ugrid.mesh` package."""
diff --git a/lib/iris/tests/unit/ugrid/metadata/__init__.py b/lib/iris/tests/unit/ugrid/metadata/__init__.py
deleted file mode 100644
index e1ca32ec43..0000000000
--- a/lib/iris/tests/unit/ugrid/metadata/__init__.py
+++ /dev/null
@@ -1,7 +0,0 @@
-# Copyright Iris contributors
-#
-# This file is part of Iris and is released under the BSD license.
-# See LICENSE in the root of the repository for full licensing details.
-"""Unit tests for the :mod:`iris.ugrid.metadata` package."""
-
-from __future__ import annotations