diff --git a/include/libcamera/camera_descriptor.h b/include/libcamera/camera_descriptor.h
new file mode 100644
index 000000000000..8da4b316fd42
--- /dev/null
+++ b/include/libcamera/camera_descriptor.h
@@ -0,0 +1,34 @@
+/* SPDX-License-Identifier: LGPL-2.1-or-later */
+/*
+ * Copyright (C) 2026, Raspberry Pi Ltd
+ *
+ */
+
+#pragma once
+
+#include <memory>
+#include <string>
+
+#include <libcamera/base/class.h>
+
+#include <libcamera/controls.h>
+
+namespace libcamera {
+
+class CameraDescriptor final : public Extensible
+{
+	LIBCAMERA_DECLARE_PRIVATE()
+
+public:
+	static std::shared_ptr<CameraDescriptor> create(std::unique_ptr<Private> d);
+
+	const std::string &id() const;
+	const ControlList &properties() const;
+
+private:
+	LIBCAMERA_DISABLE_COPY(CameraDescriptor)
+
+	CameraDescriptor(std::unique_ptr<Private> d);
+};
+
+} /* namespace libcamera */
diff --git a/include/libcamera/internal/camera_descriptor.h b/include/libcamera/internal/camera_descriptor.h
new file mode 100644
index 000000000000..7c1bf52aec12
--- /dev/null
+++ b/include/libcamera/internal/camera_descriptor.h
@@ -0,0 +1,37 @@
+/* SPDX-License-Identifier: LGPL-2.1-or-later */
+/*
+ * Copyright (C) 2026, Raspberry Pi Ltd
+ *
+ */
+
+#pragma once
+
+#include <memory>
+#include <string>
+#include <vector>
+
+#include <libcamera/base/class.h>
+
+#include <libcamera/camera_descriptor.h>
+#include <libcamera/controls.h>
+
+namespace libcamera {
+
+class MediaDevice;
+class PipelineHandlerFactoryBase;
+
+class CameraDescriptor::Private : public Extensible::Private
+{
+	LIBCAMERA_DECLARE_PUBLIC(CameraDescriptor)
+
+public:
+	Private();
+
+	const PipelineHandlerFactoryBase *factory_;
+	std::vector<std::shared_ptr<MediaDevice>> mediaDevices_;
+	std::string entityName_;
+	std::string id_;
+	ControlList properties_;
+};
+
+} /* namespace libcamera */
diff --git a/include/libcamera/internal/meson.build b/include/libcamera/internal/meson.build
index fd375134a5c4..b5a144e9e539 100644
--- a/include/libcamera/internal/meson.build
+++ b/include/libcamera/internal/meson.build
@@ -7,6 +7,7 @@ libcamera_internal_headers = files([
     'byte_stream_buffer.h',
     'camera.h',
     'camera_controls.h',
+    'camera_descriptor.h',
     'camera_lens.h',
     'camera_manager.h',
     'camera_sensor.h',
diff --git a/include/libcamera/meson.build b/include/libcamera/meson.build
index 30ea76f9470a..91c76a10781a 100644
--- a/include/libcamera/meson.build
+++ b/include/libcamera/meson.build
@@ -4,6 +4,7 @@ libcamera_include_dir = 'libcamera' / 'libcamera'
 
 libcamera_public_headers = files([
     'camera.h',
+    'camera_descriptor.h',
     'camera_manager.h',
     'color_space.h',
     'controls.h',
diff --git a/src/libcamera/camera_descriptor.cpp b/src/libcamera/camera_descriptor.cpp
new file mode 100644
index 000000000000..b63f4c5fc330
--- /dev/null
+++ b/src/libcamera/camera_descriptor.cpp
@@ -0,0 +1,141 @@
+/* SPDX-License-Identifier: LGPL-2.1-or-later */
+/*
+ * Copyright (C) 2026, Raspberry Pi Ltd
+ *
+ */
+
+#include "libcamera/internal/camera_descriptor.h"
+
+#include <memory>
+
+#include <libcamera/camera_descriptor.h>
+#include <libcamera/property_ids.h>
+
+/**
+ * \file libcamera/camera_descriptor.h
+ * \brief Describing cameras ahead of initialisation
+ */
+
+namespace libcamera {
+
+/**
+ * \class CameraDescriptor
+ * \brief Describe a camera known to the system but not yet initialised
+ *
+ * A CameraDescriptor represents a camera discovered during device enumeration,
+ * before any camera initialisation has taken place. Descriptors are produced by
+ * CameraManager::enumerate() without acquiring any device, and carry the
+ * information about a camera that is available at enumeration time.
+ *
+ * The descriptor holds the camera identifier, guaranteed to be identical to
+ * the Camera::id() of the corresponding Camera instance, and a list of
+ * properties known at enumeration time. A descriptor can be passed to
+ * CameraManager::initialize() to create the corresponding fully initialised
+ * Camera.
+ */
+
+#ifndef __DOXYGEN_PUBLIC__
+/**
+ * \class CameraDescriptor::Private
+ * \brief Base class for camera descriptor private data
+ */
+
+/**
+ * \brief Construct a CameraDescriptor::Private instance
+ */
+CameraDescriptor::Private::Private()
+	: factory_(nullptr), properties_(properties::properties)
+{
+}
+
+/**
+ * \var CameraDescriptor::Private::factory_
+ * \brief The factory of the pipeline handler that produced this descriptor
+ *
+ * This is set by the camera manager when it collects the descriptors reported
+ * by a pipeline handler, and is used to create a pipeline handler for the
+ * camera when the descriptor is initialised.
+ */
+
+/**
+ * \var CameraDescriptor::Private::mediaDevices_
+ * \brief The media devices the camera is part of
+ *
+ * The media devices needed to initialise the camera. The first entry is the
+ * media device that identifies the camera's pipeline instance, and is used to
+ * route the camera to a live pipeline handler holding it.
+ */
+
+/**
+ * \var CameraDescriptor::Private::entityName_
+ * \brief The name of the camera's main media entity
+ *
+ * The entity that identifies the camera within its media device, for instance
+ * the camera sensor for a CSI receiver or the default video node for a USB
+ * camera.
+ */
+
+/**
+ * \var CameraDescriptor::Private::id_
+ * \brief The camera identifier
+ * \sa CameraDescriptor::id()
+ */
+
+/**
+ * \var CameraDescriptor::Private::properties_
+ * \brief The properties of the camera known at enumeration time
+ * \sa CameraDescriptor::properties()
+ */
+#endif /* __DOXYGEN_PUBLIC__ */
+
+/**
+ * \brief Create a camera descriptor instance
+ * \param[in] d Camera descriptor private data
+ *
+ * The caller is responsible for populating the private data before creating
+ * the descriptor.
+ *
+ * \return A shared pointer to the newly created camera descriptor object
+ */
+std::shared_ptr<CameraDescriptor>
+CameraDescriptor::create(std::unique_ptr<Private> d)
+{
+	return std::shared_ptr<CameraDescriptor>(new CameraDescriptor(std::move(d)));
+}
+
+/**
+ * \brief Retrieve the ID of the camera
+ *
+ * The camera ID is identical to the Camera::id() of the Camera instance
+ * created by initialising this descriptor. It is guaranteed to be unique and
+ * stable so the same camera will have the same ID across both unplug/replug and
+ * boot cycles.
+ *
+ * \return ID of the camera
+ */
+const std::string &CameraDescriptor::id() const
+{
+	return _d()->id_;
+}
+
+/**
+ * \brief Retrieve the properties of the camera known at enumeration time
+ *
+ * Camera properties are metadata that describe the camera. Only the subset of
+ * properties that can be determined at enumeration time (without initialising
+ * the camera) is reported here. The complete property list is available from
+ * Camera::properties() once the camera has been initialised.
+ *
+ * \return The list of camera properties known at enumeration time
+ */
+const ControlList &CameraDescriptor::properties() const
+{
+	return _d()->properties_;
+}
+
+CameraDescriptor::CameraDescriptor(std::unique_ptr<Private> d)
+	: Extensible(std::move(d))
+{
+}
+
+} /* namespace libcamera */
diff --git a/src/libcamera/meson.build b/src/libcamera/meson.build
index 17c1b2cb3479..ba51f5ee47b3 100644
--- a/src/libcamera/meson.build
+++ b/src/libcamera/meson.build
@@ -2,6 +2,7 @@
 
 libcamera_public_sources = files([
     'camera.cpp',
+    'camera_descriptor.cpp',
     'camera_manager.cpp',
     'color_space.cpp',
     'controls.cpp',
