Repository: astropy/astropy

Issue:
# Tables with custom dtype cannot be stacked

### Description

As found in https://github.com/nanograv/PINT/issues/1944#issuecomment-3774332270, where an experiment was run using a column with a quadruple precision dtype (https://github.com/numpy/numpy-quaddtype): tables can hold numpy arrays with that dtype, but then they cannot be stacked, because internally tables use a string representation of the dtype instead of the actual one (and that string representation is not recognized by `np.dtype`).

~I'll investigate a bit more once I'm at a computer where I can install quaddtype myself...~ For details, see below.

### Expected behavior

No reason user dtypes should not work

### How to Reproduce
```
python3 -m venv temp
source temp/bin/activate 
pip install git+https://github.com/numpy/numpy-quaddtype.git#egg=numpy-quaddtype
pip install astropy ipython
```
And then in `ipython`:
```python
from astropy.table import Table, Column, vstack
from numpy_quaddtype import QuadPrecDType
c = Column([1, 2], dtype=QuadPrecDType()) + 1e-25
print(c-[1,2])
#                              None                            
# -------------------------------------------------------------
# 0.00000000000000000000000010000000002821819343901975675512978
# 0.00000000000000000000000010000000002821819343901975675512978
t = Table([c], names=["c"])
vstack([t, t])
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
Cell In[5], line 1
----> 1 vstack([t, t])

File ~/data/mhvk/packages/numpy-quaddtype/temp/lib/python3.13/site-packages/astropy/table/operations.py:715, in vstack(tables, join_type, metadata_conflicts)
    712 if len(tables) == 1:
    713     return tables[0]  # no point in stacking a single table
--> 715 out = _vstack(tables, join_type, metadata_conflicts)
    717 # Merge table metadata
    718 _merge_table_meta(out, tables, metadata_conflicts=metadata_conflicts)

File ~/data/mhvk/packages/numpy-quaddtype/temp/lib/python3.13/site-packages/astropy/table/operations.py:1492, in _vstack(arrays, join_type, metadata_conflicts)
   1488     raise NotImplementedError(
   1489         f"vstack unavailable for mixin column type(s): {col_cls.__name__}"
   1490     )
   1491 try:
-> 1492     col = col_cls.info.new_like(cols, n_rows, metadata_conflicts, out_name)
   1493 except metadata.MergeConflictError as err:
   1494     # Beautify the error message when we are trying to merge columns with incompatible
   1495     # types by including the name of the columns that originated the error.
   1496     raise TableMergeError(
   1497         f"The '{out_name}' columns have incompatible types: "
   1498         f"{err._incompat_types}"
   1499     ) from err

File ~/data/mhvk/packages/numpy-quaddtype/temp/lib/python3.13/site-packages/astropy/table/column.py:483, in ColumnInfo.new_like(self, cols, length, metadata_conflicts, name)
    455 """
    456 Return a new Column instance which is consistent with the
    457 input ``cols`` and has ``length`` rows.
   (...)    477 
    478 """
    479 attrs = self.merge_cols_attributes(
    480     cols, metadata_conflicts, name, ("meta", "unit", "format", "description")
    481 )
--> 483 return self._parent_cls(length=length, **attrs)

File ~/data/mhvk/packages/numpy-quaddtype/temp/lib/python3.13/site-packages/astropy/table/column.py:1251, in Column.__new__(cls, data, name, dtype, shape, length, description, unit, format, meta, copy, copy_indices)
   1246 if isinstance(data, MaskedColumn) and np.any(data.mask):
   1247     raise TypeError(
   1248         "Cannot convert a MaskedColumn with masked value to a Column"
   1249     )
-> 1251 self = super().__new__(
   1252     cls,
   1253     data=data,
   1254     name=name,
   1255     dtype=dtype,
   1256     shape=shape,
   1257     length=length,
   1258     description=description,
   1259     unit=unit,
   1260     format=format,
   1261     meta=meta,
   1262     copy=copy,
   1263     copy_indices=copy_indices,
   1264 )
   1265 return self

File ~/data/mhvk/packages/numpy-quaddtype/temp/lib/python3.13/site-packages/astropy/table/column.py:517, in BaseColumn.__new__(cls, data, name, dtype, shape, length, description, unit, format, meta, copy, copy_indices)
    502 def __new__(
    503     cls,
    504     data=None,
   (...)    514     copy_indices=True,
    515 ):
    516     if data is None:
--> 517         self_data = np.zeros((length,) + shape, dtype=dtype)
    518     elif isinstance(data, BaseColumn) and hasattr(data, "_name"):
    519         # When unpickling a MaskedColumn, ``data`` will be a bare
    520         # BaseColumn with none of the expected attributes.  In this case
    521         # do NOT execute this block which initializes from ``data``
    522         # attributes.
    523         self_data = np.array(data.data, dtype=dtype, copy=copy)

TypeError: data type "QuadPrecDType(backend='sleef')" not understood
```


### Versions

```python
import astropy
astropy.system_info()

platform
--------
platform.platform() = 'Linux-6.17.13+deb14-amd64-x86_64-with-glibc2.42'
platform.version() = '#1 SMP PREEMPT_DYNAMIC Debian 6.17.13-1 (2025-12-20)'
platform.python_version() = '3.13.11'

packages
--------
astropy              7.2.0
numpy                2.4.1
scipy                --
matplotlib           --
pandas               --
pyerfa               2.0.1.5

```


Implementation patch:
diff --git a/astropy/table/operations.py b/astropy/table/operations.py
index 81e1c6262ed5..f1b71da10fb6 100644
--- a/astropy/table/operations.py
+++ b/astropy/table/operations.py
@@ -1011,7 +1011,7 @@ def get_descrs(arrays, col_name_map):
 
         # Output dtype is the superset of all dtypes in in_arrays
         try:
-            dtype = common_dtype(in_cols)
+            dtype = result_type(in_cols)
         except TableMergeError as tme:
             # Beautify the error message when we are trying to merge columns with incompatible
             # types by including the name of the columns that originated the error.
@@ -1033,7 +1033,7 @@ def get_descrs(arrays, col_name_map):
     return out_descrs
 
 
-def common_dtype(cols):
+def result_type(cols):
     """
     Use numpy to find the common dtype for a list of columns.
 
@@ -1041,7 +1041,7 @@ def common_dtype(cols):
     np.bool_, np.object_, np.number, np.character, np.void
     """
     try:
-        return metadata.common_dtype(cols)
+        return metadata.utils.result_type(cols)
     except metadata.MergeConflictError as err:
         tme = TableMergeError(f"Columns have incompatible types {err._incompat_types}")
         tme._incompat_types = err._incompat_types
@@ -1088,7 +1088,7 @@ def _get_join_sort_idxs(keys, left, right):
             sort_right[sort_key] = right_sort_col
 
             # Build up dtypes for the structured array that gets sorted.
-            dtype_str = common_dtype([left_sort_col, right_sort_col])
+            dtype_str = result_type([left_sort_col, right_sort_col])
             sort_keys_dtypes.append((sort_key, dtype_str))
             ii += 1
 
diff --git a/astropy/utils/compat/optional_deps.py b/astropy/utils/compat/optional_deps.py
index 808a9eba20c2..505944640d94 100644
--- a/astropy/utils/compat/optional_deps.py
+++ b/astropy/utils/compat/optional_deps.py
@@ -32,6 +32,7 @@
     "matplotlib",
     "mpmath",
     "narwhals",
+    "numpy_quaddtype",
     "pandas",
     "PIL",
     "polars",
diff --git a/astropy/utils/data_info.py b/astropy/utils/data_info.py
index 08f498fa27cf..b068dda20c17 100644
--- a/astropy/utils/data_info.py
+++ b/astropy/utils/data_info.py
@@ -738,7 +738,7 @@ def getattrs(col):
             )
 
         # Output dtype is the superset of all dtypes in in_cols
-        out["dtype"] = metadata.common_dtype(cols)
+        out["dtype"] = metadata.utils.result_type(cols)
 
         # Make sure all input shapes are the same
         uniq_shapes = {col.shape[1:] for col in cols}
diff --git a/astropy/utils/metadata/merge.py b/astropy/utils/metadata/merge.py
index c7ee740fb861..ec21cada0347 100644
--- a/astropy/utils/metadata/merge.py
+++ b/astropy/utils/metadata/merge.py
@@ -8,7 +8,7 @@
 import numpy as np
 
 from .exceptions import MergeConflictError, MergeConflictWarning
-from .utils import common_dtype
+from .utils import result_type
 
 __all__ = [
     "MERGE_STRATEGIES",
@@ -130,7 +130,7 @@ class MergeNpConcatenate(MergeStrategy):
     @classmethod
     def merge(cls, left, right):
         left, right = np.asanyarray(left), np.asanyarray(right)
-        common_dtype([left, right])  # Ensure left and right have compatible dtype
+        result_type([left, right])  # Ensure left and right have compatible dtype
         return np.concatenate([left, right])
 
 
diff --git a/astropy/utils/metadata/utils.py b/astropy/utils/metadata/utils.py
index 8f4f16b93732..19573d6462a6 100644
--- a/astropy/utils/metadata/utils.py
+++ b/astropy/utils/metadata/utils.py
@@ -3,8 +3,6 @@
 
 import numpy as np
 
-from astropy.utils.misc import dtype_bytes_or_chars
-
 from .exceptions import MergeConflictError
 
 __all__ = ["common_dtype"]
@@ -31,32 +29,41 @@ def common_dtype(arrs):
     dtype_str : str
         String representation of dytpe (dtype ``str`` attribute)
     """
+    dt = result_type(arrs)
+    return dt.str if dt.names is None else dt.descr
+
+
+def result_type(arrs):
+    """
+    Use numpy to find the common type for a list of ndarray.
+
+    The difference with `numpy.result_type` is that all arrays should
+    share the same fundamental numpy data type, one of:
+    ``np.bool_``, ``np.object_``, ``np.number``, ``np.character``, ``np.void``
+    Hence, a mix like integer and string will raise instead of resulting
+    in a string type.
+
+    Parameters
+    ----------
+    arrs : list of ndarray
+        Array likes for which to find the common dtype. Anything not
+        an array (e.g, |Time|), will be considered an object array.
+
+    Returns
+    -------
+    dtype : ~numpy.dtype
+        The common type.
+    """
+    dtypes = [dtype(arr) for arr in arrs]
     np_types = (np.bool_, np.object_, np.number, np.character, np.void)
     uniq_types = {
-        tuple(issubclass(dtype(arr).type, np_type) for np_type in np_types)
-        for arr in arrs
+        tuple(issubclass(dt.type, np_type) for np_type in np_types) for dt in dtypes
     }
     if len(uniq_types) > 1:
         # Embed into the exception the actual list of incompatible types.
-        incompat_types = [dtype(arr).name for arr in arrs]
+        incompat_types = [dt.name for dt in dtypes]
         tme = MergeConflictError(f"Arrays have incompatible types {incompat_types}")
         tme._incompat_types = incompat_types
         raise tme
 
-    arrs = [np.empty(1, dtype=dtype(arr)) for arr in arrs]
-
-    # For string-type arrays need to explicitly fill in non-zero
-    # values or the final arr_common = .. step is unpredictable.
-    for i, arr in enumerate(arrs):
-        if arr.dtype.kind in ("S", "U"):
-            arrs[i] = [
-                ("0" if arr.dtype.kind == "U" else b"0")
-                * dtype_bytes_or_chars(arr.dtype)
-            ]
-
-    arr_common = np.array([arr[0] for arr in arrs])
-    return (
-        arr_common.dtype.str
-        if arr_common.dtype.names is None
-        else arr_common.dtype.descr
-    )
+    return np.result_type(*dtypes)


Test patch:
diff --git a/astropy/table/tests/test_operations.py b/astropy/table/tests/test_operations.py
index 63d1b09d8c10..bd0541b6e392 100644
--- a/astropy/table/tests/test_operations.py
+++ b/astropy/table/tests/test_operations.py
@@ -6,6 +6,7 @@
 
 import numpy as np
 import pytest
+from numpy.testing import assert_array_equal
 
 from astropy import table
 from astropy import units as u
@@ -32,7 +33,7 @@
 from astropy.timeseries import TimeSeries
 from astropy.units.quantity import Quantity
 from astropy.utils import metadata
-from astropy.utils.compat.optional_deps import HAS_SCIPY
+from astropy.utils.compat.optional_deps import HAS_NUMPY_QUADDTYPE, HAS_SCIPY
 from astropy.utils.masked import Masked
 from astropy.utils.metadata import MergeConflictError
 
@@ -2600,3 +2601,16 @@ def test_empty_skycoord_vstack():
     table2 = table.vstack([table1, table1])  # Used to fail.
     assert len(table2) == 0
     assert isinstance(table2["foo"], SkyCoord)
+
+
+@pytest.mark.skipif(not HAS_NUMPY_QUADDTYPE, reason="Tests QuadDtype")
+def test_user_dtype_vstack():
+    # Regression test for gh-19197
+    from numpy_quaddtype import QuadPrecDType
+
+    c = Column([1, 2], dtype=QuadPrecDType()) + 1e-25
+    t = Table([c], names=["c"])
+    t2 = table.vstack([t, t])  # This used to fail
+    assert t2["c"].dtype == c.dtype
+    assert_array_equal(t2[:2]["c"], c)
+    assert_array_equal(t2[2:]["c"], c)
diff --git a/astropy/utils/metadata/tests/test_metadata.py b/astropy/utils/metadata/tests/test_metadata.py
index ea755ddf51ad..436c2f602586 100644
--- a/astropy/utils/metadata/tests/test_metadata.py
+++ b/astropy/utils/metadata/tests/test_metadata.py
@@ -13,6 +13,7 @@
     enable_merge_strategies,
     merge,
 )
+from astropy.utils.metadata.utils import result_type
 
 
 class OrderedDictSubclass(OrderedDict):
@@ -244,29 +245,30 @@ class MergeConcatStrings(metadata.MergePlus):
     metadata.MERGE_STRATEGIES = original_merge_strategies
 
 
-def test_common_dtype_string():
+def test_result_type_string():
     u3 = np.array(["123"])
     u4 = np.array(["1234"])
     b3 = np.array([b"123"])
     b5 = np.array([b"12345"])
-    assert common_dtype([u3, u4]).endswith("U4")
-    assert common_dtype([b5, u4]).endswith("U5")
-    assert common_dtype([b3, b5]).endswith("S5")
+    assert result_type([u3, u4]) == np.dtype("U4")
+    assert result_type([b5, u4]) == np.dtype("U5")
+    assert result_type([b3, b5]) == np.dtype("S5")
 
 
-def test_common_dtype_basic():
+def test_result_type_basic():
     i8 = np.array(1, dtype=np.int64)
     f8 = np.array(1, dtype=np.float64)
     u3 = np.array("123")
 
     with pytest.raises(MergeConflictError):
-        common_dtype([i8, u3])
+        result_type([i8, u3])
 
-    assert common_dtype([i8, i8]).endswith("i8")
-    assert common_dtype([i8, f8]).endswith("f8")
+    assert result_type([i8, i8]) == np.dtype("i8")
+    assert result_type([i8, f8]) == np.dtype("f8")
 
 
-def test_common_dtype_exhaustive():
+@pytest.mark.parametrize("function", [result_type, common_dtype])
+def test_finding_common_type_exhaustive(function):
     """
     Test that allowed combinations are those expected.
     """
@@ -286,7 +288,7 @@ def test_common_dtype_exhaustive():
     for name1, _ in dtype:
         for name2, _ in dtype:
             try:
-                common_dtype([arr[name1], arr[name2]])
+                function([arr[name1], arr[name2]])
                 succeed.add(f"{name1} {name2}")
             except MergeConflictError:
                 fail.add(f"{name1} {name2}")