diff --git a/.github/workflows/pytest.yaml b/.github/workflows/pytest.yaml
index 9f2922851..ad1f0f8d5 100644
--- a/.github/workflows/pytest.yaml
+++ b/.github/workflows/pytest.yaml
@@ -22,13 +22,13 @@ jobs:
         - ubuntu-latest
         - windows-latest
         python-version:
-        - "3.8"  # Earliest supported version
-        - "3.9"
+        - "3.9"          # Earliest supported version
         - "3.10"
         - "3.11"
-        - "3.12"  # Latest supported version
+        - "3.12"
+        - "3.13"  # Latest supported version
         # commented: only enable once next Python version enters RC
-        # - "3.13.0-rc.1"  # Development version
+        # - "3.14.0-rc.1"  # Development version
 
       fail-fast: false
 
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 6589561af..8fe0ae500 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -1,6 +1,6 @@
 repos:
 - repo: https://github.com/pre-commit/mirrors-mypy
-  rev: v1.11.1
+  rev: v1.12.0
   hooks:
   - id: mypy
     additional_dependencies:
@@ -15,7 +15,7 @@ repos:
     - types-requests
     args: []
 - repo: https://github.com/astral-sh/ruff-pre-commit
-  rev: v0.6.0
+  rev: v0.6.9
   hooks:
   - id: ruff
   - id: ruff-format
diff --git a/doc/install.rst b/doc/install.rst
index 667d00b63..e577cc86a 100644
--- a/doc/install.rst
+++ b/doc/install.rst
@@ -4,7 +4,7 @@ Installation
 Dependencies
 ============
 
-:mod:`sdmx` is a pure `Python <https://python.org>`_ package requiring Python 3.8 or higher, which can be installed:
+:mod:`sdmx` is a pure `Python <https://python.org>`_ package requiring Python 3.9 or higher, which can be installed:
 
 - from `the Python website <https://www.python.org/downloads/>`_, or
 - using a scientific Python distribution that includes other packages useful for data analysis, such as
diff --git a/doc/whatsnew.rst b/doc/whatsnew.rst
index a04451005..144a5ee02 100644
--- a/doc/whatsnew.rst
+++ b/doc/whatsnew.rst
@@ -3,8 +3,12 @@
 What's new?
 ***********
 
-.. Next release
-.. ============
+Next release
+============
+
+- Python 3.13 (`released 2024-10-07 <https://www.python.org/downloads/release/python-3130/>`_) is fully supported (:pull:`195`).
+- Python 3.8 support is dropped, as `it has reached end-of-life <https://peps.python.org/pep-0569/#lifespan>`__ (:pull:`195`).
+  :mod:`sdmx` requires Python 3.9 or later.
 
 v2.17.0 (2024-09-03)
 ====================
diff --git a/pyproject.toml b/pyproject.toml
index 43d29ca84..5a41d0ed5 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -19,15 +19,15 @@ classifiers = [
   "Operating System :: OS Independent",
   "Programming Language :: Python",
   "Programming Language :: Python :: 3",
-  "Programming Language :: Python :: 3.8",
   "Programming Language :: Python :: 3.9",
   "Programming Language :: Python :: 3.10",
   "Programming Language :: Python :: 3.11",
   "Programming Language :: Python :: 3.12",
+  "Programming Language :: Python :: 3.13",
   "Topic :: Scientific/Engineering",
   "Topic :: Scientific/Engineering :: Information Analysis",
 ]
-requires-python = ">=3.8"
+requires-python = ">=3.9"
 dependencies = [
   "lxml >= 3.6",
   "pandas >= 1.0",
@@ -41,7 +41,6 @@ cache = ["requests-cache"]
 docs = ["IPython", "sphinx >=4", "sphinx-book-theme"]
 tests = [
   "Jinja2",
-  "pyarrow",  # Suppress a warning from pandas >=2.2, <3.0
   "pytest >= 5",
   "pytest-cov",
   "pytest-xdist",
diff --git a/sdmx/client.py b/sdmx/client.py
index 06ccdf14a..6d037e349 100644
--- a/sdmx/client.py
+++ b/sdmx/client.py
@@ -1,6 +1,6 @@
 import logging
 from functools import partial
-from typing import IO, TYPE_CHECKING, Any, Dict, Optional, Union
+from typing import IO, TYPE_CHECKING, Any, Optional, Union
 from warnings import warn
 
 import requests
@@ -56,7 +56,7 @@ class Client:
 
     """
 
-    cache: Dict[str, "sdmx.message.Message"] = {}
+    cache: dict[str, "sdmx.message.Message"] = {}
 
     #: :class:`.source.Source` for requests sent from the instance.
     source: "sdmx.source.Source"
@@ -65,7 +65,7 @@ class Client:
     session: requests.Session
 
     # Stored keyword arguments "allow_redirects" and "timeout" for pre-requests.
-    _send_kwargs: Dict[str, Any] = {}
+    _send_kwargs: dict[str, Any] = {}
 
     def __init__(self, source=None, log_level=None, **session_opts):
         try:
diff --git a/sdmx/dictlike.py b/sdmx/dictlike.py
index 517008bab..9792e65de 100644
--- a/sdmx/dictlike.py
+++ b/sdmx/dictlike.py
@@ -1,7 +1,7 @@
 import logging
 import typing
 from dataclasses import fields
-from typing import Generic, Tuple, TypeVar, Union, get_args, get_origin
+from typing import Generic, TypeVar, Union, get_args, get_origin
 
 log = logging.getLogger(__name__)
 
@@ -67,7 +67,7 @@ def update(self, other):
     def __hash__(cls):
         pass
 
-    def _validate_entry(self, kv: Tuple):
+    def _validate_entry(self, kv: tuple):
         """Validate one `key`/`value` pair."""
         key, value = kv
         try:
@@ -147,7 +147,7 @@ def _get_field_types(self, obj):
         self._field = next(filter(lambda f: f.name == self._name[1:], fields(obj)))
         # The type is DictLike[KeyType, ValueType]; retrieve those arguments
         kt, vt = get_args(self._field.type)
-        # Store. If ValueType is a generic, e.g. List[int], store only List.
+        # Store. If ValueType is a generic, e.g. list[int], store only List.
         self._types = (kt, get_origin(vt) or vt)
 
     def __get__(self, obj, type) -> DictLike[KT, VT]:
diff --git a/sdmx/experimental.py b/sdmx/experimental.py
index 2b1223eed..7a49c0305 100644
--- a/sdmx/experimental.py
+++ b/sdmx/experimental.py
@@ -71,7 +71,7 @@ def add_obs(self, observations, series_key=None):
 
     @property
     def obs(self):
-        # In model.DataSet, .obs is typed as List[Observation].
+        # In model.DataSet, .obs is typed as list[Observation].
         # Here, the Observations are generated on request.
         for key, data in self._data.iterrows():
             yield self._make_obs(key, data)
diff --git a/sdmx/format/__init__.py b/sdmx/format/__init__.py
index 1262a70bf..cbe5ccb50 100644
--- a/sdmx/format/__init__.py
+++ b/sdmx/format/__init__.py
@@ -2,7 +2,7 @@
 from dataclasses import InitVar, dataclass, field
 from enum import Enum, IntFlag
 from functools import lru_cache
-from typing import List, Literal, Optional, Union
+from typing import Literal, Optional, Union
 
 from sdmx.util import parse_content_type
 
@@ -141,7 +141,7 @@ def is_time_series(self) -> bool:
 ]
 
 
-def list_media_types(**filters) -> List[MediaType]:
+def list_media_types(**filters) -> list[MediaType]:
     """Return the string for each item in :data:`MEDIA_TYPES` matching `filters`."""
     result = []
     for mt in MEDIA_TYPES:
diff --git a/sdmx/format/xml/common.py b/sdmx/format/xml/common.py
index 5f1d32cc4..b92b12f21 100644
--- a/sdmx/format/xml/common.py
+++ b/sdmx/format/xml/common.py
@@ -6,7 +6,7 @@
 from operator import itemgetter
 from pathlib import Path
 from shutil import copytree
-from typing import IO, Iterable, List, Mapping, Optional, Tuple, Union
+from typing import IO, Iterable, Mapping, Optional, Union
 
 from lxml import etree
 from lxml.etree import QName
@@ -221,7 +221,7 @@ def _extracted_zipball(version: Version) -> Path:
 
 def _handle_validate_args(
     schema_dir: Optional[Path], version: Union[str, Version]
-) -> Tuple[Path, Version]:
+) -> tuple[Path, Version]:
     """Handle arguments for :func:`.install_schemas` and :func:`.validate_xml`."""
     import platformdirs
 
@@ -274,9 +274,9 @@ def install_schemas(
 
 class XMLFormat:
     NS: Mapping[str, Optional[str]]
-    _class_tag: List
+    _class_tag: list
 
-    def __init__(self, model, base_ns: str, class_tag: Iterable[Tuple[str, str]]):
+    def __init__(self, model, base_ns: str, class_tag: Iterable[tuple[str, str]]):
         from sdmx import message  # noqa: F401
 
         self.base_ns = base_ns
diff --git a/sdmx/message.py b/sdmx/message.py
index f0a66a30c..e9007ea0b 100644
--- a/sdmx/message.py
+++ b/sdmx/message.py
@@ -16,11 +16,8 @@
 from typing import (
     TYPE_CHECKING,
     Generator,
-    List,
     Optional,
     Text,
-    Tuple,
-    Type,
     Union,
     get_args,
 )
@@ -42,7 +39,7 @@
 log = logging.getLogger(__name__)
 
 
-def _summarize(obj, include: Optional[List[str]] = None):
+def _summarize(obj, include: Optional[list[str]] = None):
     """Helper method for __repr__ on Header and Message (sub)classes."""
     import requests
 
@@ -123,7 +120,7 @@ class Footer:
     #:
     severity: Optional[str] = None
     #: The body text of the Footer contains zero or more blocks of text.
-    text: List[model.InternationalString] = field(default_factory=list)
+    text: list[model.InternationalString] = field(default_factory=list)
     #:
     code: Optional[int] = None
 
@@ -315,7 +312,7 @@ def get(
         # separator characters
         urn_expr = re.compile(rf"[=:]{re.escape(id_)}")
 
-        candidates: List[model.IdentifiableArtefact] = []
+        candidates: list[model.IdentifiableArtefact] = []
         for key, obj in chain(*[c.items() for c in self._collections]):
             if id_ in (key, obj.id) or urn_expr.search(obj.urn or ""):
                 candidates.append(obj)
@@ -325,7 +322,7 @@ def get(
 
         return candidates[0] if len(candidates) == 1 else None
 
-    def iter_collections(self) -> Generator[Tuple[str, type], None, None]:
+    def iter_collections(self) -> Generator[tuple[str, type], None, None]:
         """Iterate over collections."""
         for f in direct_fields(self.__class__):
             yield f.name, get_args(f.type)[1]
@@ -380,7 +377,7 @@ class DataMessage(Message):
     """
 
     #: :class:`list` of :class:`.DataSet`.
-    data: List[model.BaseDataSet] = field(default_factory=list)
+    data: list[model.BaseDataSet] = field(default_factory=list)
     #: :class:`.DataflowDefinition` that contains the data.
     dataflow: Optional[model.BaseDataflow] = None
     #: The "dimension at observation level".
@@ -388,7 +385,7 @@ class DataMessage(Message):
         Union[
             model._AllDimensions,
             model.DimensionComponent,
-            List[model.DimensionComponent],
+            list[model.DimensionComponent],
         ]
     ] = None
 
@@ -407,7 +404,7 @@ def structure(self):
         return self.dataflow.structure
 
     @property
-    def structure_type(self) -> Type[common.Structure]:
+    def structure_type(self) -> type[common.Structure]:
         """:class:`.Structure` subtype describing the contained (meta)data."""
         return {
             Version["2.1"]: v21.DataStructureDefinition,
@@ -522,7 +519,7 @@ class MetadataMessage(DataMessage):
     """SDMX Metadata Message."""
 
     @property
-    def structure_type(self) -> Type[common.Structure]:
+    def structure_type(self) -> type[common.Structure]:
         return {
             Version["2.1"]: v21.MetadataStructureDefinition,
             Version["3.0.0"]: v30.MetadataStructureDefinition,
diff --git a/sdmx/model/__init__.py b/sdmx/model/__init__.py
index dd6f7bcfc..21688de7b 100644
--- a/sdmx/model/__init__.py
+++ b/sdmx/model/__init__.py
@@ -2,7 +2,7 @@
 
 from . import common, v21
 
-WARNED = set()
+WARNED: set[str] = set()
 
 
 def __dir__():
diff --git a/sdmx/model/common.py b/sdmx/model/common.py
index 16efb70cd..04f418dfb 100644
--- a/sdmx/model/common.py
+++ b/sdmx/model/common.py
@@ -16,18 +16,13 @@
 from typing import (
     Any,
     ClassVar,
-    Dict,
     Generator,
     Generic,
     Iterable,
-    List,
     Mapping,
     MutableMapping,
     Optional,
     Sequence,
-    Set,
-    Tuple,
-    Type,
     TypeVar,
     Union,
     get_args,
@@ -179,7 +174,7 @@ class AnnotableArtefact:
     #:
     #: :mod:`.sdmx` implementation detail: The IM does not specify the name of this
     #: feature.
-    annotations: List[Annotation] = field(default_factory=list)
+    annotations: list[Annotation] = field(default_factory=list)
 
     def get_annotation(self, **attrib):
         """Return a :class:`Annotation` with given `attrib`, e.g. 'id'.
@@ -243,7 +238,7 @@ class IdentifiableArtefact(AnnotableArtefact):
     #: a URN.
     urn: Optional[str] = None
 
-    urn_group: Dict = field(default_factory=dict, repr=False)
+    urn_group: dict = field(default_factory=dict, repr=False)
 
     def __post_init__(self):
         if not isinstance(self.id, str):
@@ -525,7 +520,7 @@ def __contains__(self, name): ...
 @NameableArtefact._preserve("eq", "hash", "repr")
 class Item(NameableArtefact, Generic[IT]):
     parent: Optional[Union[IT, "ItemScheme"]] = None
-    child: List[IT] = field(default_factory=list)
+    child: list[IT] = field(default_factory=list)
 
     def __post_init__(self):
         super().__post_init__()
@@ -624,11 +619,11 @@ class ItemScheme(MaintainableArtefact, Generic[IT]):
     #: Members of the ItemScheme. Both ItemScheme and Item are abstract classes.
     #: Concrete classes are paired: for example, a :class:`.Codelist` contains
     #: :class:`Codes <.Code>`.
-    items: Dict[str, IT] = field(default_factory=dict)
+    items: dict[str, IT] = field(default_factory=dict)
 
     # The type of the Items in the ItemScheme. This is necessary because the type hint
     # in the class declaration is static; not meant to be available at runtime.
-    _Item: ClassVar[Type] = Item
+    _Item: ClassVar[type] = Item
 
     # Convenience access to items
     def __getattr__(self, name: str) -> IT:
@@ -814,7 +809,7 @@ class Representation:
     #:
     enumerated: Optional[ItemScheme] = None
     #:
-    non_enumerated: List[Facet] = field(default_factory=list)
+    non_enumerated: list[Facet] = field(default_factory=list)
 
     def __repr__(self):
         return "<{}: {}, {}>".format(
@@ -890,13 +885,13 @@ def __contains__(self, value):
 @dataclass
 class ComponentList(IdentifiableArtefact, Generic[CT]):
     #:
-    components: List[CT] = field(default_factory=list)
+    components: list[CT] = field(default_factory=list)
     #: Counter used to automatically populate :attr:`.DimensionComponent.order` values.
     auto_order = 1
 
     # The default type of the Components in the ComponentList. See comment on
     # ItemScheme._Item
-    _Component: ClassVar[Type] = Component
+    _Component: ClassVar[type] = Component
 
     # Convenience access to the components
     def append(self, value: CT) -> None:
@@ -1054,20 +1049,20 @@ class Contact:
     #:
     responsibility: InternationalStringDescriptor = InternationalStringDescriptor()
     #:
-    email: List[str] = field(default_factory=list)
+    email: list[str] = field(default_factory=list)
     #:
-    fax: List[str] = field(default_factory=list)
+    fax: list[str] = field(default_factory=list)
     #:
-    uri: List[str] = field(default_factory=list)
+    uri: list[str] = field(default_factory=list)
     #:
-    x400: List[str] = field(default_factory=list)
+    x400: list[str] = field(default_factory=list)
 
 
 @dataclass
 @NameableArtefact._preserve("eq", "hash", "repr")
 class Organisation(Item["Organisation"]):
     #:
-    contact: List[Contact] = field(default_factory=list)
+    contact: list[Contact] = field(default_factory=list)
 
 
 class OrganisationScheme(ItemScheme[IT]):
@@ -1112,7 +1107,7 @@ class Structure(MaintainableArtefact):
     @property
     def grouping(self) -> Sequence[ComponentList]:
         """A collection of all the ComponentLists associated with a subclass."""
-        result: List[ComponentList] = []
+        result: list[ComponentList] = []
         for f in fields(self):
             types = get_args(f.type) or (f.type,)
             try:
@@ -1264,7 +1259,7 @@ class AttributeRelationship:
 @dataclass
 class DimensionRelationship(AttributeRelationship):
     #:
-    dimensions: List[DimensionComponent] = field(default_factory=list)
+    dimensions: list[DimensionComponent] = field(default_factory=list)
     #: NB the IM says "0..*" here in a diagram, but the text does not match.
     group_key: Optional["GroupDimensionDescriptor"] = None
 
@@ -1309,13 +1304,13 @@ class BaseDataStructureDefinition(Structure, ConstrainableArtefact):
     )
 
     # Specific types to be used in concrete subclasses
-    MemberValue: ClassVar[Type["BaseMemberValue"]]
-    MemberSelection: ClassVar[Type["BaseMemberSelection"]]
-    ConstraintType: ClassVar[Type[BaseConstraint]]
+    MemberValue: ClassVar[type["BaseMemberValue"]]
+    MemberSelection: ClassVar[type["BaseMemberSelection"]]
+    ConstraintType: ClassVar[type[BaseConstraint]]
 
     # Convenience methods
     def iter_keys(
-        self, constraint: Optional[BaseConstraint] = None, dims: List[str] = []
+        self, constraint: Optional[BaseConstraint] = None, dims: list[str] = []
     ) -> Generator["Key", None, None]:
         """Iterate over keys.
 
@@ -1341,7 +1336,7 @@ def make_factory(id=None, value_for=None):
             return lambda value: KeyValue(id=id, value=value, value_for=value_for)
 
         # List of iterables of (dim.id, KeyValues) along each dimension
-        all_kvs: List[Iterable[KeyValue]] = []
+        all_kvs: list[Iterable[KeyValue]] = []
 
         # Iterate over dimensions
         for dim in self.dimensions.components:
@@ -1491,7 +1486,7 @@ def make_key(self, key_cls, values: Mapping, extend=False, group_id=None):
         attr = getattr(self.attributes, get_method)
 
         # Arguments for creating the Key
-        args: Dict[str, Any] = dict(described_by=self.dimensions)
+        args: dict[str, Any] = dict(described_by=self.dimensions)
 
         if key_cls is GroupKey:
             # Get the GroupDimensionDescriptor, if indicated by group_id
@@ -1564,7 +1559,7 @@ def __post_init__(self):
             self.structure.is_external_reference = self.is_external_reference
 
     def iter_keys(
-        self, constraint: Optional[BaseConstraint] = None, dims: List[str] = []
+        self, constraint: Optional[BaseConstraint] = None, dims: list[str] = []
     ) -> Generator["Key", None, None]:
         """Iterate over keys.
 
@@ -1762,7 +1757,7 @@ def __init__(self, arg: Union[Mapping, Sequence[KeyValue], None] = None, **kwarg
                 )
             kwargs.update(arg)
 
-        kvs: Iterable[Tuple] = []
+        kvs: Iterable[tuple] = []
 
         if isinstance(arg, Sequence):
             # Sequence of already-prepared KeyValues; assume already sorted
@@ -1909,7 +1904,7 @@ def __init__(self, arg: Optional[Mapping] = None, **kwargs):
 @dataclass
 class SeriesKey(Key):
     #: :mod:`sdmx` extension not in the IM.
-    group_keys: Set[GroupKey] = field(default_factory=set)
+    group_keys: set[GroupKey] = field(default_factory=set)
 
     __eq__ = Key.__eq__
     __hash__ = Key.__hash__
@@ -1942,7 +1937,7 @@ class BaseObservation:
     #: Data value.
     value: Optional[Union[Any, Code]] = None
     #: :mod:`sdmx` extension not in the IM.
-    group_keys: Set[GroupKey] = field(default_factory=set)
+    group_keys: set[GroupKey] = field(default_factory=set)
 
     @property
     def attrib(self):
@@ -2011,14 +2006,14 @@ class BaseDataSet(AnnotableArtefact):
     structured_by: Optional[BaseDataStructureDefinition] = None
 
     #: All observations in the DataSet.
-    obs: List[BaseObservation] = field(default_factory=list)
+    obs: list[BaseObservation] = field(default_factory=list)
 
     #: Map of series key → list of observations.
     #: :mod:`sdmx` extension not in the IM.
-    series: DictLikeDescriptor[SeriesKey, List[BaseObservation]] = DictLikeDescriptor()
+    series: DictLikeDescriptor[SeriesKey, list[BaseObservation]] = DictLikeDescriptor()
     #: Map of group key → list of observations.
     #: :mod:`sdmx` extension not in the IM.
-    group: DictLikeDescriptor[GroupKey, List[BaseObservation]] = DictLikeDescriptor()
+    group: DictLikeDescriptor[GroupKey, list[BaseObservation]] = DictLikeDescriptor()
 
     def __post_init__(self):
         if self.action and not isinstance(self.action, ActionType):
@@ -2117,7 +2112,7 @@ class MetadataAttribute(AttributeComponent):
     min_occurs: Optional[int] = None
 
     parent: Optional["MetadataAttribute"] = None
-    child: List["MetadataAttribute"] = field(default_factory=list)
+    child: list["MetadataAttribute"] = field(default_factory=list)
 
 
 class BaseMetadataStructureDefinition(Structure, ConstrainableArtefact):
@@ -2202,7 +2197,7 @@ class HierarchicalCode(IdentifiableArtefact):
     parent: Optional[Union["HierarchicalCode", Any]] = (
         None  # NB second element is "Hierarchy"
     )
-    child: List["HierarchicalCode"] = field(default_factory=list)
+    child: list["HierarchicalCode"] = field(default_factory=list)
 
 
 # SDMX 2.1 §10.2: Constraint inheritance
@@ -2276,7 +2271,7 @@ class BaseDataKey:
     # :obj:`False` if they are excluded.
     included: bool
     #: Mapping from :class:`.Component` to :class:`.ComponentValue` comprising the key.
-    key_value: Dict[Component, ComponentValue] = field(default_factory=dict)
+    key_value: dict[Component, ComponentValue] = field(default_factory=dict)
 
 
 @dataclass
@@ -2287,7 +2282,7 @@ class BaseDataKeySet:
     #: :class:`Constraint <.BaseConstraint>`; :obj:`False` if they are excluded.
     included: bool
     #: :class:`DataKeys <.BaseDataKey>` appearing in the set.
-    keys: List[BaseDataKey] = field(default_factory=list)
+    keys: list[BaseDataKey] = field(default_factory=list)
 
     def __len__(self):
         """:func:`len` of the DataKeySet = :func:`len` of its :attr:`keys`."""
@@ -2307,7 +2302,7 @@ class BaseMemberSelection:
     included: bool = True
     #: Value(s) included in the selection. Note that the name of this attribute is not
     #: stated in the IM, so 'values' is chosen for the implementation in this package.
-    values: List[BaseSelectionValue] = field(default_factory=list)
+    values: list[BaseSelectionValue] = field(default_factory=list)
 
     def __contains__(self, value):
         """Compare KeyValue to MemberValue."""
@@ -2329,7 +2324,7 @@ class CubeRegion:
     #:
     included: bool = True
     #:
-    member: Dict[DimensionComponent, BaseMemberSelection] = field(default_factory=dict)
+    member: dict[DimensionComponent, BaseMemberSelection] = field(default_factory=dict)
 
     def __contains__(self, other: Union["Key", "KeyValue"]) -> bool:
         """Membership test.
@@ -2561,7 +2556,7 @@ class BaseContentConstraint:
 #: The SDMX-IM groups classes into 'packages'; these are used in :class:`URNs <.URN>`.
 PACKAGE = dict()
 
-_PACKAGE_CLASS: Dict[str, set] = {
+_PACKAGE_CLASS: dict[str, set] = {
     "base": {
         "Agency",
         "AgencyScheme",
@@ -2627,15 +2622,15 @@ class BaseContentConstraint:
 @dataclass
 class ClassFinder:
     module_name: str
-    name_map: Dict[str, str] = field(default_factory=dict)
-    parent_map: Dict[type, type] = field(default_factory=dict)
+    name_map: dict[str, str] = field(default_factory=dict)
+    parent_map: dict[type, type] = field(default_factory=dict)
 
     def __post_init__(self):
         self._module = sys.modules[self.module_name]
         self._parent = ChainMap(PARENT, self.parent_map)
 
     @lru_cache()
-    def get_class(self, name: Union[str, Resource], package=None) -> Optional[Type]:
+    def get_class(self, name: Union[str, Resource], package=None) -> Optional[type]:
         """Return a class for `name` and (optional) `package` names."""
         if isinstance(name, Resource):
             # Convert a Resource enumeration value to a string
diff --git a/sdmx/model/internationalstring.py b/sdmx/model/internationalstring.py
index 0441ef9ab..7597672b6 100644
--- a/sdmx/model/internationalstring.py
+++ b/sdmx/model/internationalstring.py
@@ -1,5 +1,5 @@
 from copy import copy
-from typing import Dict, Iterable, Mapping, Optional, Sequence, Tuple, Union
+from typing import Iterable, Mapping, Optional, Sequence, Union
 
 # TODO read this from the environment, or use any value set in the SDMX-ML spec.
 #      Currently set to 'en' because test_dsd.py expects it.
@@ -50,9 +50,9 @@ class Foo:
     __slots__ = ("localizations",)
 
     # Types that can be converted into InternationalString
-    _CONVERTIBLE = Union[str, Sequence, Mapping, Iterable[Tuple[str, str]]]
+    _CONVERTIBLE = Union[str, Sequence, Mapping, Iterable[tuple[str, str]]]
 
-    localizations: Dict[str, str]
+    localizations: dict[str, str]
 
     def __init__(self, value: Optional[_CONVERTIBLE] = None, **kwargs):
         # Handle initial values according to type
diff --git a/sdmx/model/v21.py b/sdmx/model/v21.py
index 261584579..155bee145 100644
--- a/sdmx/model/v21.py
+++ b/sdmx/model/v21.py
@@ -7,13 +7,9 @@
 from dataclasses import dataclass, field
 from typing import (
     ClassVar,
-    Dict,
     Generator,
     Generic,
-    List,
     Optional,
-    Set,
-    Type,
     TypeVar,
     Union,
 )
@@ -171,9 +167,9 @@ class MemberSelection(common.BaseMemberSelection):
 @NameableArtefact._preserve("repr")
 class ContentConstraint(Constraint, common.BaseContentConstraint):
     #: :class:`CubeRegions <.CubeRegion>` included in the ContentConstraint.
-    data_content_region: List[common.CubeRegion] = field(default_factory=list)
+    data_content_region: list[common.CubeRegion] = field(default_factory=list)
     #:
-    content: Set[ConstrainableArtefact] = field(default_factory=set)
+    content: set[ConstrainableArtefact] = field(default_factory=set)
     metadata_content_region: Optional[common.MetadataTargetRegion] = None
 
     def __contains__(self, value):
@@ -195,7 +191,7 @@ def to_query_string(self, structure):
     def iter_keys(
         self,
         obj: Union["DataStructureDefinition", "DataflowDefinition"],
-        dims: List[str] = [],
+        dims: list[str] = [],
     ) -> Generator[Key, None, None]:
         """Iterate over keys.
 
@@ -350,7 +346,7 @@ class IdentifiableObjectTarget(TargetObject):
     """SDMX 2.1 IdentifiableObjectTarget."""
 
     #: Type of :class:`.IdentifiableArtefact` that is targeted.
-    object_type: Optional[Type[IdentifiableArtefact]] = None
+    object_type: Optional[type[IdentifiableArtefact]] = None
 
     def compare(self, other, strict=True):
         """Return :obj:`True` if `self` is the same as `other`.
@@ -385,7 +381,7 @@ class ReportStructure(ComponentList):
 
     _Component = common.MetadataAttribute
 
-    report_for: List[MetadataTarget] = field(default_factory=list)
+    report_for: list[MetadataTarget] = field(default_factory=list)
 
 
 @dataclass
@@ -433,10 +429,18 @@ class TargetIdentifiableObject(TargetObjectValue):
 
 @dataclass
 class TargetObjectKey:
-    """SDMX 2.1 TargetObjectKey."""
+    """SDMX 2.1 TargetObjectKey.
 
+    TargetObjectKey supports item access (:py:`tok["name"]`) to members of
+    :attr:`.key_values`.
+    """
+
+    #: Keys and values of the TargetObjectKey.
     key_values: DictLikeDescriptor[str, TargetObjectValue] = DictLikeDescriptor()
 
+    def __getitem__(self, name: str) -> TargetObjectValue:
+        return self.key_values[name]
+
 
 @dataclass
 class ReportedAttribute:
@@ -447,7 +451,7 @@ class ReportedAttribute:
 
     value_for: common.MetadataAttribute
     parent: Optional["ReportedAttribute"] = None
-    child: List["ReportedAttribute"] = field(default_factory=list)
+    child: list["ReportedAttribute"] = field(default_factory=list)
 
     def __getitem__(self, index: int) -> "ReportedAttribute":
         return self.child[index]
@@ -498,7 +502,7 @@ class XHTMLAttributeValue(NonEnumeratedAttributeValue, common.BaseXHTMLAttribute
 class MetadataReport:
     """SDMX 2.1 MetadataReport."""
 
-    metadata: List[ReportedAttribute] = field(default_factory=list)
+    metadata: list[ReportedAttribute] = field(default_factory=list)
     target: Optional[MetadataTarget] = None
     attaches_to: Optional[TargetObjectKey] = None
 
@@ -521,7 +525,7 @@ class MetadataSet(NameableArtefact, common.BaseMetadataSet):
     #: Analogous to :attr:`.v30.MetadataSet.provided_by`.
     published_by: Optional[common.DataProvider] = None
 
-    report: List[MetadataReport] = field(default_factory=list)
+    report: list[MetadataReport] = field(default_factory=list)
 
 
 # §8 Hierarchical Code List
@@ -534,7 +538,7 @@ class Hierarchy(NameableArtefact):
     has_formal_levels: bool = False
 
     #: Hierarchical codes in the hierarchy.
-    codes: Dict[str, common.HierarchicalCode] = field(default_factory=dict)
+    codes: dict[str, common.HierarchicalCode] = field(default_factory=dict)
 
     level: Optional[common.Level] = None
 
@@ -543,7 +547,7 @@ class Hierarchy(NameableArtefact):
 class HierarchicalCodelist(common.MaintainableArtefact):
     """SDMX 2.1 HierarchicalCodelist."""
 
-    hierarchy: List[Hierarchy] = field(default_factory=list)
+    hierarchy: list[Hierarchy] = field(default_factory=list)
 
     def __repr__(self) -> str:
         tmp = super(NameableArtefact, self).__repr__()[:-1]
@@ -557,7 +561,7 @@ def __repr__(self) -> str:
 class ItemAssociation(common.AnnotableArtefact, Generic[IT]):
     """SDMX 2.1 ItemAssociation."""
 
-    _Item: ClassVar[Type[common.Item]] = common.Item
+    _Item: ClassVar[type[common.Item]] = common.Item
 
     source: Optional[IT] = None
     target: Optional[IT] = None
@@ -577,12 +581,12 @@ class CodeMap(ItemAssociation[common.Code]):
 class ItemSchemeMap(NameableArtefact, Generic[IST, IAT]):
     """SDMX 2.1 ItemSchemeMap."""
 
-    _ItemAssociation: ClassVar[Type[ItemAssociation]] = ItemAssociation
+    _ItemAssociation: ClassVar[type[ItemAssociation]] = ItemAssociation
 
     source: Optional[IST] = None
     target: Optional[IST] = None
 
-    item_association: List[IAT] = field(default_factory=list)
+    item_association: list[IAT] = field(default_factory=list)
 
 
 class CodelistMap(ItemSchemeMap[common.Codelist, CodeMap]):
@@ -595,7 +599,7 @@ class CodelistMap(ItemSchemeMap[common.Codelist, CodeMap]):
 class StructureSet(common.MaintainableArtefact):
     """SDMX 2.1 StructureSet."""
 
-    item_scheme_map: List[ItemSchemeMap] = field(default_factory=list)
+    item_scheme_map: list[ItemSchemeMap] = field(default_factory=list)
 
 
 CF = common.ClassFinder(
diff --git a/sdmx/model/v30.py b/sdmx/model/v30.py
index 707e39fbb..8a8916f99 100644
--- a/sdmx/model/v30.py
+++ b/sdmx/model/v30.py
@@ -3,7 +3,7 @@
 from dataclasses import dataclass, field
 from datetime import date
 from enum import Enum
-from typing import Any, ClassVar, Dict, List, Optional, Set
+from typing import Any, ClassVar, Optional
 
 from . import common
 from .common import (
@@ -77,7 +77,7 @@
 
 @dataclass
 class CodeSelection:
-    mv: List["MemberValue"] = field(default_factory=list)
+    mv: list["MemberValue"] = field(default_factory=list)
 
 
 class ExclusiveCodeSelection(CodeSelection):
@@ -190,7 +190,7 @@ class ValueList(EnumeratedList):
 
     _Item = ValueItem
 
-    items: List[ValueItem] = field(default_factory=list)
+    items: list[ValueItem] = field(default_factory=list)
 
     def append(self, item: ValueItem) -> None:
         self.items.append(item)
@@ -284,7 +284,7 @@ class MemberSelection(common.BaseMemberSelection):
 @NameableArtefact._preserve("repr")
 class DataConstraint(Constraint):
     #:
-    content: Set[ConstrainableArtefact] = field(default_factory=set)
+    content: set[ConstrainableArtefact] = field(default_factory=set)
 
     data_content_keys: Optional[DataKeySet] = None
     data_content_region: Optional[common.CubeRegion] = None
@@ -432,7 +432,7 @@ class MetadataAttributeValue:
     #    offends mypy.
 
     parent: Optional["MetadataAttributeValue"] = None
-    child: List["MetadataAttributeValue"] = field(default_factory=list)
+    child: list["MetadataAttributeValue"] = field(default_factory=list)
 
 
 class CodedMetadataAttributeValue(MetadataAttributeValue):
@@ -505,9 +505,9 @@ class MetadataSet(MaintainableArtefact, common.BaseMetadataSet):
     #: Analogous to :attr:`.v21.MetadataSet.published_by`.
     provided_by: Optional[MetadataProvider] = None
 
-    attaches_to: List[TargetIdentifiableObject] = field(default_factory=list)
+    attaches_to: list[TargetIdentifiableObject] = field(default_factory=list)
 
-    metadata: List[MetadataAttributeValue] = field(default_factory=list)
+    metadata: list[MetadataAttributeValue] = field(default_factory=list)
 
 
 # §8: Hierarchy
@@ -523,7 +523,7 @@ class Hierarchy(MaintainableArtefact):
     level: Optional[common.Level] = None
 
     #: The top-level :class:`HierarchicalCodes <HierarchicalCode>` in the hierarchy.
-    codes: Dict[str, common.HierarchicalCode] = field(default_factory=dict)
+    codes: dict[str, common.HierarchicalCode] = field(default_factory=dict)
 
 
 @dataclass
diff --git a/sdmx/reader/base.py b/sdmx/reader/base.py
index 7465b41ee..97c60ef5c 100644
--- a/sdmx/reader/base.py
+++ b/sdmx/reader/base.py
@@ -1,7 +1,7 @@
 import logging
 from abc import ABC, abstractmethod
 from functools import lru_cache
-from typing import TYPE_CHECKING, ClassVar, List, Optional
+from typing import TYPE_CHECKING, ClassVar, Optional
 from warnings import warn
 
 from sdmx.format import MediaType
@@ -14,10 +14,10 @@
 
 class BaseReader(ABC):
     #: List of media types handled by the reader.
-    media_types: ClassVar[List[MediaType]] = []
+    media_types: ClassVar[list[MediaType]] = []
 
     #: List of file name suffixes handled by the reader.
-    suffixes: ClassVar[List[str]] = []
+    suffixes: ClassVar[list[str]] = []
 
     @classmethod
     def detect(cls, content: bytes) -> bool:
diff --git a/sdmx/reader/xml/common.py b/sdmx/reader/xml/common.py
index d7d0603af..53195f5e9 100644
--- a/sdmx/reader/xml/common.py
+++ b/sdmx/reader/xml/common.py
@@ -8,14 +8,11 @@
     Any,
     Callable,
     ClassVar,
-    Dict,
     Iterable,
     Iterator,
     Mapping,
     Optional,
     Sequence,
-    Tuple,
-    Type,
     Union,
     cast,
 )
@@ -105,7 +102,7 @@ def __init__(self, reader, elem, cls_hint=None):
 
     @classmethod
     @abstractmethod
-    def info_from_element(cls, elem) -> Dict[str, Any]: ...
+    def info_from_element(cls, elem) -> dict[str, Any]: ...
 
     def __str__(self):
         # NB for debugging only
@@ -130,16 +127,16 @@ class XMLEventReader(BaseReader):
     model: ClassVar["types.ModuleType"]
 
     #: :class:`.BaseReference` subclass used by this reader.
-    Reference: ClassVar[Type[BaseReference]]
+    Reference: ClassVar[type[BaseReference]]
 
     # Mapping from (QName, ["start", "end"]) to a function that parses the element/event
     # or else None
-    parser: ClassVar[Mapping[Tuple[QName, str], Callable]]
+    parser: ClassVar[Mapping[tuple[QName, str], Callable]]
 
     # One-way counter for use in stacks
     _count: Iterator[int]
 
-    def __init_subclass__(cls: Type["XMLEventReader"]):
+    def __init_subclass__(cls: type["XMLEventReader"]):
         # Empty dictionary
         cls.parser = {}
 
@@ -162,7 +159,7 @@ def read_message(  # noqa: C901 TODO reduce complexity 12 → ≤11
         **kwargs,
     ) -> message.Message:
         # Initialize stacks
-        self.stack: Dict[Union[Type, str], Dict[Union[str, int], Any]] = defaultdict(
+        self.stack: dict[Union[type, str], dict[Union[str, int], Any]] = defaultdict(
             dict
         )
 
@@ -177,7 +174,7 @@ def read_message(  # noqa: C901 TODO reduce complexity 12 → ≤11
 
         if _events is None:
             events = cast(
-                Iterator[Tuple[str, etree._Element]],
+                Iterator[tuple[str, etree._Element]],
                 etree.iterparse(source, events=("start", "end")),
             )
         else:
@@ -385,7 +382,7 @@ def qname(cls, ns_or_name, name=None) -> QName:
 
     def get_single(
         self,
-        cls_or_name: Union[Type, str],
+        cls_or_name: Union[type, str],
         id: Optional[str] = None,
         version: Optional[str] = None,
         subclass: bool = False,
@@ -402,7 +399,7 @@ def get_single(
         the stack `cls_or_name` *or any stack for a subclass of this class*.
         """
         if subclass:
-            keys: Iterable[Union[Type, str]] = filter(
+            keys: Iterable[Union[type, str]] = filter(
                 matching_class(cls_or_name), self.stack.keys()
             )
             results: Mapping = ChainMap(*[self.stack[k] for k in keys])
@@ -422,14 +419,14 @@ def get_single(
         else:
             return next(iter(results.values()))
 
-    def pop_all(self, cls_or_name: Union[Type, str], subclass=False) -> Sequence:
+    def pop_all(self, cls_or_name: Union[type, str], subclass=False) -> Sequence:
         """Pop all objects from stack *cls_or_name* and return.
 
         If `cls_or_name` is a class and `subclass` is :obj:`True`; return all objects in
         the stack `cls_or_name` *or any stack for a subclass of this class*.
         """
         if subclass:
-            keys: Iterable[Union[Type, str]] = list(
+            keys: Iterable[Union[type, str]] = list(
                 filter(matching_class(cls_or_name), self.stack.keys())
             )
             result: Iterable = chain(*[self.stack.pop(k).values() for k in keys])
@@ -438,14 +435,14 @@ def pop_all(self, cls_or_name: Union[Type, str], subclass=False) -> Sequence:
 
         return list(result)
 
-    def pop_single(self, cls_or_name: Union[Type, str]):
+    def pop_single(self, cls_or_name: Union[type, str]):
         """Pop a single object from the stack for `cls_or_name` and return."""
         try:
             return self.stack[cls_or_name].popitem()[1]
         except KeyError:
             return None
 
-    def peek(self, cls_or_name: Union[Type, str]):
+    def peek(self, cls_or_name: Union[type, str]):
         """Get the object at the top of stack `cls_or_name` without removing it."""
         try:
             key, value = self.stack[cls_or_name].popitem()
@@ -454,7 +451,7 @@ def peek(self, cls_or_name: Union[Type, str]):
         except KeyError:  # pragma: no cover
             return None
 
-    def pop_resolved_ref(self, cls_or_name: Union[Type, str]):
+    def pop_resolved_ref(self, cls_or_name: Union[type, str]):
         """Pop a reference to `cls_or_name` and resolve it."""
         return self.resolve(self.pop_single(cls_or_name))
 
diff --git a/sdmx/reader/xml/v21.py b/sdmx/reader/xml/v21.py
index f246ccdc3..ee23e45db 100644
--- a/sdmx/reader/xml/v21.py
+++ b/sdmx/reader/xml/v21.py
@@ -11,7 +11,7 @@
 from copy import copy
 from itertools import chain
 from sys import maxsize
-from typing import Any, Dict, MutableMapping, Optional, Type, cast
+from typing import Any, MutableMapping, Optional, cast
 
 from dateutil.parser import isoparse
 from lxml import etree
@@ -392,7 +392,7 @@ def _ref(reader: Reader, elem):
         # <com:Structure>/<str:Structure>: use message property for a class hint
         msg = reader.get_single(message.DataMessage, subclass=True)
         if msg:
-            cls_hint = cast(Type[message.DataMessage], type(msg))(
+            cls_hint = cast(type[message.DataMessage], type(msg))(
                 version=reader.xml_version
             ).structure_type
         elif QName(elem.getparent()).localname == "Dataflow":
@@ -497,7 +497,7 @@ def _item_end(reader: Reader, elem):
 )
 @possible_reference()  # <str:CustomTypeScheme> in <str:Transformation>
 def _itemscheme(reader: Reader, elem):
-    cls: Type[common.ItemScheme] = reader.class_for_tag(elem.tag)
+    cls: type[common.ItemScheme] = reader.class_for_tag(elem.tag)
 
     try:
         args = dict(is_partial=elem.attrib["isPartial"])
@@ -510,7 +510,7 @@ def _itemscheme(reader: Reader, elem):
     iter_all = chain(*[iter(item) for item in reader.pop_all(cls._Item, subclass=True)])
 
     # Set of objects already added to `items`
-    seen: Dict[Any, Any] = dict()
+    seen: dict[Any, Any] = dict()
 
     # Flatten the list, with each item appearing only once
     for i in filter(lambda i: i not in seen, iter_all):
@@ -1464,14 +1464,14 @@ def _hcl(reader: Reader, elem):
 
 @start("str:CodelistMap", only=False)
 def _ismap_start(reader: Reader, elem):
-    cls: Type[model.ItemSchemeMap] = reader.class_for_tag(elem.tag)
+    cls: type[model.ItemSchemeMap] = reader.class_for_tag(elem.tag)
     # Push class for reference while parsing sub-elements
     reader.push("ItemAssociation class", cls._ItemAssociation._Item)
 
 
 @end("str:CodelistMap", only=False)
 def _ismap_end(reader: Reader, elem):
-    cls: Type[model.ItemSchemeMap] = reader.class_for_tag(elem.tag)
+    cls: type[model.ItemSchemeMap] = reader.class_for_tag(elem.tag)
 
     # Remove class from stacks
     reader.pop_single("ItemAssociation class")
@@ -1506,7 +1506,7 @@ def _ismap_end(reader: Reader, elem):
 
 @end("str:CodeMap")
 def _item_map(reader: Reader, elem):
-    cls: Type[model.ItemAssociation] = reader.class_for_tag(elem.tag)
+    cls: type[model.ItemAssociation] = reader.class_for_tag(elem.tag)
 
     # Store Source and Target as Reference instances
     return reader.annotable(
@@ -1622,7 +1622,7 @@ def _udo(reader: Reader, elem):
 @end("str:VtlMapping")
 def _vtlm(reader: Reader, elem):
     ref = reader.resolve(reader.pop_single(reader.Reference))
-    args: Dict[str, Any] = dict()
+    args: dict[str, Any] = dict()
     if isinstance(ref, common.BaseDataflow):
         cls = model.VTLDataflowMapping
         args["dataflow_alias"] = ref
diff --git a/sdmx/reader/xml/v30.py b/sdmx/reader/xml/v30.py
index 29a043fd9..b7fb69d09 100644
--- a/sdmx/reader/xml/v30.py
+++ b/sdmx/reader/xml/v30.py
@@ -1,6 +1,6 @@
 """SDMX-ML 3.0.0 reader."""
 
-from typing import Any, Dict
+from typing import Any
 
 import sdmx.urn
 from sdmx.format import Version
@@ -140,7 +140,7 @@ def _ar(reader: Reader, elem):
         return
 
     # Iterate over parsed references to Components
-    args: Dict[str, Any] = dict(dimensions=list())
+    args: dict[str, Any] = dict(dimensions=list())
     for ref in refs:
         # Use the <Ref id="..."> to retrieve a Component from the DSD
         if issubclass(ref.target_cls, model.DimensionComponent):
diff --git a/sdmx/rest/common.py b/sdmx/rest/common.py
index d1190f804..d00c7c020 100644
--- a/sdmx/rest/common.py
+++ b/sdmx/rest/common.py
@@ -5,7 +5,7 @@
 from copy import copy
 from dataclasses import dataclass, field
 from enum import Enum
-from typing import TYPE_CHECKING, Any, ClassVar, Dict, Mapping, Optional
+from typing import TYPE_CHECKING, Any, ClassVar, Mapping, Optional
 from urllib.parse import urlsplit, urlunsplit
 
 if TYPE_CHECKING:
@@ -155,7 +155,7 @@ class Parameter(abc.ABC):
     default: Optional[str] = None
 
     @abc.abstractmethod
-    def handle(self, parameters: Dict[str, Any]) -> Dict[str, str]:
+    def handle(self, parameters: dict[str, Any]) -> dict[str, str]:
         """Return a dict to update :attr:`.URL.path` or :attr:`.URL.query`."""
 
 
@@ -246,7 +246,7 @@ def handle(self, parameters):
 # - common:NCNameIDType
 # - common:VersionType
 
-PARAM: Dict[str, Parameter] = {
+PARAM: dict[str, Parameter] = {
     # Path parameters
     "agency_id": PathParameter("agency_id"),
     "key": OptionalPath("key"),
@@ -321,10 +321,10 @@ class URL(abc.ABC):
     resource_type: Resource
 
     #: Pieces for the hierarchical path component of the URL. If
-    _path: Dict[str, Optional[str]]
+    _path: dict[str, Optional[str]]
 
     #: Pieces for the query component of the URL.
-    query: Dict[str, str]
+    query: dict[str, str]
 
     # Keyword arguments to the constructor
     _params: dict
diff --git a/sdmx/rest/v21.py b/sdmx/rest/v21.py
index faa0feff7..783f78785 100644
--- a/sdmx/rest/v21.py
+++ b/sdmx/rest/v21.py
@@ -9,14 +9,13 @@
 """
 
 from collections import ChainMap
-from typing import Dict
 from warnings import warn
 
 from . import common
 from .common import OptionalPath, PathParameter, QueryParameter, Resource
 
 #: v1.5.0-specific path and query parameters.
-PARAM: Dict[str, common.Parameter] = {
+PARAM: dict[str, common.Parameter] = {
     # Path parameters
     # NB the text and YAML OpenAPI specification disagree on whether this is required
     "component_id": OptionalPath("component_id"),
diff --git a/sdmx/rest/v30.py b/sdmx/rest/v30.py
index edd83bac0..f9f49c422 100644
--- a/sdmx/rest/v30.py
+++ b/sdmx/rest/v30.py
@@ -8,13 +8,12 @@
 """
 
 from collections import ChainMap
-from typing import Dict
 
 from . import common
 from .common import PathParameter, QueryParameter, Resource
 
 #: v2.1.0-specific path and query parameters.
-PARAM: Dict[str, common.Parameter] = {
+PARAM: dict[str, common.Parameter] = {
     # Path parameters
     "component_id": PathParameter("component_id"),
     "context": PathParameter(
diff --git a/sdmx/source/__init__.py b/sdmx/source/__init__.py
index 8e9051922..6cbbfa9fd 100644
--- a/sdmx/source/__init__.py
+++ b/sdmx/source/__init__.py
@@ -4,7 +4,7 @@
 from enum import Enum
 from importlib import import_module
 from io import IOBase
-from typing import TYPE_CHECKING, Any, Dict, Optional, Set, Tuple, Type, Union
+from typing import TYPE_CHECKING, Any, Optional, Union
 
 from requests import Response
 
@@ -16,7 +16,7 @@
     import sdmx.rest.common
 
 #: Data sources registered with :mod:`sdmx`.
-sources: Dict[str, "Source"] = {}
+sources: dict[str, "Source"] = {}
 
 #: Valid data content types for SDMX REST API messages.
 DataContentType = Enum("DataContentType", "CSV JSON XML")
@@ -68,14 +68,14 @@ class Source:
     name: str
 
     #: Additional HTTP headers to supply by default with all requests.
-    headers: Dict[str, Any] = field(default_factory=dict)
+    headers: dict[str, Any] = field(default_factory=dict)
 
     #: :class:`.DataContentType` indicating the type of data returned by the source.
     data_content_type: DataContentType = DataContentType.XML
 
     #: SDMX REST API version(s) supported. Default: :class:`.Version["2.1"] <.Version>`
     #: only.
-    versions: Set[Version] = field(default_factory=lambda: {Version["2.1"]})
+    versions: set[Version] = field(default_factory=lambda: {Version["2.1"]})
 
     #: Mapping from :class:`.Resource` values to :class:`bool` indicating support for
     #: SDMX-REST endpoints and features. If not supplied, the defaults from
@@ -87,7 +87,7 @@ class Source:
     #:   See :meth:`.preview_data`.
     #: - ``"structure-specific data"=True`` if the source can return structure-
     #:   specific data messages.
-    supports: Dict[Union[str, Resource], bool] = field(default_factory=dict)
+    supports: dict[Union[str, Resource], bool] = field(default_factory=dict)
 
     def __post_init__(self):
         # Sanity check: _id attribute of a subclass matches the loaded ID.
@@ -121,7 +121,7 @@ def __post_init__(self):
                 self.supports.pop(f_name, SDMX_ML_SUPPORTS.get(feature, sdmx_ml)),
             )
 
-    def get_url_class(self) -> Type["sdmx.rest.common.URL"]:
+    def get_url_class(self) -> type["sdmx.rest.common.URL"]:
         """Return a class for constructing URLs for this Source.
 
         - If :attr:`.versions` includes *only* SDMX 3.0.0, return :class:`.v30.URL`.
@@ -142,7 +142,7 @@ def get_url_class(self) -> Type["sdmx.rest.common.URL"]:
     # Hooks
     def handle_response(
         self, response: Response, content: IOBase
-    ) -> Tuple[Response, IOBase]:
+    ) -> tuple[Response, IOBase]:
         """Handle response content of unknown type.
 
         This hook is called by :meth:`.Client.get` *only* when the `content` cannot be
@@ -198,7 +198,7 @@ class _NoSource(Source):
 
 
 def add_source(
-    info: Union[Dict, str], id: Optional[str] = None, override: bool = False, **kwargs
+    info: Union[dict, str], id: Optional[str] = None, override: bool = False, **kwargs
 ) -> None:
     """Add a new data source.
 
diff --git a/sdmx/source/estat.py b/sdmx/source/estat.py
index b6624ca3d..f177fca02 100644
--- a/sdmx/source/estat.py
+++ b/sdmx/source/estat.py
@@ -1,7 +1,6 @@
 import logging
 from tempfile import NamedTemporaryFile
 from time import sleep
-from typing import Dict
 from urllib.parse import urlparse
 from zipfile import ZipFile
 
@@ -13,7 +12,7 @@
 log = logging.getLogger(__name__)
 
 
-def handle_references_param(kwargs: Dict) -> None:
+def handle_references_param(kwargs: dict) -> None:
     """Handle the "references" query parameter for ESTAT and similar.
 
     For this parameter, the server software behind ESTAT's data source only supports
diff --git a/sdmx/testing/__init__.py b/sdmx/testing/__init__.py
index c32e7769f..6ad4b1522 100644
--- a/sdmx/testing/__init__.py
+++ b/sdmx/testing/__init__.py
@@ -3,7 +3,7 @@
 from collections import ChainMap
 from contextlib import contextmanager
 from pathlib import Path, PurePosixPath
-from typing import List, Tuple, Union
+from typing import Union
 
 import numpy as np
 import pandas as pd
@@ -233,7 +233,7 @@ class SpecimenCollection:
 
     # Path to specimen; file format; data/structure
     # TODO add version
-    specimens: List[Tuple[Path, str, str]]
+    specimens: list[tuple[Path, str, str]]
 
     def __init__(self, base_path):
         self.base_path = base_path
diff --git a/sdmx/testing/report.py b/sdmx/testing/report.py
index 8c550acf5..95a04de70 100644
--- a/sdmx/testing/report.py
+++ b/sdmx/testing/report.py
@@ -2,7 +2,7 @@
 import os
 from itertools import chain
 from pathlib import Path
-from typing import Dict, Optional
+from typing import Optional
 
 from jinja2 import Template
 
@@ -207,7 +207,7 @@ def main(base_path: Optional[Path] = None):
     base_path = base_path or Path.cwd().joinpath("source-tests")
 
     # Locate, read, and merge JSON files
-    data: Dict[str, Dict[str, str]] = {}
+    data: dict[str, dict[str, str]] = {}
     for path in base_path.glob("**/*.json"):
         # Update `data` with the file contents
         with open(path) as f:
diff --git a/sdmx/tests/__init__.py b/sdmx/tests/__init__.py
index 4464ea804..d1fe67291 100644
--- a/sdmx/tests/__init__.py
+++ b/sdmx/tests/__init__.py
@@ -1,5 +1,5 @@
 import importlib
-from typing import Optional, Tuple
+from typing import Optional
 
 import pytest
 from packaging.version import Version
@@ -8,7 +8,7 @@
 # thanks to xarray
 def _importorskip(
     modname: str, minversion: Optional[str] = None
-) -> Tuple[bool, pytest.MarkDecorator]:
+) -> tuple[bool, pytest.MarkDecorator]:
     try:
         mod = importlib.import_module(modname)
         has = True
diff --git a/sdmx/tests/model/test_v21.py b/sdmx/tests/model/test_v21.py
index eb0e33170..d53bb21a5 100644
--- a/sdmx/tests/model/test_v21.py
+++ b/sdmx/tests/model/test_v21.py
@@ -1,10 +1,10 @@
 from operator import attrgetter
-from typing import List
 
 import pytest
 
 import sdmx
 import sdmx.message
+from sdmx.model import v21
 from sdmx.model import v21 as model
 from sdmx.model.v21 import (
     AttributeDescriptor,
@@ -33,6 +33,7 @@
     MemberSelection,
     MemberValue,
     Observation,
+    TargetObjectKey,
     value_for_dsd_ref,
 )
 
@@ -55,7 +56,7 @@ def cl(self):
     def components(self):
         return [Dimension(id="C1"), Dimension(id="C2"), Dimension(id="C3")]
 
-    def test_append(self, cl: ComponentList, components: List[Dimension]) -> None:
+    def test_append(self, cl: ComponentList, components: list[Dimension]) -> None:
         # Components have no order
         assert (None, None, None) == tuple(map(attrgetter("order"), components))
 
@@ -75,14 +76,14 @@ def test_getdefault(self, cl) -> None:
         assert not hasattr(foo, "order")
 
     def test_extend_no_order(
-        self, cl: ComponentList, components: List[Dimension]
+        self, cl: ComponentList, components: list[Dimension]
     ) -> None:
         cl.extend(components)
 
         # extend() also adds order
         assert (1, 2, 3) == tuple(map(attrgetter("order"), components))
 
-    def test_extend_order(self, cl: ComponentList, components: List[Dimension]) -> None:
+    def test_extend_order(self, cl: ComponentList, components: list[Dimension]) -> None:
         components[2].order = 1
         components[1].order = 2
         components[0].order = 3
@@ -649,3 +650,15 @@ def test_hierarchy(self, msg: sdmx.message.StructureMessage) -> None:
 
     def test_repr(self, obj: model.HierarchicalCodelist):
         assert "<HierarchicalCodelist HCL_COUNTRY: 1 hierarchies>" == repr(obj)
+
+
+class TestTargetObjectKey:
+    def test_getitem(self) -> None:
+        """:meth:`TargetObjectKey.__getitem__` works."""
+        to = v21.TargetObject(id="FOO")
+        c = Code(id="BAR")
+        tok = TargetObjectKey(
+            key_values={"FOO": v21.TargetIdentifiableObject(value_for=to, obj=c)}
+        )
+
+        assert tok["FOO"].obj is c  # type: ignore [attr-defined]
diff --git a/sdmx/tests/reader/test_base.py b/sdmx/tests/reader/test_base.py
index 9cfd9e126..456bd41ad 100644
--- a/sdmx/tests/reader/test_base.py
+++ b/sdmx/tests/reader/test_base.py
@@ -26,9 +26,12 @@ def test_deprecated_kwarg(self):
 
         dsd0 = v21.DataStructureDefinition(id="FOO")
         dsd1 = v21.DataStructureDefinition(id="BAR")
-        with pytest.warns(
-            DeprecationWarning, match="dsd=.* keyword argument; use structure="
-        ), pytest.raises(ValueError, match="Mismatched structure=FOO, dsd=BAR"):
+        with (
+            pytest.warns(
+                DeprecationWarning, match="dsd=.* keyword argument; use structure="
+            ),
+            pytest.raises(ValueError, match="Mismatched structure=FOO, dsd=BAR"),
+        ):
             r.read_message(None, structure=dsd0, dsd=dsd1)
 
     def test_detect(self, MinimalReader):
diff --git a/sdmx/tests/test_client.py b/sdmx/tests/test_client.py
index d74ad2c49..37519dbc5 100644
--- a/sdmx/tests/test_client.py
+++ b/sdmx/tests/test_client.py
@@ -172,8 +172,11 @@ def test_v3_unsupported(self, testsource, client):
             headers={"Content-Type": "application/vnd.sdmx.data+xml; version=3.0.0"},
         )
 
-        with mock, pytest.raises(
-            ValueError, match="can't determine a reader for response content type"
+        with (
+            mock,
+            pytest.raises(
+                ValueError, match="can't determine a reader for response content type"
+            ),
         ):
             client.get("data", resource_id=df_id, key=key)
 
diff --git a/sdmx/tests/test_rest.py b/sdmx/tests/test_rest.py
index d01707509..bf848947c 100644
--- a/sdmx/tests/test_rest.py
+++ b/sdmx/tests/test_rest.py
@@ -1,5 +1,5 @@
 import re
-from typing import TYPE_CHECKING, Any, Dict, Optional, Tuple, Union
+from typing import TYPE_CHECKING, Any, Optional, Union
 
 import pytest
 
@@ -77,9 +77,9 @@ def test_handle(self, p):
 _S = "?startPeriod=2024-02-12"
 
 R = Resource
-PARAMS: Tuple[
+PARAMS: tuple[
     Union[
-        Tuple[Resource, Dict[str, Any], Optional[str], Optional[str]],
+        tuple[Resource, dict[str, Any], Optional[str], Optional[str]],
         "_pytest.mark.ParameterSet",
     ],
     ...,
diff --git a/sdmx/tests/test_sources.py b/sdmx/tests/test_sources.py
index 81eccdb9f..ef8298465 100644
--- a/sdmx/tests/test_sources.py
+++ b/sdmx/tests/test_sources.py
@@ -7,7 +7,7 @@
 # TODO add a pytest argument for clearing this cache in conftest.py
 import logging
 from pathlib import Path
-from typing import Any, Dict, Tuple, Type, Union
+from typing import Any, Union
 
 import pytest
 import requests_mock
@@ -31,17 +31,17 @@ class DataSourceTest:
     source_id: str
 
     #: Failures affecting **all** data sources, internal to :mod:`sdmx`.
-    xfail_common: Dict[str, Any] = {}
+    xfail_common: dict[str, Any] = {}
 
     #: Mapping of endpoint → Exception subclass. Tests of these endpoints are expected
     #: to fail with the given kind of exception.
-    xfail: Dict[str, Union[Type[Exception], Tuple[Type[Exception], str]]] = {}
+    xfail: dict[str, Union[type[Exception], tuple[type[Exception], str]]] = {}
 
     #: True to xfail if a 503 Error is returned.
     tolerate_503 = False
 
     #: Keyword arguments for particular endpoints.
-    endpoint_args: Dict[str, Dict[str, Any]] = {}
+    endpoint_args: dict[str, dict[str, Any]] = {}
 
     @pytest.fixture
     def cache_path(self, test_data_path):
diff --git a/sdmx/tests/test_testing.py b/sdmx/tests/test_testing.py
index 78121322d..d8627a775 100644
--- a/sdmx/tests/test_testing.py
+++ b/sdmx/tests/test_testing.py
@@ -1,15 +1,8 @@
 import json
-import sys
-
-import pytest
 
 from sdmx.testing.report import main
 
 
-@pytest.mark.skipif(
-    sys.version_info.minor < 9,
-    reason="Uses dict() | other, not available in Python 3.8",
-)
 def test_report_main(tmp_path):
     # Example input data
     with open(tmp_path.joinpath("TEST.json"), "w") as f:
diff --git a/sdmx/urn.py b/sdmx/urn.py
index bb889a453..81db8e607 100644
--- a/sdmx/urn.py
+++ b/sdmx/urn.py
@@ -1,5 +1,5 @@
 import re
-from typing import Dict, Optional
+from typing import Optional
 
 from sdmx.model import PACKAGE, MaintainableArtefact
 
@@ -140,7 +140,7 @@ def make(
     )
 
 
-def match(value: str) -> Dict[str, str]:
+def match(value: str) -> dict[str, str]:
     """Match :data:`URN` in `value`, returning a :class:`dict` with the match groups.
 
     Example
diff --git a/sdmx/util/__init__.py b/sdmx/util/__init__.py
index 889715edb..eab0c84a5 100644
--- a/sdmx/util/__init__.py
+++ b/sdmx/util/__init__.py
@@ -2,7 +2,7 @@
 from collections.abc import Iterator
 from dataclasses import Field, fields
 from functools import lru_cache
-from typing import Any, Dict, Iterable, List, Tuple
+from typing import Any, Iterable
 
 import requests
 
@@ -57,7 +57,7 @@ def only(iterator: Iterator) -> Any:
 
 
 @lru_cache()
-def parse_content_type(value: str) -> Tuple[str, Dict[str, Any]]:
+def parse_content_type(value: str) -> tuple[str, dict[str, Any]]:
     """Return content type and parameters from `value`.
 
     Modified from :mod:`requests.util`.
@@ -85,7 +85,7 @@ def ucfirst(value: str) -> str:
     return value[0].upper() + value[1:]
 
 
-_FIELDS_CACHE: Dict[str, List[Field]] = dict()
+_FIELDS_CACHE: dict[str, list[Field]] = dict()
 
 
 def direct_fields(cls) -> Iterable[Field]:
diff --git a/sdmx/util/item_structure.py b/sdmx/util/item_structure.py
index bb5083d79..3eddd6fd4 100644
--- a/sdmx/util/item_structure.py
+++ b/sdmx/util/item_structure.py
@@ -3,7 +3,7 @@
 import logging
 import operator
 import re
-from typing import Callable, Dict, List, Optional, Tuple, Union
+from typing import Callable, Optional, Union
 
 from sdmx.model.common import DEFAULT_LOCALE, Item, ItemScheme
 
@@ -16,7 +16,7 @@
 
 def parse_item_description(
     item: Item, locale: Optional[str] = None
-) -> List[Tuple[Callable, str]]:
+) -> list[tuple[Callable, str]]:
     """Parse the :attr:`.description` of `item` for a structure expression.
 
     A common—but **non-standard**—SDMX usage is that :class:`Items <.Item>` in
@@ -119,7 +119,7 @@ def parse_item_description(
 
 def parse_item(
     itemscheme: ItemScheme, id=str, **kwargs
-) -> List[Tuple[Callable, Union[Item, str]]]:
+) -> list[tuple[Callable, Union[Item, str]]]:
     """Parse a structure expression for the item in `itemscheme` with the given `id`.
 
     In addition to the behaviour of :func:`parse_item_description`, :func:`parse_item`
@@ -175,7 +175,7 @@ def parse_item(
 
 def parse_all(
     itemscheme: ItemScheme, **kwargs
-) -> Dict[str, List[Tuple[Callable, Union[Item, str]]]]:
+) -> dict[str, list[tuple[Callable, Union[Item, str]]]]:
     """Parse structure expressions for every item in `itemscheme`.
 
     Parameters
diff --git a/sdmx/writer/csv.py b/sdmx/writer/csv.py
index ce27d4bc6..77b69386c 100644
--- a/sdmx/writer/csv.py
+++ b/sdmx/writer/csv.py
@@ -4,7 +4,7 @@
 """
 
 from os import PathLike
-from typing import Literal, Optional, Type, Union
+from typing import Literal, Optional, Union
 
 import pandas as pd
 
@@ -21,7 +21,7 @@ def to_csv(
     obj,
     *args,
     path: Optional[PathLike] = None,
-    rtype: Type[Union[str, pd.DataFrame]] = str,
+    rtype: type[Union[str, pd.DataFrame]] = str,
     **kwargs,
 ) -> Union[None, str, pd.DataFrame]:
     """Convert an SDMX *obj* to SDMX-CSV.
diff --git a/sdmx/writer/pandas.py b/sdmx/writer/pandas.py
index 80c57feb5..9f341b77a 100644
--- a/sdmx/writer/pandas.py
+++ b/sdmx/writer/pandas.py
@@ -1,5 +1,5 @@
 from itertools import chain
-from typing import Any, Dict, Hashable, Set, Union
+from typing import Any, Hashable, Union
 
 import numpy as np
 import pandas as pd
@@ -294,7 +294,7 @@ def write_dataset(  # noqa: C901 TODO reduce complexity 12 → ≤11
         raise ValueError(f"attributes must be in 'osgd'; got {attributes}")
 
     # Iterate on observations
-    data: Dict[Hashable, Dict[str, Any]] = {}
+    data: dict[Hashable, dict[str, Any]] = {}
     for observation in obj.obs:
         # Check that the Observation is within the constraint, if any
         key = observation.key.order()
@@ -501,7 +501,7 @@ def write_itemscheme(obj: model.ItemScheme, locale=DEFAULT_LOCALE):
     pandas.Series or pandas.DataFrame
     """
     items = {}
-    seen: Set[Item] = set()
+    seen: set[Item] = set()
 
     def add_item(item):
         """Recursive helper for adding items."""
diff --git a/sdmx/writer/xml.py b/sdmx/writer/xml.py
index 1dac8cc1e..54f2353ec 100644
--- a/sdmx/writer/xml.py
+++ b/sdmx/writer/xml.py
@@ -6,7 +6,7 @@
 # - writer functions for sdmx.model classes, in the same order as model.py
 
 import logging
-from typing import Iterable, List, Literal, MutableMapping, Optional
+from typing import Iterable, Literal, MutableMapping, Optional
 
 from lxml import etree
 from lxml.builder import ElementMaker
@@ -263,7 +263,7 @@ def _footer(obj: message.Footer):
 # §3.2: Base structures
 
 
-def i11lstring(obj, name) -> List[etree._Element]:
+def i11lstring(obj, name) -> list[etree._Element]:
     """InternationalString.
 
     Returns a list of elements with name `name`.