diff --git a/changes/75.misc.rst b/changes/75.misc.rst
new file mode 100644
index 0000000..23e1496
--- /dev/null
+++ b/changes/75.misc.rst
@@ -0,0 +1 @@
+Cache ``to_camel_case``, speeding up building and serialising DIDL objects
diff --git a/didl_lite/utils.py b/didl_lite/utils.py
index 40afba0..9771027 100644
--- a/didl_lite/utils.py
+++ b/didl_lite/utils.py
@@ -1,5 +1,6 @@
"""Utilities."""
+import functools
import re
NAMESPACES = {
@@ -42,6 +43,10 @@ def split_namespace_tag(namespaced_tag: str) -> tuple[str | None, str]:
return namespace, tag
+# Cached: called for every attribute set on a DidlObject, with a small closed
+# set of property names. Bounded, as from_xml also passes names read from
+# documents received from other devices.
+@functools.lru_cache(maxsize=256)
def to_camel_case(name: str) -> str:
"""Get camel case of name."""
sub1 = re.sub("(.)([A-Z][a-z]+)", r"\1_\2", name)
diff --git a/tests/test_didl_lite.py b/tests/test_didl_lite.py
index 8e9454d..743bced 100644
--- a/tests/test_didl_lite.py
+++ b/tests/test_didl_lite.py
@@ -3,7 +3,7 @@
import pytest
from defusedxml import ElementTree as ET
-from didl_lite import didl_lite
+from didl_lite import didl_lite, utils
NAMESPACES = {
"didl_lite": "urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/",
@@ -793,3 +793,13 @@ def test_music_track_artist_and_genre(self) -> None:
assert b"My Artist" in xml
assert b"My Genre" in xml
+
+ def test_to_camel_case_is_cached(self) -> None:
+ """Test to_camel_case is cached, and used cached through didl_lite."""
+ assert hasattr(utils.to_camel_case, "cache_info")
+ # didl_lite imports the name; it must be the cached function too.
+ assert didl_lite.to_camel_case is utils.to_camel_case
+
+ hits_before = utils.to_camel_case.cache_info().hits
+ didl_lite.MusicTrack(id="0", parent_id="0", title="Title", restricted="1")
+ assert utils.to_camel_case.cache_info().hits > hits_before