Repository: astropy/astropy

Issue:
Add Frame objects without data to a Table
### What is the problem this feature will solve?

Please make it possible to store Frame objects that do not have data in Astropy tables.

I am working on an Astropy-based observation planning and scheduling toolkit for space missions, primarily for tiled imaging observations with wide-field cameras. A natural way for my code to represent the fields to observe is as SkyOffsetICRS objects without any attached data: these objects represent the RA, Dec, and roll of the spacecraft for the observation.

I would like my code to be able to save the planned timeline of observations as a Table which has a column of type SkyOffsetICRS. However, I find that Astropy does not allow me to do this. Here's a minimal reproducer:

```python
from astropy.coordinates import SkyCoord, SkyOffsetFrame
from astropy.table import Table
from astropy import units as u
import numpy as np

ra = [1, 2, 3] * u.deg
de = [4, 5, 6] * u.deg
pa = [7, 8, 9] * u.deg

# SkyOffsetFrame with dummy values is happy, but it's not what I want.
dummy = np.zeros(3) * u.deg
frame_with_dummy_data = SkyOffsetFrame(
    dummy, dummy, origin=SkyCoord(ra, de), rotation=pa)
Table({'frame': frame_with_dummy_data})

# SkyCoord with dummy values is happy, but it's not what I want either.
dummy_coord_with_frame = SkyCoord(
    dummy, dummy, frame=SkyOffsetFrame(origin=SkyCoord(ra, de), rotation=pa))
Table({'coord': dummy_coord_with_frame})

# This is what I want: a bare frame with no data. But the Table constructor
# raises the following exception: `ValueError: The frame object
# "<SkyOffsetICRS ...>" does not have associated data`
frame = SkyOffsetFrame(origin=SkyCoord(ra, de), rotation=pa)
Table({'frame': frame})
```

### Describe the desired outcome

Add support for adding Frame objects that do not have data as columns in tables.


### Additional context

_No response_

Discussion:
github-actions[bot]: Thank you for your contribution to Astropy! 🌌 This checklist is meant to remind the package maintainers who will review this pull request of some common things to look for.

  - [ ] Do the proposed changes actually accomplish desired goals?
  - [ ] Do the proposed changes follow the [Astropy coding guidelines](https://docs.astropy.org/en/latest/development/codeguide.html)?
  - [ ] Are tests added/updated as required? If so, do they follow the [Astropy testing guidelines](https://docs.astropy.org/en/latest/development/testguide.html)?
  - [ ] Are docs added/updated as required? If so, do they follow the [Astropy documentation guidelines](https://docs.astropy.org/en/latest/development/docguide.html)?
  - [ ] Is rebase and/or squash necessary? If so, please provide the author with appropriate instructions. Also see instructions for [rebase](https://docs.astropy.org/en/latest/development/development_details.html#rebase-if-necessary) and [squash](https://docs.astropy.org/en/latest/development/development_details.html#squash-if-necessary).
  - [ ] Did the CI pass? If no, are the failures related? If you need to run daily and weekly cron jobs as part of the PR, please apply the "Extra CI" label. Codestyle issues can be fixed by the [bot](https://docs.astropy.org/en/latest/development/development_details.html#pre-commit).
  - [ ] Is a change log needed? If yes, did the change log check pass? If no, add the "no-changelog-entry-needed" label. If this is a manual backport, use the "skip-changelog-checks" label unless special changelog handling is necessary.
  - [ ] Is this a big PR that makes a "What's new?" entry worthwhile and if so, is (1) a "what's new" entry included in this PR and (2) the "whatsnew-needed" label applied?
  - [ ] At the time of adding the milestone, if the milestone set requires a backport to release branch(es), apply the appropriate "backport-X.Y.x" label(s) *before* merge.
lpsinger: Very nice!
ayshih: So, how can the serialization support be extended so that it will also work for SunPy stuff?  We have both:

- our own frame classes, so they are not under `astropy.coordinates.builtin_frames`
- our own frame creator a la `SkyOffsetFrame`, so the created frame classes start with `abc.RotatedSun...`
mhvk: @ayshih - maybe a better solution than what I have is to check for `BaseFrame` subclasses. But the on-the-fly frames are trickier, since there needs to be some way to know how to recreate them. Let me ping @taldcroft about that, since we discussed how to solve this serialization issue before (without a good solution, clearly!). Note that I don't think this PR necessarily should wait for that to be resolved - this problem really holds for any class that uses `.info` to provide a way for it to be included in a `Table` - right now, it is not possible to roundtrip through ECSV.
mhvk: @taldcroft - coordinate frames now have a `len()` even without data, so I rebased this attempt to allow them to be stored in tables -- now the only changes to `table` itself are additions to `serialize` to ensure the frames can get recognized.

@StuartLittlefair, @adrn - this is the PR that actually uses the length of data-less frames (triggered by @lpsinger's need for storing such frames).

Patch:
diff --git a/astropy/coordinates/baseframe.py b/astropy/coordinates/baseframe.py
index 75b811eca9..c05b738d08 100644
--- a/astropy/coordinates/baseframe.py
+++ b/astropy/coordinates/baseframe.py
@@ -8,12 +8,14 @@ from __future__ import annotations
 
 __all__ = [
     "BaseCoordinateFrame",
+    "CoordinateFrameInfo",
     "frame_transform_graph",
     "GenericFrame",
     "RepresentationMapping",
 ]
 
 import copy
+import operator
 import warnings
 from collections import defaultdict
 from typing import TYPE_CHECKING, NamedTuple
@@ -22,6 +24,7 @@ import numpy as np
 
 from astropy import units as u
 from astropy.utils import ShapedLikeNDArray
+from astropy.utils.data_info import MixinInfo
 from astropy.utils.decorators import deprecated, format_doc, lazyproperty
 from astropy.utils.exceptions import AstropyWarning, _add_note_to_exception
 
@@ -134,6 +137,212 @@ class RepresentationMapping(NamedTuple):
     defaultunit: str | Unit = "recommended"
 
 
+class CoordinateFrameInfo(MixinInfo):
+    """
+    Container for meta information like name, description, format.  This is
+    required when the object is used as a mixin column within a table, but can
+    be used as a general way to store meta information.
+    """
+
+    attrs_from_parent = {"unit"}  # Unit is read-only
+    _supports_indexing = False
+
+    @staticmethod
+    def default_format(val):
+        repr_data = val.info._repr_data
+        formats = ["{0." + compname + ".value:}" for compname in repr_data.components]
+        return ",".join(formats).format(repr_data)
+
+    @property
+    def unit(self):
+        repr_data = self._repr_data
+        return (
+            ",".join(
+                str(getattr(repr_data, comp).unit) or "None"
+                for comp in repr_data.components
+            )
+            if repr_data is not None
+            else None
+        )
+
+    @property
+    def _repr_data(self):
+        coord = self._parent
+        if coord is None or not coord.has_data:
+            return None
+
+        if issubclass(
+            coord.representation_type, r.SphericalRepresentation
+        ) and isinstance(coord.data, r.UnitSphericalRepresentation):
+            repr_data = coord.represent_as(coord.data.__class__, in_frame_units=True)
+        else:
+            repr_data = coord.represent_as(
+                coord.representation_type, in_frame_units=True
+            )
+        return repr_data
+
+    def _represent_as_dict(self):
+        coord = self._parent
+        attrs = []
+        if coord.has_data:
+            attrs.extend(coord.representation_component_names)
+            # Don't output distance unless it's actually distance.
+            if isinstance(coord.data, r.UnitSphericalRepresentation):
+                attrs = attrs[:-1]
+
+            diff = coord.data.differentials.get("s")
+            if diff is not None:
+                diff_attrs = list(coord.get_representation_component_names("s"))
+                # Don't output proper motions if they haven't been specified.
+                if isinstance(diff, r.RadialDifferential):
+                    diff_attrs = diff_attrs[2:]
+                    # Don't output radial velocity unless it's actually velocity.
+                elif isinstance(
+                    diff,
+                    (r.UnitSphericalDifferential, r.UnitSphericalCosLatDifferential),
+                ):
+                    diff_attrs = diff_attrs[:-1]
+                attrs.extend(diff_attrs)
+
+        attrs.extend(frame_transform_graph.frame_attributes.keys())
+
+        out = super()._represent_as_dict(attrs)
+
+        out["representation_type"] = coord.representation_type.get_name()
+        # Note that coord.info.unit is a fake composite unit (e.g. 'deg,deg,None'
+        # or None,None,m) and is not stored. The individual attributes have
+        # units.
+
+        return out
+
+    def new_like(self, coords, length, metadata_conflicts="warn", name=None):
+        """A new consistent coordinate instance with the given length.
+
+        Return a new SkyCoord or BaseCoordinateFrame instance which is
+        consistent with the input coordinate objects ``coords`` and has
+        ``length`` rows.  Being "consistent" is defined as being able to set an
+        item from one to each of the rest without any exception being raised.
+
+        This is intended for creating a new coordinate instance whose elements
+        can be set in-place for table operations like join or vstack.  This is
+        used when a coordinate object is used as a mixin column in an astropy
+        Table.
+
+        The data values are not predictable and it is expected that the consumer
+        of the object will fill in all values.
+
+        Parameters
+        ----------
+        coords : list
+            List of input SkyCoord or BaseCoordinateFrame objects
+        length : int
+            Length of the output SkyCoord or BaseCoordinateFrame object
+        metadata_conflicts : str ('warn'|'error'|'silent')
+            How to handle metadata conflicts
+        name : str
+            Output name (sets output coord.info.name)
+
+        Returns
+        -------
+        coord : |SkyCoord|, |BaseFrame|
+            Instance of this class consistent with ``coords``
+
+        """
+        # Get merged info attributes like shape, dtype, format, description, etc.
+        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]
+
+        # Use __setitem__ machinery to check for consistency of all coords
+        for coord in coords[1:]:
+            try:
+                out[0] = coord[0]
+            except Exception as err:
+                raise ValueError("Input coords are inconsistent.") from err
+
+        # Set (merged) info attributes
+        for attr in ("name", "meta", "description"):
+            if attr in attrs:
+                setattr(out.info, attr, attrs[attr])
+
+        return out
+
+    def _insert(self, obj, values, axis=0):
+        """
+        Make a copy with coordinate values inserted before the given indices.
+
+        The values to be inserted must conform to the rules for in-place setting
+        of the object.
+
+        The API signature matches the ``np.insert`` API, but is more limited.
+        The specification of insert index ``obj`` must be a single integer,
+        and the ``axis`` must be ``0`` for simple insertion before the index.
+
+        Parameters
+        ----------
+        obj : int
+            Integer index before which ``values`` is inserted.
+        values : array-like
+            Value(s) to insert.  If the type of ``values`` is different
+            from that of quantity, ``values`` is converted to the matching type.
+        axis : int, optional
+            Axis along which to insert ``values``.  Default is 0, which is the
+            only allowed value and will insert a row.
+
+        Returns
+        -------
+        coord : |SkyCoord|, |BaseFrame|
+            Copy of instance with new values inserted.
+        """
+        # TODO: move this up to MixinInfo, so it can be used for Time too?
+        coord = self._parent
+        # Validate inputs: obj arg is integer, axis=0, coord is not a scalar, and
+        # input index is in bounds.
+        try:
+            idx0 = operator.index(obj)
+        except TypeError:
+            raise TypeError("obj arg must be an integer")
+
+        if axis != 0:
+            raise ValueError("axis must be 0")
+
+        if not coord.shape:
+            raise TypeError(
+                f"cannot insert into scalar {coord.__class__.__name__} object"
+            )
+
+        if abs(idx0) > len(coord):
+            raise IndexError(
+                f"index {idx0} is out of bounds for axis 0 with size {len(coord)}"
+            )
+
+        # Turn negative index into positive
+        if idx0 < 0:
+            idx0 = len(coord) + idx0
+
+        n_values = len(values) if values.shape else 1
+
+        # Finally make the new object with the correct length and set values for the
+        # three sections, before insert, the insert, and after the insert.
+        out = self.new_like([coord], len(coord) + n_values, name=self.name)
+
+        # Set the output values. This is where validation of `values` takes place to ensure
+        # that it can indeed be inserted.
+        out[:idx0] = coord[:idx0]
+        out[idx0 : idx0 + n_values] = values
+        out[idx0 + n_values :] = coord[idx0:]
+
+        return out
+
+
 base_doc = """{__doc__}
     Parameters
     ----------
@@ -220,6 +429,10 @@ class BaseCoordinateFrame(ShapedLikeNDArray):
     frame_attributes = {}
     # Default empty frame_attributes dict
 
+    # Declare that BaseCoordinateFrame can be used as a Table column by defining
+    # the info property.
+    info = CoordinateFrameInfo()
+
     def __init_subclass__(cls, **kwargs):
         # We first check for explicitly set values for these:
         default_repr = getattr(cls, "default_representation", None)
@@ -1519,6 +1732,12 @@ class BaseCoordinateFrame(ShapedLikeNDArray):
 
         new._shape = new_shape
 
+        # Copy other 'info' attr only if it has actually been defined.
+        # See PR #3898 for further explanation and justification, along
+        # with Quantity.__array_finalize__
+        if "info" in self.__dict__:
+            new.info = self.info
+
         return new
 
     def __setitem__(self, item, value):
@@ -1574,6 +1793,11 @@ class BaseCoordinateFrame(ShapedLikeNDArray):
 
         self.cache.clear()
 
+    def insert(self, obj, values, axis=0):
+        return self.info._insert(obj, values, axis)
+
+    insert.__doc__ = CoordinateFrameInfo._insert.__doc__
+
     def __dir__(self):
         """
         Override the builtin `dir` behavior to include representation
diff --git a/astropy/coordinates/sky_coordinate.py b/astropy/coordinates/sky_coordinate.py
index e5bc16d596..2b3d6f090d 100644
--- a/astropy/coordinates/sky_coordinate.py
+++ b/astropy/coordinates/sky_coordinate.py
@@ -1,7 +1,6 @@
 from __future__ import annotations
 
 import copy
-import operator
 import re
 import warnings
 from typing import TYPE_CHECKING
@@ -15,18 +14,19 @@ from astropy.table import QTable
 from astropy.time import Time
 from astropy.utils import ShapedLikeNDArray
 from astropy.utils.compat import COPY_IF_NEEDED
-from astropy.utils.data_info import MixinInfo
 from astropy.utils.exceptions import AstropyUserWarning
 
 from .angles import Angle
-from .baseframe import BaseCoordinateFrame, GenericFrame, frame_transform_graph
+from .baseframe import (
+    BaseCoordinateFrame,
+    CoordinateFrameInfo,
+    GenericFrame,
+    frame_transform_graph,
+)
 from .distances import Distance
 from .representation import (
-    RadialDifferential,
     SphericalDifferential,
     SphericalRepresentation,
-    UnitSphericalCosLatDifferential,
-    UnitSphericalDifferential,
     UnitSphericalRepresentation,
 )
 from .sky_coordinate_parsers import (
@@ -41,134 +41,13 @@ if TYPE_CHECKING:
 __all__ = ["SkyCoord", "SkyCoordInfo"]
 
 
-class SkyCoordInfo(MixinInfo):
-    """
-    Container for meta information like name, description, format.  This is
-    required when the object is used as a mixin column within a table, but can
-    be used as a general way to store meta information.
-    """
-
-    attrs_from_parent = {"unit"}  # Unit is read-only
-    _supports_indexing = False
-
-    @staticmethod
-    def default_format(val):
-        repr_data = val.info._repr_data
-        formats = ["{0." + compname + ".value:}" for compname in repr_data.components]
-        return ",".join(formats).format(repr_data)
-
-    @property
-    def unit(self):
-        repr_data = self._repr_data
-        unit = ",".join(
-            str(getattr(repr_data, comp).unit) or "None"
-            for comp in repr_data.components
-        )
-        return unit
-
-    @property
-    def _repr_data(self):
-        if self._parent is None:
-            return None
-
-        sc = self._parent
-        if issubclass(sc.representation_type, SphericalRepresentation) and isinstance(
-            sc.data, UnitSphericalRepresentation
-        ):
-            repr_data = sc.represent_as(sc.data.__class__, in_frame_units=True)
-        else:
-            repr_data = sc.represent_as(sc.representation_type, in_frame_units=True)
-        return repr_data
-
+class SkyCoordInfo(CoordinateFrameInfo):
+    # Information for a SkyCoord is almost identical to that of a frame;
+    # we only need to add the name of the frame used underneath.
     def _represent_as_dict(self):
         sc = self._parent
-        attrs = list(sc.representation_component_names)
-
-        # Don't output distance unless it's actually distance.
-        if isinstance(sc.data, UnitSphericalRepresentation):
-            attrs = attrs[:-1]
-
-        diff = sc.data.differentials.get("s")
-        if diff is not None:
-            diff_attrs = list(sc.get_representation_component_names("s"))
-            # Don't output proper motions if they haven't been specified.
-            if isinstance(diff, RadialDifferential):
-                diff_attrs = diff_attrs[2:]
-            # Don't output radial velocity unless it's actually velocity.
-            elif isinstance(
-                diff, (UnitSphericalDifferential, UnitSphericalCosLatDifferential)
-            ):
-                diff_attrs = diff_attrs[:-1]
-            attrs.extend(diff_attrs)
-
-        attrs.extend(frame_transform_graph.frame_attributes.keys())
-
-        out = super()._represent_as_dict(attrs)
-
-        out["representation_type"] = sc.representation_type.get_name()
+        out = super()._represent_as_dict()
         out["frame"] = sc.frame.name
-        # Note that sc.info.unit is a fake composite unit (e.g. 'deg,deg,None'
-        # or None,None,m) and is not stored.  The individual attributes have
-        # units.
-
-        return out
-
-    def new_like(self, skycoords, length, metadata_conflicts="warn", name=None):
-        """
-        Return a new SkyCoord instance which is consistent with the input
-        SkyCoord objects ``skycoords`` and has ``length`` rows.  Being
-        "consistent" is defined as being able to set an item from one to each of
-        the rest without any exception being raised.
-
-        This is intended for creating a new SkyCoord instance whose elements can
-        be set in-place for table operations like join or vstack.  This is used
-        when a SkyCoord object is used as a mixin column in an astropy Table.
-
-        The data values are not predictable and it is expected that the consumer
-        of the object will fill in all values.
-
-        Parameters
-        ----------
-        skycoords : list
-            List of input SkyCoord objects
-        length : int
-            Length of the output skycoord object
-        metadata_conflicts : str ('warn'|'error'|'silent')
-            How to handle metadata conflicts
-        name : str
-            Output name (sets output skycoord.info.name)
-
-        Returns
-        -------
-        skycoord : |SkyCoord| (or subclass)
-            Instance of this class consistent with ``skycoords``
-
-        """
-        # Get merged info attributes like shape, dtype, format, description, etc.
-        attrs = self.merge_cols_attributes(
-            skycoords, metadata_conflicts, name, ("meta", "description")
-        )
-        skycoord0 = skycoords[0]
-
-        # Make a new SkyCoord object with the desired length and attributes
-        # by using the _apply / __getitem__ machinery to effectively return
-        # skycoord0[[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 = skycoord0[indexes]
-
-        # Use __setitem__ machinery to check for consistency of all skycoords
-        for skycoord in skycoords[1:]:
-            try:
-                out[0] = skycoord[0]
-            except Exception as err:
-                raise ValueError("Input skycoords are inconsistent.") from err
-
-        # Set (merged) info attributes
-        for attr in ("name", "meta", "description"):
-            if attr in attrs:
-                setattr(out.info, attr, attrs[attr])
-
         return out
 
 
@@ -509,73 +388,9 @@ class SkyCoord(ShapedLikeNDArray):
         self._sky_coord_frame[item] = value._sky_coord_frame
 
     def insert(self, obj, values, axis=0):
-        """
-        Insert coordinate values before the given indices in the object and
-        return a new Frame object.
-
-        The values to be inserted must conform to the rules for in-place setting
-        of |SkyCoord| objects.
-
-        The API signature matches the ``np.insert`` API, but is more limited.
-        The specification of insert index ``obj`` must be a single integer,
-        and the ``axis`` must be ``0`` for simple insertion before the index.
-
-        Parameters
-        ----------
-        obj : int
-            Integer index before which ``values`` is inserted.
-        values : array-like
-            Value(s) to insert.  If the type of ``values`` is different
-            from that of quantity, ``values`` is converted to the matching type.
-        axis : int, optional
-            Axis along which to insert ``values``.  Default is 0, which is the
-            only allowed value and will insert a row.
-
-        Returns
-        -------
-        out : `~astropy.coordinates.SkyCoord` instance
-            New coordinate object with inserted value(s)
-
-        """
-        # Validate inputs: obj arg is integer, axis=0, self is not a scalar, and
-        # input index is in bounds.
-        try:
-            idx0 = operator.index(obj)
-        except TypeError:
-            raise TypeError("obj arg must be an integer")
-
-        if axis != 0:
-            raise ValueError("axis must be 0")
-
-        if not self.shape:
-            raise TypeError(
-                f"cannot insert into scalar {self.__class__.__name__} object"
-            )
-
-        if abs(idx0) > len(self):
-            raise IndexError(
-                f"index {idx0} is out of bounds for axis 0 with size {len(self)}"
-            )
-
-        # Turn negative index into positive
-        if idx0 < 0:
-            idx0 = len(self) + idx0
-
-        n_values = len(values) if values.shape else 1
-
-        # Finally make the new object with the correct length and set values for the
-        # three sections, before insert, the insert, and after the insert.
-        out = self.__class__.info.new_like(
-            [self], len(self) + n_values, name=self.info.name
-        )
+        return self.info._insert(obj, values, axis)
 
-        # Set the output values. This is where validation of `values` takes place to ensure
-        # that it can indeed be inserted.
-        out[:idx0] = self[:idx0]
-        out[idx0 : idx0 + n_values] = values
-        out[idx0 + n_values :] = self[idx0:]
-
-        return out
+    insert.__doc__ = SkyCoordInfo._insert.__doc__
 
     def is_transformable_to(self, new_frame):
         """
@@ -900,30 +715,30 @@ class SkyCoord(ShapedLikeNDArray):
 
     def __setattr__(self, attr, val):
         # This is to make anything available through __getattr__ immutable
-        if "_sky_coord_frame" in self.__dict__:
-            if self._is_name(attr):
-                raise AttributeError(f"'{attr}' is immutable")
+        if attr != "info" and not attr.startswith("_"):
+            if "_sky_coord_frame" in self.__dict__:
+                if self._is_name(attr):
+                    raise AttributeError(f"'{attr}' is immutable")
 
-            if not attr.startswith("_") and hasattr(self._sky_coord_frame, attr):
-                setattr(self._sky_coord_frame, attr, val)
+                if hasattr(self._sky_coord_frame, attr):
+                    setattr(self._sky_coord_frame, attr, val)
+                    return
+
+            if attr in frame_transform_graph.frame_attributes:
+                # All possible frame attributes can be set, but only via a private
+                # variable.  See __getattr__ above.
+                super().__setattr__("_" + attr, val)
+                # Validate it
+                frame_transform_graph.frame_attributes[attr].__get__(self)
+                # And add to set of extra attributes
+                self._extra_frameattr_names |= {attr}
                 return
 
-            frame_cls = frame_transform_graph.lookup_name(attr)
-            if frame_cls is not None and self.frame.is_transformable_to(frame_cls):
+            if frame_transform_graph.lookup_name(attr) is not None:
                 raise AttributeError(f"'{attr}' is immutable")
 
-        if attr in frame_transform_graph.frame_attributes:
-            # All possible frame attributes can be set, but only via a private
-            # variable.  See __getattr__ above.
-            super().__setattr__("_" + attr, val)
-            # Validate it
-            frame_transform_graph.frame_attributes[attr].__get__(self)
-            # And add to set of extra attributes
-            self._extra_frameattr_names |= {attr}
-
-        else:
-            # Otherwise, do the standard Python attribute setting
-            super().__setattr__(attr, val)
+        # Otherwise, do the standard Python attribute setting
+        super().__setattr__(attr, val)
 
     def __delattr__(self, attr):
         # mirror __setattr__ above
diff --git a/astropy/table/serialize.py b/astropy/table/serialize.py
index 96b4ce6d0a..19e019b1f9 100644
--- a/astropy/table/serialize.py
+++ b/astropy/table/serialize.py
@@ -336,7 +336,14 @@ def _construct_mixin_from_obj_attrs_and_info(obj_attrs, info):
         mixin.info.name = info["name"]
         return mixin
 
-    if cls_full_name not in __construct_mixin_classes:
+    # We translate locally created skyoffset frames and treat all
+    # built-in frames as known.
+    if cls_full_name.startswith("abc.SkyOffset"):
+        cls_full_name = "astropy.coordinates.SkyOffsetFrame"
+    elif (
+        cls_full_name not in __construct_mixin_classes
+        and not cls_full_name.startswith("astropy.coordinates.builtin_frames")
+    ):
         raise ValueError(f"unsupported class for construct {cls_full_name}")
 
     mod_name, _, cls_name = cls_full_name.rpartition(".")
diff --git a/docs/changes/coordinates/16831.feature.rst b/docs/changes/coordinates/16831.feature.rst
new file mode 100644
index 0000000000..53b2e57392
--- /dev/null
+++ b/docs/changes/coordinates/16831.feature.rst
@@ -0,0 +1,5 @@
+``BaseCoordinateFrame`` instances such as ``ICRS``, ``SkyOffsetFrame``, etc.,
+can now be stored directly in tables (previously, they were stored as
+``object`` type columns). Furthermore, storage in tables is now also possible
+for frames that have no data (but which have attributes with the correct shape
+to fit in the table).
diff --git a/docs/whatsnew/7.0.rst b/docs/whatsnew/7.0.rst
index f055219d19..609b8495cb 100644
--- a/docs/whatsnew/7.0.rst
+++ b/docs/whatsnew/7.0.rst
@@ -13,6 +13,7 @@ the 6.1 release.
 In particular, this release includes:
 
 * :ref:`whatsnew-7.0-table-masked-quantity`
+* :ref:`whatsnew-7.0-table-frames`
 * :ref:`whatsnew_7_0_quantity_to_string_formatter`
 * :ref:`whatsnew_7_0_ecsv_meta_default_dict`
 * :ref:`whatsnew_7_0_contributor_doc_improvement`
@@ -41,6 +42,20 @@ fixes functions like reading of table data from a list of dict that includes
 quantities with missing entries, and aggregation of ``MaskedQuantity`` in
 table groups.
 
+.. _whatsnew-7.0-table-frames:
+
+Coordinate frames can now be stored in tables
+=============================================
+
+Coordinate frames like ``ICRS`` and ``AltAz`` can now be stored in tables, as
+was already the case for ``SkyCoord`` and the underlying representations such
+as ``SphericalRepresentation``.
+
+This includes all frames, also those that do not have associated data, such as
+a ``SkyOffsetFrame`` in which the RA, Dec of the origin might represent a
+pointing directions for a tiled observation, and the position angle the roll of
+a spacecraft.
+
 .. _whatsnew_7_0_quantity_to_string_formatter:
 
 ``Quantity.to_string`` supports ``formatter`` for formatting


Test patch:
diff --git a/astropy/coordinates/tests/test_frames.py b/astropy/coordinates/tests/test_frames.py
index 01589bb07d..3858e84359 100644
--- a/astropy/coordinates/tests/test_frames.py
+++ b/astropy/coordinates/tests/test_frames.py
@@ -39,6 +39,7 @@ from astropy.coordinates.representation import (
     REPRESENTATION_CLASSES,
     CartesianDifferential,
 )
+from astropy.coordinates.tests.helper import skycoord_equal
 from astropy.tests.helper import PYTEST_LT_8_0
 from astropy.tests.helper import assert_quantity_allclose as assert_allclose
 from astropy.time import Time
@@ -1755,3 +1756,22 @@ def test_spherical_offsets_with_wrap(shape):
 
     scom = sc.spherical_offsets_by(-2 * u.deg, 0 * u.deg)
     assert scom.shape == shape
+
+
+def test_insert():
+    # Tests are a subset of those in test_sky_coord.
+    c0 = ICRS([1, 2] * u.deg, [3, 4] * u.deg)
+    c1 = ICRS(5 * u.deg, 6 * u.deg)
+    c3 = ICRS([10, 20] * u.deg, [30, 40] * u.deg)
+
+    # Insert a scalar
+    c = c0.insert(1, c1)
+    assert skycoord_equal(c, ICRS([1, 5, 2] * u.deg, [3, 6, 4] * u.deg))
+
+    # Insert length=2 array at start of array
+    c = c0.insert(0, c3)
+    assert skycoord_equal(c, ICRS([10, 20, 1, 2] * u.deg, [30, 40, 3, 4] * u.deg))
+
+    # Insert length=2 array at end of array
+    c = c0.insert(2, c3)
+    assert skycoord_equal(c, ICRS([1, 2, 10, 20] * u.deg, [3, 4, 30, 40] * u.deg))
diff --git a/astropy/io/tests/mixin_columns.py b/astropy/io/tests/mixin_columns.py
index da287487fc..57418dd9cc 100644
--- a/astropy/io/tests/mixin_columns.py
+++ b/astropy/io/tests/mixin_columns.py
@@ -46,6 +46,24 @@ scpmrv = coordinates.SkyCoord(
 scrv = coordinates.SkyCoord(
     [1, 2], [3, 4], [5, 6], unit="deg,deg,pc", radial_velocity=[11, 12] * u.km / u.s
 )
+icrs = coordinates.ICRS([1, 2] * u.deg, [3, 4] * u.deg)
+altaz = coordinates.AltAz(
+    [1, 2] * u.deg,
+    [3, 4] * u.deg,
+    obstime=time.Time("2010-11-12T13:14:15"),
+    location=el,
+)
+so = coordinates.SkyOffsetFrame(
+    [-1, -2] * u.deg,
+    [-3, -4] * u.deg,
+    origin=altaz,
+    rotation=[5, 6] * u.deg,
+)
+sond = coordinates.SkyOffsetFrame(
+    origin=icrs,
+    rotation=[5, 6] * u.deg,
+)
+
 tm = time.Time(
     [51000.5, 51001.5], format="mjd", scale="tai", precision=5, location=el[0]
 )
@@ -78,6 +96,10 @@ mixin_cols = {
     "scpm": scpm,
     "scpmrv": scpmrv,
     "scrv": scrv,
+    "icrs": icrs,
+    "altaz": altaz,
+    "so": so,
+    "sond": sond,
     "x": [1, 2] * u.m,
     "qdb": [10, 20] * u.dB(u.mW),
     "qdex": [4.5, 5.5] * u.dex(u.cm / u.s**2),
@@ -141,6 +163,10 @@ compare_attrs = {
         "representation_type",
         "frame.name",
     ],
+    "icrs": ["ra", "dec", "representation_type"],
+    "altaz": ["alt", "az", "obstime", "location", "representation_type"],
+    "so": ["lon", "lat", "rotation", "origin"],
+    "sond": ["rotation", "origin"],
     "x": ["value", "unit"],
     "qdb": ["value", "unit"],
     "qdex": ["value", "unit"],
@@ -190,6 +216,25 @@ non_trivial_names = {
         "scpmrv.radial_velocity",
     ],
     "scrv": ["scrv.ra", "scrv.dec", "scrv.distance", "scrv.radial_velocity"],
+    "icrs": ["icrs.ra", "icrs.dec"],
+    "altaz": [
+        "altaz.az",
+        "altaz.alt",
+        "altaz.location.x",
+        "altaz.location.y",
+        "altaz.location.z",
+    ],
+    "so": [
+        "so.lon",
+        "so.lat",
+        "so.rotation",
+        "so.origin.az",
+        "so.origin.alt",
+        "so.origin.location.x",
+        "so.origin.location.y",
+        "so.origin.location.z",
+    ],
+    "sond": ["sond.rotation", "sond.origin.ra", "sond.origin.dec"],
     "sd": ["sd.d_lon_coslat", "sd.d_lat", "sd.d_distance"],
     "sr": ["sr.lon", "sr.lat", "sr.distance"],
     "srd": [