diff --git a/CHANGES.rst b/CHANGES.rst
index d7fe12596a00..40b457f2cbca 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -237,6 +237,10 @@ astropy.utils
   to return the smallest array that can be broadcasted back to the initial
   array. [#9209]

+- The specific IERS Earth rotation parameter table used for time and
+  coordinate transformations can now be set, either in a context or per
+  session, using ``astropy.utils.iers.earth_rotation_table``. [#9244]
+
 astropy.visualization
 ^^^^^^^^^^^^^^^^^^^^^

diff --git a/astropy/coordinates/builtin_frames/utils.py b/astropy/coordinates/builtin_frames/utils.py
index 1ab33cceab6c..153b42cf92c6 100644
--- a/astropy/coordinates/builtin_frames/utils.py
+++ b/astropy/coordinates/builtin_frames/utils.py
@@ -38,15 +38,16 @@ def get_polar_motion(time):
     gets the two polar motion components in radians for use with apio13
     """
     # Get the polar motion from the IERS table
-    xp, yp, status = iers.IERS_Auto.open().pm_xy(time, return_status=True)
+    iers_table = iers.earth_orientation_table.get()
+    xp, yp, status = iers_table.pm_xy(time, return_status=True)

     wmsg = None
     if np.any(status == iers.TIME_BEFORE_IERS_RANGE):
         wmsg = ('Tried to get polar motions for times before IERS data is '
                 'valid. Defaulting to polar motion from the 50-yr mean for those. '
                 'This may affect precision at the 10s of arcsec level')
-        xp.ravel()[status.ravel() == iers.TIME_BEFORE_IERS_RANGE] = _DEFAULT_PM[0]
-        yp.ravel()[status.ravel() == iers.TIME_BEFORE_IERS_RANGE] = _DEFAULT_PM[1]
+        xp[status == iers.TIME_BEFORE_IERS_RANGE] = _DEFAULT_PM[0]
+        yp[status == iers.TIME_BEFORE_IERS_RANGE] = _DEFAULT_PM[1]

         warnings.warn(wmsg, AstropyWarning)

@@ -55,8 +56,8 @@ def get_polar_motion(time):
                 'valid. Defaulting to polar motion from the 50-yr mean for those. '
                 'This may affect precision at the 10s of arcsec level')

-        xp.ravel()[status.ravel() == iers.TIME_BEYOND_IERS_RANGE] = _DEFAULT_PM[0]
-        yp.ravel()[status.ravel() == iers.TIME_BEYOND_IERS_RANGE] = _DEFAULT_PM[1]
+        xp[status == iers.TIME_BEYOND_IERS_RANGE] = _DEFAULT_PM[0]
+        yp[status == iers.TIME_BEYOND_IERS_RANGE] = _DEFAULT_PM[1]

         warnings.warn(wmsg, AstropyWarning)

diff --git a/astropy/time/core.py b/astropy/time/core.py
index 9cf38aa522ee..7e6a1e8e3f94 100644
--- a/astropy/time/core.py
+++ b/astropy/time/core.py
@@ -1654,8 +1654,9 @@ def get_delta_ut1_utc(self, iers_table=None, return_status=False):
         ----------
         iers_table : `~astropy.utils.iers.IERS` table, optional
             Table containing UT1-UTC differences from IERS Bulletins A
-            and/or B.  If `None`, use default combined version (see
-            `astropy.utils.iers.IERS_Auto`)
+            and/or B.  Default: `~astropy.utils.iers.earth_orientation_table`
+            (which in turn defaults to the combined version provided by
+            `~astropy.utils.iers.IERS_Auto`).
         return_status : bool
             Whether to return status values.  If `False` (default), iers
             raises `IndexError` if any time is out of the range
@@ -1689,8 +1690,8 @@ def get_delta_ut1_utc(self, iers_table=None, return_status=False):
             array([ True, False]...)
         """
         if iers_table is None:
-            from astropy.utils.iers import IERS_Auto
-            iers_table = IERS_Auto.open()
+            from astropy.utils.iers import earth_orientation_table
+            iers_table = earth_orientation_table.get()

         return iers_table.ut1_utc(self.utc, return_status=return_status)

@@ -1705,8 +1706,8 @@ def _get_delta_ut1_utc(self, jd1=None, jd2=None):
         # Sec. 4.3.1: the arg DUT is the quantity delta_UT1 = UT1 - UTC in
         # seconds. It is obtained from tables published by the IERS.
         if not hasattr(self, '_delta_ut1_utc'):
-            from astropy.utils.iers import IERS_Auto
-            iers_table = IERS_Auto.open()
+            from astropy.utils.iers import earth_orientation_table
+            iers_table = earth_orientation_table.get()
             # jd1, jd2 are normally set (see above), except if delta_ut1_utc
             # is access directly; ensure we behave as expected for that case
             if jd1 is None:
diff --git a/astropy/utils/iers/iers.py b/astropy/utils/iers/iers.py
index 4b76132e6106..c3f6dccd0afc 100644
--- a/astropy/utils/iers/iers.py
+++ b/astropy/utils/iers/iers.py
@@ -22,11 +22,12 @@
 from astropy import units as u
 from astropy.table import QTable, MaskedColumn
 from astropy.utils.data import get_pkg_data_filename, clear_download_cache
+from astropy.utils.state import ScienceState
 from astropy.utils.compat import NUMPY_LT_1_17
 from astropy import utils
 from astropy.utils.exceptions import AstropyWarning

-__all__ = ['Conf', 'conf',
+__all__ = ['Conf', 'conf', 'earth_orientation_table',
            'IERS', 'IERS_B', 'IERS_A', 'IERS_Auto',
            'FROM_IERS_B', 'FROM_IERS_A', 'FROM_IERS_A_PREDICTION',
            'TIME_BEFORE_IERS_RANGE', 'TIME_BEYOND_IERS_RANGE',
@@ -802,3 +803,47 @@ def _substitute_iers_b(cls, table):
             table['dY_2000A_B'][:n_iers_b] = iers_b['dY_2000A']

         return table
+
+
+class earth_orientation_table(ScienceState):
+    """Default IERS table for Earth rotation and reference systems service.
+
+    These tables are used to calculate the offsets between ``UT1`` and ``UTC``
+    and for conversion to Earth-based coordinate systems.
+
+    The state itself is an IERS table, as an instance of one of the
+    `~astropy.utils.iers.IERS` classes.  The default, the auto-updating
+    `~astropy.utils.iers.IERS_Auto` class, should suffice for most
+    purposes.
+
+    Examples
+    --------
+    To temporarily use the IERS-B file packaged with astropy::
+
+      >>> from astropy.utils import iers
+      >>> from astropy.time import Time
+      >>> iers_b = iers.IERS_B.open(iers.IERS_B_FILE)
+      >>> with iers.earth_orientation_table.set(iers_b):
+      ...     print(Time('2000-01-01').ut1.isot)
+      2000-01-01T00:00:00.355
+
+    To use the most recent IERS-A file for the whole session::
+
+      >>> iers_a = iers.IERS_A.open(iers.IERS_A_URL)  # doctest: +SKIP
+      >>> iers.earth_orientation_table.set(iers_a)  # doctest: +SKIP
+      <ScienceState earth_orientation_table: <IERS_A length=17463>...>
+
+    To go back to the default (of `~astropy.utils.iers.IERS_Auto`)::
+
+      >>> iers.earth_orientation_table.set(None)  # doctest: +SKIP
+      <ScienceState earth_orientation_table: <IERS_Auto length=17428>...>
+    """
+    _value = None
+
+    @classmethod
+    def validate(cls, value):
+        if value is None:
+            value = IERS_Auto.open()
+        if not isinstance(value, IERS):
+            raise ValueError("earth_orientation_table requires an IERS Table.")
+        return value
diff --git a/astropy/utils/state.py b/astropy/utils/state.py
index 262b41dd2773..050ad9a3b9e5 100644
--- a/astropy/utils/state.py
+++ b/astropy/utils/state.py
@@ -56,8 +56,13 @@ def __exit__(self, type, value, tb):
                 self._parent._value = self._value

             def __repr__(self):
-                return ('<ScienceState {}: {!r}>'
-                        .format(self._parent.__name__, self._parent._value))
+                # Ensure we have a single-line repr, just in case our
+                # value is not something simple like a string.
+                value_repr, lb, _ = repr(self._parent._value).partition('\n')
+                if lb:
+                    value_repr += '...'
+                return ('<ScienceState {}: {}>'
+                        .format(self._parent.__name__, value_repr))

         ctx = _Context(cls, cls._value)
         value = cls.validate(value)
diff --git a/docs/utils/iers.rst b/docs/utils/iers.rst
index 7fe000ef7d9d..726cee3ce0f5 100644
--- a/docs/utils/iers.rst
+++ b/docs/utils/iers.rst
@@ -29,7 +29,7 @@ and how this can be controlled.
 Basic usage
 -----------

-The IERS data are managed via a instances of the
+By default, the IERS data are managed via instances of the
 :class:`~astropy.utils.iers.IERS_Auto` class.  These instances are created
 internally within the relevant time and coordinate objects during
 transformations.  If the astropy data cache does not have the required IERS
@@ -47,7 +47,7 @@ machine.  Here is an example that shows the typical download progress bar::
 Note that you can forcibly clear the download cache as follows::

   >>> from astropy.utils.data import clear_download_cache
-  >>> clear_download_cache()
+  >>> clear_download_cache()  # doctest: +SKIP

 The default IERS data used automatically is updated by the service every 7 days
 and includes transforms dating back to 1973-01-01.
@@ -146,34 +146,44 @@ Direct table access
 -------------------

 In most cases the automatic interface will suffice, but you may need to
-directly load and manipulate IERS tables.  IERS-B values are provided as
-part of astropy and can be used for transformations.  For example::
+directly load and manipulate IERS tables.  IERS-B values are provided as part
+of astropy and can be used to calculate time offsets and polar motion
+directly, or set up for internal use in further time and coordinate
+transformations.  For example::

   >>> from astropy.utils import iers
   >>> t = Time('2010:001')
   >>> iers_b = iers.IERS_B.open()
   >>> iers_b.ut1_utc(t)  # doctest: +FLOAT_CMP
   <Quantity 0.1140827 s>
-  >>> t.delta_ut1_utc = iers_b.ut1_utc(t)
+  >>> iers.earth_orientation_table.set(iers_b)
+  <ScienceState earth_orientation_table: <IERS_B length=...>...>
   >>> t.ut1.iso
   '2010-01-01 00:00:00.114'

 Instead of local copies of IERS files, one can also download them, using
-``iers.IERS_A_URL`` (or ``iers.IERS_A_URL_MIRROR``) and ``iers.IERS_B_URL``::
+``iers.IERS_A_URL`` (or ``iers.IERS_A_URL_MIRROR``) and ``iers.IERS_B_URL``,
+and then use those for future time and coordinate transformations (in this
+example, just for a single calculation, by using
+`~astropy.utils.iers.earth_orientation_table` as a context manager)::

   >>> iers_a = iers.IERS_A.open(iers.IERS_A_URL)  # doctest: +SKIP
+  >>> with iers.earth_orientation_table.set(iers_a):  # doctest: +SKIP
+  ...     print(t.ut1.iso)
+  2010-01-01 00:00:00.114

-For coordinate transformations that require IERS polar motion values,
-setting the values manually can be done as follows (where one could also
-select IERS_B)::
+To reset to the default, pass in `None` (which is equivalent to passing in
+``iers.IERS_Auto.open()``)::

-  >>> iers.conf.auto_download = False
-  >>> iers.IERS.iers_table = iers.IERS_A.open(iers.IERS_A_URL)  # doctest: +SKIP
+  >>> iers.earth_orientation_table.set(None)  # doctest: +REMOTE_DATA
+  <ScienceState earth_orientation_table: <IERS_Auto length=...>...>

 To see the internal IERS data that gets used in astropy you can do the
 following::

-  >>> dat = iers.IERS_Auto.open()  # doctest: +SKIP
+  >>> dat = iers.earth_orientation_table.get()  # doctest: +REMOTE_DATA
+  >>> type(dat)  # doctest: +REMOTE_DATA
+  <class 'astropy.utils.iers.iers.IERS_Auto'>
   >>> dat  # doctest: +SKIP
   <IERS_Auto length=16196>
    year month  day    MJD   PolPMFlag_A ... UT1Flag    PM_x     PM_y   PolPMFlag

diff --git a/astropy/coordinates/tests/test_iau_fullstack.py b/astropy/coordinates/tests/test_iau_fullstack.py
index cd788834326b..996800b55b24 100644
--- a/astropy/coordinates/tests/test_iau_fullstack.py
+++ b/astropy/coordinates/tests/test_iau_fullstack.py
@@ -117,7 +117,8 @@ def test_iau_fullstack(fullstack_icrs, fullstack_fiducial_altaz,
     assert np.all(addecs < tol), 'largest Dec change is {} mas, > {}'.format(np.max(addecs.arcsec*1000), tol)

     # check that we're consistent with the ERFA alt/az result
-    xp, yp = u.Quantity(iers.IERS_Auto.open().pm_xy(fullstack_times)).to_value(u.radian)
+    iers_tab = iers.earth_orientation_table.get()
+    xp, yp = u.Quantity(iers_tab.pm_xy(fullstack_times)).to_value(u.radian)
     lon = fullstack_locations.geodetic[0].to_value(u.radian)
     lat = fullstack_locations.geodetic[1].to_value(u.radian)
     height = fullstack_locations.geodetic[2].to_value(u.m)
@@ -178,7 +179,7 @@ def test_future_altaz():
     # table does not raise an exception.  Only if using IERS_B (which happens without
     # --remote-data, i.e. for all CI testing) do we expect another warning.
     messages_to_find = ["Tried to get polar motions for times after IERS data is valid."]
-    if isinstance(iers.IERS_Auto.iers_table, iers.IERS_B):
+    if isinstance(iers.earth_orientation_table.get(), iers.IERS_B):
         messages_to_find.append("(some) times are outside of range covered by IERS table.")

     messages_found = [False for _ in messages_to_find]
diff --git a/astropy/coordinates/tests/test_intermediate_transformations.py b/astropy/coordinates/tests/test_intermediate_transformations.py
index d80a7c271a3f..35dea9cdd587 100644
--- a/astropy/coordinates/tests/test_intermediate_transformations.py
+++ b/astropy/coordinates/tests/test_intermediate_transformations.py
@@ -7,12 +7,14 @@
 import numpy as np

 from astropy import units as u
-from astropy.tests.helper import (assert_quantity_allclose as assert_allclose)
+from astropy.tests.helper import (assert_quantity_allclose as assert_allclose,
+                                  catch_warnings)
 from astropy.time import Time
 from astropy.coordinates import (EarthLocation, get_sun, ICRS, GCRS, CIRS, ITRS, AltAz,
                 PrecessedGeocentric, CartesianRepresentation, SkyCoord,
                 SphericalRepresentation, UnitSphericalRepresentation,
                 HCRS, HeliocentricMeanEcliptic)
+from astropy.utils import iers

 from astropy._erfa import epv00
@@ -490,6 +492,37 @@ def test_gcrs_self_transform_closeby():
     assert_allclose(delta, 0.0*u.m, atol=1*u.m)

+@pytest.mark.remote_data
+def test_earth_orientation_table():
+    """Check that we can set the IERS table used as Earth Reference.
+
+    Use the here and now to be sure we get a difference.
+    """
+    t = Time.now()
+    location = EarthLocation(lat=0*u.deg, lon=0*u.deg)
+    altaz = AltAz(location=location, obstime=t)
+    sc = SkyCoord(1*u.deg, 2*u.deg)
+    # Default: uses IERS_Auto, which will give a prediction.
+    with catch_warnings() as w:
+        altaz_auto = sc.transform_to(altaz)
+
+    assert len(w) == 0
+
+    with iers.earth_orientation_table.set(iers.IERS_B.open()):
+        with catch_warnings() as w:
+            altaz_b = sc.transform_to(altaz)
+        assert len(w) == 1
+        assert 'after IERS data' in str(w[0].message)
+
+    sep_b_auto = altaz_b.separation(altaz_auto)
+    assert_allclose(sep_b_auto, 0.0*u.deg, atol=1*u.arcsec)
+    assert sep_b_auto > 10*u.microarcsecond
+
+    # Check we returned to regular IERS system.
+    altaz_auto2 = sc.transform_to(altaz)
+    assert altaz_auto2.separation(altaz_auto) == 0.
+
+
 @pytest.mark.remote_data
 @pytest.mark.skipif('not HAS_JPLEPHEM')
 def test_ephemerides():
diff --git a/astropy/coordinates/tests/test_regression.py b/astropy/coordinates/tests/test_regression.py
index 4e6c02048118..3b124d5eccc7 100644
--- a/astropy/coordinates/tests/test_regression.py
+++ b/astropy/coordinates/tests/test_regression.py
@@ -225,7 +225,13 @@ def test_regression_futuretimes_4302():
         c = CIRS(1*u.deg, 2*u.deg, obstime=future_time)
         c.transform_to(ITRS(obstime=future_time))

-    if not isinstance(iers.IERS_Auto.iers_table, iers.IERS_Auto):
+    # check that out-of-range warning appears among any other warnings.  If
+    # tests are run with --remote-data then the IERS table will be an instance
+    # of IERS_Auto which is assured of being "fresh".  In this case getting
+    # times outside the range of the table does not raise an exception.  Only
+    # if using IERS_B (which happens without --remote-data, i.e. for all CI
+    # testing) do we expect another warning.
+    if isinstance(iers.earth_orientation_table.get(), iers.IERS_B):
         saw_iers_warnings = False
         for w in found_warnings:
             if issubclass(w.category, AstropyWarning):
diff --git a/astropy/time/tests/test_ut1.py b/astropy/time/tests/test_ut1.py
index 7fed75ea9192..21443387e28b 100644
--- a/astropy/time/tests/test_ut1.py
+++ b/astropy/time/tests/test_ut1.py
@@ -72,23 +72,42 @@ def test_delta_ut1_utc(self):
             assert allclose_sec(t._delta_ut1_utc, -0.58682110003124965)

-@pytest.mark.skipif('not HAS_IERS_A')
-class TestTimeUT1_IERSA:
-    def test_ut1_iers_A(self):
+class TestTimeUT1SpecificIERSTable:
+    def do_ut1_prediction_test(self, iers_type):
         tnow = Time.now()
-        iers_a = iers.IERS_A.open()
-        tnow.delta_ut1_utc, status = iers_a.ut1_utc(tnow, return_status=True)
+        iers_tab = iers_type.open()
+        tnow.delta_ut1_utc, status = iers_tab.ut1_utc(tnow, return_status=True)
         assert status == iers.FROM_IERS_A_PREDICTION
         tnow_ut1_jd = tnow.ut1.jd
         assert tnow_ut1_jd != tnow.jd

+        delta_ut1_utc = tnow.delta_ut1_utc
+        with iers.earth_orientation_table.set(iers_type.open()):
+            delta2, status2 = tnow.get_delta_ut1_utc(return_status=True)
+            assert status2 == status
+            assert delta2.to_value('s') == delta_ut1_utc
+
+            tnow_ut1 = tnow.ut1
+            assert tnow_ut1._delta_ut1_utc == delta_ut1_utc
+            assert tnow_ut1.jd != tnow.jd
+
+    @pytest.mark.skipif('not HAS_IERS_A')
+    def test_ut1_iers_A(self):
+        self.do_ut1_prediction_test(iers.IERS_A)

-@pytest.mark.remote_data
-class TestTimeUT1_IERS_Auto:
+    @pytest.mark.remote_data
     def test_ut1_iers_auto(self):
+        self.do_ut1_prediction_test(iers.IERS_Auto)
+
+    def test_ut1_iers_B(self):
         tnow = Time.now()
-        iers_a = iers.IERS_Auto.open()
-        tnow.delta_ut1_utc, status = iers_a.ut1_utc(tnow, return_status=True)
-        assert status == iers.FROM_IERS_A_PREDICTION
-        tnow_ut1_jd = tnow.ut1.jd
-        assert tnow_ut1_jd != tnow.jd
+        iers_b = iers.IERS_B.open()
+        delta1, status1 = tnow.get_delta_ut1_utc(iers_b, return_status=True)
+        assert status1 == iers.TIME_BEYOND_IERS_RANGE
+
+        with iers.earth_orientation_table.set(iers.IERS_B.open()):
+            delta2, status2 = tnow.get_delta_ut1_utc(return_status=True)
+            assert status2 == status1
+
+            with pytest.raises(iers.IERSRangeError):
+                tnow.ut1

diff --git a/docs/utils/iers.rst b/docs/utils/iers.rst
index 7fe000ef7d..726cee3ce0 100644
--- a/docs/utils/iers.rst
+++ b/docs/utils/iers.rst
@@ -29,7 +29,7 @@ and how this can be controlled.
 Basic usage
 -----------

-The IERS data are managed via a instances of the
+By default, the IERS data are managed via instances of the
 :class:`~astropy.utils.iers.IERS_Auto` class.  These instances are created
 internally within the relevant time and coordinate objects during
 transformations.  If the astropy data cache does not have the required IERS
@@ -47,7 +47,7 @@ machine.  Here is an example that shows the typical download progress bar::
 Note that you can forcibly clear the download cache as follows::

   >>> from astropy.utils.data import clear_download_cache
-  >>> clear_download_cache()
+  >>> clear_download_cache()  # doctest: +SKIP

 The default IERS data used automatically is updated by the service every 7 days
 and includes transforms dating back to 1973-01-01.
@@ -146,34 +146,44 @@ Direct table access
 -------------------

 In most cases the automatic interface will suffice, but you may need to
-directly load and manipulate IERS tables.  IERS-B values are provided as
-part of astropy and can be used for transformations.  For example::
+directly load and manipulate IERS tables.  IERS-B values are provided as part
+of astropy and can be used to calculate time offsets and polar motion
+directly, or set up for internal use in further time and coordinate
+transformations.  For example::

   >>> from astropy.utils import iers
   >>> t = Time('2010:001')
   >>> iers_b = iers.IERS_B.open()
   >>> iers_b.ut1_utc(t)  # doctest: +FLOAT_CMP
   <Quantity 0.1140827 s>
-  >>> t.delta_ut1_utc = iers_b.ut1_utc(t)
+  >>> iers.earth_orientation_table.set(iers_b)
+  <ScienceState earth_orientation_table: <IERS_B length=...>...>
   >>> t.ut1.iso
   '2010-01-01 00:00:00.114'

 Instead of local copies of IERS files, one can also download them, using
-``iers.IERS_A_URL`` (or ``iers.IERS_A_URL_MIRROR``) and ``iers.IERS_B_URL``::
+``iers.IERS_A_URL`` (or ``iers.IERS_A_URL_MIRROR``) and ``iers.IERS_B_URL``,
+and then use those for future time and coordinate transformations (in this
+example, just for a single calculation, by using
+`~astropy.utils.iers.earth_orientation_table` as a context manager)::

   >>> iers_a = iers.IERS_A.open(iers.IERS_A_URL)  # doctest: +SKIP
+  >>> with iers.earth_orientation_table.set(iers_a):  # doctest: +SKIP
+  ...     print(t.ut1.iso)
+  2010-01-01 00:00:00.114

-For coordinate transformations that require IERS polar motion values,
-setting the values manually can be done as follows (where one could also
-select IERS_B)::
+To reset to the default, pass in `None` (which is equivalent to passing in
+``iers.IERS_Auto.open()``)::

-  >>> iers.conf.auto_download = False
-  >>> iers.IERS.iers_table = iers.IERS_A.open(iers.IERS_A_URL)  # doctest: +SKIP
+  >>> iers.earth_orientation_table.set(None)  # doctest: +REMOTE_DATA
+  <ScienceState earth_orientation_table: <IERS_Auto length=...>...>

 To see the internal IERS data that gets used in astropy you can do the
 following::

-  >>> dat = iers.IERS_Auto.open()  # doctest: +SKIP
+  >>> dat = iers.earth_orientation_table.get()  # doctest: +REMOTE_DATA
+  >>> type(dat)  # doctest: +REMOTE_DATA
+  <class 'astropy.utils.iers.iers.IERS_Auto'>
   >>> dat  # doctest: +SKIP
   <IERS_Auto length=16196>
    year month  day    MJD   PolPMFlag_A ... UT1Flag    PM_x     PM_y   PolPMFlag


diff --git a/CHANGES.rst b/CHANGES.rst
index 7035d4a29860..b1c7305aee37 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -142,6 +142,11 @@ astropy.table

 - Added ``Table.iterrows()`` for making row-wise iteration faster. [#8969]

+- Allow table to be initialized with a list of dict where the dict keys
+  are not the same in every row. The table column names are the set of all keys
+  found in the input data, and any missing key/value pairs are turned into
+  missing data in the table. [#9425]
+
 astropy.tests
 ^^^^^^^^^^^^^

diff --git a/astropy/table/table.py b/astropy/table/table.py
index 81b9126c4cba..2058af76eb64 100644
--- a/astropy/table/table.py
+++ b/astropy/table/table.py
@@ -3,7 +3,7 @@
 from .index import TableIndices, TableLoc, TableILoc, TableLocIndices

 import sys
-from collections import OrderedDict
+from collections import OrderedDict, defaultdict
 from collections.abc import Mapping
 import warnings
 from copy import deepcopy
@@ -175,6 +175,23 @@ def has_info_class(obj, cls):
     return hasattr(obj, 'info') and isinstance(obj.info, cls)

+def _get_names_from_list_of_dict(rows):
+    """Return list of column names if ``rows`` is a list of dict that
+    defines table data.
+
+    If rows is not a list of dict then return None.
+    """
+    if rows is None:
+        return None
+
+    names = set()
+    for row in rows:
+        if not isinstance(row, dict):
+            return None
+        names.update(row)
+    return list(names)
+
+
 # Note to future maintainers: when transitioning this to dict
 # be sure to change the OrderedDict ref(s) in Row and in __len__().

@@ -450,18 +467,23 @@ def __init__(self, data=None, masked=False, names=None, dtype=None,
         if not copy and dtype is not None:
             raise ValueError('Cannot specify dtype when copy=False')

+        # Specifies list of names found for the case of initializing table with
+        # a list of dict. If data are not list of dict then this is None.
+        names_from_list_of_dict = None
+
         # Row-oriented input, e.g. list of lists or list of tuples, list of
         # dict, Row instance.  Set data to something that the subsequent code
         # will parse correctly.
-        is_list_of_dict = False
         if rows is not None:
             if data is not None:
                 raise ValueError('Cannot supply both `data` and `rows` values')
             if isinstance(rows, types.GeneratorType):
                 # Without this then the all(..) test below uses up the generator
                 rows = list(rows)
-            if all(isinstance(row, dict) for row in rows):
-                is_list_of_dict = True  # Avoid doing the all(...) test twice.
+
+            # Get column names if `rows` is a list of dict, otherwise this is None
+            names_from_list_of_dict = _get_names_from_list_of_dict(rows)
+            if names_from_list_of_dict:
                 data = rows
             elif isinstance(rows, self.Row):
                 data = rows
@@ -493,10 +515,15 @@ def __init__(self, data=None, masked=False, names=None, dtype=None,
             data = data._table[data._index:data._index + 1]

         if isinstance(data, (list, tuple)):
-            init_func = self._init_from_list
-            if data and (is_list_of_dict or all(isinstance(row, dict) for row in data)):
-                n_cols = len(data[0])
+            # Get column names from `data` if it is a list of dict, otherwise this is None.
+            # This might be previously defined if `rows` was supplied as an init arg.
+            names_from_list_of_dict = (names_from_list_of_dict or
+                                       _get_names_from_list_of_dict(data))
+            if names_from_list_of_dict:
+                init_func = self._init_from_list_of_dicts
+                n_cols = len(names_from_list_of_dict)
             else:
+                init_func = self._init_from_list
                 n_cols = len(data)

         elif isinstance(data, np.ndarray):
@@ -804,10 +831,20 @@ def _check_names_dtype(self, names, dtype, n_cols):
                 .format(inp_str))

     def _init_from_list_of_dicts(self, data, names, dtype, n_cols, copy):
+        """Initialize table from a list of dictionaries representing rows."""
+        # Define placeholder for missing values as a unique object that cannot
+        # every occur in user data.
+        MISSING = object()
+
+        # Gather column names that exist in the input `data`.
         names_from_data = set()
         for row in data:
             names_from_data.update(row)

+        # Put names into a preferred order, either using the first row of data
+        # if it is ordered, or alphabetically.  Starting with Python 3.7, dict
+        # is ordered so this test can be relaxed.  (In practice CPython 3.6 is
+        # this way, but not according to the formal spec).
         if (isinstance(data[0], OrderedDict) and
                 set(data[0].keys()) == names_from_data):
             names_from_data = list(data[0].keys())
@@ -817,29 +854,53 @@ def _init_from_list_of_dicts(self, data, names, dtype, n_cols, copy):
         # Note: if set(data[0].keys()) != names_from_data, this will give an
         # exception later, so NO need to catch here.

+        # Convert list of dict into dict of list (cols), keep track of missing
+        # indexes and put in MISSING placeholders in the `cols` lists.
         cols = {}
+        missing_indexes = defaultdict(list)
         for name in names_from_data:
             cols[name] = []
-            for i, row in enumerate(data):
+            for ii, row in enumerate(data):
                 try:
-                    cols[name].append(row[name])
+                    val = row[name]
                 except KeyError:
-                    raise ValueError(f'Row {i} has no value for column {name}')
-
+                    missing_indexes[name].append(ii)
+                    val = MISSING
+                cols[name].append(val)
+
+        # Fill the missing entries with first values
+        if missing_indexes:
+            for name, indexes in missing_indexes.items():
+                col = cols[name]
+                first_val = next(val for val in col if val is not MISSING)
+                for index in indexes:
+                    col[index] = first_val
+
+        # prepare initialization
         if all(name is None for name in names):
             names = names_from_data

         self._init_from_dict(cols, names, dtype, n_cols, copy)
+
+        # Mask the missing values if necessary, converting columns to MaskedColumn
+        # as needed.
+        if missing_indexes:
+            for name, indexes in missing_indexes.items():
+                col = self[name]
+                # Ensure that any Column subclasses with MISSING values can support
+                # setting masked values. As of astropy 4.0 the test condition below is
+                # always True since _init_from_dict cannot result in mixin columns.
+                if isinstance(col, Column) and not isinstance(col, MaskedColumn):
+                    self[name] = self.MaskedColumn(col, copy=False)
+
+                # Finally do the masking in a mixin-safe way.
+                self[name][indexes] = np.ma.masked
         return

     def _init_from_list(self, data, names, dtype, n_cols, copy):
         """Initialize table from a list of column data.  A column can be a
         Column object, np.ndarray, mixin, or any other iterable object.
         """
-        if data and all(isinstance(row, dict) for row in data):
-            self._init_from_list_of_dicts(data, names, dtype, n_cols, copy)
-            return
-
         cols = []
         default_names = _auto_names(n_cols)

diff --git a/docs/table/construct_table.rst b/docs/table/construct_table.rst
index 56b36e076610..c9a96c5613a5 100644
--- a/docs/table/construct_table.rst
+++ b/docs/table/construct_table.rst
@@ -225,20 +225,22 @@ list of dict objects.  The keys determine the column names::

   >>> data = [{'a': 5, 'b': 10},
   ...         {'a': 15, 'b': 20}]
-  >>> Table(rows=data)  # doctest: +SKIP
-  <Table length=2>
-    a     b
-  int64 int64
-  ----- -----
-      5    10
-     15    20
+  >>> t = Table(rows=data)
+  >>> print(t)
+   a   b
+  --- ---
+    5  10
+   15  20

-Every row must have the same set of keys or a ValueError will be thrown::
+If there are missing keys in one or more rows then the corresponding values will be
+marked as missing (masked)::

-  >>> t = Table(rows=[{'a': 5, 'b': 10}, {'a': 15, 'b': 30, 'c': 50}])
-  Traceback (most recent call last):
-    ...
-  ValueError: Row 0 has no value for column c
+  >>> t = Table(rows=[{'a': 5, 'b': 10}, {'a': 15, 'c': 50}])
+  >>> print(t)
+   a   b   c
+  --- --- ---
+    5  10  --
+   15  --  50

 You can also preserve the column order by using ``OrderedDict``. If the first item is an
 ``OrderedDict`` then the order is preserved:

diff --git a/astropy/table/tests/test_init_table.py b/astropy/table/tests/test_init_table.py
index 613c8c071b81..e1b1e008d612 100644
--- a/astropy/table/tests/test_init_table.py
+++ b/astropy/table/tests/test_init_table.py
@@ -6,7 +6,7 @@
 import pytest
 import numpy as np

-from astropy.table import Column, TableColumns, Table
+from astropy.table import Column, TableColumns, Table, MaskedColumn

 class TestTableColumnsInit():
@@ -168,7 +168,7 @@ def test_bad_data(self, table_type):
         self._setup(table_type)
         with pytest.raises(ValueError):
             table_type([[1, 2],
-                   [3, 4, 5]])
+                        [3, 4, 5]])

 @pytest.mark.usefixtures('table_type')
@@ -188,11 +188,21 @@ def test_names_ordered(self, table_type):
         t = table_type(self.data, names=('c', 'b', 'a'))
         assert t.colnames == ['c', 'b', 'a']

-    def test_bad_data(self, table_type):
-        self._setup(table_type)
-        with pytest.raises(ValueError):
-            table_type([{'a': 1, 'b': 2, 'c': 3},
-                   {'a': 2, 'b': 4}])
+    def test_missing_data_init_from_dict(self, table_type):
+        dat = [{'a': 1, 'b': 2},
+               {'a': 2, 'c': 4}]
+        for rows in [False, True]:
+            t = table_type(rows=dat) if rows else table_type(dat)
+
+            assert np.all(t['a'] == [1, 2])
+            assert np.all(t['b'].mask == [False, True])
+            assert np.all(t['b'].data == [2, 2])
+            assert np.all(t['c'].mask == [True, False])
+            assert np.all(t['c'].data == [4, 4])
+
+            assert type(t['a']) is (MaskedColumn if t.masked else Column)
+            assert type(t['b']) is MaskedColumn
+            assert type(t['c']) is MaskedColumn

 @pytest.mark.usefixtures('table_type')
@@ -517,9 +527,6 @@ def test_init_from_row_OrderedDict(table_type):
     assert t1.colnames == ['b', 'a']
     assert t2.colnames == ['a', 'b']

-    with pytest.raises(ValueError):
-        table_type(rows=[OrderedDict([('b', 1)]), {'a': 10, 'b': 20}])
-

 def test_init_from_rows_as_generator():
     rows = ((1 + ii, 2 + ii) for ii in range(2))

diff --git a/docs/table/construct_table.rst b/docs/table/construct_table.rst
index 56b36e0766..c9a96c5613 100644
--- a/docs/table/construct_table.rst
+++ b/docs/table/construct_table.rst
@@ -225,20 +225,22 @@ list of dict objects.  The keys determine the column names::

   >>> data = [{'a': 5, 'b': 10},
   ...         {'a': 15, 'b': 20}]
-  >>> Table(rows=data)  # doctest: +SKIP
-  <Table length=2>
-    a     b
-  int64 int64
-  ----- -----
-      5    10
-     15    20
+  >>> t = Table(rows=data)
+  >>> print(t)
+   a   b
+  --- ---
+    5  10
+   15  20

-Every row must have the same set of keys or a ValueError will be thrown::
+If there are missing keys in one or more rows then the corresponding values will be
+marked as missing (masked)::

-  >>> t = Table(rows=[{'a': 5, 'b': 10}, {'a': 15, 'b': 30, 'c': 50}])
-  Traceback (most recent call last):
-    ...
-  ValueError: Row 0 has no value for column c
+  >>> t = Table(rows=[{'a': 5, 'b': 10}, {'a': 15, 'c': 50}])
+  >>> print(t)
+   a   b   c
+  --- --- ---
+    5  10  --
+   15  --  50

 You can also preserve the column order by using ``OrderedDict``. If the first item is an
 ``OrderedDict`` then the order is preserved:

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/CHANGES.rst b/CHANGES.rst
index b585271083c5..f6b820b72052 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -161,6 +161,10 @@ astropy.table
 - Added depth-wise stacking ``cstack()`` in higher level table operation.
   It help will in stacking table column depth-wise. [#8939]

+- Added a new table equality method ``values_equal()`` which allows comparison
+  table values to another table, list, or value, and returns an element-by-element
+  equality table. [#9068]
+
 - Added new ``join_type='cartesian'`` option to the ``join`` operation.
   [#9288]

diff --git a/astropy/table/table.py b/astropy/table/table.py
index 2058af76eb64..6561100da223 100644
--- a/astropy/table/table.py
+++ b/astropy/table/table.py
@@ -2998,6 +2998,35 @@ def __ge__(self, other):
         return super().__ge__(other)

     def __eq__(self, other):
+        return self._rows_equal(other)
+
+    def __ne__(self, other):
+        return ~self.__eq__(other)
+
+    def _rows_equal(self, other):
+        """
+        Row-wise comparison of table with any other object.
+
+        This is actual implementation for __eq__.
+
+        Returns a 1-D boolean numpy array showing result of row-wise comparison.
+        This is the same as the ``==`` comparison for tables.
+
+        Parameters
+        ----------
+        other : Table or DataFrame or ndarray
+             An object to compare with table
+
+        Examples
+        --------
+        Comparing one Table with other::
+
+            >>> t1 = Table([[1,2],[4,5],[7,8]], names=('a','b','c'))
+            >>> t2 = Table([[1,2],[4,5],[7,8]], names=('a','b','c'))
+            >>> t1._rows_equal(t2)
+            array([ True,  True])
+
+        """

         if isinstance(other, Table):
             other = other.as_array()
@@ -3021,8 +3050,80 @@ def __eq__(self, other):

         return result

-    def __ne__(self, other):
-        return ~self.__eq__(other)
+    def values_equal(self, other):
+        """
+        Element-wise comparison of table with another table, list, or scalar.
+
+        Returns a ``Table`` with the same columns containing boolean values
+        showing result of comparison.
+
+        Parameters
+        ----------
+        other : Table-like object or list or scalar
+             Object to compare with table
+
+        Examples
+        --------
+        Compare one Table with other::
+
+          >>> t1 = Table([[1, 2], [4, 5], [-7, 8]], names=('a', 'b', 'c'))
+          >>> t2 = Table([[1, 2], [-4, 5], [7, 8]], names=('a', 'b', 'c'))
+          >>> t1.values_equal(t2)
+

+           a     b     c
+          bool  bool  bool
+          ---- ----- -----
+          True False False
+          True  True  True
+
+        """
+        if isinstance(other, Table):
+            names = other.colnames
+        else:
+            try:
+                other = Table(other, copy=False)
+                names = other.colnames
+            except Exception:
+                # Broadcast other into a dict, so e.g. other = 2 will turn into
+                # other = {'a': 2, 'b': 2} and then equality does a
+                # column-by-column broadcasting.
+                names = self.colnames
+                other = {name: other for name in names}
+
+        # Require column names match but do not require same column order
+        if set(self.colnames) != set(names):
+            raise ValueError('cannot compare tables with different column names')
+
+        eqs = []
+        for name in names:
+            try:
+                np.broadcast(self[name], other[name])  # Check if broadcast-able
+                # Catch the numpy FutureWarning related to equality checking,
+                # "elementwise comparison failed; returning scalar instead, but
+                #  in the future will perform elementwise comparison".  Turn this
+                # into an exception since the scalar answer is not what we want.
+                with warnings.catch_warnings(record=True) as warns:
+                    warnings.simplefilter('always')
+                    eq = self[name] == other[name]
+                    if (warns and issubclass(warns[-1].category, FutureWarning) and
+                            'elementwise comparison failed' in str(warns[-1].message)):
+                        raise FutureWarning(warns[-1].message)
+            except Exception as err:
+                raise ValueError(f'unable to compare column {name}') from err
+
+            # Be strict about the result from the comparison. E.g. SkyCoord __eq__ is just
+            # broken and completely ignores that it should return an array.
+            if not (isinstance(eq, np.ndarray) and
+                    eq.dtype is np.dtype('bool') and
+                    len(eq) == len(self)):
+                raise TypeError(f'comparison for column {name} returned {eq} '
+                                f'instead of the expected boolean ndarray')
+
+            eqs.append(eq)
+
+        out = Table(eqs, names=names)
+
+        return out

     @property
     def groups(self):
diff --git a/docs/table/access_table.rst b/docs/table/access_table.rst
index a27604fa928a..cf472aeb56f8 100644
--- a/docs/table/access_table.rst
+++ b/docs/table/access_table.rst
@@ -314,6 +314,56 @@ structured array by creating a copy or reference with ``np.array``::
   >>> data = np.array(t, copy=False)  # reference to data in t

+Table Equality
+--------------
+We can check table data equality using two different methods:
+
+- The ``==`` comparison operator.  This returns a ``True`` or ``False`` for
+  each row if the *entire row* matches.  This is the same as the behavior of
+  numpy structured arrays.
+- Table :meth:`~astropy.table.Table.values_equal` to compare table values
+  element-wise.  This returns a boolean ``True`` or ``False`` for each table
+  *element*, so one gets a `~astropy.table.Table` of values.
+
+Examples::
+
+  >>> t1 = Table(rows=[[1, 2, 3],
+  ...                  [4, 5, 6],
+  ...                  [7, 7, 9]], names=['a', 'b', 'c'])
+  >>> t2 = Table(rows=[[1, 2, -1],
+  ...                  [4, -1, 6],
+  ...                  [7, 7, 9]], names=['a', 'b', 'c'])
+
+  >>> t1 == t2
+  array([False, False,  True])
+
+  >>> t1.values_equal(t2)  # Compare to another table
+

+   a     b     c
+  bool  bool  bool
+  ---- ----- -----
+  True  True False
+  True False  True
+  True  True  True
+
+  >>> t1.values_equal([2, 4, 7])  # Compare to an array column-wise
+

+    a     b     c
+   bool  bool  bool
+  ----- ----- -----
+  False  True False
+   True False False
+   True  True False
+
+  >>> t1.values_equal(7)  # Compare to a scalar column-wise
+

+    a     b     c
+   bool  bool  bool
+  ----- ----- -----
+  False False False
+  False False False
+   True  True False
+
 Formatted printing
 ------------------

diff --git a/astropy/table/tests/test_table.py b/astropy/table/tests/test_table.py
index 188b908a8a8a..fa787ebd04d7 100644
--- a/astropy/table/tests/test_table.py
+++ b/astropy/table/tests/test_table.py
@@ -15,6 +15,7 @@
 from astropy.table import Table, QTable, MaskedColumn
 from astropy.tests.helper import (assert_follows_unicode_guidelines,
                                   ignore_warnings, catch_warnings)
+from astropy.coordinates import SkyCoord

 from astropy.utils.data import get_pkg_data_filename
 from astropy import table
@@ -1382,7 +1383,71 @@ def test_disallow_inequality_comparisons():
         t <= -1.1

-def test_equality():
+def test_values_equal_part1():
+
+    col1 = [1, 2]
+    col2 = [1.0, 2.0]
+    col3 = ['a', 'b']
+    t1 = table.Table([col1, col2, col3], names=['a', 'b', 'c'])
+    t2 = table.Table([col1, col2], names=['a', 'b'])
+    t3 = table.table_helpers.simple_table()
+    tm = t1.copy()
+    tm['time'] = Time([1, 2], format='cxcsec')
+    tm1 = tm.copy()
+    tm1['time'][0] = np.ma.masked
+
+    tq = table.table_helpers.simple_table()
+    tq['quantity'] = [1., 2., 3.]*u.m
+
+    tsk = table.table_helpers.simple_table()
+    tsk['sk'] = SkyCoord(1, 2,  unit='deg')
+
+    with pytest.raises(ValueError, match='cannot compare tables with different column names'):
+        t2.values_equal(t1)
+
+    with pytest.raises(ValueError, match='unable to compare column a'):
+        # Shape mismatch
+        t3.values_equal(t1)
+
+    with pytest.raises(ValueError, match='unable to compare column c'):
+        # Type mismatch in column c causes FutureWarning
+        t1.values_equal(2)
+
+    with pytest.raises(ValueError, match='unable to compare column c'):
+        t1.values_equal([1, 2])
+
+    with pytest.raises(TypeError, match='comparison for column sk'):
+        tsk.values_equal(tsk)
+
+    eq = t2.values_equal(t2)
+    for col in eq.colnames:
+        assert np.all(eq[col] == [True, True])
+
+    eq1 = tm1.values_equal(tm)
+    for col in eq1.colnames:
+        assert np.all(eq1[col] == [True, True])
+
+    eq2 = tq.values_equal(tq)
+    for col in eq2.colnames:
+        assert np.all(eq2[col] == [True, True, True])
+
+    eq3 = t2.values_equal(2)
+    for col in eq3.colnames:
+        assert np.all(eq3[col] == [False, True])
+
+    eq4 = t2.values_equal([1, 2])
+    for col in eq4.colnames:
+        assert np.all(eq4[col] == [True, True])
+
+    # Compare table to its first row
+    t = table.Table(rows=[(1, 'a'),
+                          (1, 'b')])
+    eq = t.values_equal(t[0])
+    assert np.all(eq['col0'] == [True, True])
+    assert np.all(eq['col1'] == [True, False])
+
+
+def test_rows_equal():

     t = table.Table.read([' a b  c  d',
                           ' 2 c 7.0 0',
@@ -1392,8 +1457,8 @@ def test_equality():
                           ' 0 a 0.0 4',
                           ' 1 b 3.0 5',
                           ' 1 a 2.0 6',
-                          ' 1 a 1.0 7',
-                         ], format='ascii')
+                          ' 1 a 1.0 7'],
+                         format='ascii')

     # All rows are equal
     assert np.all(t == t)

diff --git a/docs/table/access_table.rst b/docs/table/access_table.rst
index a27604fa92..cf472aeb56 100644
--- a/docs/table/access_table.rst
+++ b/docs/table/access_table.rst
@@ -314,6 +314,56 @@ structured array by creating a copy or reference with ``np.array``::
   >>> data = np.array(t, copy=False)  # reference to data in t

+Table Equality
+--------------
+We can check table data equality using two different methods:
+
+- The ``==`` comparison operator.  This returns a ``True`` or ``False`` for
+  each row if the *entire row* matches.  This is the same as the behavior of
+  numpy structured arrays.
+- Table :meth:`~astropy.table.Table.values_equal` to compare table values
+  element-wise.  This returns a boolean ``True`` or ``False`` for each table
+  *element*, so one gets a `~astropy.table.Table` of values.
+
+Examples::
+
+  >>> t1 = Table(rows=[[1, 2, 3],
+  ...                  [4, 5, 6],
+  ...                  [7, 7, 9]], names=['a', 'b', 'c'])
+  >>> t2 = Table(rows=[[1, 2, -1],
+  ...                  [4, -1, 6],
+  ...                  [7, 7, 9]], names=['a', 'b', 'c'])
+
+  >>> t1 == t2
+  array([False, False,  True])
+
+  >>> t1.values_equal(t2)  # Compare to another table
+

+   a     b     c
+  bool  bool  bool
+  ---- ----- -----
+  True  True False
+  True False  True
+  True  True  True
+
+  >>> t1.values_equal([2, 4, 7])  # Compare to an array column-wise
+

+    a     b     c
+   bool  bool  bool
+  ----- ----- -----
+  False  True False
+   True False False
+   True  True False
+
+  >>> t1.values_equal(7)  # Compare to a scalar column-wise
+

+    a     b     c
+   bool  bool  bool
+  ----- ----- -----
+  False False False
+  False False False
+   True  True False
+
 Formatted printing
 ------------------

diff --git a/docs/table/access_table.rst b/docs/table/access_table.rst
index a27604fa928a..cf472aeb56f8 100644
--- a/docs/table/access_table.rst
+++ b/docs/table/access_table.rst
@@ -314,6 +314,56 @@ structured array by creating a copy or reference with ``np.array``::
   >>> data = np.array(t, copy=False)  # reference to data in t

+Table Equality
+--------------
+We can check table data equality using two different methods:
+
+- The ``==`` comparison operator.  This returns a ``True`` or ``False`` for
+  each row if the *entire row* matches.  This is the same as the behavior of
+  numpy structured arrays.
+- Table :meth:`~astropy.table.Table.values_equal` to compare table values
+  element-wise.  This returns a boolean ``True`` or ``False`` for each table
+  *element*, so one gets a `~astropy.table.Table` of values.
+
+Examples::
+
+  >>> t1 = Table(rows=[[1, 2, 3],
+  ...                  [4, 5, 6],
+  ...                  [7, 7, 9]], names=['a', 'b', 'c'])
+  >>> t2 = Table(rows=[[1, 2, -1],
+  ...                  [4, -1, 6],
+  ...                  [7, 7, 9]], names=['a', 'b', 'c'])
+
+  >>> t1 == t2
+  array([False, False,  True])
+
+  >>> t1.values_equal(t2)  # Compare to another table
+

+   a     b     c
+  bool  bool  bool
+  ---- ----- -----
+  True  True False
+  True False  True
+  True  True  True
+
+  >>> t1.values_equal([2, 4, 7])  # Compare to an array column-wise
+

+    a     b     c
+   bool  bool  bool
+  ----- ----- -----
+  False  True False
+   True False False
+   True  True False
+
+  >>> t1.values_equal(7)  # Compare to a scalar column-wise
+

+    a     b     c
+   bool  bool  bool
+  ----- ----- -----
+  False False False
+  False False False
+   True  True False
+
 Formatted printing
 ------------------

If completing this task requires creating new files, classes, fields, or error messages, you may consider using the following suggested entity names:
[{'type': 'error_msg', 'name': 'cannot compare tables with different column names'}]


diff --git a/CHANGES.rst b/CHANGES.rst
index 3c8869f97b64..ded568008cfd 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -91,6 +91,9 @@ astropy.table
 - ``MaskedColumn.data`` will now return a plain ``MaskedArray`` rather than
   the previous (unintended) ``masked_BaseColumn``. [#8855]

+- Adding depth-wise stacking ``cstack()`` in higher level table operation.
+  It help will in stacking table column depth-wise. [#8939]
+
 astropy.tests
 ^^^^^^^^^^^^^

diff --git a/astropy/table/__init__.py b/astropy/table/__init__.py
index effa98c74657..66a1645c6c6b 100644
--- a/astropy/table/__init__.py
+++ b/astropy/table/__init__.py
@@ -42,7 +42,7 @@ class Conf(_config.ConfigNamespace):
 from .groups import TableGroups, ColumnGroups
 from .table import (Table, QTable, TableColumns, Row, TableFormatter,
                     NdarrayMixin, TableReplaceWarning)
-from .operations import join, setdiff, hstack, vstack, unique, TableMergeError
+from .operations import join, setdiff, hstack, cstack, vstack, unique, TableMergeError
 from .bst import BST, FastBST, FastRBT
 from .sorted_array import SortedArray
 from .soco import SCEngine
diff --git a/astropy/table/operations.py b/astropy/table/operations.py
index 213898c221c2..105b093f77e3 100644
--- a/astropy/table/operations.py
+++ b/astropy/table/operations.py
@@ -5,6 +5,7 @@
 - setdiff()
 - hstack()
 - vstack()
+- cstack()
 """
 # Licensed under a 3-clause BSD style license - see LICENSE.rst

@@ -231,6 +232,93 @@ def setdiff(table1, table2, keys=None):
     return t12_diff

+def cstack(tables, join_type='outer', metadata_conflicts='warn'):
+    """
+    Stack columns within tables depth-wise
+
+    A ``join_type`` of 'exact' means that the tables must all have exactly
+    the same column names (though the order can vary).  If ``join_type``
+    is 'inner' then the intersection of common columns will be the output.
+    A value of 'outer' (default) means the output will have the union of
+    all columns, with table values being masked where no common values are
+    available.
+
+    Parameters
+    ----------
+    tables : Table or list of Table objects
+        Table(s) to stack along depth-wise with the current table
+        Table columns should have same shape and name for depth-wise stacking
+    join_type : str
+        Join type ('inner' | 'exact' | 'outer'), default is 'outer'
+    metadata_conflicts : str
+        How to proceed with metadata conflicts. This should be one of:
+            * ``'silent'``: silently pick the last conflicting meta-data value
+            * ``'warn'``: pick the last conflicting meta-data value, but emit a warning (default)
+            * ``'error'``: raise an exception.
+
+    Returns
+    -------
+    stacked_table : `~astropy.table.Table` object
+        New table containing the stacked data from the input tables.
+
+    Examples
+    --------
+    To stack two tables along rows do::
+
+      >>> from astropy.table import vstack, Table
+      >>> t1 = Table({'a': [1, 2], 'b': [3, 4]}, names=('a', 'b'))
+      >>> t2 = Table({'a': [5, 6], 'b': [7, 8]}, names=('a', 'b'))
+      >>> print(t1)
+       a   b
+      --- ---
+        1   3
+        2   4
+      >>> print(t2)
+       a   b
+      --- ---
+        5   7
+        6   8
+      >>> print(cstack([t1, t2]))
+      a [2]  b [2]
+      ------ ------
+      1 .. 5 3 .. 7
+      2 .. 6 4 .. 8
+    """
+    tables = _get_list_of_tables(tables)
+    if len(tables) == 1:
+        return tables[0]  # no point in stacking a single table
+
+    n_rows = set(len(table) for table in tables)
+    if len(n_rows) != 1:
+        raise ValueError('Table lengths must all match for cstack')
+    n_row = n_rows.pop()
+
+    out = vstack(tables, join_type, metadata_conflicts)
+
+    for name, col in out.columns.items():
+        col = out[name]
+
+        # Reshape to so each original column is now in a row.
+        # If entries are not 0-dim then those additional shape dims
+        # are just carried along.
+        # [x x x y y y] => [[x x x],
+        #                   [y y y]]
+        col.shape = (len(tables), n_row) + col.shape[1:]
+
+        # Transpose the table and row axes to get to
+        # [[x, y],
+        #  [x, y]
+        #  [x, y]]
+        axes = np.arange(len(col.shape))
+        axes[:2] = [1, 0]
+
+        # This temporarily makes `out` be corrupted (columns of different
+        # length) but it all works out in the end.
+        out.columns.__setitem__(name, col.transpose(axes), validated=True)
+
+    return out
+
+
 def vstack(tables, join_type='outer', metadata_conflicts='warn'):
     """
     Stack tables vertically (along rows)
diff --git a/docs/table/operations.rst b/docs/table/operations.rst
index 3f44210d851f..504b0b8122b1 100644
--- a/docs/table/operations.rst
+++ b/docs/table/operations.rst
@@ -591,6 +591,56 @@ The metadata from the input tables is merged by the process described in the `Me
 metadata`_ section.  Note also that you can use a single table row instead of a
 full table as one of the inputs.

+.. _stack-depthwise:
+
+Stack depth-wise
+----------------
+
+The |Table| class supports stacking columns within tables depth-wise using
+the `~astropy.table.cstack` function. It corresponds roughly
+to running the `numpy.dstack` function on the individual columns matched
+by name.
+
+For example, suppose one has tables of data for sources giving information on the enclosed
+source counts for different PSF fractions::
+
+  >>> from astropy.table import Table, cstack
+  >>> src1 = Table.read("""psf_frac  counts
+  ...                      0.10        45
+  ...                      0.50        90
+  ...                      0.90       120
+  ...                      """, format='ascii')
+
+  >>> src2 = Table.read("""psf_frac  counts
+  ...                      0.10       200
+  ...                      0.50       300
+  ...                      0.90       350
+  ...                      """, format='ascii')
+
+Now we can stack these two tables depth-wise to get a single table with the
+characteristics of both sources::
+
+  >>> srcs = cstack([src1, src2])
+  >>> print(srcs)
+  psf_frac [2] counts [2]
+  ------------ ----------
+    0.1 .. 0.1  45 .. 200
+    0.5 .. 0.5  90 .. 300
+    0.9 .. 0.9 120 .. 350
+
+In this case the counts for the first source are accessible as ``srcs['counts'][:, 0]``,
+and likewise the second source counts are ``srcs['counts'][:, 1]``.
+
+For this function the length of all input tables must be the same.  This function can
+accept ``join_type`` and ``metadata_conflicts`` just like the `~astropy.table.vstack`
+function.  The ``join_type`` argument controls how to handle mismatches in the columns of
+the input table.
+
+See also the sections on `Merging metadata`_ and `Merging column
+attributes`_ for details on how these characteristics of the input tables are merged in
+the single output table.  Note also that you can use a single table row instead of a
+full table as one of the inputs.
+
 .. _table-join:

 Join

diff --git a/astropy/table/tests/test_operations.py b/astropy/table/tests/test_operations.py
index 3cb6652d90ed..3e4ba76c6732 100644
--- a/astropy/table/tests/test_operations.py
+++ b/astropy/table/tests/test_operations.py
@@ -1004,6 +1004,123 @@ def test_mixin_functionality(self, mixin_cols):
                     'vstack unavailable' in str(err.value))

+class TestCStack():
+
+    def _setup(self, t_cls=Table):
+        self.t1 = t_cls.read([' a   b',
+                              ' 0. foo',
+                              ' 1. bar'], format='ascii')
+
+        self.t2 = t_cls.read([' a    b   c',
+                              ' 2.  pez  4',
+                              ' 3.  sez  5'], format='ascii')
+        self.t2['d'] = Time([1, 2], format='cxcsec')
+
+        self.t3 = t_cls({'a': [[5., 6.], [4., 3.]],
+                         'b': [['foo', 'bar'], ['pez', 'sez']]},
+                        names=('a', 'b'))
+
+        self.t4 = t_cls(self.t1, copy=True, masked=t_cls is Table)
+
+        self.t5 = t_cls({'a': [[4., 2.], [1., 6.]],
+                         'b': [['foo', 'pez'], ['bar', 'sez']]},
+                        names=('a', 'b'))
+        self.t6 = t_cls.read([' a    b   c',
+                              ' 7.  pez  2',
+                              ' 4.  sez  6',
+                              ' 6.  foo  3'], format='ascii')
+
+    @staticmethod
+    def compare_cstack(tables, out):
+        for ii, tbl in enumerate(tables):
+            for name, out_col in out.columns.items():
+                if name in tbl.colnames:
+                    # Columns always compare equal
+                    assert np.all(tbl[name] == out[name][:, ii])
+
+                    # If input has a mask then output must have same mask
+                    if hasattr(tbl[name], 'mask'):
+                        assert np.all(tbl[name].mask == out[name].mask[:, ii])
+
+                    # If input has no mask then output might have a mask (if other table
+                    # is missing that column). If so then all mask values should be False.
+                    elif hasattr(out[name], 'mask'):
+                        assert not np.any(out[name].mask[:, ii])
+
+                else:
+                    # Column missing for this table, out must have a mask with all True.
+                    assert np.all(out[name].mask[:, ii])
+
+    def test_cstack_table_column(self, operation_table_type):
+        """Stack a table with 3 cols and one column (gets auto-converted to Table).
+        """
+        self._setup(operation_table_type)
+        t2 = self.t1.copy()
+        out = table.cstack([self.t1, t2['a']])
+        self.compare_cstack([self.t1, t2[('a',)]], out)
+
+    def test_cstack_basic_outer(self, operation_table_type):
+        if operation_table_type is QTable:
+            pytest.xfail('Quantity columns do not support masking.')
+        self._setup(operation_table_type)
+        t1 = self.t1
+        t2 = self.t2
+        t4 = self.t4
+        t4['a'].mask[0] = True
+        # Test for non-masked table
+        t12 = table.cstack([t1, t2], join_type='outer')
+        assert type(t12) is operation_table_type
+        assert type(t12['a']) is type(t1['a'])
+        assert type(t12['b']) is type(t1['b'])
+        self.compare_cstack([t1, t2], t12)
+
+        # Test for masked table
+        t124 = table.cstack([t1, t2, t4], join_type='outer')
+        assert type(t124) is operation_table_type
+        assert type(t124['a']) is type(t4['a'])
+        assert type(t124['b']) is type(t4['b'])
+        self.compare_cstack([t1, t2, t4], t124)
+
+    def test_cstack_basic_inner(self, operation_table_type):
+        self._setup(operation_table_type)
+        t1 = self.t1
+        t2 = self.t2
+        t4 = self.t4
+
+        # Test for masked table
+        t124 = table.cstack([t1, t2, t4], join_type='inner')
+        assert type(t124) is operation_table_type
+        assert type(t124['a']) is type(t4['a'])
+        assert type(t124['b']) is type(t4['b'])
+        self.compare_cstack([t1, t2, t4], t124)
+
+    def test_cstack_multi_dimension_column(self, operation_table_type):
+        self._setup(operation_table_type)
+        t3 = self.t3
+        t5 = self.t5
+        t2 = self.t2
+        t35 = table.cstack([t3, t5])
+        assert type(t35) is operation_table_type
+        assert type(t35['a']) is type(t3['a'])
+        assert type(t35['b']) is type(t3['b'])
+        self.compare_cstack([t3, t5], t35)
+
+        with pytest.raises(TableMergeError):
+            table.cstack([t2, t3])
+
+    def test_cstack_different_length_table(self, operation_table_type):
+        self._setup(operation_table_type)
+        t2 = self.t2
+        t6 = self.t6
+        with pytest.raises(ValueError):
+            table.cstack([t2, t6])
+
+    def test_cstack_single_table(self):
+        self._setup(Table)
+        out = table.cstack(self.t1)
+        assert np.all(out == self.t1)
+
+
 class TestHStack():

     def _setup(self, t_cls=Table):

diff --git a/docs/table/operations.rst b/docs/table/operations.rst
index 3f44210d85..504b0b8122 100644
--- a/docs/table/operations.rst
+++ b/docs/table/operations.rst
@@ -591,6 +591,56 @@ The metadata from the input tables is merged by the process described in the `Me
 metadata`_ section.  Note also that you can use a single table row instead of a
 full table as one of the inputs.

+.. _stack-depthwise:
+
+Stack depth-wise
+----------------
+
+The |Table| class supports stacking columns within tables depth-wise using
+the `~astropy.table.cstack` function. It corresponds roughly
+to running the `numpy.dstack` function on the individual columns matched
+by name.
+
+For example, suppose one has tables of data for sources giving information on the enclosed
+source counts for different PSF fractions::
+
+  >>> from astropy.table import Table, cstack
+  >>> src1 = Table.read("""psf_frac  counts
+  ...                      0.10        45
+  ...                      0.50        90
+  ...                      0.90       120
+  ...                      """, format='ascii')
+
+  >>> src2 = Table.read("""psf_frac  counts
+  ...                      0.10       200
+  ...                      0.50       300
+  ...                      0.90       350
+  ...                      """, format='ascii')
+
+Now we can stack these two tables depth-wise to get a single table with the
+characteristics of both sources::
+
+  >>> srcs = cstack([src1, src2])
+  >>> print(srcs)
+  psf_frac [2] counts [2]
+  ------------ ----------
+    0.1 .. 0.1  45 .. 200
+    0.5 .. 0.5  90 .. 300
+    0.9 .. 0.9 120 .. 350
+
+In this case the counts for the first source are accessible as ``srcs['counts'][:, 0]``,
+and likewise the second source counts are ``srcs['counts'][:, 1]``.
+
+For this function the length of all input tables must be the same.  This function can
+accept ``join_type`` and ``metadata_conflicts`` just like the `~astropy.table.vstack`
+function.  The ``join_type`` argument controls how to handle mismatches in the columns of
+the input table.
+
+See also the sections on `Merging metadata`_ and `Merging column
+attributes`_ for details on how these characteristics of the input tables are merged in
+the single output table.  Note also that you can use a single table row instead of a
+full table as one of the inputs.
+
 .. _table-join:

 Join


diff --git a/CHANGES.rst b/CHANGES.rst
index 80b4e8d3406e..95d78702124c 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -129,9 +129,12 @@ astropy.table
 - ``MaskedColumn.data`` will now return a plain ``MaskedArray`` rather than
   the previous (unintended) ``masked_BaseColumn``. [#8855]

-- Adding depth-wise stacking ``cstack()`` in higher level table operation.
+- Added depth-wise stacking ``cstack()`` in higher level table operation.
   It help will in stacking table column depth-wise. [#8939]

+- Added new ``join_type='cartesian'`` option to the ``join`` operation.
+  [#9288]
+
 - Allow adding a table column as a list of mixin-type objects, for instance
   ``t['q'] = [1 * u.m, 2 * u.m]``. [#9165]

diff --git a/astropy/table/operations.py b/astropy/table/operations.py
index f6810b2dc8c8..46a84b1a764a 100644
--- a/astropy/table/operations.py
+++ b/astropy/table/operations.py
@@ -104,7 +104,7 @@ def join(left, right, keys=None, join_type='inner',
         Name(s) of column(s) used to match rows of left and right tables.
         Default is to use all columns which are common to both tables.
     join_type : str
-        Join type ('inner' | 'outer' | 'left' | 'right'), default is 'inner'
+        Join type ('inner' | 'outer' | 'left' | 'right' | 'cartesian'), default is 'inner'
     uniq_col_name : str or None
         String generate a unique output column name in case of a conflict.
         The default is '{col_name}_{table_name}'.
@@ -777,7 +777,7 @@ def _join(left, right, keys=None, join_type='inner',
         Name(s) of column(s) used to match rows of left and right tables.
         Default is to use all columns which are common to both tables.
     join_type : str
-        Join type ('inner' | 'outer' | 'left' | 'right'), default is 'inner'
+        Join type ('inner' | 'outer' | 'left' | 'right' | 'cartesian'), default is 'inner'
     uniq_col_name : str or None
         String generate a unique output column name in case of a conflict.
         The default is '{col_name}_{table_name}'.
@@ -801,11 +801,27 @@ def _join(left, right, keys=None, join_type='inner',
     # Store user-provided col_name_map until the end
     _col_name_map = col_name_map

-    if join_type not in ('inner', 'outer', 'left', 'right'):
+    # Special column name for cartesian join, should never collide with real column
+    cartesian_index_name = '__table_cartesian_join_temp_index__'
+
+    if join_type not in ('inner', 'outer', 'left', 'right', 'cartesian'):
         raise ValueError("The 'join_type' argument should be in 'inner', "
-                         "'outer', 'left' or 'right' (got '{}' instead)".
+                         "'outer', 'left', 'right', or 'cartesian' "
+                         "(got '{}' instead)".
                          format(join_type))

+    if join_type == 'cartesian':
+        if keys:
+            raise ValueError('cannot supply keys for a cartesian join')
+
+        # Make light copies of left and right, then add temporary index columns
+        # with all the same value so later an outer join turns into a cartesian join.
+        left = left.copy(copy_data=False)
+        right = right.copy(copy_data=False)
+        left[cartesian_index_name] = np.uint8(0)
+        right[cartesian_index_name] = np.uint8(0)
+        keys = (cartesian_index_name, )
+
     # If we have a single key, put it in a tuple
     if keys is None:
         keys = tuple(name for name in left.colnames if name in right.colnames)
@@ -840,13 +856,16 @@ def _join(left, right, keys=None, join_type='inner',

     # Main inner loop in Cython to compute the cartesian product
     # indices for the given join type
-    int_join_type = {'inner': 0, 'outer': 1, 'left': 2, 'right': 3}[join_type]
+    int_join_type = {'inner': 0, 'outer': 1, 'left': 2, 'right': 3,
+                     'cartesian': 1}[join_type]
     masked, n_out, left_out, left_mask, right_out, right_mask = \
         _np_utils.join_inner(idxs, idx_sort, len_left, int_join_type)

     out = _get_out_class([left, right])()

     for out_name, dtype, shape in out_descrs:
+        if out_name == cartesian_index_name:
+            continue

         left_name, right_name = col_name_map[out_name]
         if left_name and right_name:  # this is a key which comes from left and right
diff --git a/docs/table/operations.rst b/docs/table/operations.rst
index fc40c8d63bb7..2c026fbd8a31 100644
--- a/docs/table/operations.rst
+++ b/docs/table/operations.rst
@@ -721,7 +721,7 @@ matching key includes both ``name`` and ``obs_date``.  Specifying the key as onl
 Likewise one can construct a new table with every row of the right table and matching left
 values (when available) using ``join_type='right'``.

-Finally, to make a table with the union of rows from both tables do an "outer" join::
+To make a table with the union of rows from both tables do an "outer" join::

   >>> print(join(optical, xray, join_type='outer'))
     name   obs_date  mag_b mag_v logLx
@@ -732,9 +732,26 @@ Finally, to make a table with the union of rows from both tables do an "outer" j
       M82 2012-10-29  16.2  15.2  45.0
   NGC3516 2011-11-11    --    --  42.1

-In all cases the output join table will be sorted by the key column(s) and in general
+In all the above cases the output join table will be sorted by the key column(s) and in general
 will not preserve the row order of the input tables.

+Finally, one can do a "cartesian" join, which is the cartesian product of all available
+rows.  In this case one there are no key columns (and supplying a ``keys`` argument is
+an error)::
+
+  >>> print(join(optical, xray, join_type='cartesian'))
+  name_1 obs_date_1 mag_b mag_v  name_2 obs_date_2 logLx
+  ------ ---------- ----- ----- ------- ---------- -----
+     M31 2012-01-02  17.0  16.0 NGC3516 2011-11-11  42.1
+     M31 2012-01-02  17.0  16.0     M31 1999-01-05  43.1
+     M31 2012-01-02  17.0  16.0     M82 2012-10-29  45.0
+     M82 2012-10-29  16.2  15.2 NGC3516 2011-11-11  42.1
+     M82 2012-10-29  16.2  15.2     M31 1999-01-05  43.1
+     M82 2012-10-29  16.2  15.2     M82 2012-10-29  45.0
+    M101 2012-10-31  15.1  15.5 NGC3516 2011-11-11  42.1
+    M101 2012-10-31  15.1  15.5     M31 1999-01-05  43.1
+    M101 2012-10-31  15.1  15.5     M82 2012-10-29  45.0
+
 Non-identical key column names
 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

diff --git a/astropy/table/tests/test_operations.py b/astropy/table/tests/test_operations.py
index da5f9f1020be..6da2f2a8eff5 100644
--- a/astropy/table/tests/test_operations.py
+++ b/astropy/table/tests/test_operations.py
@@ -547,15 +547,36 @@ def test_mixin_functionality(self, mixin_cols):
                 assert ('join requires masking' in str(err.value) or
                         'join unavailable' in str(err.value))

+    def test_cartesian_join(self, operation_table_type):
+        t1 = Table(rows=[(1, 'a'),
+                         (2, 'b')], names=['a', 'b'])
+        t2 = Table(rows=[(3, 'c'),
+                         (4, 'd')], names=['a', 'c'])
+        t12 = table.join(t1, t2, join_type='cartesian')
+
+        assert t1.colnames == ['a', 'b']
+        assert t2.colnames == ['a', 'c']
+        assert len(t12) == len(t1) * len(t2)
+        assert str(t12).splitlines() == [
+            'a_1  b  a_2  c ',
+            '--- --- --- ---',
+            '  1   a   3   c',
+            '  1   a   4   d',
+            '  2   b   3   c',
+            '  2   b   4   d']
+
+        with pytest.raises(ValueError, match='cannot supply keys for a cartesian join'):
+            t12 = table.join(t1, t2, join_type='cartesian', keys='a')
+

 class TestSetdiff():

     def _setup(self, t_cls=Table):
         lines1 = [' a   b ',
-                 '  0 foo ',
-                 '  1 foo ',
-                 '  1 bar ',
-                 '  2 bar ']
+                  '  0 foo ',
+                  '  1 foo ',
+                  '  1 bar ',
+                  '  2 bar ']
         lines2 = [' a   b ',
                   '  0 foo ',
                   '  3 foo ',

diff --git a/docs/table/operations.rst b/docs/table/operations.rst
index fc40c8d63b..2c026fbd8a 100644
--- a/docs/table/operations.rst
+++ b/docs/table/operations.rst
@@ -721,7 +721,7 @@ matching key includes both ``name`` and ``obs_date``.  Specifying the key as onl
 Likewise one can construct a new table with every row of the right table and matching left
 values (when available) using ``join_type='right'``.

-Finally, to make a table with the union of rows from both tables do an "outer" join::
+To make a table with the union of rows from both tables do an "outer" join::

   >>> print(join(optical, xray, join_type='outer'))
     name   obs_date  mag_b mag_v logLx
@@ -732,9 +732,26 @@ Finally, to make a table with the union of rows from both tables do an "outer" j
       M82 2012-10-29  16.2  15.2  45.0
   NGC3516 2011-11-11    --    --  42.1

-In all cases the output join table will be sorted by the key column(s) and in general
+In all the above cases the output join table will be sorted by the key column(s) and in general
 will not preserve the row order of the input tables.

+Finally, one can do a "cartesian" join, which is the cartesian product of all available
+rows.  In this case one there are no key columns (and supplying a ``keys`` argument is
+an error)::
+
+  >>> print(join(optical, xray, join_type='cartesian'))
+  name_1 obs_date_1 mag_b mag_v  name_2 obs_date_2 logLx
+  ------ ---------- ----- ----- ------- ---------- -----
+     M31 2012-01-02  17.0  16.0 NGC3516 2011-11-11  42.1
+     M31 2012-01-02  17.0  16.0     M31 1999-01-05  43.1
+     M31 2012-01-02  17.0  16.0     M82 2012-10-29  45.0
+     M82 2012-10-29  16.2  15.2 NGC3516 2011-11-11  42.1
+     M82 2012-10-29  16.2  15.2     M31 1999-01-05  43.1
+     M82 2012-10-29  16.2  15.2     M82 2012-10-29  45.0
+    M101 2012-10-31  15.1  15.5 NGC3516 2011-11-11  42.1
+    M101 2012-10-31  15.1  15.5     M31 1999-01-05  43.1
+    M101 2012-10-31  15.1  15.5     M82 2012-10-29  45.0
+
 Non-identical key column names
 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

diff --git a/docs/table/operations.rst b/docs/table/operations.rst
index fc40c8d63bb7..2c026fbd8a31 100644
--- a/docs/table/operations.rst
+++ b/docs/table/operations.rst
@@ -721,7 +721,7 @@ matching key includes both ``name`` and ``obs_date``.  Specifying the key as onl
 Likewise one can construct a new table with every row of the right table and matching left
 values (when available) using ``join_type='right'``.

-Finally, to make a table with the union of rows from both tables do an "outer" join::
+To make a table with the union of rows from both tables do an "outer" join::

   >>> print(join(optical, xray, join_type='outer'))
     name   obs_date  mag_b mag_v logLx
@@ -732,9 +732,26 @@ Finally, to make a table with the union of rows from both tables do an "outer" j
       M82 2012-10-29  16.2  15.2  45.0
   NGC3516 2011-11-11    --    --  42.1

-In all cases the output join table will be sorted by the key column(s) and in general
+In all the above cases the output join table will be sorted by the key column(s) and in general
 will not preserve the row order of the input tables.

+Finally, one can do a "cartesian" join, which is the cartesian product of all available
+rows.  In this case one there are no key columns (and supplying a ``keys`` argument is
+an error)::
+
+  >>> print(join(optical, xray, join_type='cartesian'))
+  name_1 obs_date_1 mag_b mag_v  name_2 obs_date_2 logLx
+  ------ ---------- ----- ----- ------- ---------- -----
+     M31 2012-01-02  17.0  16.0 NGC3516 2011-11-11  42.1
+     M31 2012-01-02  17.0  16.0     M31 1999-01-05  43.1
+     M31 2012-01-02  17.0  16.0     M82 2012-10-29  45.0
+     M82 2012-10-29  16.2  15.2 NGC3516 2011-11-11  42.1
+     M82 2012-10-29  16.2  15.2     M31 1999-01-05  43.1
+     M82 2012-10-29  16.2  15.2     M82 2012-10-29  45.0
+    M101 2012-10-31  15.1  15.5 NGC3516 2011-11-11  42.1
+    M101 2012-10-31  15.1  15.5     M31 1999-01-05  43.1
+    M101 2012-10-31  15.1  15.5     M82 2012-10-29  45.0
+
 Non-identical key column names
 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

If completing this task requires creating new files, classes, fields, or error messages, you may consider using the following suggested entity names:
[{'type': 'error_msg', 'name': 'cannot supply keys for a cartesian join'}]