diff --git a/include/libcamera/meson.build b/include/libcamera/meson.build
index 30ea76f94..410b548dd 100644
--- a/include/libcamera/meson.build
+++ b/include/libcamera/meson.build
@@ -12,6 +12,8 @@ libcamera_public_headers = files([
     'framebuffer_allocator.h',
     'geometry.h',
     'logging.h',
+    'metadata_list.h',
+    'metadata_list_plan.h',
     'orientation.h',
     'pixel_format.h',
     'request.h',
diff --git a/include/libcamera/metadata_list.h b/include/libcamera/metadata_list.h
new file mode 100644
index 000000000..7514bd2ad
--- /dev/null
+++ b/include/libcamera/metadata_list.h
@@ -0,0 +1,619 @@
+/* SPDX-License-Identifier: LGPL-2.1-or-later */
+/*
+ * Copyright (C) 2025, Ideas On Board Oy
+ *
+ * Metadata list
+ */
+
+#pragma once
+
+#include <algorithm>
+#include <atomic>
+#include <cassert>
+#include <cstdint>
+#include <cstring>
+#include <new>
+#include <optional>
+#include <type_traits>
+
+#include <libcamera/base/details/align.h>
+#include <libcamera/base/details/cxx20.h>
+#include <libcamera/base/span.h>
+
+#include <libcamera/controls.h>
+#include <libcamera/metadata_list_plan.h>
+
+// TODO: want this?
+#if __has_include(<sanitizer/asan_interface.h>)
+#if __SANITIZE_ADDRESS__ /* gcc */
+#include <sanitizer/asan_interface.h>
+#define HAS_ASAN 1
+#elif defined(__has_feature)
+#if __has_feature(address_sanitizer) /* clang */
+#include <sanitizer/asan_interface.h>
+#define HAS_ASAN 1
+#endif
+#endif
+#endif
+
+namespace libcamera {
+
+class MetadataList
+{
+private:
+	struct ValueParams {
+		ControlType type;
+		bool isArray;
+		std::uint32_t numElements;
+	};
+
+	struct Entry {
+		const std::uint32_t tag;
+		const std::uint32_t capacity;
+		const std::uint32_t alignment;
+		const ControlType type;
+		bool isArray;
+
+		static constexpr std::uint32_t invalidOffset = -1;
+		/*
+		* Offset from the beginning of the allocation, and
+		* and _not_ relative to `contentOffset_`.
+		*/
+		std::atomic_uint32_t headerOffset = invalidOffset;
+
+		[[nodiscard]] std::optional<std::uint32_t> hasValue() const
+		{
+			auto offset = headerOffset.load(std::memory_order_relaxed);
+			if (offset == invalidOffset)
+				return {};
+
+			return offset;
+		}
+
+		[[nodiscard]] std::optional<std::uint32_t> acquireData() const
+		{
+			auto offset = hasValue();
+			if (offset) {
+				/* sync with release-store on `headerOffset` in `MetadataList::set()` */
+				std::atomic_thread_fence(std::memory_order_acquire);
+			}
+
+			return offset;
+		}
+	};
+
+	struct ValueHeader {
+		std::uint32_t tag;
+		std::uint32_t size;
+		std::uint32_t alignment;
+		ValueParams params;
+	};
+
+	struct State {
+		std::uint32_t count;
+		std::uint32_t fill;
+	};
+
+public:
+	explicit MetadataList(const MetadataListPlan &plan)
+		: capacity_(plan.size()),
+		  contentOffset_(MetadataList::contentOffset(capacity_)),
+		  alloc_(contentOffset_)
+	{
+		for (const auto &[tag, e] : plan) {
+			assert(details::cxx20::has_single_bit(e.alignment));
+
+			alloc_ += sizeof(ValueHeader);
+			alloc_ += e.alignment - 1; // XXX: this is the maximum
+			alloc_ += e.size;
+			alloc_ += alignof(ValueHeader) - 1; // XXX: this is the maximum
+		}
+
+		p_ = static_cast<std::byte *>(::operator new(alloc_));
+
+		auto *entries = reinterpret_cast<Entry *>(p_ + entriesOffset());
+		auto it = plan.begin();
+
+		for (std::size_t i = 0; i < capacity_; i++, ++it) {
+			const auto &[tag, e] = *it;
+
+			new (&entries[i]) Entry{
+				.tag = tag,
+				.capacity = e.size,
+				.alignment = e.alignment,
+				.type = e.type,
+				.isArray = e.isArray,
+			};
+		}
+
+#if HAS_ASAN
+		::__sanitizer_annotate_contiguous_container(
+			p_ + contentOffset_, p_ + alloc_,
+			p_ + alloc_, p_ + contentOffset_
+		);
+#endif
+	}
+
+	MetadataList(const MetadataList &other)
+		: capacity_(other.capacity_),
+		  contentOffset_(other.contentOffset_),
+		  alloc_(other.alloc_),
+		  p_(static_cast<std::byte *>(::operator new(alloc_)))
+	{
+		auto *entries = reinterpret_cast<Entry *>(p_ + entriesOffset());
+		const auto otherEntries = other.entries();
+
+		for (std::size_t i = 0; i < capacity_; i++) {
+			auto *e = new (&entries[i]) Entry{
+				.tag = otherEntries[i].tag,
+				.capacity = otherEntries[i].capacity,
+				.alignment = otherEntries[i].alignment,
+				.type = otherEntries[i].type,
+				.isArray = otherEntries[i].isArray,
+			};
+
+			auto v = other.data_of(otherEntries[i]);
+			if (!v)
+				continue;
+
+			[[maybe_unused]] auto r = set(*e, v);
+			assert(r == SetError());
+		}
+
+#if HAS_ASAN
+		::__sanitizer_annotate_contiguous_container(
+			p_ + contentOffset_, p_ + alloc_,
+			p_ + alloc_, p_ + contentOffset_
+		);
+#endif
+	}
+
+	MetadataList(MetadataList &&) = delete;
+
+	MetadataList &operator=(const MetadataList &) = delete;
+	MetadataList &operator=(MetadataList &&) = delete;
+
+	~MetadataList()
+	{
+#if HAS_ASAN
+		/*
+		 * The documentation says the range apparently has to be
+		 * restored to its initial state before it is deallocated.
+		 */
+		::__sanitizer_annotate_contiguous_container(
+			p_ + contentOffset_, p_ + alloc_,
+			p_ + contentOffset_ + state_.load(std::memory_order_relaxed).fill, p_ + alloc_
+		);
+#endif
+
+		::operator delete(p_, alloc_);
+	}
+
+	// TODO: want these?
+	[[nodiscard]] std::size_t size() const { return state_.load(std::memory_order_relaxed).count; }
+	[[nodiscard]] bool empty() const { return size() == 0; }
+
+	enum class SetError {
+		UnknownTag = 1,
+		AlreadySet,
+		DataTooLarge,
+		TypeMismatch,
+	};
+
+	[[nodiscard]] SetError set(std::uint32_t tag, ControlValueView v)
+	{
+		auto *e = find(tag);
+		if (!e)
+			return SetError::UnknownTag;
+
+		return set(*e, v);
+	}
+
+	template<typename T>
+	/* TODO: [[nodiscard]] */ SetError set(const Control<T> &ctrl, const details::cxx20::type_identity_t<T> &value)
+	{
+		using TypeInfo = libcamera::details::control_type<T>;
+
+		if constexpr (TypeInfo::size > 0) {
+			static_assert(std::is_trivially_copyable_v<typename T::value_type>);
+
+			return set(ctrl.id(), {
+				TypeInfo::value,
+				true,
+				value.size(),
+				reinterpret_cast<const std::byte *>(value.data()),
+			});
+		} else {
+			static_assert(std::is_trivially_copyable_v<T>);
+
+			return set(ctrl.id(), {
+				TypeInfo::value,
+				false,
+				1,
+				reinterpret_cast<const std::byte *>(&value),
+			});
+		}
+	}
+
+	template<typename T>
+	[[nodiscard]] decltype(auto) get(const Control<T> &ctrl) const
+	{
+		ControlValueView v = get(ctrl.id());
+
+		return v ? std::optional(v.get<T>()) : std::nullopt;
+	}
+
+	// TODO: operator ControlListView() const ?
+	// TODO: explicit operator ControlList() const ?
+
+	[[nodiscard]] ControlValueView get(std::uint32_t tag) const
+	{
+		const auto *e = find(tag);
+		if (!e)
+			return {};
+
+		return data_of(*e);
+	}
+
+	void clear()
+	{
+		for (auto &e : entries())
+			e.headerOffset.store(Entry::invalidOffset, std::memory_order_relaxed);
+
+		[[maybe_unused]] auto s = state_.exchange({}, std::memory_order_relaxed);
+
+#if HAS_ASAN
+		::__sanitizer_annotate_contiguous_container(
+			p_ + contentOffset_, p_ + alloc_,
+			p_ + contentOffset_ + s.fill, p_ + contentOffset_
+		);
+#endif
+	}
+
+	class iterator
+	{
+	public:
+		using difference_type = std::ptrdiff_t;
+		using value_type = std::pair<std::uint32_t, ControlValueView>;
+		using pointer = void;
+		using reference = value_type;
+		using iterator_category = std::forward_iterator_tag;
+
+		iterator() = default;
+
+		iterator& operator++()
+		{
+			const auto &h = header();
+
+			p_ += sizeof(h);
+			p_ = details::align::up(p_, h.alignment);
+			p_ += h.size;
+			p_ = details::align::up(p_, alignof(decltype(h)));
+
+			return *this;
+		}
+
+		iterator operator++(int)
+		{
+			auto copy = *this;
+			++*this;
+			return copy;
+		}
+
+		[[nodiscard]] reference operator*() const
+		{
+			const auto &h = header();
+			const auto *data = details::align::up(p_ + sizeof(h), h.alignment);
+
+			return { h.tag, { h.params.type, h.params.isArray, h.params.numElements, data } };
+		}
+
+		[[nodiscard]] bool operator==(const iterator &other) const
+		{
+			return p_ == other.p_;
+		}
+
+		[[nodiscard]] bool operator!=(const iterator &other) const
+		{
+			return !(*this == other);
+		}
+
+	private:
+		iterator(const std::byte *p)
+			: p_(p)
+		{
+		}
+
+		[[nodiscard]] const ValueHeader &header() const
+		{
+			return *reinterpret_cast<const ValueHeader *>(p_);
+		}
+
+		friend MetadataList;
+
+		const std::byte *p_ = nullptr;
+	};
+
+	[[nodiscard]] iterator begin() const
+	{
+		return { p_ + contentOffset_ };
+	}
+
+	[[nodiscard]] iterator end() const
+	{
+		return { p_ + contentOffset_ + state_.load(std::memory_order_acquire).fill };
+	}
+
+	class Diff
+	{
+	public:
+		// TODO: want these?
+		[[nodiscard]] explicit operator bool() const { return !empty(); }
+		[[nodiscard]] bool empty() const { return start_ == stop_; }
+		[[nodiscard]] std::size_t size() const { return changed_; }
+		[[nodiscard]] const MetadataList &list() const { return *l_; }
+
+		[[nodiscard]] ControlValueView get(std::uint32_t tag) const
+		{
+			const auto *e = l_->find(tag);
+			if (!e)
+				return {};
+
+			auto o = e->acquireData();
+			if (!o)
+				return {};
+
+			if (!(start_ <= *o && *o < stop_))
+				return {};
+
+			return l_->data_of(*o);
+		}
+
+		template<typename T>
+		[[nodiscard]] decltype(auto) get(const Control<T> &ctrl) const
+		{
+			ControlValueView v = get(ctrl.id());
+
+			return v ? std::optional(v.get<T>()) : std::nullopt;
+		}
+
+		[[nodiscard]] iterator begin() const
+		{
+			return { l_->p_ + start_ };
+		}
+
+		[[nodiscard]] iterator end() const
+		{
+			return { l_->p_ + stop_ };
+		}
+
+	private:
+		Diff(const MetadataList &l, std::size_t changed, std::size_t oldFill, std::size_t newFill)
+			: l_(&l),
+			  changed_(changed),
+			  start_(l.contentOffset_ + oldFill),
+			  stop_(l.contentOffset_ + newFill)
+		{
+		}
+
+		friend MetadataList;
+		friend struct Checkpoint;
+
+		const MetadataList *l_ = nullptr;
+		std::size_t changed_;
+		std::size_t start_;
+		std::size_t stop_;
+	};
+
+	Diff merge(const MetadataList &other)
+	{
+		const auto entries = this->entries();
+		const auto otherEntries = other.entries();
+		const auto c = checkpoint();
+
+		for (std::size_t i = 0, j = 0; i < entries.size() && j < otherEntries.size(); ) {
+			if (entries[i].tag < otherEntries[j].tag) {
+				i += 1;
+				continue;
+			}
+
+			if (entries[i].tag > otherEntries[j].tag) {
+				j += 1;
+				continue;
+			}
+
+			assert(entries[i].alignment >= otherEntries[j].alignment);
+			assert(entries[i].capacity >= otherEntries[j].capacity);
+
+			if (!entries[i].hasValue()) {
+				auto v = other.data_of(otherEntries[j]);
+				if (v) {
+					[[maybe_unused]] auto r = set(entries[i], v);
+					assert(r == SetError());
+				}
+			}
+
+			i += 1;
+			j += 1;
+		}
+
+		return c.diffSince();
+	}
+
+	Diff merge(const ControlList &other)
+	{
+		// TODO: check id map of `other`?
+
+		const auto c = checkpoint();
+
+		for (auto &&[tag, value] : other) {
+			auto *e = find(tag);
+			if (e) {
+				[[maybe_unused]] auto r = set(*e, value);
+				assert(r == SetError() || r == SetError::AlreadySet); // TODO: ?
+			}
+		}
+
+		return c.diffSince();
+	}
+
+	class Checkpoint
+	{
+	public:
+		// TODO: want this?
+		[[nodiscard]] const MetadataList &list() const { return *l_; }
+
+		[[nodiscard]] Diff diffSince() const
+		{
+			/* sync with release-store on `state_` in `set()` */
+			const auto curr = l_->state_.load(std::memory_order_acquire);
+
+			assert(s_.count <= curr.count);
+			assert(s_.fill <= curr.fill);
+
+			return {
+				*l_,
+				curr.count - s_.count,
+				s_.fill,
+				curr.fill,
+			};
+		}
+
+	private:
+		Checkpoint(const MetadataList &l)
+			: l_(&l),
+			  s_(l.state_.load(std::memory_order_relaxed))
+		{
+		}
+
+		friend MetadataList;
+
+		const MetadataList *l_ = nullptr;
+		State s_ = {};
+	};
+
+	[[nodiscard]] Checkpoint checkpoint() const
+	{
+		return { *this };
+	}
+
+private:
+	[[nodiscard]] static constexpr std::size_t entriesOffset()
+	{
+		return 0;
+	}
+
+	[[nodiscard]] static constexpr std::size_t contentOffset(std::size_t entries)
+	{
+		return details::align::up(entriesOffset() + entries * sizeof(Entry), alignof(ValueHeader));
+	}
+
+	[[nodiscard]] Span<Entry> entries() const
+	{
+		return { reinterpret_cast<Entry *>(p_ + entriesOffset()), capacity_ };
+	}
+
+	[[nodiscard]] Entry *find(std::uint32_t tag) const
+	{
+		const auto entries = this->entries();
+		auto it = std::lower_bound(entries.begin(), entries.end(), tag, [](const auto &e, const auto &t) {
+			return e.tag < t;
+		});
+
+		if (it == entries.end() || it->tag != tag)
+			return nullptr;
+
+		return &*it;
+	}
+
+	[[nodiscard]] ControlValueView data_of(const Entry &e) const
+	{
+		const auto o = e.acquireData();
+		return o ? data_of(*o) : ControlValueView{ };
+	}
+
+	[[nodiscard]] ControlValueView data_of(std::size_t headerOffset) const
+	{
+		assert(headerOffset <= alloc_ - sizeof(ValueHeader));
+		assert(details::align::is(p_ + headerOffset, alignof(ValueHeader)));
+
+		const auto *vh = reinterpret_cast<const ValueHeader *>(p_ + headerOffset);
+		const auto *p = reinterpret_cast<const std::byte *>(vh) + sizeof(*vh);
+		std::size_t avail = p_ + alloc_ - p;
+
+		const auto *data = details::align::up(vh->size, vh->alignment, p, &avail);
+		assert(data);
+
+		return { vh->params.type, vh->params.isArray, vh->params.numElements, data };
+	}
+
+	[[nodiscard]] SetError set(Entry &e, ControlValueView v)
+	{
+		if (e.hasValue())
+			return SetError::AlreadySet;
+		if (e.type != v.type() || e.isArray != v.isArray())
+			return SetError::TypeMismatch;
+
+		const auto src = v.data();
+		if (e.isArray) {
+			if (src.size_bytes() > e.capacity)
+				return SetError::DataTooLarge;
+		} else {
+			assert(src.size_bytes() == e.capacity);
+		}
+
+		auto s = state_.load(std::memory_order_relaxed);
+		std::byte *oldEnd = p_ + contentOffset_ + s.fill;
+		std::byte *p = oldEnd;
+
+		auto *headerPtr = details::align::up<ValueHeader>(p);
+		auto *dataPtr = details::align::up(src.size_bytes(), e.alignment, p);
+		details::align::up(0, alignof(ValueHeader), p);
+
+#if HAS_ASAN
+		::__sanitizer_annotate_contiguous_container(
+			p_ + contentOffset_, p_ + alloc_,
+			oldEnd, p
+		);
+#endif
+
+		new (headerPtr) ValueHeader{
+			.tag = e.tag,
+			.size = std::uint32_t(src.size_bytes()),
+			.alignment = e.alignment,
+			.params = {
+				.type = v.type(),
+				.isArray = v.isArray(),
+				.numElements = std::uint32_t(v.numElements()),
+			},
+		};
+		std::memcpy(dataPtr, src.data(), src.size_bytes());
+		e.headerOffset.store(reinterpret_cast<std::byte *>(headerPtr) - p_, std::memory_order_release);
+
+		s.fill += p - oldEnd;
+		s.count += 1;
+
+		state_.store(s, std::memory_order_release);
+
+		return {};
+	}
+
+	std::size_t capacity_ = 0;
+	std::size_t contentOffset_ = -1;
+	std::size_t alloc_ = 0;
+	std::atomic<State> state_ = State{};
+	std::byte *p_ = nullptr;
+	// TODO: ControlIdMap in any way shape or form?
+
+	/*
+	 * If this is problematic on a 32-bit architecture, then
+	 * `count` can be stored in a separate atomic variable
+	 * but then `Diff::changed_` must be removed since the fill
+	 * level and item count cannot be retrieved atomically.
+	 */
+	static_assert(decltype(state_)::is_always_lock_free);
+};
+
+} /* namespace libcamera */
+
+#undef HAS_ASAN
diff --git a/include/libcamera/metadata_list_plan.h b/include/libcamera/metadata_list_plan.h
new file mode 100644
index 000000000..9ad4ae87e
--- /dev/null
+++ b/include/libcamera/metadata_list_plan.h
@@ -0,0 +1,109 @@
+/* SPDX-License-Identifier: LGPL-2.1-or-later */
+/*
+ * Copyright (C) 2025, Ideas On Board Oy
+ */
+
+#pragma once
+
+#include <cassert>
+#include <cstddef>
+#include <cstdint>
+#include <limits>
+#include <map>
+#include <type_traits>
+
+#include <libcamera/base/details/cxx20.h>
+
+#include <libcamera/controls.h>
+
+namespace libcamera {
+
+class MetadataListPlan
+{
+public:
+	[[nodiscard]] bool empty() const { return items_.empty(); }
+	[[nodiscard]] std::size_t size() const { return items_.size(); }
+	[[nodiscard]] decltype(auto) begin() const { return items_.begin(); }
+	[[nodiscard]] decltype(auto) end() const { return items_.end(); }
+	void clear() { items_.clear(); }
+
+	template<
+		typename T,
+		std::enable_if_t<libcamera::details::control_type<T>::size != libcamera::dynamic_extent> * = nullptr
+	>
+	decltype(auto) add(const Control<T> &ctrl)
+	{
+		if constexpr (libcamera::details::control_type<T>::size > 0) {
+			static_assert(libcamera::details::control_type<T>::size != libcamera::dynamic_extent);
+
+			return add<typename T::value_type>(
+				ctrl.id(),
+				libcamera::details::control_type<T>::size,
+				true
+			);
+		} else {
+			return add<T>(ctrl.id(), 1, false);
+		}
+	}
+
+	template<
+		typename T,
+		std::enable_if_t<libcamera::details::control_type<T>::size == libcamera::dynamic_extent> * = nullptr
+	>
+	decltype(auto) add(const Control<T> &ctrl, std::size_t count)
+	{
+		return add<typename T::value_type>(ctrl.id(), count, true);
+	}
+
+#ifndef __DOXYGEN__
+	MetadataListPlan &add(std::uint32_t tag,
+			      std::size_t size, std::size_t count, std::size_t alignment,
+			      ControlType type, bool isArray)
+	{
+		assert(size > 0 && size <= std::numeric_limits<std::uint32_t>::max());
+		assert(count <= std::numeric_limits<std::uint32_t>::max() / size);
+		assert(alignment <= std::numeric_limits<std::uint32_t>::max());
+		assert(details::cxx20::has_single_bit(alignment));
+		assert(isArray || count == 1);
+
+		items_.insert_or_assign(tag, Entry{
+			.size = std::uint32_t(size * count),
+			.alignment = std::uint32_t(alignment),
+			.type = type,
+			.isArray = isArray,
+		});
+
+		return *this;
+	}
+#endif
+
+	bool remove(std::uint32_t tag)
+	{
+		return items_.erase(tag);
+	}
+
+	bool remove(const ControlId &ctrl)
+	{
+		return remove(ctrl.id());
+	}
+
+private:
+	struct Entry {
+		std::uint32_t size;
+		std::uint32_t alignment; // TODO: is this necessary?
+		ControlType type;
+		bool isArray;
+	};
+
+	std::map<std::uint32_t, Entry> items_;
+
+	template<typename T>
+	decltype(auto) add(std::uint32_t tag, std::size_t count, bool isArray)
+	{
+		static_assert(std::is_trivially_copyable_v<T>);
+
+		return add(tag, sizeof(T), count, alignof(T), details::control_type<T>::value, isArray);
+	}
+};
+
+} /* namespace libcamera */
diff --git a/src/libcamera/meson.build b/src/libcamera/meson.build
index 202db1efe..d7a850907 100644
--- a/src/libcamera/meson.build
+++ b/src/libcamera/meson.build
@@ -9,6 +9,7 @@ libcamera_public_sources = files([
     'framebuffer.cpp',
     'framebuffer_allocator.cpp',
     'geometry.cpp',
+    'metadata_list.cpp',
     'orientation.cpp',
     'pixel_format.cpp',
     'request.cpp',
diff --git a/src/libcamera/metadata_list.cpp b/src/libcamera/metadata_list.cpp
new file mode 100644
index 000000000..ccfe37318
--- /dev/null
+++ b/src/libcamera/metadata_list.cpp
@@ -0,0 +1,315 @@
+/* SPDX-License-Identifier: LGPL-2.1-or-later */
+/*
+ * Copyright (C) 2025, Ideas On Board Oy
+ */
+
+#include <libcamera/metadata_list.h>
+
+namespace libcamera {
+
+/**
+ * \class MetadataListPlan
+ * \brief Class to hold the possible set of metadata items for a \ref MetadataList
+ */
+
+/**
+ * \fn MetadataListPlan::begin() const
+ * \brief Retrieve the begin iterator
+ */
+
+/**
+ * \fn MetadataListPlan::end() const
+ * \brief Retrieve the end iterator
+ */
+
+/**
+ * \fn MetadataListPlan::size() const
+ * \brief Retrieve the number of controls
+ */
+
+/**
+ * \fn MetadataListPlan::empty() const
+ * \brief Check if empty
+ */
+
+/**
+ * \fn MetadataListPlan::clear()
+ * \brief Remove all controls
+ */
+
+/**
+ * \fn MetadataListPlan::add(const Control<T> &ctrl)
+ * \brief Add a control to the metadata list plan
+ */
+
+/**
+ * \fn MetadataListPlan::add(const Control<T> &ctrl, std::size_t count)
+ * \brief Add a dynamically-sized control to the metadata list plan
+ * \param[in] ctrl The control
+ * \param[in] count The maximum number of elements
+ *
+ * Add the dynamically-sized control \a ctrl to the metadata list plan with a maximum
+ * capacity of \a count elements.
+ */
+
+/**
+ * \fn MetadataListPlan::remove(std::uint32_t tag)
+ * \brief Remove the entry with given identifier from the plan
+ */
+
+/**
+ * \fn MetadataListPlan::remove(const ControlId &ctrl)
+ * \brief Remove \a ctrl from the plan
+ */
+
+/**
+ * \class MetadataList
+ * \brief Class to hold metadata items
+ */
+
+/**
+ * \fn MetadataList::MetadataList(const MetadataListPlan &plan)
+ * \brief Construct a metadata list according to \a plan
+ *
+ * Construct a metadata list according to the provided \a plan.
+ */
+
+/**
+ * \fn MetadataList::MetadataList(const MetadataList &other)
+ * \brief Copy constructor
+ * \context This function is \threadsafe wrt. \a other.
+ */
+
+/**
+ * \fn MetadataList::size() const
+ * \brief Retrieve the number of controls
+ * \context This function is \threadsafe.
+ * \note If the list is being modified, the return value may be out of
+ *       date by the time the function returns
+ */
+
+/**
+ * \fn MetadataList::empty() const
+ * \brief Check if empty
+ * \context This function is \threadsafe.
+ * \note If the list is being modified, the return value may be out of
+ *       date by the time the function returns
+ */
+
+/**
+ * \internal
+ * \fn MetadataList::clear()
+ * \brief Remove all items from the list
+ * \note This function in effect resets the list to its original state. As a consequence it invalidates - among others -
+ *       all iterators, Checkpoint, and Diff objects that are associated with the list. No readers must exist
+ *       when this function is called.
+ */
+
+/**
+ * \fn MetadataList::begin() const
+ * \brief Retrieve begin iterator
+ * \context This function is \threadsafe.
+ */
+
+/**
+ * \fn MetadataList::end() const
+ * \brief Retrieve end iterator
+ * \context This function is \threadsafe.
+ */
+
+/**
+ * \fn MetadataList::get(const Control<T> &ctrl) const
+ * \brief Get the value of control \a ctrl
+ * \return A std::optional<T> containing the control value, or std::nullopt if
+ *         the control \a ctrl is not present in the list
+ * \context This function is \threadsafe.
+ */
+
+/**
+ * \fn MetadataList::get(std::uint32_t tag) const
+ * \brief Get the value of pertaining to the numeric identifier \a tag
+ * \return A std::optional<T> containing the control value, or std::nullopt if
+ *         the control is not present in the list
+ * \context This function is \threadsafe.
+ */
+
+/**
+ * \fn MetadataList::set(const Control<T> &ctrl, const details::cxx20::type_identity_t<T> &value)
+ * \brief Set the value of control \a ctrl to \a value
+ */
+
+/**
+ * \fn MetadataList::set(std::uint32_t tag, ControlValueView v)
+ * \brief Set the value of pertaining to the numeric identifier \a tag to \a v
+ */
+
+/**
+ * \internal
+ * \fn MetadataList::merge(const MetadataList &other)
+ * \brief Add all missing items from \a other
+ *
+ * Add all items from \a other that are not present in \a this. If an item
+ * has a numeric identifier that was not present in the MetadataListPlan
+ * used to construct \a this, then the item is ignored.
+ *
+ * \context This function is \threadsafe wrt. \a other.
+ */
+
+/**
+ * \internal
+ * \fn MetadataList::merge(const ControlList &other)
+ * \copydoc MetadataList::merge(const MetadataList &other)
+ */
+
+/**
+ * \enum MetadataList::SetError
+ * \brief TODO
+ *
+ * \var MetadataList::SetError::UnknownTag
+ * \brief The tag is not supported by the metadata list
+ * \var MetadataList::SetError::AlreadySet
+ * \brief A value has already been added with the given tag
+ * \var MetadataList::SetError::DataTooLarge
+ * \brief The data is too large for the given tag
+ * \var MetadataList::SetError::TypeMismatch
+ * \brief The type of the value does not match the expected type
+ */
+
+/**
+ * \internal
+ * \fn MetadataList::checkpoint() const
+ * \brief Create a checkpoint
+ * \context This function is \threadsafe.
+ */
+
+/**
+ * \class MetadataList::iterator
+ * \brief Iterator
+ */
+
+/**
+ * \typedef MetadataList::iterator::difference_type
+ * \brief iterator's difference type
+ */
+
+/**
+ * \typedef MetadataList::iterator::value_type
+ * \brief iterator's value type
+ */
+
+/**
+ * \typedef MetadataList::iterator::pointer
+ * \brief iterator's pointer type
+ */
+
+/**
+ * \typedef MetadataList::iterator::reference
+ * \brief iterator's reference type
+ */
+
+/**
+ * \typedef MetadataList::iterator::iterator_category
+ * \brief iterator's category
+ */
+
+/**
+ * \fn MetadataList::iterator::operator*()
+ * \brief Retrieve value at iterator
+ * \return A \a ControlListView representing the value
+ */
+
+/**
+ * \fn MetadataList::iterator::operator==(const iterator &other) const
+ * \brief Check if two iterators are equal
+ */
+
+/**
+ * \fn MetadataList::iterator::operator!=(const iterator &other) const
+ * \brief Check if two iterators are not equal
+ */
+
+/**
+ * \fn MetadataList::iterator::operator++(int)
+ * \brief Advance the iterator
+ */
+
+/**
+ * \fn MetadataList::iterator::operator++()
+ * \brief Advance the iterator
+ */
+
+/**
+ * \class MetadataList::Diff
+ * \brief Designates a set of consecutively added metadata items from a particular MetadataList
+ * \sa Camera::metadataAvailable
+ * \internal
+ * \sa MetadataList::Checkpoint::diffSince()
+ * \endinternal
+ */
+
+/**
+ * \fn MetadataList::Diff::list() const
+ * \brief Retrieve the associated MetadataList
+ */
+
+/**
+ * \fn MetadataList::Diff::size() const
+ * \brief Retrieve the number of metadata items designated
+ */
+
+/**
+ * \fn MetadataList::Diff::empty() const
+ * \brief Check if no metadata items are designated
+ */
+
+/**
+ * \fn MetadataList::Diff::operator bool() const
+ * \copydoc MetadataList::Diff::empty() const
+ */
+
+/**
+ * \fn MetadataList::Diff::get(const Control<T> &ctrl) const
+ * \copydoc MetadataList::get(const Control<T> &ctrl) const
+ * \note The value pertaining to \a ctrl will only be returned if it is part of Diff,
+ *       meaning that even if \a ctrl is part of the backing MetadataList, it will not
+ *       be returned if \a ctrl is not in the set of controls designated by this Diff object.
+ */
+
+/**
+ * \fn MetadataList::Diff::get(std::uint32_t tag) const
+ * \copydoc MetadataList::Diff::get(const Control<T>&ctrl) const
+ */
+
+/**
+ * \fn MetadataList::Diff::begin() const
+ * \brief Retrieve the begin iterator
+ */
+
+/**
+ * \fn MetadataList::Diff::end() const
+ * \brief Retrieve the end iterator
+ */
+
+/**
+ * \internal
+ * \class MetadataList::Checkpoint
+ * \brief Designates a point in the stream of metadata items
+ *
+ * A Checkpoint object designates a point in the stream of metadata items in the associated
+ * MetadataList. Its main use to be able to retrieve the set of metadata items that were
+ * added to the list after the designated point using diffSince().
+ */
+
+/**
+ * \internal
+ * \fn MetadataList::Checkpoint::list() const
+ * \brief Retrieve the associated \ref MetadataList
+ */
+
+/**
+ * \internal
+ * \fn MetadataList::Checkpoint::diffSince() const
+ * \brief Retrieve the set of metadata items added since the checkpoint was created
+ */
+
+} /* namespace libcamera */
diff --git a/test/controls/meson.build b/test/controls/meson.build
index 763f8905e..b68a4fc53 100644
--- a/test/controls/meson.build
+++ b/test/controls/meson.build
@@ -5,6 +5,7 @@ control_tests = [
     {'name': 'control_info_map', 'sources': ['control_info_map.cpp']},
     {'name': 'control_list', 'sources': ['control_list.cpp']},
     {'name': 'control_value', 'sources': ['control_value.cpp']},
+    {'name': 'metadata_list', 'sources': ['metadata_list.cpp']},
 ]
 
 foreach test : control_tests
diff --git a/test/controls/metadata_list.cpp b/test/controls/metadata_list.cpp
new file mode 100644
index 000000000..e02b4e28e
--- /dev/null
+++ b/test/controls/metadata_list.cpp
@@ -0,0 +1,171 @@
+/* SPDX-License-Identifier: GPL-2.0-or-later */
+/*
+ * Copyright (C) 2025, Ideas On Board Oy
+ *
+ * MetadataList tests
+ */
+
+#include <future>
+#include <iostream>
+#include <thread>
+
+#include <libcamera/control_ids.h>
+#include <libcamera/metadata_list.h>
+#include <libcamera/property_ids.h>
+
+#include "test.h"
+
+using namespace std;
+using namespace libcamera;
+
+#define ASSERT(x) do { \
+	if (!static_cast<bool>(x)) { \
+		std::cerr << '`' << #x << "` failed" << std::endl; \
+		return TestFail; \
+	} \
+} while (false)
+
+class MetadataListTest : public Test
+{
+public:
+	MetadataListTest() = default;
+
+protected:
+	int run() override
+	{
+		MetadataListPlan mlp;
+		mlp.add(controls::ExposureTime);
+		mlp.add(controls::ExposureValue);
+		mlp.add(controls::ColourGains);
+		mlp.add(controls::AfWindows, 10);
+		mlp.add(controls::AeEnable);
+		mlp.add(controls::SensorTimestamp);
+
+		MetadataList ml(mlp);
+
+		static_assert(static_cast<unsigned int>(properties::LOCATION) == controls::AE_ENABLE);
+		ASSERT(ml.set(properties::Location, properties::CameraLocationFront) == MetadataList::SetError::TypeMismatch);
+
+		ASSERT(ml.set(controls::AfWindows, std::array<Rectangle, 11>{}) == MetadataList::SetError::DataTooLarge);
+		ASSERT(ml.set(controls::ColourTemperature, 123) == MetadataList::SetError::UnknownTag);
+
+		auto f1 = std::async(std::launch::async, [&] {
+			using namespace std::chrono_literals;
+
+			std::this_thread::sleep_for(500ms);
+			ASSERT(ml.set(controls::ExposureTime, 0x1111) == MetadataList::SetError());
+
+			std::this_thread::sleep_for(500ms);
+			ASSERT(ml.set(controls::ExposureValue, 1) == MetadataList::SetError());
+
+			std::this_thread::sleep_for(500ms);
+			ASSERT(ml.set(controls::ColourGains, std::array{
+				123.f,
+				456.f
+			}) == MetadataList::SetError());
+
+			std::this_thread::sleep_for(500ms);
+			ASSERT(ml.set(controls::AfWindows, std::array{
+				Rectangle(),
+				Rectangle(1, 2, 3, 4),
+				Rectangle(0x1111, 0x2222, 0x3333, 0x4444),
+			}) == MetadataList::SetError());
+
+			return TestPass;
+		});
+
+		auto f2 = std::async(std::launch::async, [&] {
+			for (;;) {
+				const auto x = ml.get(controls::ExposureTime);
+				const auto y = ml.get(controls::ExposureValue);
+				const auto z = ml.get(controls::ColourGains);
+				const auto w = ml.get(controls::AfWindows);
+
+				if (x)
+					ASSERT(*x == 0x1111);
+
+				if (y)
+					ASSERT(*y == 1.0f);
+
+				if (z) {
+					ASSERT(z->size() == 2);
+					ASSERT((*z)[0] == 123.f);
+					ASSERT((*z)[1] == 456.f);
+				}
+
+				if (w) {
+					ASSERT(w->size() == 3);
+					ASSERT((*w)[0].isNull());
+					ASSERT((*w)[1] == Rectangle(1, 2, 3, 4));
+					ASSERT((*w)[2] == Rectangle(0x1111, 0x2222, 0x3333, 0x4444));
+				}
+
+				if (x && y && z && w)
+					break;
+			}
+
+			return TestPass;
+		});
+
+		ASSERT(f1.get() == TestPass);
+		ASSERT(f2.get() == TestPass);
+
+		ASSERT(ml.set(controls::ExposureTime, 0x2222) == MetadataList::SetError::AlreadySet);
+		ASSERT(ml.set(controls::ExposureValue, 2) == MetadataList::SetError::AlreadySet);
+
+		ASSERT(ml.get(controls::ExposureTime) == 0x1111);
+		ASSERT(ml.get(controls::ExposureValue) == 1);
+
+		for (auto &&[tag, v] : ml)
+			std::cout << "[" << tag << "] -> " << v << '\n';
+
+		std::cout << std::endl;
+
+		ml.clear();
+		ASSERT(ml.empty());
+		ASSERT(ml.size() == 0);
+
+		ASSERT(ml.set(controls::ExposureTime, 0x2222) == MetadataList::SetError());
+		ASSERT(ml.get(controls::ExposureTime) == 0x2222);
+
+		auto c = ml.checkpoint();
+		ASSERT(&c.list() == &ml);
+
+		ASSERT(ml.set(controls::ExposureValue, 2) == MetadataList::SetError());
+		ASSERT(ml.set(controls::SensorTimestamp, 0x99999999) == MetadataList::SetError());
+
+		auto d = c.diffSince();
+		ASSERT(&d.list() == &ml);
+
+		ASSERT(ml.set(controls::ColourGains, std::array{ 1.f, 2.f }) == MetadataList::SetError());
+
+		ASSERT(d);
+		ASSERT(!d.empty());
+		ASSERT(d.size() == 2);
+		ASSERT(!d.get(controls::ExposureTime));
+		ASSERT(!d.get(controls::ColourGains));
+		ASSERT(!d.get(controls::AfWindows));
+		ASSERT(d.get(controls::ExposureValue) == 2);
+		ASSERT(d.get(controls::SensorTimestamp) == 0x99999999);
+
+		for (auto &&[tag, v] : d)
+			std::cout << "[" << tag << "] -> " << v << '\n';
+
+		/* Test if iterators work with algorithms. */
+		std::ignore = std::find_if(d.begin(), d.end(), [](const auto &) {
+			return false;
+		});
+
+#if 0
+               {
+                       auto it = ml.begin();
+                       ml.clear();
+                       std::ignore = *it; /* Trigger ASAN. */
+               }
+#endif
+
+		return TestPass;
+	}
+};
+
+TEST_REGISTER(MetadataListTest)
