diff --git a/astropy/coordinates/representation.py b/astropy/coordinates/representation.py
index 76e5dde236e5..a806523a993a 100644
--- a/astropy/coordinates/representation.py
+++ b/astropy/coordinates/representation.py
@@ -1048,9 +1048,6 @@ def _scale_operation(self, op, *args):
             Any arguments required for the operator (typically, what is to
             be multiplied with, divided by).
         """
-
-        self._raise_if_has_differentials(op.__name__)
-
         results = []
         for component, cls in self.attr_classes.items():
             value = getattr(self, component)
@@ -1063,10 +1060,16 @@ def _scale_operation(self, op, *args):
         # as operations that returned NotImplemented or a representation
         # instead of a quantity (as would happen for, e.g., rep * rep).
         try:
-            return self.__class__(*results)
+            result = self.__class__(*results)
         except Exception:
             return NotImplemented

+        for key, differential in self.differentials.items():
+            diff_result = differential._scale_operation(op, *args, scaled_base=True)
+            result.differentials[key] = diff_result
+
+        return result
+
     def _combine_operation(self, op, other, reverse=False):
         """Combine two representation.

@@ -1655,19 +1658,23 @@ def transform(self, matrix):

         return rep

-    def __mul__(self, other):
-        self._raise_if_has_differentials('multiplication')
-        return self._dimensional_representation(lon=self.lon, lat=self.lat,
-                                                distance=1. * other)
-
-    def __truediv__(self, other):
-        self._raise_if_has_differentials('division')
-        return self._dimensional_representation(lon=self.lon, lat=self.lat,
-                                                distance=1. / other)
+    def _scale_operation(self, op, *args):
+        return self._dimensional_representation(
+            lon=self.lon, lat=self.lat, distance=1.,
+            differentials=self.differentials)._scale_operation(op, *args)

     def __neg__(self):
-        self._raise_if_has_differentials('negation')
-        return self.__class__(self.lon + 180. * u.deg, -self.lat, copy=False)
+        if any(differential.base_representation is not self.__class__
+               for differential in self.differentials.values()):
+            return super().__neg__()
+
+        result = self.__class__(self.lon + 180. * u.deg, -self.lat, copy=False)
+        for key, differential in self.differentials.items():
+            new_comps = (op(getattr(differential, comp))
+                         for op, comp in zip((operator.pos, operator.neg),
+                                             differential.components))
+            result.differentials[key] = differential.__class__(*new_comps, copy=False)
+        return result

     def norm(self):
         """Vector norm.
@@ -1805,9 +1812,11 @@ def from_cartesian(cls, cart):
         """
         return cls(distance=cart.norm(), copy=False)

-    def _scale_operation(self, op, *args):
-        self._raise_if_has_differentials(op.__name__)
-        return op(self.distance, *args)
+    def __mul__(self, other):
+        if isinstance(other, BaseRepresentation):
+            return self.distance * other
+        else:
+            return super().__mul__(other)

     def norm(self):
         """Vector norm.
@@ -1838,6 +1847,23 @@ def transform(self, matrix):
         )

+def _spherical_op_funcs(op, *args):
+    """For given operator, return functions that adjust lon, lat, distance."""
+    if op is operator.neg:
+        return lambda x: x+180*u.deg, operator.neg, operator.pos
+
+    try:
+        scale_sign = np.sign(args[0])
+    except Exception:
+        # This should always work, even if perhaps we get a negative distance.
+        return operator.pos, operator.pos, lambda x: op(x, *args)
+
+    scale = abs(args[0])
+    return (lambda x: x + 180*u.deg*np.signbit(scale_sign),
+            lambda x: x * scale_sign,
+            lambda x: op(x, scale))
+
+
 class SphericalRepresentation(BaseRepresentation):
     """
     Representation of points in 3D spherical coordinates.
@@ -2021,10 +2047,22 @@ def norm(self):
         """
         return np.abs(self.distance)

-    def __neg__(self):
-        self._raise_if_has_differentials('negation')
-        return self.__class__(self.lon + 180. * u.deg, -self.lat, self.distance,
-                              copy=False)
+    def _scale_operation(self, op, *args):
+        # TODO: expand special-casing to UnitSpherical and RadialDifferential.
+        if any(differential.base_representation is not self.__class__
+               for differential in self.differentials.values()):
+            return super()._scale_operation(op, *args)
+
+        lon_op, lat_op, distance_op = _spherical_op_funcs(op, *args)
+
+        result = self.__class__(lon_op(self.lon), lat_op(self.lat),
+                                distance_op(self.distance), copy=False)
+        for key, differential in self.differentials.items():
+            new_comps = (op(getattr(differential, comp)) for op, comp in zip(
+                (operator.pos, lat_op, distance_op),
+                differential.components))
+            result.differentials[key] = differential.__class__(*new_comps, copy=False)
+        return result

 class PhysicsSphericalRepresentation(BaseRepresentation):
@@ -2214,6 +2252,24 @@ def norm(self):
         """
         return np.abs(self.r)

+    def _scale_operation(self, op, *args):
+        if any(differential.base_representation is not self.__class__
+               for differential in self.differentials.values()):
+            return super()._scale_operation(op, *args)
+
+        phi_op, adjust_theta_sign, r_op = _spherical_op_funcs(op, *args)
+        # Also run phi_op on theta to ensure theta remains between 0 and 180:
+        # any time the scale is negative, we do -theta + 180 degrees.
+        result = self.__class__(phi_op(self.phi),
+                                phi_op(adjust_theta_sign(self.theta)),
+                                r_op(self.r), copy=False)
+        for key, differential in self.differentials.items():
+            new_comps = (op(getattr(differential, comp)) for op, comp in zip(
+                (operator.pos, adjust_theta_sign, r_op),
+                differential.components))
+            result.differentials[key] = differential.__class__(*new_comps, copy=False)
+        return result
+

 class CylindricalRepresentation(BaseRepresentation):
     """
@@ -2317,6 +2373,22 @@ def to_cartesian(self):

         return CartesianRepresentation(x=x, y=y, z=z, copy=False)

+    def _scale_operation(self, op, *args):
+        if any(differential.base_representation is not self.__class__
+               for differential in self.differentials.values()):
+            return super()._scale_operation(op, *args)
+
+        phi_op, _, rho_op = _spherical_op_funcs(op, *args)
+        z_op = lambda x: op(x, *args)
+
+        result = self.__class__(rho_op(self.rho), phi_op(self.phi),
+                                z_op(self.z), copy=False)
+        for key, differential in self.differentials.items():
+            new_comps = (op(getattr(differential, comp)) for op, comp in zip(
+                (rho_op, operator.pos, z_op), differential.components))
+            result.differentials[key] = differential.__class__(*new_comps, copy=False)
+        return result
+

 class BaseDifferential(BaseRepresentationOrDifferential):
     r"""A base class representing differentials of representations.
@@ -2561,7 +2633,7 @@ class is a differential representation, the base will be converted
         diff = cdiff.represent_as(self.__class__, transformed_base)
         return diff

-    def _scale_operation(self, op, *args):
+    def _scale_operation(self, op, *args, scaled_base=False):
         """Scale all components.

         Parameters
@@ -2571,6 +2643,11 @@ def _scale_operation(self, op, *args):
         *args
             Any arguments required for the operator (typically, what is to
             be multiplied with, divided by).
+        scaled_base : bool, optional
+            Whether the base was scaled the same way. This affects whether
+            differential components should be scaled. For instance, a differential
+            in longitude should not be scaled if its spherical base is scaled
+            in radius.
         """
         scaled_attrs = [op(getattr(self, c), *args) for c in self.components]
         return self.__class__(*scaled_attrs, copy=False)
@@ -2913,6 +2990,12 @@ class is a differential representation, the base will be converted

         return diff

+    def _scale_operation(self, op, *args, scaled_base=False):
+        if scaled_base:
+            return self.copy()
+        else:
+            return super()._scale_operation(op, *args)
+

 class SphericalDifferential(BaseSphericalDifferential):
     """Differential(s) of points in 3D spherical coordinates.
@@ -2965,6 +3048,12 @@ def from_representation(cls, representation, base=None):

         return super().from_representation(representation, base)

+    def _scale_operation(self, op, *args, scaled_base=False):
+        if scaled_base:
+            return self.__class__(self.d_lon, self.d_lat, op(self.d_distance, *args))
+        else:
+            return super()._scale_operation(op, *args)
+

 class BaseSphericalCosLatDifferential(BaseDifferential):
     """Differentials from points on a spherical base representation.
@@ -3156,6 +3245,12 @@ class is a differential representation, the base will be converted

         return diff

+    def _scale_operation(self, op, *args, scaled_base=False):
+        if scaled_base:
+            return self.copy()
+        else:
+            return super()._scale_operation(op, *args)
+

 class SphericalCosLatDifferential(BaseSphericalCosLatDifferential):
     """Differential(s) of points in 3D spherical coordinates.
@@ -3213,6 +3308,12 @@ def from_representation(cls, representation, base=None):

         return super().from_representation(representation, base)

+    def _scale_operation(self, op, *args, scaled_base=False):
+        if scaled_base:
+            return self.__class__(self.d_lon_coslat, self.d_lat, op(self.d_distance, *args))
+        else:
+            return super()._scale_operation(op, *args)
+

 class RadialDifferential(BaseDifferential):
     """Differential(s) of radial distances.
@@ -3325,6 +3426,12 @@ def from_representation(cls, representation, base=None):

         return super().from_representation(representation, base)

+    def _scale_operation(self, op, *args, scaled_base=False):
+        if scaled_base:
+            return self.__class__(self.d_phi, self.d_theta, op(self.d_r, *args))
+        else:
+            return super()._scale_operation(op, *args)
+

 class CylindricalDifferential(BaseDifferential):
     """Differential(s) of points in cylindrical coordinates.
diff --git a/docs/changes/coordinates/11470.feature.rst b/docs/changes/coordinates/11470.feature.rst
new file mode 100644
index 000000000000..51927b0b0a47
--- /dev/null
+++ b/docs/changes/coordinates/11470.feature.rst
@@ -0,0 +1,7 @@
+Allow negation, multiplication and division also of representations that
+include a differential (e.g., ``SphericalRepresentation`` with a
+``SphericalCosLatDifferential``).  For all operations, the outcome is
+equivalent to transforming the representation and differential to cartesian,
+then operating on those, and transforming back to the original representation
+(except for ``UnitSphericalRepresentation``, which will return a
+``SphericalRepresentation`` if there is a scale change).

diff --git a/astropy/coordinates/tests/test_representation_arithmetic.py b/astropy/coordinates/tests/test_representation_arithmetic.py
index 1979dbbb9c30..18815959cd04 100644
--- a/astropy/coordinates/tests/test_representation_arithmetic.py
+++ b/astropy/coordinates/tests/test_representation_arithmetic.py
@@ -1,18 +1,20 @@
 # Licensed under a 3-clause BSD style license - see LICENSE.rst

 import functools
+import operator

 import pytest
 import numpy as np

 from astropy import units as u
-from astropy.coordinates import (PhysicsSphericalRepresentation, CartesianRepresentation,
-                CylindricalRepresentation, SphericalRepresentation,
-                UnitSphericalRepresentation, SphericalDifferential,
-                CartesianDifferential, UnitSphericalDifferential,
-                SphericalCosLatDifferential, UnitSphericalCosLatDifferential,
-                PhysicsSphericalDifferential, CylindricalDifferential,
-                RadialRepresentation, RadialDifferential, Longitude, Latitude)
+from astropy.coordinates import (
+    PhysicsSphericalRepresentation, CartesianRepresentation,
+    CylindricalRepresentation, SphericalRepresentation,
+    UnitSphericalRepresentation, SphericalDifferential,
+    CartesianDifferential, UnitSphericalDifferential,
+    SphericalCosLatDifferential, UnitSphericalCosLatDifferential,
+    PhysicsSphericalDifferential, CylindricalDifferential,
+    RadialRepresentation, RadialDifferential, Longitude, Latitude)
 from astropy.coordinates.representation import DIFFERENTIAL_CLASSES
 from astropy.coordinates.angle_utilities import angular_separation
 from astropy.tests.helper import assert_quantity_allclose, quantity_allclose
@@ -150,7 +152,8 @@ def test_mul_div(self, representation):
         assert_quantity_allclose(r2.norm(),
                                  self.distance * np.array([[1.], [2.]]))
         r3 = -in_rep
-        assert np.all(representation_equal(r3, in_rep * -1.))
+        assert_representation_allclose(r3.to_cartesian(),
+                                       (in_rep * -1.).to_cartesian(), atol=1e-5*u.pc)
         with pytest.raises(TypeError):
             in_rep * in_rep
         with pytest.raises(TypeError):
@@ -669,16 +672,16 @@ def test_differential_init_errors(self, omit_coslat):
             self.SD_cls(1.*u.arcsec, 0., 0.)
         with pytest.raises(TypeError):
             self.SD_cls(1.*u.arcsec, 0.*u.arcsec, 0.*u.kpc,
-                                  False, False)
+                        False, False)
         with pytest.raises(TypeError):
             self.SD_cls(1.*u.arcsec, 0.*u.arcsec, 0.*u.kpc,
-                            copy=False, d_lat=0.*u.arcsec)
+                        copy=False, d_lat=0.*u.arcsec)
         with pytest.raises(TypeError):
             self.SD_cls(1.*u.arcsec, 0.*u.arcsec, 0.*u.kpc,
-                            copy=False, flying='circus')
+                        copy=False, flying='circus')
         with pytest.raises(ValueError):
             self.SD_cls(np.ones(2)*u.arcsec,
-                            np.zeros(3)*u.arcsec, np.zeros(2)*u.kpc)
+                        np.zeros(3)*u.arcsec, np.zeros(2)*u.kpc)
         with pytest.raises(u.UnitsError):
             self.SD_cls(1.*u.arcsec, 1.*u.s, 0.*u.kpc)
         with pytest.raises(u.UnitsError):
@@ -1190,6 +1193,127 @@ def test_combinations(self, sd_cls):
         assert_representation_allclose(self.s + (uo+ro), self.s+so1)

+@pytest.mark.parametrize('op,args', [
+    (operator.neg, ()),
+    (operator.pos, ()),
+    (operator.mul, (-8.,)),
+    (operator.truediv, ([4., 8.]*u.s,))], scope='class')
+class TestArithmeticWithDifferentials:
+    def setup_class(self):
+        self.cr = CartesianRepresentation([1, 2, 3]*u.kpc)
+        self.cd = CartesianDifferential([.1, -.2, .3]*u.km/u.s)
+        self.c = self.cr.with_differentials(self.cd)
+
+    def test_operation_cartesian(self, op, args):
+        ncr = op(self.c, *args)
+        expected = (op(self.cr, *args)).with_differentials(op(self.cd, *args))
+        assert np.all(ncr == expected)
+
+    def test_operation_radial(self, op, args):
+        rep = self.c.represent_as(RadialRepresentation, {'s': RadialDifferential})
+        result = op(rep, *args)
+        expected_distance = op(self.cr.norm(), *args)
+        expected_rv = op((self.cr/self.cr.norm()).dot(self.cd), *args)
+        assert u.allclose(result.distance, expected_distance)
+        assert u.allclose(result.differentials['s'].d_distance, expected_rv)
+
+    @pytest.mark.parametrize('diff_cls', [
+        SphericalDifferential,
+        SphericalCosLatDifferential,
+        PhysicsSphericalDifferential,
+        CylindricalDifferential])
+    def test_operation_other(self, diff_cls, op, args):
+        rep_cls = diff_cls.base_representation
+        rep = self.c.represent_as(rep_cls, {'s': diff_cls})
+        result = op(rep, *args)
+
+        expected_c = op(self.c, *args)
+        expected = expected_c.represent_as(rep_cls, {'s': diff_cls})
+        # Check that we match in the representation itself.
+        assert_representation_allclose(result, expected)
+        assert_differential_allclose(result.differentials['s'],
+                                     expected.differentials['s'])
+
+        # Check that we compare correctly in cartesian as well, just to be sure.
+        result_c = result.represent_as(CartesianRepresentation,
+                                       {'s': CartesianDifferential})
+        assert_representation_allclose(result_c, expected_c)
+        assert_differential_allclose(result_c.differentials['s'],
+                                     expected_c.differentials['s'])
+
+    @pytest.mark.parametrize('rep_cls', [
+        SphericalRepresentation,
+        PhysicsSphericalRepresentation,
+        CylindricalRepresentation])
+    def test_operation_cartesian_differential(self, rep_cls, op, args):
+        rep = self.c.represent_as(rep_cls, {'s': CartesianDifferential})
+        result = op(rep, *args)
+
+        expected_c = op(self.c, *args)
+        expected = expected_c.represent_as(rep_cls, {'s': CartesianDifferential})
+        # Check that we match in the representation itself.
+        assert_representation_allclose(result, expected)
+        assert_differential_allclose(result.differentials['s'],
+                                     expected.differentials['s'])
+
+    @pytest.mark.parametrize('diff_cls', [
+        UnitSphericalDifferential,
+        UnitSphericalCosLatDifferential])
+    def test_operation_unit_spherical(self, diff_cls, op, args):
+        rep_cls = diff_cls.base_representation
+        rep = self.c.represent_as(rep_cls, {'s': diff_cls})
+        result = op(rep, *args)
+
+        if op not in (operator.neg, operator.pos):
+            expected_cls = rep._dimensional_representation
+        else:
+            expected_cls = rep_cls
+
+        assert type(result) is expected_cls
+        assert type(result.differentials['s']) is diff_cls
+
+        # Have lost information, so unlike above we convert our initial
+        # unit-spherical back to Cartesian, and check that applying
+        # the operation on that cartesian representation gives the same result.
+        # We do not compare the output directly, since for multiplication
+        # and division there will be sign flips in the spherical distance.
+        expected_c = op(rep.represent_as(CartesianRepresentation,
+                                         {'s': CartesianDifferential}), *args)
+        result_c = result.represent_as(CartesianRepresentation,
+                                       {'s': CartesianDifferential})
+        assert_representation_allclose(result_c, expected_c)
+        assert_differential_allclose(result_c.differentials['s'],
+                                     expected_c.differentials['s'])
+
+    @pytest.mark.parametrize('diff_cls', [
+        RadialDifferential,
+        UnitSphericalDifferential,
+        UnitSphericalCosLatDifferential])
+    def test_operation_spherical_with_rv_or_pm(self, diff_cls, op, args):
+        rep = self.c.represent_as(SphericalRepresentation, {'s': diff_cls})
+        result = op(rep, *args)
+        assert type(result) is SphericalRepresentation
+        assert type(result.differentials['s']) is diff_cls
+
+        expected_c = op(rep.represent_as(CartesianRepresentation,
+                                         {'s': CartesianDifferential}), *args)
+        result_c = result.represent_as(CartesianRepresentation,
+                                       {'s': CartesianDifferential})
+        assert_representation_allclose(result_c, expected_c)
+        assert_differential_allclose(result_c.differentials['s'],
+                                     expected_c.differentials['s'])
+
+
+@pytest.mark.parametrize('op,args', [
+    (operator.neg, ()),
+    (operator.mul, (10.,))])
+def test_operation_unitspherical_with_rv_fails(op, args):
+    rep = UnitSphericalRepresentation(
+        0*u.deg, 0*u.deg, differentials={'s': RadialDifferential(10*u.km/u.s)})
+    with pytest.raises(ValueError, match='unit key'):
+        op(rep, *args)
+
+
 @pytest.mark.parametrize('rep,dif', [
     [CartesianRepresentation([1, 2, 3]*u.kpc),
      CartesianDifferential([.1, .2, .3]*u.km/u.s)],
@@ -1211,12 +1335,3 @@ def test_arithmetic_with_differentials_fail(rep, dif):

     with pytest.raises(TypeError):
         rep / rep
-
-    with pytest.raises(TypeError):
-        10. * rep
-
-    with pytest.raises(TypeError):
-        rep / 10.
-
-    with pytest.raises(TypeError):
-        -rep

diff --git a/docs/changes/coordinates/#####.feature.rst b/docs/changes/coordinates/#####.feature.rst
new file mode 100644
index 0000000000..51927b0b0a
--- /dev/null
+++ b/docs/changes/coordinates/#####.feature.rst
@@ -0,0 +1,7 @@
+Allow negation, multiplication and division also of representations that
+include a differential (e.g., ``SphericalRepresentation`` with a
+``SphericalCosLatDifferential``).  For all operations, the outcome is
+equivalent to transforming the representation and differential to cartesian,
+then operating on those, and transforming back to the original representation
+(except for ``UnitSphericalRepresentation``, which will return a
+``SphericalRepresentation`` if there is a scale change).

diff --git a/docs/changes/coordinates/11470.feature.rst b/docs/changes/coordinates/11470.feature.rst
new file mode 100644
index 000000000000..51927b0b0a47
--- /dev/null
+++ b/docs/changes/coordinates/11470.feature.rst
@@ -0,0 +1,7 @@
+Allow negation, multiplication and division also of representations that
+include a differential (e.g., ``SphericalRepresentation`` with a
+``SphericalCosLatDifferential``).  For all operations, the outcome is
+equivalent to transforming the representation and differential to cartesian,
+then operating on those, and transforming back to the original representation
+(except for ``UnitSphericalRepresentation``, which will return a
+``SphericalRepresentation`` if there is a scale change).

If completing this task requires creating new files, classes, fields, or error messages, you may consider using the following suggested entity names:
[{'type': 'field', 'name': 'pos'}]


diff --git a/astropy/io/fits/connect.py b/astropy/io/fits/connect.py
index ddeadde4f1f2..c792aff28205 100644
--- a/astropy/io/fits/connect.py
+++ b/astropy/io/fits/connect.py
@@ -112,7 +112,7 @@ def _decode_mixins(tbl):

 def read_table_fits(input, hdu=None, astropy_native=False, memmap=False,
-                    character_as_bytes=True):
+                    character_as_bytes=True, unit_parse_strict='warn'):
     """
     Read a Table object from an FITS file

@@ -152,6 +152,12 @@ def read_table_fits(input, hdu=None, astropy_native=False, memmap=False,
         ``U``) internally, you should set this to `False`, but note that this
         will use more memory. If set to `False`, string columns will not be
         memory-mapped even if ``memmap`` is `True`.
+    unit_parse_strict: str, optional
+        Behaviour when encountering invalid column units in the FITS header.
+        Default is "silent", which will create a
+        :class:`~astropy.units.core.UnrecognizedUnit`.
+        Values are the ones allowed by the ``parse_strict`` argument of
+        :class:`~astropy.units.core.Unit`.
     """

     if isinstance(input, HDUList):
@@ -211,8 +217,11 @@ def read_table_fits(input, hdu=None, astropy_native=False, memmap=False,
                             memmap=memmap)

         try:
-            return read_table_fits(hdulist, hdu=hdu,
-                                   astropy_native=astropy_native)
+            return read_table_fits(
+                hdulist, hdu=hdu,
+                astropy_native=astropy_native,
+                unit_parse_strict=unit_parse_strict,
+            )
         finally:
             hdulist.close()

@@ -249,7 +258,7 @@ def read_table_fits(input, hdu=None, astropy_native=False, memmap=False,

         # Copy over units
         if col.unit is not None:
-            column.unit = u.Unit(col.unit, format='fits', parse_strict='silent')
+            column.unit = u.Unit(col.unit, format='fits', parse_strict=unit_parse_strict)

         # Copy over display format
         if col.disp is not None:
diff --git a/docs/changes/io.fits/11843.feature.rst b/docs/changes/io.fits/11843.feature.rst
new file mode 100644
index 000000000000..5f84c63ac195
--- /dev/null
+++ b/docs/changes/io.fits/11843.feature.rst
@@ -0,0 +1,4 @@
+Add option ``unit_parse_strict`` to `~astropy.io.fits.connect.read_table_fits`
+to enable warnings or errors about invalid FITS units when using `~astropy.table.Table.read`.
+The default for this new option is ``"warn"``, which means warnings are now raised for
+columns with invalid units.
diff --git a/docs/timeseries/index.rst b/docs/timeseries/index.rst
index e9c8c5a769bb..34fa7a5674fe 100644
--- a/docs/timeseries/index.rst
+++ b/docs/timeseries/index.rst
@@ -49,7 +49,7 @@ source::
 We can then use the |TimeSeries| class to read in this file::

     >>> from astropy.timeseries import TimeSeries
-    >>> ts = TimeSeries.read(filename, format='kepler.fits')  # doctest: +REMOTE_DATA
+    >>> ts = TimeSeries.read(filename, format='kepler.fits')  # doctest: +REMOTE_DATA +IGNORE_WARNINGS

 Time series are specialized kinds of |Table| objects::

diff --git a/astropy/io/fits/tests/test_connect.py b/astropy/io/fits/tests/test_connect.py
index b2396bc4f398..0090c7abbb6a 100644
--- a/astropy/io/fits/tests/test_connect.py
+++ b/astropy/io/fits/tests/test_connect.py
@@ -234,7 +234,8 @@ def test_read_with_nonstandard_units(self):
         hdu.columns[0].unit = 'RADIANS'
         hdu.columns[1].unit = 'spam'
         hdu.columns[2].unit = 'millieggs'
-        t = Table.read(hdu)
+        with pytest.warns(u.UnitsWarning, match="did not parse as fits unit"):
+            t = Table.read(hdu)
         assert equal_data(t, self.data)

     @pytest.mark.parametrize('table_type', (Table, QTable))
@@ -657,7 +658,8 @@ def test_unit_warnings_read_write(tmpdir):
         t1.write(filename, overwrite=True)
     assert len(w) == 1

-    Table.read(filename, hdu=1)
+    with pytest.warns(u.UnitsWarning, match="'not-a-unit' did not parse as fits unit") as w:
+        Table.read(filename, hdu=1)

 def test_convert_comment_convention(tmpdir):
diff --git a/astropy/io/fits/tests/test_table.py b/astropy/io/fits/tests/test_table.py
index b1a055bc4fa5..3150258a28aa 100644
--- a/astropy/io/fits/tests/test_table.py
+++ b/astropy/io/fits/tests/test_table.py
@@ -18,7 +18,7 @@

 from astropy.io import fits
 from astropy.table import Table
-from astropy.units import UnitsWarning
+from astropy.units import UnitsWarning, Unit, UnrecognizedUnit
 from astropy.utils.exceptions import AstropyDeprecationWarning, AstropyUserWarning

 from astropy.io.fits.column import Delayed, NUMPY2FITS
@@ -3515,3 +3515,31 @@ def test_invalid_file(tmp_path):
     hdu.writeto(testfile, output_verify='ignore')
     with fits.open(testfile) as hdul:
         assert hdul[1].data is not None
+
+
+def test_unit_parse_strict(tmp_path):
+    path = tmp_path / 'invalid_unit.fits'
+
+    # this is a unit parseable by the generic format but invalid for FITS
+    invalid_unit = '1 / (MeV sr s)'
+    unit = Unit(invalid_unit)
+
+    t = Table({'a': [1, 2, 3]})
+    t.write(path)
+    with fits.open(path, mode='update') as hdul:
+        hdul[1].header['TUNIT1'] = invalid_unit
+
+    # default is "warn"
+    with pytest.warns(UnitsWarning):
+        t = Table.read(path)
+
+    assert isinstance(t['a'].unit, UnrecognizedUnit)
+
+    t = Table.read(path, unit_parse_strict='silent')
+    assert isinstance(t['a'].unit, UnrecognizedUnit)
+
+    with pytest.raises(ValueError):
+        Table.read(path, unit_parse_strict='raise')
+
+    with pytest.warns(UnitsWarning):
+        Table.read(path, unit_parse_strict='warn')
diff --git a/astropy/table/tests/test_showtable.py b/astropy/table/tests/test_showtable.py
index 928062dd983d..83fb579ebfb2 100644
--- a/astropy/table/tests/test_showtable.py
+++ b/astropy/table/tests/test_showtable.py
@@ -1,5 +1,6 @@
 import os
 import re
+import pytest

 from astropy.table.scripts import showtable

@@ -53,8 +54,13 @@ def test_fits(capsys):

 def test_fits_hdu(capsys):
-    showtable.main([os.path.join(FITS_ROOT, 'data/zerowidth.fits'),
-                    '--hdu', 'AIPS OF'])
+    from astropy.units import UnitsWarning
+    with pytest.warns(UnitsWarning):
+        showtable.main([
+            os.path.join(FITS_ROOT, 'data/zerowidth.fits'),
+            '--hdu', 'AIPS OF',
+        ])
+
     out, err = capsys.readouterr()
     assert out.startswith(
         '   TIME    SOURCE ID ANTENNA NO. SUBARRAY FREQ ID ANT FLAG STATUS 1\n'
diff --git a/astropy/timeseries/io/tests/test_kepler.py b/astropy/timeseries/io/tests/test_kepler.py
index 214a5046da22..b5dd7c945744 100644
--- a/astropy/timeseries/io/tests/test_kepler.py
+++ b/astropy/timeseries/io/tests/test_kepler.py
@@ -68,8 +68,13 @@ def test_raise_timesys_tess(mock_file):

 @pytest.mark.remote_data(source='astropy')
 def test_kepler_astropy():
+    from astropy.units import UnitsWarning
+
     filename = get_pkg_data_filename('timeseries/kplr010666592-2009131110544_slc.fits')
-    timeseries = kepler_fits_reader(filename)
+
+    with pytest.warns(UnitsWarning):
+        timeseries = kepler_fits_reader(filename)
+
     assert timeseries["time"].format == 'isot'
     assert timeseries["time"].scale == 'tdb'
     assert timeseries["sap_flux"].unit.to_string() == 'electron / s'
diff --git a/astropy/timeseries/tests/test_sampled.py b/astropy/timeseries/tests/test_sampled.py
index 68f8715256e5..bcc06d929ee6 100644
--- a/astropy/timeseries/tests/test_sampled.py
+++ b/astropy/timeseries/tests/test_sampled.py
@@ -323,8 +323,13 @@ def test_read():

 @pytest.mark.remote_data(source='astropy')
 def test_kepler_astropy():
+    from astropy.units import UnitsWarning
+
     filename = get_pkg_data_filename('timeseries/kplr010666592-2009131110544_slc.fits')
-    timeseries = TimeSeries.read(filename, format='kepler.fits')
+
+    with pytest.warns(UnitsWarning):
+        timeseries = TimeSeries.read(filename, format='kepler.fits')
+
     assert timeseries["time"].format == 'isot'
     assert timeseries["time"].scale == 'tdb'
     assert timeseries["sap_flux"].unit.to_string() == 'electron / s'

diff --git a/docs/changes/io.fits/#####.feature.rst b/docs/changes/io.fits/#####.feature.rst
new file mode 100644
index 0000000000..5f84c63ac1
--- /dev/null
+++ b/docs/changes/io.fits/#####.feature.rst
@@ -0,0 +1,4 @@
+Add option ``unit_parse_strict`` to `~astropy.io.fits.connect.read_table_fits`
+to enable warnings or errors about invalid FITS units when using `~astropy.table.Table.read`.
+The default for this new option is ``"warn"``, which means warnings are now raised for
+columns with invalid units.

diff --git a/docs/timeseries/index.rst b/docs/timeseries/index.rst
index e9c8c5a769..34fa7a5674 100644
--- a/docs/timeseries/index.rst
+++ b/docs/timeseries/index.rst
@@ -49,7 +49,7 @@ source::
 We can then use the |TimeSeries| class to read in this file::

     >>> from astropy.timeseries import TimeSeries
-    >>> ts = TimeSeries.read(filename, format='kepler.fits')  # doctest: +REMOTE_DATA
+    >>> ts = TimeSeries.read(filename, format='kepler.fits')  # doctest: +REMOTE_DATA +IGNORE_WARNINGS

 Time series are specialized kinds of |Table| objects::

If completing this task requires creating new files, classes, fields, or error messages, you may consider using the following suggested entity names:


diff --git a/astropy/cosmology/flrw.py b/astropy/cosmology/flrw.py
index 0ca7b1300cd6..7479bfa01375 100644
--- a/astropy/cosmology/flrw.py
+++ b/astropy/cosmology/flrw.py
@@ -285,6 +285,11 @@ def Ob0(self, param, value):
     # ---------------------------------------------------------------
     # properties

+    @property
+    def Otot0(self):
+        """Omega total; the total density/critical density at z=0."""
+        return self._Om0 + self._Ogamma0 + self._Onu0 + self._Ode0 + self._Ok0
+
     @property
     def Odm0(self):
         """Omega dark matter; dark matter density/critical density at z=0."""
@@ -365,6 +370,22 @@ def w(self, z):
         """
         raise NotImplementedError("w(z) is not implemented")

+    def Otot(self, z):
+        """The total density parameter at redshift ``z``.
+
+        Parameters
+        ----------
+        z : Quantity-like ['redshift'], array-like, or `~numbers.Number`
+            Input redshifts.
+
+        Returns
+        -------
+        Otot : ndarray or float
+            The total density relative to the critical density at each redshift.
+            Returns float if input scalar.
+        """
+        return self.Om(z) + self.Ogamma(z) + self.Onu(z) + self.Ode(z) + self.Ok(z)
+
     def Om(self, z):
         """
         Return the density parameter for non-relativistic matter
@@ -1433,6 +1454,26 @@ def __init__(self, *args, **kw):
         self._Ode0 = 1.0 - self._Om0 - self._Ogamma0 - self._Onu0
         self._Ok0 = 0.0

+    @property
+    def Otot0(self):
+        """Omega total; the total density/critical density at z=0."""
+        return 1.0
+
+    def Otot(self, z):
+        """The total density parameter at redshift ``z``.
+
+        Parameters
+        ----------
+        z : Quantity-like ['redshift'], array-like, or `~numbers.Number`
+            Input redshifts.
+
+        Returns
+        -------
+        Otot : ndarray or float
+            Returns float if input scalar. Value of 1.
+        """
+        return 1.0 if isinstance(z, (Number, np.generic)) else np.ones_like(z, subok=False)
+
     def __equiv__(self, other):
         """flat-FLRW equivalence. Use ``.is_equivalent()`` for actual check!

diff --git a/docs/changes/cosmology/12590.feature.rst b/docs/changes/cosmology/12590.feature.rst
new file mode 100644
index 000000000000..41520e99612a
--- /dev/null
+++ b/docs/changes/cosmology/12590.feature.rst
@@ -0,0 +1,2 @@
+Add methods ``Otot`` and ``Otot0`` to FLRW cosmologies to calculate the total
+energy density of the Universe.

diff --git a/astropy/cosmology/tests/test_cosmology.py b/astropy/cosmology/tests/test_cosmology.py
index 4945368417f9..7e4f40a3f026 100644
--- a/astropy/cosmology/tests/test_cosmology.py
+++ b/astropy/cosmology/tests/test_cosmology.py
@@ -15,26 +15,6 @@
 from astropy.utils.exceptions import AstropyDeprecationWarning, AstropyUserWarning

-def test_basic():
-    cosmo = flrw.FlatLambdaCDM(H0=70, Om0=0.27, Tcmb0=2.0, Neff=3.04,
-                               Ob0=0.05, name="test", meta={"a": "b"})
-    # This next test will fail if astropy.const starts returning non-mks
-    #  units by default; see the comment at the top of core.py
-    assert allclose(cosmo.Om0 + cosmo.Ode0 + cosmo.Ogamma0 + cosmo.Onu0,
-                    1.0, rtol=1e-6)
-    assert allclose(cosmo.Om(1) + cosmo.Ode(1) + cosmo.Ogamma(1) +
-                    cosmo.Onu(1), 1.0, rtol=1e-6)
-
-    # Make sure setting them as quantities gives the same results
-    H0 = u.Quantity(70, u.km / (u.s * u.Mpc))
-    T = u.Quantity(2.0, u.K)
-    cosmo = flrw.FlatLambdaCDM(H0=H0, Om0=0.27, Tcmb0=T, Neff=3.04, Ob0=0.05)
-    assert allclose(cosmo.Om0 + cosmo.Ode0 + cosmo.Ogamma0 + cosmo.Onu0,
-                    1.0, rtol=1e-6)
-    assert allclose(cosmo.Om(1) + cosmo.Ode(1) + cosmo.Ogamma(1) +
-                    cosmo.Onu(1), 1.0, rtol=1e-6)
-
-
 @pytest.mark.skipif('not HAS_SCIPY')
 def test_units():
     """ Test if the right units are being returned"""
diff --git a/astropy/cosmology/tests/test_flrw.py b/astropy/cosmology/tests/test_flrw.py
index 0244d59fd699..6086092d3b3a 100644
--- a/astropy/cosmology/tests/test_flrw.py
+++ b/astropy/cosmology/tests/test_flrw.py
@@ -463,6 +463,7 @@ def test_init(self, cosmo_cls):
         # TODO! transfer tests for initializing neutrinos

     # ---------------------------------------------------------------
+    # Properties

     def test_Odm0(self, cosmo_cls, cosmo):
         """Test property ``Odm0``."""
@@ -593,6 +594,32 @@ def test_Onu0(self, cosmo_cls, cosmo):
             # and check compatibility with nu_relative_density
             assert np.allclose(cosmo.nu_relative_density(0), 0.22710731766 * cosmo._Neff)

+    def test_Otot0(self, cosmo):
+        """Test :attr:`astropy.cosmology.FLRW.Otot0`."""
+        assert cosmo.Otot0 == cosmo.Om0 + cosmo.Ogamma0 + cosmo.Onu0 + cosmo.Ode0 + cosmo.Ok0
+
+    # ---------------------------------------------------------------
+    # Methods
+
+    def test_Otot(self, cosmo):
+        """Test :meth:`astropy.cosmology.FLRW.Otot`."""
+        exception = NotImplementedError if HAS_SCIPY else ModuleNotFoundError
+        with pytest.raises(exception):
+            assert cosmo.Otot(1)
+
+    def test_efunc_vs_invefunc(self, cosmo):
+        """
+        Test that efunc and inv_efunc give inverse values.
+        Here they just fail b/c no ``w(z)`` or no scipy.
+        """
+        exception = NotImplementedError if HAS_SCIPY else ModuleNotFoundError
+
+        with pytest.raises(exception):
+            cosmo.efunc(0.5)
+
+        with pytest.raises(exception):
+            cosmo.inv_efunc(0.5)
+
     # ---------------------------------------------------------------
     # from Cosmology

@@ -653,21 +680,6 @@ def test_is_equivalent(self, cosmo):
             assert not cosmo.is_equivalent(Planck18)
             assert not Planck18.is_equivalent(cosmo)

-    # ---------------------------------------------------------------
-
-    def test_efunc_vs_invefunc(self, cosmo):
-        """
-        Test that efunc and inv_efunc give inverse values.
-        Here they just fail b/c no ``w(z)`` or no scipy.
-        """
-        exception = NotImplementedError if HAS_SCIPY else ModuleNotFoundError
-
-        with pytest.raises(exception):
-            cosmo.efunc(0.5)
-
-        with pytest.raises(exception):
-            cosmo.inv_efunc(0.5)
-

 class FLRWSubclassTest(TestFLRW):
     """
@@ -682,6 +694,20 @@ def setup_class(self):
         pass

     # ===============================================================
+    # Method & Attribute Tests
+
+    # ---------------------------------------------------------------
+    # Densities
+
+    @pytest.mark.skipif(HAS_SCIPY, reason="scipy is not installed")
+    def test_Otot(self, cosmo):
+        """Test :meth:`astropy.cosmology.FLRW.Otot`."""
+        z = np.arange(0, 1e4, int(1e3))
+        assert np.allclose(
+            cosmo.Otot(z),
+            cosmo.Om(z) + cosmo.Ogamma(z) + cosmo.Onu(z) + cosmo.Ode(z) + cosmo.Ok(z))
+
+    # ---------------------------------------------------------------

     def test_efunc_vs_invefunc(self, cosmo):
         """Test that ``efunc`` and ``inv_efunc`` give inverse values.
@@ -766,6 +792,27 @@ def test_Ok0(self, cosmo_cls, cosmo):
         # for flat cosmologies, Ok0 is not *close* to 0, it *is* 0
         assert cosmo.Ok0 == 0.0

+    def test_Otot0(self, cosmo):
+        """Test :attr:`astropy.cosmology.FLRW.Otot0`. Should always be 1."""
+        super().test_Otot0(cosmo)
+
+        assert cosmo.Otot0 == 1.0
+
+    @pytest.mark.skipif(HAS_SCIPY, reason="scipy is not installed")
+    def test_Otot(self, cosmo):
+        """Test :meth:`astropy.cosmology.FLRW.Otot`. Should always be 1."""
+        super().test_Otot(cosmo)
+
+        # scalar
+        assert cosmo.Otot(1049.5) == 1.0
+        assert cosmo.Otot(np.float64(1345.3)) == 1.0
+
+        # array
+        z = np.arange(0, 1e4, int(1e3))
+        assert np.allclose(cosmo.Otot(z), np.ones_like(z, subok=False))
+
+    # ---------------------------------------------------------------
+
     def test_is_equivalent(self, cosmo, nonflatcosmo):
         """Test :meth:`astropy.cosmology.FLRW.is_equivalent`."""
         super().test_is_equivalent(cosmo)  # pass to TestFLRW
diff --git a/astropy/cosmology/tests/test_funcs.py b/astropy/cosmology/tests/test_funcs.py
index 96080ee4653b..470688d456d0 100644
--- a/astropy/cosmology/tests/test_funcs.py
+++ b/astropy/cosmology/tests/test_funcs.py
@@ -228,7 +228,7 @@ def test_z_at_value_roundtrip(cosmo):
     # special handling
     # clone is not a redshift-dependent method
     # nu_relative_density is not redshift-dependent in the WMAP cosmologies
-    skip = ('Ok',
+    skip = ('Ok', 'Otot',
             'angular_diameter_distance_z1z2',
             'clone', 'is_equivalent',
             'de_density_scale', 'w')

diff --git a/docs/changes/cosmology/#####.feature.rst b/docs/changes/cosmology/#####.feature.rst
new file mode 100644
index 0000000000..41520e9961
--- /dev/null
+++ b/docs/changes/cosmology/#####.feature.rst
@@ -0,0 +1,2 @@
+Add methods ``Otot`` and ``Otot0`` to FLRW cosmologies to calculate the total
+energy density of the Universe.

diff --git a/docs/changes/cosmology/12590.feature.rst b/docs/changes/cosmology/12590.feature.rst
new file mode 100644
index 000000000000..41520e99612a
--- /dev/null
+++ b/docs/changes/cosmology/12590.feature.rst
@@ -0,0 +1,2 @@
+Add methods ``Otot`` and ``Otot0`` to FLRW cosmologies to calculate the total
+energy density of the Universe.

If completing this task requires creating new files, classes, fields, or error messages, you may consider using the following suggested entity names:
[{'type': 'field', 'name': 'Ode'}, {'type': 'field', 'name': 'Onu'}, {'type': 'field', 'name': 'ones_like'}, {'type': 'field', 'name': 'Om'}, {'type': 'field', 'name': 'Ok'}, {'type': 'field', 'name': 'subok'}]


diff --git a/astropy/cosmology/io/row.py b/astropy/cosmology/io/row.py
new file mode 100644
index 000000000000..1581e050b09d
--- /dev/null
+++ b/astropy/cosmology/io/row.py
@@ -0,0 +1,139 @@
+# Licensed under a 3-clause BSD style license - see LICENSE.rst
+
+import copy
+
+import numpy as np
+
+from astropy.table import Row
+from astropy.cosmology.connect import convert_registry
+from astropy.cosmology.core import Cosmology
+
+from .mapping import from_mapping
+
+
+def from_row(row, *, move_to_meta=False, cosmology=None):
+    """Instantiate a `~astropy.cosmology.Cosmology` from a `~astropy.table.Row`.
+
+    Parameters
+    ----------
+    row : `~astropy.table.Row`
+        The object containing the Cosmology information.
+    move_to_meta : bool (optional, keyword-only)
+        Whether to move keyword arguments that are not in the Cosmology class'
+        signature to the Cosmology's metadata. This will only be applied if the
+        Cosmology does NOT have a keyword-only argument (e.g. ``**kwargs``).
+        Arguments moved to the metadata will be merged with existing metadata,
+        preferring specified metadata in the case of a merge conflict
+        (e.g. for ``Cosmology(meta={'key':10}, key=42)``, the ``Cosmology.meta``
+        will be ``{'key': 10}``).
+
+    cosmology : str, `~astropy.cosmology.Cosmology` class, or None (optional, keyword-only)
+        The cosmology class (or string name thereof) to use when constructing
+        the cosmology instance. The class also provides default parameter values,
+        filling in any non-mandatory arguments missing in 'table'.
+
+    Returns
+    -------
+    `~astropy.cosmology.Cosmology` subclass instance
+
+    Examples
+    --------
+    To see loading a `~astropy.cosmology.Cosmology` from a Row with
+    ``from_row``, we will first make a `~astropy.table.Row` using
+    :func:`~astropy.cosmology.Cosmology.to_format`.
+
+        >>> from astropy.cosmology import Cosmology, Planck18
+        >>> cr = Planck18.to_format("astropy.row")
+        >>> cr
+        <Row index=0>
+          cosmology     name        H0        Om0    Tcmb0    Neff    m_nu [3]    Ob0
+                               km / (Mpc s)            K                 eV
+            str13       str8     float64    float64 float64 float64   float64   float64
+        ------------- -------- ------------ ------- ------- ------- ----------- -------
+        FlatLambdaCDM Planck18        67.66 0.30966  2.7255   3.046 0.0 .. 0.06 0.04897
+
+    Now this row can be used to load a new cosmological instance identical
+    to the ``Planck18`` cosmology from which it was generated.
+
+        >>> cosmo = Cosmology.from_format(cr, format="astropy.row")
+        >>> cosmo
+        FlatLambdaCDM(name="Planck18", H0=67.7 km / (Mpc s), Om0=0.31,
+                      Tcmb0=2.725 K, Neff=3.05, m_nu=[0. 0. 0.06] eV, Ob0=0.049)
+    """
+    # special values
+    name = row['name'] if 'name' in row.columns else None  # get name from column
+    meta = copy.deepcopy(row.meta)
+
+    # turn row into mapping, filling cosmo if not in a column
+    mapping = dict(row)
+    mapping["name"] = name
+    mapping.setdefault("cosmology", meta.pop("cosmology", None))
+    mapping["meta"] = meta
+
+    # build cosmology from map
+    return from_mapping(mapping, move_to_meta=move_to_meta, cosmology=cosmology)
+
+
+def to_row(cosmology, *args, cosmology_in_meta=False):
+    """Serialize the cosmology into a `~astropy.table.Row`.
+
+    Parameters
+    ----------
+    cosmology : `~astropy.cosmology.Cosmology` subclass instance
+    *args
+        Not used. Needed for compatibility with
+        `~astropy.io.registry.UnifiedReadWriteMethod`
+    cosmology_in_meta : bool
+        Whether to put the cosmology class in the Table metadata (if `True`) or
+        as the first column (if `False`, default).
+
+    Returns
+    -------
+    `~astropy.table.Row`
+        With columns for the cosmology parameters, and metadata in the Table's
+        ``meta`` attribute. The cosmology class name will either be a column
+        or in ``meta``, depending on 'cosmology_in_meta'.
+
+    Examples
+    --------
+    A Cosmology as a `~astropy.table.Row` will have the cosmology's name and
+    parameters as columns.
+
+        >>> from astropy.cosmology import Planck18
+        >>> cr = Planck18.to_format("astropy.row")
+        >>> cr
+        <Row index=0>
+          cosmology     name        H0        Om0    Tcmb0    Neff    m_nu [3]    Ob0
+                               km / (Mpc s)            K                 eV
+            str13       str8     float64    float64 float64 float64   float64   float64
+        ------------- -------- ------------ ------- ------- ------- ----------- -------
+        FlatLambdaCDM Planck18        67.66 0.30966  2.7255   3.046 0.0 .. 0.06 0.04897
+
+    The cosmological class and other metadata, e.g. a paper reference, are in
+    the Table's metadata.
+    """
+    from .table import to_table
+
+    table = to_table(cosmology, cosmology_in_meta=cosmology_in_meta)
+    return table[0]  # extract row from table
+
+
+def row_identify(origin, format, *args, **kwargs):
+    """Identify if object uses the `~astropy.table.Row` format.
+
+    Returns
+    -------
+    bool
+    """
+    itis = False
+    if origin == "read":
+        itis = isinstance(args[1], Row) and (format in (None, "astropy.row"))
+    return itis
+
+
+# ===================================================================
+# Register
+
+convert_registry.register_reader("astropy.row", Cosmology, from_row)
+convert_registry.register_writer("astropy.row", Cosmology, to_row)
+convert_registry.register_identifier("astropy.row", Cosmology, row_identify)
diff --git a/astropy/cosmology/io/table.py b/astropy/cosmology/io/table.py
index 7c7490d8cbfa..9adbb4d438bc 100644
--- a/astropy/cosmology/io/table.py
+++ b/astropy/cosmology/io/table.py
@@ -8,7 +8,8 @@
 from astropy.cosmology.core import Cosmology
 from astropy.table import QTable, Table

-from .mapping import from_mapping, to_mapping
+from .mapping import to_mapping
+from .row import from_row

 def from_table(table, index=None, *, move_to_meta=False, cosmology=None):
@@ -136,18 +137,7 @@ def from_table(table, index=None, *, move_to_meta=False, cosmology=None):
     # ------------------
     # parse row to cosmo

-    # special values
-    name = row['name'] if 'name' in row.columns else None  # get name from column
-    meta = copy.deepcopy(row.meta)
-
-    # turn row into mapping, filling cosmo if not in a column
-    mapping = dict(row)
-    mapping["name"] = name
-    mapping.setdefault("cosmology", meta.pop("cosmology", None))
-    mapping["meta"] = meta
-
-    # build cosmology from map
-    return from_mapping(mapping, move_to_meta=move_to_meta, cosmology=cosmology)
+    return from_row(row, move_to_meta=move_to_meta, cosmology=cosmology)

 def to_table(cosmology, *args, cls=QTable, cosmology_in_meta=True):
diff --git a/docs/changes/cosmology/12313.feature.rst b/docs/changes/cosmology/12313.feature.rst
new file mode 100644
index 000000000000..fa7179584dd6
--- /dev/null
+++ b/docs/changes/cosmology/12313.feature.rst
@@ -0,0 +1,2 @@
+Register "astropy.row" into Cosmology's to/from format I/O, allowing a
+Cosmology instance to be parse from or converted to an Astropy Table Row.
diff --git a/docs/cosmology/io.rst b/docs/cosmology/io.rst
index 4dc1353ade82..4d3989e0b4d4 100644
--- a/docs/cosmology/io.rst
+++ b/docs/cosmology/io.rst
@@ -102,6 +102,7 @@ To see the a list of the available conversion formats:
         Format    Read Write Auto-identify
     ------------- ---- ----- -------------
     astropy.model  Yes   Yes           Yes
+      astropy.row  Yes   Yes           Yes
     astropy.table  Yes   Yes           Yes
           mapping  Yes   Yes           Yes
         mypackage  Yes   Yes           Yes
@@ -249,6 +250,7 @@ class to use. Details about metadata treatment are in
     ...     # turn row into mapping (dict of the arguments)
     ...     mapping = dict(row)
     ...     mapping['name'] = name
+    ...     mapping.setdefault("cosmology", meta.pop("cosmology", None))
     ...     mapping["meta"] = meta
     ...     # build cosmology from map
     ...     return Cosmology.from_format(mapping, move_to_meta=move_to_meta,
@@ -286,12 +288,13 @@ register everything into `astropy.io.registry`.
     >>> def row_identify(origin, format, *args, **kwargs):
     ...     """Identify if object uses the Table format."""
     ...     if origin == "read":
-    ...         return isinstance(args[1], Row) and (format in (None, "row"))
+    ...         return isinstance(args[1], Row) and (format in (None, "astropy.row"))
     ...     return False

-    >>> convert_registry.register_reader("row", Cosmology, from_table_row)
-    >>> convert_registry.register_writer("row", Cosmology, to_table_row)
-    >>> convert_registry.register_identifier("row", Cosmology, row_identify)
+    >>> # These exact functions are already registered in astropy
+    >>> # convert_registry.register_reader("astropy.row", Cosmology, from_table_row)
+    >>> # convert_registry.register_writer("astropy.row", Cosmology, to_table_row)
+    >>> # convert_registry.register_identifier("astropy.row", Cosmology, row_identify)

 Now the registered functions can be used in
 :meth:`astropy.cosmology.Cosmology.from_format` and
@@ -300,7 +303,7 @@ Now the registered functions can be used in
 .. code-block:: python

     >>> from astropy.cosmology import Planck18
-    >>> row = Planck18.to_format("row")
+    >>> row = Planck18.to_format("astropy.row")
     >>> row
     <Row index=0>
       cosmology     name        H0        Om0    Tcmb0    Neff    m_nu [3]    Ob0
@@ -313,19 +316,6 @@ Now the registered functions can be used in
     >>> cosmo == Planck18  # test it round-trips
     True

-
-.. doctest::
-    :hide:
-
-    >>> from astropy.io.registry import IORegistryError
-    >>> convert_registry.unregister_reader("row", Cosmology)
-    >>> convert_registry.unregister_writer("row", Cosmology)
-    >>> convert_registry.unregister_identifier("row", Cosmology)
-    >>> try:
-    ...     convert_registry.get_reader("row", Cosmology)
-    ... except IORegistryError:
-    ...     pass
-
 .. EXAMPLE END

diff --git a/astropy/cosmology/io/tests/test_row.py b/astropy/cosmology/io/tests/test_row.py
new file mode 100644
index 000000000000..9ad1b7f151ce
--- /dev/null
+++ b/astropy/cosmology/io/tests/test_row.py
@@ -0,0 +1,117 @@
+# Licensed under a 3-clause BSD style license - see LICENSE.rst
+
+# THIRD PARTY
+import pytest
+
+# LOCAL
+import astropy.units as u
+from astropy import cosmology
+from astropy.cosmology import Cosmology, realizations, Planck18
+from astropy.cosmology.core import _COSMOLOGY_CLASSES, Parameter
+from astropy.cosmology.io.row import from_row, to_row
+from astropy.table import Row
+from astropy.cosmology.parameters import available
+
+from .base import IOTestMixinBase, IOFormatTestBase
+
+cosmo_instances = [getattr(realizations, name) for name in available]
+cosmo_instances.append("TestToFromRow.setup.<locals>.CosmologyWithKwargs")
+
+###############################################################################
+
+
+class ToFromRowTestMixin(IOTestMixinBase):
+    """
+    Tests for a Cosmology[To/From]Format with ``format="astropy.row"``.
+    This class will not be directly called by :mod:`pytest` since its name does
+    not begin with ``Test``. To activate the contained tests this class must
+    be inherited in a subclass. Subclasses must define a :func:`pytest.fixture`
+    ``cosmo`` that returns/yields an instance of a |Cosmology|.
+    See ``TestCosmologyToFromFormat`` or ``TestCosmology`` for examples.
+    """
+
+    @pytest.mark.parametrize("in_meta", [True, False])
+    def test_to_row_in_meta(self, cosmo, in_meta):
+        """Test where the cosmology class is placed."""
+        row = cosmo.to_format('astropy.row', cosmology_in_meta=in_meta)
+
+        # if it's in metadata, it's not a column. And vice versa.
+        if in_meta:
+            assert row.meta["cosmology"] == cosmo.__class__.__qualname__
+            assert "cosmology" not in row.colnames  # not also a column
+        else:
+            assert row["cosmology"] == cosmo.__class__.__qualname__
+            assert "cosmology" not in row.meta
+
+    # -----------------------
+
+    def test_tofrom_row_instance(self, cosmo, to_format, from_format):
+        """Test cosmology -> astropy.row -> cosmology."""
+        # ------------
+        # To Row
+
+        row = to_format("astropy.row")
+        assert isinstance(row, Row)
+        assert row["cosmology"] == cosmo.__class__.__qualname__
+        assert row["name"] == cosmo.name
+
+        # ------------
+        # From Row
+
+        row.table["mismatching"] = "will error"
+
+        # tests are different if the last argument is a **kwarg
+        if tuple(cosmo._init_signature.parameters.values())[-1].kind == 4:
+            got = from_format(row, format="astropy.row")
+
+            assert got.__class__ is cosmo.__class__
+            assert got.name == cosmo.name
+            assert "mismatching" not in got.meta
+
+            return  # don't continue testing
+
+        # read with mismatching parameters errors
+        with pytest.raises(TypeError, match="there are unused parameters"):
+            from_format(row, format="astropy.row")
+
+        # unless mismatched are moved to meta
+        got = from_format(row, format="astropy.row", move_to_meta=True)
+        assert got == cosmo
+        assert got.meta["mismatching"] == "will error"
+
+        # it won't error if everything matches up
+        row.table.remove_column("mismatching")
+        got = from_format(row, format="astropy.row")
+        assert got == cosmo
+
+        # and it will also work if the cosmology is a class
+        # Note this is not the default output of ``to_format``.
+        cosmology = _COSMOLOGY_CLASSES[row["cosmology"]]
+        row.table.remove_column("cosmology")
+        row.table["cosmology"] = cosmology
+        got = from_format(row, format="astropy.row")
+        assert got == cosmo
+
+        # also it auto-identifies 'format'
+        got = from_format(row)
+        assert got == cosmo
+
+    def test_fromformat_row_subclass_partial_info(self, cosmo):
+        """
+        Test writing from an instance and reading from that class.
+        This works with missing information.
+        """
+        pass  # there are no partial info options
+
+
+class TestToFromTable(IOFormatTestBase, ToFromRowTestMixin):
+    """
+    Directly test ``to/from_row``.
+    These are not public API and are discouraged from use, in favor of
+    ``Cosmology.to/from_format(..., format="astropy.row")``, but should be
+    tested regardless b/c 3rd party packages might use these in their Cosmology
+    I/O. Also, it's cheap to test.
+    """
+
+    def setup_class(self):
+        self.functions = {"to": to_row, "from": from_row}
diff --git a/astropy/cosmology/tests/test_connect.py b/astropy/cosmology/tests/test_connect.py
index 5407d8dea55d..ff9f78ee0d86 100644
--- a/astropy/cosmology/tests/test_connect.py
+++ b/astropy/cosmology/tests/test_connect.py
@@ -10,7 +10,7 @@
 from astropy.cosmology import Cosmology, w0wzCDM
 from astropy.cosmology.connect import CosmologyRead, readwrite_registry
 from astropy.cosmology.core import Cosmology
-from astropy.cosmology.io.tests import test_ecsv, test_mapping, test_model, test_table
+from astropy.cosmology.io.tests import test_ecsv, test_mapping, test_model, test_row, test_table
 from astropy.table import QTable

 from .conftest import json_identify, read_json, write_json
@@ -205,7 +205,7 @@ def test_readwrite_reader_class_mismatch(self, cosmo, tmpdir, format):
 class ToFromFormatTestMixin(
     # convert
     test_mapping.ToFromMappingTestMixin, test_model.ToFromModelTestMixin,
-    test_table.ToFromTableTestMixin,
+    test_row.ToFromRowTestMixin, test_table.ToFromTableTestMixin,
     # read/write
     test_ecsv.ReadWriteECSVTestMixin,
 ):

diff --git a/docs/changes/cosmology/#####.feature.rst b/docs/changes/cosmology/#####.feature.rst
new file mode 100644
index 0000000000..fa7179584d
--- /dev/null
+++ b/docs/changes/cosmology/#####.feature.rst
@@ -0,0 +1,2 @@
+Register "astropy.row" into Cosmology's to/from format I/O, allowing a
+Cosmology instance to be parse from or converted to an Astropy Table Row.

diff --git a/docs/cosmology/io.rst b/docs/cosmology/io.rst
index 4dc1353ade..4d3989e0b4 100644
--- a/docs/cosmology/io.rst
+++ b/docs/cosmology/io.rst
@@ -102,6 +102,7 @@ To see the a list of the available conversion formats:
         Format    Read Write Auto-identify
     ------------- ---- ----- -------------
     astropy.model  Yes   Yes           Yes
+      astropy.row  Yes   Yes           Yes
     astropy.table  Yes   Yes           Yes
           mapping  Yes   Yes           Yes
         mypackage  Yes   Yes           Yes
@@ -249,6 +250,7 @@ class to use. Details about metadata treatment are in
     ...     # turn row into mapping (dict of the arguments)
     ...     mapping = dict(row)
     ...     mapping['name'] = name
+    ...     mapping.setdefault("cosmology", meta.pop("cosmology", None))
     ...     mapping["meta"] = meta
     ...     # build cosmology from map
     ...     return Cosmology.from_format(mapping, move_to_meta=move_to_meta,
@@ -286,12 +288,13 @@ register everything into `astropy.io.registry`.
     >>> def row_identify(origin, format, *args, **kwargs):
     ...     """Identify if object uses the Table format."""
     ...     if origin == "read":
-    ...         return isinstance(args[1], Row) and (format in (None, "row"))
+    ...         return isinstance(args[1], Row) and (format in (None, "astropy.row"))
     ...     return False

-    >>> convert_registry.register_reader("row", Cosmology, from_table_row)
-    >>> convert_registry.register_writer("row", Cosmology, to_table_row)
-    >>> convert_registry.register_identifier("row", Cosmology, row_identify)
+    >>> # These exact functions are already registered in astropy
+    >>> # convert_registry.register_reader("astropy.row", Cosmology, from_table_row)
+    >>> # convert_registry.register_writer("astropy.row", Cosmology, to_table_row)
+    >>> # convert_registry.register_identifier("astropy.row", Cosmology, row_identify)

 Now the registered functions can be used in
 :meth:`astropy.cosmology.Cosmology.from_format` and
@@ -300,7 +303,7 @@ Now the registered functions can be used in
 .. code-block:: python

     >>> from astropy.cosmology import Planck18
-    >>> row = Planck18.to_format("row")
+    >>> row = Planck18.to_format("astropy.row")
     >>> row
     <Row index=0>
       cosmology     name        H0        Om0    Tcmb0    Neff    m_nu [3]    Ob0
@@ -313,19 +316,6 @@ Now the registered functions can be used in
     >>> cosmo == Planck18  # test it round-trips
     True

-
-.. doctest::
-    :hide:
-
-    >>> from astropy.io.registry import IORegistryError
-    >>> convert_registry.unregister_reader("row", Cosmology)
-    >>> convert_registry.unregister_writer("row", Cosmology)
-    >>> convert_registry.unregister_identifier("row", Cosmology)
-    >>> try:
-    ...     convert_registry.get_reader("row", Cosmology)
-    ... except IORegistryError:
-    ...     pass
-
 .. EXAMPLE END

diff --git a/docs/changes/cosmology/12313.feature.rst b/docs/changes/cosmology/12313.feature.rst
new file mode 100644
index 000000000000..fa7179584dd6
--- /dev/null
+++ b/docs/changes/cosmology/12313.feature.rst
@@ -0,0 +1,2 @@
+Register "astropy.row" into Cosmology's to/from format I/O, allowing a
+Cosmology instance to be parse from or converted to an Astropy Table Row.

diff --git a/docs/cosmology/io.rst b/docs/cosmology/io.rst
index 4dc1353ade82..4d3989e0b4d4 100644
--- a/docs/cosmology/io.rst
+++ b/docs/cosmology/io.rst
@@ -102,6 +102,7 @@ To see the a list of the available conversion formats:
         Format    Read Write Auto-identify
     ------------- ---- ----- -------------
     astropy.model  Yes   Yes           Yes
+      astropy.row  Yes   Yes           Yes
     astropy.table  Yes   Yes           Yes
           mapping  Yes   Yes           Yes
         mypackage  Yes   Yes           Yes
@@ -249,6 +250,7 @@ class to use. Details about metadata treatment are in
     ...     # turn row into mapping (dict of the arguments)
     ...     mapping = dict(row)
     ...     mapping['name'] = name
+    ...     mapping.setdefault("cosmology", meta.pop("cosmology", None))
     ...     mapping["meta"] = meta
     ...     # build cosmology from map
     ...     return Cosmology.from_format(mapping, move_to_meta=move_to_meta,
@@ -286,12 +288,13 @@ register everything into `astropy.io.registry`.
     >>> def row_identify(origin, format, *args, **kwargs):
     ...     """Identify if object uses the Table format."""
     ...     if origin == "read":
-    ...         return isinstance(args[1], Row) and (format in (None, "row"))
+    ...         return isinstance(args[1], Row) and (format in (None, "astropy.row"))
     ...     return False

-    >>> convert_registry.register_reader("row", Cosmology, from_table_row)
-    >>> convert_registry.register_writer("row", Cosmology, to_table_row)
-    >>> convert_registry.register_identifier("row", Cosmology, row_identify)
+    >>> # These exact functions are already registered in astropy
+    >>> # convert_registry.register_reader("astropy.row", Cosmology, from_table_row)
+    >>> # convert_registry.register_writer("astropy.row", Cosmology, to_table_row)
+    >>> # convert_registry.register_identifier("astropy.row", Cosmology, row_identify)

 Now the registered functions can be used in
 :meth:`astropy.cosmology.Cosmology.from_format` and
@@ -300,7 +303,7 @@ Now the registered functions can be used in
 .. code-block:: python

     >>> from astropy.cosmology import Planck18
-    >>> row = Planck18.to_format("row")
+    >>> row = Planck18.to_format("astropy.row")
     >>> row
     <Row index=0>
       cosmology     name        H0        Om0    Tcmb0    Neff    m_nu [3]    Ob0
@@ -313,19 +316,6 @@ Now the registered functions can be used in
     >>> cosmo == Planck18  # test it round-trips
     True

-
-.. doctest::
-    :hide:
-
-    >>> from astropy.io.registry import IORegistryError
-    >>> convert_registry.unregister_reader("row", Cosmology)
-    >>> convert_registry.unregister_writer("row", Cosmology)
-    >>> convert_registry.unregister_identifier("row", Cosmology)
-    >>> try:
-    ...     convert_registry.get_reader("row", Cosmology)
-    ... except IORegistryError:
-    ...     pass
-
 .. EXAMPLE END

If completing this task requires creating new files, classes, fields, or error messages, you may consider using the following suggested entity names:
[{'type': 'field', 'name': 'to_row'}, {'type': 'file', 'name': 'astropy/cosmology/io/row.py'}, {'type': 'field', 'name': 'cosmology_in_meta'}, {'type': 'function', 'name': 'to_row'}, {'type': 'field', 'name': 'from_row'}, {'type': 'function', 'name': 'from_row'}, {'type': 'field', 'name': 'from_row'}, {'type': 'field', 'name': 'core'}]


diff --git a/astropy/cosmology/flrw.py b/astropy/cosmology/flrw.py
index a36c150ae34c..0ca7b1300cd6 100644
--- a/astropy/cosmology/flrw.py
+++ b/astropy/cosmology/flrw.py
@@ -1420,8 +1420,7 @@ class FlatFLRWMixin(FlatCosmologyMixin):
     but ``FlatLambdaCDM`` **will** be flat.
     """

-    Ode0 = Parameter(doc="Omega dark energy; dark energy density/critical density at z=0.",
-                     derived=True, fvalidate="float")  # no longer a Parameter
+    Ode0 = FLRW.Ode0.clone(derived=True)  # same as FLRW, but now a derived param.

     def __init_subclass__(cls):
         super().__init_subclass__()
diff --git a/astropy/cosmology/parameter.py b/astropy/cosmology/parameter.py
index 56475f3e3978..49a27ec4d77e 100644
--- a/astropy/cosmology/parameter.py
+++ b/astropy/cosmology/parameter.py
@@ -13,31 +13,31 @@ class Parameter:

     Parameters
     ----------
-    fvalidate : callable[[object, object, Any], Any] or str, optional
+    derived : bool (optional, keyword-only)
+        Whether the Parameter is 'derived', default `False`.
+        Derived parameters behave similarly to normal parameters, but are not
+        sorted by the |Cosmology| signature (probably not there) and are not
+        included in all methods. For reference, see ``Ode0`` in
+        ``FlatFLRWMixin``, which removes :math:`\Omega_{de,0}`` as an
+        independent parameter (:math:`\Omega_{de,0} \equiv 1 - \Omega_{tot}`).
+    unit : unit-like or None (optional, keyword-only)
+        The `~astropy.units.Unit` for the Parameter. If None (default) no
+        unit as assumed.
+    equivalencies : `~astropy.units.Equivalency` or sequence thereof
+        Unit equivalencies for this Parameter.
+    fvalidate : callable[[object, object, Any], Any] or str (optional, keyword-only)
         Function to validate the Parameter value from instances of the
         cosmology class. If "default", uses default validator to assign units
         (with equivalencies), if Parameter has units.
         For other valid string options, see ``Parameter._registry_validators``.
         'fvalidate' can also be set through a decorator with
         :meth:`~astropy.cosmology.Parameter.validator`.
-    doc : str or None, optional
-        Parameter description.
-    unit : unit-like or None (optional, keyword-only)
-        The `~astropy.units.Unit` for the Parameter. If None (default) no
-        unit as assumed.
-    equivalencies : `~astropy.units.Equivalency` or sequence thereof
-        Unit equivalencies for this Parameter.
     fmt : str (optional, keyword-only)
         `format` specification, used when making string representation
         of the containing Cosmology.
         See https://docs.python.org/3/library/string.html#formatspec
-    derived : bool (optional, keyword-only)
-        Whether the Parameter is 'derived', default `False`.
-        Derived parameters behave similarly to normal parameters, but are not
-        sorted by the |Cosmology| signature (probably not there) and are not
-        included in all methods. For reference, see ``Ode0`` in
-        ``FlatFLRWMixin``, which removes :math:`\Omega_{de,0}`` as an
-        independent parameter (:math:`\Omega_{de,0} \equiv 1 - \Omega_{tot}`).
+    doc : str or None (optional, keyword-only)
+        Parameter description.

     Examples
     --------
@@ -46,9 +46,24 @@ class Parameter:

     _registry_validators = {}

-    def __init__(self, fvalidate="default", doc=None, *,
-                 unit=None, equivalencies=[], fmt=".3g", derived=False):
-        # parse registered fvalidate
+    def __init__(self, *, derived=False, unit=None, equivalencies=[],
+                 fvalidate="default", fmt=".3g", doc=None):
+
+        # attribute name on container cosmology class.
+        # really set in __set_name__, but if Parameter is not init'ed as a
+        # descriptor this ensures that the attributes exist.
+        self._attr_name = self._attr_name_private = None
+
+        self._derived = derived
+        self._fmt = str(fmt)  # @property is `format_spec`
+        self.__doc__ = doc
+
+        # units stuff
+        self._unit = u.Unit(unit) if unit is not None else None
+        self._equivalencies = equivalencies
+
+        # Parse registered `fvalidate`
+        self._fvalidate_in = fvalidate  # Always store input fvalidate.
         if callable(fvalidate):
             pass
         elif fvalidate in self._registry_validators:
@@ -59,20 +74,10 @@ def __init__(self, fvalidate="default", doc=None, *,
         else:
             raise TypeError("`fvalidate` must be a function or "
                             f"{self._registry_validators.keys()}")
-
-        self.__doc__ = doc
         self._fvalidate = fvalidate

-        # units stuff
-        self._unit = u.Unit(unit) if unit is not None else None
-        self._equivalencies = equivalencies
-
-        # misc
-        self._fmt = str(fmt)
-        self._derived = derived
-
     def __set_name__(self, cosmo_cls, name):
-        # attribute name
+        # attribute name on container cosmology class
         self._attr_name = name
         self._attr_name_private = "_" + name

@@ -142,11 +147,7 @@ def validator(self, fvalidate):
         `~astropy.cosmology.Parameter`
             Copy of this Parameter but with custom ``fvalidate``.
         """
-        desc = type(self)(fvalidate=fvalidate,
-                          doc=self.__doc__, fmt=self.format_spec,
-                          unit=self.unit, equivalencies=self.equivalencies,
-                          derived=self.derived)
-        return desc
+        return self.clone(fvalidate=fvalidate)

     def validate(self, cosmology, value):
         """Run the validator on this Parameter.
@@ -210,8 +211,100 @@ def register(fvalidate):

     # -------------------------------------------

+    def _get_init_arguments(self, processed=False):
+        """Initialization arguments.
+
+        Parameters
+        ----------
+        interpreted : bool
+            Whether to more closely reproduce the input arguments (`False`,
+            default) or the processed arguments (`True`). The former is better
+            for string representations and round-tripping with ``eval(repr())``.
+
+        Returns
+        -------
+        dict[str, Any]
+        """
+        # The keys are added in this order because `repr` prints them in order.
+        kw = {"derived": self.derived,
+              "unit": self.unit,
+              "equivalencies": self.equivalencies,
+              # Validator is always turned into a function, but for ``repr`` it's nice
+              # to know if it was originally a string.
+              "fvalidate": self.fvalidate if processed else self._fvalidate_in,
+              "fmt": self.format_spec,
+              "doc": self.__doc__}
+        return kw
+
+    def clone(self, **kw):
+        """Clone this `Parameter`, changing any constructor argument.
+
+        Parameters
+        ----------
+        **kw
+            Passed to constructor. The current values, eg. ``fvalidate`` are
+            used as the default values, so an empty ``**kw`` is an exact copy.
+
+        Examples
+        --------
+        >>> p = Parameter()
+        >>> p
+        Parameter(derived=False, unit=None, equivalencies=[],
+                  fvalidate='default', fmt='.3g', doc=None)
+
+        >>> p.clone(unit="km")
+        Parameter(derived=False, unit=Unit("km"), equivalencies=[],
+                  fvalidate='default', fmt='.3g', doc=None)
+        """
+        # Start with defaults, update from kw.
+        kwargs = {**self._get_init_arguments(), **kw}
+        # All initialization failures, like incorrect input are handled by init
+        cloned = type(self)(**kwargs)
+        # Transfer over the __set_name__ stuff. If `clone` is used to make a
+        # new descriptor, __set_name__ will be called again, overwriting this.
+        cloned._attr_name = self._attr_name
+        cloned._attr_name_private = self._attr_name_private
+
+        return cloned
+
+    def __eq__(self, other):
+        """Check Parameter equality. Only equal to other Parameter objects.
+
+        Returns
+        -------
+        NotImplemented or True
+            `True` if equal, `NotImplemented` otherwise. This allows `other` to
+            be check for equality with ``other.__eq__``.
+
+        Examples
+        --------
+        >>> p1, p2 = Parameter(unit="km"), Parameter(unit="km")
+        >>> p1 == p2
+        True
+
+        >>> p3 = Parameter(unit="km / s")
+        >>> p3 == p1
+        False
+
+        >>> p1 != 2
+        True
+        """
+        if not isinstance(other, Parameter):
+            return NotImplemented
+        # Check equality on all `_init_arguments` & `name`.
+        # Need to compare the processed arguments because the inputs are many-
+        # to-one, e.g. `fvalidate` can be a string or the equivalent function.
+        return ((self._get_init_arguments(True) == other._get_init_arguments(True))
+                and (self.name == other.name))
+
     def __repr__(self):
-        return f"<Parameter {self._attr_name!r} at {hex(id(self))}>"
+        """String representation.
+
+        ``eval(repr())`` should work, depending if contents like ``fvalidate``
+        can be similarly round-tripped.
+        """
+        return "Parameter({})".format(", ".join(f"{k}={v!r}" for k, v in
+                                                self._get_init_arguments().items()))

 # ===================================================================
diff --git a/docs/changes/cosmology/12479.feature.rst b/docs/changes/cosmology/12479.feature.rst
new file mode 100644
index 000000000000..f48871bb0f1f
--- /dev/null
+++ b/docs/changes/cosmology/12479.feature.rst
@@ -0,0 +1,6 @@
+A method ``clone`` has been added to ``Parameter`` to quickly deep copy the
+object and change any constructor argument.
+A supporting equality method is added, and ``repr`` is enhanced to be able to
+roundtrip -- ``eval(repr(Parameter()))`` -- if the Parameter's arguments can
+similarly roundtrip.
+Parameter's arguments are made keyword-only.
diff --git a/docs/cosmology/dev.rst b/docs/cosmology/dev.rst
index 1014d727d3b3..6228691a94ce 100644
--- a/docs/cosmology/dev.rst
+++ b/docs/cosmology/dev.rst
@@ -140,6 +140,17 @@ The last thing to note is pretty formatting for the |Cosmology|. Each
 <https://docs.python.org/3/library/string.html#formatspec>`_ ".3g", but this
 may be overridden, like :attr:`~astropy.cosmology.FLRW.Tcmb0` does.

+If a new cosmology modifies an existing Parameter, then the
+`Parameter.clone() <astropy.cosmology.Parameter.clone>`_ method is useful
+to deep-copy the parameter and change any constructor argument. For example,
+see ``FlatFLRWMixin`` in ``astropy.cosmology.flrw`` (also shown below).
+
+.. code-block:: python
+
+    class FlatFLRWMixin(FlatCosmologyMixin):
+        ...
+
+        Ode0 = FLRW.Ode0.clone(derived=True)  # now a derived param.

 Mixins
 ------

diff --git a/astropy/cosmology/tests/test_parameter.py b/astropy/cosmology/tests/test_parameter.py
index 4660e95dae2d..f6a1bce59d4d 100644
--- a/astropy/cosmology/tests/test_parameter.py
+++ b/astropy/cosmology/tests/test_parameter.py
@@ -6,7 +6,9 @@
 # IMPORTS

 # STDLIB
+import ast
 import inspect
+import sys

 # THIRD PARTY
 import pytest
@@ -70,6 +72,7 @@ def test_Parameter_init(self):
         assert parameter.equivalencies == []
         assert parameter.format_spec == ".3g"
         assert parameter.derived is False
+        assert parameter.name is None

         # setting all kwargs
         parameter = Parameter(fvalidate="float", doc="DOCSTRING",
@@ -166,15 +169,6 @@ def test_Parameter_descriptor_set(self, cosmo, all_parameter):
     # validate value
     # tested later.

-    # -------------------------------------------
-
-    def test_Parameter_repr(self, cosmo_cls, all_parameter):
-        """Test Parameter repr."""
-        r = repr(getattr(cosmo_cls, all_parameter.name))
-
-        assert all_parameter._attr_name in r
-        assert hex(id(all_parameter)) in r
-
     # ===============================================================
     # Usage Tests

@@ -249,7 +243,7 @@ class TestParameter(ParameterTestMixin):

     def setup_class(self):
         class Example1(Cosmology):
-            param = Parameter(doc="example parameter",
+            param = Parameter(doc="Description of example parameter.",
                               unit=u.m, equivalencies=u.mass_energy())

             def __init__(self, param=15):
@@ -293,7 +287,7 @@ def test_Parameter_instance_attributes(self, param):
         super().test_Parameter_instance_attributes(param)

         # property
-        assert param.__doc__ == "example parameter"
+        assert param.__doc__ == "Description of example parameter."

         # custom from init
         assert param._unit == u.m
@@ -406,6 +400,67 @@ def func(cosmology, param, value):
         finally:
             param.__class__._registry_validators.pop("newvalidator", None)

+    # -------------------------------------------
+
+    def test_Parameter_clone(self, param):
+        """Test :meth:`astropy.cosmology.Parameter.clone`."""
+        # this implicitly relies on `__eq__` testing properly. Which is tested.
+
+        # basic test that nothing changes
+        assert param.clone() == param
+        assert param.clone() is not param  # but it's not a 'singleton'
+
+        # passing kwargs will change stuff
+        newparam = param.clone(unit="km/(yr sr)")
+        assert newparam.unit == u.km / u.yr / u.sr
+        assert param.unit != u.km / u.yr / u.sr  # original is unchanged
+
+        # expected failure for not-an-argument
+        with pytest.raises(TypeError):
+            param.clone(not_a_valid_parameter=True)
+
+    # -------------------------------------------
+
+    def test_Parameter_equality(self):
+        """
+        Test Parameter equality.
+        Determined from the processed initialization args (including defaults).
+        """
+        p1 = Parameter(unit="km / (s Mpc)")
+        p2 = Parameter(unit="km / (s Mpc)")
+        assert p1 == p2
+
+        # not equal parameters
+        p3 = Parameter(unit="km / s")
+        assert p3 != p1
+
+        # misc
+        assert p1 != 2  # show doesn't error
+
+    # -------------------------------------------
+
+    def test_Parameter_repr(self, cosmo_cls, param):
+        """Test Parameter repr."""
+        r = repr(param)
+
+        assert "Parameter(" in r
+        for subs in ("derived=False", 'unit=Unit("m")', 'equivalencies=[(Unit("kg"), Unit("J")',
+                     "fmt='.3g'", "doc='Description of example parameter.'"):
+            assert subs in r, subs
+
+        # `fvalidate` is a little tricker b/c one of them is custom!
+        if param.fvalidate in param._registry_validators.values():  # not custom
+            assert "fvalidate='default'" in r
+        else:
+            assert "fvalidate=<" in r  # Some function, don't care about details.
+
+    def test_Parameter_repr_roundtrip(self, param):
+        """Test ``eval(repr(Parameter))`` can round trip to ``Parameter``."""
+        P = Parameter(doc="A description of this parameter.", derived=True)
+        NP = eval(repr(P))  # Evaluate string representation back into a param.
+
+        assert P == NP
+
     # ==============================================================

     def test_Parameter_doesnt_change_with_generic_class(self):

diff --git a/docs/changes/cosmology/#####.feature.rst b/docs/changes/cosmology/#####.feature.rst
new file mode 100644
index 0000000000..f48871bb0f
--- /dev/null
+++ b/docs/changes/cosmology/#####.feature.rst
@@ -0,0 +1,6 @@
+A method ``clone`` has been added to ``Parameter`` to quickly deep copy the
+object and change any constructor argument.
+A supporting equality method is added, and ``repr`` is enhanced to be able to
+roundtrip -- ``eval(repr(Parameter()))`` -- if the Parameter's arguments can
+similarly roundtrip.
+Parameter's arguments are made keyword-only.

diff --git a/docs/cosmology/dev.rst b/docs/cosmology/dev.rst
index 1014d727d3..6228691a94 100644
--- a/docs/cosmology/dev.rst
+++ b/docs/cosmology/dev.rst
@@ -140,6 +140,17 @@ The last thing to note is pretty formatting for the |Cosmology|. Each
 <https://docs.python.org/3/library/string.html#formatspec>`_ ".3g", but this
 may be overridden, like :attr:`~astropy.cosmology.FLRW.Tcmb0` does.

+If a new cosmology modifies an existing Parameter, then the
+`Parameter.clone() <astropy.cosmology.Parameter.clone>`_ method is useful
+to deep-copy the parameter and change any constructor argument. For example,
+see ``FlatFLRWMixin`` in ``astropy.cosmology.flrw`` (also shown below).
+
+.. code-block:: python
+
+    class FlatFLRWMixin(FlatCosmologyMixin):
+        ...
+
+        Ode0 = FLRW.Ode0.clone(derived=True)  # now a derived param.

 Mixins
 ------