diff --git a/core/CMakeLists.txt b/core/CMakeLists.txt
index 5fa5594a38..9a05f21bfa 100644
--- a/core/CMakeLists.txt
+++ b/core/CMakeLists.txt
@@ -61,6 +61,7 @@ set(CUBOS_CORE_SOURCE
     "src/cubos/core/data/fs/embedded_archive.cpp"
     "src/cubos/core/data/ser/serializer.cpp"
     "src/cubos/core/data/ser/debug.cpp"
+    "src/cubos/core/data/des/deserializer.cpp"
 
     "src/cubos/core/io/window.cpp"
     "src/cubos/core/io/cursor.cpp"
diff --git a/core/include/cubos/core/data/des/deserializer.hpp b/core/include/cubos/core/data/des/deserializer.hpp
new file mode 100644
index 0000000000..5964b0dae3
--- /dev/null
+++ b/core/include/cubos/core/data/des/deserializer.hpp
@@ -0,0 +1,83 @@
+/// @file
+/// @brief Class @ref cubos::core::data::Deserializer.
+/// @ingroup core-data-des
+
+#pragma once
+
+#include <unordered_map>
+
+#include <cubos/core/memory/function.hpp>
+#include <cubos/core/reflection/reflect.hpp>
+
+namespace cubos::core::data
+{
+    /// @brief Base class for deserializers, which defines the interface for deserializing
+    /// arbitrary data using its reflection metadata.
+    ///
+    /// Deserializers are type visitors which allow overriding the default deserialization
+    /// behaviour for each type using hooks. Hooks are functions which are called when the
+    /// deserializer encounters a type, and can be used to customize the deserialization process.
+    ///
+    /// If a type which can't be further decomposed is encountered for which no hook is defined,
+    /// the deserializer will emit a warning and fail. Implementations should set default hooks for
+    /// at least the primitive types.
+    ///
+    /// @ingroup core-data-des
+    class Deserializer
+    {
+    public:
+        virtual ~Deserializer() = default;
+
+        /// @brief Function type for deserialization hooks.
+        using Hook = memory::Function<bool(void*)>;
+
+        /// @brief Function type for deserialization hooks.
+        /// @tparam T Type.
+        template <typename T>
+        using TypedHook = memory::Function<bool(T&)>;
+
+        /// @brief Deserialize the given value.
+        /// @param type Type.
+        /// @param value Value.
+        /// @return Whether the value was successfully deserialized.
+        bool read(const reflection::Type& type, void* value);
+
+        /// @brief Deserialize the given value.
+        /// @tparam T Type.
+        /// @param value Value.
+        /// @return Whether the value was successfully deserialized.
+        template <typename T>
+        bool read(T& value)
+        {
+            return this->read(reflection::reflect<T>(), &value);
+        }
+
+        /// @brief Sets the hook to be called on deserialization of the given type.
+        /// @param type Type.
+        /// @param hook Hook.
+        void hook(const reflection::Type& type, Hook hook);
+
+        /// @brief Sets the hook to be called on deserialization of the given type.
+        /// @tparam T Type.
+        /// @param hook Hook.
+        template <typename T>
+        void hook(TypedHook<T> hook)
+        {
+            this->hook(reflection::reflect<T>(),
+                       [hook = memory::move(hook)](void* value) mutable { return hook(*static_cast<T*>(value)); });
+        }
+
+    protected:
+        /// @brief Called for each type with no hook defined.
+        ///
+        /// Should recurse by calling @ref read() again as appropriate.
+        ///
+        /// @param type Type.
+        /// @param value Value.
+        /// @return Whether the value was successfully deserialized.
+        virtual bool decompose(const reflection::Type& type, void* value) = 0;
+
+    private:
+        std::unordered_map<const reflection::Type*, Hook> mHooks;
+    };
+} // namespace cubos::core::data
diff --git a/core/include/cubos/core/data/des/module.dox b/core/include/cubos/core/data/des/module.dox
new file mode 100644
index 0000000000..3dd74916c4
--- /dev/null
+++ b/core/include/cubos/core/data/des/module.dox
@@ -0,0 +1,9 @@
+/// @dir
+/// @brief @ref core-data-des directory.
+
+namespace cubos::core::data
+{
+    /// @defgroup core-data-des Deserialization
+    /// @ingroup core-data
+    /// @brief Provides deserialization utilities.
+}
diff --git a/core/samples/CMakeLists.txt b/core/samples/CMakeLists.txt
index 28dea7f796..843680df3f 100644
--- a/core/samples/CMakeLists.txt
+++ b/core/samples/CMakeLists.txt
@@ -36,6 +36,7 @@ make_sample(DIR "reflection/traits/string_conversion")
 make_sample(DIR "data/fs/embedded_archive" SOURCES "embed.cpp")
 make_sample(DIR "data/fs/standard_archive")
 make_sample(DIR "data/ser/custom")
+make_sample(DIR "data/des/custom")
 make_sample(DIR "data/serialization")
 make_sample(DIR "ecs/events")
 make_sample(DIR "ecs/general")
diff --git a/core/samples/data/des/custom/main.cpp b/core/samples/data/des/custom/main.cpp
new file mode 100644
index 0000000000..918cab7fd2
--- /dev/null
+++ b/core/samples/data/des/custom/main.cpp
@@ -0,0 +1,118 @@
+#include <cubos/core/log.hpp>
+#include <cubos/core/memory/stream.hpp>
+
+using cubos::core::memory::Stream;
+
+/// [Include]
+#include <cubos/core/data/des/deserializer.hpp>
+
+using cubos::core::data::Deserializer;
+using cubos::core::reflection::Type;
+/// [Include]
+
+/// [Your own deserializer]
+class MyDeserializer : public Deserializer
+{
+public:
+    MyDeserializer();
+
+protected:
+    bool decompose(const Type& type, void* value) override;
+};
+/// [Your own deserializer]
+
+/// [Setting up hooks]
+#include <cubos/core/reflection/external/primitives.hpp>
+
+using cubos::core::reflection::reflect;
+
+MyDeserializer::MyDeserializer()
+{
+    this->hook<int32_t>([](int32_t& value) {
+        Stream::stdOut.print("enter an int32_t: ");
+        Stream::stdIn.parse(value);
+        return true;
+    });
+}
+/// [Setting up hooks]
+
+/// [Decomposing types]
+#include <cubos/core/reflection/traits/array.hpp>
+#include <cubos/core/reflection/traits/fields.hpp>
+#include <cubos/core/reflection/type.hpp>
+
+using cubos::core::reflection::ArrayTrait;
+using cubos::core::reflection::FieldsTrait;
+
+bool MyDeserializer::decompose(const Type& type, void* value)
+{
+    if (type.has<ArrayTrait>())
+    {
+        const auto& arrayTrait = type.get<ArrayTrait>();
+        auto arrayView = arrayTrait.view(value);
+
+        auto length = static_cast<uint64_t>(arrayView.length());
+        Stream::stdOut.printf("enter array size: ", length);
+        Stream::stdIn.parse(length);
+
+        for (std::size_t i = 0; i < static_cast<std::size_t>(length); ++i)
+        {
+            if (i == arrayView.length())
+            {
+                arrayView.insertDefault(i);
+            }
+
+            Stream::stdOut.printf("writing array[{}]: ", i);
+            this->read(arrayTrait.elementType(), arrayView.get(i));
+        }
+
+        while (arrayView.length() > static_cast<std::size_t>(length))
+        {
+            arrayView.erase(static_cast<std::size_t>(length));
+        }
+
+        return true;
+    }
+    /// [Decomposing types]
+
+    /// [Decomposing types with fields]
+    if (type.has<FieldsTrait>())
+    {
+        for (const auto& [field, fieldValue] : type.get<FieldsTrait>().view(value))
+        {
+            Stream::stdOut.printf("writing field '{}': ", field->name());
+            if (!this->read(field->type(), fieldValue))
+            {
+                return false;
+            }
+        }
+
+        return true;
+    }
+
+    CUBOS_WARN("Cannot decompose '{}'", type.name());
+    return false;
+}
+/// [Decomposing types with fields]
+
+/// [Usage]
+#include <glm/vec3.hpp>
+
+#include <cubos/core/reflection/external/glm.hpp>
+#include <cubos/core/reflection/external/vector.hpp>
+
+int main()
+{
+    std::vector<glm::ivec3> vec{};
+    MyDeserializer des{};
+    des.read(vec);
+
+    Stream::stdOut.print("-----------\n");
+    Stream::stdOut.print("Resulting vec: [ ");
+    for (const auto& v : vec)
+    {
+        Stream::stdOut.printf("({}, {}, {}) ", v.x, v.y, v.z);
+    }
+    Stream::stdOut.print("]\n");
+}
+/// [Usage]
diff --git a/core/samples/data/des/custom/page.md b/core/samples/data/des/custom/page.md
new file mode 100644
index 0000000000..62f10725b4
--- /dev/null
+++ b/core/samples/data/des/custom/page.md
@@ -0,0 +1,56 @@
+# Custom Deserializer {#examples-core-data-des-custom}
+
+@brief Implementing your own @ref cubos::core::data::Deserializer "Deserializer".
+
+@see Full source code [here](https://github.com/GameDevTecnico/cubos/tree/main/core/samples/data/des/custom).
+
+To define your own deserializer type, you'll need to include
+@ref core/data/des/deserializer.hpp. For simplicity, in this sample we'll use
+the following aliases:
+
+@snippet data/des/custom/main.cpp Include
+
+We'll define a deserializer that will print the data to the standard output.
+
+@snippet data/des/custom/main.cpp Your own deserializer
+
+In the constructor, we should set hooks to be called for deserializing primitive
+types or any other type we want to handle specifically.
+
+In this example, we'll only handle `int32_t`, but usually you should at least
+cover all primitive types.
+
+@snippet data/des/custom/main.cpp Setting up hooks
+
+The only other thing you need to do is implement the @ref
+cubos::core::data::deserializer::decompose "deserializer::decompose" method,
+which acts as a catch-all for any type without a specific hook.
+
+Here, we can use traits such as @ref cubos::core::reflection::FieldsTrait
+"FieldsTrait" to access the fields of a type and write to them.
+
+In this sample, we'll only be handling fields and arrays, but you should try to
+cover as many kinds of data as possible.
+
+@snippet data/des/custom/main.cpp Decomposing types
+
+We start by checking if the type can be viewed as an array. If it can, we'll
+ask the user how many elements they want the array to have. We resize it, and
+then, we recurse into the elements. If the type doesn't have this trait, we'll
+fallback into checking if it has fields.
+
+@snippet data/des/custom/main.cpp Decomposing types with fields
+
+If the type has fields, we'll iterate over them and ask the user to enter
+values for them. Otherwise, we'll fail by returning `false`.
+
+Using our deserializer is as simple as constructing it and calling @ref
+cubos::core::data::Deserializer::read "Deserializer::read" on the data we want
+to deserialize.
+
+In this case, we'll be deserializing a `std::vector<glm::ivec3>`, which is
+an array of objects with three `int32_t` fields.
+
+@snippet data/des/custom/main.cpp Usage
+
+This should output the values you enter when you execute it.
diff --git a/core/samples/data/des/page.md b/core/samples/data/des/page.md
new file mode 100644
index 0000000000..bf4a733f9d
--- /dev/null
+++ b/core/samples/data/des/page.md
@@ -0,0 +1,5 @@
+# Deserialization {#examples-core-data-des}
+
+@brief Using the @ref core-data-des module.
+
+- @subpage examples-core-data-des-custom - @copybrief examples-core-data-des-custom
diff --git a/core/samples/data/page.md b/core/samples/data/page.md
index cab52f11a2..f92057e737 100644
--- a/core/samples/data/page.md
+++ b/core/samples/data/page.md
@@ -3,3 +3,4 @@
 @brief Using the @ref core-data module.
 
 - @subpage examples-core-data-ser - @copybrief examples-core-data-ser
+- @subpage examples-core-data-des - @copybrief examples-core-data-des
diff --git a/core/src/cubos/core/data/des/deserializer.cpp b/core/src/cubos/core/data/des/deserializer.cpp
new file mode 100644
index 0000000000..0e18baa284
--- /dev/null
+++ b/core/src/cubos/core/data/des/deserializer.cpp
@@ -0,0 +1,34 @@
+#include <cubos/core/data/des/deserializer.hpp>
+#include <cubos/core/log.hpp>
+#include <cubos/core/reflection/type.hpp>
+
+using cubos::core::data::Deserializer;
+
+bool Deserializer::read(const reflection::Type& type, void* value)
+{
+    if (auto it = mHooks.find(&type); it != mHooks.end())
+    {
+        if (!it->second(value))
+        {
+            CUBOS_WARN("Deserialization hook for type '{}' failed", type.name());
+            return false;
+        }
+    }
+    else if (!this->decompose(type, value))
+    {
+        CUBOS_WARN("Deserialization decomposition for type '{}' failed", type.name());
+        return false;
+    }
+
+    return true;
+}
+
+void Deserializer::hook(const reflection::Type& type, Hook hook)
+{
+    if (auto it = mHooks.find(&type); it != mHooks.end())
+    {
+        CUBOS_WARN("Hook for type '{}' already exists, overwriting", type.name());
+    }
+
+    mHooks.emplace(&type, memory::move(hook));
+}