Repository: astropy/astropy

Issue:
vstack fails for tables of zero length that have SkyCoord columns
### Description

vstack fails for tables of zero rows that have SkyCoord columns.

### Expected behavior

The code below should succeed, and `table2` should be a new table with zero rows.

### How to Reproduce

```pycon
>>> from astropy.coordinates import SkyCoord
>>> from astropy.table import Table, vstack
>>> table1 = Table({'foo': SkyCoord([], [], unit='deg')})
>>> table2 = vstack((table1, table1))
Traceback (most recent call last):
  File "/Users/lpsinger/Library/Python/3.11/lib/python/site-packages/astropy/coordinates/sky_coordinate.py", line 181, in new_like
    out[0] = skycoord[0]
             ~~~~~~~~^^^
  File "/Users/lpsinger/Library/Python/3.11/lib/python/site-packages/astropy/utils/shapes.py", line 234, in __getitem__
    return self._apply("__getitem__", item)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/lpsinger/Library/Python/3.11/lib/python/site-packages/astropy/coordinates/sky_coordinate.py", line 475, in _apply
    new._sky_coord_frame = self._sky_coord_frame._apply(method, *args, **kwargs)
                           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/lpsinger/Library/Python/3.11/lib/python/site-packages/astropy/coordinates/baseframe.py", line 1605, in _apply
    new._data = apply_method(self.data)
                ^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/lpsinger/Library/Python/3.11/lib/python/site-packages/astropy/coordinates/baseframe.py", line 1574, in apply_method
    return value._apply(method, *args, **kwargs)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/lpsinger/Library/Python/3.11/lib/python/site-packages/astropy/coordinates/representation/base.py", line 992, in _apply
    rep = super()._apply(method, *args, **kwargs)
          ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/lpsinger/Library/Python/3.11/lib/python/site-packages/astropy/coordinates/representation/base.py", line 369, in _apply
    setattr(new, "_" + component, apply_method(getattr(self, component)))
                                  ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/lpsinger/Library/Python/3.11/lib/python/site-packages/astropy/units/quantity.py", line 1302, in __getitem__
    out = super().__getitem__(key)
          ^^^^^^^^^^^^^^^^^^^^^^^^
IndexError: index 0 is out of bounds for axis 0 with size 0

The above exception was the direct cause of the following exception:

Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "/Users/lpsinger/Library/Python/3.11/lib/python/site-packages/astropy/table/operations.py", line 684, in vstack
    out = _vstack(tables, join_type, col_name_map, metadata_conflicts)
          ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/lpsinger/Library/Python/3.11/lib/python/site-packages/astropy/table/operations.py", line 1478, in _vstack
    col = col_cls.info.new_like(cols, n_rows, metadata_conflicts, out_name)
          ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/lpsinger/Library/Python/3.11/lib/python/site-packages/astropy/coordinates/sky_coordinate.py", line 183, in new_like
    raise ValueError("Input skycoords are inconsistent.") from err
ValueError: Input skycoords are inconsistent.
```


### Versions

```
macOS-14.7.1-arm64-arm-64bit
Python 3.11.10 (main, Sep  7 2024, 05:47:53) [Clang 15.0.0 (clang-1500.1.0.2.5)]
astropy 6.1.5
Numpy 2.1.3
pyerfa 2.0.1.4
Scipy 1.14.1
Matplotlib 3.9.2
```


Discussion:

Patch:
diff --git a/astropy/coordinates/baseframe.py b/astropy/coordinates/baseframe.py
index 3a4348962e..c69010f177 100644
--- a/astropy/coordinates/baseframe.py
+++ b/astropy/coordinates/baseframe.py
@@ -270,19 +270,17 @@ class CoordinateFrameInfo(MixinInfo):
         attrs = self.merge_cols_attributes(
             coords, metadata_conflicts, name, ("meta", "description")
         )
-        coord0 = coords[0]
 
-        # Make a new coord object with the desired length and attributes
-        # by using the _apply / __getitem__ machinery to effectively return
-        # coord0[[0, 0, ..., 0, 0]]. This will have the all the right frame
-        # attributes with the right shape.
-        indexes = np.zeros(length, dtype=np.int64)
-        out = coord0[indexes]
+        # Make a new coordinate with the desired length.
+        coord0 = coords[0]
+        out = coord0._apply(np.zeros_like, shape=(length,) + coord0.shape[1:])
 
-        # Use __setitem__ machinery to check for consistency of all coords
+        # Use __setitem__ machinery to check for consistency of all coords.
+        # We use :0 to ensure we do not break on empty coordinates (with the
+        # side benefit that we do not actually set anything).
         for coord in coords[1:]:
             try:
-                out[0] = coord[0]
+                out[:0] = coord[:0]
             except Exception as err:
                 raise ValueError("Input coords are inconsistent.") from err
 
diff --git a/astropy/coordinates/representation/base.py b/astropy/coordinates/representation/base.py
index 0ab48b765c..e44f32767d 100644
--- a/astropy/coordinates/representation/base.py
+++ b/astropy/coordinates/representation/base.py
@@ -104,18 +104,18 @@ class BaseRepresentationOrDifferentialInfo(MixinInfo):
         attrs = self.merge_cols_attributes(
             reps, metadata_conflicts, name, ("meta", "description")
         )
-        # Make a new representation or differential with the desired length
-        # using the _apply / __getitem__ machinery to effectively return
-        # rep0[[0, 0, ..., 0, 0]]. This will have the right shape, and
-        # include possible differentials.
-        indexes = np.zeros(length, dtype=np.int64)
-        out = reps[0][indexes]
+
+        # Make a new representation or differential with the desired length.
+        rep0 = reps[0]
+        out = rep0._apply(np.zeros_like, shape=(length,) + rep0.shape[1:])
 
         # Use __setitem__ machinery to check whether all representations
         # can represent themselves as this one without loss of information.
+        # We use :0 to ensure we do not break on empty coordinates (with the
+        # side benefit that we do not actually set anything).
         for rep in reps[1:]:
             try:
-                out[0] = rep[0]
+                out[:0] = rep[:0]
             except Exception as err:
                 raise ValueError("input representations are inconsistent.") from err
 
diff --git a/docs/changes/table/17380.bugfix.rst b/docs/changes/table/17380.bugfix.rst
new file mode 100644
index 0000000000..3a9cfba8de
--- /dev/null
+++ b/docs/changes/table/17380.bugfix.rst
@@ -0,0 +1,3 @@
+Ensure that tables holding coordinates or representations can also be stacked
+if they have zero length. This fix also ensures that the ``insert`` method
+works correctly with a zero-length table holding a coordinate object.


Test patch:
diff --git a/astropy/table/tests/test_mixin.py b/astropy/table/tests/test_mixin.py
index e235768722..6882b3566b 100644
--- a/astropy/table/tests/test_mixin.py
+++ b/astropy/table/tests/test_mixin.py
@@ -632,30 +632,34 @@ def test_vstack():
         vstack([t1, t2])
 
 
-def test_insert_row(mixin_cols):
+@pytest.mark.parametrize("empty_table", [False, True])
+def test_insert_row(mixin_cols, empty_table):
     """
     Test inserting a row, which works for Column, Quantity, Time and SkyCoord.
     """
-    t = QTable(mixin_cols)
-    t0 = t.copy()
+    t0 = QTable(mixin_cols)
+    if empty_table:
+        t = t0[:0].copy()
+        insert_index = 0
+        result_indices = [-1]
+    else:
+        t = t0.copy()
+        insert_index = 1
+        result_indices = [0, -1, 1, 2, 3]
     t["m"].info.description = "d"
-    idxs = [0, -1, 1, 2, 3]
     if isinstance(
         t["m"], (u.Quantity, Column, time.Time, time.TimeDelta, coordinates.SkyCoord)
     ):
-        t.insert_row(1, t[-1])
+        t.insert_row(insert_index, t0[-1])
 
         for name in t.colnames:
-            col = t[name]
-            if isinstance(col, coordinates.SkyCoord):
-                assert skycoord_equal(col, t0[name][idxs])
-            else:
-                assert np.all(col == t0[name][idxs])
+            assert np.all(t[name] == t0[name][result_indices])
 
+        assert np.all(t == t0[result_indices])
         assert t["m"].info.description == "d"
     else:
         with pytest.raises(ValueError) as exc:
-            t.insert_row(1, t[-1])
+            t.insert_row(insert_index, t0[-1])
         assert "Unable to insert row" in str(exc.value)
 
 
diff --git a/astropy/table/tests/test_operations.py b/astropy/table/tests/test_operations.py
index d2cbce6942..93c1197f33 100644
--- a/astropy/table/tests/test_operations.py
+++ b/astropy/table/tests/test_operations.py
@@ -28,6 +28,7 @@ from astropy.units.quantity import Quantity
 from astropy.utils import metadata
 from astropy.utils.compat import NUMPY_LT_2_0
 from astropy.utils.compat.optional_deps import HAS_SCIPY
+from astropy.utils.masked import Masked
 from astropy.utils.metadata import MergeConflictError
 
 
@@ -35,6 +36,17 @@ def sort_eq(list1, list2):
     return sorted(list1) == sorted(list2)
 
 
+def check_cols_equal(col1, col2):
+    """Check that col1 == col2, taking care of zero-length masked columns."""
+    assert (
+        type(col1) is type(col2)
+        or (isinstance(col1, Masked) and type(col1) is Masked(type(col2)))
+        or (isinstance(col2, Masked) and type(col2) is Masked(type(col1)))
+    )
+    eq = np.all(col1 == col2)
+    return eq or isinstance(eq, Masked) and not eq.shape and eq.unmasked
+
+
 def check_mask(col, exp_mask):
     """Check that col.mask == exp_mask"""
     if hasattr(col, "mask"):
@@ -1438,15 +1450,22 @@ class TestVStack:
         assert (self.t1 == table.vstack(self.t1)).all()
         assert (self.t1 == table.vstack([self.t1])).all()
 
-    def test_mixin_functionality(self, mixin_cols):
-        col = mixin_cols["m"]
-        len_col = len(col)
-        t = table.QTable([col], names=["a"])
-        cls_name = type(col).__name__
+    @pytest.mark.parametrize("empty_table1", [False, True])
+    @pytest.mark.parametrize("empty_table2", [False, True])
+    def test_mixin_functionality(self, mixin_cols, empty_table1, empty_table2):
+        col1 = col2 = mixin_cols["m"]
+        if empty_table1:
+            col1 = col1[:0]
+        if empty_table2:
+            col2 = col2[:0]
+        len_col1 = len(col1)
+        t1 = table.QTable([col1], names=["a"])
+        len_col2 = len(col2)
+        t2 = table.QTable([col2], names=["a"])
 
         # Vstack works for these classes:
         if isinstance(
-            col,
+            col1,
             (
                 u.Quantity,
                 Time,
@@ -1457,40 +1476,32 @@ class TestVStack:
                 StokesCoord,
             ),
         ):
-            out = table.vstack([t, t])
-            assert len(out) == len_col * 2
-            if cls_name == "SkyCoord":
-                # Argh, SkyCoord needs __eq__!!
-                assert skycoord_equal(out["a"][len_col:], col)
-                assert skycoord_equal(out["a"][:len_col], col)
-            elif "Repr" in cls_name or "Diff" in cls_name:
-                assert np.all(representation_equal(out["a"][:len_col], col))
-                assert np.all(representation_equal(out["a"][len_col:], col))
-            else:
-                assert np.all(out["a"][:len_col] == col)
-                assert np.all(out["a"][len_col:] == col)
+            out = table.vstack([t1, t2])
+            assert len(out) == len_col1 + len_col2
+            assert check_cols_equal(out["a"][:len_col1], col1)
+            assert check_cols_equal(out["a"][len_col1:], col2)
         else:
-            msg = f"vstack unavailable for mixin column type(s): {cls_name}"
+            msg = f"vstack unavailable for mixin column type(s): {type(col1).__name__}"
             with pytest.raises(NotImplementedError, match=re.escape(msg)):
-                table.vstack([t, t])
+                table.vstack([t1, t2])
 
         # Check for outer stack which requires masking.  Works for
         # the listed mixins classes.
-        t2 = table.QTable([col], names=["b"])  # different from col name for t
-        if isinstance(col, (Time, TimeDelta, Quantity, SkyCoord)):
-            out = table.vstack([t, t2], join_type="outer")
-            assert len(out) == len_col * 2
-            assert np.all(out["a"][:len_col] == col)
-            assert np.all(out["b"][len_col:] == col)
-            assert check_mask(out["a"], [False] * len_col + [True] * len_col)
-            assert check_mask(out["b"], [True] * len_col + [False] * len_col)
+        t2 = table.QTable([col2], names=["b"])  # different from col name for t
+        if isinstance(col1, (Time, TimeDelta, Quantity, SkyCoord)):
+            out = table.vstack([t1, t2], join_type="outer")
+            assert len(out) == len_col1 + len_col2
+            assert check_cols_equal(out["a"][:len_col1], col1)
+            assert check_cols_equal(out["b"][len_col1:], col2)
+            assert check_mask(out["a"], [False] * len_col1 + [True] * len_col2)
+            assert check_mask(out["b"], [True] * len_col1 + [False] * len_col2)
             # check directly stacking mixin columns:
-            out2 = table.vstack([t, t2["b"]])
-            assert np.all(out["a"] == out2["a"])
-            assert np.all(out["b"] == out2["b"])
+            out2 = table.vstack([t1, t2["b"]])
+            assert check_cols_equal(out["a"], out2["a"])
+            assert check_cols_equal(out["b"], out2["b"])
         else:
             with pytest.raises(NotImplementedError) as err:
-                table.vstack([t, t2], join_type="outer")
+                table.vstack([t1, t2], join_type="outer")
             assert "vstack requires masking" in str(
                 err.value
             ) or "vstack unavailable" in str(err.value)
@@ -1512,6 +1523,15 @@ class TestVStack:
         with pytest.raises(ValueError, match="representations are inconsistent"):
             table.vstack([t1, t3])
 
+    def test_vstack_different_sky_coordinates(self):
+        """Test that SkyCoord can generally not be mixed together."""
+        sc1 = SkyCoord([1, 2] * u.deg, [3, 4] * u.deg)
+        sc2 = SkyCoord([5, 6] * u.deg, [7, 8] * u.deg, frame="fk5")
+        t1 = Table([sc1])
+        t2 = Table([sc2])
+        with pytest.raises(ValueError, match="coords are inconsistent"):
+            table.vstack([t1, t2])
+
     def test_vstack_structured_column(self):
         """Regression tests for gh-13271."""
         # Two tables with matching names, including a structured column.
@@ -2597,3 +2617,11 @@ def test_table_comp(t1, t2):
         assert not any(t2 == t1)
         assert all(t1 != t2)
         assert all(t2 != t1)
+
+
+def test_empty_skycoord_vstack():
+    # Explicit regression test for gh-17378
+    table1 = Table({"foo": SkyCoord([], [], unit="deg")})
+    table2 = table.vstack([table1, table1])  # Used to fail.
+    assert len(table2) == 0
+    assert isinstance(table2["foo"], SkyCoord)