Repository: scverse/spatialdata

Issue:
Naming constraints break compatibility with existing datasets
[`_check_valid_name`](https://github.com/scverse/spatialdata/blame/95d69ff7138305ee2a530bcc45f8460a06e8118b/src/spatialdata/_core/_elements.py#L34-L40) implemented stricter naming constraints in https://github.com/scverse/spatialdata/commit/137e1e06c946800599d55c45f18fe8a6a1fb06eb. We already have existing SpatialData datasets where `.` is used as separator for naming components with different meanings, like `Slide1.A2.0.pre_maldi`. When loading them with spatialdata 0.2.1, we get the error "Name must contain only alphanumeric characters, underscores, and hyphens". I would understand for Unicode characters, or characters reserved for SpatialData itself (`/`) or characters problematic on a subset of supported platforms (`:`, `?`, `\`…).

1. Is there a reason for not allowing `.` anymore? Will it be used by SpatialData itself, or the NGFF specification?

2. What would be a solution, can `.` be allowed again or should we change the way how we create new datasets and adjust all existing datasets to the new naming constraints?
   - `_` feels more like a replacement for space ` ` within a naming component
   - `-` feels more fitting for joining parts of a naming component, not separating them.
   - What allowed character would convey the meaning of separating components of a name?

Discussion:
LucaMarconato: Great addition @aeisenbarth! The mechanism to collect the exception and display a single message works great!

> There were still some redundant validations that I kept:
> When a given name is used to referr to an existing element (not to add a new one), we can assume the existing elements had been validated at construction time, and when an invalid name is queried, no element will be found. This is the case in SpatialData.write_element(element_name), SpatialData.write_transformations(element_name), SpatialData.write_metadata(element_name).
> I decided to remove the name validation in SpatialData.delete_element_from_disk, for the reason above, and especially if an element somehow got an invalid name we should allow deleting it.

Good points!

> Add a flag to optionally skip validation on reading and maybe on model construction.

I also prefer this option (option 1), but I would wait to see if there are multiple instances of having to correct the names of the datasets before making such change, so we keep the code simpler.

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..e69de29
diff --git a/docs/design_doc.md b/docs/design_doc.md
index af6cd88..d04eb0b 100644
--- a/docs/design_doc.md
+++ b/docs/design_doc.md
@@ -130,6 +130,21 @@ _SpatialData_ follows the OME-NGFF specifications whenever possible and therefor
 - Any `Element` MAY be annotated by `Tables`; also `Shapes` and `Points` MAY contain annotations within themselves as additional dataframe columns (e.g. intensity of point spread function of a each point, or gene id).
 - `Tables` CAN NOT be annotated by other `Tables`.
 
+#### Naming
+
+Names of SpatialData elements must fulfill certain restrictions to ensure robust storage and compatibility:
+
+- MUST NOT be the empty string ``.
+- MUST only contain alphanumeric characters or hyphens `-`, dots `.`, underscores `_`. Alphanumeric includes letters from different alphabets and number-like symbols, but excludes whitespace, slashes and other symbols.
+- MUST NOT be the full strings `.` or `..`, which would have special meanings as file paths.
+- MUST NOT start with double underscores `__`.
+- MUST NOT only differ in character case, to avoid name collisions on case-insensitive systems.
+
+In tables, the above restrictions apply to the column names of `obs` and `var`, and to the key names of the `obsm`,
+`obsp`, `varm`, `varp`, `uns`, `layers` slots (for example `adata.obs['has space']` and `adata.layers['.']` are not
+allowed).
+Additionally, dataframes in tables MUST NOT have a column named `_index`, which is reserved.
+
 #### Images
 
 Images of a sample. Should conform to the [OME-NGFF concept of an image](https://ngff.openmicroscopy.org/latest/#image-layout).
diff --git a/src/spatialdata/_core/_elements.py b/src/spatialdata/_core/_elements.py
index 29ed3cc..a72bf31 100644
--- a/src/spatialdata/_core/_elements.py
+++ b/src/spatialdata/_core/_elements.py
@@ -11,6 +11,7 @@ from anndata import AnnData
 from dask.dataframe import DataFrame as DaskDataFrame
 from geopandas import GeoDataFrame
 
+from spatialdata._core.validation import check_key_is_case_insensitively_unique, check_valid_name
 from spatialdata._types import Raster_T
 from spatialdata.models import (
     Image2DModel,
@@ -30,30 +31,30 @@ class Elements(UserDict[str, Any]):
         self._shared_keys = shared_keys
         super().__init__()
 
-    @staticmethod
-    def _check_valid_name(name: str) -> None:
-        if not isinstance(name, str):
-            raise TypeError(f"Name must be a string, not {type(name).__name__}.")
-        if len(name) == 0:
-            raise ValueError("Name cannot be an empty string.")
-        if not all(c.isalnum() or c in "_-" for c in name):
-            raise ValueError("Name must contain only alphanumeric characters, underscores, and hyphens.")
+    def _add_shared_key(self, key: str) -> None:
+        self._shared_keys.add(key)
+
+    def _remove_shared_key(self, key: str) -> None:
+        self._shared_keys.remove(key)
 
     @staticmethod
     def _check_key(key: str, element_keys: Iterable[str], shared_keys: set[str | None]) -> None:
-        Elements._check_valid_name(key)
+        check_valid_name(key)
         if key in element_keys:
             warn(f"Key `{key}` already exists. Overwriting it in-memory.", UserWarning, stacklevel=2)
         else:
-            if key in shared_keys:
-                raise KeyError(f"Key `{key}` already exists.")
+            try:
+                check_key_is_case_insensitively_unique(key, shared_keys)
+            except ValueError as e:
+                # Validation raises ValueError, but inappropriate mapping key must raise KeyError.
+                raise KeyError(*e.args) from e
 
     def __setitem__(self, key: str, value: Any) -> None:
-        self._shared_keys.add(key)
+        self._add_shared_key(key)
         super().__setitem__(key, value)
 
     def __delitem__(self, key: str) -> None:
-        self._shared_keys.remove(key)
+        self._remove_shared_key(key)
         super().__delitem__(key)
 
     def keys(self) -> KeysView[str]:
diff --git a/src/spatialdata/_core/spatialdata.py b/src/spatialdata/_core/spatialdata.py
index 0fe70dc..a931839 100644
--- a/src/spatialdata/_core/spatialdata.py
+++ b/src/spatialdata/_core/spatialdata.py
@@ -22,6 +22,13 @@ from shapely import MultiPolygon, Polygon
 from xarray import DataArray, DataTree
 
 from spatialdata._core._elements import Images, Labels, Points, Shapes, Tables
+from spatialdata._core.validation import (
+    check_all_keys_case_insensitively_unique,
+    check_target_region_column_symmetry,
+    check_valid_name,
+    raise_validation_errors,
+    validate_table_attr_keys,
+)
 from spatialdata._logging import logger
 from spatialdata._types import ArrayLike, Raster_T
 from spatialdata._utils import (
@@ -36,7 +43,6 @@ from spatialdata.models import (
     PointsModel,
     ShapesModel,
     TableModel,
-    check_target_region_column_symmetry,
     get_model,
     get_table_keys,
 )
@@ -147,26 +153,37 @@ class SpatialData:
                 f"Element names must be unique. The following element names are used multiple times: {duplicates}"
             )
 
-        if images is not None:
-            for k, v in images.items():
-                self.images[k] = v
-
-        if labels is not None:
-            for k, v in labels.items():
-                self.labels[k] = v
-
-        if shapes is not None:
-            for k, v in shapes.items():
-                self.shapes[k] = v
-
-        if points is not None:
-            for k, v in points.items():
-                self.points[k] = v
-
-        if tables is not None:
-            for k, v in tables.items():
-                self.validate_table_in_spatialdata(v)
-                self.tables[k] = v
+        with raise_validation_errors(
+            title="Cannot construct SpatialData object, input contains invalid elements.\n"
+            "For renaming, please see the discussion here https://github.com/scverse/spatialdata/discussions/707 .",
+            exc_type=(ValueError, KeyError),
+        ) as collect_error:
+
+            if images is not None:
+                for k, v in images.items():
+                    with collect_error(location=("images", k)):
+                        self.images[k] = v
+
+            if labels is not None:
+                for k, v in labels.items():
+                    with collect_error(location=("labels", k)):
+                        self.labels[k] = v
+
+            if shapes is not None:
+                for k, v in shapes.items():
+                    with collect_error(location=("shapes", k)):
+                        self.shapes[k] = v
+
+            if points is not None:
+                for k, v in points.items():
+                    with collect_error(location=("points", k)):
+                        self.points[k] = v
+
+            if tables is not None:
+                for k, v in tables.items():
+                    with collect_error(location=("tables", k)):
+                        self.validate_table_in_spatialdata(v)
+                        self.tables[k] = v
 
         self._query = QueryManager(self)
 
@@ -1142,6 +1159,20 @@ class SpatialData:
                     " the current Zarr store." + WORKAROUND
                 )
 
+    def _validate_all_elements(self) -> None:
+        with raise_validation_errors(
+            title="SpatialData contains elements with invalid names.\n"
+            "For renaming, please see the discussion here https://github.com/scverse/spatialdata/discussions/707 .",
+            exc_type=ValueError,
+        ) as collect_error:
+            for element_type, element_name, element in self.gen_elements():
+                element_path = (element_type, element_name)
+                with collect_error(location=element_path):
+                    check_valid_name(element_name)
+                if element_type == "tables":
+                    with collect_error(location=element_path):
+                        validate_table_attr_keys(element, location=element_path)
+
     def write(
         self,
         file_path: str | Path,
@@ -1177,6 +1208,7 @@ class SpatialData:
         if isinstance(file_path, str):
             file_path = Path(file_path)
         self._validate_can_safely_write_to_path(file_path, overwrite=overwrite)
+        self._validate_all_elements()
 
         store = parse_url(file_path, mode="w").store
         zarr_group = zarr.group(store=store, overwrite=overwrite)
@@ -1272,9 +1304,7 @@ class SpatialData:
                 self.write_element(name, overwrite=overwrite)
             return
 
-        from spatialdata._core._elements import Elements
-
-        Elements._check_valid_name(element_name)
+        check_valid_name(element_name)
         self._validate_element_names_are_unique()
         element = self.get(element_name)
         if element is None:
@@ -1293,6 +1323,8 @@ class SpatialData:
                 break
         if element_type is None:
             raise ValueError(f"Element with name {element_name} not found in SpatialData object.")
+        if element_type == "tables":
+            validate_table_attr_keys(element)
 
         self._check_element_not_on_disk_with_different_type(element_type=element_type, element_name=element_name)
 
@@ -1344,11 +1376,8 @@ class SpatialData:
                 self.delete_element_from_disk(name)
             return
 
-        from spatialdata._core._elements import Elements
         from spatialdata._io._utils import _backed_elements_contained_in_path
 
-        Elements._check_valid_name(element_name)
-
         if self.path is None:
             raise ValueError("The SpatialData object is not backed by a Zarr store.")
 
@@ -1480,10 +1509,10 @@ class SpatialData:
             The name of the element to write the channel names of. If None, write the channel names of all image
             elements.
         """
-        from spatialdata._core._elements import Elements
-
         if element_name is not None:
-            Elements._check_valid_name(element_name)
+            check_valid_name(element_name)
+            if element_name not in self:
+                raise ValueError(f"Element with name {element_name} not found in SpatialData object.")
 
         # recursively write the transformation for all the SpatialElement
         if element_name is None:
@@ -1518,10 +1547,10 @@ class SpatialData:
         element_name
             The name of the element to write. If None, write the transformations of all elements.
         """
-        from spatialdata._core._elements import Elements
-
         if element_name is not None:
-            Elements._check_valid_name(element_name)
+            check_valid_name(element_name)
+            if element_name not in self:
+                raise ValueError(f"Element with name {element_name} not found in SpatialData object.")
 
         # recursively write the transformation for all the SpatialElement
         if element_name is None:
@@ -1638,10 +1667,10 @@ class SpatialData:
         -----
         When using the methods `write()` and `write_element()`, the metadata is written automatically.
         """
-        from spatialdata._core._elements import Elements
-
         if element_name is not None:
-            Elements._check_valid_name(element_name)
+            check_valid_name(element_name)
+            if element_name not in self:
+                raise ValueError(f"Element with name {element_name} not found in SpatialData object.")
 
         self.write_transformations(element_name)
         self.write_channel_names(element_name)
@@ -2172,11 +2201,7 @@ class SpatialData:
         ValueError
             If the element names are not unique.
         """
-        element_names = set()
-        for _, element_name, _ in self.gen_elements():
-            if element_name in element_names:
-                raise ValueError(f"Element name {element_name!r} is not unique.")
-            element_names.add(element_name)
+        check_all_keys_case_insensitively_unique([name for _, name, _ in self.gen_elements()], location=())
 
     def _find_element(self, element_name: str) -> tuple[str, str, SpatialElement | AnnData]:
         """
diff --git a/src/spatialdata/_core/validation.py b/src/spatialdata/_core/validation.py
new file mode 100644
index 0000000..d10ba35
--- /dev/null
+++ b/src/spatialdata/_core/validation.py
@@ -0,0 +1,383 @@
+from __future__ import annotations
+
+from collections.abc import Collection
+from types import TracebackType
+from typing import NamedTuple, cast
+
+import pandas as pd
+from anndata import AnnData
+
+
+class ErrorDetails(NamedTuple):
+    location: tuple[str, ...]
+    """Tuple of strings identifying the element for which the error occurred."""
+
+    message: str
+    """A human readable error message."""
+
+
+class ValidationError(ValueError):
+    def __init__(self, title: str, errors: list[ErrorDetails]):
+        self._errors: list[ErrorDetails] = list(errors)
+        super().__init__(title)
+
+    @property
+    def title(self) -> str:
+        return str(self.args[0]) if self.args else ""
+
+    @property
+    def errors(self) -> list[ErrorDetails]:
+        return list(self._errors)
+
+    def __str__(self) -> str:
+        return f"{self.title}\n" + "\n".join(
+            f"  {'/'.join(str(key) for key in details.location)}: {details.message}" for details in self.errors
+        )
+
+
+def check_target_region_column_symmetry(table: AnnData, region_key: str, target: str | pd.Series) -> None:
+    """
+    Check region and region_key column symmetry.
+
+    This checks whether the specified targets are also present in the region key column in obs and raises an error
+    if this is not the case.
+
+    Parameters
+    ----------
+    table
+        Table annotating specific SpatialElements
+    region_key
+        The column in obs containing for each row which SpatialElement is annotated by that row.
+    target
+         Name of target(s) SpatialElement(s)
+
+    Raises
+    ------
+    ValueError
+        If there is a mismatch between specified target regions and regions in the region key column of table.obs.
+
+    Example
+    -------
+    Assuming we have a table with region column in obs given by `region_key` called 'region' for which we want to check
+    whether it contains the specified annotation targets in the `target` variable as `pd.Series['region1', 'region2']`:
+
+    ```python
+    check_target_region_column_symmetry(table, region_key=region_key, target=target)
+    ```
+
+    This returns None if both specified targets are present in the region_key obs column. In this case the annotation
+    targets can be safely set. If not then a ValueError is raised stating the elements that are not shared between
+    the region_key column in obs and the specified targets.
+    """
+    found_regions = set(table.obs[region_key].unique().tolist())
+    target_element_set = [target] if isinstance(target, str) else target
+    symmetric_difference = found_regions.symmetric_difference(target_element_set)
+    if symmetric_difference:
+        raise ValueError(
+            f"Mismatch(es) found between regions in region column in obs and target element: "
+            f"{', '.join(diff for diff in symmetric_difference)}"
+        )
+
+
+def check_valid_name(name: str) -> None:
+    """
+    Check that a name is valid for SpatialData elements.
+
+    This checks whether the proposed name fulfills the naming restrictions and raises an error
+    otherwise.
+
+    Parameters
+    ----------
+    name
+        The name for a SpatialData element
+
+    Raises
+    ------
+    TypeError
+        If given argument is not of type string.
+    ValueError
+        If the proposed name violates a naming restriction.
+    """
+    if not isinstance(name, str):
+        raise TypeError(f"Name must be a string, not {type(name).__name__}.")
+    if len(name) == 0:
+        raise ValueError("Name cannot be an empty string.")
+    if name == ".":
+        raise ValueError("Name cannot be '.'.")
+    if name == "..":
+        raise ValueError("Name cannot be '..'.")
+    if name.startswith("__"):
+        raise ValueError("Name cannot start with '__'.")
+    if not all(c.isalnum() or c in "_-." for c in name):
+        raise ValueError("Name must contain only alphanumeric characters, underscores, dots and hyphens.")
+
+
+def check_all_keys_case_insensitively_unique(keys: Collection[str], location: tuple[str, ...] = ()) -> None:
+    """
+    Check that all keys are unique when ignoring case.
+
+    This checks whether the keys contain no duplicates on an case-insensitive system. If keys
+    differ in character case, an error is raised.
+
+    Parameters
+    ----------
+    keys
+        A collection of string keys
+    location
+        Tuple of strings identifying the parent element
+
+    Raises
+    ------
+    ValueError
+        If two keys differ only in character case.
+
+    Example
+    -------
+
+    ```pycon
+    >>> check_all_keys_case_insensitively_unique(["abc", "def"])
+    >>> check_all_keys_case_insensitively_unique(["abc", "def", "Abc"])
+    Traceback (most recent call last):
+        ...
+    ValueError: Key `Abc` is not unique, or another case-variant of it exists.
+    ```
+    """
+    seen: set[str | None] = set()
+    with raise_validation_errors(
+        title="Element contains conflicting keys.\n"
+        "For renaming, please see the discussion here https://github.com/scverse/spatialdata/discussions/707 .",
+        exc_type=ValueError,
+    ) as collect_error:
+        for key in keys:
+            normalized_key = key.lower()
+            with collect_error(location=location + (key,)):
+                check_key_is_case_insensitively_unique(key, seen)
+            seen.add(normalized_key)
+
+
+def check_key_is_case_insensitively_unique(key: str, other_keys: set[str | None]) -> None:
+    """
+    Check that a specific key is not contained in a set of keys, ignoring case.
+
+    This checks whether a given key is not contained among a set of reference keys. If the key or
+    a case-variant of it is contained, an error is raised.
+
+    Parameters
+    ----------
+    key
+        A string key
+    other_keys
+        A collection of string keys
+
+    Raises
+    ------
+    ValueError
+        If reference keys contain a variant of the key that only differs in character case.
+
+    Example
+    -------
+
+    ```pycon
+    >>> check_key_is_case_insensitively_unique("def", ["abc"])
+    >>> check_key_is_case_insensitively_unique("abc", ["def", "Abc"])
+    Traceback (most recent call last):
+        ...
+    ValueError: Key `Abc` is not unique, or another case-variant of it exists.
+    ```
+    """
+    normalized_key = key.lower()
+    if normalized_key in other_keys:
+        raise ValueError(f"Key `{key}` is not unique, or another case-variant of it exists.")
+
+
+def check_valid_dataframe_column_name(name: str) -> None:
+    """
+    Check that a name is valid for SpatialData table dataframe.
+
+    This checks whether the proposed name fulfills the naming restrictions and raises an error
+    otherwise. In addition to the element naming restriction, a column cannot be named "_index".
+
+    Parameters
+    ----------
+    name
+        The name for a table column
+
+    Raises
+    ------
+    TypeError
+        If given argument is not of type string.
+    ValueError
+        If the proposed name violates a naming restriction.
+    """
+    check_valid_name(name)
+    if name == "_index":
+        raise ValueError("Name cannot be '_index'")
+
+
+def validate_table_attr_keys(data: AnnData, location: tuple[str, ...] = ()) -> None:
+    """
+    Check that all keys of all AnnData attributes have valid names.
+
+    This checks for AnnData obs, var, obsm, obsp, varm, varp, uns, layers whether their keys fulfill the
+    naming restrictions and raises an error otherwise.
+
+    Parameters
+    ----------
+    data
+        The AnnData table
+    location
+        Tuple of strings identifying the parent element
+
+    Raises
+    ------
+    ValueError
+        If the AnnData contains one or several invalid keys.
+    """
+    with raise_validation_errors(
+        title="Table contains invalid names.\n"
+        "For renaming, please see the discussion here https://github.com/scverse/spatialdata/discussions/707 .",
+        exc_type=ValueError,
+    ) as collect_error:
+        for attr in ("obs", "obsm", "obsp", "var", "varm", "varp", "uns", "layers"):
+            attr_path = location + (attr,)
+            with collect_error(location=attr_path):
+                check_all_keys_case_insensitively_unique(getattr(data, attr).keys(), location=attr_path)
+            for key in getattr(data, attr):
+                key_path = attr_path + (key,)
+                with collect_error(location=key_path):
+                    if attr in ("obs", "var"):
+                        check_valid_dataframe_column_name(key)
+                    else:
+                        check_valid_name(key)
+
+
+class _ErrorDetailsCollector:
+    """
+    Context manager to collect possible exceptions into a list.
+
+    This is syntactic sugar for shortening the try/except construction when the error handling is
+    the same. Only for internal use by `raise_validation_errors`.
+
+    Parameters
+    ----------
+    exc_type
+        The class of the exception to catch. Other exceptions are raised.
+    """
+
+    def __init__(
+        self,
+        exc_type: type[BaseException] | tuple[type[BaseException], ...],
+    ) -> None:
+        self.errors: list[ErrorDetails] = []
+        self._location: tuple[str, ...] = ()
+        self._exc_type = exc_type
+        self._exc_type_override: type[BaseException] | tuple[type[BaseException], ...] | None = None
+
+    def __call__(
+        self,
+        location: str | tuple[str, ...] = (),
+        expected_exception: type[BaseException] | tuple[type[BaseException], ...] | None = None,
+    ) -> _ErrorDetailsCollector:
+        """
+        Set or override error details in advance before an exception is raised.
+
+        Parameters
+        ----------
+        location
+            Tuple of strings identifying the parent element
+        expected_exception
+            The class of the exception to catch. Other exceptions are raised.
+        """
+        if isinstance(location, str):
+            location = (location,)
+        self._location = location
+        if expected_exception is not None:
+            self._exc_type_override = expected_exception
+        return self
+
+    def __enter__(self) -> None:
+        pass
+
+    def __exit__(
+        self,
+        exc_type: type[BaseException] | None,
+        exc_val: BaseException | None,
+        exc_tb: TracebackType | None,
+    ) -> bool:
+        # No exception happened
+        if exc_type is None:
+            return True
+        # An exception that we cannot handle, let the interpreter raise it.
+        handled_exception_type = self._exc_type_override or self._exc_type
+        if not issubclass(exc_type, handled_exception_type):
+            return False
+        # One of the expected exceptions happened, or another ValidationError that we can merge.
+        assert exc_val is not None
+        if issubclass(exc_type, ValidationError):
+            exc_val = cast(ValidationError, exc_val)
+            self.errors += exc_val.errors
+        else:
+            details = ErrorDetails(location=self._location, message=str(exc_val.args[0]))
+            self.errors.append(details)
+        # Reset temporary attributes
+        self._location = ()
+        self._exc_type_override = None
+        return True
+
+
+class raise_validation_errors:
+    """
+    Context manager to raise collected exceptions together as one ValidationError.
+
+    This is syntactic sugar for shortening the try/except construction when the error handling is
+    the same.
+
+    Parameters
+    ----------
+    title
+        A validation error summary to display above the individual errors
+    exc_type
+        The class of the exception to catch. Other exceptions are raised.
+
+    Example
+    -------
+
+    ```pycon
+    >>> with raise_validation_errors(
+    ...     "Some errors happened", exc_type=ValueError
+    ... ) as collect_error:
+    ...     for key, value in {"first": 1, "second": 2, "third": 3}.items():
+    ...         with collect_error(location=key):
+    ...             if value % 2 != 0:
+    ...                 raise ValueError("Odd value encountered")
+    ...
+    spatialdata._core.validation.ValidationErro: Some errors happened
+    first: Odd value encountered
+    third: Odd value encountered
+    ```
+    """
+
+    def __init__(
+        self,
+        title: str = "Validation errors happened",
+        exc_type: type[BaseException] | tuple[type[BaseException], ...] = ValueError,
+    ) -> None:
+        self._message = title
+        self._collector = _ErrorDetailsCollector(exc_type=exc_type)
+
+    def __enter__(self) -> _ErrorDetailsCollector:
+        return self._collector
+
+    def __exit__(
+        self,
+        exc_type: type[BaseException] | None,
+        exc_val: BaseException | None,
+        exc_tb: TracebackType | None,
+    ) -> bool:
+        # An unexpected exception happened, let the interpreter handle it.
+        if exc_type is not None:
+            return False
+        # Exceptions were collected that we want to raise as a combined validation error.
+        if self._collector.errors:
+            raise ValidationError(title=self._message, errors=self._collector.errors)
+        return True
diff --git a/src/spatialdata/models/__init__.py b/src/spatialdata/models/__init__.py
index 83a179d..3c86fa0 100644
--- a/src/spatialdata/models/__init__.py
+++ b/src/spatialdata/models/__init__.py
@@ -1,3 +1,4 @@
+from spatialdata._core.validation import check_target_region_column_symmetry
 from spatialdata.models._utils import (
     C,
     SpatialElement,
@@ -23,7 +24,6 @@ from spatialdata.models.models import (
     PointsModel,
     ShapesModel,
     TableModel,
-    check_target_region_column_symmetry,
     get_model,
     get_table_keys,
 )
diff --git a/src/spatialdata/models/models.py b/src/spatialdata/models/models.py
index 1777c7c..b2663e5 100644
--- a/src/spatialdata/models/models.py
+++ b/src/spatialdata/models/models.py
@@ -31,6 +31,7 @@ from xarray_schema.components import (
 )
 from xarray_schema.dataarray import DataArraySchema
 
+from spatialdata._core.validation import validate_table_attr_keys
 from spatialdata._logging import logger
 from spatialdata._types import ArrayLike
 from spatialdata._utils import _check_match_length_channels_c_dim
@@ -971,6 +972,7 @@ class TableModel:
         -------
         The validated data.
         """
+        validate_table_attr_keys(data)
         if ATTRS_KEY not in data.uns:
             return data
 
@@ -1004,6 +1006,7 @@ class TableModel:
         -------
         The parsed data.
         """
+        validate_table_attr_keys(adata)
         # either all live in adata.uns or all be passed in as argument
         n_args = sum([region is not None, region_key is not None, instance_key is not None])
         if n_args == 0:
@@ -1033,6 +1036,8 @@ class TableModel:
         if instance_key is None:
             raise ValueError("`instance_key` must be provided.")
 
+        # note! this is an expensive check and therefore we skip it during validation
+        # https://github.com/scverse/spatialdata/issues/715
         grouped = adata.obs.groupby(region_key, observed=True)
         grouped_size = grouped.size()
         grouped_nunique = grouped.nunique()
@@ -1124,47 +1129,3 @@ def get_table_keys(table: AnnData) -> tuple[str | list[str], str, str]:
     raise ValueError(
         "No spatialdata_attrs key found in table.uns, therefore, no table keys found. Please parse the table."
     )
-
-
-def check_target_region_column_symmetry(table: AnnData, region_key: str, target: str | pd.Series) -> None:
-    """
-    Check region and region_key column symmetry.
-
-    This checks whether the specified targets are also present in the region key column in obs and raises an error
-    if this is not the case.
-
-    Parameters
-    ----------
-    table
-        Table annotating specific SpatialElements
-    region_key
-        The column in obs containing for each row which SpatialElement is annotated by that row.
-    target
-         Name of target(s) SpatialElement(s)
-
-    Raises
-    ------
-    ValueError
-        If there is a mismatch between specified target regions and regions in the region key column of table.obs.
-
-    Example
-    -------
-    Assuming we have a table with region column in obs given by `region_key` called 'region' for which we want to check
-    whether it contains the specified annotation targets in the `target` variable as `pd.Series['region1', 'region2']`:
-
-    ```python
-    check_target_region_column_symmetry(table, region_key=region_key, target=target)
-    ```
-
-    This returns None if both specified targets are present in the region_key obs column. In this case the annotation
-    targets can be safely set. If not then a ValueError is raised stating the elements that are not shared between
-    the region_key column in obs and the specified targets.
-    """
-    found_regions = set(table.obs[region_key].unique().tolist())
-    target_element_set = [target] if isinstance(target, str) else target
-    symmetric_difference = found_regions.symmetric_difference(target_element_set)
-    if symmetric_difference:
-        raise ValueError(
-            f"Mismatch(es) found between regions in region column in obs and target element: "
-            f"{', '.join(diff for diff in symmetric_difference)}"
-        )


Test patch:
diff --git a/tests/conftest.py b/tests/conftest.py
index e4498c7..2cfa67f 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -66,7 +66,7 @@ def points() -> SpatialData:
 
 @pytest.fixture()
 def table_single_annotation() -> SpatialData:
-    return SpatialData(tables=_get_table(region="labels2d"))
+    return SpatialData(tables={"table": _get_table(region="labels2d")})
 
 
 @pytest.fixture()
@@ -93,7 +93,7 @@ def full_sdata() -> SpatialData:
         labels=_get_labels(),
         shapes=_get_shapes(),
         points=_get_points(),
-        tables=_get_table(region="labels2d"),
+        tables=_get_tables(region="labels2d"),
     )
 
 
@@ -128,7 +128,7 @@ def sdata(request) -> SpatialData:
             labels=_get_labels(),
             shapes=_get_shapes(),
             points=_get_points(),
-            tables=_get_table("labels2d"),
+            tables=_get_tables(region="labels2d"),
         )
     if request.param == "empty":
         return SpatialData()
@@ -278,6 +278,14 @@ def _get_points() -> dict[str, DaskDataFrame]:
     return out
 
 
+def _get_tables(
+    region: None | str | list[str] = "sample1",
+    region_key: None | str = "region",
+    instance_key: None | str = "instance_id",
+) -> dict[str, AnnData]:
+    return {"table": _get_table(region=region, region_key=region_key, instance_key=instance_key)}
+
+
 def _get_table(
     region: None | str | list[str] = "sample1",
     region_key: None | str = "region",
diff --git a/tests/core/operations/test_spatialdata_operations.py b/tests/core/operations/test_spatialdata_operations.py
index 95b567b..a5949ab 100644
--- a/tests/core/operations/test_spatialdata_operations.py
+++ b/tests/core/operations/test_spatialdata_operations.py
@@ -38,6 +38,7 @@ def test_element_names_unique() -> None:
     points = PointsModel.parse(np.array([[0, 0]]))
     labels = Labels2DModel.parse(np.array([[0, 0], [0, 0]]), dims=["y", "x"])
     image = Image2DModel.parse(np.array([[[0, 0], [0, 0]]]), dims=["c", "y", "x"])
+    table = TableModel.parse(AnnData(shape=(1, 0)))
 
     with pytest.raises(KeyError):
         SpatialData(images={"image": image}, points={"image": points})
@@ -45,9 +46,15 @@ def test_element_names_unique() -> None:
         SpatialData(images={"image": image}, shapes={"image": shapes})
     with pytest.raises(KeyError):
         SpatialData(images={"image": image}, labels={"image": labels})
+    with pytest.raises(KeyError):
+        SpatialData(images={"image": image}, labels={"image": table})
 
     sdata = SpatialData(
-        images={"image": image}, points={"points": points}, shapes={"shapes": shapes}, labels={"labels": labels}
+        images={"image": image},
+        points={"points": points},
+        shapes={"shapes": shapes},
+        labels={"labels": labels},
+        tables={"table": table},
     )
 
     # add elements with the same name
@@ -60,6 +67,8 @@ def test_element_names_unique() -> None:
         sdata.shapes["shapes"] = shapes
     with pytest.warns(UserWarning):
         sdata.labels["labels"] = labels
+    with pytest.warns(UserWarning):
+        sdata.tables["table"] = table
 
     # add elements with the same name
     # of element of different type
@@ -73,11 +82,27 @@ def test_element_names_unique() -> None:
         sdata.points["shapes"] = points
     with pytest.raises(KeyError):
         sdata.shapes["labels"] = shapes
+    with pytest.raises(KeyError):
+        sdata.tables["labels"] = table
+
+    # add elements with the case-variant of an existing name
+    # of element of same type
+    with pytest.raises(KeyError):
+        sdata.images["Image"] = image
+    with pytest.raises(KeyError):
+        sdata.points["POINTS"] = points
+    with pytest.raises(KeyError):
+        sdata.shapes["Shapes"] = shapes
+    with pytest.raises(KeyError):
+        sdata.labels["Labels"] = labels
+    with pytest.raises(KeyError):
+        sdata.tables["Table"] = table
 
     assert sdata["image"].shape == image.shape
     assert sdata["labels"].shape == labels.shape
     assert len(sdata["points"]) == len(points)
     assert sdata["shapes"].shape == shapes.shape
+    assert len(sdata["table"]) == len(table)
 
     # add elements with the same name, test only couples of elements
     with pytest.raises(KeyError):
@@ -90,7 +115,11 @@ def test_element_names_unique() -> None:
 
     # test replacing complete attribute
     sdata = SpatialData(
-        images={"image": image}, points={"points": points}, shapes={"shapes": shapes}, labels={"labels": labels}
+        images={"image": image},
+        points={"points": points},
+        shapes={"shapes": shapes},
+        labels={"labels": labels},
+        tables={"table": table},
     )
     # test for images
     sdata.images = {"image2": image}
@@ -107,11 +136,16 @@ def test_element_names_unique() -> None:
     assert set(sdata.points.keys()) == {"points2"}
     assert "points2" in sdata._shared_keys
     assert "points" not in sdata._shared_keys
-    # test for points
+    # test for shapes
     sdata.shapes = {"shapes2": shapes}
     assert set(sdata.shapes.keys()) == {"shapes2"}
     assert "shapes2" in sdata._shared_keys
     assert "shapes" not in sdata._shared_keys
+    # test for tables
+    sdata.tables = {"table2": table}
+    assert set(sdata.tables.keys()) == {"table2"}
+    assert "table2" in sdata._shared_keys
+    assert "table" not in sdata._shared_keys
 
 
 def test_element_type_from_element_name(points: SpatialData) -> None:
diff --git a/tests/core/test_validation.py b/tests/core/test_validation.py
new file mode 100644
index 0000000..258c147
--- /dev/null
+++ b/tests/core/test_validation.py
@@ -0,0 +1,29 @@
+import pytest
+
+from spatialdata._core.validation import ValidationError, raise_validation_errors
+
+
+def test_raise_validation_errors():
+    with pytest.raises(expected_exception=ValidationError, match="Some errors happened") as actual_exc_info:
+        ...
+        with raise_validation_errors("Some errors happened", exc_type=ValueError) as collect_error:
+            with collect_error(expected_exception=TypeError):
+                raise TypeError("Another error type")
+            for key, value in {"first": 1, "second": 2, "third": 3}.items():
+                with collect_error(location=key):
+                    if value % 2 != 0:
+                        raise ValueError("Odd value encountered")
+    actual_message = str(actual_exc_info.value)
+    assert "Another error" in actual_message
+    assert "first" in actual_message
+    assert "second" not in actual_message
+    assert "third" in actual_message
+
+
+def test_raise_validation_errors_does_not_catch_other_errors():
+    with pytest.raises(expected_exception=RuntimeError, match="Not to be collected"):
+        ...
+        with raise_validation_errors(exc_type=ValueError) as collect_error:
+            ...
+            with collect_error:
+                raise RuntimeError("Not to be collected as ValidationError")
diff --git a/tests/io/test_readwrite.py b/tests/io/test_readwrite.py
index abfe4ea..ad8c66b 100644
--- a/tests/io/test_readwrite.py
+++ b/tests/io/test_readwrite.py
@@ -11,6 +11,7 @@ from anndata import AnnData
 from numpy.random import default_rng
 
 from spatialdata import SpatialData, deepcopy, read_zarr
+from spatialdata._core.validation import ValidationError
 from spatialdata._io._utils import _are_directories_identical, get_dask_backing_files
 from spatialdata.datasets import blobs
 from spatialdata.models import Image2DModel
@@ -21,7 +22,7 @@ from spatialdata.transformations.operations import (
     set_transformation,
 )
 from spatialdata.transformations.transformations import Identity, Scale
-from tests.conftest import _get_images, _get_labels, _get_points, _get_shapes
+from tests.conftest import _get_images, _get_labels, _get_points, _get_shapes, _get_table, _get_tables
 
 RNG = default_rng(0)
 
@@ -104,34 +105,47 @@ class TestReadWrite:
             sdata.images[f"additional_{k}"] = v
             with pytest.warns(UserWarning):
                 sdata.images[f"additional_{k}"] = v
+            with pytest.warns(UserWarning):
                 sdata[f"additional_{k}"] = v
-            with pytest.raises(KeyError, match="Key `table` already exists."):
+            with pytest.raises(KeyError, match="Key `table` is not unique"):
                 sdata["table"] = v
 
         for k, v in _get_labels().items():
             sdata.labels[f"additional_{k}"] = v
             with pytest.warns(UserWarning):
                 sdata.labels[f"additional_{k}"] = v
+            with pytest.warns(UserWarning):
                 sdata[f"additional_{k}"] = v
-            with pytest.raises(KeyError, match="Key `table` already exists."):
+            with pytest.raises(KeyError, match="Key `table` is not unique"):
                 sdata["table"] = v
 
         for k, v in _get_shapes().items():
             sdata.shapes[f"additional_{k}"] = v
             with pytest.warns(UserWarning):
                 sdata.shapes[f"additional_{k}"] = v
+            with pytest.warns(UserWarning):
                 sdata[f"additional_{k}"] = v
-            with pytest.raises(KeyError, match="Key `table` already exists."):
+            with pytest.raises(KeyError, match="Key `table` is not unique"):
                 sdata["table"] = v
 
         for k, v in _get_points().items():
             sdata.points[f"additional_{k}"] = v
             with pytest.warns(UserWarning):
                 sdata.points[f"additional_{k}"] = v
+            with pytest.warns(UserWarning):
                 sdata[f"additional_{k}"] = v
-            with pytest.raises(KeyError, match="Key `table` already exists."):
+            with pytest.raises(KeyError, match="Key `table` is not unique"):
                 sdata["table"] = v
 
+        for k, v in _get_tables().items():
+            sdata.tables[f"additional_{k}"] = v
+            with pytest.warns(UserWarning):
+                sdata.tables[f"additional_{k}"] = v
+            with pytest.warns(UserWarning):
+                sdata[f"additional_{k}"] = v
+            with pytest.raises(KeyError, match="Key `poly` is not unique"):
+                sdata["poly"] = v
+
     def test_incremental_io_list_of_elements(self, shapes: SpatialData) -> None:
         with tempfile.TemporaryDirectory() as tmpdir:
             f = os.path.join(tmpdir, "data.zarr")
@@ -568,17 +582,30 @@ def _check_valid_name(f: Callable[[str], Any]) -> None:
         f(2)
     with pytest.raises(ValueError, match="Name cannot be an empty string."):
         f("")
-    with pytest.raises(ValueError, match="Name must contain only alphanumeric characters, underscores, and hyphens."):
-        f("not valid")
-    with pytest.raises(ValueError, match="Name must contain only alphanumeric characters, underscores, and hyphens."):
+    with pytest.raises(ValueError, match="Name cannot be '.'"):
+        f(".")
+    with pytest.raises(ValueError, match="Name cannot be '..'"):
+        f("..")
+    with pytest.raises(ValueError, match="Name cannot start with '__'"):
+        f("__a")
+    with pytest.raises(
+        ValueError, match="Name must contain only alphanumeric characters, underscores, dots and hyphens."
+    ):
+        f("has whitespace")
+    with pytest.raises(
+        ValueError, match="Name must contain only alphanumeric characters, underscores, dots and hyphens."
+    ):
         f("this/is/not/valid")
+    with pytest.raises(
+        ValueError, match="Name must contain only alphanumeric characters, underscores, dots and hyphens."
+    ):
+        f("non-alnum_#$%&()*+,?@")
 
 
-def test_incremental_io_valid_name(points: SpatialData) -> None:
-    _check_valid_name(points.write_element)
-    _check_valid_name(points.write_metadata)
-    _check_valid_name(points.write_transformations)
-    _check_valid_name(points.delete_element_from_disk)
+def test_incremental_io_valid_name(full_sdata: SpatialData) -> None:
+    _check_valid_name(full_sdata.write_element)
+    _check_valid_name(full_sdata.write_metadata)
+    _check_valid_name(full_sdata.write_transformations)
 
 
 def test_incremental_io_attrs(points: SpatialData) -> None:
@@ -695,3 +722,83 @@ def test_element_already_on_disk_different_type(full_sdata, element_name: str) -
             match=ERROR_MSG,
         ):
             full_sdata.write_transformations(element_name)
+
+
+def test_writing_invalid_name(tmp_path: Path):
+    invalid_sdata = SpatialData()
+    # Circumvent validation at construction time and check validation happens again at writing time.
+    invalid_sdata.images.data[""] = next(iter(_get_images().values()))
+    invalid_sdata.labels.data["."] = next(iter(_get_labels().values()))
+    invalid_sdata.points.data["path/separator"] = next(iter(_get_points().values()))
+    invalid_sdata.shapes.data["non-alnum_#$%&()*+,?@"] = next(iter(_get_shapes().values()))
+    invalid_sdata.tables.data["has whitespace"] = _get_table()
+
+    with pytest.raises(ValueError, match="Name (must|cannot)"):
+        invalid_sdata.write(tmp_path / "data.zarr")
+
+
+def test_writing_valid_table_name_invalid_table(tmp_path: Path):
+    # also try with a valid table name but invalid table
+    # testing just one case, all the cases are in test_table_model_invalid_names()
+    invalid_sdata = SpatialData()
+    invalid_sdata.tables.data["valid_name"] = AnnData(np.array([[0]]), layers={"invalid name": np.array([[0]])})
+    with pytest.raises(ValueError, match="Name (must|cannot)"):
+        invalid_sdata.write(tmp_path / "data.zarr")
+
+
+def test_incremental_writing_invalid_name(tmp_path: Path):
+    invalid_sdata = SpatialData()
+    invalid_sdata.write(tmp_path / "data.zarr")
+
+    # Circumvent validation at construction time and check validation happens again at writing time.
+    invalid_sdata.images.data[""] = next(iter(_get_images().values()))
+    invalid_sdata.labels.data["."] = next(iter(_get_labels().values()))
+    invalid_sdata.points.data["path/separator"] = next(iter(_get_points().values()))
+    invalid_sdata.shapes.data["non-alnum_#$%&()*+,?@"] = next(iter(_get_shapes().values()))
+    invalid_sdata.tables.data["has whitespace"] = _get_table()
+
+    for element_type in ["images", "labels", "points", "shapes", "tables"]:
+        elements = getattr(invalid_sdata, element_type)
+        for name in elements:
+            with pytest.raises(ValueError, match="Name (must|cannot)"):
+                invalid_sdata.write_element(name)
+
+
+def test_incremental_writing_valid_table_name_invalid_table(tmp_path: Path):
+    # also try with a valid table name but invalid table
+    # testing just one case, all the cases are in test_table_model_invalid_names()
+    invalid_sdata = SpatialData()
+    invalid_sdata.write(tmp_path / "data2.zarr")
+    invalid_sdata.tables.data["valid_name"] = AnnData(np.array([[0]]), layers={"invalid name": np.array([[0]])})
+    with pytest.raises(ValueError, match="Name (must|cannot)"):
+        invalid_sdata.write_element("valid_name")
+
+
+def test_reading_invalid_name(tmp_path: Path):
+    image_name, image = next(iter(_get_images().items()))
+    labels_name, labels = next(iter(_get_labels().items()))
+    points_name, points = next(iter(_get_points().items()))
+    shapes_name, shapes = next(iter(_get_shapes().items()))
+    table_name, table = "table", _get_table()
+    valid_sdata = SpatialData(
+        images={image_name: image},
+        labels={labels_name: labels},
+        points={points_name: points},
+        shapes={shapes_name: shapes},
+        tables={table_name: table},
+    )
+    valid_sdata.write(tmp_path / "data.zarr")
+    # Circumvent validation at construction time and check validation happens again at writing time.
+    (tmp_path / "data.zarr/points" / points_name).rename(tmp_path / "data.zarr/points" / "has whitespace")
+    (tmp_path / "data.zarr/shapes" / shapes_name).rename(tmp_path / "data.zarr/shapes" / "non-alnum_#$%&()*+,?@")
+
+    with pytest.raises(ValidationError, match="Cannot construct SpatialData") as exc_info:
+        read_zarr(tmp_path / "data.zarr")
+
+    actual_message = str(exc_info.value)
+    assert "points/has whitespace" in actual_message
+    assert "shapes/non-alnum_#$%&()*+,?@" in actual_message
+    assert (
+        "For renaming, please see the discussion here https://github.com/scverse/spatialdata/discussions/707"
+        in actual_message
+    )
diff --git a/tests/models/test_models.py b/tests/models/test_models.py
index 5abc536..f5889f9 100644
--- a/tests/models/test_models.py
+++ b/tests/models/test_models.py
@@ -25,6 +25,7 @@ from spatial_image import to_spatial_image
 from xarray import DataArray, DataTree
 
 from spatialdata._core.spatialdata import SpatialData
+from spatialdata._core.validation import ValidationError
 from spatialdata._types import ArrayLike
 from spatialdata.models._utils import (
     force_2d,
@@ -366,6 +367,36 @@ class TestModels:
         assert TableModel.REGION_KEY_KEY in table.uns[TableModel.ATTRS_KEY]
         assert table.uns[TableModel.ATTRS_KEY][TableModel.REGION_KEY] == region
 
+    @pytest.mark.parametrize(
+        "name",
+        [
+            "",
+            ".",
+            "..",
+            "__dunder",
+            "has whitespace",
+            "path/separator",
+            "non-alnum_#$%&()*+,?@",
+        ],
+    )
+    @pytest.mark.parametrize("element_type", ["images", "labels", "points", "shapes", "tables"])
+    def test_model_invalid_names(self, full_sdata, element_type: str, name: str):
+        element = next(iter(getattr(full_sdata, element_type).values()))
+        with pytest.raises(ValueError, match="Name (must|cannot)"):
+            SpatialData(**{element_type: {name: element}})
+
+    @pytest.mark.parametrize(
+        "names",
+        [
+            ["abc", "Abc"],
+        ],
+    )
+    @pytest.mark.parametrize("element_type", ["images", "labels", "points", "shapes", "tables"])
+    def test_model_not_unique_names(self, full_sdata, element_type: str, names: list[str]):
+        element = next(iter(getattr(full_sdata, element_type).values()))
+        with pytest.raises(ValidationError, match="Key `.*` is not unique"):
+            SpatialData(**{element_type: {name: element for name in names}})
+
     @pytest.mark.parametrize("model", [TableModel])
     @pytest.mark.parametrize("region", [["sample_1"] * 5 + ["sample_2"] * 5])
     def test_table_instance_key_values_not_unique(self, model: TableModel, region: str | np.ndarray):
@@ -381,6 +412,71 @@ class TestModels:
         with pytest.raises(ValueError, match=re.escape("Instance key column for region(s) `sample_1, sample_2`")):
             model.parse(adata, region=region, region_key=region_key, instance_key="A")
 
+    @pytest.mark.parametrize(
+        "key",
+        [
+            "",
+            ".",
+            "..",
+            "__dunder",
+            "_index",
+            "has whitespace",
+            "path/separator",
+            "non-alnum_#$%&()*+,?@",
+        ],
+    )
+    @pytest.mark.parametrize("attr", ["obs", "obsm", "obsp", "var", "varm", "varp", "uns", "layers"])
+    @pytest.mark.parametrize("parse", [True, False])
+    def test_table_model_invalid_names(self, key: str, attr: str, parse: bool):
+        if attr in ("obs", "var"):
+            df = pd.DataFrame([[None]], columns=[key], index=["1"])
+            adata = AnnData(np.array([[0]]), **{attr: df})
+            with pytest.raises(ValueError, match=f"Table contains invalid names(.|\n)*\n  {attr}/{re.escape(key)}"):
+                if parse:
+                    TableModel.parse(adata)
+                else:
+                    TableModel().validate(adata)
+        elif key != "_index":  # "_index" is only disallowed in obs/var
+            if attr in ("obsm", "varm", "obsp", "varp", "layers"):
+                array = np.array([[0]])
+                adata = AnnData(np.array([[0]]), **{attr: {key: array}})
+                with pytest.raises(ValueError, match=f"Table contains invalid names(.|\n)*\n  {attr}/{re.escape(key)}"):
+                    if parse:
+                        TableModel.parse(adata)
+                    else:
+                        TableModel().validate(adata)
+            elif attr == "uns":
+                adata = AnnData(np.array([[0]]), **{attr: {key: {}}})
+                with pytest.raises(ValueError, match=f"Table contains invalid names(.|\n)*\n  {attr}/{re.escape(key)}"):
+                    if parse:
+                        TableModel.parse(adata)
+                    else:
+                        TableModel().validate(adata)
+
+    @pytest.mark.parametrize(
+        "keys",
+        [
+            ["abc", "abc"],
+            ["abc", "Abc", "ABC"],
+        ],
+    )
+    @pytest.mark.parametrize("attr", ["obs", "var"])
+    @pytest.mark.parametrize("parse", [True, False])
+    def test_table_model_not_unique_columns(self, keys: list[str], attr: str, parse: bool):
+        invalid_key = keys[1]
+        key_regex = re.escape(invalid_key)
+        df = pd.DataFrame([[None] * len(keys)], columns=keys, index=["1"])
+        adata = AnnData(np.array([[0]]), **{attr: df})
+        with pytest.raises(
+            ValueError,
+            match=f"Table contains invalid names(.|\n)*\n  {attr}/{invalid_key}: "
+            + f"Key `{key_regex}` is not unique, or another case-variant of it exists.",
+        ):
+            if parse:
+                TableModel.parse(adata)
+            else:
+                TableModel().validate(adata)
+
 
 def test_get_schema():
     images = _get_images()